Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/.impeccable/surfaces/src-app-tsx.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ related_targets: ["src/ConsoleApp.tsx"]

# Administrator console (operate)

Scope: the signed-in console shell and every page behind it, plus the first-run step. Visitor mode: Operate.
Scope: the signed-in console shell and every page behind it, including Getting started on the Overview and the optional console tour. Visitor mode: Operate.
Audience: the administrator of one Parsar Core deployment. Task: judge health, capacity, usage and failures; inspect and delete or copy project assets; manage projects, keys and nodes.
Constraints: Web API only (`/core/v1`); missing data stays visibly missing; no small print, explanations live in help tips; API terms stay English in Chinese copy; zh-CN and English, light and dark.

Expand Down
44 changes: 29 additions & 15 deletions apps/web/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -368,9 +368,9 @@ heading row sits 12px above its content.
The recurring shapes in the body are the KPI strip (auto-fit columns, min 158px;
three per row below 1180px), chart grids (two equal columns, single below 1180px),
full-width table cards, and a fact row on detail pages. Overview has its own
arrangement: four metric tiles, Session activity beside the fleet topology (Core
in the middle, nodes left and right, solid lines online and dashed offline; Core
and each node open an anchored popover with a two-column glance and links to
arrangement: Getting started while a step is to do, four metric tiles, Session
activity beside the fleet topology (Core in the middle, nodes left and right,
solid lines online and dashed offline; Core and each node open an anchored popover with a two-column glance and links to
their pages), then the attention table and usage by project, each on its own
card with a 16px gap. Popovers are the overlay card (14px radius, overlay shadow,
16px padding): a 14px title, 12px labels over 13px values, links at a ruled foot. Nodes itself is a plain list with a detail page.
Expand Down Expand Up @@ -511,7 +511,7 @@ items are 30px rows with a 15px outline icon and Sidebar Ink text. Hover takes t
ink wash; the active item sits on a white chip (the page panel's surface, ringed)
with Ledger Ink at 500, and the chip glides to the next item on navigation. The
Platform group sits below a hairline. A secondary page (one Session) highlights its
parent. The footer holds sign-out and the language/theme menu. A detail page's back arrow returns to the page it was opened
parent. The footer holds Show Getting started, then sign-out and the language/theme menu. A detail page's back arrow returns to the page it was opened
from (a Skill opened from a template goes back to the template); opened directly,
it goes to its list. The arrow is labelled plainly "Back".

Expand Down Expand Up @@ -572,7 +572,7 @@ with a help tip. Safety notices (a key shown once, a destructive consequence) st
visible in body text.

### Onboarding
Signing in and first-run setup share one frame: a dark stage on the left (always
Signing in and the console tour share one frame: a dark stage on the left (always
dark, whatever the theme) and the task panel on the right, which follows the
theme. The stage is the product's one authored moment: a flickering indigo dot
grid under slow light rays (Magic UI's flickering grid and light rays), Core as
Expand All @@ -583,16 +583,30 @@ ink; it is a paragraph, not a heading, because the panel's title names the task.
Signing in asks for one thing, the deployment's Core key, in a single password
field; where the key is kept and what it can do sit behind a help tip beside the
label, and a refused key, too many attempts or an unavailable console is an error
beside the field. When no project exists yet, first run follows on the same stage
in two steps with numbered progress in the panel header: the first project and its
project API key (shown once; the example request types itself into a terminal),
and a three-chapter tour — Monitor,
Resources, Platform — whose stage shows a real dark screenshot of those pages,
tilted towards the panel. Steps change inside a View Transition: the stage holds
still while the panel slides and blurs across; opening the console dissolves
the onboarding forward and reveals the console in a circle growing from the
pressed button. With reduced motion the orbits hold their places, the grid is a
still frame, the request is a plain block and no transition runs.
beside the field. Signing in opens the console on the Overview. The optional tour
has three chapters — Monitor, Resources, Platform — whose stage shows a real dark
screenshot of those pages, tilted towards the panel; it takes the place of the
console until its last button, Skip or Escape, and then returns the focus to the
control that opened it. Entering the console or the tour, and leaving the tour,
happen inside a View Transition: the old page dissolves forward and the new one
is revealed in a circle growing from the pressed button. With reduced motion the
orbits hold their places, the grid is a still frame and no transition runs.

### Getting started
The first card on the Overview while any step is to do: a card header ("Getting
started", "n of 3 done", a help tip, then a ghost Take the tour button and an icon
button that hides it) over three rows split by Faint Rules. Each row has a 22px
numbered ring (a check on the tile wash when done), a 13px/600 title over one
12.5px Graphite line, a status dot (Done in green, To do in Idle Gray, Checking
pending, Unknown for a failed read) and one outline action while the step is to
do: Set up sandboxes, Add node, Open Nodes or Open sandbox backend; Create project
(which continues to the new project's first key) or Issue key; Projects and keys.
Add node, Create project and Issue key open their page with the dialog already
open. Every step done turns it into one line, "You're set", with Take the tour and
Dismiss; it stays, through the tour, until dismissed, and the checklist does not
come back on its own. The choice is kept per installation in the browser, also
while the deployment cannot be read; Show Getting started, a quiet row above the
sidebar's account controls, opens it again at any time.

### Sandbox setup
Setting up hosted sandboxes, and changing the provider or resources in maintenance,
Expand Down
15 changes: 10 additions & 5 deletions apps/web/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,16 @@ workbench.
many sleep (Core's retained minus active) on the Nodes list, a node's page, Sandbox
metrics and Overview; a node's allocations show how long each has been suspended and
about when Core reclaims it. Docker never suspends and shows none of it.
- **First run**: after signing in with the Core key while no project exists,
full-screen steps outside the shell create the first project (default name
`Default`) and its first project API key, show the plaintext once with an example request,
then give a three-chapter tour of the console (Monitor, Resources, Platform)
before opening it. Signing in uses the same stage.
- **Getting started**: signing in opens the console on the Overview; nothing is
forced first. While a step is to do, a Getting started checklist on the Overview
shows three steps, in any order, each with its state and one action: sandboxes
ready (a saved deployment and a node online and ready, or a saved E2B deployment
whose template build is not reported as not ready),
a project with an active key, and a first Session. Completion comes from reads the
console already makes. It can be hidden; Show Getting started in the sidebar
opens it again, and it ends with a brief "You're set". The optional
three-chapter tour of the console (Monitor, Resources, Platform) opens from it,
on the sign-in stage.
- Terminology: API terms stay in English in the Chinese UI (Agent, Session, Turn,
Skill, Vault, Credential, API key). The sign-in credential is the Core key
("Core Key"); keys issued in a project for applications are project API keys
Expand Down
92 changes: 83 additions & 9 deletions apps/web/e2e/access.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect, test, type Page } from "@playwright/test";

import { expectManagementBoundary, FIXTURE_CORE_KEY, resetFixture } from "./console";
import { expectManagementBoundary, FIXTURE_CORE_KEY, openConsole, resetFixture } from "./console";

test.afterEach(async ({ request }) => expectManagementBoundary(request));

Expand Down Expand Up @@ -38,23 +38,97 @@ test("signs in with the Core key, keeps it out of the browser, and signs out and
expect(await browserStorage(page)).not.toContain(FIXTURE_CORE_KEY);
});

test("sets up a fresh install: Core key sign-in, first project, a key shown once, the tour, then the console", async ({ page, request }) => {
test("opens a fresh install on the Overview's Getting started: a project and its key shown once, then the step is done", async ({ page, request }) => {
await resetFixture(request, "login", { fresh: true });
await page.addInitScript(() => window.localStorage.setItem("agents-core-web.language", "en"));
await page.goto("/");
await signIn(page, FIXTURE_CORE_KEY);

await expect(page.getByRole("heading", { name: "Create your first project" })).toBeVisible();
await page.locator("input[name=key-name]").fill("my-app");
await page.getByRole("button", { name: "Create project and key" }).click();
await expect(page.getByLabel("New key my-app")).toHaveValue(/fixture-secret/);
await page.getByRole("button", { name: "I've saved it, continue" }).click();
await expect(page.getByRole("heading", { name: "Overview", level: 1 })).toBeVisible();
const step = (name: string) => page.getByRole("region", { name: "Getting started" }).getByRole("listitem").filter({ hasText: name });
// The fixture deployment already has a ready node.
await expect(step("Get sandboxes ready")).toContainText("Done");
await expect(step("Create a project and issue a key")).toContainText("To do");
await expect(step("Run the first Session")).toContainText("To do");

await expect(page.getByLabel("New key my-app")).toHaveCount(0);
// The tour stays optional.
await page.getByRole("button", { name: "Take the tour" }).click();
await expect(page.getByRole("heading", { name: "Is it healthy, and where does it fail?" })).toBeVisible();
await page.getByRole("button", { name: "Skip" }).click();
await expect(page.getByRole("heading", { name: "Overview", level: 1 })).toBeVisible();

await step("Create a project and issue a key").getByRole("button", { name: "Create project" }).click();
await page.getByRole("dialog").getByLabel("Name").fill("My app");
// Core's project list answers late: the new project's key dialog must not wait for it.
const projectList = /\/core\/v1\/projects(\?.*)?$/;
await page.route(projectList, async (route) => {
if (route.request().method() === "GET") await new Promise((resolve) => setTimeout(resolve, 5_000));
await route.fallback();
});
await page.getByRole("dialog").getByRole("button", { name: "Create" }).click();
const issue = page.getByRole("dialog", { name: "Issue a key for My app" });
await expect(issue).toBeVisible({ timeout: 3_000 });
await page.unroute(projectList);
await issue.getByLabel("Key name").fill("my-app");
await issue.getByRole("button", { name: "Issue key" }).click();
const issued = page.getByRole("dialog", { name: "Key issued" });
await expect(issued.getByLabel("New key my-app")).toHaveValue(/fixture-secret/);
await issued.getByRole("button", { name: "I've saved this key" }).click();
await expect(page.getByLabel("New key my-app")).toHaveCount(0);

await page.getByRole("button", { name: "Overview", exact: true }).click();
await expect(step("Create a project and issue a key")).toContainText("Done");
const stored = await browserStorage(page);
expect(stored).not.toContain("fixture-secret");
expect(stored).not.toContain(FIXTURE_CORE_KEY);
});

test("shows every page empty on a fresh install, and Getting started hides, comes back and leads to sandbox setup", async ({ page, request }) => {
await openConsole(page, request, "overview", { fresh: true, sandbox: "none", nodes: "none" });
// What each page shows once its reads are done: an empty state, Core's own figures, or sandbox setup.
const loaded = (view: string) => view === "core-metrics" ? page.locator(".kpi-strip").first()
: view === "nodes" ? page.getByRole("heading", { name: "Where should sandboxes run?" })
: page.locator(".console-empty").first();
for (const view of ["core-metrics", "agent-metrics", "sandbox-metrics", "sessions", "agents", "templates", "skills", "files", "vaults", "projects", "nodes", "system", "overview"]) {
await page.goto(`/#${view}`);
await expect(loaded(view)).toBeVisible();
await expect(page.locator('[aria-busy="true"]')).toHaveCount(0);
await expect(page.getByText(/Loading|Connecting/)).toHaveCount(0);
// No failed read: neither an error on the page nor an error toast.
await expect(page.getByRole("alert")).toHaveCount(0);
await expect(page.locator(".toast-region-assertive")).toBeEmpty();
}

const checklist = page.getByRole("region", { name: "Getting started" });
await checklist.getByRole("button", { name: "Hide Getting started" }).click();
await expect(checklist).toHaveCount(0);
await page.reload();
await expect(page.locator(".console-empty").first()).toBeVisible();
await expect(checklist).toHaveCount(0);
await page.getByRole("button", { name: "Show Getting started" }).click();
const step = checklist.getByRole("listitem").filter({ hasText: "Get sandboxes ready" });
await expect(step).toContainText("To do");
await step.getByRole("button", { name: "Set up sandboxes" }).click();
await expect(page.getByRole("heading", { name: "Where should sandboxes run?" })).toBeVisible();
});

test("opens Add node from Getting started when no node has joined", async ({ page, request }) => {
await openConsole(page, request, "overview", { fresh: true, nodes: "none" });
const step = page.getByRole("region", { name: "Getting started" }).getByRole("listitem").filter({ hasText: "Get sandboxes ready" });
await expect(step).toContainText("To do");
await step.getByRole("button", { name: "Add node" }).click();
await expect(page.getByRole("heading", { name: "Nodes", level: 1 })).toBeVisible();
await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible();
});

test("reopens a finished Getting started from the sidebar and keeps You're set through the tour", async ({ page, request }) => {
await openConsole(page, request);
await expect(page.getByRole("heading", { name: "Overview", level: 1 })).toBeVisible();
await page.getByRole("button", { name: "Show Getting started" }).click();
const done = page.getByRole("region", { name: "You're set" });
await expect(done).toBeVisible();
await done.getByRole("button", { name: "Take the tour" }).click();
await page.getByRole("button", { name: "Skip" }).click();
await expect(done.getByRole("button", { name: "Take the tour" })).toBeFocused();
await done.getByRole("button", { name: "Dismiss" }).click();
await expect(done).toHaveCount(0);
});
12 changes: 9 additions & 3 deletions apps/web/e2e/console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,19 @@ const web = `http://127.0.0.1:${process.env.AGENTS_WEB_PORT ?? 4174}`;
/** The fixture deployment's Core key; the same value as CORE_KEY in fixture-console.mjs. */
export const FIXTURE_CORE_KEY = "fixture-core-key-3f9a2c71";

/**
* Fixture state options: `fresh` is a new install (no project, Session or Runtime),
* `sandbox` the sandbox deployment, and `nodes: "none"` a deployment no node has joined.
*/
export interface FixtureOptions { fresh?: boolean; sandbox?: "configured" | "none" | "e2b"; nodes?: "none" }

/** Fresh fixture state: signed out ("login") or already signed in ("authenticated"). */
export async function resetFixture(request: APIRequestContext, auth: "login" | "authenticated" = "authenticated", options: { fresh?: boolean; sandbox?: "configured" | "none" | "e2b" } = {}) {
await request.post(`${fixture}/__fixture/reset?auth=${auth}${options.fresh ? "&projects=none" : ""}&sandbox=${options.sandbox ?? "configured"}`);
export async function resetFixture(request: APIRequestContext, auth: "login" | "authenticated" = "authenticated", options: FixtureOptions = {}) {
await request.post(`${fixture}/__fixture/reset?auth=${auth}${options.fresh ? "&projects=none" : ""}&sandbox=${options.sandbox ?? "configured"}${options.nodes ? `&nodes=${options.nodes}` : ""}`);
}

/** Opens the console already signed in, in English. */
export async function openConsole(page: Page, request: APIRequestContext, hash = "overview", options: { sandbox?: "configured" | "none" | "e2b" } = {}) {
export async function openConsole(page: Page, request: APIRequestContext, hash = "overview", options: FixtureOptions = {}) {
await resetFixture(request, "authenticated", options);
await page.context().addCookies([{ name: "core_console", value: "fixture-session", url: web }]);
await page.addInitScript(() => window.localStorage.setItem("agents-core-web.language", "en"));
Expand Down
10 changes: 6 additions & 4 deletions apps/web/e2e/fixture-console.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,15 @@ function e2bDeployment() {
return { ...configuredDeployment(), provider: "e2b", mode: "direct", resources: { allocations: 3, pending: 1 }, specification: { resources: { cpus: 2, memory_mib: 2048 } }, e2b: { template: "parsar-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b", credential_configured: true, template_build: templateBuild } };
}

function reset(mode = "login", fresh = false, sandbox = "configured") {
function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "demo") {
const base = buildDemo();
const now = Math.floor(Date.now() / 1000);
const resources = buildResources(now, base.agents, base.sessions);
const admin = buildAdmin(now, base, resources);
// A fresh install: no project yet, so the console starts first-run setup.
if (fresh) admin.projects.splice(0);
// A fresh install: no project, Session or Runtime yet; Getting started leads.
if (fresh) for (const list of [admin.projects, base.sessions, base.observations, base.allocations]) list.splice(0);
// "none": no node has enrolled yet.
if (nodes === "none") base.nodes.splice(0);
state = {
...base, resources, admin,
// "authenticated": the console holds a session for the fixture cookie; "login": it holds none.
Expand Down Expand Up @@ -339,7 +341,7 @@ function registeredNode(nodeId) {
async function fixtureRoute(request, response, url) {
if (url.pathname === "/__fixture/health") return send(response, 200, { ok: true });
if (url.pathname === "/__fixture/reset" && request.method === "POST") {
reset(url.searchParams.get("auth") ?? "login", url.searchParams.get("projects") === "none", url.searchParams.get("sandbox") ?? "configured");
reset(url.searchParams.get("auth") ?? "login", url.searchParams.get("projects") === "none", url.searchParams.get("sandbox") ?? "configured", url.searchParams.get("nodes") ?? "demo");
return send(response, 200, { ok: true });
}
if (url.pathname === "/__fixture/fail-next" && request.method === "POST") {
Expand Down
Loading