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

The user-visible payoff of the whole delivery workstream.

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

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

Report: docs/plan/reports/agent-3-phase-6.md
This commit is contained in:
mjalmoudy94
2026-10-14 02:03:38 +03:30
parent 72408a0a4f
commit 3c8296e354
15 changed files with 2462 additions and 659 deletions
+9
View File
@@ -6,6 +6,15 @@ const nextConfig = {
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL ?? "",
},
// v2: `/projects` is gone — one organization has exactly one application, so
// there is no list to browse. A permanent redirect keeps old bookmarks (and
// the browser history of anyone mid-flow) working instead of 404ing.
async redirects() {
return [
{ source: "/projects", destination: "/app", permanent: true },
{ source: "/projects/:path*", destination: "/app", permanent: true },
];
},
async rewrites() {
return [
{
+320 -10
View File
@@ -1,17 +1,327 @@
"use client";
import { PlaceholderPage } from "@/components/PlaceholderPage";
/**
* The platform owner's domain console (D9).
*
* A tenant gets one automatic demo host and nothing else. Everything here is the
* owner alone: attach another host, detach one (which is what takes a URL offline,
* so it needs the host typed back), and converge Caddy on the database.
*
* The re-sync button is the interesting one. It is safe to press at any time
* because the server compares the rendered routes against what is on disk and
* **reloads only when they differ** — a reload drops every live connection on the
* host, including tenants' demos, so doing nothing when nothing changed is the
* whole point. The `changed` flag the server returns is shown to the operator so
* the result is visible rather than implied.
*/
import * as React from "react";
import type { DomainRoute, SuggestedRoute } from "@agy/contracts";
import {
Badge,
Button,
Card,
CardHint,
CardTitle,
ConfirmDialog,
EmptyState,
ErrorState,
Field,
LoadingState,
Table,
type TableColumn,
} from "@agy/ui";
import { ApiError } from "@/lib/api";
import { domainsApi } from "@/lib/api-delivery";
interface Row {
id: string;
host: string;
project: string;
port: string;
status: string;
protected: string;
route: DomainRoute;
}
function columns(onDetach: (route: DomainRoute) => void): Array<TableColumn<Row>> {
return [
{
key: "host",
header: "دامنه",
className: "ltr-inline",
render: (row) => <span className="text-slate-200">{row.host}</span>,
},
{
key: "project",
header: "اپلیکیشن",
render: (row) => <span className="text-slate-300">{row.project}</span>,
},
{
key: "port",
header: "پورت",
className: "ltr-inline",
render: (row) => <span className="text-slate-400">{row.port}</span>,
},
{
key: "status",
header: "وضعیت",
render: (row) => (
<Badge tone={row.status === "active" ? "success" : "neutral"}>{row.status}</Badge>
),
},
{
key: "protected",
header: "محافظت‌شده",
render: (row) => <span className="text-slate-400">{row.protected}</span>,
},
{
key: "actions",
header: "عملیات",
render: (row) => (
<Button
size="sm"
variant="danger"
onClick={() => onDetach(row.route)}
disabled={row.protected === "بله"}
>
حذف
</Button>
),
},
];
}
export default function AdminDomainsPage() {
const [routes, setRoutes] = React.useState<DomainRoute[] | null>(null);
const [suggested, setSuggested] = React.useState<SuggestedRoute[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [busy, setBusy] = React.useState(false);
const [notice, setNotice] = React.useState<string | null>(null);
const [host, setHost] = React.useState("");
const [pendingDetach, setPendingDetach] = React.useState<DomainRoute | null>(null);
const [typed, setTyped] = React.useState("");
const load = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
const [nextRoutes, nextSuggested] = await Promise.all([
domainsApi.list(),
domainsApi.suggested().catch(() => []),
]);
setRoutes(nextRoutes);
setSuggested(nextSuggested);
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت فهرست دامنه‌ها ناموفق بود.");
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
void load();
}, [load]);
const attach = async () => {
const target = host.trim();
if (!target) return;
setBusy(true);
setNotice(null);
try {
// The owner attaches a host to a project, and the server points it at the
// port the app already serves on. The project is picked from the routes
// already registered, so this console cannot attach a host to an app that
// has no port at all.
const listed = await domainsApi.list();
const first = listed[0];
if (!first) {
setNotice("هنوز اپلیکیشنی با مسیر فعال وجود ندارد.");
return;
}
await domainsApi.attach(first.organization_id, first.project_id, target);
setHost("");
setNotice(`دامنهٔ ${target} اضافه شد.`);
await load();
} catch (err) {
setNotice(err instanceof ApiError ? err.message : "افزودن دامنه ناموفق بود.");
} finally {
setBusy(false);
}
};
const detach = async () => {
if (!pendingDetach) return;
setBusy(true);
try {
await domainsApi.detach(pendingDetach.id, typed.trim());
setNotice(`دامنهٔ ${pendingDetach.host} حذف شد.`);
setPendingDetach(null);
setTyped("");
await load();
} catch (err) {
setNotice(err instanceof ApiError ? err.message : "حذف دامنه ناموفق بود.");
} finally {
setBusy(false);
}
};
const sync = async () => {
setBusy(true);
setNotice(null);
try {
const result = await domainsApi.sync();
setNotice(
result.changed
? `${result.message} (${result.routes} مسیر)`
: "چیزی تغییر نکرده بود؛ Caddy بارگذاری مجدد نشد.",
);
await load();
} catch (err) {
setNotice(err instanceof ApiError ? err.message : "همگام‌سازی ناموفق بود.");
} finally {
setBusy(false);
}
};
if (loading) return <LoadingState label="در حال دریافت دامنه‌ها…" />;
if (error) return <ErrorState message={error} onRetry={load} />;
const rows: Row[] = (routes ?? []).map((route) => ({
id: route.id,
host: route.host,
project: route.project_name || route.project_id,
port: String(route.port),
status: route.status,
protected: route.protected ? "بله" : "خیر",
route,
}));
return (
<PlaceholderPage
title="دامنه‌ها"
description="مسیرهای Caddy و دامنه‌های اپلیکیشن‌ها."
planned={[
"فهرست مسیرها",
"افزودن و حذف دامنه",
"همگام‌سازی Caddy",
]}
/>
<div className="flex flex-col gap-4">
{notice && (
<Card>
<p className="text-sm text-slate-300">{notice}</p>
</Card>
)}
<Card>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle>دامنه‌های فعال</CardTitle>
<CardHint>
هر اپلیکیشن یک دامنهٔ دموی خودکار دارد. افزودن دامنهٔ دیگر فقط با مالک سامانه
ممکن است؛ کاربران نمی‌توانند دامنه اضافه کنند.
</CardHint>
</div>
<Button variant="secondary" onClick={sync} loading={busy}>
همگام‌سازی Caddy
</Button>
</div>
{rows.length === 0 ? (
<div className="mt-4">
<EmptyState title="مسیری ثبت نشده است" description="هنوز هیچ اپلیکیشنی دمو ندارد." />
</div>
) : (
<div className="mt-4">
<Table
columns={columns((route) => setPendingDetach(route))}
rows={rows}
rowKey={(row) => row.id}
caption="مسیرهای دامنهٔ سامانه"
/>
</div>
)}
</Card>
<Card>
<CardTitle>افزودن دامنه</CardTitle>
<CardHint>
یک برچسب زیر دامنهٔ سکو (مثل{" "}
<span className="ltr-inline">shop.msa.artacloud.ir</span>) یا، اگر فعال باشد، یک
دامنهٔ سفارشی کامل. دامنهٔ wildcard مجاز نیست.
</CardHint>
<form
className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-end"
onSubmit={(event) => {
event.preventDefault();
void attach();
}}
>
<div className="flex-1">
<Field
label="نام دامنه"
value={host}
onChange={(event) => setHost(event.target.value)}
placeholder="shop.msa.artacloud.ir"
className="ltr-inline"
/>
</div>
<Button type="submit" loading={busy} disabled={!host.trim()}>
افزودن
</Button>
</form>
</Card>
{suggested.length > 0 && (
<Card>
<CardTitle>مسیرهای پیشنهادی سامانه</CardTitle>
<CardHint>
این مسیرها سرویس‌های خود سامانه‌اند، نه اپلیکیشن کاربر. پیش از فعال‌سازی، رکورد
DNS و گواهی TLS باید آماده باشد.
</CardHint>
<ul className="mt-3 flex flex-col gap-2">
{suggested.map((route) => (
<li
key={route.label}
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-surface-border px-3 py-3"
>
<div className="flex min-w-0 flex-col gap-1">
<div className="flex items-center gap-2">
<span className="ltr-inline text-sm text-slate-200">{route.host}</span>
<Badge tone="neutral">پورت {route.port}</Badge>
</div>
<p className="text-xs text-slate-500">{route.description}</p>
</div>
{route.enabled ? <Badge tone="success">فعال</Badge> : (
<span className="text-xs text-slate-500">غیرفعال</span>
)}
</li>
))}
</ul>
</Card>
)}
<ConfirmDialog
open={pendingDetach !== null}
title="حذف دامنه"
description={
pendingDetach
? `با حذف این مسیر، آدرس ${pendingDetach.host} از کار می‌افتد. برای تأیید، نام دامنه را تایپ کنید.`
: undefined
}
confirmLabel={busy ? "در حال حذف…" : "حذف کن"}
onConfirm={detach}
onCancel={() => {
setPendingDetach(null);
setTyped("");
}}
/>
{pendingDetach && (
<Card>
<Field
label="نام دامنه را برای تأیید تایپ کنید"
value={typed}
onChange={(event) => setTyped(event.target.value)}
placeholder={pendingDetach.host}
className="ltr-inline"
/>
</Card>
)}
</div>
);
}
+237 -10
View File
@@ -1,17 +1,244 @@
"use client";
import { PlaceholderPage } from "@/components/PlaceholderPage";
/**
* The platform owner's sandbox console (D5).
*
* Containers are infrastructure, and in v2 infrastructure is the owner's alone.
* This page therefore shows *everything* running on the host that carries our
* labels — workspace, demo and build containers, across every organization —
* because the person who needs to see them is the one responsible for the host.
*
* Stopping a container is destructive and needs the id typed back: it is the only
* way to make a misclick land on a confirmation rather than on a running demo.
* The provider is asked, not assumed: a mock or an adapter without the capability
* reports an empty list rather than inventing one.
*/
import * as React from "react";
import {
Badge,
Button,
Card,
CardHint,
CardTitle,
ConfirmDialog,
EmptyState,
ErrorState,
Field,
LoadingState,
Stat,
} from "@agy/ui";
import { apiRequest } from "@/lib/api";
interface ContainerRow {
id: string;
name: string;
status: string;
organization_id: string;
project_id: string;
role: string;
}
interface SandboxSummary {
containers: ContainerRow[];
limits: Array<{
organization_id: string;
cpu: number;
memory_mb: number;
disk_mb: number;
timeout_seconds: number;
network_policy: string;
}>;
images: Record<string, string>;
}
const ROLE_LABELS: Record<string, string> = {
workspace: "فضای کار کارمندان",
demo: "نمایش زنده",
build: "ساخت",
};
export default function AdminSandboxPage() {
const [summary, setSummary] = React.useState<SandboxSummary | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [notice, setNotice] = React.useState<string | null>(null);
const [pending, setPending] = React.useState<ContainerRow | null>(null);
const [typed, setTyped] = React.useState("");
const [busy, setBusy] = React.useState(false);
const load = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
setSummary(await apiRequest<SandboxSummary>("/api/admin/sandbox/containers"));
} catch (err) {
// The response body carries a plain Persian message; anything else is ours.
setError(err instanceof Error ? err.message : "دریافت وضعیت کانتینرها ناموفق بود.");
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
void load();
}, [load]);
const stop = async () => {
if (!pending) return;
setBusy(true);
try {
await apiRequest<void>(`/api/admin/sandbox/containers/${pending.id}/stop`, {
method: "POST",
body: { confirm: typed.trim() },
});
setNotice(`کانتینر ${pending.id.slice(0, 12)} متوقف شد.`);
setPending(null);
setTyped("");
await load();
} catch (err) {
setNotice(err instanceof Error ? err.message : "توقف کانتینر ناموفق بود.");
} finally {
setBusy(false);
}
};
if (loading) return <LoadingState label="در حال دریافت کانتینرها…" />;
if (error) return <ErrorState message={error} onRetry={load} />;
if (!summary) return null;
const byRole = summary.containers.reduce<Record<string, number>>((accumulator, row) => {
accumulator[row.role] = (accumulator[row.role] ?? 0) + 1;
return accumulator;
}, {});
return (
<PlaceholderPage
title="سندباکس‌ها"
description="کانتینرهای در حال اجرا و محدودیت‌های منابع."
planned={[
"کانتینرهای هر سازمان",
"محدودیت CPU و حافظه",
"توقف و راه‌اندازی مجدد",
]}
/>
<div className="flex flex-col gap-4">
{notice && (
<Card>
<p className="text-sm text-slate-300">{notice}</p>
</Card>
)}
<Card>
<CardTitle>کانتینرهای در حال اجرا</CardTitle>
<CardHint>
هر اپلیکیشن یک کانتینر فضای کار (کارمندان در آن کد می‌نویسند) و یک کانتینر
نمایش زنده دارد. کانتینرهای ساخت موقت‌اند و پس از هر اجرا حذف می‌شوند.
</CardHint>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Stat label="فضای کار" value={String(byRole.workspace ?? 0)} />
<Stat label="نمایش زنده" value={String(byRole.demo ?? 0)} />
<Stat label="ساخت" value={String(byRole.build ?? 0)} />
<Stat label="مجموع" value={String(summary.containers.length)} />
</div>
{summary.containers.length === 0 ? (
<div className="mt-4">
<EmptyState
title="کانتینر فعالی وجود ندارد"
description="اگر ارائه‌دهندهٔ سندباکس روی docker تنظیم باشد، کانتینرها اینجا دیده می‌شوند."
/>
</div>
) : (
<ul className="mt-4 flex flex-col gap-2">
{summary.containers.map((row) => (
<li
key={row.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-surface-border px-3 py-3"
>
<div className="flex min-w-0 flex-col gap-1">
<div className="flex items-center gap-2">
<Badge tone={row.status.startsWith("Up") ? "success" : "neutral"}>
{row.status}
</Badge>
<span className="ltr-inline truncate text-xs text-slate-400">{row.name}</span>
</div>
<p className="text-xs text-slate-500">
{ROLE_LABELS[row.role] ?? row.role} ·{" "}
<span className="ltr-inline">{row.organization_id.slice(0, 8)}</span>
</p>
</div>
<Button size="sm" variant="danger" onClick={() => setPending(row)}>
توقف
</Button>
</li>
))}
</ul>
)}
</Card>
<Card>
<CardTitle>محدودیت منابع سازمان‌ها</CardTitle>
<CardHint>
این مقادیر از صفحهٔ پروژه برای هر سازمان تنظیم می‌شوند و در سطح سرویس همیشه
محدود نگه داشته می‌شوند.
</CardHint>
{summary.limits.length === 0 ? (
<p className="mt-3 text-xs text-slate-500">محدودیت سفارشی‌ای ثبت نشده است.</p>
) : (
<ul className="mt-3 flex flex-col gap-2">
{summary.limits.map((row) => (
<li
key={row.organization_id}
className="grid gap-3 rounded-lg border border-surface-border px-3 py-3 sm:grid-cols-4"
>
<Stat label="سازمان" value={row.organization_id.slice(0, 8)} />
<Stat label="CPU" value={`${row.cpu} هسته`} />
<Stat label="حافظه" value={`${row.memory_mb} مگابایت`} />
<Stat label="دیسک" value={`${row.disk_mb} مگابایت`} />
</li>
))}
</ul>
)}
</Card>
<Card>
<CardTitle>نگاشت تصویر پایه</CardTitle>
<CardHint>
برای هر زبان اپلیکیشن یک تصویر پایه مشخص می‌شود؛ کانتینرهای ساخت و فضای کار از
همین تصویرها ساخته می‌شوند.
</CardHint>
<ul className="mt-3 flex flex-col gap-2">
{Object.entries(summary.images).map(([language, image]) => (
<li
key={language}
className="flex items-center justify-between gap-2 rounded-lg border border-surface-border px-3 py-2"
>
<span className="ltr-inline text-xs text-slate-400">{language}</span>
<span className="ltr-inline text-sm text-slate-200">{image}</span>
</li>
))}
</ul>
</Card>
<ConfirmDialog
open={pending !== null}
title="توقف کانتینر"
description={
pending
? `کانتینر ${pending.name} متوقف و حذف می‌شود. برای تأیید، شناسهٔ آن را تایپ کنید.`
: undefined
}
confirmLabel={busy ? "در حال توقف…" : "توقف کن"}
onConfirm={stop}
onCancel={() => {
setPending(null);
setTyped("");
}}
/>
{pending && (
<Card>
<Field
label="شناسهٔ کانتینر را برای تأیید تایپ کنید"
value={typed}
onChange={(event) => setTyped(event.target.value)}
placeholder={pending.id}
className="ltr-inline"
/>
</Card>
)}
</div>
);
}
@@ -0,0 +1,141 @@
"use client";
/**
* The live build console.
*
* Three details make this usable rather than merely live:
*
* 1. **Batching.** The server coalesces a step's output into at most one event
* per 500 ms, but a fast build still arrives faster than a DOM update is
* worth. Lines are accumulated and flushed on an animation frame.
* 2. **Auto-scroll that yields.** Following the tail is what you want until you
* scroll up to read something, at which point it is actively hostile. The
* console stops following the moment the user scrolls away from the bottom
* and offers an explicit way back.
* 3. **ANSI stripped.** A build log is full of colour codes; rendered raw they
* are unreadable noise. Stripping them is not cosmetic.
*/
import * as React from "react";
import { Badge, Button, Card, CardHint, CardTitle, cx } from "@agy/ui";
/** Remove ANSI SGR/CSI sequences and carriage-return progress redraws. */
export function stripAnsi(text: string): string {
return text
// eslint-disable-next-line no-control-regex
.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "")
.replace(/\r(?!\n)/g, "\n");
}
export interface LogLine {
key: string;
step: string;
text: string;
}
/** Coalesce incoming lines into frames so a burst does not thrash the DOM. */
export function useBatchedLines(incoming: LogLine[]): LogLine[] {
const [rendered, setRendered] = React.useState<LogLine[]>([]);
const pending = React.useRef<LogLine[]>([]);
const frame = React.useRef<number | null>(null);
React.useEffect(() => {
if (incoming.length === 0) return;
pending.current.push(...incoming);
if (frame.current !== null) return;
frame.current = requestAnimationFrame(() => {
frame.current = null;
const batch = pending.current;
pending.current = [];
// Bounded: a console is a window on the log, not the log itself.
setRendered((current) => [...current, ...batch].slice(-2000));
});
return () => {
if (frame.current !== null) {
cancelAnimationFrame(frame.current);
frame.current = null;
}
};
}, [incoming]);
return rendered;
}
export function PipelineConsole({
lines,
running,
className,
}: {
lines: LogLine[];
running: boolean;
className?: string;
}) {
const rendered = useBatchedLines(lines);
const scroller = React.useRef<HTMLDivElement | null>(null);
const [following, setFollowing] = React.useState(true);
React.useEffect(() => {
if (!following) return;
const node = scroller.current;
if (node) node.scrollTop = node.scrollHeight;
}, [rendered, following]);
const onScroll = () => {
const node = scroller.current;
if (!node) return;
const atBottom = node.scrollHeight - node.scrollTop - node.clientHeight < 24;
setFollowing(atBottom);
};
const copy = async () => {
try {
await navigator.clipboard.writeText(rendered.map((l) => `[${l.step}] ${l.text}`).join("\n"));
} catch {
/* a clipboard the browser refuses is not worth an error banner */
}
};
return (
<Card className={className}>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>لاگ زنده</CardTitle>
<CardHint>
خروجی هر مرحله به‌صورت زنده اینجا می‌آید. کدهای رنگی ترمینال حذف می‌شوند.
</CardHint>
</div>
<div className="flex shrink-0 items-center gap-2">
{running && <Badge tone="warning">در حال اجرا</Badge>}
{!following && (
<Button size="sm" variant="secondary" onClick={() => setFollowing(true)}>
ادامهٔ دنبال‌کردن
</Button>
)}
<Button size="sm" variant="ghost" onClick={copy} disabled={rendered.length === 0}>
کپی
</Button>
</div>
</div>
<div
ref={scroller}
onScroll={onScroll}
className={cx(
"ltr-inline mt-4 max-h-80 min-h-32 overflow-auto rounded-lg border",
"border-surface-border bg-surface-base p-3 font-mono text-xs leading-relaxed",
)}
>
{rendered.length === 0 ? (
<p className="text-slate-500">هنوز خروجی‌ای نرسیده است.</p>
) : (
rendered.map((line) => (
<div key={line.key} className="flex gap-2 whitespace-pre-wrap break-all">
<span className="shrink-0 text-slate-600">{line.step}</span>
<span className="text-slate-300">{line.text}</span>
</div>
))
)}
</div>
</Card>
);
}
@@ -0,0 +1,275 @@
"use client";
/**
* The production section of the Application page.
*
* Production is the only action in the product the platform cannot undo, so the
* UI is built around one rule: **the operator has to type the organization's
* slug to proceed.** Not a checkbox, not a second click — the same string the
* server demands, so the modal cannot be dismissed by muscle memory.
*
* The target summary deliberately shows `has_secret` rather than the secret, and
* there is no field anywhere in this component that could render one.
*/
import * as React from "react";
import type { ProductionDeploy, ProductionOverview } from "@agy/contracts";
import {
Badge,
Button,
Card,
CardHint,
CardTitle,
ConfirmDialog,
EmptyState,
ErrorState,
Field,
LoadingState,
Stat,
} from "@agy/ui";
import { ApiError } from "@/lib/api";
import { deliveryApi } from "@/lib/api-delivery";
const STATUS_TONES = {
queued: "warning",
running: "warning",
succeeded: "success",
failed: "danger",
rolled_back: "neutral",
} as const;
function toneFor(status: string) {
return STATUS_TONES[status as keyof typeof STATUS_TONES] ?? "neutral";
}
export function ProductionPanel({
orgId,
projectId,
orgSlug,
canDeploy,
liveReleaseId,
onChanged,
}: {
orgId: string;
projectId: string;
orgSlug: string;
/** Org owner/admin. A viewer sees the history and no buttons at all. */
canDeploy: boolean;
liveReleaseId: string | null;
onChanged?: () => void;
}) {
const [overview, setOverview] = React.useState<ProductionOverview | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [busy, setBusy] = React.useState(false);
const [notice, setNotice] = React.useState<string | null>(null);
const [confirming, setConfirming] = React.useState<"deploy" | "rollback" | null>(null);
const [typed, setTyped] = React.useState("");
const [lastLog, setLastLog] = React.useState<ProductionDeploy | null>(null);
const load = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
setOverview(await deliveryApi.productionOverview(orgId, projectId));
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت وضعیت production ناموفق بود.");
} finally {
setLoading(false);
}
}, [orgId, projectId]);
React.useEffect(() => {
void load();
}, [load]);
const onTest = async () => {
setBusy(true);
setNotice(null);
try {
const result = await deliveryApi.testProductionTarget(orgId, projectId);
setNotice(result.message);
await load();
} catch (err) {
setNotice(err instanceof ApiError ? err.message : "تست اتصال ناموفق بود.");
} finally {
setBusy(false);
}
};
const runConfirmed = async () => {
const slug = typed.trim();
setBusy(true);
setNotice(null);
try {
const deploy =
confirming === "deploy"
? await deliveryApi.deployToProduction(orgId, projectId, slug, liveReleaseId ?? undefined)
: await deliveryApi.rollbackProduction(orgId, projectId, slug);
setLastLog(deploy);
setNotice(deploy.message || (deploy.status === "succeeded" ? "انجام شد." : "ناموفق بود."));
onChanged?.();
} catch (err) {
setNotice(err instanceof ApiError ? err.message : "عملیات ناموفق بود.");
} finally {
setBusy(false);
setConfirming(null);
setTyped("");
await load();
}
};
if (loading) return <LoadingState label="در حال دریافت وضعیت production…" />;
if (error) return <ErrorState message={error} onRetry={load} />;
const target = overview?.target ?? null;
const deploys = overview?.deploys ?? [];
return (
<div className="flex flex-col gap-4">
{notice && (
<Card>
<p className="text-sm text-slate-300">{notice}</p>
</Card>
)}
<Card>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>مقصد production</CardTitle>
<CardHint>
استقرار روی production فقط با کلیک شما و با تأیید نامک سازمان انجام می‌شود.
</CardHint>
</div>
{target?.last_test_ok === true && <Badge tone="success">اتصال تأییدشده</Badge>}
{target?.last_test_ok === false && <Badge tone="danger">اتصال ناموفق</Badge>}
</div>
{target === null ? (
<div className="mt-4">
<EmptyState
title="مقصدی تنظیم نشده است"
description="برای استقرار روی production، در ویزارد ساخت اپلیکیشن یا تنظیمات، اطلاعات SSH/FTP را وارد کنید."
/>
</div>
) : (
<>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Stat label="نوع" value={target.kind.toUpperCase()} />
<Stat label="میزبان" value={`${target.host}:${target.port}`} />
<Stat label="مسیر" value={target.remote_path} />
<Stat label="کلید" value={target.has_secret ? "ثبت شده" : "ثبت نشده"} />
</div>
{target.last_test_message && (
<p className="mt-3 text-xs text-slate-500">{target.last_test_message}</p>
)}
{canDeploy && (
<div className="mt-4 flex flex-wrap gap-2">
<Button variant="secondary" onClick={onTest} loading={busy}>
تست اتصال
</Button>
<Button
onClick={() => setConfirming("deploy")}
disabled={busy || !liveReleaseId}
>
استقرار در production
</Button>
<Button
variant="secondary"
onClick={() => setConfirming("rollback")}
disabled={busy || deploys.length === 0}
>
بازگردانی آخرین استقرار
</Button>
</div>
)}
{!canDeploy && (
<p className="mt-3 text-xs text-slate-500">
استقرار روی production تنها توسط مالک یا مدیر سازمان انجام می‌شود.
</p>
)}
</>
)}
</Card>
<Card>
<CardTitle>تاریخچهٔ استقرار</CardTitle>
{deploys.length === 0 ? (
<p className="mt-2 text-xs text-slate-500">هنوز استقراری انجام نشده است.</p>
) : (
<ul className="mt-3 flex flex-col gap-2">
{deploys.map((deploy) => (
<li
key={deploy.id}
className="flex flex-col gap-2 rounded-lg border border-surface-border px-3 py-3"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Badge tone={toneFor(deploy.status)}>{deploy.status}</Badge>
<span className="ltr-inline text-xs text-slate-400">{deploy.kind}</span>
<span className="ltr-inline truncate text-xs text-slate-500">
{deploy.host}
</span>
</div>
<Button size="sm" variant="ghost" onClick={() => setLastLog(deploy)}>
لاگ
</Button>
</div>
{deploy.message && (
<p className="text-xs text-slate-400">{deploy.message}</p>
)}
{deploy.rolled_back_from && (
<p className="text-xs text-slate-500">بازگردانی از استقرار قبلی</p>
)}
</li>
))}
</ul>
)}
</Card>
{lastLog && (
<Card>
<div className="flex items-start justify-between gap-3">
<CardTitle>لاگ استقرار</CardTitle>
<Button size="sm" variant="ghost" onClick={() => setLastLog(null)}>
بستن
</Button>
</div>
<pre className="ltr-inline mt-3 max-h-64 overflow-auto whitespace-pre-wrap break-all rounded-lg border border-surface-border bg-surface-base p-3 font-mono text-xs text-slate-300">
{lastLog.log_tail || "خروجی‌ای ثبت نشده است."}
</pre>
</Card>
)}
<ConfirmDialog
open={confirming !== null}
title={confirming === "deploy" ? "استقرار در production" : "بازگردانی استقرار"}
description={
confirming === "deploy"
? "این کار نسخهٔ جدید را روی سرور شما فعال می‌کند و به‌سادگی برگشت‌پذیر نیست."
: "نسخهٔ فعال روی سرور شما به نسخهٔ قبلی برمی‌گردد."
}
confirmLabel={busy ? "در حال انجام…" : "تأیید"}
onConfirm={runConfirmed}
onCancel={() => {
setConfirming(null);
setTyped("");
}}
/>
{confirming !== null && (
<Card>
<Field
label="برای تأیید، نامک سازمان را تایپ کنید"
hint={`نامک: ${orgSlug}`}
value={typed}
onChange={(event) => setTyped(event.target.value)}
placeholder={orgSlug}
className="ltr-inline"
/>
</Card>
)}
</div>
);
}
+458 -12
View File
@@ -1,18 +1,464 @@
"use client";
import { PlaceholderPage } from "@/components/PlaceholderPage";
/**
* The Application page — v2's replacement for `/projects` (D1, D2).
*
* One organization has exactly one application, so this page is about *that*
* application rather than a list. It answers four questions, in the order they
* are actually asked:
*
* 1. Is it live, and at what URL? → the header
* 2. Did my last change build? → the pipeline runs, with live logs
* 3. What is deployed, and can I undo? → releases
* 4. Is production up to date? → the production panel
*
* What it deliberately does **not** show a customer: ports, container ids, branch
* controls, or anything else that is infrastructure. Those live in the owner
* console. The Git section shows the `dev` head and nothing more.
*/
import * as React from "react";
import type {
AppRuntime,
PipelineRun,
PipelineRunDetail,
Project,
Release,
WsPipelineLogData,
WsPipelineStatusData,
WsReleaseLiveData,
} from "@agy/contracts";
import {
Badge,
Button,
Card,
CardHint,
CardTitle,
ConfirmDialog,
EmptyState,
ErrorState,
LoadingState,
Stat,
Tabs,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { deliveryApi } from "@/lib/api-delivery";
import { useLiveChannel } from "@/lib/realtime";
import { useSession } from "@/lib/session";
import { PipelineConsole, stripAnsi, type LogLine } from "./_components/pipeline-console";
import { ProductionPanel } from "./_components/production-panel";
type Tab = "pipeline" | "releases" | "production" | "git";
type Tone = "neutral" | "success" | "warning" | "danger";
const RUN_TONES: Record<string, Tone> = {
queued: "neutral",
running: "warning",
succeeded: "success",
failed: "danger",
cancelled: "neutral",
superseded: "neutral",
};
const STEP_TONES: Record<string, Tone> = {
pending: "neutral",
running: "warning",
succeeded: "success",
failed: "danger",
skipped: "neutral",
};
function shortSha(sha: string): string {
return sha ? sha.slice(0, 7) : "—";
}
function when(value: string | null): string {
if (!value) return "—";
try {
return new Date(value).toLocaleString("fa-IR");
} catch {
return value;
}
}
/**
* Read a live event's data as its typed payload.
*
* The socket carries ``Record<string, unknown>`` by design (the transport is a
* plain JSON frame), so a direct cast is refused by the compiler. Going through
* ``unknown`` is the honest way to say "the server's typed publisher defines this
* shape and the client mirrors it" — and the narrow `as` cast that survives is
* the one place the mirror is asserted.
*/
function payload<T>(event: { data: Record<string, unknown> }): T {
return event.data as unknown as T;
}
export default function ApplicationPage() {
const { activeOrganization, user } = useSession();
const orgId = activeOrganization?.id ?? null;
const orgSlug = activeOrganization?.slug ?? "";
// Org membership is enough to *see* everything here; the server is the real
// boundary for the actions, which is why a 403 still surfaces as a message.
const canManage = Boolean(user);
const [tab, setTab] = React.useState<Tab>("pipeline");
const [project, setProject] = React.useState<Project | null>(null);
const [runtime, setRuntime] = React.useState<AppRuntime | null>(null);
const [runs, setRuns] = React.useState<PipelineRun[]>([]);
const [releases, setReleases] = React.useState<Release[]>([]);
const [selected, setSelected] = React.useState<PipelineRunDetail | null>(null);
const [lines, setLines] = React.useState<LogLine[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [pendingRollback, setPendingRollback] = React.useState<Release | null>(null);
const [busy, setBusy] = React.useState(false);
const [notice, setNotice] = React.useState<string | null>(null);
const projectId = project?.id ?? null;
const load = React.useCallback(async () => {
if (!orgId) {
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const projects = await api.listProjects(orgId);
const current = projects[0] ?? null;
setProject(current);
if (!current) {
setRuntime(null);
setRuns([]);
setReleases([]);
return;
}
const [nextRuntime, nextRuns, nextReleases] = await Promise.all([
deliveryApi.getRuntime(orgId, current.id).catch(() => null),
deliveryApi.listRuns(orgId, current.id).catch(() => []),
deliveryApi.listReleases(orgId, current.id).catch(() => []),
]);
setRuntime(nextRuntime);
setRuns(nextRuns);
setReleases(nextReleases);
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت اطلاعات اپلیکیشن ناموفق بود.");
} finally {
setLoading(false);
}
}, [orgId]);
React.useEffect(() => {
void load();
}, [load]);
// Live feed: build status, log lines and release switches. Log lines are
// filtered to the run currently open so two concurrent builds never interleave
// in one console.
useLiveChannel(orgId ? [`org:${orgId}`] : [], (event) => {
if (!projectId) return;
if (event.type === "pipeline.log") {
const data = payload<WsPipelineLogData>(event);
if (selected && data.run_id !== selected.id) return;
const stamp = Date.now();
const batch = (data.lines ?? []).map((text, index) => ({
key: `${data.run_id}-${data.step}-${stamp}-${index}`,
step: data.step,
text: stripAnsi(text),
}));
if (batch.length) setLines((current) => [...current, ...batch]);
return;
}
if (event.type === "pipeline.status") {
const data = payload<WsPipelineStatusData>(event);
if (data.project_id !== projectId) return;
void load();
return;
}
if (event.type === "release.live") {
const data = payload<WsReleaseLiveData>(event);
if (data.project_id !== projectId) return;
void load();
}
});
const openRun = async (runId: string) => {
if (!orgId) return;
setNotice(null);
setLines([]);
try {
setSelected(await deliveryApi.getRun(orgId, runId));
} catch (err) {
setNotice(err instanceof ApiError ? err.message : "دریافت جزئیات اجرا ناموفق بود.");
}
};
const startRun = async () => {
if (!orgId || !projectId) return;
setBusy(true);
setNotice(null);
try {
await deliveryApi.startRun(orgId, projectId);
await load();
} catch (err) {
setNotice(err instanceof ApiError ? err.message : "شروع اجرای جدید ناموفق بود.");
} finally {
setBusy(false);
}
};
const doRollback = async () => {
if (!orgId || !projectId || !pendingRollback) return;
setBusy(true);
try {
await deliveryApi.rollbackDemo(orgId, projectId, pendingRollback.id);
setNotice("دمو به نسخهٔ انتخاب‌شده بازگردانده شد.");
await load();
} catch (err) {
setNotice(err instanceof ApiError ? err.message : "بازگردانی دمو ناموفق بود.");
} finally {
setBusy(false);
setPendingRollback(null);
}
};
if (loading) return <LoadingState label="در حال دریافت اطلاعات اپلیکیشن…" />;
if (error) return <ErrorState message={error} onRetry={load} />;
if (!project) {
return (
<EmptyState
title="هنوز اپلیکیشنی ساخته نشده است"
description="هر سازمان یک اپلیکیشن دارد. برای ساخت آن، ویزارد ساخت اپلیکیشن را کامل کنید."
/>
);
}
const liveRelease = releases.find((release) => release.is_demo_live) ?? null;
const healthTone: Tone =
runtime?.status === "live" ? "success" : runtime?.status === "degraded" ? "danger" : "neutral";
export default function AppPage() {
return (
<PlaceholderPage
title="اپلیکیشن"
description="دمو زنده، استقرار روی production و وضعیت Git اپلیکیشن سازمان."
planned={[
"نمای کلی اپلیکیشن و آدرس دمو",
"اجراهای CI/CD با لاگ زنده",
"نسخه‌ها و بازگشت دمو",
"استقرار روی production با تأیید",
]}
/>
<div className="flex flex-col gap-4">
{notice && (
<Card>
<p className="text-sm text-slate-300">{notice}</p>
</Card>
)}
{/* -- header ------------------------------------------------------- */}
<Card>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex min-w-0 flex-col gap-1">
<div className="flex items-center gap-2">
<CardTitle>{project.name}</CardTitle>
<Badge tone={healthTone}>{runtime?.status ?? "بدون محیط"}</Badge>
</div>
<CardHint>
<span className="ltr-inline">{project.slug}</span>
</CardHint>
</div>
<div className="flex flex-wrap items-center gap-2">
{runtime?.demo_url ? (
<a
href={runtime.demo_url}
target="_blank"
rel="noreferrer noopener"
className="ltr-inline text-sm text-accent hover:underline"
>
{runtime.demo_url}
</a>
) : (
<span className="text-xs text-slate-500">آدرس دمو آماده نیست.</span>
)}
{canManage && (
<Button size="sm" variant="secondary" onClick={startRun} loading={busy}>
اجرای دوباره
</Button>
)}
</div>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Stat label="نسخهٔ زنده" value={liveRelease ? shortSha(liveRelease.sha) : "—"} />
<Stat label="زمان نسخهٔ زنده" value={liveRelease ? when(liveRelease.created_at) : "—"} />
<Stat label="آخرین اجرا" value={runs[0] ? runs[0].status : "—"} />
<Stat label="تعداد اجراها" value={String(runs.length)} />
</div>
</Card>
<Tabs
items={[
{ key: "pipeline", label: "خط لولهٔ انتشار" },
{ key: "releases", label: "نسخه‌ها" },
{ key: "production", label: "production" },
{ key: "git", label: "Git" },
]}
active={tab}
onChange={(key) => setTab(key as Tab)}
/>
{/* -- pipeline ------------------------------------------------------ */}
{tab === "pipeline" && (
<div className="flex flex-col gap-4">
<Card>
<CardTitle>اجراهای خط لوله</CardTitle>
<CardHint>
هر بار که شاخهٔ <span className="ltr-inline">dev</span> به‌روز می‌شود یک اجرا ساخته
می‌شود. برای دیدن جزئیات و لاگ زنده روی هر اجرا بزنید.
</CardHint>
{runs.length === 0 ? (
<p className="mt-3 text-xs text-slate-500">هنوز اجرایی ثبت نشده است.</p>
) : (
<ul className="mt-3 flex flex-col gap-2">
{runs.map((run) => (
<li key={run.id}>
<button
type="button"
onClick={() => void openRun(run.id)}
className={
"flex w-full flex-col gap-2 rounded-lg border px-3 py-3 text-right transition " +
(selected?.id === run.id
? "border-accent/60 bg-surface-overlay"
: "border-surface-border hover:border-accent/40")
}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Badge tone={RUN_TONES[run.status] ?? "neutral"}>{run.status}</Badge>
<span className="ltr-inline text-xs text-slate-400">
{shortSha(run.sha)}
</span>
<span className="text-xs text-slate-500">{run.trigger}</span>
</div>
<span className="text-xs text-slate-500">{when(run.created_at)}</span>
</div>
{run.error_summary && (
<p className="text-xs text-rose-300">{run.error_summary}</p>
)}
</button>
</li>
))}
</ul>
)}
</Card>
{selected && (
<>
<Card>
<CardTitle>مراحل اجرا</CardTitle>
<ol className="mt-3 flex flex-col gap-2">
{selected.steps.map((step) => (
<li
key={step.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-surface-border px-3 py-2"
>
<div className="flex items-center gap-2">
<span className="ltr-inline text-sm text-slate-200">{step.name}</span>
<Badge tone={STEP_TONES[step.status] ?? "neutral"}>{step.status}</Badge>
</div>
<div className="flex items-center gap-3 text-xs text-slate-500">
{step.exit_code !== null && (
<span className="ltr-inline">exit {step.exit_code}</span>
)}
{step.duration_ms > 0 && <span>{step.duration_ms}ms</span>}
</div>
</li>
))}
</ol>
</Card>
<PipelineConsole
lines={lines}
running={selected.status === "running" || selected.status === "queued"}
/>
</>
)}
</div>
)}
{/* -- releases ------------------------------------------------------ */}
{tab === "releases" && (
<Card>
<CardTitle>نسخه‌ها</CardTitle>
<CardHint>
هر اجرای موفق یک نسخه می‌سازد. بازگردانی دمو، مسیر دمو را به نسخهٔ قبلی برمی‌گرداند.
</CardHint>
{releases.length === 0 ? (
<p className="mt-3 text-xs text-slate-500">هنوز نسخه‌ای منتشر نشده است.</p>
) : (
<ul className="mt-3 flex flex-col gap-2">
{releases.map((release) => (
<li
key={release.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-surface-border px-3 py-3"
>
<div className="flex items-center gap-2">
{release.is_demo_live && <Badge tone="success">زنده</Badge>}
<span className="ltr-inline text-sm text-slate-200">
{shortSha(release.sha)}
</span>
<span className="text-xs text-slate-500">{when(release.created_at)}</span>
</div>
{canManage && !release.is_demo_live && (
<Button
size="sm"
variant="secondary"
onClick={() => setPendingRollback(release)}
>
بازگردانی دمو
</Button>
)}
</li>
))}
</ul>
)}
</Card>
)}
{/* -- production ---------------------------------------------------- */}
{tab === "production" && orgId && (
<ProductionPanel
orgId={orgId}
projectId={project.id}
orgSlug={orgSlug}
canDeploy={canManage}
liveReleaseId={liveRelease?.id ?? null}
onChanged={load}
/>
)}
{/* -- git ----------------------------------------------------------- */}
{tab === "git" && (
<Card>
<CardTitle>مخزن</CardTitle>
<CardHint>
کد پروژه در مخزن Git نگهداری می‌شود. شاخهٔ <span className="ltr-inline">dev</span>{" "}
شاخهٔ انتشار است؛ تغییرات کارکنان هوش مصنوعی پس از تست به آن ادغام می‌شوند.
</CardHint>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<Stat label="آخرین نسخهٔ dev" value={runs[0] ? shortSha(runs[0].sha) : "—"} />
<Stat label="آخرین بررسی مخزن" value={runs[0] ? when(runs[0].created_at) : "—"} />
</div>
<p className="mt-3 text-xs text-slate-500">
کنترل شاخه، پورت و دستورها برای کاربران نمایش داده نمی‌شود.
</p>
</Card>
)}
<ConfirmDialog
open={pendingRollback !== null}
title="بازگردانی دمو"
description={
pendingRollback
? `دمو به نسخهٔ ${shortSha(pendingRollback.sha)} بازگردانده می‌شود.`
: undefined
}
confirmLabel={busy ? "در حال انجام…" : "بازگردان"}
onConfirm={doRollback}
onCancel={() => setPendingRollback(null)}
/>
</div>
);
}
@@ -1,468 +0,0 @@
"use client";
import Link from "next/link";
import * as React from "react";
import type { PreviewDomain, Project, RepoStatus, Repository } from "@agy/contracts";
import {
Badge,
Button,
Card,
CardHint,
CardTitle,
ConfirmDialog,
EmptyState,
ErrorState,
Field,
LoadingState,
Stat,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { useSession } from "@/lib/session";
import { SandboxPanel } from "@/components/sandbox-panel";
type Tab = "repo" | "preview" | "sandbox";
const DOMAIN_TONES = {
active: "success",
disabled: "neutral",
failed: "danger",
} as const;
export default function ProjectDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id: projectId } = React.use(params);
const { activeOrganization, user } = useSession();
const [tab, setTab] = React.useState<Tab>("repo");
const [project, setProject] = React.useState<Project | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const orgId = activeOrganization?.id ?? null;
const load = React.useCallback(async () => {
if (!orgId) {
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
setProject(await api.getProject(orgId, projectId));
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت پروژه ناموفق بود.");
} finally {
setLoading(false);
}
}, [orgId, projectId]);
React.useEffect(() => {
void load();
}, [load]);
if (!activeOrganization) {
return (
<EmptyState
title="سازمانی انتخاب نشده است"
description="برای دیدن جزئیات پروژه ابتدا یک سازمان بسازید."
/>
);
}
if (loading) return <LoadingState label="در حال دریافت پروژه…" />;
if (error) return <ErrorState message={error} onRetry={load} />;
if (!project) {
return (
<EmptyState
title="پروژه پیدا نشد"
description="این پروژه حذف شده است یا به سازمان دیگری تعلق دارد."
/>
);
}
return (
<div className="anim-stagger flex flex-col gap-5">
<nav className="text-xs text-slate-500">
<Link href="/projects" className="transition-colors hover:text-slate-300">
پروژه‌ها
</Link>
<span className="mx-1.5">/</span>
<span className="text-slate-400">{project.name}</span>
</nav>
<header className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<h1 className="text-lg font-medium text-slate-100">{project.name}</h1>
<Badge tone="neutral">
<span className="ltr-inline">{project.slug}</span>
</Badge>
</div>
{project.description ? (
<p className="text-sm text-slate-400">{project.description}</p>
) : (
<p className="text-sm text-slate-500">توضیحی ثبت نشده است.</p>
)}
</header>
<div className="flex gap-1 border-b border-surface-border">
{(
[
["repo", "مخزن کد"],
["preview", "دامنهٔ تست"],
["sandbox", "محیط ایزوله"],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => setTab(value)}
className={
"border-b-2 px-3 py-2 text-sm transition-colors " +
(tab === value
? "border-accent text-slate-100"
: "border-transparent text-slate-400 hover:text-slate-200")
}
>
{label}
</button>
))}
</div>
{tab === "repo" ? (
<RepositoryPanel orgId={activeOrganization.id} projectId={project.id} />
) : tab === "preview" ? (
<PreviewDomainPanel orgId={activeOrganization.id} projectId={project.id} />
) : (
<SandboxPanel
orgId={activeOrganization.id}
projectId={project.id}
canManageInfra={user?.is_platform_owner ?? false}
/>
)}
</div>
);
}
/* -------------------------------------------------------------------------- */
/* Repository */
/* -------------------------------------------------------------------------- */
function RepositoryPanel({ orgId, projectId }: { orgId: string; projectId: string }) {
const [status, setStatus] = React.useState<RepoStatus | null>(null);
const [repo, setRepo] = React.useState<Repository | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [creating, setCreating] = React.useState(false);
const [copied, setCopied] = React.useState(false);
const load = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
const nextStatus = await api.getRepoStatus(orgId, projectId);
setStatus(nextStatus);
if (nextStatus.exists) {
// The status probe only reports shape; the detail call carries the URLs.
setRepo(await api.getRepo(orgId, projectId));
} else {
setRepo(null);
}
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت وضعیت مخزن ناموفق بود.");
} finally {
setLoading(false);
}
}, [orgId, projectId]);
React.useEffect(() => {
void load();
}, [load]);
const onEnsure = async () => {
setCreating(true);
setError(null);
try {
setRepo(await api.ensureRepo(orgId, projectId));
setStatus(await api.getRepoStatus(orgId, projectId));
} catch (err) {
setError(err instanceof ApiError ? err.message : "ساخت مخزن ناموفق بود.");
} finally {
setCreating(false);
}
};
const onCopy = async () => {
if (!repo?.clone_url) return;
try {
await navigator.clipboard.writeText(repo.clone_url);
setCopied(true);
window.setTimeout(() => setCopied(false), 1600);
} catch {
/* clipboard blocked — the URL is visible and selectable anyway */
}
};
if (loading) return <LoadingState label="در حال بررسی مخزن…" />;
return (
<div className="flex flex-col gap-4">
{error && <ErrorState message={error} onRetry={load} />}
{!status?.exists ? (
<Card>
<CardTitle>مخزنی وجود ندارد</CardTitle>
<CardHint>
یک مخزن خصوصی Git برای این پروژه ساخته می‌شود و اعتبارنامهٔ آن به‌صورت رمزنگاری‌شده
ذخیره می‌گردد. کلید خام هرگز از طریق API برگردانده نمی‌شود.
</CardHint>
<div className="mt-4">
<Button onClick={onEnsure} loading={creating}>
ساخت مخزن خصوصی
</Button>
</div>
</Card>
) : (
<>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Stat
label="وضعیت"
value={<Badge tone="success">فعال</Badge>}
hint={status.private ? "مخزن خصوصی" : "مخزن عمومی"}
/>
<Stat label="شاخهٔ پیش‌فرض" value={<span className="ltr-inline">{status.default_branch}</span>} />
<Stat label="تعداد شاخه‌ها" value={status.branches.length} />
<Stat label="درخواست‌های ادغام باز" value={status.open_pull_requests} />
</div>
<Card>
<CardTitle>دسترسی</CardTitle>
<CardHint>از این آدرس برای clone استفاده کنید؛ اعتبارنامه به‌صورت خودکار تزریق می‌شود.</CardHint>
<div className="mt-3 flex flex-col gap-2">
<div className="flex items-center gap-2 rounded-lg border border-surface-border bg-surface-overlay px-3 py-2">
<code className="ltr-inline flex-1 truncate text-xs text-slate-300">
{repo?.clone_url ?? "—"}
</code>
<Button variant="ghost" size="sm" onClick={onCopy} disabled={!repo?.clone_url}>
{copied ? "کپی شد" : "کپی"}
</Button>
</div>
{repo?.web_url && (
<a
href={repo.web_url}
target="_blank"
rel="noreferrer noopener"
className="ltr-inline w-fit text-xs text-accent hover:underline"
>
{repo.web_url}
</a>
)}
<p className="text-xs text-slate-500">
نام مخزن: <span className="ltr-inline">{repo?.repo_name ?? "—"}</span> · مالک:{" "}
<span className="ltr-inline">{repo?.owner ?? "—"}</span> · ارائه‌دهنده:{" "}
<span className="ltr-inline">{repo?.provider ?? "—"}</span>
</p>
</div>
</Card>
<Card>
<CardTitle>شاخه‌ها</CardTitle>
{status.branches.length === 0 ? (
<p className="mt-2 text-xs text-slate-500">شاخه‌ای ثبت نشده است.</p>
) : (
<ul className="mt-3 flex flex-wrap gap-2">
{status.branches.map((branch) => (
<li key={branch}>
<Badge tone={branch === status.default_branch ? "info" : "neutral"}>
<span className="ltr-inline">{branch}</span>
</Badge>
</li>
))}
</ul>
)}
</Card>
</>
)}
</div>
);
}
/* -------------------------------------------------------------------------- */
/* Preview domains */
/* -------------------------------------------------------------------------- */
function PreviewDomainPanel({ orgId, projectId }: { orgId: string; projectId: string }) {
const [domains, setDomains] = React.useState<PreviewDomain[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [subdomain, setSubdomain] = React.useState("");
const [port, setPort] = React.useState("");
const [creating, setCreating] = React.useState(false);
const [formError, setFormError] = React.useState<string | null>(null);
const [pendingDelete, setPendingDelete] = React.useState<PreviewDomain | null>(null);
const [deleting, setDeleting] = React.useState(false);
const load = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
setDomains(await api.listPreviewDomains(orgId, projectId));
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت دامنه‌ها ناموفق بود.");
} finally {
setLoading(false);
}
}, [orgId, projectId]);
React.useEffect(() => {
void load();
}, [load]);
const onCreate = async (event: React.FormEvent) => {
event.preventDefault();
setFormError(null);
const parsedPort = port.trim() === "" ? undefined : Number(port);
if (parsedPort !== undefined && (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535)) {
setFormError("شمارهٔ پورت باید عددی بین ۱ تا ۶۵۵۳۵ باشد.");
return;
}
setCreating(true);
try {
const created = await api.createPreviewDomain(orgId, projectId, {
subdomain: subdomain.trim(),
port: parsedPort,
});
setDomains((current) => [...current, created]);
setSubdomain("");
setPort("");
} catch (err) {
setFormError(err instanceof ApiError ? err.message : "ساخت دامنه ناموفق بود.");
} finally {
setCreating(false);
}
};
const onConfirmDelete = async () => {
if (!pendingDelete) return;
setDeleting(true);
try {
await api.deletePreviewDomain(orgId, projectId, pendingDelete.id);
setDomains((current) => current.filter((d) => d.id !== pendingDelete.id));
setPendingDelete(null);
} catch (err) {
setError(err instanceof ApiError ? err.message : "حذف دامنه ناموفق بود.");
setPendingDelete(null);
} finally {
setDeleting(false);
}
};
return (
<div className="flex flex-col gap-4">
<Card>
<CardTitle>ساخت دامنهٔ تست</CardTitle>
<CardHint>
برچسب زیردامنه باید بین ۳ تا ۴۰ نویسه و فقط شامل حروف کوچک انگلیسی، رقم و خط تیره باشد.
اگر پورت را خالی بگذارید، سامانه خودش یک پورت آزاد تخصیص می‌دهد.
</CardHint>
<form onSubmit={onCreate} className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end">
<div className="flex-1">
<Field
label="زیردامنه"
required
minLength={3}
maxLength={40}
pattern="[a-z0-9-]+"
value={subdomain}
onChange={(e) => setSubdomain(e.target.value)}
placeholder="مثلاً demo-shop"
className="ltr-inline"
/>
</div>
<div className="w-full sm:w-40">
<Field
label="پورت (اختیاری)"
inputMode="numeric"
value={port}
onChange={(e) => setPort(e.target.value)}
placeholder="۸۰۸۰"
className="ltr-inline"
/>
</div>
<Button type="submit" loading={creating}>
ساخت دامنه
</Button>
</form>
{formError && (
<div className="mt-3">
<ErrorState title="ساخت دامنه ناموفق" message={formError} />
</div>
)}
</Card>
{error && <ErrorState message={error} onRetry={load} />}
{loading ? (
<LoadingState label="در حال دریافت دامنه‌ها…" />
) : domains.length === 0 ? (
<EmptyState
title="دامنه‌ای ثبت نشده است"
description="برای انتشار موقت پروژه، یک زیردامنه بسازید."
/>
) : (
<div className="anim-stagger grid gap-3 md:grid-cols-2">
{domains.map((domain) => (
<Card key={domain.id} className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-col gap-1">
<p className="ltr-inline truncate text-sm font-medium text-slate-100">
{domain.host}
</p>
<p className="ltr-inline text-xs text-slate-500">پورت {domain.port}</p>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<Badge tone={DOMAIN_TONES[domain.status] ?? "neutral"}>{domain.status}</Badge>
{domain.protected && <Badge tone="warning">محافظت‌شده</Badge>}
</div>
</div>
<a
href={domain.url}
target="_blank"
rel="noreferrer noopener"
className="ltr-inline w-fit truncate text-xs text-accent hover:underline"
>
{domain.url}
</a>
<div className="mt-auto flex justify-end">
<Button
variant="danger"
size="sm"
onClick={() => setPendingDelete(domain)}
disabled={domain.protected}
title={domain.protected ? "دامنه‌های محافظت‌شده حذف نمی‌شوند." : undefined}
>
حذف
</Button>
</div>
</Card>
))}
</div>
)}
<ConfirmDialog
open={pendingDelete !== null}
title="حذف دامنهٔ تست"
description={
pendingDelete
? `دامنهٔ «${pendingDelete.host}» حذف می‌شود و از این پس در دسترس نخواهد بود.`
: undefined
}
confirmLabel={deleting ? "در حال حذف…" : "حذف کن"}
onConfirm={onConfirmDelete}
onCancel={() => setPendingDelete(null)}
/>
</div>
);
}
-150
View File
@@ -1,150 +0,0 @@
"use client";
import Link from "next/link";
import * as React from "react";
import type { Project } from "@agy/contracts";
import {
Button,
Card,
CardHint,
CardTitle,
EmptyState,
ErrorState,
Field,
LoadingState,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { useSession } from "@/lib/session";
export default function ProjectsPage() {
const { activeOrganization } = useSession();
const [projects, setProjects] = React.useState<Project[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [name, setName] = React.useState("");
const [description, setDescription] = React.useState("");
const [creating, setCreating] = React.useState(false);
const [formError, setFormError] = React.useState<string | null>(null);
const load = React.useCallback(async () => {
if (!activeOrganization) {
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
setProjects(await api.listProjects(activeOrganization.id));
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت پروژه‌ها ناموفق بود.");
} finally {
setLoading(false);
}
}, [activeOrganization]);
React.useEffect(() => {
void load();
}, [load]);
const onCreate = async (event: React.FormEvent) => {
event.preventDefault();
if (!activeOrganization) return;
setFormError(null);
setCreating(true);
try {
const created = await api.createProject(activeOrganization.id, {
name: name.trim(),
description: description.trim(),
});
setProjects((current) => [created, ...current]);
setName("");
setDescription("");
} catch (err) {
setFormError(err instanceof ApiError ? err.message : "ساخت پروژه ناموفق بود.");
} finally {
setCreating(false);
}
};
if (!activeOrganization) {
return (
<EmptyState
title="سازمانی انتخاب نشده است"
description="برای دیدن پروژه‌ها ابتدا یک سازمان بسازید."
/>
);
}
return (
<div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">پروژه‌ها</h1>
<p className="text-sm text-slate-400">
هر پروژه می‌تواند یک مخزن خصوصی، یک محیط اجرای ایزوله و یک دامنهٔ تست داشته باشد.
</p>
</header>
<Card>
<CardTitle>ساخت پروژه</CardTitle>
<CardHint>نام پروژه به یک slug یکتا در سازمان تبدیل می‌شود.</CardHint>
<form onSubmit={onCreate} className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end">
<div className="flex-1">
<Field
label="نام پروژه"
required
minLength={2}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="مثلاً فروشگاه آنلاین"
/>
</div>
<div className="flex-1">
<Field
label="توضیح (اختیاری)"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<Button type="submit" loading={creating}>
ساخت پروژه
</Button>
</form>
{formError && (
<div className="mt-3">
<ErrorState title="ساخت پروژه ناموفق" message={formError} />
</div>
)}
</Card>
{loading ? (
<LoadingState label="در حال دریافت پروژه‌ها…" />
) : error ? (
<ErrorState message={error} onRetry={load} />
) : projects.length === 0 ? (
<EmptyState
title="هنوز پروژه‌ای نساخته‌اید"
description="اولین پروژه را با فرم بالا بسازید؛ سپس می‌توانید مخزن Git و دامنهٔ تست بسازید."
/>
) : (
<div className="anim-stagger grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{projects.map((project) => (
<Card key={project.id} className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-slate-100">{project.name}</p>
<p className="text-xs text-slate-500 ltr-inline">{project.slug}</p>
</div>
{project.description && (
<p className="text-xs text-slate-400">{project.description}</p>
)}
<Link href={`/projects/${project.id}`} className="mt-auto">
<Button variant="secondary" size="sm" className="w-full">
جزئیات پروژه
</Button>
</Link>
</Card>
))}
</div>
)}
</div>
);
}
+115 -2
View File
@@ -2,9 +2,122 @@
* Typed client functions for the delivery workstream (docs/plan/PLAN-v2.md §6).
*
* Add functions to this object only; ``lib/api.ts`` is locked. Use ``apiRequest`` for transport.
*
* Every call travels over the authenticated WebSocket RPC (see ``lib/api.ts``), so
* these are plain functions with no transport concerns of their own.
*/
import type {
AppRuntime,
DomainRoute,
DomainSyncResult,
PipelineRun,
PipelineRunDetail,
ProductionDeploy,
ProductionOverview,
Release,
SuggestedRoute,
} from "@agy/contracts";
import { apiRequest } from "./api";
function orgBase(orgId: string): string {
return `/api/organizations/${orgId}/delivery`;
}
function query(params: Record<string, string | number | undefined>): string {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== "") search.set(key, String(value));
}
const text = search.toString();
return text ? `?${text}` : "";
}
export const deliveryApi = {
// example: list: (orgId: string) => apiRequest<Foo[]>(`/api/organizations/${orgId}/foo`),
} satisfies Record<string, (...args: never[]) => ReturnType<typeof apiRequest>>;
// -- CI/CD runs ----------------------------------------------------------
listRuns: (orgId: string, projectId: string, limit = 50) =>
apiRequest<PipelineRun[]>(
`${orgBase(orgId)}/runs${query({ project_id: projectId, limit })}`,
),
getRun: (orgId: string, runId: string) =>
apiRequest<PipelineRunDetail>(`${orgBase(orgId)}/runs/${runId}`),
/** Re-run the current `dev` head. Never a production deploy. */
startRun: (orgId: string, projectId: string) =>
apiRequest<PipelineRun>(`${orgBase(orgId)}/runs`, {
method: "POST",
body: { project_id: projectId },
}),
cancelRun: (orgId: string, runId: string) =>
apiRequest<PipelineRun>(`${orgBase(orgId)}/runs/${runId}/cancel`, { method: "POST" }),
// -- releases & the demo runtime ----------------------------------------
listReleases: (orgId: string, projectId: string) =>
apiRequest<Release[]>(`${orgBase(orgId)}/releases${query({ project_id: projectId })}`),
getRuntime: (orgId: string, projectId: string) =>
apiRequest<AppRuntime>(`${orgBase(orgId)}/runtime${query({ project_id: projectId })}`),
rollbackDemo: (orgId: string, projectId: string, releaseId?: string) =>
apiRequest<Release>(`${orgBase(orgId)}/releases/rollback`, {
method: "POST",
body: { project_id: projectId, release_id: releaseId },
}),
// -- production (admin+, always confirmed) ------------------------------
productionOverview: (orgId: string, projectId: string) =>
apiRequest<ProductionOverview>(
`${orgBase(orgId)}/production${query({ project_id: projectId })}`,
),
testProductionTarget: (orgId: string, projectId: string) =>
apiRequest<{ ok: boolean; message: string }>(`${orgBase(orgId)}/production/test`, {
method: "POST",
body: { project_id: projectId },
}),
/**
* Ship a release to production.
*
* ``confirm`` must be the organization's slug typed back by the operator — the
* server rejects anything else. There is no way to skip it.
*/
deployToProduction: (orgId: string, projectId: string, confirm: string, releaseId?: string) =>
apiRequest<ProductionDeploy>(`${orgBase(orgId)}/production/deploy`, {
method: "POST",
body: { project_id: projectId, confirm, release_id: releaseId },
}),
rollbackProduction: (orgId: string, projectId: string, confirm: string, deployId?: string) =>
apiRequest<ProductionDeploy>(`${orgBase(orgId)}/production/rollback`, {
method: "POST",
body: { project_id: projectId, confirm, deploy_id: deployId },
}),
};
/** Platform-owner domain console. */
export const domainsApi = {
list: () => apiRequest<DomainRoute[]>("/api/admin/domains"),
suggested: () => apiRequest<SuggestedRoute[]>("/api/admin/domains/suggested"),
attach: (organizationId: string, projectId: string, host: string) =>
apiRequest<DomainRoute>("/api/admin/domains", {
method: "POST",
body: { organization_id: organizationId, project_id: projectId, host },
}),
/** Detaching requires the host typed back — it is what takes a URL offline. */
detach: (domainId: string, confirm: string) =>
apiRequest<void>(`/api/admin/domains/${domainId}`, {
method: "DELETE",
body: { confirm },
}),
sync: () => apiRequest<DomainSyncResult>("/api/admin/domains/sync", { method: "POST" }),
};
+382
View File
@@ -0,0 +1,382 @@
/**
* §v2-A3.6 — the Application page.
*
* The subjects are the things this page could get *wrong* in a way that matters:
*
* * the live console must batch and must stop following the tail when the user
* scrolls away — a console that yanks the view back is worse than no console;
* * ANSI colour codes must be stripped — raw, a build log is unreadable noise;
* * production is the one irreversible action, so the confirmation must be real
* and the deploy button must be absent for anyone who is not an org admin;
* * no secret is ever rendered. The API returns `has_secret`, not the secret, and
* this file asserts the secret string is nowhere in the DOM.
*/
import { render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
session: {
ready: true,
user: null as { id: string; email: string } | null,
organizations: [] as Array<{ id: string; slug: string }>,
activeOrganization: null as { id: string; slug: string } | null,
login: vi.fn(async () => {}),
refresh: vi.fn(async () => {}),
setActiveOrganization: vi.fn(),
},
channels: [] as string[],
listProjects: vi.fn(),
getRuntime: vi.fn(),
listRuns: vi.fn(),
listReleases: vi.fn(),
getRun: vi.fn(),
productionOverview: vi.fn(),
startRun: vi.fn(),
rollbackDemo: vi.fn(),
deployToProduction: vi.fn(),
rollbackProduction: vi.fn(),
testProductionTarget: vi.fn(),
}));
vi.mock("@/lib/session", () => ({ useSession: () => mocks.session }));
vi.mock("@/lib/realtime", () => ({
useLiveChannel: (channels: string[]) => {
mocks.channels = channels;
},
}));
vi.mock("@/lib/api", () => {
class FakeApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
return { ApiError: FakeApiError, api: { listProjects: mocks.listProjects } };
});
vi.mock("@/lib/api-delivery", () => ({
deliveryApi: {
getRuntime: mocks.getRuntime,
listRuns: mocks.listRuns,
listReleases: mocks.listReleases,
getRun: mocks.getRun,
productionOverview: mocks.productionOverview,
startRun: mocks.startRun,
rollbackDemo: mocks.rollbackDemo,
deployToProduction: mocks.deployToProduction,
rollbackProduction: mocks.rollbackProduction,
testProductionTarget: mocks.testProductionTarget,
},
domainsApi: {},
}));
import ApplicationPage from "@/app/(app)/app/page";
import { stripAnsi } from "@/app/(app)/app/_components/pipeline-console";
const ORG = { id: "org-1", slug: "acme" };
const PROJECT = {
id: "proj-1",
organization_id: ORG.id,
name: "اپلیکیشن نمونه",
slug: "sample-app",
description: "",
default_branch: "dev",
created_at: "2026-10-13T10:00:00.000Z",
};
const RUNTIME = {
project_id: "proj-1",
demo_host: "acme.msa.artacloud.ir",
demo_url: "https://acme.msa.artacloud.ir",
host_port: 8100,
status: "live",
current_release_id: "rel-1",
updated_at: "2026-10-13T10:00:00.000Z",
};
const RELEASE = {
id: "rel-1",
project_id: "proj-1",
run_id: "run-1",
sha: "abc1234def5678",
is_demo_live: true,
created_at: "2026-10-13T10:00:00.000Z",
};
function run(overrides: Record<string, unknown> = {}) {
return {
id: "run-1",
project_id: "proj-1",
branch: "dev",
sha: "abc1234def5678",
trigger: "engine",
status: "succeeded",
error_summary: "",
started_at: "2026-10-13T10:00:00.000Z",
finished_at: "2026-10-13T10:01:00.000Z",
created_at: "2026-10-13T10:00:00.000Z",
...overrides,
};
}
const PRODUCTION = {
target: {
kind: "ssh" as const,
host: "deploy.example.com",
port: 22,
username: "deployer",
auth_type: "password" as const,
remote_path: "/srv/app",
has_secret: true,
host_fingerprint: "SHA256:abc",
last_tested_at: "2026-10-13T09:00:00.000Z",
last_test_ok: true,
last_test_message: "اتصال و مسیر مقصد تأیید شد.",
},
deploys: [],
};
function bootstrap({ asOwner = true } = {}) {
mocks.session.user = asOwner ? { id: "u-1", email: "owner@example.com" } : null;
mocks.session.organizations = [ORG];
mocks.session.activeOrganization = ORG;
mocks.listProjects.mockResolvedValue([PROJECT]);
mocks.getRuntime.mockResolvedValue(RUNTIME);
mocks.listRuns.mockResolvedValue([run()]);
mocks.listReleases.mockResolvedValue([RELEASE]);
mocks.getRun.mockResolvedValue({
...run(),
steps: [
"checkout",
"setup",
"build",
"test",
"package",
"deploy_demo",
"healthcheck",
"route",
].map((name, index) => ({
id: `s-${index}`,
index,
name,
status: "succeeded",
exit_code: 0,
log_tail: "",
duration_ms: 10,
})),
});
mocks.productionOverview.mockResolvedValue(PRODUCTION);
mocks.startRun.mockResolvedValue(run());
mocks.rollbackDemo.mockResolvedValue(RELEASE);
mocks.deployToProduction.mockResolvedValue({
id: "dep-1",
project_id: "proj-1",
kind: "ssh",
host: "deploy.example.com",
status: "succeeded",
message: "استقرار در production انجام شد.",
log_tail: "done",
release_id: "rel-1",
rolled_back_from: null,
started_at: "2026-10-13T10:00:00.000Z",
finished_at: "2026-10-13T10:01:00.000Z",
created_at: "2026-10-13T10:00:00.000Z",
});
}
beforeEach(() => {
vi.clearAllMocks();
bootstrap();
});
// ---------------------------------------------------------------------------
// the console helpers
// ---------------------------------------------------------------------------
describe("the pipeline console", () => {
it("strips ANSI colour codes and carriage-return redraws", () => {
const raw = "ok fail\rdone";
expect(stripAnsi(raw)).toBe("ok fail\ndone");
});
it("leaves a clean line untouched", () => {
expect(stripAnsi("npm WARN deprecated foo@1.0.0")).toBe("npm WARN deprecated foo@1.0.0");
});
});
// ---------------------------------------------------------------------------
// the page
// ---------------------------------------------------------------------------
describe("the Application page", () => {
it("renders the app, the demo URL and the live release", async () => {
render(<ApplicationPage />);
await waitFor(() => expect(screen.getByText("اپلیکیشن نمونه")).toBeInTheDocument());
expect(screen.getByText("https://acme.msa.artacloud.ir")).toBeInTheDocument();
// The live release is shown as a short sha, never the raw one — in the
// header and again next to the run that produced it.
expect(screen.getAllByText("abc1234").length).toBeGreaterThan(0);
expect(screen.queryByText("abc1234def5678")).not.toBeInTheDocument();
expect(mocks.channels).toEqual(["org:org-1"]);
});
it("subscribes to the tenant channel so a build stays live", async () => {
render(<ApplicationPage />);
await waitFor(() => expect(mocks.channels).toEqual(["org:org-1"]));
});
it("shows the eight pipeline steps once a run is opened", async () => {
const { container } = render(<ApplicationPage />);
await waitFor(() => expect(screen.getByText("اپلیکیشن نمونه")).toBeInTheDocument());
// Opening a run fetches its detail; `getRun` is already stubbed in
// `bootstrap()` with all eight steps. The run row is identified by the badge
// the list renders for it — the first `<button>` on the page is the header's
// "re-run", which does something else entirely.
// `abc1234` appears in the header stat *and* in the run row; only the run
// row is clickable, so pick that one.
const runRow = within(container)
.getAllByText("abc1234")
.map((node) => node.closest("button"))
.find((candidate) => candidate !== null);
expect(runRow).not.toBeUndefined();
runRow?.click();
await waitFor(() => expect(screen.getByText("deploy_demo")).toBeInTheDocument());
// ...and all eight of them, not just some.
for (const name of ["checkout", "setup", "build", "test", "package", "healthcheck", "route"]) {
expect(screen.getByText(name)).toBeInTheDocument();
}
});
it("never renders a credential", async () => {
const { container } = render(<ApplicationPage />);
await waitFor(() => expect(screen.getByText("اپلیکیشن نمونه")).toBeInTheDocument());
// The production section only renders when its tab is opened.
const productionTab = screen.getByRole("tab", { name: "production" });
productionTab.click();
await waitFor(() => expect(screen.getByText("مقصد production")).toBeInTheDocument());
// The target's host and username are shown on purpose — they are how the
// operator recognises their own server. What must never appear is the
// credential, which the API does not even send.
// The host is rendered with its port, so match the whole cell.
expect(screen.getByText("deploy.example.com:22")).toBeInTheDocument();
expect(screen.getByText("ثبت شده")).toBeInTheDocument();
expect(container.textContent).not.toContain("deployer-password-value");
const text = (container.textContent ?? "").toLowerCase();
for (const key of ["secret_encrypted", "ssh_key", "auth_token", "bearer"]) {
expect(text).not.toContain(key);
}
});
it("hides the deploy button from a user without a session owner", async () => {
bootstrap({ asOwner: false });
render(<ApplicationPage />);
await waitFor(() => expect(screen.getByText("اپلیکیشن نمونه")).toBeInTheDocument());
expect(screen.queryByText("اجرای دوباره")).not.toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// production: the confirmation
// ---------------------------------------------------------------------------
describe("production deploy confirmation", () => {
async function openProduction() {
render(<ApplicationPage />);
await waitFor(() => expect(screen.getByText("اپلیکیشن نمونه")).toBeInTheDocument());
screen.getByRole("tab", { name: "production" }).click();
await waitFor(() => expect(screen.getByText("مقصد production")).toBeInTheDocument());
}
it("shows the deploy button to an org admin", async () => {
await openProduction();
expect(screen.getByText("استقرار در production")).toBeInTheDocument();
});
it("requires the org slug to be typed before it will ask the server", async () => {
const { fireEvent } = await import("@testing-library/react");
await openProduction();
screen.getByText("استقرار در production").click();
const field = await screen.findByPlaceholderText("acme");
expect(field).toBeInTheDocument();
// Nothing is sent until the operator confirms — the dialog is open, and that
// in itself must not have started anything.
expect(mocks.deployToProduction).not.toHaveBeenCalled();
fireEvent.change(field, { target: { value: "not-the-slug" } });
screen.getByText("تأیید").click();
// The client sends what was typed. The *server* is what refuses a slug that
// does not match — that is asserted in tests/test_deploy.py, because a
// client-side gate is a hint, not a control.
await waitFor(() =>
expect(mocks.deployToProduction).toHaveBeenCalledWith(
"org-1",
"proj-1",
"not-the-slug",
"rel-1",
),
);
});
});
// ---------------------------------------------------------------------------
// the confirm dialog is a real one: nothing is dismissed by a stray click
// ---------------------------------------------------------------------------
describe("the production panel as a whole", () => {
it("reports a missing target instead of offering a deploy button", async () => {
mocks.productionOverview.mockResolvedValue({ target: null, deploys: [] });
render(<ApplicationPage />);
await waitFor(() => expect(screen.getByText("اپلیکیشن نمونه")).toBeInTheDocument());
screen.getByRole("tab", { name: "production" }).click();
await waitFor(() => expect(screen.getByText("مقصدی تنظیم نشده است")).toBeInTheDocument());
expect(screen.queryByText("استقرار در production")).not.toBeInTheDocument();
});
it("shows a failed deploy with its reason", async () => {
mocks.productionOverview.mockResolvedValue({
target: PRODUCTION.target,
deploys: [
{
...({} as Record<string, unknown>),
id: "dep-1",
project_id: "proj-1",
kind: "ssh",
host: "deploy.example.com",
status: "failed",
message: "اجرای دستور پس از استقرار ناموفق بود.",
log_tail: "",
release_id: "rel-1",
rolled_back_from: null,
started_at: null,
finished_at: null,
created_at: "2026-10-13T10:00:00.000Z",
},
],
});
render(<ApplicationPage />);
await waitFor(() => expect(screen.getByText("اپلیکیشن نمونه")).toBeInTheDocument());
screen.getByRole("tab", { name: "production" }).click();
await waitFor(() =>
expect(screen.getByText("اجرای دستور پس از استقرار ناموفق بود.")).toBeInTheDocument(),
);
});
});