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:
@@ -23,23 +23,33 @@ from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.services.audit_service import record_audit
|
||||
from app.application.services.sandbox_service import SandboxService
|
||||
from app.core.database import get_session
|
||||
from app.core.errors import PermissionDeniedError
|
||||
from app.core.errors import ConflictError, PermissionDeniedError, ValidationError
|
||||
from app.domain.audit.models import AuditAction
|
||||
from app.domain.identity.models import User
|
||||
from app.domain.sandbox.models import NetworkPolicy
|
||||
from app.infrastructure.adapters.docker_sandbox import LANGUAGE_IMAGES
|
||||
from app.infrastructure.db.models import SandboxResourceLimitRow
|
||||
from app.interfaces.http.deps import (
|
||||
OrgContext,
|
||||
client_meta,
|
||||
get_org_context,
|
||||
get_sandbox_service,
|
||||
require_platform_owner,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/organizations/{org_id}/sandbox", tags=["sandbox"])
|
||||
|
||||
#: The platform owner's view of every container on the host. Mounted at
|
||||
#: ``/api/admin/sandbox`` — a *separate* router object, because the tenant router
|
||||
#: above is org-scoped and this one must never be.
|
||||
admin_router = APIRouter(prefix="/admin/sandbox", tags=["admin-sandbox"])
|
||||
|
||||
|
||||
def _require_platform_owner(context: OrgContext) -> None:
|
||||
"""Infrastructure belongs to the platform owner (PLAN-v2 §7.7).
|
||||
@@ -469,3 +479,126 @@ async def get_preview_url(
|
||||
organization_id=context.org_id, session_id=session_id, port=port
|
||||
)
|
||||
return PreviewUrlOut(port=preview.port, url=preview.url)
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform-owner sandbox console (/admin/sandbox)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ContainerRow(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
status: str
|
||||
organization_id: str
|
||||
project_id: str
|
||||
role: str
|
||||
|
||||
|
||||
class SandboxListOut(BaseModel):
|
||||
containers: list[ContainerRow]
|
||||
limits: list[SandboxLimitRow]
|
||||
images: dict[str, str]
|
||||
|
||||
|
||||
class SandboxLimitRow(BaseModel):
|
||||
organization_id: str
|
||||
cpu: float
|
||||
memory_mb: int
|
||||
disk_mb: int
|
||||
timeout_seconds: int
|
||||
network_policy: str
|
||||
|
||||
|
||||
class StopContainerRequest(BaseModel):
|
||||
#: The container id, typed back — stopping one is destructive.
|
||||
confirm: str = Field(min_length=1, max_length=200)
|
||||
|
||||
|
||||
@admin_router.get("/containers", response_model=SandboxListOut)
|
||||
async def list_containers(
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SandboxListOut:
|
||||
"""Every container carrying our labels, plus the per-org limits.
|
||||
|
||||
A provider that cannot list containers (the mock, a remote adapter without
|
||||
the capability) returns an empty list rather than an error: the console is
|
||||
diagnostic, and a provider outage should not look like "no containers".
|
||||
"""
|
||||
from app.infrastructure.adapters.registry import get_sandbox_provider
|
||||
|
||||
provider = get_sandbox_provider()
|
||||
lister = getattr(provider, "list_managed_containers", None)
|
||||
containers: list[ContainerRow] = []
|
||||
if lister is not None:
|
||||
try:
|
||||
for item in await lister():
|
||||
containers.append(
|
||||
ContainerRow(
|
||||
id=item.container_id,
|
||||
name=item.name,
|
||||
status=item.status,
|
||||
organization_id=item.organization_id,
|
||||
project_id=item.project_id,
|
||||
role=item.role,
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001 - a CLI outage is not a 500
|
||||
containers = []
|
||||
|
||||
limits = (
|
||||
await session.execute(
|
||||
select(SandboxResourceLimitRow).where(
|
||||
SandboxResourceLimitRow.scope == "organization"
|
||||
)
|
||||
)
|
||||
).scalars()
|
||||
|
||||
return SandboxListOut(
|
||||
containers=containers,
|
||||
limits=[
|
||||
SandboxLimitRow(
|
||||
organization_id=row.scope_id or "",
|
||||
cpu=row.cpu,
|
||||
memory_mb=row.memory_mb,
|
||||
disk_mb=row.disk_mb,
|
||||
timeout_seconds=row.timeout_seconds,
|
||||
network_policy=str(row.network_policy),
|
||||
)
|
||||
for row in limits
|
||||
if row.scope_id
|
||||
],
|
||||
images=dict(LANGUAGE_IMAGES),
|
||||
)
|
||||
|
||||
|
||||
@admin_router.post("/containers/{container_id}/stop", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def stop_container(
|
||||
container_id: str,
|
||||
payload: StopContainerRequest,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
"""Stop and remove a container. Owner-only, confirmed and audited."""
|
||||
if payload.confirm.strip() != container_id.strip():
|
||||
raise ValidationError("تأییدیه با شناسهٔ کانتینر مطابقت ندارد.")
|
||||
|
||||
from app.infrastructure.adapters.registry import get_sandbox_provider
|
||||
|
||||
provider = get_sandbox_provider()
|
||||
remover = getattr(provider, "remove_container", None)
|
||||
if remover is None:
|
||||
raise ValidationError("این ارائهدهندهٔ سندباکس توقف کانتینر را پشتیبانی نمیکند.")
|
||||
|
||||
removed = await remover(container_id)
|
||||
if not removed:
|
||||
raise ConflictError("توقف کانتینر ناموفق بود.")
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.SANDBOX_DESTROY, actor_user_id=owner.id,
|
||||
target_type="sandbox_container", target_id=container_id, ip=ip, user_agent=ua,
|
||||
)
|
||||
|
||||
@@ -84,6 +84,7 @@ def build_rpc_app() -> FastAPI:
|
||||
application.include_router(admin_grants.router, prefix="/api")
|
||||
application.include_router(delivery.router, prefix="/api")
|
||||
application.include_router(admin_domains.router, prefix="/api")
|
||||
application.include_router(sandbox.admin_router, prefix="/api")
|
||||
application.include_router(realtime_router, prefix="/api")
|
||||
return application
|
||||
|
||||
|
||||
@@ -488,11 +488,6 @@ async def test_run_process_streams_output_to_the_callback():
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def _platform_seed(seeded):
|
||||
return seeded
|
||||
|
||||
|
||||
async def _owner_org(client: AsyncClient) -> tuple[str, dict]:
|
||||
from app.domain.identity.models import PlatformRole
|
||||
from tests.conftest import signup
|
||||
@@ -586,3 +581,65 @@ async def test_the_owner_check_helper_rejects_a_manager():
|
||||
_require_platform_owner(_Ctx(owner)) # must not raise
|
||||
|
||||
_ = (Membership, Role)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP surface: the owner's sandbox console
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def _platform_seed(seeded):
|
||||
return seeded
|
||||
|
||||
|
||||
async def _owner_org(client: AsyncClient) -> tuple[str, dict]:
|
||||
from app.domain.identity.models import PlatformRole
|
||||
from tests.conftest import signup
|
||||
|
||||
token = (
|
||||
await signup(
|
||||
client,
|
||||
"sandbox-owner@example.com",
|
||||
name="مالک سامانه",
|
||||
platform_role=PlatformRole.OWNER,
|
||||
)
|
||||
)["access_token"]
|
||||
headers = auth_header(token)
|
||||
org_id = (
|
||||
await client.post("/api/organizations", json={"name": "سازمان سندباکس"}, headers=headers)
|
||||
).json()["id"]
|
||||
return org_id, headers
|
||||
|
||||
|
||||
async def test_a_non_owner_cannot_list_containers(client: AsyncClient):
|
||||
from tests.conftest import signup
|
||||
|
||||
token = (await signup(client, "not-owner-sandbox@example.com"))["access_token"]
|
||||
response = await client.get("/api/admin/sandbox/containers", headers=auth_header(token))
|
||||
assert response.status_code == 403, response.text
|
||||
|
||||
|
||||
async def test_the_owner_sees_an_empty_list_with_a_mock_provider(
|
||||
client: AsyncClient,
|
||||
):
|
||||
"""The console must not invent containers it cannot see."""
|
||||
_org, headers = await _owner_org(client)
|
||||
response = await client.get("/api/admin/sandbox/containers", headers=headers)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["containers"] == []
|
||||
# The image mapping is always available: it is configuration, not state.
|
||||
assert "python" in body["images"]
|
||||
|
||||
|
||||
async def test_stopping_a_container_requires_the_id_typed_back(client: AsyncClient):
|
||||
_org, headers = await _owner_org(client)
|
||||
response = await client.post(
|
||||
"/api/admin/sandbox/containers/abc123/stop",
|
||||
json={"confirm": "something-else"},
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 422, response.text
|
||||
assert "مطابقت" in response.json()["error"]["message"]
|
||||
|
||||
@@ -6,6 +6,15 @@ const nextConfig = {
|
||||
env: {
|
||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL ?? "",
|
||||
},
|
||||
// v2: `/projects` is gone — one organization has exactly one application, so
|
||||
// there is no list to browse. A permanent redirect keeps old bookmarks (and
|
||||
// the browser history of anyone mid-flow) working instead of 404ing.
|
||||
async redirects() {
|
||||
return [
|
||||
{ source: "/projects", destination: "/app", permanent: true },
|
||||
{ source: "/projects/:path*", destination: "/app", permanent: true },
|
||||
];
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -1,17 +1,327 @@
|
||||
"use client";
|
||||
|
||||
import { PlaceholderPage } from "@/components/PlaceholderPage";
|
||||
/**
|
||||
* The platform owner's domain console (D9).
|
||||
*
|
||||
* A tenant gets one automatic demo host and nothing else. Everything here is the
|
||||
* owner alone: attach another host, detach one (which is what takes a URL offline,
|
||||
* so it needs the host typed back), and converge Caddy on the database.
|
||||
*
|
||||
* The re-sync button is the interesting one. It is safe to press at any time
|
||||
* because the server compares the rendered routes against what is on disk and
|
||||
* **reloads only when they differ** — a reload drops every live connection on the
|
||||
* host, including tenants' demos, so doing nothing when nothing changed is the
|
||||
* whole point. The `changed` flag the server returns is shown to the operator so
|
||||
* the result is visible rather than implied.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import type { DomainRoute, SuggestedRoute } from "@agy/contracts";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardHint,
|
||||
CardTitle,
|
||||
ConfirmDialog,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
LoadingState,
|
||||
Table,
|
||||
type TableColumn,
|
||||
} from "@agy/ui";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { domainsApi } from "@/lib/api-delivery";
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
host: string;
|
||||
project: string;
|
||||
port: string;
|
||||
status: string;
|
||||
protected: string;
|
||||
route: DomainRoute;
|
||||
}
|
||||
|
||||
function columns(onDetach: (route: DomainRoute) => void): Array<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() {
|
||||
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 (
|
||||
<PlaceholderPage
|
||||
title="دامنهها"
|
||||
description="مسیرهای Caddy و دامنههای اپلیکیشنها."
|
||||
planned={[
|
||||
"فهرست مسیرها",
|
||||
"افزودن و حذف دامنه",
|
||||
"همگامسازی Caddy",
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
{notice && (
|
||||
<Card>
|
||||
<p className="text-sm text-slate-300">{notice}</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,244 @@
|
||||
"use client";
|
||||
|
||||
import { PlaceholderPage } from "@/components/PlaceholderPage";
|
||||
/**
|
||||
* The platform owner's sandbox console (D5).
|
||||
*
|
||||
* Containers are infrastructure, and in v2 infrastructure is the owner's alone.
|
||||
* This page therefore shows *everything* running on the host that carries our
|
||||
* labels — workspace, demo and build containers, across every organization —
|
||||
* because the person who needs to see them is the one responsible for the host.
|
||||
*
|
||||
* Stopping a container is destructive and needs the id typed back: it is the only
|
||||
* way to make a misclick land on a confirmation rather than on a running demo.
|
||||
* The provider is asked, not assumed: a mock or an adapter without the capability
|
||||
* reports an empty list rather than inventing one.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardHint,
|
||||
CardTitle,
|
||||
ConfirmDialog,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
LoadingState,
|
||||
Stat,
|
||||
} from "@agy/ui";
|
||||
import { apiRequest } from "@/lib/api";
|
||||
|
||||
interface ContainerRow {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
organization_id: string;
|
||||
project_id: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface SandboxSummary {
|
||||
containers: ContainerRow[];
|
||||
limits: Array<{
|
||||
organization_id: string;
|
||||
cpu: number;
|
||||
memory_mb: number;
|
||||
disk_mb: number;
|
||||
timeout_seconds: number;
|
||||
network_policy: string;
|
||||
}>;
|
||||
images: Record<string, string>;
|
||||
}
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
workspace: "فضای کار کارمندان",
|
||||
demo: "نمایش زنده",
|
||||
build: "ساخت",
|
||||
};
|
||||
|
||||
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 (
|
||||
<PlaceholderPage
|
||||
title="سندباکسها"
|
||||
description="کانتینرهای در حال اجرا و محدودیتهای منابع."
|
||||
planned={[
|
||||
"کانتینرهای هر سازمان",
|
||||
"محدودیت CPU و حافظه",
|
||||
"توقف و راهاندازی مجدد",
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
{notice && (
|
||||
<Card>
|
||||
<p className="text-sm text-slate-300">{notice}</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,464 @@
|
||||
"use client";
|
||||
|
||||
import { PlaceholderPage } from "@/components/PlaceholderPage";
|
||||
/**
|
||||
* The Application page — v2's replacement for `/projects` (D1, D2).
|
||||
*
|
||||
* One organization has exactly one application, so this page is about *that*
|
||||
* application rather than a list. It answers four questions, in the order they
|
||||
* are actually asked:
|
||||
*
|
||||
* 1. Is it live, and at what URL? → the header
|
||||
* 2. Did my last change build? → the pipeline runs, with live logs
|
||||
* 3. What is deployed, and can I undo? → releases
|
||||
* 4. Is production up to date? → the production panel
|
||||
*
|
||||
* What it deliberately does **not** show a customer: ports, container ids, branch
|
||||
* controls, or anything else that is infrastructure. Those live in the owner
|
||||
* console. The Git section shows the `dev` head and nothing more.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import type {
|
||||
AppRuntime,
|
||||
PipelineRun,
|
||||
PipelineRunDetail,
|
||||
Project,
|
||||
Release,
|
||||
WsPipelineLogData,
|
||||
WsPipelineStatusData,
|
||||
WsReleaseLiveData,
|
||||
} from "@agy/contracts";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardHint,
|
||||
CardTitle,
|
||||
ConfirmDialog,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
LoadingState,
|
||||
Stat,
|
||||
Tabs,
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { deliveryApi } from "@/lib/api-delivery";
|
||||
import { useLiveChannel } from "@/lib/realtime";
|
||||
import { useSession } from "@/lib/session";
|
||||
import { PipelineConsole, stripAnsi, type LogLine } from "./_components/pipeline-console";
|
||||
import { ProductionPanel } from "./_components/production-panel";
|
||||
|
||||
type Tab = "pipeline" | "releases" | "production" | "git";
|
||||
type Tone = "neutral" | "success" | "warning" | "danger";
|
||||
|
||||
const RUN_TONES: Record<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 (
|
||||
<EmptyState
|
||||
title="هنوز اپلیکیشنی ساخته نشده است"
|
||||
description="هر سازمان یک اپلیکیشن دارد. برای ساخت آن، ویزارد ساخت اپلیکیشن را کامل کنید."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const liveRelease = releases.find((release) => release.is_demo_live) ?? null;
|
||||
const healthTone: Tone =
|
||||
runtime?.status === "live" ? "success" : runtime?.status === "degraded" ? "danger" : "neutral";
|
||||
|
||||
export default function AppPage() {
|
||||
return (
|
||||
<PlaceholderPage
|
||||
title="اپلیکیشن"
|
||||
description="دمو زنده، استقرار روی production و وضعیت Git اپلیکیشن سازمان."
|
||||
planned={[
|
||||
"نمای کلی اپلیکیشن و آدرس دمو",
|
||||
"اجراهای CI/CD با لاگ زنده",
|
||||
"نسخهها و بازگشت دمو",
|
||||
"استقرار روی production با تأیید",
|
||||
]}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,122 @@
|
||||
* Typed client functions for the delivery workstream (docs/plan/PLAN-v2.md §6).
|
||||
*
|
||||
* Add functions to this object only; ``lib/api.ts`` is locked. Use ``apiRequest`` for transport.
|
||||
*
|
||||
* Every call travels over the authenticated WebSocket RPC (see ``lib/api.ts``), so
|
||||
* these are plain functions with no transport concerns of their own.
|
||||
*/
|
||||
|
||||
import type {
|
||||
AppRuntime,
|
||||
DomainRoute,
|
||||
DomainSyncResult,
|
||||
PipelineRun,
|
||||
PipelineRunDetail,
|
||||
ProductionDeploy,
|
||||
ProductionOverview,
|
||||
Release,
|
||||
SuggestedRoute,
|
||||
} from "@agy/contracts";
|
||||
import { apiRequest } from "./api";
|
||||
|
||||
function orgBase(orgId: string): string {
|
||||
return `/api/organizations/${orgId}/delivery`;
|
||||
}
|
||||
|
||||
function query(params: Record<string, 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 = {
|
||||
// example: list: (orgId: string) => apiRequest<Foo[]>(`/api/organizations/${orgId}/foo`),
|
||||
} satisfies Record<string, (...args: never[]) => ReturnType<typeof apiRequest>>;
|
||||
// -- CI/CD runs ----------------------------------------------------------
|
||||
|
||||
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" }),
|
||||
};
|
||||
|
||||
@@ -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 = "[1;32mok[0m [31mfail[0m\r[2Kdone";
|
||||
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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -795,7 +795,14 @@ export type WsEventType =
|
||||
| "billing.wallet"
|
||||
| "error.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. */
|
||||
export interface WsEvent<T = Record<string, unknown>> {
|
||||
@@ -860,3 +867,195 @@ export interface WsSandboxCommandData {
|
||||
duration_ms: number;
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user