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 fastapi import APIRouter, Depends, Query, Request, status
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.application.services.audit_service import record_audit
|
from app.application.services.audit_service import record_audit
|
||||||
from app.application.services.sandbox_service import SandboxService
|
from app.application.services.sandbox_service import SandboxService
|
||||||
from app.core.database import get_session
|
from app.core.database import get_session
|
||||||
from app.core.errors import PermissionDeniedError
|
from app.core.errors import ConflictError, PermissionDeniedError, ValidationError
|
||||||
from app.domain.audit.models import AuditAction
|
from app.domain.audit.models import AuditAction
|
||||||
|
from app.domain.identity.models import User
|
||||||
from app.domain.sandbox.models import NetworkPolicy
|
from app.domain.sandbox.models import NetworkPolicy
|
||||||
|
from app.infrastructure.adapters.docker_sandbox import LANGUAGE_IMAGES
|
||||||
|
from app.infrastructure.db.models import SandboxResourceLimitRow
|
||||||
from app.interfaces.http.deps import (
|
from app.interfaces.http.deps import (
|
||||||
OrgContext,
|
OrgContext,
|
||||||
client_meta,
|
client_meta,
|
||||||
get_org_context,
|
get_org_context,
|
||||||
get_sandbox_service,
|
get_sandbox_service,
|
||||||
|
require_platform_owner,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/organizations/{org_id}/sandbox", tags=["sandbox"])
|
router = APIRouter(prefix="/organizations/{org_id}/sandbox", tags=["sandbox"])
|
||||||
|
|
||||||
|
#: The platform owner's view of every container on the host. Mounted at
|
||||||
|
#: ``/api/admin/sandbox`` — a *separate* router object, because the tenant router
|
||||||
|
#: above is org-scoped and this one must never be.
|
||||||
|
admin_router = APIRouter(prefix="/admin/sandbox", tags=["admin-sandbox"])
|
||||||
|
|
||||||
|
|
||||||
def _require_platform_owner(context: OrgContext) -> None:
|
def _require_platform_owner(context: OrgContext) -> None:
|
||||||
"""Infrastructure belongs to the platform owner (PLAN-v2 §7.7).
|
"""Infrastructure belongs to the platform owner (PLAN-v2 §7.7).
|
||||||
@@ -469,3 +479,126 @@ async def get_preview_url(
|
|||||||
organization_id=context.org_id, session_id=session_id, port=port
|
organization_id=context.org_id, session_id=session_id, port=port
|
||||||
)
|
)
|
||||||
return PreviewUrlOut(port=preview.port, url=preview.url)
|
return PreviewUrlOut(port=preview.port, url=preview.url)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Platform-owner sandbox console (/admin/sandbox)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ContainerRow(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
status: str
|
||||||
|
organization_id: str
|
||||||
|
project_id: str
|
||||||
|
role: str
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxListOut(BaseModel):
|
||||||
|
containers: list[ContainerRow]
|
||||||
|
limits: list[SandboxLimitRow]
|
||||||
|
images: dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxLimitRow(BaseModel):
|
||||||
|
organization_id: str
|
||||||
|
cpu: float
|
||||||
|
memory_mb: int
|
||||||
|
disk_mb: int
|
||||||
|
timeout_seconds: int
|
||||||
|
network_policy: str
|
||||||
|
|
||||||
|
|
||||||
|
class StopContainerRequest(BaseModel):
|
||||||
|
#: The container id, typed back — stopping one is destructive.
|
||||||
|
confirm: str = Field(min_length=1, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.get("/containers", response_model=SandboxListOut)
|
||||||
|
async def list_containers(
|
||||||
|
owner: User = Depends(require_platform_owner),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> SandboxListOut:
|
||||||
|
"""Every container carrying our labels, plus the per-org limits.
|
||||||
|
|
||||||
|
A provider that cannot list containers (the mock, a remote adapter without
|
||||||
|
the capability) returns an empty list rather than an error: the console is
|
||||||
|
diagnostic, and a provider outage should not look like "no containers".
|
||||||
|
"""
|
||||||
|
from app.infrastructure.adapters.registry import get_sandbox_provider
|
||||||
|
|
||||||
|
provider = get_sandbox_provider()
|
||||||
|
lister = getattr(provider, "list_managed_containers", None)
|
||||||
|
containers: list[ContainerRow] = []
|
||||||
|
if lister is not None:
|
||||||
|
try:
|
||||||
|
for item in await lister():
|
||||||
|
containers.append(
|
||||||
|
ContainerRow(
|
||||||
|
id=item.container_id,
|
||||||
|
name=item.name,
|
||||||
|
status=item.status,
|
||||||
|
organization_id=item.organization_id,
|
||||||
|
project_id=item.project_id,
|
||||||
|
role=item.role,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001 - a CLI outage is not a 500
|
||||||
|
containers = []
|
||||||
|
|
||||||
|
limits = (
|
||||||
|
await session.execute(
|
||||||
|
select(SandboxResourceLimitRow).where(
|
||||||
|
SandboxResourceLimitRow.scope == "organization"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars()
|
||||||
|
|
||||||
|
return SandboxListOut(
|
||||||
|
containers=containers,
|
||||||
|
limits=[
|
||||||
|
SandboxLimitRow(
|
||||||
|
organization_id=row.scope_id or "",
|
||||||
|
cpu=row.cpu,
|
||||||
|
memory_mb=row.memory_mb,
|
||||||
|
disk_mb=row.disk_mb,
|
||||||
|
timeout_seconds=row.timeout_seconds,
|
||||||
|
network_policy=str(row.network_policy),
|
||||||
|
)
|
||||||
|
for row in limits
|
||||||
|
if row.scope_id
|
||||||
|
],
|
||||||
|
images=dict(LANGUAGE_IMAGES),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.post("/containers/{container_id}/stop", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def stop_container(
|
||||||
|
container_id: str,
|
||||||
|
payload: StopContainerRequest,
|
||||||
|
request: Request,
|
||||||
|
owner: User = Depends(require_platform_owner),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> None:
|
||||||
|
"""Stop and remove a container. Owner-only, confirmed and audited."""
|
||||||
|
if payload.confirm.strip() != container_id.strip():
|
||||||
|
raise ValidationError("تأییدیه با شناسهٔ کانتینر مطابقت ندارد.")
|
||||||
|
|
||||||
|
from app.infrastructure.adapters.registry import get_sandbox_provider
|
||||||
|
|
||||||
|
provider = get_sandbox_provider()
|
||||||
|
remover = getattr(provider, "remove_container", None)
|
||||||
|
if remover is None:
|
||||||
|
raise ValidationError("این ارائهدهندهٔ سندباکس توقف کانتینر را پشتیبانی نمیکند.")
|
||||||
|
|
||||||
|
removed = await remover(container_id)
|
||||||
|
if not removed:
|
||||||
|
raise ConflictError("توقف کانتینر ناموفق بود.")
|
||||||
|
|
||||||
|
ip, ua = client_meta(request)
|
||||||
|
await record_audit(
|
||||||
|
session, action=AuditAction.SANDBOX_DESTROY, actor_user_id=owner.id,
|
||||||
|
target_type="sandbox_container", target_id=container_id, ip=ip, user_agent=ua,
|
||||||
|
)
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ def build_rpc_app() -> FastAPI:
|
|||||||
application.include_router(admin_grants.router, prefix="/api")
|
application.include_router(admin_grants.router, prefix="/api")
|
||||||
application.include_router(delivery.router, prefix="/api")
|
application.include_router(delivery.router, prefix="/api")
|
||||||
application.include_router(admin_domains.router, prefix="/api")
|
application.include_router(admin_domains.router, prefix="/api")
|
||||||
|
application.include_router(sandbox.admin_router, prefix="/api")
|
||||||
application.include_router(realtime_router, prefix="/api")
|
application.include_router(realtime_router, prefix="/api")
|
||||||
return application
|
return application
|
||||||
|
|
||||||
|
|||||||
@@ -488,11 +488,6 @@ async def test_run_process_streams_output_to_the_callback():
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture(autouse=True)
|
|
||||||
async def _platform_seed(seeded):
|
|
||||||
return seeded
|
|
||||||
|
|
||||||
|
|
||||||
async def _owner_org(client: AsyncClient) -> tuple[str, dict]:
|
async def _owner_org(client: AsyncClient) -> tuple[str, dict]:
|
||||||
from app.domain.identity.models import PlatformRole
|
from app.domain.identity.models import PlatformRole
|
||||||
from tests.conftest import signup
|
from tests.conftest import signup
|
||||||
@@ -586,3 +581,65 @@ async def test_the_owner_check_helper_rejects_a_manager():
|
|||||||
_require_platform_owner(_Ctx(owner)) # must not raise
|
_require_platform_owner(_Ctx(owner)) # must not raise
|
||||||
|
|
||||||
_ = (Membership, Role)
|
_ = (Membership, Role)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# HTTP surface: the owner's sandbox console
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(autouse=True)
|
||||||
|
async def _platform_seed(seeded):
|
||||||
|
return seeded
|
||||||
|
|
||||||
|
|
||||||
|
async def _owner_org(client: AsyncClient) -> tuple[str, dict]:
|
||||||
|
from app.domain.identity.models import PlatformRole
|
||||||
|
from tests.conftest import signup
|
||||||
|
|
||||||
|
token = (
|
||||||
|
await signup(
|
||||||
|
client,
|
||||||
|
"sandbox-owner@example.com",
|
||||||
|
name="مالک سامانه",
|
||||||
|
platform_role=PlatformRole.OWNER,
|
||||||
|
)
|
||||||
|
)["access_token"]
|
||||||
|
headers = auth_header(token)
|
||||||
|
org_id = (
|
||||||
|
await client.post("/api/organizations", json={"name": "سازمان سندباکس"}, headers=headers)
|
||||||
|
).json()["id"]
|
||||||
|
return org_id, headers
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_non_owner_cannot_list_containers(client: AsyncClient):
|
||||||
|
from tests.conftest import signup
|
||||||
|
|
||||||
|
token = (await signup(client, "not-owner-sandbox@example.com"))["access_token"]
|
||||||
|
response = await client.get("/api/admin/sandbox/containers", headers=auth_header(token))
|
||||||
|
assert response.status_code == 403, response.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_owner_sees_an_empty_list_with_a_mock_provider(
|
||||||
|
client: AsyncClient,
|
||||||
|
):
|
||||||
|
"""The console must not invent containers it cannot see."""
|
||||||
|
_org, headers = await _owner_org(client)
|
||||||
|
response = await client.get("/api/admin/sandbox/containers", headers=headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
body = response.json()
|
||||||
|
assert body["containers"] == []
|
||||||
|
# The image mapping is always available: it is configuration, not state.
|
||||||
|
assert "python" in body["images"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_stopping_a_container_requires_the_id_typed_back(client: AsyncClient):
|
||||||
|
_org, headers = await _owner_org(client)
|
||||||
|
response = await client.post(
|
||||||
|
"/api/admin/sandbox/containers/abc123/stop",
|
||||||
|
json={"confirm": "something-else"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 422, response.text
|
||||||
|
assert "مطابقت" in response.json()["error"]["message"]
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ const nextConfig = {
|
|||||||
env: {
|
env: {
|
||||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL ?? "",
|
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL ?? "",
|
||||||
},
|
},
|
||||||
|
// v2: `/projects` is gone — one organization has exactly one application, so
|
||||||
|
// there is no list to browse. A permanent redirect keeps old bookmarks (and
|
||||||
|
// the browser history of anyone mid-flow) working instead of 404ing.
|
||||||
|
async redirects() {
|
||||||
|
return [
|
||||||
|
{ source: "/projects", destination: "/app", permanent: true },
|
||||||
|
{ source: "/projects/:path*", destination: "/app", permanent: true },
|
||||||
|
];
|
||||||
|
},
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,17 +1,327 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { PlaceholderPage } from "@/components/PlaceholderPage";
|
/**
|
||||||
|
* The platform owner's domain console (D9).
|
||||||
|
*
|
||||||
|
* A tenant gets one automatic demo host and nothing else. Everything here is the
|
||||||
|
* owner alone: attach another host, detach one (which is what takes a URL offline,
|
||||||
|
* so it needs the host typed back), and converge Caddy on the database.
|
||||||
|
*
|
||||||
|
* The re-sync button is the interesting one. It is safe to press at any time
|
||||||
|
* because the server compares the rendered routes against what is on disk and
|
||||||
|
* **reloads only when they differ** — a reload drops every live connection on the
|
||||||
|
* host, including tenants' demos, so doing nothing when nothing changed is the
|
||||||
|
* whole point. The `changed` flag the server returns is shown to the operator so
|
||||||
|
* the result is visible rather than implied.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import type { DomainRoute, SuggestedRoute } from "@agy/contracts";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardHint,
|
||||||
|
CardTitle,
|
||||||
|
ConfirmDialog,
|
||||||
|
EmptyState,
|
||||||
|
ErrorState,
|
||||||
|
Field,
|
||||||
|
LoadingState,
|
||||||
|
Table,
|
||||||
|
type TableColumn,
|
||||||
|
} from "@agy/ui";
|
||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
import { domainsApi } from "@/lib/api-delivery";
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
id: string;
|
||||||
|
host: string;
|
||||||
|
project: string;
|
||||||
|
port: string;
|
||||||
|
status: string;
|
||||||
|
protected: string;
|
||||||
|
route: DomainRoute;
|
||||||
|
}
|
||||||
|
|
||||||
|
function columns(onDetach: (route: DomainRoute) => void): Array<TableColumn<Row>> {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "host",
|
||||||
|
header: "دامنه",
|
||||||
|
className: "ltr-inline",
|
||||||
|
render: (row) => <span className="text-slate-200">{row.host}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "project",
|
||||||
|
header: "اپلیکیشن",
|
||||||
|
render: (row) => <span className="text-slate-300">{row.project}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "port",
|
||||||
|
header: "پورت",
|
||||||
|
className: "ltr-inline",
|
||||||
|
render: (row) => <span className="text-slate-400">{row.port}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
header: "وضعیت",
|
||||||
|
render: (row) => (
|
||||||
|
<Badge tone={row.status === "active" ? "success" : "neutral"}>{row.status}</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "protected",
|
||||||
|
header: "محافظتشده",
|
||||||
|
render: (row) => <span className="text-slate-400">{row.protected}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
header: "عملیات",
|
||||||
|
render: (row) => (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => onDetach(row.route)}
|
||||||
|
disabled={row.protected === "بله"}
|
||||||
|
>
|
||||||
|
حذف
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminDomainsPage() {
|
export default function AdminDomainsPage() {
|
||||||
|
const [routes, setRoutes] = React.useState<DomainRoute[] | null>(null);
|
||||||
|
const [suggested, setSuggested] = React.useState<SuggestedRoute[]>([]);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = React.useState(false);
|
||||||
|
const [notice, setNotice] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const [host, setHost] = React.useState("");
|
||||||
|
const [pendingDetach, setPendingDetach] = React.useState<DomainRoute | null>(null);
|
||||||
|
const [typed, setTyped] = React.useState("");
|
||||||
|
|
||||||
|
const load = React.useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [nextRoutes, nextSuggested] = await Promise.all([
|
||||||
|
domainsApi.list(),
|
||||||
|
domainsApi.suggested().catch(() => []),
|
||||||
|
]);
|
||||||
|
setRoutes(nextRoutes);
|
||||||
|
setSuggested(nextSuggested);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "دریافت فهرست دامنهها ناموفق بود.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const attach = async () => {
|
||||||
|
const target = host.trim();
|
||||||
|
if (!target) return;
|
||||||
|
setBusy(true);
|
||||||
|
setNotice(null);
|
||||||
|
try {
|
||||||
|
// The owner attaches a host to a project, and the server points it at the
|
||||||
|
// port the app already serves on. The project is picked from the routes
|
||||||
|
// already registered, so this console cannot attach a host to an app that
|
||||||
|
// has no port at all.
|
||||||
|
const listed = await domainsApi.list();
|
||||||
|
const first = listed[0];
|
||||||
|
if (!first) {
|
||||||
|
setNotice("هنوز اپلیکیشنی با مسیر فعال وجود ندارد.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await domainsApi.attach(first.organization_id, first.project_id, target);
|
||||||
|
setHost("");
|
||||||
|
setNotice(`دامنهٔ ${target} اضافه شد.`);
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setNotice(err instanceof ApiError ? err.message : "افزودن دامنه ناموفق بود.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const detach = async () => {
|
||||||
|
if (!pendingDetach) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await domainsApi.detach(pendingDetach.id, typed.trim());
|
||||||
|
setNotice(`دامنهٔ ${pendingDetach.host} حذف شد.`);
|
||||||
|
setPendingDetach(null);
|
||||||
|
setTyped("");
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setNotice(err instanceof ApiError ? err.message : "حذف دامنه ناموفق بود.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sync = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setNotice(null);
|
||||||
|
try {
|
||||||
|
const result = await domainsApi.sync();
|
||||||
|
setNotice(
|
||||||
|
result.changed
|
||||||
|
? `${result.message} (${result.routes} مسیر)`
|
||||||
|
: "چیزی تغییر نکرده بود؛ Caddy بارگذاری مجدد نشد.",
|
||||||
|
);
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setNotice(err instanceof ApiError ? err.message : "همگامسازی ناموفق بود.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <LoadingState label="در حال دریافت دامنهها…" />;
|
||||||
|
if (error) return <ErrorState message={error} onRetry={load} />;
|
||||||
|
|
||||||
|
const rows: Row[] = (routes ?? []).map((route) => ({
|
||||||
|
id: route.id,
|
||||||
|
host: route.host,
|
||||||
|
project: route.project_name || route.project_id,
|
||||||
|
port: String(route.port),
|
||||||
|
status: route.status,
|
||||||
|
protected: route.protected ? "بله" : "خیر",
|
||||||
|
route,
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlaceholderPage
|
<div className="flex flex-col gap-4">
|
||||||
title="دامنهها"
|
{notice && (
|
||||||
description="مسیرهای Caddy و دامنههای اپلیکیشنها."
|
<Card>
|
||||||
planned={[
|
<p className="text-sm text-slate-300">{notice}</p>
|
||||||
"فهرست مسیرها",
|
</Card>
|
||||||
"افزودن و حذف دامنه",
|
)}
|
||||||
"همگامسازی Caddy",
|
|
||||||
]}
|
<Card>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle>دامنههای فعال</CardTitle>
|
||||||
|
<CardHint>
|
||||||
|
هر اپلیکیشن یک دامنهٔ دموی خودکار دارد. افزودن دامنهٔ دیگر فقط با مالک سامانه
|
||||||
|
ممکن است؛ کاربران نمیتوانند دامنه اضافه کنند.
|
||||||
|
</CardHint>
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary" onClick={sync} loading={busy}>
|
||||||
|
همگامسازی Caddy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div className="mt-4">
|
||||||
|
<EmptyState title="مسیری ثبت نشده است" description="هنوز هیچ اپلیکیشنی دمو ندارد." />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4">
|
||||||
|
<Table
|
||||||
|
columns={columns((route) => setPendingDetach(route))}
|
||||||
|
rows={rows}
|
||||||
|
rowKey={(row) => row.id}
|
||||||
|
caption="مسیرهای دامنهٔ سامانه"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardTitle>افزودن دامنه</CardTitle>
|
||||||
|
<CardHint>
|
||||||
|
یک برچسب زیر دامنهٔ سکو (مثل{" "}
|
||||||
|
<span className="ltr-inline">shop.msa.artacloud.ir</span>) یا، اگر فعال باشد، یک
|
||||||
|
دامنهٔ سفارشی کامل. دامنهٔ wildcard مجاز نیست.
|
||||||
|
</CardHint>
|
||||||
|
<form
|
||||||
|
className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-end"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void attach();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Field
|
||||||
|
label="نام دامنه"
|
||||||
|
value={host}
|
||||||
|
onChange={(event) => setHost(event.target.value)}
|
||||||
|
placeholder="shop.msa.artacloud.ir"
|
||||||
|
className="ltr-inline"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" loading={busy} disabled={!host.trim()}>
|
||||||
|
افزودن
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{suggested.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardTitle>مسیرهای پیشنهادی سامانه</CardTitle>
|
||||||
|
<CardHint>
|
||||||
|
این مسیرها سرویسهای خود سامانهاند، نه اپلیکیشن کاربر. پیش از فعالسازی، رکورد
|
||||||
|
DNS و گواهی TLS باید آماده باشد.
|
||||||
|
</CardHint>
|
||||||
|
<ul className="mt-3 flex flex-col gap-2">
|
||||||
|
{suggested.map((route) => (
|
||||||
|
<li
|
||||||
|
key={route.label}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-surface-border px-3 py-3"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="ltr-inline text-sm text-slate-200">{route.host}</span>
|
||||||
|
<Badge tone="neutral">پورت {route.port}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500">{route.description}</p>
|
||||||
|
</div>
|
||||||
|
{route.enabled ? <Badge tone="success">فعال</Badge> : (
|
||||||
|
<span className="text-xs text-slate-500">غیرفعال</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={pendingDetach !== null}
|
||||||
|
title="حذف دامنه"
|
||||||
|
description={
|
||||||
|
pendingDetach
|
||||||
|
? `با حذف این مسیر، آدرس ${pendingDetach.host} از کار میافتد. برای تأیید، نام دامنه را تایپ کنید.`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
confirmLabel={busy ? "در حال حذف…" : "حذف کن"}
|
||||||
|
onConfirm={detach}
|
||||||
|
onCancel={() => {
|
||||||
|
setPendingDetach(null);
|
||||||
|
setTyped("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{pendingDetach && (
|
||||||
|
<Card>
|
||||||
|
<Field
|
||||||
|
label="نام دامنه را برای تأیید تایپ کنید"
|
||||||
|
value={typed}
|
||||||
|
onChange={(event) => setTyped(event.target.value)}
|
||||||
|
placeholder={pendingDetach.host}
|
||||||
|
className="ltr-inline"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,244 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { PlaceholderPage } from "@/components/PlaceholderPage";
|
/**
|
||||||
|
* The platform owner's sandbox console (D5).
|
||||||
|
*
|
||||||
|
* Containers are infrastructure, and in v2 infrastructure is the owner's alone.
|
||||||
|
* This page therefore shows *everything* running on the host that carries our
|
||||||
|
* labels — workspace, demo and build containers, across every organization —
|
||||||
|
* because the person who needs to see them is the one responsible for the host.
|
||||||
|
*
|
||||||
|
* Stopping a container is destructive and needs the id typed back: it is the only
|
||||||
|
* way to make a misclick land on a confirmation rather than on a running demo.
|
||||||
|
* The provider is asked, not assumed: a mock or an adapter without the capability
|
||||||
|
* reports an empty list rather than inventing one.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardHint,
|
||||||
|
CardTitle,
|
||||||
|
ConfirmDialog,
|
||||||
|
EmptyState,
|
||||||
|
ErrorState,
|
||||||
|
Field,
|
||||||
|
LoadingState,
|
||||||
|
Stat,
|
||||||
|
} from "@agy/ui";
|
||||||
|
import { apiRequest } from "@/lib/api";
|
||||||
|
|
||||||
|
interface ContainerRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
organization_id: string;
|
||||||
|
project_id: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SandboxSummary {
|
||||||
|
containers: ContainerRow[];
|
||||||
|
limits: Array<{
|
||||||
|
organization_id: string;
|
||||||
|
cpu: number;
|
||||||
|
memory_mb: number;
|
||||||
|
disk_mb: number;
|
||||||
|
timeout_seconds: number;
|
||||||
|
network_policy: string;
|
||||||
|
}>;
|
||||||
|
images: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
|
workspace: "فضای کار کارمندان",
|
||||||
|
demo: "نمایش زنده",
|
||||||
|
build: "ساخت",
|
||||||
|
};
|
||||||
|
|
||||||
export default function AdminSandboxPage() {
|
export default function AdminSandboxPage() {
|
||||||
|
const [summary, setSummary] = React.useState<SandboxSummary | null>(null);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
const [notice, setNotice] = React.useState<string | null>(null);
|
||||||
|
const [pending, setPending] = React.useState<ContainerRow | null>(null);
|
||||||
|
const [typed, setTyped] = React.useState("");
|
||||||
|
const [busy, setBusy] = React.useState(false);
|
||||||
|
|
||||||
|
const load = React.useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setSummary(await apiRequest<SandboxSummary>("/api/admin/sandbox/containers"));
|
||||||
|
} catch (err) {
|
||||||
|
// The response body carries a plain Persian message; anything else is ours.
|
||||||
|
setError(err instanceof Error ? err.message : "دریافت وضعیت کانتینرها ناموفق بود.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const stop = async () => {
|
||||||
|
if (!pending) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await apiRequest<void>(`/api/admin/sandbox/containers/${pending.id}/stop`, {
|
||||||
|
method: "POST",
|
||||||
|
body: { confirm: typed.trim() },
|
||||||
|
});
|
||||||
|
setNotice(`کانتینر ${pending.id.slice(0, 12)} متوقف شد.`);
|
||||||
|
setPending(null);
|
||||||
|
setTyped("");
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setNotice(err instanceof Error ? err.message : "توقف کانتینر ناموفق بود.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <LoadingState label="در حال دریافت کانتینرها…" />;
|
||||||
|
if (error) return <ErrorState message={error} onRetry={load} />;
|
||||||
|
if (!summary) return null;
|
||||||
|
|
||||||
|
const byRole = summary.containers.reduce<Record<string, number>>((accumulator, row) => {
|
||||||
|
accumulator[row.role] = (accumulator[row.role] ?? 0) + 1;
|
||||||
|
return accumulator;
|
||||||
|
}, {});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlaceholderPage
|
<div className="flex flex-col gap-4">
|
||||||
title="سندباکسها"
|
{notice && (
|
||||||
description="کانتینرهای در حال اجرا و محدودیتهای منابع."
|
<Card>
|
||||||
planned={[
|
<p className="text-sm text-slate-300">{notice}</p>
|
||||||
"کانتینرهای هر سازمان",
|
</Card>
|
||||||
"محدودیت CPU و حافظه",
|
)}
|
||||||
"توقف و راهاندازی مجدد",
|
|
||||||
]}
|
<Card>
|
||||||
|
<CardTitle>کانتینرهای در حال اجرا</CardTitle>
|
||||||
|
<CardHint>
|
||||||
|
هر اپلیکیشن یک کانتینر فضای کار (کارمندان در آن کد مینویسند) و یک کانتینر
|
||||||
|
نمایش زنده دارد. کانتینرهای ساخت موقتاند و پس از هر اجرا حذف میشوند.
|
||||||
|
</CardHint>
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Stat label="فضای کار" value={String(byRole.workspace ?? 0)} />
|
||||||
|
<Stat label="نمایش زنده" value={String(byRole.demo ?? 0)} />
|
||||||
|
<Stat label="ساخت" value={String(byRole.build ?? 0)} />
|
||||||
|
<Stat label="مجموع" value={String(summary.containers.length)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{summary.containers.length === 0 ? (
|
||||||
|
<div className="mt-4">
|
||||||
|
<EmptyState
|
||||||
|
title="کانتینر فعالی وجود ندارد"
|
||||||
|
description="اگر ارائهدهندهٔ سندباکس روی docker تنظیم باشد، کانتینرها اینجا دیده میشوند."
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-4 flex flex-col gap-2">
|
||||||
|
{summary.containers.map((row) => (
|
||||||
|
<li
|
||||||
|
key={row.id}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-surface-border px-3 py-3"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge tone={row.status.startsWith("Up") ? "success" : "neutral"}>
|
||||||
|
{row.status}
|
||||||
|
</Badge>
|
||||||
|
<span className="ltr-inline truncate text-xs text-slate-400">{row.name}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{ROLE_LABELS[row.role] ?? row.role} ·{" "}
|
||||||
|
<span className="ltr-inline">{row.organization_id.slice(0, 8)}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="danger" onClick={() => setPending(row)}>
|
||||||
|
توقف
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardTitle>محدودیت منابع سازمانها</CardTitle>
|
||||||
|
<CardHint>
|
||||||
|
این مقادیر از صفحهٔ پروژه برای هر سازمان تنظیم میشوند و در سطح سرویس همیشه
|
||||||
|
محدود نگه داشته میشوند.
|
||||||
|
</CardHint>
|
||||||
|
{summary.limits.length === 0 ? (
|
||||||
|
<p className="mt-3 text-xs text-slate-500">محدودیت سفارشیای ثبت نشده است.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-3 flex flex-col gap-2">
|
||||||
|
{summary.limits.map((row) => (
|
||||||
|
<li
|
||||||
|
key={row.organization_id}
|
||||||
|
className="grid gap-3 rounded-lg border border-surface-border px-3 py-3 sm:grid-cols-4"
|
||||||
|
>
|
||||||
|
<Stat label="سازمان" value={row.organization_id.slice(0, 8)} />
|
||||||
|
<Stat label="CPU" value={`${row.cpu} هسته`} />
|
||||||
|
<Stat label="حافظه" value={`${row.memory_mb} مگابایت`} />
|
||||||
|
<Stat label="دیسک" value={`${row.disk_mb} مگابایت`} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardTitle>نگاشت تصویر پایه</CardTitle>
|
||||||
|
<CardHint>
|
||||||
|
برای هر زبان اپلیکیشن یک تصویر پایه مشخص میشود؛ کانتینرهای ساخت و فضای کار از
|
||||||
|
همین تصویرها ساخته میشوند.
|
||||||
|
</CardHint>
|
||||||
|
<ul className="mt-3 flex flex-col gap-2">
|
||||||
|
{Object.entries(summary.images).map(([language, image]) => (
|
||||||
|
<li
|
||||||
|
key={language}
|
||||||
|
className="flex items-center justify-between gap-2 rounded-lg border border-surface-border px-3 py-2"
|
||||||
|
>
|
||||||
|
<span className="ltr-inline text-xs text-slate-400">{language}</span>
|
||||||
|
<span className="ltr-inline text-sm text-slate-200">{image}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={pending !== null}
|
||||||
|
title="توقف کانتینر"
|
||||||
|
description={
|
||||||
|
pending
|
||||||
|
? `کانتینر ${pending.name} متوقف و حذف میشود. برای تأیید، شناسهٔ آن را تایپ کنید.`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
confirmLabel={busy ? "در حال توقف…" : "توقف کن"}
|
||||||
|
onConfirm={stop}
|
||||||
|
onCancel={() => {
|
||||||
|
setPending(null);
|
||||||
|
setTyped("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{pending && (
|
||||||
|
<Card>
|
||||||
|
<Field
|
||||||
|
label="شناسهٔ کانتینر را برای تأیید تایپ کنید"
|
||||||
|
value={typed}
|
||||||
|
onChange={(event) => setTyped(event.target.value)}
|
||||||
|
placeholder={pending.id}
|
||||||
|
className="ltr-inline"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The live build console.
|
||||||
|
*
|
||||||
|
* Three details make this usable rather than merely live:
|
||||||
|
*
|
||||||
|
* 1. **Batching.** The server coalesces a step's output into at most one event
|
||||||
|
* per 500 ms, but a fast build still arrives faster than a DOM update is
|
||||||
|
* worth. Lines are accumulated and flushed on an animation frame.
|
||||||
|
* 2. **Auto-scroll that yields.** Following the tail is what you want until you
|
||||||
|
* scroll up to read something, at which point it is actively hostile. The
|
||||||
|
* console stops following the moment the user scrolls away from the bottom
|
||||||
|
* and offers an explicit way back.
|
||||||
|
* 3. **ANSI stripped.** A build log is full of colour codes; rendered raw they
|
||||||
|
* are unreadable noise. Stripping them is not cosmetic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Badge, Button, Card, CardHint, CardTitle, cx } from "@agy/ui";
|
||||||
|
|
||||||
|
/** Remove ANSI SGR/CSI sequences and carriage-return progress redraws. */
|
||||||
|
export function stripAnsi(text: string): string {
|
||||||
|
return text
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "")
|
||||||
|
.replace(/\r(?!\n)/g, "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogLine {
|
||||||
|
key: string;
|
||||||
|
step: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Coalesce incoming lines into frames so a burst does not thrash the DOM. */
|
||||||
|
export function useBatchedLines(incoming: LogLine[]): LogLine[] {
|
||||||
|
const [rendered, setRendered] = React.useState<LogLine[]>([]);
|
||||||
|
const pending = React.useRef<LogLine[]>([]);
|
||||||
|
const frame = React.useRef<number | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (incoming.length === 0) return;
|
||||||
|
pending.current.push(...incoming);
|
||||||
|
if (frame.current !== null) return;
|
||||||
|
frame.current = requestAnimationFrame(() => {
|
||||||
|
frame.current = null;
|
||||||
|
const batch = pending.current;
|
||||||
|
pending.current = [];
|
||||||
|
// Bounded: a console is a window on the log, not the log itself.
|
||||||
|
setRendered((current) => [...current, ...batch].slice(-2000));
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
if (frame.current !== null) {
|
||||||
|
cancelAnimationFrame(frame.current);
|
||||||
|
frame.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [incoming]);
|
||||||
|
|
||||||
|
return rendered;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PipelineConsole({
|
||||||
|
lines,
|
||||||
|
running,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
lines: LogLine[];
|
||||||
|
running: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const rendered = useBatchedLines(lines);
|
||||||
|
const scroller = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
const [following, setFollowing] = React.useState(true);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!following) return;
|
||||||
|
const node = scroller.current;
|
||||||
|
if (node) node.scrollTop = node.scrollHeight;
|
||||||
|
}, [rendered, following]);
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
const node = scroller.current;
|
||||||
|
if (!node) return;
|
||||||
|
const atBottom = node.scrollHeight - node.scrollTop - node.clientHeight < 24;
|
||||||
|
setFollowing(atBottom);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(rendered.map((l) => `[${l.step}] ${l.text}`).join("\n"));
|
||||||
|
} catch {
|
||||||
|
/* a clipboard the browser refuses is not worth an error banner */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle>لاگ زنده</CardTitle>
|
||||||
|
<CardHint>
|
||||||
|
خروجی هر مرحله بهصورت زنده اینجا میآید. کدهای رنگی ترمینال حذف میشوند.
|
||||||
|
</CardHint>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{running && <Badge tone="warning">در حال اجرا</Badge>}
|
||||||
|
{!following && (
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => setFollowing(true)}>
|
||||||
|
ادامهٔ دنبالکردن
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="ghost" onClick={copy} disabled={rendered.length === 0}>
|
||||||
|
کپی
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={scroller}
|
||||||
|
onScroll={onScroll}
|
||||||
|
className={cx(
|
||||||
|
"ltr-inline mt-4 max-h-80 min-h-32 overflow-auto rounded-lg border",
|
||||||
|
"border-surface-border bg-surface-base p-3 font-mono text-xs leading-relaxed",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{rendered.length === 0 ? (
|
||||||
|
<p className="text-slate-500">هنوز خروجیای نرسیده است.</p>
|
||||||
|
) : (
|
||||||
|
rendered.map((line) => (
|
||||||
|
<div key={line.key} className="flex gap-2 whitespace-pre-wrap break-all">
|
||||||
|
<span className="shrink-0 text-slate-600">{line.step}</span>
|
||||||
|
<span className="text-slate-300">{line.text}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The production section of the Application page.
|
||||||
|
*
|
||||||
|
* Production is the only action in the product the platform cannot undo, so the
|
||||||
|
* UI is built around one rule: **the operator has to type the organization's
|
||||||
|
* slug to proceed.** Not a checkbox, not a second click — the same string the
|
||||||
|
* server demands, so the modal cannot be dismissed by muscle memory.
|
||||||
|
*
|
||||||
|
* The target summary deliberately shows `has_secret` rather than the secret, and
|
||||||
|
* there is no field anywhere in this component that could render one.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import type { ProductionDeploy, ProductionOverview } from "@agy/contracts";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardHint,
|
||||||
|
CardTitle,
|
||||||
|
ConfirmDialog,
|
||||||
|
EmptyState,
|
||||||
|
ErrorState,
|
||||||
|
Field,
|
||||||
|
LoadingState,
|
||||||
|
Stat,
|
||||||
|
} from "@agy/ui";
|
||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
import { deliveryApi } from "@/lib/api-delivery";
|
||||||
|
|
||||||
|
const STATUS_TONES = {
|
||||||
|
queued: "warning",
|
||||||
|
running: "warning",
|
||||||
|
succeeded: "success",
|
||||||
|
failed: "danger",
|
||||||
|
rolled_back: "neutral",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function toneFor(status: string) {
|
||||||
|
return STATUS_TONES[status as keyof typeof STATUS_TONES] ?? "neutral";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProductionPanel({
|
||||||
|
orgId,
|
||||||
|
projectId,
|
||||||
|
orgSlug,
|
||||||
|
canDeploy,
|
||||||
|
liveReleaseId,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
projectId: string;
|
||||||
|
orgSlug: string;
|
||||||
|
/** Org owner/admin. A viewer sees the history and no buttons at all. */
|
||||||
|
canDeploy: boolean;
|
||||||
|
liveReleaseId: string | null;
|
||||||
|
onChanged?: () => void;
|
||||||
|
}) {
|
||||||
|
const [overview, setOverview] = React.useState<ProductionOverview | null>(null);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = React.useState(false);
|
||||||
|
const [notice, setNotice] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const [confirming, setConfirming] = React.useState<"deploy" | "rollback" | null>(null);
|
||||||
|
const [typed, setTyped] = React.useState("");
|
||||||
|
const [lastLog, setLastLog] = React.useState<ProductionDeploy | null>(null);
|
||||||
|
|
||||||
|
const load = React.useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setOverview(await deliveryApi.productionOverview(orgId, projectId));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "دریافت وضعیت production ناموفق بود.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [orgId, projectId]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const onTest = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setNotice(null);
|
||||||
|
try {
|
||||||
|
const result = await deliveryApi.testProductionTarget(orgId, projectId);
|
||||||
|
setNotice(result.message);
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setNotice(err instanceof ApiError ? err.message : "تست اتصال ناموفق بود.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runConfirmed = async () => {
|
||||||
|
const slug = typed.trim();
|
||||||
|
setBusy(true);
|
||||||
|
setNotice(null);
|
||||||
|
try {
|
||||||
|
const deploy =
|
||||||
|
confirming === "deploy"
|
||||||
|
? await deliveryApi.deployToProduction(orgId, projectId, slug, liveReleaseId ?? undefined)
|
||||||
|
: await deliveryApi.rollbackProduction(orgId, projectId, slug);
|
||||||
|
setLastLog(deploy);
|
||||||
|
setNotice(deploy.message || (deploy.status === "succeeded" ? "انجام شد." : "ناموفق بود."));
|
||||||
|
onChanged?.();
|
||||||
|
} catch (err) {
|
||||||
|
setNotice(err instanceof ApiError ? err.message : "عملیات ناموفق بود.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
setConfirming(null);
|
||||||
|
setTyped("");
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <LoadingState label="در حال دریافت وضعیت production…" />;
|
||||||
|
if (error) return <ErrorState message={error} onRetry={load} />;
|
||||||
|
|
||||||
|
const target = overview?.target ?? null;
|
||||||
|
const deploys = overview?.deploys ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{notice && (
|
||||||
|
<Card>
|
||||||
|
<p className="text-sm text-slate-300">{notice}</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle>مقصد production</CardTitle>
|
||||||
|
<CardHint>
|
||||||
|
استقرار روی production فقط با کلیک شما و با تأیید نامک سازمان انجام میشود.
|
||||||
|
</CardHint>
|
||||||
|
</div>
|
||||||
|
{target?.last_test_ok === true && <Badge tone="success">اتصال تأییدشده</Badge>}
|
||||||
|
{target?.last_test_ok === false && <Badge tone="danger">اتصال ناموفق</Badge>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{target === null ? (
|
||||||
|
<div className="mt-4">
|
||||||
|
<EmptyState
|
||||||
|
title="مقصدی تنظیم نشده است"
|
||||||
|
description="برای استقرار روی production، در ویزارد ساخت اپلیکیشن یا تنظیمات، اطلاعات SSH/FTP را وارد کنید."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Stat label="نوع" value={target.kind.toUpperCase()} />
|
||||||
|
<Stat label="میزبان" value={`${target.host}:${target.port}`} />
|
||||||
|
<Stat label="مسیر" value={target.remote_path} />
|
||||||
|
<Stat label="کلید" value={target.has_secret ? "ثبت شده" : "ثبت نشده"} />
|
||||||
|
</div>
|
||||||
|
{target.last_test_message && (
|
||||||
|
<p className="mt-3 text-xs text-slate-500">{target.last_test_message}</p>
|
||||||
|
)}
|
||||||
|
{canDeploy && (
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
<Button variant="secondary" onClick={onTest} loading={busy}>
|
||||||
|
تست اتصال
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setConfirming("deploy")}
|
||||||
|
disabled={busy || !liveReleaseId}
|
||||||
|
>
|
||||||
|
استقرار در production
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setConfirming("rollback")}
|
||||||
|
disabled={busy || deploys.length === 0}
|
||||||
|
>
|
||||||
|
بازگردانی آخرین استقرار
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!canDeploy && (
|
||||||
|
<p className="mt-3 text-xs text-slate-500">
|
||||||
|
استقرار روی production تنها توسط مالک یا مدیر سازمان انجام میشود.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardTitle>تاریخچهٔ استقرار</CardTitle>
|
||||||
|
{deploys.length === 0 ? (
|
||||||
|
<p className="mt-2 text-xs text-slate-500">هنوز استقراری انجام نشده است.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-3 flex flex-col gap-2">
|
||||||
|
{deploys.map((deploy) => (
|
||||||
|
<li
|
||||||
|
key={deploy.id}
|
||||||
|
className="flex flex-col gap-2 rounded-lg border border-surface-border px-3 py-3"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge tone={toneFor(deploy.status)}>{deploy.status}</Badge>
|
||||||
|
<span className="ltr-inline text-xs text-slate-400">{deploy.kind}</span>
|
||||||
|
<span className="ltr-inline truncate text-xs text-slate-500">
|
||||||
|
{deploy.host}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setLastLog(deploy)}>
|
||||||
|
لاگ
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{deploy.message && (
|
||||||
|
<p className="text-xs text-slate-400">{deploy.message}</p>
|
||||||
|
)}
|
||||||
|
{deploy.rolled_back_from && (
|
||||||
|
<p className="text-xs text-slate-500">بازگردانی از استقرار قبلی</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{lastLog && (
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<CardTitle>لاگ استقرار</CardTitle>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setLastLog(null)}>
|
||||||
|
بستن
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<pre className="ltr-inline mt-3 max-h-64 overflow-auto whitespace-pre-wrap break-all rounded-lg border border-surface-border bg-surface-base p-3 font-mono text-xs text-slate-300">
|
||||||
|
{lastLog.log_tail || "خروجیای ثبت نشده است."}
|
||||||
|
</pre>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirming !== null}
|
||||||
|
title={confirming === "deploy" ? "استقرار در production" : "بازگردانی استقرار"}
|
||||||
|
description={
|
||||||
|
confirming === "deploy"
|
||||||
|
? "این کار نسخهٔ جدید را روی سرور شما فعال میکند و بهسادگی برگشتپذیر نیست."
|
||||||
|
: "نسخهٔ فعال روی سرور شما به نسخهٔ قبلی برمیگردد."
|
||||||
|
}
|
||||||
|
confirmLabel={busy ? "در حال انجام…" : "تأیید"}
|
||||||
|
onConfirm={runConfirmed}
|
||||||
|
onCancel={() => {
|
||||||
|
setConfirming(null);
|
||||||
|
setTyped("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{confirming !== null && (
|
||||||
|
<Card>
|
||||||
|
<Field
|
||||||
|
label="برای تأیید، نامک سازمان را تایپ کنید"
|
||||||
|
hint={`نامک: ${orgSlug}`}
|
||||||
|
value={typed}
|
||||||
|
onChange={(event) => setTyped(event.target.value)}
|
||||||
|
placeholder={orgSlug}
|
||||||
|
className="ltr-inline"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,464 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { PlaceholderPage } from "@/components/PlaceholderPage";
|
/**
|
||||||
|
* The Application page — v2's replacement for `/projects` (D1, D2).
|
||||||
|
*
|
||||||
|
* One organization has exactly one application, so this page is about *that*
|
||||||
|
* application rather than a list. It answers four questions, in the order they
|
||||||
|
* are actually asked:
|
||||||
|
*
|
||||||
|
* 1. Is it live, and at what URL? → the header
|
||||||
|
* 2. Did my last change build? → the pipeline runs, with live logs
|
||||||
|
* 3. What is deployed, and can I undo? → releases
|
||||||
|
* 4. Is production up to date? → the production panel
|
||||||
|
*
|
||||||
|
* What it deliberately does **not** show a customer: ports, container ids, branch
|
||||||
|
* controls, or anything else that is infrastructure. Those live in the owner
|
||||||
|
* console. The Git section shows the `dev` head and nothing more.
|
||||||
|
*/
|
||||||
|
|
||||||
export default function AppPage() {
|
import * as React from "react";
|
||||||
|
import type {
|
||||||
|
AppRuntime,
|
||||||
|
PipelineRun,
|
||||||
|
PipelineRunDetail,
|
||||||
|
Project,
|
||||||
|
Release,
|
||||||
|
WsPipelineLogData,
|
||||||
|
WsPipelineStatusData,
|
||||||
|
WsReleaseLiveData,
|
||||||
|
} from "@agy/contracts";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardHint,
|
||||||
|
CardTitle,
|
||||||
|
ConfirmDialog,
|
||||||
|
EmptyState,
|
||||||
|
ErrorState,
|
||||||
|
LoadingState,
|
||||||
|
Stat,
|
||||||
|
Tabs,
|
||||||
|
} from "@agy/ui";
|
||||||
|
import { ApiError, api } from "@/lib/api";
|
||||||
|
import { deliveryApi } from "@/lib/api-delivery";
|
||||||
|
import { useLiveChannel } from "@/lib/realtime";
|
||||||
|
import { useSession } from "@/lib/session";
|
||||||
|
import { PipelineConsole, stripAnsi, type LogLine } from "./_components/pipeline-console";
|
||||||
|
import { ProductionPanel } from "./_components/production-panel";
|
||||||
|
|
||||||
|
type Tab = "pipeline" | "releases" | "production" | "git";
|
||||||
|
type Tone = "neutral" | "success" | "warning" | "danger";
|
||||||
|
|
||||||
|
const RUN_TONES: Record<string, Tone> = {
|
||||||
|
queued: "neutral",
|
||||||
|
running: "warning",
|
||||||
|
succeeded: "success",
|
||||||
|
failed: "danger",
|
||||||
|
cancelled: "neutral",
|
||||||
|
superseded: "neutral",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STEP_TONES: Record<string, Tone> = {
|
||||||
|
pending: "neutral",
|
||||||
|
running: "warning",
|
||||||
|
succeeded: "success",
|
||||||
|
failed: "danger",
|
||||||
|
skipped: "neutral",
|
||||||
|
};
|
||||||
|
|
||||||
|
function shortSha(sha: string): string {
|
||||||
|
return sha ? sha.slice(0, 7) : "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function when(value: string | null): string {
|
||||||
|
if (!value) return "—";
|
||||||
|
try {
|
||||||
|
return new Date(value).toLocaleString("fa-IR");
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a live event's data as its typed payload.
|
||||||
|
*
|
||||||
|
* The socket carries ``Record<string, unknown>`` by design (the transport is a
|
||||||
|
* plain JSON frame), so a direct cast is refused by the compiler. Going through
|
||||||
|
* ``unknown`` is the honest way to say "the server's typed publisher defines this
|
||||||
|
* shape and the client mirrors it" — and the narrow `as` cast that survives is
|
||||||
|
* the one place the mirror is asserted.
|
||||||
|
*/
|
||||||
|
function payload<T>(event: { data: Record<string, unknown> }): T {
|
||||||
|
return event.data as unknown as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ApplicationPage() {
|
||||||
|
const { activeOrganization, user } = useSession();
|
||||||
|
const orgId = activeOrganization?.id ?? null;
|
||||||
|
const orgSlug = activeOrganization?.slug ?? "";
|
||||||
|
// Org membership is enough to *see* everything here; the server is the real
|
||||||
|
// boundary for the actions, which is why a 403 still surfaces as a message.
|
||||||
|
const canManage = Boolean(user);
|
||||||
|
|
||||||
|
const [tab, setTab] = React.useState<Tab>("pipeline");
|
||||||
|
const [project, setProject] = React.useState<Project | null>(null);
|
||||||
|
const [runtime, setRuntime] = React.useState<AppRuntime | null>(null);
|
||||||
|
const [runs, setRuns] = React.useState<PipelineRun[]>([]);
|
||||||
|
const [releases, setReleases] = React.useState<Release[]>([]);
|
||||||
|
const [selected, setSelected] = React.useState<PipelineRunDetail | null>(null);
|
||||||
|
const [lines, setLines] = React.useState<LogLine[]>([]);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
const [pendingRollback, setPendingRollback] = React.useState<Release | null>(null);
|
||||||
|
const [busy, setBusy] = React.useState(false);
|
||||||
|
const [notice, setNotice] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const projectId = project?.id ?? null;
|
||||||
|
|
||||||
|
const load = React.useCallback(async () => {
|
||||||
|
if (!orgId) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const projects = await api.listProjects(orgId);
|
||||||
|
const current = projects[0] ?? null;
|
||||||
|
setProject(current);
|
||||||
|
if (!current) {
|
||||||
|
setRuntime(null);
|
||||||
|
setRuns([]);
|
||||||
|
setReleases([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [nextRuntime, nextRuns, nextReleases] = await Promise.all([
|
||||||
|
deliveryApi.getRuntime(orgId, current.id).catch(() => null),
|
||||||
|
deliveryApi.listRuns(orgId, current.id).catch(() => []),
|
||||||
|
deliveryApi.listReleases(orgId, current.id).catch(() => []),
|
||||||
|
]);
|
||||||
|
setRuntime(nextRuntime);
|
||||||
|
setRuns(nextRuns);
|
||||||
|
setReleases(nextReleases);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "دریافت اطلاعات اپلیکیشن ناموفق بود.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [orgId]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
// Live feed: build status, log lines and release switches. Log lines are
|
||||||
|
// filtered to the run currently open so two concurrent builds never interleave
|
||||||
|
// in one console.
|
||||||
|
useLiveChannel(orgId ? [`org:${orgId}`] : [], (event) => {
|
||||||
|
if (!projectId) return;
|
||||||
|
if (event.type === "pipeline.log") {
|
||||||
|
const data = payload<WsPipelineLogData>(event);
|
||||||
|
if (selected && data.run_id !== selected.id) return;
|
||||||
|
const stamp = Date.now();
|
||||||
|
const batch = (data.lines ?? []).map((text, index) => ({
|
||||||
|
key: `${data.run_id}-${data.step}-${stamp}-${index}`,
|
||||||
|
step: data.step,
|
||||||
|
text: stripAnsi(text),
|
||||||
|
}));
|
||||||
|
if (batch.length) setLines((current) => [...current, ...batch]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.type === "pipeline.status") {
|
||||||
|
const data = payload<WsPipelineStatusData>(event);
|
||||||
|
if (data.project_id !== projectId) return;
|
||||||
|
void load();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.type === "release.live") {
|
||||||
|
const data = payload<WsReleaseLiveData>(event);
|
||||||
|
if (data.project_id !== projectId) return;
|
||||||
|
void load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const openRun = async (runId: string) => {
|
||||||
|
if (!orgId) return;
|
||||||
|
setNotice(null);
|
||||||
|
setLines([]);
|
||||||
|
try {
|
||||||
|
setSelected(await deliveryApi.getRun(orgId, runId));
|
||||||
|
} catch (err) {
|
||||||
|
setNotice(err instanceof ApiError ? err.message : "دریافت جزئیات اجرا ناموفق بود.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startRun = async () => {
|
||||||
|
if (!orgId || !projectId) return;
|
||||||
|
setBusy(true);
|
||||||
|
setNotice(null);
|
||||||
|
try {
|
||||||
|
await deliveryApi.startRun(orgId, projectId);
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setNotice(err instanceof ApiError ? err.message : "شروع اجرای جدید ناموفق بود.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const doRollback = async () => {
|
||||||
|
if (!orgId || !projectId || !pendingRollback) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await deliveryApi.rollbackDemo(orgId, projectId, pendingRollback.id);
|
||||||
|
setNotice("دمو به نسخهٔ انتخابشده بازگردانده شد.");
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setNotice(err instanceof ApiError ? err.message : "بازگردانی دمو ناموفق بود.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
setPendingRollback(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <LoadingState label="در حال دریافت اطلاعات اپلیکیشن…" />;
|
||||||
|
if (error) return <ErrorState message={error} onRetry={load} />;
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
return (
|
return (
|
||||||
<PlaceholderPage
|
<EmptyState
|
||||||
title="اپلیکیشن"
|
title="هنوز اپلیکیشنی ساخته نشده است"
|
||||||
description="دمو زنده، استقرار روی production و وضعیت Git اپلیکیشن سازمان."
|
description="هر سازمان یک اپلیکیشن دارد. برای ساخت آن، ویزارد ساخت اپلیکیشن را کامل کنید."
|
||||||
planned={[
|
|
||||||
"نمای کلی اپلیکیشن و آدرس دمو",
|
|
||||||
"اجراهای CI/CD با لاگ زنده",
|
|
||||||
"نسخهها و بازگشت دمو",
|
|
||||||
"استقرار روی production با تأیید",
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const liveRelease = releases.find((release) => release.is_demo_live) ?? null;
|
||||||
|
const healthTone: Tone =
|
||||||
|
runtime?.status === "live" ? "success" : runtime?.status === "degraded" ? "danger" : "neutral";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{notice && (
|
||||||
|
<Card>
|
||||||
|
<p className="text-sm text-slate-300">{notice}</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* -- header ------------------------------------------------------- */}
|
||||||
|
<Card>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CardTitle>{project.name}</CardTitle>
|
||||||
|
<Badge tone={healthTone}>{runtime?.status ?? "بدون محیط"}</Badge>
|
||||||
|
</div>
|
||||||
|
<CardHint>
|
||||||
|
<span className="ltr-inline">{project.slug}</span>
|
||||||
|
</CardHint>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{runtime?.demo_url ? (
|
||||||
|
<a
|
||||||
|
href={runtime.demo_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
className="ltr-inline text-sm text-accent hover:underline"
|
||||||
|
>
|
||||||
|
{runtime.demo_url}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-slate-500">آدرس دمو آماده نیست.</span>
|
||||||
|
)}
|
||||||
|
{canManage && (
|
||||||
|
<Button size="sm" variant="secondary" onClick={startRun} loading={busy}>
|
||||||
|
اجرای دوباره
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Stat label="نسخهٔ زنده" value={liveRelease ? shortSha(liveRelease.sha) : "—"} />
|
||||||
|
<Stat label="زمان نسخهٔ زنده" value={liveRelease ? when(liveRelease.created_at) : "—"} />
|
||||||
|
<Stat label="آخرین اجرا" value={runs[0] ? runs[0].status : "—"} />
|
||||||
|
<Stat label="تعداد اجراها" value={String(runs.length)} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
items={[
|
||||||
|
{ key: "pipeline", label: "خط لولهٔ انتشار" },
|
||||||
|
{ key: "releases", label: "نسخهها" },
|
||||||
|
{ key: "production", label: "production" },
|
||||||
|
{ key: "git", label: "Git" },
|
||||||
|
]}
|
||||||
|
active={tab}
|
||||||
|
onChange={(key) => setTab(key as Tab)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* -- pipeline ------------------------------------------------------ */}
|
||||||
|
{tab === "pipeline" && (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardTitle>اجراهای خط لوله</CardTitle>
|
||||||
|
<CardHint>
|
||||||
|
هر بار که شاخهٔ <span className="ltr-inline">dev</span> بهروز میشود یک اجرا ساخته
|
||||||
|
میشود. برای دیدن جزئیات و لاگ زنده روی هر اجرا بزنید.
|
||||||
|
</CardHint>
|
||||||
|
{runs.length === 0 ? (
|
||||||
|
<p className="mt-3 text-xs text-slate-500">هنوز اجرایی ثبت نشده است.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-3 flex flex-col gap-2">
|
||||||
|
{runs.map((run) => (
|
||||||
|
<li key={run.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void openRun(run.id)}
|
||||||
|
className={
|
||||||
|
"flex w-full flex-col gap-2 rounded-lg border px-3 py-3 text-right transition " +
|
||||||
|
(selected?.id === run.id
|
||||||
|
? "border-accent/60 bg-surface-overlay"
|
||||||
|
: "border-surface-border hover:border-accent/40")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge tone={RUN_TONES[run.status] ?? "neutral"}>{run.status}</Badge>
|
||||||
|
<span className="ltr-inline text-xs text-slate-400">
|
||||||
|
{shortSha(run.sha)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500">{run.trigger}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-slate-500">{when(run.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
{run.error_summary && (
|
||||||
|
<p className="text-xs text-rose-300">{run.error_summary}</p>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardTitle>مراحل اجرا</CardTitle>
|
||||||
|
<ol className="mt-3 flex flex-col gap-2">
|
||||||
|
{selected.steps.map((step) => (
|
||||||
|
<li
|
||||||
|
key={step.id}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-surface-border px-3 py-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="ltr-inline text-sm text-slate-200">{step.name}</span>
|
||||||
|
<Badge tone={STEP_TONES[step.status] ?? "neutral"}>{step.status}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-slate-500">
|
||||||
|
{step.exit_code !== null && (
|
||||||
|
<span className="ltr-inline">exit {step.exit_code}</span>
|
||||||
|
)}
|
||||||
|
{step.duration_ms > 0 && <span>{step.duration_ms}ms</span>}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<PipelineConsole
|
||||||
|
lines={lines}
|
||||||
|
running={selected.status === "running" || selected.status === "queued"}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* -- releases ------------------------------------------------------ */}
|
||||||
|
{tab === "releases" && (
|
||||||
|
<Card>
|
||||||
|
<CardTitle>نسخهها</CardTitle>
|
||||||
|
<CardHint>
|
||||||
|
هر اجرای موفق یک نسخه میسازد. بازگردانی دمو، مسیر دمو را به نسخهٔ قبلی برمیگرداند.
|
||||||
|
</CardHint>
|
||||||
|
{releases.length === 0 ? (
|
||||||
|
<p className="mt-3 text-xs text-slate-500">هنوز نسخهای منتشر نشده است.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-3 flex flex-col gap-2">
|
||||||
|
{releases.map((release) => (
|
||||||
|
<li
|
||||||
|
key={release.id}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-surface-border px-3 py-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{release.is_demo_live && <Badge tone="success">زنده</Badge>}
|
||||||
|
<span className="ltr-inline text-sm text-slate-200">
|
||||||
|
{shortSha(release.sha)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500">{when(release.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
{canManage && !release.is_demo_live && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setPendingRollback(release)}
|
||||||
|
>
|
||||||
|
بازگردانی دمو
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* -- production ---------------------------------------------------- */}
|
||||||
|
{tab === "production" && orgId && (
|
||||||
|
<ProductionPanel
|
||||||
|
orgId={orgId}
|
||||||
|
projectId={project.id}
|
||||||
|
orgSlug={orgSlug}
|
||||||
|
canDeploy={canManage}
|
||||||
|
liveReleaseId={liveRelease?.id ?? null}
|
||||||
|
onChanged={load}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* -- git ----------------------------------------------------------- */}
|
||||||
|
{tab === "git" && (
|
||||||
|
<Card>
|
||||||
|
<CardTitle>مخزن</CardTitle>
|
||||||
|
<CardHint>
|
||||||
|
کد پروژه در مخزن Git نگهداری میشود. شاخهٔ <span className="ltr-inline">dev</span>{" "}
|
||||||
|
شاخهٔ انتشار است؛ تغییرات کارکنان هوش مصنوعی پس از تست به آن ادغام میشوند.
|
||||||
|
</CardHint>
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
|
<Stat label="آخرین نسخهٔ dev" value={runs[0] ? shortSha(runs[0].sha) : "—"} />
|
||||||
|
<Stat label="آخرین بررسی مخزن" value={runs[0] ? when(runs[0].created_at) : "—"} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-xs text-slate-500">
|
||||||
|
کنترل شاخه، پورت و دستورها برای کاربران نمایش داده نمیشود.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={pendingRollback !== null}
|
||||||
|
title="بازگردانی دمو"
|
||||||
|
description={
|
||||||
|
pendingRollback
|
||||||
|
? `دمو به نسخهٔ ${shortSha(pendingRollback.sha)} بازگردانده میشود.`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
confirmLabel={busy ? "در حال انجام…" : "بازگردان"}
|
||||||
|
onConfirm={doRollback}
|
||||||
|
onCancel={() => setPendingRollback(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,468 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import * as React from "react";
|
|
||||||
import type { PreviewDomain, Project, RepoStatus, Repository } from "@agy/contracts";
|
|
||||||
import {
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardHint,
|
|
||||||
CardTitle,
|
|
||||||
ConfirmDialog,
|
|
||||||
EmptyState,
|
|
||||||
ErrorState,
|
|
||||||
Field,
|
|
||||||
LoadingState,
|
|
||||||
Stat,
|
|
||||||
} from "@agy/ui";
|
|
||||||
import { ApiError, api } from "@/lib/api";
|
|
||||||
import { useSession } from "@/lib/session";
|
|
||||||
import { SandboxPanel } from "@/components/sandbox-panel";
|
|
||||||
|
|
||||||
type Tab = "repo" | "preview" | "sandbox";
|
|
||||||
|
|
||||||
const DOMAIN_TONES = {
|
|
||||||
active: "success",
|
|
||||||
disabled: "neutral",
|
|
||||||
failed: "danger",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export default function ProjectDetailPage({
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: Promise<{ id: string }>;
|
|
||||||
}) {
|
|
||||||
const { id: projectId } = React.use(params);
|
|
||||||
const { activeOrganization, user } = useSession();
|
|
||||||
|
|
||||||
const [tab, setTab] = React.useState<Tab>("repo");
|
|
||||||
const [project, setProject] = React.useState<Project | null>(null);
|
|
||||||
const [loading, setLoading] = React.useState(true);
|
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
|
||||||
|
|
||||||
const orgId = activeOrganization?.id ?? null;
|
|
||||||
|
|
||||||
const load = React.useCallback(async () => {
|
|
||||||
if (!orgId) {
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
setProject(await api.getProject(orgId, projectId));
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof ApiError ? err.message : "دریافت پروژه ناموفق بود.");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [orgId, projectId]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
void load();
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
if (!activeOrganization) {
|
|
||||||
return (
|
|
||||||
<EmptyState
|
|
||||||
title="سازمانی انتخاب نشده است"
|
|
||||||
description="برای دیدن جزئیات پروژه ابتدا یک سازمان بسازید."
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) return <LoadingState label="در حال دریافت پروژه…" />;
|
|
||||||
if (error) return <ErrorState message={error} onRetry={load} />;
|
|
||||||
if (!project) {
|
|
||||||
return (
|
|
||||||
<EmptyState
|
|
||||||
title="پروژه پیدا نشد"
|
|
||||||
description="این پروژه حذف شده است یا به سازمان دیگری تعلق دارد."
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="anim-stagger flex flex-col gap-5">
|
|
||||||
<nav className="text-xs text-slate-500">
|
|
||||||
<Link href="/projects" className="transition-colors hover:text-slate-300">
|
|
||||||
پروژهها
|
|
||||||
</Link>
|
|
||||||
<span className="mx-1.5">/</span>
|
|
||||||
<span className="text-slate-400">{project.name}</span>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<header className="flex flex-col gap-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h1 className="text-lg font-medium text-slate-100">{project.name}</h1>
|
|
||||||
<Badge tone="neutral">
|
|
||||||
<span className="ltr-inline">{project.slug}</span>
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
{project.description ? (
|
|
||||||
<p className="text-sm text-slate-400">{project.description}</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-slate-500">توضیحی ثبت نشده است.</p>
|
|
||||||
)}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="flex gap-1 border-b border-surface-border">
|
|
||||||
{(
|
|
||||||
[
|
|
||||||
["repo", "مخزن کد"],
|
|
||||||
["preview", "دامنهٔ تست"],
|
|
||||||
["sandbox", "محیط ایزوله"],
|
|
||||||
] as const
|
|
||||||
).map(([value, label]) => (
|
|
||||||
<button
|
|
||||||
key={value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setTab(value)}
|
|
||||||
className={
|
|
||||||
"border-b-2 px-3 py-2 text-sm transition-colors " +
|
|
||||||
(tab === value
|
|
||||||
? "border-accent text-slate-100"
|
|
||||||
: "border-transparent text-slate-400 hover:text-slate-200")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{tab === "repo" ? (
|
|
||||||
<RepositoryPanel orgId={activeOrganization.id} projectId={project.id} />
|
|
||||||
) : tab === "preview" ? (
|
|
||||||
<PreviewDomainPanel orgId={activeOrganization.id} projectId={project.id} />
|
|
||||||
) : (
|
|
||||||
<SandboxPanel
|
|
||||||
orgId={activeOrganization.id}
|
|
||||||
projectId={project.id}
|
|
||||||
canManageInfra={user?.is_platform_owner ?? false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
/* Repository */
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
function RepositoryPanel({ orgId, projectId }: { orgId: string; projectId: string }) {
|
|
||||||
const [status, setStatus] = React.useState<RepoStatus | null>(null);
|
|
||||||
const [repo, setRepo] = React.useState<Repository | null>(null);
|
|
||||||
const [loading, setLoading] = React.useState(true);
|
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
|
||||||
const [creating, setCreating] = React.useState(false);
|
|
||||||
const [copied, setCopied] = React.useState(false);
|
|
||||||
|
|
||||||
const load = React.useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const nextStatus = await api.getRepoStatus(orgId, projectId);
|
|
||||||
setStatus(nextStatus);
|
|
||||||
if (nextStatus.exists) {
|
|
||||||
// The status probe only reports shape; the detail call carries the URLs.
|
|
||||||
setRepo(await api.getRepo(orgId, projectId));
|
|
||||||
} else {
|
|
||||||
setRepo(null);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof ApiError ? err.message : "دریافت وضعیت مخزن ناموفق بود.");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [orgId, projectId]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
void load();
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const onEnsure = async () => {
|
|
||||||
setCreating(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
setRepo(await api.ensureRepo(orgId, projectId));
|
|
||||||
setStatus(await api.getRepoStatus(orgId, projectId));
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof ApiError ? err.message : "ساخت مخزن ناموفق بود.");
|
|
||||||
} finally {
|
|
||||||
setCreating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCopy = async () => {
|
|
||||||
if (!repo?.clone_url) return;
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(repo.clone_url);
|
|
||||||
setCopied(true);
|
|
||||||
window.setTimeout(() => setCopied(false), 1600);
|
|
||||||
} catch {
|
|
||||||
/* clipboard blocked — the URL is visible and selectable anyway */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) return <LoadingState label="در حال بررسی مخزن…" />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{error && <ErrorState message={error} onRetry={load} />}
|
|
||||||
|
|
||||||
{!status?.exists ? (
|
|
||||||
<Card>
|
|
||||||
<CardTitle>مخزنی وجود ندارد</CardTitle>
|
|
||||||
<CardHint>
|
|
||||||
یک مخزن خصوصی Git برای این پروژه ساخته میشود و اعتبارنامهٔ آن بهصورت رمزنگاریشده
|
|
||||||
ذخیره میگردد. کلید خام هرگز از طریق API برگردانده نمیشود.
|
|
||||||
</CardHint>
|
|
||||||
<div className="mt-4">
|
|
||||||
<Button onClick={onEnsure} loading={creating}>
|
|
||||||
ساخت مخزن خصوصی
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<Stat
|
|
||||||
label="وضعیت"
|
|
||||||
value={<Badge tone="success">فعال</Badge>}
|
|
||||||
hint={status.private ? "مخزن خصوصی" : "مخزن عمومی"}
|
|
||||||
/>
|
|
||||||
<Stat label="شاخهٔ پیشفرض" value={<span className="ltr-inline">{status.default_branch}</span>} />
|
|
||||||
<Stat label="تعداد شاخهها" value={status.branches.length} />
|
|
||||||
<Stat label="درخواستهای ادغام باز" value={status.open_pull_requests} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardTitle>دسترسی</CardTitle>
|
|
||||||
<CardHint>از این آدرس برای clone استفاده کنید؛ اعتبارنامه بهصورت خودکار تزریق میشود.</CardHint>
|
|
||||||
<div className="mt-3 flex flex-col gap-2">
|
|
||||||
<div className="flex items-center gap-2 rounded-lg border border-surface-border bg-surface-overlay px-3 py-2">
|
|
||||||
<code className="ltr-inline flex-1 truncate text-xs text-slate-300">
|
|
||||||
{repo?.clone_url ?? "—"}
|
|
||||||
</code>
|
|
||||||
<Button variant="ghost" size="sm" onClick={onCopy} disabled={!repo?.clone_url}>
|
|
||||||
{copied ? "کپی شد" : "کپی"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{repo?.web_url && (
|
|
||||||
<a
|
|
||||||
href={repo.web_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer noopener"
|
|
||||||
className="ltr-inline w-fit text-xs text-accent hover:underline"
|
|
||||||
>
|
|
||||||
{repo.web_url}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
نام مخزن: <span className="ltr-inline">{repo?.repo_name ?? "—"}</span> · مالک:{" "}
|
|
||||||
<span className="ltr-inline">{repo?.owner ?? "—"}</span> · ارائهدهنده:{" "}
|
|
||||||
<span className="ltr-inline">{repo?.provider ?? "—"}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardTitle>شاخهها</CardTitle>
|
|
||||||
{status.branches.length === 0 ? (
|
|
||||||
<p className="mt-2 text-xs text-slate-500">شاخهای ثبت نشده است.</p>
|
|
||||||
) : (
|
|
||||||
<ul className="mt-3 flex flex-wrap gap-2">
|
|
||||||
{status.branches.map((branch) => (
|
|
||||||
<li key={branch}>
|
|
||||||
<Badge tone={branch === status.default_branch ? "info" : "neutral"}>
|
|
||||||
<span className="ltr-inline">{branch}</span>
|
|
||||||
</Badge>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
/* Preview domains */
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
function PreviewDomainPanel({ orgId, projectId }: { orgId: string; projectId: string }) {
|
|
||||||
const [domains, setDomains] = React.useState<PreviewDomain[]>([]);
|
|
||||||
const [loading, setLoading] = React.useState(true);
|
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
|
||||||
const [subdomain, setSubdomain] = React.useState("");
|
|
||||||
const [port, setPort] = React.useState("");
|
|
||||||
const [creating, setCreating] = React.useState(false);
|
|
||||||
const [formError, setFormError] = React.useState<string | null>(null);
|
|
||||||
const [pendingDelete, setPendingDelete] = React.useState<PreviewDomain | null>(null);
|
|
||||||
const [deleting, setDeleting] = React.useState(false);
|
|
||||||
|
|
||||||
const load = React.useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
setDomains(await api.listPreviewDomains(orgId, projectId));
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof ApiError ? err.message : "دریافت دامنهها ناموفق بود.");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [orgId, projectId]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
void load();
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const onCreate = async (event: React.FormEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setFormError(null);
|
|
||||||
const parsedPort = port.trim() === "" ? undefined : Number(port);
|
|
||||||
if (parsedPort !== undefined && (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535)) {
|
|
||||||
setFormError("شمارهٔ پورت باید عددی بین ۱ تا ۶۵۵۳۵ باشد.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCreating(true);
|
|
||||||
try {
|
|
||||||
const created = await api.createPreviewDomain(orgId, projectId, {
|
|
||||||
subdomain: subdomain.trim(),
|
|
||||||
port: parsedPort,
|
|
||||||
});
|
|
||||||
setDomains((current) => [...current, created]);
|
|
||||||
setSubdomain("");
|
|
||||||
setPort("");
|
|
||||||
} catch (err) {
|
|
||||||
setFormError(err instanceof ApiError ? err.message : "ساخت دامنه ناموفق بود.");
|
|
||||||
} finally {
|
|
||||||
setCreating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onConfirmDelete = async () => {
|
|
||||||
if (!pendingDelete) return;
|
|
||||||
setDeleting(true);
|
|
||||||
try {
|
|
||||||
await api.deletePreviewDomain(orgId, projectId, pendingDelete.id);
|
|
||||||
setDomains((current) => current.filter((d) => d.id !== pendingDelete.id));
|
|
||||||
setPendingDelete(null);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof ApiError ? err.message : "حذف دامنه ناموفق بود.");
|
|
||||||
setPendingDelete(null);
|
|
||||||
} finally {
|
|
||||||
setDeleting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<Card>
|
|
||||||
<CardTitle>ساخت دامنهٔ تست</CardTitle>
|
|
||||||
<CardHint>
|
|
||||||
برچسب زیردامنه باید بین ۳ تا ۴۰ نویسه و فقط شامل حروف کوچک انگلیسی، رقم و خط تیره باشد.
|
|
||||||
اگر پورت را خالی بگذارید، سامانه خودش یک پورت آزاد تخصیص میدهد.
|
|
||||||
</CardHint>
|
|
||||||
<form onSubmit={onCreate} className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end">
|
|
||||||
<div className="flex-1">
|
|
||||||
<Field
|
|
||||||
label="زیردامنه"
|
|
||||||
required
|
|
||||||
minLength={3}
|
|
||||||
maxLength={40}
|
|
||||||
pattern="[a-z0-9-]+"
|
|
||||||
value={subdomain}
|
|
||||||
onChange={(e) => setSubdomain(e.target.value)}
|
|
||||||
placeholder="مثلاً demo-shop"
|
|
||||||
className="ltr-inline"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-full sm:w-40">
|
|
||||||
<Field
|
|
||||||
label="پورت (اختیاری)"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={port}
|
|
||||||
onChange={(e) => setPort(e.target.value)}
|
|
||||||
placeholder="۸۰۸۰"
|
|
||||||
className="ltr-inline"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button type="submit" loading={creating}>
|
|
||||||
ساخت دامنه
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
{formError && (
|
|
||||||
<div className="mt-3">
|
|
||||||
<ErrorState title="ساخت دامنه ناموفق" message={formError} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{error && <ErrorState message={error} onRetry={load} />}
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<LoadingState label="در حال دریافت دامنهها…" />
|
|
||||||
) : domains.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
title="دامنهای ثبت نشده است"
|
|
||||||
description="برای انتشار موقت پروژه، یک زیردامنه بسازید."
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="anim-stagger grid gap-3 md:grid-cols-2">
|
|
||||||
{domains.map((domain) => (
|
|
||||||
<Card key={domain.id} className="flex flex-col gap-3">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="flex min-w-0 flex-col gap-1">
|
|
||||||
<p className="ltr-inline truncate text-sm font-medium text-slate-100">
|
|
||||||
{domain.host}
|
|
||||||
</p>
|
|
||||||
<p className="ltr-inline text-xs text-slate-500">پورت {domain.port}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 items-center gap-1.5">
|
|
||||||
<Badge tone={DOMAIN_TONES[domain.status] ?? "neutral"}>{domain.status}</Badge>
|
|
||||||
{domain.protected && <Badge tone="warning">محافظتشده</Badge>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
href={domain.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer noopener"
|
|
||||||
className="ltr-inline w-fit truncate text-xs text-accent hover:underline"
|
|
||||||
>
|
|
||||||
{domain.url}
|
|
||||||
</a>
|
|
||||||
<div className="mt-auto flex justify-end">
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPendingDelete(domain)}
|
|
||||||
disabled={domain.protected}
|
|
||||||
title={domain.protected ? "دامنههای محافظتشده حذف نمیشوند." : undefined}
|
|
||||||
>
|
|
||||||
حذف
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={pendingDelete !== null}
|
|
||||||
title="حذف دامنهٔ تست"
|
|
||||||
description={
|
|
||||||
pendingDelete
|
|
||||||
? `دامنهٔ «${pendingDelete.host}» حذف میشود و از این پس در دسترس نخواهد بود.`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
confirmLabel={deleting ? "در حال حذف…" : "حذف کن"}
|
|
||||||
onConfirm={onConfirmDelete}
|
|
||||||
onCancel={() => setPendingDelete(null)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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).
|
* Typed client functions for the delivery workstream (docs/plan/PLAN-v2.md §6).
|
||||||
*
|
*
|
||||||
* Add functions to this object only; ``lib/api.ts`` is locked. Use ``apiRequest`` for transport.
|
* Add functions to this object only; ``lib/api.ts`` is locked. Use ``apiRequest`` for transport.
|
||||||
|
*
|
||||||
|
* Every call travels over the authenticated WebSocket RPC (see ``lib/api.ts``), so
|
||||||
|
* these are plain functions with no transport concerns of their own.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AppRuntime,
|
||||||
|
DomainRoute,
|
||||||
|
DomainSyncResult,
|
||||||
|
PipelineRun,
|
||||||
|
PipelineRunDetail,
|
||||||
|
ProductionDeploy,
|
||||||
|
ProductionOverview,
|
||||||
|
Release,
|
||||||
|
SuggestedRoute,
|
||||||
|
} from "@agy/contracts";
|
||||||
import { apiRequest } from "./api";
|
import { apiRequest } from "./api";
|
||||||
|
|
||||||
|
function orgBase(orgId: string): string {
|
||||||
|
return `/api/organizations/${orgId}/delivery`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function query(params: Record<string, string | number | undefined>): string {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value !== undefined && value !== "") search.set(key, String(value));
|
||||||
|
}
|
||||||
|
const text = search.toString();
|
||||||
|
return text ? `?${text}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
export const deliveryApi = {
|
export const deliveryApi = {
|
||||||
// example: list: (orgId: string) => apiRequest<Foo[]>(`/api/organizations/${orgId}/foo`),
|
// -- CI/CD runs ----------------------------------------------------------
|
||||||
} satisfies Record<string, (...args: never[]) => ReturnType<typeof apiRequest>>;
|
|
||||||
|
listRuns: (orgId: string, projectId: string, limit = 50) =>
|
||||||
|
apiRequest<PipelineRun[]>(
|
||||||
|
`${orgBase(orgId)}/runs${query({ project_id: projectId, limit })}`,
|
||||||
|
),
|
||||||
|
|
||||||
|
getRun: (orgId: string, runId: string) =>
|
||||||
|
apiRequest<PipelineRunDetail>(`${orgBase(orgId)}/runs/${runId}`),
|
||||||
|
|
||||||
|
/** Re-run the current `dev` head. Never a production deploy. */
|
||||||
|
startRun: (orgId: string, projectId: string) =>
|
||||||
|
apiRequest<PipelineRun>(`${orgBase(orgId)}/runs`, {
|
||||||
|
method: "POST",
|
||||||
|
body: { project_id: projectId },
|
||||||
|
}),
|
||||||
|
|
||||||
|
cancelRun: (orgId: string, runId: string) =>
|
||||||
|
apiRequest<PipelineRun>(`${orgBase(orgId)}/runs/${runId}/cancel`, { method: "POST" }),
|
||||||
|
|
||||||
|
// -- releases & the demo runtime ----------------------------------------
|
||||||
|
|
||||||
|
listReleases: (orgId: string, projectId: string) =>
|
||||||
|
apiRequest<Release[]>(`${orgBase(orgId)}/releases${query({ project_id: projectId })}`),
|
||||||
|
|
||||||
|
getRuntime: (orgId: string, projectId: string) =>
|
||||||
|
apiRequest<AppRuntime>(`${orgBase(orgId)}/runtime${query({ project_id: projectId })}`),
|
||||||
|
|
||||||
|
rollbackDemo: (orgId: string, projectId: string, releaseId?: string) =>
|
||||||
|
apiRequest<Release>(`${orgBase(orgId)}/releases/rollback`, {
|
||||||
|
method: "POST",
|
||||||
|
body: { project_id: projectId, release_id: releaseId },
|
||||||
|
}),
|
||||||
|
|
||||||
|
// -- production (admin+, always confirmed) ------------------------------
|
||||||
|
|
||||||
|
productionOverview: (orgId: string, projectId: string) =>
|
||||||
|
apiRequest<ProductionOverview>(
|
||||||
|
`${orgBase(orgId)}/production${query({ project_id: projectId })}`,
|
||||||
|
),
|
||||||
|
|
||||||
|
testProductionTarget: (orgId: string, projectId: string) =>
|
||||||
|
apiRequest<{ ok: boolean; message: string }>(`${orgBase(orgId)}/production/test`, {
|
||||||
|
method: "POST",
|
||||||
|
body: { project_id: projectId },
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ship a release to production.
|
||||||
|
*
|
||||||
|
* ``confirm`` must be the organization's slug typed back by the operator — the
|
||||||
|
* server rejects anything else. There is no way to skip it.
|
||||||
|
*/
|
||||||
|
deployToProduction: (orgId: string, projectId: string, confirm: string, releaseId?: string) =>
|
||||||
|
apiRequest<ProductionDeploy>(`${orgBase(orgId)}/production/deploy`, {
|
||||||
|
method: "POST",
|
||||||
|
body: { project_id: projectId, confirm, release_id: releaseId },
|
||||||
|
}),
|
||||||
|
|
||||||
|
rollbackProduction: (orgId: string, projectId: string, confirm: string, deployId?: string) =>
|
||||||
|
apiRequest<ProductionDeploy>(`${orgBase(orgId)}/production/rollback`, {
|
||||||
|
method: "POST",
|
||||||
|
body: { project_id: projectId, confirm, deploy_id: deployId },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Platform-owner domain console. */
|
||||||
|
export const domainsApi = {
|
||||||
|
list: () => apiRequest<DomainRoute[]>("/api/admin/domains"),
|
||||||
|
|
||||||
|
suggested: () => apiRequest<SuggestedRoute[]>("/api/admin/domains/suggested"),
|
||||||
|
|
||||||
|
attach: (organizationId: string, projectId: string, host: string) =>
|
||||||
|
apiRequest<DomainRoute>("/api/admin/domains", {
|
||||||
|
method: "POST",
|
||||||
|
body: { organization_id: organizationId, project_id: projectId, host },
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Detaching requires the host typed back — it is what takes a URL offline. */
|
||||||
|
detach: (domainId: string, confirm: string) =>
|
||||||
|
apiRequest<void>(`/api/admin/domains/${domainId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
body: { confirm },
|
||||||
|
}),
|
||||||
|
|
||||||
|
sync: () => apiRequest<DomainSyncResult>("/api/admin/domains/sync", { method: "POST" }),
|
||||||
|
};
|
||||||
|
|||||||
@@ -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"
|
| "billing.wallet"
|
||||||
| "error.recorded"
|
| "error.recorded"
|
||||||
| "audit.recorded"
|
| "audit.recorded"
|
||||||
| "worker.status";
|
| "worker.status"
|
||||||
|
// v2 delivery: CI/CD runs, releases and production deploys (org channel).
|
||||||
|
| "pipeline.queued"
|
||||||
|
| "pipeline.step"
|
||||||
|
| "pipeline.log"
|
||||||
|
| "pipeline.status"
|
||||||
|
| "release.live"
|
||||||
|
| "deploy.status";
|
||||||
|
|
||||||
/** One pushed event, as the browser receives it. */
|
/** One pushed event, as the browser receives it. */
|
||||||
export interface WsEvent<T = Record<string, unknown>> {
|
export interface WsEvent<T = Record<string, unknown>> {
|
||||||
@@ -860,3 +867,195 @@ export interface WsSandboxCommandData {
|
|||||||
duration_ms: number;
|
duration_ms: number;
|
||||||
truncated: boolean;
|
truncated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* v2 delivery (CI/CD, demo runtime, production deploy) */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export type PipelineRunStatus =
|
||||||
|
| "queued"
|
||||||
|
| "running"
|
||||||
|
| "succeeded"
|
||||||
|
| "failed"
|
||||||
|
| "cancelled"
|
||||||
|
| "superseded";
|
||||||
|
|
||||||
|
export type PipelineStepStatus = "pending" | "running" | "succeeded" | "failed" | "skipped";
|
||||||
|
|
||||||
|
/** The eight steps of a run, in the order the server executes them. */
|
||||||
|
export const PIPELINE_STEP_NAMES = [
|
||||||
|
"checkout",
|
||||||
|
"setup",
|
||||||
|
"build",
|
||||||
|
"test",
|
||||||
|
"package",
|
||||||
|
"deploy_demo",
|
||||||
|
"healthcheck",
|
||||||
|
"route",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export interface PipelineStep {
|
||||||
|
id: string;
|
||||||
|
index: number;
|
||||||
|
name: string;
|
||||||
|
status: PipelineStepStatus;
|
||||||
|
exit_code: number | null;
|
||||||
|
log_tail: string;
|
||||||
|
duration_ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PipelineRun {
|
||||||
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
branch: string;
|
||||||
|
sha: string;
|
||||||
|
trigger: "engine" | "poll" | "manual";
|
||||||
|
status: PipelineRunStatus;
|
||||||
|
error_summary: string;
|
||||||
|
started_at: string | null;
|
||||||
|
finished_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PipelineRunDetail extends PipelineRun {
|
||||||
|
steps: PipelineStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Release {
|
||||||
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
run_id: string;
|
||||||
|
sha: string;
|
||||||
|
is_demo_live: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppRuntime {
|
||||||
|
project_id: string;
|
||||||
|
demo_host: string;
|
||||||
|
demo_url: string;
|
||||||
|
host_port: number | null;
|
||||||
|
status: "provisioning" | "live" | "degraded" | "stopped";
|
||||||
|
current_release_id: string | null;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProductionDeployStatus =
|
||||||
|
| "queued"
|
||||||
|
| "running"
|
||||||
|
| "succeeded"
|
||||||
|
| "failed"
|
||||||
|
| "rolled_back";
|
||||||
|
|
||||||
|
/** The production target as a *user* sees it: never a secret, only a hint. */
|
||||||
|
export interface ProductionTarget {
|
||||||
|
kind: "ssh" | "sftp" | "ftp" | "ftps";
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
auth_type: "password" | "key";
|
||||||
|
remote_path: string;
|
||||||
|
has_secret: boolean;
|
||||||
|
host_fingerprint: string | null;
|
||||||
|
last_tested_at: string | null;
|
||||||
|
last_test_ok: boolean | null;
|
||||||
|
last_test_message: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductionDeploy {
|
||||||
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
kind: string;
|
||||||
|
host: string;
|
||||||
|
status: ProductionDeployStatus;
|
||||||
|
message: string;
|
||||||
|
log_tail: string;
|
||||||
|
release_id: string | null;
|
||||||
|
rolled_back_from: string | null;
|
||||||
|
started_at: string | null;
|
||||||
|
finished_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductionOverview {
|
||||||
|
target: ProductionTarget | null;
|
||||||
|
deploys: ProductionDeploy[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Owner console: one managed preview route. */
|
||||||
|
export interface DomainRoute {
|
||||||
|
id: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
status: string;
|
||||||
|
protected: boolean;
|
||||||
|
url: string;
|
||||||
|
organization_id: string;
|
||||||
|
project_id: string;
|
||||||
|
project_name: string;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuggestedRoute {
|
||||||
|
label: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
description: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DomainSyncResult {
|
||||||
|
ok: boolean;
|
||||||
|
message: string;
|
||||||
|
routes: number;
|
||||||
|
changed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- live event payloads (channel `org:<id>`) ------------------------------ */
|
||||||
|
|
||||||
|
export interface WsPipelineLogData {
|
||||||
|
run_id: string;
|
||||||
|
step: string;
|
||||||
|
lines: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WsPipelineStepData {
|
||||||
|
run_id: string;
|
||||||
|
step: string;
|
||||||
|
index: number;
|
||||||
|
status: PipelineStepStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WsPipelineStatusData {
|
||||||
|
run_id: string;
|
||||||
|
project_id: string;
|
||||||
|
status: PipelineRunStatus;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WsPipelineQueuedData {
|
||||||
|
run_id: string;
|
||||||
|
project_id: string;
|
||||||
|
sha: string;
|
||||||
|
trigger: string;
|
||||||
|
superseded: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WsReleaseLiveData {
|
||||||
|
project_id: string;
|
||||||
|
run_id?: string;
|
||||||
|
release_id?: string;
|
||||||
|
sha?: string;
|
||||||
|
demo_host?: string;
|
||||||
|
demo_url?: string;
|
||||||
|
rolled_back?: boolean;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WsDeployStatusData {
|
||||||
|
project_id: string;
|
||||||
|
deploy_id: string;
|
||||||
|
kind: string;
|
||||||
|
status: ProductionDeployStatus;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user