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