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
+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(),
);
});
});