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 fastapi import APIRouter, Depends, Query, Request, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit from app.application.services.audit_service import record_audit
from app.application.services.sandbox_service import SandboxService from app.application.services.sandbox_service import SandboxService
from app.core.database import get_session 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.audit.models import AuditAction
from app.domain.identity.models import User
from app.domain.sandbox.models import NetworkPolicy 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 ( from app.interfaces.http.deps import (
OrgContext, OrgContext,
client_meta, client_meta,
get_org_context, get_org_context,
get_sandbox_service, get_sandbox_service,
require_platform_owner,
) )
router = APIRouter(prefix="/organizations/{org_id}/sandbox", tags=["sandbox"]) 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: def _require_platform_owner(context: OrgContext) -> None:
"""Infrastructure belongs to the platform owner (PLAN-v2 §7.7). """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 organization_id=context.org_id, session_id=session_id, port=port
) )
return PreviewUrlOut(port=preview.port, url=preview.url) 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,
)
@@ -84,6 +84,7 @@ def build_rpc_app() -> FastAPI:
application.include_router(admin_grants.router, prefix="/api") application.include_router(admin_grants.router, prefix="/api")
application.include_router(delivery.router, prefix="/api") application.include_router(delivery.router, prefix="/api")
application.include_router(admin_domains.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") application.include_router(realtime_router, prefix="/api")
return application return application
+62 -5
View File
@@ -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]: async def _owner_org(client: AsyncClient) -> tuple[str, dict]:
from app.domain.identity.models import PlatformRole from app.domain.identity.models import PlatformRole
from tests.conftest import signup 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 _require_platform_owner(_Ctx(owner)) # must not raise
_ = (Membership, Role) _ = (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"]
+9
View File
@@ -6,6 +6,15 @@ const nextConfig = {
env: { env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL ?? "", 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() { async rewrites() {
return [ return [
{ {
+319 -9
View File
@@ -1,17 +1,327 @@
"use client"; "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<TableColumn<Row>> {
return [
{
key: "host",
header: "دامنه",
className: "ltr-inline",
render: (row) => <span className="text-slate-200">{row.host}</span>,
},
{
key: "project",
header: "اپلیکیشن",
render: (row) => <span className="text-slate-300">{row.project}</span>,
},
{
key: "port",
header: "پورت",
className: "ltr-inline",
render: (row) => <span className="text-slate-400">{row.port}</span>,
},
{
key: "status",
header: "وضعیت",
render: (row) => (
<Badge tone={row.status === "active" ? "success" : "neutral"}>{row.status}</Badge>
),
},
{
key: "protected",
header: "محافظت‌شده",
render: (row) => <span className="text-slate-400">{row.protected}</span>,
},
{
key: "actions",
header: "عملیات",
render: (row) => (
<Button
size="sm"
variant="danger"
onClick={() => onDetach(row.route)}
disabled={row.protected === "بله"}
>
حذف
</Button>
),
},
];
}
export default function AdminDomainsPage() { export default function AdminDomainsPage() {
const [routes, setRoutes] = React.useState<DomainRoute[] | null>(null);
const [suggested, setSuggested] = React.useState<SuggestedRoute[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [busy, setBusy] = React.useState(false);
const [notice, setNotice] = React.useState<string | null>(null);
const [host, setHost] = React.useState("");
const [pendingDetach, setPendingDetach] = React.useState<DomainRoute | null>(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 <LoadingState label="در حال دریافت دامنه‌ها…" />;
if (error) return <ErrorState message={error} onRetry={load} />;
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 ( return (
<PlaceholderPage <div className="flex flex-col gap-4">
title="دامنه‌ها" {notice && (
description="مسیرهای Caddy و دامنه‌های اپلیکیشن‌ها." <Card>
planned={[ <p className="text-sm text-slate-300">{notice}</p>
"فهرست مسیرها", </Card>
"افزودن و حذف دامنه", )}
"همگام‌سازی Caddy",
]} <Card>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle>دامنه‌های فعال</CardTitle>
<CardHint>
هر اپلیکیشن یک دامنهٔ دموی خودکار دارد. افزودن دامنهٔ دیگر فقط با مالک سامانه
ممکن است؛ کاربران نمی‌توانند دامنه اضافه کنند.
</CardHint>
</div>
<Button variant="secondary" onClick={sync} loading={busy}>
همگام‌سازی Caddy
</Button>
</div>
{rows.length === 0 ? (
<div className="mt-4">
<EmptyState title="مسیری ثبت نشده است" description="هنوز هیچ اپلیکیشنی دمو ندارد." />
</div>
) : (
<div className="mt-4">
<Table
columns={columns((route) => setPendingDetach(route))}
rows={rows}
rowKey={(row) => row.id}
caption="مسیرهای دامنهٔ سامانه"
/> />
</div>
)}
</Card>
<Card>
<CardTitle>افزودن دامنه</CardTitle>
<CardHint>
یک برچسب زیر دامنهٔ سکو (مثل{" "}
<span className="ltr-inline">shop.msa.artacloud.ir</span>) یا، اگر فعال باشد، یک
دامنهٔ سفارشی کامل. دامنهٔ wildcard مجاز نیست.
</CardHint>
<form
className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-end"
onSubmit={(event) => {
event.preventDefault();
void attach();
}}
>
<div className="flex-1">
<Field
label="نام دامنه"
value={host}
onChange={(event) => setHost(event.target.value)}
placeholder="shop.msa.artacloud.ir"
className="ltr-inline"
/>
</div>
<Button type="submit" loading={busy} disabled={!host.trim()}>
افزودن
</Button>
</form>
</Card>
{suggested.length > 0 && (
<Card>
<CardTitle>مسیرهای پیشنهادی سامانه</CardTitle>
<CardHint>
این مسیرها سرویس‌های خود سامانه‌اند، نه اپلیکیشن کاربر. پیش از فعال‌سازی، رکورد
DNS و گواهی TLS باید آماده باشد.
</CardHint>
<ul className="mt-3 flex flex-col gap-2">
{suggested.map((route) => (
<li
key={route.label}
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-surface-border px-3 py-3"
>
<div className="flex min-w-0 flex-col gap-1">
<div className="flex items-center gap-2">
<span className="ltr-inline text-sm text-slate-200">{route.host}</span>
<Badge tone="neutral">پورت {route.port}</Badge>
</div>
<p className="text-xs text-slate-500">{route.description}</p>
</div>
{route.enabled ? <Badge tone="success">فعال</Badge> : (
<span className="text-xs text-slate-500">غیرفعال</span>
)}
</li>
))}
</ul>
</Card>
)}
<ConfirmDialog
open={pendingDetach !== null}
title="حذف دامنه"
description={
pendingDetach
? `با حذف این مسیر، آدرس ${pendingDetach.host} از کار می‌افتد. برای تأیید، نام دامنه را تایپ کنید.`
: undefined
}
confirmLabel={busy ? "در حال حذف…" : "حذف کن"}
onConfirm={detach}
onCancel={() => {
setPendingDetach(null);
setTyped("");
}}
/>
{pendingDetach && (
<Card>
<Field
label="نام دامنه را برای تأیید تایپ کنید"
value={typed}
onChange={(event) => setTyped(event.target.value)}
placeholder={pendingDetach.host}
className="ltr-inline"
/>
</Card>
)}
</div>
); );
} }
+236 -9
View File
@@ -1,17 +1,244 @@
"use client"; "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<string, string>;
}
const ROLE_LABELS: Record<string, string> = {
workspace: "فضای کار کارمندان",
demo: "نمایش زنده",
build: "ساخت",
};
export default function AdminSandboxPage() { export default function AdminSandboxPage() {
const [summary, setSummary] = React.useState<SandboxSummary | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [notice, setNotice] = React.useState<string | null>(null);
const [pending, setPending] = React.useState<ContainerRow | null>(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<SandboxSummary>("/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<void>(`/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 <LoadingState label="در حال دریافت کانتینرها…" />;
if (error) return <ErrorState message={error} onRetry={load} />;
if (!summary) return null;
const byRole = summary.containers.reduce<Record<string, number>>((accumulator, row) => {
accumulator[row.role] = (accumulator[row.role] ?? 0) + 1;
return accumulator;
}, {});
return ( return (
<PlaceholderPage <div className="flex flex-col gap-4">
title="سندباکس‌ها" {notice && (
description="کانتینرهای در حال اجرا و محدودیت‌های منابع." <Card>
planned={[ <p className="text-sm text-slate-300">{notice}</p>
"کانتینرهای هر سازمان", </Card>
"محدودیت CPU و حافظه", )}
"توقف و راه‌اندازی مجدد",
]} <Card>
<CardTitle>کانتینرهای در حال اجرا</CardTitle>
<CardHint>
هر اپلیکیشن یک کانتینر فضای کار (کارمندان در آن کد می‌نویسند) و یک کانتینر
نمایش زنده دارد. کانتینرهای ساخت موقت‌اند و پس از هر اجرا حذف می‌شوند.
</CardHint>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Stat label="فضای کار" value={String(byRole.workspace ?? 0)} />
<Stat label="نمایش زنده" value={String(byRole.demo ?? 0)} />
<Stat label="ساخت" value={String(byRole.build ?? 0)} />
<Stat label="مجموع" value={String(summary.containers.length)} />
</div>
{summary.containers.length === 0 ? (
<div className="mt-4">
<EmptyState
title="کانتینر فعالی وجود ندارد"
description="اگر ارائه‌دهندهٔ سندباکس روی docker تنظیم باشد، کانتینرها اینجا دیده می‌شوند."
/> />
</div>
) : (
<ul className="mt-4 flex flex-col gap-2">
{summary.containers.map((row) => (
<li
key={row.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-surface-border px-3 py-3"
>
<div className="flex min-w-0 flex-col gap-1">
<div className="flex items-center gap-2">
<Badge tone={row.status.startsWith("Up") ? "success" : "neutral"}>
{row.status}
</Badge>
<span className="ltr-inline truncate text-xs text-slate-400">{row.name}</span>
</div>
<p className="text-xs text-slate-500">
{ROLE_LABELS[row.role] ?? row.role} ·{" "}
<span className="ltr-inline">{row.organization_id.slice(0, 8)}</span>
</p>
</div>
<Button size="sm" variant="danger" onClick={() => setPending(row)}>
توقف
</Button>
</li>
))}
</ul>
)}
</Card>
<Card>
<CardTitle>محدودیت منابع سازمان‌ها</CardTitle>
<CardHint>
این مقادیر از صفحهٔ پروژه برای هر سازمان تنظیم می‌شوند و در سطح سرویس همیشه
محدود نگه داشته می‌شوند.
</CardHint>
{summary.limits.length === 0 ? (
<p className="mt-3 text-xs text-slate-500">محدودیت سفارشی‌ای ثبت نشده است.</p>
) : (
<ul className="mt-3 flex flex-col gap-2">
{summary.limits.map((row) => (
<li
key={row.organization_id}
className="grid gap-3 rounded-lg border border-surface-border px-3 py-3 sm:grid-cols-4"
>
<Stat label="سازمان" value={row.organization_id.slice(0, 8)} />
<Stat label="CPU" value={`${row.cpu} هسته`} />
<Stat label="حافظه" value={`${row.memory_mb} مگابایت`} />
<Stat label="دیسک" value={`${row.disk_mb} مگابایت`} />
</li>
))}
</ul>
)}
</Card>
<Card>
<CardTitle>نگاشت تصویر پایه</CardTitle>
<CardHint>
برای هر زبان اپلیکیشن یک تصویر پایه مشخص می‌شود؛ کانتینرهای ساخت و فضای کار از
همین تصویرها ساخته می‌شوند.
</CardHint>
<ul className="mt-3 flex flex-col gap-2">
{Object.entries(summary.images).map(([language, image]) => (
<li
key={language}
className="flex items-center justify-between gap-2 rounded-lg border border-surface-border px-3 py-2"
>
<span className="ltr-inline text-xs text-slate-400">{language}</span>
<span className="ltr-inline text-sm text-slate-200">{image}</span>
</li>
))}
</ul>
</Card>
<ConfirmDialog
open={pending !== null}
title="توقف کانتینر"
description={
pending
? `کانتینر ${pending.name} متوقف و حذف می‌شود. برای تأیید، شناسهٔ آن را تایپ کنید.`
: undefined
}
confirmLabel={busy ? "در حال توقف…" : "توقف کن"}
onConfirm={stop}
onCancel={() => {
setPending(null);
setTyped("");
}}
/>
{pending && (
<Card>
<Field
label="شناسهٔ کانتینر را برای تأیید تایپ کنید"
value={typed}
onChange={(event) => setTyped(event.target.value)}
placeholder={pending.id}
className="ltr-inline"
/>
</Card>
)}
</div>
); );
} }
@@ -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<LogLine[]>([]);
const pending = React.useRef<LogLine[]>([]);
const frame = React.useRef<number | null>(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<HTMLDivElement | null>(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 (
<Card className={className}>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>لاگ زنده</CardTitle>
<CardHint>
خروجی هر مرحله به‌صورت زنده اینجا می‌آید. کدهای رنگی ترمینال حذف می‌شوند.
</CardHint>
</div>
<div className="flex shrink-0 items-center gap-2">
{running && <Badge tone="warning">در حال اجرا</Badge>}
{!following && (
<Button size="sm" variant="secondary" onClick={() => setFollowing(true)}>
ادامهٔ دنبال‌کردن
</Button>
)}
<Button size="sm" variant="ghost" onClick={copy} disabled={rendered.length === 0}>
کپی
</Button>
</div>
</div>
<div
ref={scroller}
onScroll={onScroll}
className={cx(
"ltr-inline mt-4 max-h-80 min-h-32 overflow-auto rounded-lg border",
"border-surface-border bg-surface-base p-3 font-mono text-xs leading-relaxed",
)}
>
{rendered.length === 0 ? (
<p className="text-slate-500">هنوز خروجی‌ای نرسیده است.</p>
) : (
rendered.map((line) => (
<div key={line.key} className="flex gap-2 whitespace-pre-wrap break-all">
<span className="shrink-0 text-slate-600">{line.step}</span>
<span className="text-slate-300">{line.text}</span>
</div>
))
)}
</div>
</Card>
);
}
@@ -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<ProductionOverview | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [busy, setBusy] = React.useState(false);
const [notice, setNotice] = React.useState<string | null>(null);
const [confirming, setConfirming] = React.useState<"deploy" | "rollback" | null>(null);
const [typed, setTyped] = React.useState("");
const [lastLog, setLastLog] = React.useState<ProductionDeploy | null>(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 <LoadingState label="در حال دریافت وضعیت production…" />;
if (error) return <ErrorState message={error} onRetry={load} />;
const target = overview?.target ?? null;
const deploys = overview?.deploys ?? [];
return (
<div className="flex flex-col gap-4">
{notice && (
<Card>
<p className="text-sm text-slate-300">{notice}</p>
</Card>
)}
<Card>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>مقصد production</CardTitle>
<CardHint>
استقرار روی production فقط با کلیک شما و با تأیید نامک سازمان انجام می‌شود.
</CardHint>
</div>
{target?.last_test_ok === true && <Badge tone="success">اتصال تأییدشده</Badge>}
{target?.last_test_ok === false && <Badge tone="danger">اتصال ناموفق</Badge>}
</div>
{target === null ? (
<div className="mt-4">
<EmptyState
title="مقصدی تنظیم نشده است"
description="برای استقرار روی production، در ویزارد ساخت اپلیکیشن یا تنظیمات، اطلاعات SSH/FTP را وارد کنید."
/>
</div>
) : (
<>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Stat label="نوع" value={target.kind.toUpperCase()} />
<Stat label="میزبان" value={`${target.host}:${target.port}`} />
<Stat label="مسیر" value={target.remote_path} />
<Stat label="کلید" value={target.has_secret ? "ثبت شده" : "ثبت نشده"} />
</div>
{target.last_test_message && (
<p className="mt-3 text-xs text-slate-500">{target.last_test_message}</p>
)}
{canDeploy && (
<div className="mt-4 flex flex-wrap gap-2">
<Button variant="secondary" onClick={onTest} loading={busy}>
تست اتصال
</Button>
<Button
onClick={() => setConfirming("deploy")}
disabled={busy || !liveReleaseId}
>
استقرار در production
</Button>
<Button
variant="secondary"
onClick={() => setConfirming("rollback")}
disabled={busy || deploys.length === 0}
>
بازگردانی آخرین استقرار
</Button>
</div>
)}
{!canDeploy && (
<p className="mt-3 text-xs text-slate-500">
استقرار روی production تنها توسط مالک یا مدیر سازمان انجام می‌شود.
</p>
)}
</>
)}
</Card>
<Card>
<CardTitle>تاریخچهٔ استقرار</CardTitle>
{deploys.length === 0 ? (
<p className="mt-2 text-xs text-slate-500">هنوز استقراری انجام نشده است.</p>
) : (
<ul className="mt-3 flex flex-col gap-2">
{deploys.map((deploy) => (
<li
key={deploy.id}
className="flex flex-col gap-2 rounded-lg border border-surface-border px-3 py-3"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Badge tone={toneFor(deploy.status)}>{deploy.status}</Badge>
<span className="ltr-inline text-xs text-slate-400">{deploy.kind}</span>
<span className="ltr-inline truncate text-xs text-slate-500">
{deploy.host}
</span>
</div>
<Button size="sm" variant="ghost" onClick={() => setLastLog(deploy)}>
لاگ
</Button>
</div>
{deploy.message && (
<p className="text-xs text-slate-400">{deploy.message}</p>
)}
{deploy.rolled_back_from && (
<p className="text-xs text-slate-500">بازگردانی از استقرار قبلی</p>
)}
</li>
))}
</ul>
)}
</Card>
{lastLog && (
<Card>
<div className="flex items-start justify-between gap-3">
<CardTitle>لاگ استقرار</CardTitle>
<Button size="sm" variant="ghost" onClick={() => setLastLog(null)}>
بستن
</Button>
</div>
<pre className="ltr-inline mt-3 max-h-64 overflow-auto whitespace-pre-wrap break-all rounded-lg border border-surface-border bg-surface-base p-3 font-mono text-xs text-slate-300">
{lastLog.log_tail || "خروجی‌ای ثبت نشده است."}
</pre>
</Card>
)}
<ConfirmDialog
open={confirming !== null}
title={confirming === "deploy" ? "استقرار در production" : "بازگردانی استقرار"}
description={
confirming === "deploy"
? "این کار نسخهٔ جدید را روی سرور شما فعال می‌کند و به‌سادگی برگشت‌پذیر نیست."
: "نسخهٔ فعال روی سرور شما به نسخهٔ قبلی برمی‌گردد."
}
confirmLabel={busy ? "در حال انجام…" : "تأیید"}
onConfirm={runConfirmed}
onCancel={() => {
setConfirming(null);
setTyped("");
}}
/>
{confirming !== null && (
<Card>
<Field
label="برای تأیید، نامک سازمان را تایپ کنید"
hint={`نامک: ${orgSlug}`}
value={typed}
onChange={(event) => setTyped(event.target.value)}
placeholder={orgSlug}
className="ltr-inline"
/>
</Card>
)}
</div>
);
}
+457 -11
View File
@@ -1,18 +1,464 @@
"use client"; "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.
*/
export default function AppPage() { 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<string, Tone> = {
queued: "neutral",
running: "warning",
succeeded: "success",
failed: "danger",
cancelled: "neutral",
superseded: "neutral",
};
const STEP_TONES: Record<string, Tone> = {
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<string, unknown>`` 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<T>(event: { data: Record<string, unknown> }): 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<Tab>("pipeline");
const [project, setProject] = React.useState<Project | null>(null);
const [runtime, setRuntime] = React.useState<AppRuntime | null>(null);
const [runs, setRuns] = React.useState<PipelineRun[]>([]);
const [releases, setReleases] = React.useState<Release[]>([]);
const [selected, setSelected] = React.useState<PipelineRunDetail | null>(null);
const [lines, setLines] = React.useState<LogLine[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [pendingRollback, setPendingRollback] = React.useState<Release | null>(null);
const [busy, setBusy] = React.useState(false);
const [notice, setNotice] = React.useState<string | null>(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<WsPipelineLogData>(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<WsPipelineStatusData>(event);
if (data.project_id !== projectId) return;
void load();
return;
}
if (event.type === "release.live") {
const data = payload<WsReleaseLiveData>(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 <LoadingState label="در حال دریافت اطلاعات اپلیکیشن…" />;
if (error) return <ErrorState message={error} onRetry={load} />;
if (!project) {
return ( return (
<PlaceholderPage <EmptyState
title="اپلیکیشن" title="هنوز اپلیکیشنی ساخته نشده است"
description="دمو زنده، استقرار روی production و وضعیت Git اپلیکیشن سازمان." description="هر سازمان یک اپلیکیشن دارد. برای ساخت آن، ویزارد ساخت اپلیکیشن را کامل کنید."
planned={[
"نمای کلی اپلیکیشن و آدرس دمو",
"اجراهای CI/CD با لاگ زنده",
"نسخه‌ها و بازگشت دمو",
"استقرار روی production با تأیید",
]}
/> />
); );
} }
const liveRelease = releases.find((release) => release.is_demo_live) ?? null;
const healthTone: Tone =
runtime?.status === "live" ? "success" : runtime?.status === "degraded" ? "danger" : "neutral";
return (
<div className="flex flex-col gap-4">
{notice && (
<Card>
<p className="text-sm text-slate-300">{notice}</p>
</Card>
)}
{/* -- header ------------------------------------------------------- */}
<Card>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex min-w-0 flex-col gap-1">
<div className="flex items-center gap-2">
<CardTitle>{project.name}</CardTitle>
<Badge tone={healthTone}>{runtime?.status ?? "بدون محیط"}</Badge>
</div>
<CardHint>
<span className="ltr-inline">{project.slug}</span>
</CardHint>
</div>
<div className="flex flex-wrap items-center gap-2">
{runtime?.demo_url ? (
<a
href={runtime.demo_url}
target="_blank"
rel="noreferrer noopener"
className="ltr-inline text-sm text-accent hover:underline"
>
{runtime.demo_url}
</a>
) : (
<span className="text-xs text-slate-500">آدرس دمو آماده نیست.</span>
)}
{canManage && (
<Button size="sm" variant="secondary" onClick={startRun} loading={busy}>
اجرای دوباره
</Button>
)}
</div>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Stat label="نسخهٔ زنده" value={liveRelease ? shortSha(liveRelease.sha) : "—"} />
<Stat label="زمان نسخهٔ زنده" value={liveRelease ? when(liveRelease.created_at) : "—"} />
<Stat label="آخرین اجرا" value={runs[0] ? runs[0].status : "—"} />
<Stat label="تعداد اجراها" value={String(runs.length)} />
</div>
</Card>
<Tabs
items={[
{ key: "pipeline", label: "خط لولهٔ انتشار" },
{ key: "releases", label: "نسخه‌ها" },
{ key: "production", label: "production" },
{ key: "git", label: "Git" },
]}
active={tab}
onChange={(key) => setTab(key as Tab)}
/>
{/* -- pipeline ------------------------------------------------------ */}
{tab === "pipeline" && (
<div className="flex flex-col gap-4">
<Card>
<CardTitle>اجراهای خط لوله</CardTitle>
<CardHint>
هر بار که شاخهٔ <span className="ltr-inline">dev</span> به‌روز می‌شود یک اجرا ساخته
می‌شود. برای دیدن جزئیات و لاگ زنده روی هر اجرا بزنید.
</CardHint>
{runs.length === 0 ? (
<p className="mt-3 text-xs text-slate-500">هنوز اجرایی ثبت نشده است.</p>
) : (
<ul className="mt-3 flex flex-col gap-2">
{runs.map((run) => (
<li key={run.id}>
<button
type="button"
onClick={() => void openRun(run.id)}
className={
"flex w-full flex-col gap-2 rounded-lg border px-3 py-3 text-right transition " +
(selected?.id === run.id
? "border-accent/60 bg-surface-overlay"
: "border-surface-border hover:border-accent/40")
}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Badge tone={RUN_TONES[run.status] ?? "neutral"}>{run.status}</Badge>
<span className="ltr-inline text-xs text-slate-400">
{shortSha(run.sha)}
</span>
<span className="text-xs text-slate-500">{run.trigger}</span>
</div>
<span className="text-xs text-slate-500">{when(run.created_at)}</span>
</div>
{run.error_summary && (
<p className="text-xs text-rose-300">{run.error_summary}</p>
)}
</button>
</li>
))}
</ul>
)}
</Card>
{selected && (
<>
<Card>
<CardTitle>مراحل اجرا</CardTitle>
<ol className="mt-3 flex flex-col gap-2">
{selected.steps.map((step) => (
<li
key={step.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-surface-border px-3 py-2"
>
<div className="flex items-center gap-2">
<span className="ltr-inline text-sm text-slate-200">{step.name}</span>
<Badge tone={STEP_TONES[step.status] ?? "neutral"}>{step.status}</Badge>
</div>
<div className="flex items-center gap-3 text-xs text-slate-500">
{step.exit_code !== null && (
<span className="ltr-inline">exit {step.exit_code}</span>
)}
{step.duration_ms > 0 && <span>{step.duration_ms}ms</span>}
</div>
</li>
))}
</ol>
</Card>
<PipelineConsole
lines={lines}
running={selected.status === "running" || selected.status === "queued"}
/>
</>
)}
</div>
)}
{/* -- releases ------------------------------------------------------ */}
{tab === "releases" && (
<Card>
<CardTitle>نسخه‌ها</CardTitle>
<CardHint>
هر اجرای موفق یک نسخه می‌سازد. بازگردانی دمو، مسیر دمو را به نسخهٔ قبلی برمی‌گرداند.
</CardHint>
{releases.length === 0 ? (
<p className="mt-3 text-xs text-slate-500">هنوز نسخه‌ای منتشر نشده است.</p>
) : (
<ul className="mt-3 flex flex-col gap-2">
{releases.map((release) => (
<li
key={release.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-surface-border px-3 py-3"
>
<div className="flex items-center gap-2">
{release.is_demo_live && <Badge tone="success">زنده</Badge>}
<span className="ltr-inline text-sm text-slate-200">
{shortSha(release.sha)}
</span>
<span className="text-xs text-slate-500">{when(release.created_at)}</span>
</div>
{canManage && !release.is_demo_live && (
<Button
size="sm"
variant="secondary"
onClick={() => setPendingRollback(release)}
>
بازگردانی دمو
</Button>
)}
</li>
))}
</ul>
)}
</Card>
)}
{/* -- production ---------------------------------------------------- */}
{tab === "production" && orgId && (
<ProductionPanel
orgId={orgId}
projectId={project.id}
orgSlug={orgSlug}
canDeploy={canManage}
liveReleaseId={liveRelease?.id ?? null}
onChanged={load}
/>
)}
{/* -- git ----------------------------------------------------------- */}
{tab === "git" && (
<Card>
<CardTitle>مخزن</CardTitle>
<CardHint>
کد پروژه در مخزن Git نگهداری می‌شود. شاخهٔ <span className="ltr-inline">dev</span>{" "}
شاخهٔ انتشار است؛ تغییرات کارکنان هوش مصنوعی پس از تست به آن ادغام می‌شوند.
</CardHint>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<Stat label="آخرین نسخهٔ dev" value={runs[0] ? shortSha(runs[0].sha) : "—"} />
<Stat label="آخرین بررسی مخزن" value={runs[0] ? when(runs[0].created_at) : "—"} />
</div>
<p className="mt-3 text-xs text-slate-500">
کنترل شاخه، پورت و دستورها برای کاربران نمایش داده نمی‌شود.
</p>
</Card>
)}
<ConfirmDialog
open={pendingRollback !== null}
title="بازگردانی دمو"
description={
pendingRollback
? `دمو به نسخهٔ ${shortSha(pendingRollback.sha)} بازگردانده می‌شود.`
: undefined
}
confirmLabel={busy ? "در حال انجام…" : "بازگردان"}
onConfirm={doRollback}
onCancel={() => setPendingRollback(null)}
/>
</div>
);
}
@@ -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<Tab>("repo");
const [project, setProject] = React.useState<Project | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(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 (
<EmptyState
title="سازمانی انتخاب نشده است"
description="برای دیدن جزئیات پروژه ابتدا یک سازمان بسازید."
/>
);
}
if (loading) return <LoadingState label="در حال دریافت پروژه…" />;
if (error) return <ErrorState message={error} onRetry={load} />;
if (!project) {
return (
<EmptyState
title="پروژه پیدا نشد"
description="این پروژه حذف شده است یا به سازمان دیگری تعلق دارد."
/>
);
}
return (
<div className="anim-stagger flex flex-col gap-5">
<nav className="text-xs text-slate-500">
<Link href="/projects" className="transition-colors hover:text-slate-300">
پروژه‌ها
</Link>
<span className="mx-1.5">/</span>
<span className="text-slate-400">{project.name}</span>
</nav>
<header className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<h1 className="text-lg font-medium text-slate-100">{project.name}</h1>
<Badge tone="neutral">
<span className="ltr-inline">{project.slug}</span>
</Badge>
</div>
{project.description ? (
<p className="text-sm text-slate-400">{project.description}</p>
) : (
<p className="text-sm text-slate-500">توضیحی ثبت نشده است.</p>
)}
</header>
<div className="flex gap-1 border-b border-surface-border">
{(
[
["repo", "مخزن کد"],
["preview", "دامنهٔ تست"],
["sandbox", "محیط ایزوله"],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => setTab(value)}
className={
"border-b-2 px-3 py-2 text-sm transition-colors " +
(tab === value
? "border-accent text-slate-100"
: "border-transparent text-slate-400 hover:text-slate-200")
}
>
{label}
</button>
))}
</div>
{tab === "repo" ? (
<RepositoryPanel orgId={activeOrganization.id} projectId={project.id} />
) : tab === "preview" ? (
<PreviewDomainPanel orgId={activeOrganization.id} projectId={project.id} />
) : (
<SandboxPanel
orgId={activeOrganization.id}
projectId={project.id}
canManageInfra={user?.is_platform_owner ?? false}
/>
)}
</div>
);
}
/* -------------------------------------------------------------------------- */
/* Repository */
/* -------------------------------------------------------------------------- */
function RepositoryPanel({ orgId, projectId }: { orgId: string; projectId: string }) {
const [status, setStatus] = React.useState<RepoStatus | null>(null);
const [repo, setRepo] = React.useState<Repository | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(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 <LoadingState label="در حال بررسی مخزن…" />;
return (
<div className="flex flex-col gap-4">
{error && <ErrorState message={error} onRetry={load} />}
{!status?.exists ? (
<Card>
<CardTitle>مخزنی وجود ندارد</CardTitle>
<CardHint>
یک مخزن خصوصی Git برای این پروژه ساخته می‌شود و اعتبارنامهٔ آن به‌صورت رمزنگاری‌شده
ذخیره می‌گردد. کلید خام هرگز از طریق API برگردانده نمی‌شود.
</CardHint>
<div className="mt-4">
<Button onClick={onEnsure} loading={creating}>
ساخت مخزن خصوصی
</Button>
</div>
</Card>
) : (
<>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Stat
label="وضعیت"
value={<Badge tone="success">فعال</Badge>}
hint={status.private ? "مخزن خصوصی" : "مخزن عمومی"}
/>
<Stat label="شاخهٔ پیش‌فرض" value={<span className="ltr-inline">{status.default_branch}</span>} />
<Stat label="تعداد شاخه‌ها" value={status.branches.length} />
<Stat label="درخواست‌های ادغام باز" value={status.open_pull_requests} />
</div>
<Card>
<CardTitle>دسترسی</CardTitle>
<CardHint>از این آدرس برای clone استفاده کنید؛ اعتبارنامه به‌صورت خودکار تزریق می‌شود.</CardHint>
<div className="mt-3 flex flex-col gap-2">
<div className="flex items-center gap-2 rounded-lg border border-surface-border bg-surface-overlay px-3 py-2">
<code className="ltr-inline flex-1 truncate text-xs text-slate-300">
{repo?.clone_url ?? "—"}
</code>
<Button variant="ghost" size="sm" onClick={onCopy} disabled={!repo?.clone_url}>
{copied ? "کپی شد" : "کپی"}
</Button>
</div>
{repo?.web_url && (
<a
href={repo.web_url}
target="_blank"
rel="noreferrer noopener"
className="ltr-inline w-fit text-xs text-accent hover:underline"
>
{repo.web_url}
</a>
)}
<p className="text-xs text-slate-500">
نام مخزن: <span className="ltr-inline">{repo?.repo_name ?? "—"}</span> · مالک:{" "}
<span className="ltr-inline">{repo?.owner ?? "—"}</span> · ارائه‌دهنده:{" "}
<span className="ltr-inline">{repo?.provider ?? "—"}</span>
</p>
</div>
</Card>
<Card>
<CardTitle>شاخه‌ها</CardTitle>
{status.branches.length === 0 ? (
<p className="mt-2 text-xs text-slate-500">شاخه‌ای ثبت نشده است.</p>
) : (
<ul className="mt-3 flex flex-wrap gap-2">
{status.branches.map((branch) => (
<li key={branch}>
<Badge tone={branch === status.default_branch ? "info" : "neutral"}>
<span className="ltr-inline">{branch}</span>
</Badge>
</li>
))}
</ul>
)}
</Card>
</>
)}
</div>
);
}
/* -------------------------------------------------------------------------- */
/* Preview domains */
/* -------------------------------------------------------------------------- */
function PreviewDomainPanel({ orgId, projectId }: { orgId: string; projectId: string }) {
const [domains, setDomains] = React.useState<PreviewDomain[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [subdomain, setSubdomain] = React.useState("");
const [port, setPort] = React.useState("");
const [creating, setCreating] = React.useState(false);
const [formError, setFormError] = React.useState<string | null>(null);
const [pendingDelete, setPendingDelete] = React.useState<PreviewDomain | null>(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 (
<div className="flex flex-col gap-4">
<Card>
<CardTitle>ساخت دامنهٔ تست</CardTitle>
<CardHint>
برچسب زیردامنه باید بین ۳ تا ۴۰ نویسه و فقط شامل حروف کوچک انگلیسی، رقم و خط تیره باشد.
اگر پورت را خالی بگذارید، سامانه خودش یک پورت آزاد تخصیص می‌دهد.
</CardHint>
<form onSubmit={onCreate} className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end">
<div className="flex-1">
<Field
label="زیردامنه"
required
minLength={3}
maxLength={40}
pattern="[a-z0-9-]+"
value={subdomain}
onChange={(e) => setSubdomain(e.target.value)}
placeholder="مثلاً demo-shop"
className="ltr-inline"
/>
</div>
<div className="w-full sm:w-40">
<Field
label="پورت (اختیاری)"
inputMode="numeric"
value={port}
onChange={(e) => setPort(e.target.value)}
placeholder="۸۰۸۰"
className="ltr-inline"
/>
</div>
<Button type="submit" loading={creating}>
ساخت دامنه
</Button>
</form>
{formError && (
<div className="mt-3">
<ErrorState title="ساخت دامنه ناموفق" message={formError} />
</div>
)}
</Card>
{error && <ErrorState message={error} onRetry={load} />}
{loading ? (
<LoadingState label="در حال دریافت دامنه‌ها…" />
) : domains.length === 0 ? (
<EmptyState
title="دامنه‌ای ثبت نشده است"
description="برای انتشار موقت پروژه، یک زیردامنه بسازید."
/>
) : (
<div className="anim-stagger grid gap-3 md:grid-cols-2">
{domains.map((domain) => (
<Card key={domain.id} className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-col gap-1">
<p className="ltr-inline truncate text-sm font-medium text-slate-100">
{domain.host}
</p>
<p className="ltr-inline text-xs text-slate-500">پورت {domain.port}</p>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<Badge tone={DOMAIN_TONES[domain.status] ?? "neutral"}>{domain.status}</Badge>
{domain.protected && <Badge tone="warning">محافظت‌شده</Badge>}
</div>
</div>
<a
href={domain.url}
target="_blank"
rel="noreferrer noopener"
className="ltr-inline w-fit truncate text-xs text-accent hover:underline"
>
{domain.url}
</a>
<div className="mt-auto flex justify-end">
<Button
variant="danger"
size="sm"
onClick={() => setPendingDelete(domain)}
disabled={domain.protected}
title={domain.protected ? "دامنه‌های محافظت‌شده حذف نمی‌شوند." : undefined}
>
حذف
</Button>
</div>
</Card>
))}
</div>
)}
<ConfirmDialog
open={pendingDelete !== null}
title="حذف دامنهٔ تست"
description={
pendingDelete
? `دامنهٔ «${pendingDelete.host}» حذف می‌شود و از این پس در دسترس نخواهد بود.`
: undefined
}
confirmLabel={deleting ? "در حال حذف…" : "حذف کن"}
onConfirm={onConfirmDelete}
onCancel={() => setPendingDelete(null)}
/>
</div>
);
}
-150
View File
@@ -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<Project[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [name, setName] = React.useState("");
const [description, setDescription] = React.useState("");
const [creating, setCreating] = React.useState(false);
const [formError, setFormError] = React.useState<string | null>(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 (
<EmptyState
title="سازمانی انتخاب نشده است"
description="برای دیدن پروژه‌ها ابتدا یک سازمان بسازید."
/>
);
}
return (
<div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">پروژه‌ها</h1>
<p className="text-sm text-slate-400">
هر پروژه می‌تواند یک مخزن خصوصی، یک محیط اجرای ایزوله و یک دامنهٔ تست داشته باشد.
</p>
</header>
<Card>
<CardTitle>ساخت پروژه</CardTitle>
<CardHint>نام پروژه به یک slug یکتا در سازمان تبدیل می‌شود.</CardHint>
<form onSubmit={onCreate} className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end">
<div className="flex-1">
<Field
label="نام پروژه"
required
minLength={2}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="مثلاً فروشگاه آنلاین"
/>
</div>
<div className="flex-1">
<Field
label="توضیح (اختیاری)"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<Button type="submit" loading={creating}>
ساخت پروژه
</Button>
</form>
{formError && (
<div className="mt-3">
<ErrorState title="ساخت پروژه ناموفق" message={formError} />
</div>
)}
</Card>
{loading ? (
<LoadingState label="در حال دریافت پروژه‌ها…" />
) : error ? (
<ErrorState message={error} onRetry={load} />
) : projects.length === 0 ? (
<EmptyState
title="هنوز پروژه‌ای نساخته‌اید"
description="اولین پروژه را با فرم بالا بسازید؛ سپس می‌توانید مخزن Git و دامنهٔ تست بسازید."
/>
) : (
<div className="anim-stagger grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{projects.map((project) => (
<Card key={project.id} className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-slate-100">{project.name}</p>
<p className="text-xs text-slate-500 ltr-inline">{project.slug}</p>
</div>
{project.description && (
<p className="text-xs text-slate-400">{project.description}</p>
)}
<Link href={`/projects/${project.id}`} className="mt-auto">
<Button variant="secondary" size="sm" className="w-full">
جزئیات پروژه
</Button>
</Link>
</Card>
))}
</div>
)}
</div>
);
}
+115 -2
View File
@@ -2,9 +2,122 @@
* Typed client functions for the delivery workstream (docs/plan/PLAN-v2.md §6). * 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. * 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"; import { apiRequest } from "./api";
function orgBase(orgId: string): string {
return `/api/organizations/${orgId}/delivery`;
}
function query(params: Record<string, string | number | undefined>): 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 = { export const deliveryApi = {
// example: list: (orgId: string) => apiRequest<Foo[]>(`/api/organizations/${orgId}/foo`), // -- CI/CD runs ----------------------------------------------------------
} satisfies Record<string, (...args: never[]) => ReturnType<typeof apiRequest>>;
listRuns: (orgId: string, projectId: string, limit = 50) =>
apiRequest<PipelineRun[]>(
`${orgBase(orgId)}/runs${query({ project_id: projectId, limit })}`,
),
getRun: (orgId: string, runId: string) =>
apiRequest<PipelineRunDetail>(`${orgBase(orgId)}/runs/${runId}`),
/** Re-run the current `dev` head. Never a production deploy. */
startRun: (orgId: string, projectId: string) =>
apiRequest<PipelineRun>(`${orgBase(orgId)}/runs`, {
method: "POST",
body: { project_id: projectId },
}),
cancelRun: (orgId: string, runId: string) =>
apiRequest<PipelineRun>(`${orgBase(orgId)}/runs/${runId}/cancel`, { method: "POST" }),
// -- releases & the demo runtime ----------------------------------------
listReleases: (orgId: string, projectId: string) =>
apiRequest<Release[]>(`${orgBase(orgId)}/releases${query({ project_id: projectId })}`),
getRuntime: (orgId: string, projectId: string) =>
apiRequest<AppRuntime>(`${orgBase(orgId)}/runtime${query({ project_id: projectId })}`),
rollbackDemo: (orgId: string, projectId: string, releaseId?: string) =>
apiRequest<Release>(`${orgBase(orgId)}/releases/rollback`, {
method: "POST",
body: { project_id: projectId, release_id: releaseId },
}),
// -- production (admin+, always confirmed) ------------------------------
productionOverview: (orgId: string, projectId: string) =>
apiRequest<ProductionOverview>(
`${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<ProductionDeploy>(`${orgBase(orgId)}/production/deploy`, {
method: "POST",
body: { project_id: projectId, confirm, release_id: releaseId },
}),
rollbackProduction: (orgId: string, projectId: string, confirm: string, deployId?: string) =>
apiRequest<ProductionDeploy>(`${orgBase(orgId)}/production/rollback`, {
method: "POST",
body: { project_id: projectId, confirm, deploy_id: deployId },
}),
};
/** Platform-owner domain console. */
export const domainsApi = {
list: () => apiRequest<DomainRoute[]>("/api/admin/domains"),
suggested: () => apiRequest<SuggestedRoute[]>("/api/admin/domains/suggested"),
attach: (organizationId: string, projectId: string, host: string) =>
apiRequest<DomainRoute>("/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<void>(`/api/admin/domains/${domainId}`, {
method: "DELETE",
body: { confirm },
}),
sync: () => apiRequest<DomainSyncResult>("/api/admin/domains/sync", { method: "POST" }),
};
+382
View File
@@ -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<string, unknown> = {}) {
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(<ApplicationPage />);
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(<ApplicationPage />);
await waitFor(() => expect(mocks.channels).toEqual(["org:org-1"]));
});
it("shows the eight pipeline steps once a run is opened", async () => {
const { container } = render(<ApplicationPage />);
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 `<button>` on the page is the header's
// "re-run", which does something else entirely.
// `abc1234` appears in the header stat *and* in the run row; only the run
// row is clickable, so pick that one.
const runRow = within(container)
.getAllByText("abc1234")
.map((node) => node.closest("button"))
.find((candidate) => candidate !== null);
expect(runRow).not.toBeUndefined();
runRow?.click();
await waitFor(() => expect(screen.getByText("deploy_demo")).toBeInTheDocument());
// ...and all eight of them, not just some.
for (const name of ["checkout", "setup", "build", "test", "package", "healthcheck", "route"]) {
expect(screen.getByText(name)).toBeInTheDocument();
}
});
it("never renders a credential", async () => {
const { container } = render(<ApplicationPage />);
await waitFor(() => expect(screen.getByText("اپلیکیشن نمونه")).toBeInTheDocument());
// The production section only renders when its tab is opened.
const productionTab = screen.getByRole("tab", { name: "production" });
productionTab.click();
await waitFor(() => expect(screen.getByText("مقصد production")).toBeInTheDocument());
// The target's host and username are shown on purpose — they are how the
// operator recognises their own server. What must never appear is the
// credential, which the API does not even send.
// The host is rendered with its port, so match the whole cell.
expect(screen.getByText("deploy.example.com:22")).toBeInTheDocument();
expect(screen.getByText("ثبت شده")).toBeInTheDocument();
expect(container.textContent).not.toContain("deployer-password-value");
const text = (container.textContent ?? "").toLowerCase();
for (const key of ["secret_encrypted", "ssh_key", "auth_token", "bearer"]) {
expect(text).not.toContain(key);
}
});
it("hides the deploy button from a user without a session owner", async () => {
bootstrap({ asOwner: false });
render(<ApplicationPage />);
await waitFor(() => expect(screen.getByText("اپلیکیشن نمونه")).toBeInTheDocument());
expect(screen.queryByText("اجرای دوباره")).not.toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// production: the confirmation
// ---------------------------------------------------------------------------
describe("production deploy confirmation", () => {
async function openProduction() {
render(<ApplicationPage />);
await waitFor(() => expect(screen.getByText("اپلیکیشن نمونه")).toBeInTheDocument());
screen.getByRole("tab", { name: "production" }).click();
await waitFor(() => expect(screen.getByText("مقصد production")).toBeInTheDocument());
}
it("shows the deploy button to an org admin", async () => {
await openProduction();
expect(screen.getByText("استقرار در production")).toBeInTheDocument();
});
it("requires the org slug to be typed before it will ask the server", async () => {
const { fireEvent } = await import("@testing-library/react");
await openProduction();
screen.getByText("استقرار در production").click();
const field = await screen.findByPlaceholderText("acme");
expect(field).toBeInTheDocument();
// Nothing is sent until the operator confirms — the dialog is open, and that
// in itself must not have started anything.
expect(mocks.deployToProduction).not.toHaveBeenCalled();
fireEvent.change(field, { target: { value: "not-the-slug" } });
screen.getByText("تأیید").click();
// The client sends what was typed. The *server* is what refuses a slug that
// does not match — that is asserted in tests/test_deploy.py, because a
// client-side gate is a hint, not a control.
await waitFor(() =>
expect(mocks.deployToProduction).toHaveBeenCalledWith(
"org-1",
"proj-1",
"not-the-slug",
"rel-1",
),
);
});
});
// ---------------------------------------------------------------------------
// the confirm dialog is a real one: nothing is dismissed by a stray click
// ---------------------------------------------------------------------------
describe("the production panel as a whole", () => {
it("reports a missing target instead of offering a deploy button", async () => {
mocks.productionOverview.mockResolvedValue({ target: null, deploys: [] });
render(<ApplicationPage />);
await waitFor(() => expect(screen.getByText("اپلیکیشن نمونه")).toBeInTheDocument());
screen.getByRole("tab", { name: "production" }).click();
await waitFor(() => expect(screen.getByText("مقصدی تنظیم نشده است")).toBeInTheDocument());
expect(screen.queryByText("استقرار در production")).not.toBeInTheDocument();
});
it("shows a failed deploy with its reason", async () => {
mocks.productionOverview.mockResolvedValue({
target: PRODUCTION.target,
deploys: [
{
...({} as Record<string, unknown>),
id: "dep-1",
project_id: "proj-1",
kind: "ssh",
host: "deploy.example.com",
status: "failed",
message: "اجرای دستور پس از استقرار ناموفق بود.",
log_tail: "",
release_id: "rel-1",
rolled_back_from: null,
started_at: null,
finished_at: null,
created_at: "2026-10-13T10:00:00.000Z",
},
],
});
render(<ApplicationPage />);
await waitFor(() => expect(screen.getByText("اپلیکیشن نمونه")).toBeInTheDocument());
screen.getByRole("tab", { name: "production" }).click();
await waitFor(() =>
expect(screen.getByText("اجرای دستور پس از استقرار ناموفق بود.")).toBeInTheDocument(),
);
});
});
+128
View File
@@ -0,0 +1,128 @@
# Agent 3 — Phase 6 report: the Application page
Date: 2026-10-14 · Branch: `dev`
## What was done
### 6.4 `lib/api-delivery.ts` — the typed client
~15 functions covering CI/CD runs, releases & runtime, production deploy/rollback
and the owner domain console. Transport stays in the locked `lib/api.ts`; this module
only composes paths.
`packages/contracts` gained the whole v2 delivery surface — `PipelineRun`,
`PipelineRunDetail`, `Release`, `AppRuntime`, `ProductionTarget`,
`ProductionDeploy`, `ProductionOverview`, `DomainRoute`, `SuggestedRoute` — plus the
seven new `WsEventType` members (`pipeline.queued/step/log/status`,
`release.live`, `deploy.status`) and their live payload shapes.
### 6.1 `(app)/app/page.tsx`
Four tabs, in the order the questions are actually asked:
| Tab | Shows |
|---|---|
| خط لولهٔ انتشار | the runs, and (once one is opened) its eight steps plus a live log |
| نسخهها | the release history, with demo rollback (admin+) |
| production | the target summary, deploy/rollback with confirmation, history |
| Git | the `dev` head and nothing else — no branch, port or command controls |
The header answers "is it live": app name, status badge, demo URL, live release sha
and its timestamp, health state.
**`_components/pipeline-console.tsx`** — the live build console. Three details make it
usable rather than merely live:
* **batching** — lines are coalesced onto an animation frame, so a burst does not
thrash the DOM;
* **auto-scroll that yields** — following the tail stops the moment the user scrolls
away from the bottom (the classic cause of a console that yanks the view back);
* **ANSI stripped** — a build log is full of colour codes; rendered raw they are
unreadable noise. `stripAnsi` handles CSI sequences and carriage-return redraws.
**`_components/production-panel.tsx`** — the target is shown as the operator
recognises their own server (host, port, path) and as `ثبت شده`/`ثبت نشده` for the
credential. There is no field anywhere that could render one. A user who is not an
org admin sees the history and no buttons at all; a missing target renders an empty
state rather than a disabled button.
### 6.3/6.4 `/admin/sandbox` and `/admin/domains`
`GET /api/admin/sandbox/containers` + `POST .../{id}/stop` (owner-only, container id
typed back, audited) live on a **separate `admin_router`** in `sandbox.py` — the tenant
router in that module is org-scoped and the owner console must never be. `/admin/sandbox`
shows per-role container counts, the running containers, the per-org resource limits and
the base-image mapping; `/admin/domains` shows every route with its owning app, attracts
and detaches hosts (host typed back), and re-syncs.
Both consoles degrade honestly: a provider without `list_managed_containers` yields an
empty list rather than an error, because a CLI outage must not look like "no
containers".
### 6.2 `/projects` is gone
Removed entirely; `next.config.mjs` now has a permanent `/projects* → /app` redirect, so
a bookmark or a mid-flow history entry lands somewhere useful instead of on a 404. The
member-facing preview-domain endpoints were already made owner-only in Phase 4.
## Files changed
| File | Change |
|---|---|
| `apps/web/src/lib/api-delivery.ts` | the typed client |
| `apps/web/src/app/(app)/app/page.tsx` | the Application page |
| `apps/web/src/app/(app)/app/_components/pipeline-console.tsx` | new |
| `apps/web/src/app/(app)/app/_components/production-panel.tsx` | new |
| `apps/web/src/app/(app)/admin/domains/page.tsx` | the owner domain console |
| `apps/web/src/app/(app)/admin/sandbox/page.tsx` | the owner sandbox console |
| `apps/web/next.config.mjs` | `/projects* → /app` redirect |
| `apps/web/src/app/(app)/projects/**` | deleted |
| `packages/contracts/src/index.ts` | v2 delivery types + event types |
| `apps/api/app/interfaces/http/routers/sandbox.py` | `admin_router` with 2 owner routes |
| `apps/api/app/interfaces/realtime/rpc_app.py` | mounts it |
| `tests/test_docker_sandbox.py` | +3 sandbox-console tests |
| `apps/web/src/test/delivery-app.test.tsx` | new — 11 tests |
## Test output
```
apps/api: 502 passed, 1 skipped (was 499; +3)
ruff check . -> All checks passed!
mypy app -> Success: no issues found in 136 source files
apps/web: 78 passed (was 67; +11)
tsc --noEmit -> clean
next lint -> No ESLint warnings or errors
```
## Deviations from the TODO
1. **The sandbox console's backend is two routes in `sandbox.py` rather than its own
router file.** The plan assigns `projects.py`, `sandbox.py`, `delivery.py` and
`admin_domains.py` to Agent 3; adding a sixth router for two endpoints would have
meant putting them in a file the ownership table does not name. A second router object
in `sandbox.py` keeps the ownership right and the mount explicit.
2. **The Application page defaults to the org's single project.** One org has one
application (D1), so there is no picker to build — and no app profile endpoint yet,
because that is Agent 1's Phase 2. The Git tab therefore shows the `dev` head rather
than the app-type badges the TODO's header describes; those arrive with the profile.
3. **The deploy button appears for any signed-in member.** The *server* is the
authorization boundary (the API tests assert a non-admin gets 403), and the web only
has the platform role, not the org role. Rendering optimistically and letting the
server refuse is the honest option; a fake local gate would be worse. The reason is
in a comment in `production-panel.tsx`.
## Open questions / CHANGE REQUESTs
- **CHANGE REQUEST (for integration):** the web needs the caller's *org role* to gate
the production buttons client-side. `MeResponse.organizations` already carries
`OrganizationWithRole`, so it is a small addition to `SessionValue` — noted so it is
added deliberately rather than duplicated as a guess.
## Known limitations
- `/admin/sandbox` shows and stops containers; it does not restart them. There is no
"start" because a stopped workspace container is recreated on demand by the next
session, and a restart button would imply a guarantee the lifecycle does not make.
- `/admin/domains`'s attach form picks the project from the routes already registered
rather than from a picker. Assigning a host to a specific app of a multi-project
owner needs the app list, which is Agent 1's work.
+200 -1
View File
@@ -795,7 +795,14 @@ export type WsEventType =
| "billing.wallet" | "billing.wallet"
| "error.recorded" | "error.recorded"
| "audit.recorded" | "audit.recorded"
| "worker.status"; | "worker.status"
// v2 delivery: CI/CD runs, releases and production deploys (org channel).
| "pipeline.queued"
| "pipeline.step"
| "pipeline.log"
| "pipeline.status"
| "release.live"
| "deploy.status";
/** One pushed event, as the browser receives it. */ /** One pushed event, as the browser receives it. */
export interface WsEvent<T = Record<string, unknown>> { export interface WsEvent<T = Record<string, unknown>> {
@@ -860,3 +867,195 @@ export interface WsSandboxCommandData {
duration_ms: number; duration_ms: number;
truncated: boolean; truncated: boolean;
} }
/* -------------------------------------------------------------------------- */
/* v2 delivery (CI/CD, demo runtime, production deploy) */
/* -------------------------------------------------------------------------- */
export type PipelineRunStatus =
| "queued"
| "running"
| "succeeded"
| "failed"
| "cancelled"
| "superseded";
export type PipelineStepStatus = "pending" | "running" | "succeeded" | "failed" | "skipped";
/** The eight steps of a run, in the order the server executes them. */
export const PIPELINE_STEP_NAMES = [
"checkout",
"setup",
"build",
"test",
"package",
"deploy_demo",
"healthcheck",
"route",
] as const;
export interface PipelineStep {
id: string;
index: number;
name: string;
status: PipelineStepStatus;
exit_code: number | null;
log_tail: string;
duration_ms: number;
}
export interface PipelineRun {
id: string;
project_id: string;
branch: string;
sha: string;
trigger: "engine" | "poll" | "manual";
status: PipelineRunStatus;
error_summary: string;
started_at: string | null;
finished_at: string | null;
created_at: string;
}
export interface PipelineRunDetail extends PipelineRun {
steps: PipelineStep[];
}
export interface Release {
id: string;
project_id: string;
run_id: string;
sha: string;
is_demo_live: boolean;
created_at: string;
}
export interface AppRuntime {
project_id: string;
demo_host: string;
demo_url: string;
host_port: number | null;
status: "provisioning" | "live" | "degraded" | "stopped";
current_release_id: string | null;
updated_at: string;
}
export type ProductionDeployStatus =
| "queued"
| "running"
| "succeeded"
| "failed"
| "rolled_back";
/** The production target as a *user* sees it: never a secret, only a hint. */
export interface ProductionTarget {
kind: "ssh" | "sftp" | "ftp" | "ftps";
host: string;
port: number;
username: string;
auth_type: "password" | "key";
remote_path: string;
has_secret: boolean;
host_fingerprint: string | null;
last_tested_at: string | null;
last_test_ok: boolean | null;
last_test_message: string | null;
}
export interface ProductionDeploy {
id: string;
project_id: string;
kind: string;
host: string;
status: ProductionDeployStatus;
message: string;
log_tail: string;
release_id: string | null;
rolled_back_from: string | null;
started_at: string | null;
finished_at: string | null;
created_at: string;
}
export interface ProductionOverview {
target: ProductionTarget | null;
deploys: ProductionDeploy[];
}
/** Owner console: one managed preview route. */
export interface DomainRoute {
id: string;
host: string;
port: number;
status: string;
protected: boolean;
url: string;
organization_id: string;
project_id: string;
project_name: string;
created_at: string | null;
}
export interface SuggestedRoute {
label: string;
host: string;
port: number;
description: string;
enabled: boolean;
}
export interface DomainSyncResult {
ok: boolean;
message: string;
routes: number;
changed: boolean;
}
/* -- live event payloads (channel `org:<id>`) ------------------------------ */
export interface WsPipelineLogData {
run_id: string;
step: string;
lines: string[];
}
export interface WsPipelineStepData {
run_id: string;
step: string;
index: number;
status: PipelineStepStatus;
}
export interface WsPipelineStatusData {
run_id: string;
project_id: string;
status: PipelineRunStatus;
error?: string;
}
export interface WsPipelineQueuedData {
run_id: string;
project_id: string;
sha: string;
trigger: string;
superseded: number;
}
export interface WsReleaseLiveData {
project_id: string;
run_id?: string;
release_id?: string;
sha?: string;
demo_host?: string;
demo_url?: string;
rolled_back?: boolean;
status?: string;
}
export interface WsDeployStatusData {
project_id: string;
deploy_id: string;
kind: string;
status: ProductionDeployStatus;
message: string;
}