Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5c88aac0a | ||
|
|
a9079a1b17 |
@@ -0,0 +1,77 @@
|
||||
# The console ships as a SITE RELEASE, and identity is IAM only.
|
||||
#
|
||||
# This is deliberately NOT the hanzoai/ci `site:` lane. That lane uploads with
|
||||
# `mc mirror` and refuses to publish without S3_ADMIN_ACCESS_KEY/SECRET_KEY —
|
||||
# shared static object-store keys, not IAM — so it cannot express an IAM-only
|
||||
# publish. It is not bent into one either: the upload here is cloud's OWN zip
|
||||
# route, authorized by the SAME bearer as the publish that follows it. One
|
||||
# issuer, one token, no S3 credential anywhere in the path.
|
||||
#
|
||||
# WHERE THE BEARER COMES FROM. GitHub holds only KMS_CLIENT_ID/KMS_CLIENT_SECRET,
|
||||
# as everywhere else. cloud's /v1/kms/auth/login is not a second identity: it is a
|
||||
# broker that performs the IAM client_credentials exchange and returns IAM's own
|
||||
# JWT verbatim. Measured — the token it hands back is RS256 with
|
||||
# `iss: https://hanzo.id`, `owner: hanzo`, `tokenType: access-token`, which is the
|
||||
# same token minting it directly at hanzo.id/v1/iam/oauth/token produces. So this
|
||||
# IS the IAM bearer, there is no second credential path, and nothing has to be
|
||||
# sealed in KMS for it to work.
|
||||
#
|
||||
# Publishing is `push: main` only, so a pull request can never ship a release.
|
||||
name: Site
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- run: corepack enable && pnpm install --frozen-lockfile
|
||||
# The heap bump is load-bearing, not defensive: the full @hanzo/gui export
|
||||
# OOMs into a stub shell without it. The analytics id is the public per-site
|
||||
# id Dockerfile.embed baked, so the release tracks exactly as the embed did.
|
||||
- run: pnpm build:embed
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
NEXT_PUBLIC_ANALYTICS_WEBSITE_ID: 7dce54ee-41f6-4751-96bf-fe005067c7c7
|
||||
- name: Deploy + publish
|
||||
env:
|
||||
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
|
||||
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# node, not jq: this job already pins Node 22 above, so parsing with it
|
||||
# assumes nothing about what the runner image ships.
|
||||
jv () { node -p "JSON.parse(require('fs').readFileSync('$1','utf8'))['$2'] ?? ''"; }
|
||||
post () { # url content-type curl-data-flag value
|
||||
local code
|
||||
code=$(curl -sS -o /tmp/resp.json -w '%{http_code}' -X POST "$1" \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'X-Org-Id: hanzo' \
|
||||
-H "Content-Type: $2" "$3" "$4")
|
||||
# Each non-2xx here is a different fix (402 hosting, 403 wrong org, 404
|
||||
# no such site, 409 source moved, 413 too large), so print the body.
|
||||
case "$code" in 2??) ;;
|
||||
*) echo "::error::POST $1 -> HTTP $code"; cat /tmp/resp.json; exit 1 ;;
|
||||
esac
|
||||
}
|
||||
node -e 'require("fs").writeFileSync("/tmp/login.json",JSON.stringify({clientId:process.env.KMS_CLIENT_ID,clientSecret:process.env.KMS_CLIENT_SECRET}))'
|
||||
code=$(curl -sS -o /tmp/tok.json -w '%{http_code}' -X POST https://api.hanzo.ai/v1/kms/auth/login \
|
||||
-H 'Content-Type: application/json' --data-binary @/tmp/login.json)
|
||||
# Checked before parsing: the gateway answers a down KMS with a 503 whose
|
||||
# body is `no available server`, and feeding that to a JSON parser turns an
|
||||
# outage into a stack trace instead of the one line that names it.
|
||||
case "$code" in 2??) ;;
|
||||
*) echo "::error::KMS login -> HTTP $code"; cat /tmp/tok.json; echo; exit 1 ;;
|
||||
esac
|
||||
TOKEN=$(jv /tmp/tok.json accessToken)
|
||||
[ -n "$TOKEN" ] || { echo "::error::KMS login returned no IAM bearer"; exit 1; }
|
||||
echo "::add-mask::$TOKEN"
|
||||
# index.html must be at the ZIP ROOT — cloud refuses a source without one.
|
||||
( cd out && zip -qr ../site.zip . )
|
||||
post https://api.hanzo.ai/v1/projects/hanzo-console/deploy application/zip --data-binary @site.zip
|
||||
post https://api.hanzo.ai/v1/sites/hanzo-console/publish application/json -d '{"source":"hanzo-console"}'
|
||||
echo "published $(jv /tmp/resp.json releaseId) ($(jv /tmp/resp.json objects) objects)"
|
||||
+44
-47
@@ -1,51 +1,48 @@
|
||||
# hanzoai/console — the console image. It serves itself.
|
||||
#
|
||||
# The console is a static SPA export; this puts hanzoai/static in front of it. That
|
||||
# itself: hanzoai/static in front of the bundle. It exists so a console change
|
||||
# can reach production without a cloud release.
|
||||
#
|
||||
# Today console.hanzo.ai is answered by the cloud binary, which go:embeds the
|
||||
# bundle (webui/console.go `//go:embed all:dist`). That couples a frontend change
|
||||
# to a backend release: the bundle must be published, its tag pinned in cloud's
|
||||
# Dockerfile, and a whole cloud image rebuilt and rolled out. The pin commit that
|
||||
# preceded this one says what that costs — "four changes that could not reach
|
||||
# production".
|
||||
#
|
||||
# Nothing about the request path changes when this serves instead. The embedded
|
||||
# console is already a static export talking to the SAME origin's /v1, and cloud's
|
||||
# catch-all only ever answered paths that no API route claimed (its apiPrefixes
|
||||
# list is exactly "/v1/", "/api/", "/zap", "/healthz", "/readyz"). So the split is
|
||||
# the one the ingress already expresses for admin.lux.cloud: /v1 + /zap to cloud,
|
||||
# everything else here. Same bytes, same origin, same cookie — one fewer release
|
||||
# in the way.
|
||||
#
|
||||
# -spa, not a 404 page: every unknown path IS a client-side route for an app shell
|
||||
# (/models, /billing/budgets, a deep link someone pasted). The marketing site takes
|
||||
# the opposite setting for the opposite reason — there a miss is a mistake.
|
||||
|
||||
# console2 — Hanzo Cloud Console (Next.js 15 + @hanzo/gui). MIT OR Apache-2.0.
|
||||
# NEXT_PUBLIC_* are inlined at build time (browser config), so they are build args.
|
||||
FROM public.ecr.aws/docker/library/node:24-alpine AS build
|
||||
RUN apk add --no-cache git
|
||||
WORKDIR /console
|
||||
# Heap headroom so the full @hanzo/gui static export never OOMs into a stub; telemetry off.
|
||||
ENV NEXT_TELEMETRY_DISABLED=1 NODE_OPTIONS=--max-old-space-size=8192
|
||||
# The console.hanzo.ai analytics property (public per-site id, not a KMS secret) —
|
||||
# the same default Dockerfile.embed bakes, so a bundle served from here reports
|
||||
# identically to one served from inside cloud.
|
||||
ARG NEXT_PUBLIC_ANALYTICS_WEBSITE_ID=7dce54ee-41f6-4751-96bf-fe005067c7c7
|
||||
ENV NEXT_PUBLIC_ANALYTICS_WEBSITE_ID=$NEXT_PUBLIC_ANALYTICS_WEBSITE_ID
|
||||
WORKDIR /app
|
||||
# Exact commit for a deterministic Next build id (next.config.mjs generateBuildId).
|
||||
# The alpine image has no git binary, so CI passes the SHA as a build arg -> ENV,
|
||||
# baked into .next/BUILD_ID so every replica of this image shares ONE build id.
|
||||
ARG SOURCE_COMMIT=""
|
||||
ENV SOURCE_COMMIT=$SOURCE_COMMIT
|
||||
# Copy ALL source FIRST, then install — order matters under Kaniko --single-snapshot:
|
||||
# a `COPY` that FOLLOWS the install in the same stage drops that RUN's freshly
|
||||
# created node_modules (the 'next not found' cause — the install's own `test -f next`
|
||||
# passed, then `COPY . .` wiped node_modules before the build RUN). Putting COPY
|
||||
# before install means node_modules is created by the LAST RUNs and nothing clobbers
|
||||
# it. (Layer-cache for deps is moot here — the on-cluster build runs --cache=false.)
|
||||
COPY . .
|
||||
# public/ may be empty (git doesn't track empty dirs) — ensure it exists for the runner COPY.
|
||||
RUN mkdir -p public
|
||||
# corepack installs the exact pnpm from package.json's `packageManager`, so the
|
||||
# builder and a laptop resolve identically. --frozen-lockfile is the whole reason
|
||||
# this repo is on pnpm: the old `npm install` here could not be `npm ci`, because
|
||||
# @hanzo/gui's react-native tree resolves its platform/optional packages differently
|
||||
# across npm versions and a lockfile written by one npm failed under another. pnpm
|
||||
# records every platform in the lockfile, so the build installs exactly what is
|
||||
# committed and fails loudly instead of quietly resolving something else.
|
||||
RUN corepack enable && pnpm install --frozen-lockfile
|
||||
# FAIL-HARD: the export MUST emit a real bundle, never a placeholder shell. An
|
||||
# empty index.html would serve a blank page on every route with a 200, which is
|
||||
# indistinguishable from a working deploy until someone opens it.
|
||||
RUN pnpm build:embed && [ -s out/index.html ] && [ -d out/_next ] \
|
||||
&& echo ">> servable REAL console bundle: $(wc -c < out/index.html)-byte index.html, $(du -sh out/_next | cut -f1) _next/"
|
||||
# ONE brand-agnostic image: brand (IAM org/issuer/app + wordmark) is resolved at
|
||||
# RUNTIME from the request hostname (src/config/index.ts), and /v1 is same-origin
|
||||
# per host. Baking NEXT_PUBLIC_* here would inline a single brand and break that.
|
||||
# Next 15 + @hanzo/gui (large RN dep tree) overflows Node's default heap → OOMKill
|
||||
# (exit 137); cap the heap generously (chat uses 4096).
|
||||
ENV NEXT_TELEMETRY_DISABLED=1 NODE_OPTIONS=--max-old-space-size=6144
|
||||
RUN pnpm build
|
||||
|
||||
# hanzoai/static, digest-pinned: a base image is pinned by digest so the bytes
|
||||
# cannot change under a rebuild. (The console's OWN release is named by semver in
|
||||
# the values file — that is the version a human reads.)
|
||||
FROM ghcr.io/hanzoai/static@sha256:346ad30dc7f762c508b4467c2801b3d7e9ec201ec9b257bc7a38b60d59cecc05
|
||||
COPY --from=build /console/out/ /srv/
|
||||
EXPOSE 3000
|
||||
ENTRYPOINT ["/static"]
|
||||
CMD ["-root=/srv", "-spa", "-port=3000"]
|
||||
FROM public.ecr.aws/docker/library/node:24-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=4000
|
||||
RUN addgroup -S app && adduser -S app -G app
|
||||
COPY --from=build /app/.next ./.next
|
||||
COPY --from=build /app/public ./public
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/package.json ./package.json
|
||||
COPY --from=build /app/next.config.mjs ./next.config.mjs
|
||||
# next.config.mjs imports this at load time (build AND standalone runtime); copy it or the server ERR_MODULE_NOT_FOUND-crashes on boot.
|
||||
COPY --from=build /app/src/config/build-id.mjs ./src/config/build-id.mjs
|
||||
USER app
|
||||
EXPOSE 4000
|
||||
CMD ["node", "node_modules/next/dist/bin/next", "start", "-p", "4000"]
|
||||
|
||||
@@ -3588,8 +3588,8 @@ and the rail hid them.
|
||||
rail, `SubNav`, and ⌘K all say the same word. The eight products missing `subpages`
|
||||
now declare them; the icons the strips were carrying moved onto the declarations.
|
||||
- **`components/ui/SubNav.tsx` is the ONE strip**, rendered from `productSubpages` and
|
||||
hidden at `lg+` (`$lg={{ display: 'none' }}`) because the sidebar owns level 2
|
||||
there (then `DrillNav`; now `SubRows` — see "The rail stopped drilling" below). One declaration, two mounts — never two navs painting at once. It
|
||||
hidden at `lg+` (`$lg={{ display: 'none' }}`) because the sidebar's `DrillNav` owns
|
||||
level 2 there. One declaration, two mounts — never two navs painting at once. It
|
||||
takes an optional `href` for a product whose tabs carry URL state (Containers keeps
|
||||
its `?cluster=` selection across tabs). `subpageIcon` moved here and `dashboard.tsx`
|
||||
imports it, so the sub-page icon defaults exist once.
|
||||
@@ -3833,91 +3833,3 @@ reports signed out and the app sits on its loader forever. It emits base64URL no
|
||||
which is what a JWT segment actually is. And the assistant's composer carries its own
|
||||
mic with the same `Talk to Hanzo` label, mounted-but-hidden until the panel opens, so
|
||||
a bare attribute locator matches that one first: scope to `getByTestId('assistant-fab')`.
|
||||
|
||||
|
||||
## The agent quickstart, and the rail that stopped drilling (v8.5.62)
|
||||
|
||||
Two changes that share a shape: something that looked finished was standing in for
|
||||
the thing itself.
|
||||
|
||||
**The builder had no way in.** `AgentBuilder` — the canonical, host-agnostic one —
|
||||
was reachable only as a form in a side pane, from a board you first had to have
|
||||
agents to be looking at. `agents/quickstart` is the way someone with none starts:
|
||||
describe what you want in a sentence, or take a template, then configure, run and
|
||||
integrate.
|
||||
|
||||
- **Every step is an endpoint**, which is the whole design constraint. Describe →
|
||||
`POST /v1/chat/completions` (`draftAgent`) turns a sentence into a spec; Configure →
|
||||
the SAME `AgentBuilder`, seeded; Run → `POST /v1/agents/:ref/run` executes it and
|
||||
shows the RECORDED run; Integrate → prints the request that just worked. A ladder
|
||||
of steps is a promise about what happens, and a step that only draws a checkmark
|
||||
turns the promise into decoration. Steps 1 and 3 are optional by construction —
|
||||
their loaders may be absent, and the step then says exactly what is missing.
|
||||
- **`components/agent-builder/templates.ts`** — eight presets, pure data. A template is
|
||||
a PRESET, never a promise: it may only carry fields `toCreateBody` already expresses
|
||||
(`name`, `description`, `systemPrompt`, and the real `AgentConfig` knobs), and a test
|
||||
pins exactly that. **None names a tool.** Tools are per-org, so a hardcoded
|
||||
`web.search` would name something that may not exist and would fail at the agent's
|
||||
FIRST invocation rather than in the form. What a template CAN say truthfully is
|
||||
`useTools` / `webSearch`, which are real switches in the agent contract.
|
||||
- **The tool plane was live the whole time.** `loaders.ts` said "No live tool catalog
|
||||
endpoint on this deployment yet" and left the field typeable-only. `GET /v1/tools` is
|
||||
bound and serving — one flat set spanning connector actions, functions, zap-service
|
||||
routes, agents, skills and the org's own MCP servers, deduplicated by name, each
|
||||
flagged `activated`. `lib/api/tools.ts` reads it, `proxy-allow` admits the head, and
|
||||
`/v1/tools/call` is REFUSED there: running a tool belongs to whatever runs an agent,
|
||||
never to a browser tab. An org with nothing activated gets `{"tools":[]}` — a real
|
||||
empty answer, shown honestly rather than papered over.
|
||||
- **`defaultModel` was picking an embeddings model.** It named `zen-omni` as its exact
|
||||
match, and the live catalog does not carry that id — so the exact arm never fired and
|
||||
the fallback ran instead: `^zen[-.]` over an alphabetically sorted catalog, which
|
||||
selects `zen-embedding`. Every agent created without touching the model field was
|
||||
pointed at a SKU that cannot hold a conversation, and nothing caught it because the
|
||||
dead exact-match read like the rule. The family test is `^zen\d` now, because zen's
|
||||
naming splits cleanly: **`zen5*` are the text models; `zen-<noun>` names a MODALITY**
|
||||
(embedding, image, video, rerank, voice, vl, guard). The model and tool placeholders
|
||||
were advertising the same dead id and two invented tool names; both now say things
|
||||
that exist.
|
||||
|
||||
**The rail stopped drilling.** Clicking a product used to swap the ENTIRE sidebar for
|
||||
that product's sub-nav, behind a "Back to all products" button. The options were
|
||||
identical either way — what the drill took away was every OTHER product, which is
|
||||
precisely what someone needs when the reason they opened the rail was to go somewhere
|
||||
else. `SubRows` replaces `DrillNav`: a product's sub-pages expand beneath its own row,
|
||||
indented on a hairline, `inert` when collapsed.
|
||||
|
||||
- **The label navigates; the chevron only opens and closes.** One target doing both
|
||||
would make "show me what is in here" and "take me there" the same gesture.
|
||||
- `productIsOpen` / `toggleProduct` in `nav-accordion.ts`, beside the category pair and
|
||||
keyed apart from it. The default is the OPPOSITE of a category's, deliberately:
|
||||
categories are few and describe the catalog, so they open; products are many and each
|
||||
brings four to eight rows, so opening them all would bury the catalog under its own
|
||||
detail. The product you are IN is open unless you closed it, and that choice persists.
|
||||
- **A pinned product appears twice** — once under Pinned, once in its category — and
|
||||
exactly ONE copy may carry the sub-list. Two copies is two navs painting at once,
|
||||
which is the thing this rail exists to avoid, and it doubles the rail's height for no
|
||||
information. The pinned copy owns it.
|
||||
|
||||
**Verification.** `tsc --noEmit` clean; `vitest` **3259 passed / 8 skipped** (262 files;
|
||||
+draft/handle parsing, +templates, +the product accordion, +the tool-plane allow/refuse
|
||||
pair, and a `defaultModel` test that goes red on the `zen-embedding` regression).
|
||||
RENDER-proven: `e2e/agent-quickstart.spec.ts` (3 tests) asserts the ladder, that the
|
||||
gallery sits to the RIGHT of the composer by measured geometry at 1440, that searching
|
||||
narrows it, that picking Deep researcher carries its handle and prompt into step 2, and
|
||||
that 390 stacks without the body scrolling sideways. `e2e/level-2-nav.spec.ts` (5/5) was
|
||||
retargeted, not deleted: it now asserts "All products" is still on screen while a
|
||||
product is open — the invariant the drill could never have satisfied — and that no
|
||||
"Back to all products" button exists on any of the 18 converted products.
|
||||
|
||||
**ONE door.** The board's New-Agent button opened the builder in a side pane —
|
||||
the same component, reached by a different shape, with no templates, no drafting,
|
||||
and nowhere to run what it made. It goes to the quickstart now and
|
||||
`NewAgentForm` is deleted; two entrances to one builder is two things to keep in
|
||||
step, and the pane was the lesser of them. A spec clicks the board's CTA and
|
||||
asserts the URL lands on `/agents/quickstart`.
|
||||
|
||||
One placement note that cost a debug cycle: the quickstart branch must return BEFORE
|
||||
`AgentsModule`'s loading/error/empty states. Building an agent does not depend on
|
||||
reading the ones that exist, and the moments you most need the quickstart — no agents
|
||||
yet, or the registry not answering — are exactly the ones those early returns swallow
|
||||
it in.
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
/**
|
||||
* e2e: the agent quickstart.
|
||||
*
|
||||
* The surface in the screenshot: a step ladder, "What do you want to build?" with a
|
||||
* composer, and a searchable template gallery beside it. These are assertions only a
|
||||
* browser can make — that the two columns actually paint side by side at desktop,
|
||||
* stack on a phone without the body scrolling sideways, and that picking a template
|
||||
* carries its preset into the builder.
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4000 npx playwright test agent-quickstart
|
||||
*/
|
||||
import { test, expect, type Route, type Page } from '@playwright/test'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
|
||||
requireFixtureServer()
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
const ACCOUNT = { owner: 'hanzo', name: 'z', email: 'z@hanzo.ai', displayName: 'Z Admin', isAdmin: true }
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
const json = (route: Route, body: unknown, status = 200) =>
|
||||
route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
|
||||
/** Every backend 401s — this spec is about the SURFACE, not data. */
|
||||
async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
if (url.pathname.startsWith('/auth/')) return json(route, { ok: true })
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(url.pathname)) return route.continue()
|
||||
return json(route, { error: 'Sign in to use Hanzo Cloud.' }, 401)
|
||||
}
|
||||
|
||||
async function open(page: Page) {
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page, ACCOUNT)
|
||||
await page.goto(`${BASE_URL}/agents/quickstart`, { waitUntil: 'domcontentloaded' })
|
||||
await page.locator('[data-testid="product-content"]').first().waitFor({ state: 'attached', timeout: 30_000 })
|
||||
await page.waitForTimeout(1500)
|
||||
}
|
||||
|
||||
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
|
||||
|
||||
test('desktop: the ladder, the composer and the gallery', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await open(page)
|
||||
|
||||
await expect(page.getByText('What do you want to build?')).toBeVisible()
|
||||
await expect(page.getByLabel('Describe your agent')).toBeVisible()
|
||||
await expect(page.getByText('Browse templates')).toBeVisible()
|
||||
|
||||
// Step 1 is current; later steps are present but not yet reachable.
|
||||
await expect(page.getByRole('button', { name: /Step 1: Describe/ })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: /Step 3: Run/ })).toBeDisabled()
|
||||
|
||||
// The two columns sit SIDE BY SIDE — geometry, not source.
|
||||
const composer = await page.getByLabel('Describe your agent').boundingBox()
|
||||
const gallery = await page.getByText('Browse templates').boundingBox()
|
||||
expect(composer && gallery).toBeTruthy()
|
||||
expect(gallery!.x, 'the gallery is to the right of the composer').toBeGreaterThan(composer!.x + composer!.width - 1)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'agent-quickstart-desktop.png'), fullPage: false })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('the gallery searches, and picking a template carries its preset into the builder', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await open(page)
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Start from Deep researcher' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Start from Code reviewer' })).toBeVisible()
|
||||
|
||||
await page.getByLabel('Search templates').fill('extract')
|
||||
await page.waitForTimeout(400)
|
||||
await expect(page.getByRole('button', { name: 'Start from Structured extractor' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Start from Deep researcher' })).toHaveCount(0)
|
||||
|
||||
await page.getByLabel('Search templates').fill('')
|
||||
await page.waitForTimeout(300)
|
||||
await page.getByRole('button', { name: 'Start from Deep researcher' }).click()
|
||||
await page.waitForTimeout(700)
|
||||
|
||||
// Step 2: the ONE builder, carrying the template's preset — the handle and the
|
||||
// prompt the template declares, not an empty form.
|
||||
await expect(page.getByRole('button', { name: /Step 2: Configure/ })).toBeVisible()
|
||||
await expect(page.locator('input[value="researcher"]').first()).toBeVisible()
|
||||
await expect(page.getByText(/You research questions/).first()).toBeVisible()
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'agent-quickstart-configure.png'), fullPage: false })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('phone: it stacks and the body never scrolls sideways', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
|
||||
const page = await ctx.newPage()
|
||||
await open(page)
|
||||
|
||||
await expect(page.getByText('What do you want to build?')).toBeVisible()
|
||||
await expect(page.getByLabel('Describe your agent')).toBeVisible()
|
||||
|
||||
const scrolls = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
|
||||
)
|
||||
expect(scrolls, 'body must not scroll horizontally').toBe(false)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'agent-quickstart-phone.png'), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('a template card is reachable and operable by keyboard, and it rings', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await open(page)
|
||||
|
||||
const card = page.getByRole('button', { name: 'Start from Deep researcher' })
|
||||
await card.focus()
|
||||
await expect(card).toBeFocused()
|
||||
|
||||
// The focus law lives in globals.css and keys off [tabindex] among others — a card
|
||||
// that takes focus and shows nothing is worse than one that cannot be reached.
|
||||
const ring = await card.evaluate((el) => {
|
||||
const s = getComputedStyle(el)
|
||||
return { width: s.outlineWidth, style: s.outlineStyle, color: s.outlineColor }
|
||||
})
|
||||
expect(ring.style, 'the focused card draws an outline').not.toBe('none')
|
||||
expect(parseFloat(ring.width), 'the outline has real width').toBeGreaterThan(0)
|
||||
|
||||
// Enter picks it — the same thing a click does.
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(700)
|
||||
await expect(page.getByRole('button', { name: /Step 2: Configure/ })).toBeVisible()
|
||||
await expect(page.locator('input[value="researcher"]').first()).toBeVisible()
|
||||
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('the board\'s New Agent button is the SAME door as the quickstart', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page, ACCOUNT)
|
||||
await page.goto(`${BASE_URL}/agents`, { waitUntil: 'domcontentloaded' })
|
||||
await page.locator('[data-testid="product-content"]').first().waitFor({ state: 'attached', timeout: 30_000 })
|
||||
await page.waitForTimeout(1200)
|
||||
|
||||
// Whichever New-Agent affordance the board is showing (header button or empty
|
||||
// state), it must LAND on the quickstart — not open a second, differently-shaped
|
||||
// create form in a side pane.
|
||||
const cta = page.getByRole('button', { name: /New Agent/i }).filter({ visible: true }).first()
|
||||
await cta.click()
|
||||
await page.waitForTimeout(900)
|
||||
|
||||
expect(new URL(page.url()).pathname).toBe('/agents/quickstart')
|
||||
await expect(page.getByText('What do you want to build?')).toBeVisible()
|
||||
|
||||
await ctx.close()
|
||||
})
|
||||
@@ -1,355 +0,0 @@
|
||||
/**
|
||||
* e2e: the Deploy section — mocked-network render + RESPONSIVE proof.
|
||||
*
|
||||
* Runs against a LOCAL server (BASE_URL=http://localhost:4000) with the whole
|
||||
* network mocked, so the shapes asserted here are the shapes cloud actually
|
||||
* serves (bare arrays for projects/apps/sites, `{applications}` for the CD
|
||||
* projection, `{builds}` for CI, `{buckets}` for storage) and nothing depends on
|
||||
* live estate data.
|
||||
*
|
||||
* It proves: the section renders in the console's own chrome (left nav, org
|
||||
* switcher, dark cards), the unified board folds APPS and SITES into one list,
|
||||
* each of the six sub-pages renders its own panel, the deploy FORM opens and
|
||||
* validates without posting, and at 390px the body never scrolls horizontally.
|
||||
* Screenshots at desktop (1440) and mobile (390).
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4000 npx playwright test deploy-section
|
||||
*/
|
||||
import { test, expect, type Page, type Route } from '@playwright/test'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
requireFixtureServer()
|
||||
|
||||
/** `GET /v1/platform/projects` — a bare array, as `apps/platform` serves it. */
|
||||
const PROJECTS = [{ id: 'p1', org: 'hanzo', slug: 'web', name: 'Web', applications: 2, createdAt: 1_700_000_000_000 }]
|
||||
|
||||
/** `GET /v1/platform/projects/web/apps` — bare `appList`. */
|
||||
const APPS = [
|
||||
{
|
||||
id: 'a1',
|
||||
org: 'hanzo',
|
||||
projectId: 'p1',
|
||||
slug: 'api',
|
||||
name: 'api',
|
||||
source: 'git',
|
||||
repo: { url: 'https://git.hanzo.ai/hanzoai/api.git', branch: 'main' },
|
||||
domains: ['api.hanzo.app', 'api.example.com'],
|
||||
status: 'live',
|
||||
phase: 'Running',
|
||||
health: 'green',
|
||||
replicas: 2,
|
||||
port: 8080,
|
||||
env: [],
|
||||
updatedAt: 1_754_400_000_000,
|
||||
},
|
||||
{
|
||||
id: 'a2',
|
||||
org: 'hanzo',
|
||||
projectId: 'p1',
|
||||
slug: 'worker',
|
||||
name: 'worker',
|
||||
source: 'git',
|
||||
repo: { url: 'https://git.hanzo.ai/hanzoai/worker.git' },
|
||||
domains: [],
|
||||
status: 'building',
|
||||
replicas: 1,
|
||||
env: [],
|
||||
updatedAt: 1_754_300_000_000,
|
||||
},
|
||||
]
|
||||
|
||||
/** `GET /v1/platform/sites` — bare `projectsProjects`. */
|
||||
const SITES = [
|
||||
{
|
||||
id: 's1',
|
||||
org: 'hanzo',
|
||||
slug: 'docs',
|
||||
name: 'docs',
|
||||
repo: { url: 'https://git.hanzo.ai/hanzoai/docs.git' },
|
||||
framework: 'next',
|
||||
status: 'live',
|
||||
liveUrl: 'https://docs.hanzo.app',
|
||||
createdAt: 1_754_000_000_000,
|
||||
updatedAt: 1_754_350_000_000,
|
||||
},
|
||||
]
|
||||
|
||||
/** `GET /v1/deploy/applications` — the reconciliation projection. */
|
||||
const CD = {
|
||||
applications: [
|
||||
{
|
||||
name: 'api',
|
||||
namespace: 'tenant-hanzo',
|
||||
image: { repository: 'ghcr.io/hanzoai/api', tag: 'v1.4.2' },
|
||||
phase: 'Running',
|
||||
health: 'Healthy',
|
||||
sync: 'Synced',
|
||||
replicas: 2,
|
||||
readyReplicas: 2,
|
||||
liveTag: 'v1.4.2',
|
||||
},
|
||||
{
|
||||
name: 'worker',
|
||||
namespace: 'tenant-hanzo',
|
||||
image: { repository: 'ghcr.io/hanzoai/worker', tag: 'v0.9.1' },
|
||||
phase: 'Progressing',
|
||||
health: 'Progressing',
|
||||
sync: 'OutOfSync',
|
||||
replicas: 1,
|
||||
readyReplicas: 0,
|
||||
liveTag: 'v0.9.0',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/** `GET /v1/builds`. */
|
||||
const BUILDS = {
|
||||
builds: [
|
||||
{ id: 'b1', repo: 'hanzoai/api', commit: '9f2c1ab77d10', tag: 'v1.4.2', status: 'succeeded', startedAt: '2026-08-05T18:04:00Z', duration: '2m14s' },
|
||||
{ id: 'b2', repo: 'hanzoai/worker', commit: '3ac9de00b412', tag: 'v0.9.1', status: 'building', startedAt: '2026-08-05T18:22:00Z', duration: '' },
|
||||
],
|
||||
}
|
||||
|
||||
/** `GET /v1/s3/buckets` — Unix SECONDS on `createdAt`, as the S3 app serves it. */
|
||||
const BUCKETS = { buckets: [{ name: 'docs-site', createdAt: 1_754_000_000 }, { name: 'media', createdAt: 1_750_000_000 }] }
|
||||
|
||||
const json = (route: Route, body: unknown) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
|
||||
/**
|
||||
* Mock every read the section makes, then answer everything else with an empty
|
||||
* object so an unmocked call renders an honest empty state instead of hanging.
|
||||
* Registered BEFORE `primeSession`, whose IAM handlers must win (Playwright
|
||||
* matches routes in reverse registration order).
|
||||
*/
|
||||
async function mockNetwork(page: Page): Promise<void> {
|
||||
await page.route('**/v1/**', (route) => {
|
||||
const path = new URL(route.request().url()).pathname
|
||||
if (path.endsWith('/v1/platform/projects')) return json(route, PROJECTS)
|
||||
if (path.includes('/v1/platform/projects/') && path.endsWith('/apps')) return json(route, APPS)
|
||||
if (path.endsWith('/v1/platform/sites')) return json(route, SITES)
|
||||
if (path.endsWith('/v1/deploy/applications')) return json(route, CD)
|
||||
if (path.endsWith('/v1/builds')) return json(route, BUILDS)
|
||||
if (path.endsWith('/v1/s3/buckets')) return json(route, BUCKETS)
|
||||
return json(route, {})
|
||||
})
|
||||
await primeSession(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a Deploy tab and wait for THAT tab's panel to paint.
|
||||
*
|
||||
* Keyed on the panel's own test id rather than a heading role: the console
|
||||
* renders through Tamagui/react-native-web, where a `<Text>` title carries no
|
||||
* implicit heading role, so `getByRole('heading')` matches nothing here.
|
||||
*/
|
||||
async function openDeploy(page: Page, tab = ''): Promise<void> {
|
||||
await page.goto(`${BASE_URL}/deploy${tab ? `/${tab}` : ''}`, { waitUntil: 'domcontentloaded' })
|
||||
const id = tab === '' ? 'deploy-board' : `deploy-panel-${tab}`
|
||||
await expect(page.getByTestId(id)).toBeVisible({ timeout: 45_000 })
|
||||
}
|
||||
|
||||
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
|
||||
|
||||
test('the board folds apps and sites into one list, in the console chrome', async ({ page }) => {
|
||||
await mockNetwork(page)
|
||||
await page.setViewportSize({ width: 1440, height: 1000 })
|
||||
await openDeploy(page)
|
||||
|
||||
// Both backends, one board — the whole point of the section.
|
||||
const board = page.getByTestId('deploy-board')
|
||||
await expect(board.getByText('api', { exact: true })).toBeVisible()
|
||||
await expect(board.getByText('docs', { exact: true })).toBeVisible()
|
||||
await expect(board.getByText('api.hanzo.app', { exact: true })).toBeVisible()
|
||||
|
||||
// The counts are derived from the rows, never fabricated: 2 apps + 1 site,
|
||||
// two of which the backend itself calls live.
|
||||
await expect(board.getByText('Deployments', { exact: true })).toBeVisible()
|
||||
await expect(board.getByText('3', { exact: true })).toBeVisible()
|
||||
await expect(board.getByText('Sites', { exact: true })).toBeVisible()
|
||||
|
||||
// It is IN the console, not a bolt-on page: the shell's breadcrumb trail sits
|
||||
// above it, and its level-2 nav is DECLARED. That strip hides itself at lg+ —
|
||||
// the sidebar rail owns level 2 there — so it is asserted present, not visible.
|
||||
await expect(page.getByTestId('subnav-deploy')).toHaveCount(1)
|
||||
await expect(page.getByText('Home', { exact: true }).first()).toBeVisible()
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'deploy-board-desktop.png'), fullPage: false })
|
||||
})
|
||||
|
||||
test('CD, CI and Storage each read their own canonical head', async ({ page }) => {
|
||||
await mockNetwork(page)
|
||||
await page.setViewportSize({ width: 1440, height: 1000 })
|
||||
|
||||
// CD — the reconciliation projection, showing declared → running drift.
|
||||
await openDeploy(page, 'cd')
|
||||
const cd = page.getByTestId('deploy-panel-cd')
|
||||
await expect(cd.getByText('Synced', { exact: true })).toBeVisible()
|
||||
await expect(cd.getByText('OutOfSync', { exact: true })).toBeVisible()
|
||||
await expect(cd.getByText('v0.9.1 → v0.9.0', { exact: true })).toBeVisible()
|
||||
await page.screenshot({ path: join(SHOTS, 'deploy-cd.png') })
|
||||
|
||||
// CI — the native build records.
|
||||
await openDeploy(page, 'ci')
|
||||
const ci = page.getByTestId('deploy-panel-ci')
|
||||
await expect(ci.getByText('hanzoai/api', { exact: true })).toBeVisible()
|
||||
await expect(ci.getByText('9f2c1ab77d10', { exact: true })).toBeVisible()
|
||||
await page.screenshot({ path: join(SHOTS, 'deploy-ci.png') })
|
||||
|
||||
// Storage — org buckets.
|
||||
await openDeploy(page, 'storage')
|
||||
await expect(page.getByTestId('deploy-panel-storage').getByText('docs-site', { exact: true })).toBeVisible()
|
||||
await page.screenshot({ path: join(SHOTS, 'deploy-storage.png') })
|
||||
})
|
||||
|
||||
test('Domains lists EVERY bound host, including the custom one', async ({ page }) => {
|
||||
await mockNetwork(page)
|
||||
await page.setViewportSize({ width: 1440, height: 1000 })
|
||||
|
||||
await openDeploy(page, 'domains')
|
||||
const domains = page.getByTestId('deploy-panel-domains')
|
||||
// `api` carries two hosts; a view folded to the primary would hide the second —
|
||||
// which is precisely the domain someone bound on purpose.
|
||||
await expect(domains.getByText('api.hanzo.app', { exact: true })).toBeVisible()
|
||||
await expect(domains.getByText('api.example.com', { exact: true })).toBeVisible()
|
||||
await expect(domains.getByText('docs.hanzo.app', { exact: true })).toBeVisible()
|
||||
await page.screenshot({ path: join(SHOTS, 'deploy-domains.png') })
|
||||
})
|
||||
|
||||
test('Apps and Sites narrow the SAME board', async ({ page }) => {
|
||||
await mockNetwork(page)
|
||||
await page.setViewportSize({ width: 1440, height: 1000 })
|
||||
|
||||
await openDeploy(page, 'apps')
|
||||
const apps = page.getByTestId('deploy-panel-apps')
|
||||
await expect(apps.getByText('worker', { exact: true })).toBeVisible()
|
||||
await expect(apps.getByText('docs', { exact: true })).toHaveCount(0)
|
||||
|
||||
await openDeploy(page, 'sites')
|
||||
const sites = page.getByTestId('deploy-panel-sites')
|
||||
await expect(sites.getByText('docs', { exact: true })).toBeVisible()
|
||||
await expect(sites.getByText('worker', { exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('the deploy form opens, derives a name, and refuses a bad host without posting', async ({ page }) => {
|
||||
await mockNetwork(page)
|
||||
await page.setViewportSize({ width: 1440, height: 1200 })
|
||||
|
||||
// A refused form must not create anything. Scoped to the DEPLOY writes —
|
||||
// the console shell PATCHes its own UI preferences on navigation, which is
|
||||
// unrelated traffic and would make a blanket "no writes" assertion a lie.
|
||||
const writes: string[] = []
|
||||
page.on('request', (r) => {
|
||||
const u = r.url()
|
||||
if (r.method() !== 'GET' && (u.includes('/v1/platform/') || u.includes('/v1/projects'))) {
|
||||
writes.push(`${r.method()} ${u}`)
|
||||
}
|
||||
})
|
||||
|
||||
await openDeploy(page)
|
||||
await page.getByRole('button', { name: 'New deployment' }).click()
|
||||
const form = page.getByTestId('new-deploy')
|
||||
await expect(form).toBeVisible()
|
||||
|
||||
// The name follows the repo until someone edits it by hand.
|
||||
await form.getByPlaceholder('https://git.hanzo.ai/hanzoai/console.git').fill('https://git.hanzo.ai/hanzoai/console.git')
|
||||
// `exact` matters: the repo field's own placeholder CONTAINS "console".
|
||||
await expect(form.getByPlaceholder('console', { exact: true })).toHaveValue('console')
|
||||
|
||||
// A URL in the host field is refused in the form, before any request.
|
||||
await form.getByPlaceholder('app.example.com').fill('https://bad.example.com')
|
||||
// The form REFUSES rather than posting: Deploy is disabled and says why. Scoped
|
||||
// to the form because "Deploy" is also the nav item and the breadcrumb leaf.
|
||||
await expect(form.getByRole('alert')).toContainText('https://')
|
||||
await expect(form.getByRole('button', { name: 'Deploy', exact: true })).toBeDisabled()
|
||||
|
||||
// Correcting the host clears the refusal and arms the button.
|
||||
await form.getByPlaceholder('app.example.com').fill('app.example.com')
|
||||
await expect(form.getByRole('alert')).toHaveCount(0)
|
||||
await expect(form.getByRole('button', { name: 'Deploy', exact: true })).toBeEnabled()
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'deploy-form.png') })
|
||||
expect(writes, 'a rejected form must not create an app or a site').toEqual([])
|
||||
})
|
||||
|
||||
test('every env var is SEALED by default, and only a named one opens', async ({ page }) => {
|
||||
await mockNetwork(page)
|
||||
await page.setViewportSize({ width: 1440, height: 1400 })
|
||||
await openDeploy(page)
|
||||
await page.getByRole('button', { name: 'New deployment' }).click()
|
||||
const form = page.getByTestId('new-deploy')
|
||||
|
||||
// Credential names a key-NAME regex would have missed, plus plain config.
|
||||
await form.getByRole('textbox').last().fill('STRIPE_SK=sk_live_x\nGH_PAT=ghp_x\nDB_PASS=hunter2\nPORT=8080')
|
||||
const vars = form.getByTestId('env-vars')
|
||||
await expect(vars).toBeVisible()
|
||||
|
||||
// Default is sealed for ALL FOUR — including the three the old regex let through.
|
||||
for (const key of ['STRIPE_SK', 'GH_PAT', 'DB_PASS', 'PORT']) {
|
||||
await expect(vars.getByRole('button', { name: `${key} Sealed` })).toHaveAttribute('aria-pressed', 'true')
|
||||
}
|
||||
|
||||
// Opening PORT opens ONLY PORT.
|
||||
await vars.getByRole('button', { name: 'PORT Public' }).click()
|
||||
await expect(vars.getByRole('button', { name: 'PORT Public' })).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect(vars.getByRole('button', { name: 'STRIPE_SK Sealed' })).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'deploy-env-secrets.png') })
|
||||
|
||||
// A Public mark must not outlive its line: delete PORT, retype it, and it comes
|
||||
// back SEALED like any new variable rather than inheriting the old mark.
|
||||
const env = form.getByRole('textbox').last()
|
||||
await env.fill('STRIPE_SK=sk_live_x')
|
||||
await expect(vars.getByRole('button', { name: 'PORT Sealed' })).toHaveCount(0)
|
||||
await env.fill('STRIPE_SK=sk_live_x\nPORT=9090')
|
||||
await expect(vars.getByRole('button', { name: 'PORT Sealed' })).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
test('a half-loaded board names the gap and shows no count it cannot know', async ({ page }) => {
|
||||
// Sites answer; the APPS fan-out fails. The board must not render "Apps 0".
|
||||
await page.route('**/v1/**', (route) => {
|
||||
const path = new URL(route.request().url()).pathname
|
||||
if (path.endsWith('/v1/platform/projects')) return json(route, PROJECTS)
|
||||
if (path.includes('/v1/platform/projects/') && path.endsWith('/apps')) {
|
||||
return route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"boom"}' })
|
||||
}
|
||||
if (path.endsWith('/v1/platform/sites')) return json(route, SITES)
|
||||
return json(route, {})
|
||||
})
|
||||
await primeSession(page)
|
||||
await page.setViewportSize({ width: 1440, height: 1000 })
|
||||
|
||||
await openDeploy(page)
|
||||
const board = page.getByTestId('deploy-board')
|
||||
await expect(board.getByRole('status')).toContainText('Apps could not be fully loaded')
|
||||
// The site that DID load still renders — a partial read is not an outage.
|
||||
await expect(board.getByText('docs', { exact: true })).toBeVisible()
|
||||
// Sites is knowable (1); Apps and the totals are not.
|
||||
await expect(board.getByText('1', { exact: true })).toBeVisible()
|
||||
await expect(board.getByText('—', { exact: true }).first()).toBeVisible()
|
||||
|
||||
// The Domains list inherits the same gap, and says so.
|
||||
await openDeploy(page, 'domains')
|
||||
await expect(page.getByTestId('deploy-panel-domains').getByRole('status')).toContainText('Apps could not be fully loaded')
|
||||
await page.screenshot({ path: join(SHOTS, 'deploy-partial.png') })
|
||||
})
|
||||
|
||||
test('at 390px the body never scrolls horizontally', async ({ page }) => {
|
||||
await mockNetwork(page)
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await openDeploy(page)
|
||||
|
||||
const overflow = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
)
|
||||
expect(overflow, 'the page must not scroll sideways on a phone').toBeLessThanOrEqual(1)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'deploy-board-mobile.png'), fullPage: false })
|
||||
})
|
||||
+13
-24
@@ -2,15 +2,9 @@
|
||||
* e2e: ONE level-2 nav.
|
||||
*
|
||||
* Clicking into a product must reveal ITS options rather than replacing the screen,
|
||||
* and there must be exactly ONE such nav on screen — not the sidebar's level 2 AND a
|
||||
* competing tab strip in the content, which is what `/models` used to do (eight items
|
||||
* in the rail, four in the content, disagreeing on the index's own name).
|
||||
*
|
||||
* "Rather than replacing the screen" is now literal on both axes: the product's
|
||||
* sub-pages expand BENEATH its row and the rest of the catalog stays put. The rail
|
||||
* used to swap itself for the product's sub-nav behind a "Back to all products"
|
||||
* button, so these specs assert the other products are still there — that is the
|
||||
* whole point of the change, and the part a future drill would silently undo.
|
||||
* and there must be exactly ONE such nav on screen — not the sidebar's drill-down AND
|
||||
* a competing tab strip in the content, which is what `/models` used to do (eight
|
||||
* items in the rail, four in the content, disagreeing on the index's own name).
|
||||
*
|
||||
* These are assertions only a browser can make. They read COMPUTED style and
|
||||
* GEOMETRY, not source: a strip hidden by a `$lg` media style prop is still in the
|
||||
@@ -82,12 +76,8 @@ test('desktop: the sidebar owns level 2 — the content strip is not a second na
|
||||
const page = await ctx.newPage()
|
||||
await open(page, '/models')
|
||||
|
||||
// The rail expanded Models in place — and did NOT swap itself for it.
|
||||
await expect(page.getByRole('button', { name: 'Back to all products' })).toHaveCount(0)
|
||||
// The rest of the catalog is still there — "All products" sits at the FOOT of the
|
||||
// product list, so its presence proves the list was never swapped away. This is the
|
||||
// assertion the drill could not have passed.
|
||||
await expect(page.getByRole('button', { name: 'All products' }).first()).toBeVisible()
|
||||
// The rail drilled into Models and shows the product's own options.
|
||||
await expect(page.getByRole('button', { name: 'Back to all products' })).toBeVisible()
|
||||
|
||||
// The index is named what the PRODUCT calls it — Models' index is the Catalog,
|
||||
// not a generic "Overview". This is the registry's `indexLabel`, read by the nav.
|
||||
@@ -176,10 +166,10 @@ test('back returns a level without losing pinned state', async ({ browser }) =>
|
||||
await page.waitForTimeout(900)
|
||||
expect(new URL(page.url()).pathname).toBe('/models')
|
||||
|
||||
// Models is still expanded with the same options — browser Back moved the ROUTE,
|
||||
// and the rail followed it without collapsing what the user was looking at.
|
||||
// Still drilled into Models with the same options — Back moved the LEVEL, it did
|
||||
// not throw the user out to the product list.
|
||||
await expect(page.getByRole('button', { name: 'Back to all products' })).toBeVisible()
|
||||
await expect(visibleTab(page, 'Catalog').first()).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Back to all products' })).toHaveCount(0)
|
||||
|
||||
expect(await page.evaluate(() => localStorage.getItem('hanzo.preferences.cache'))).toBe(pinsBefore)
|
||||
await ctx.close()
|
||||
@@ -187,10 +177,9 @@ test('back returns a level without losing pinned state', async ({ browser }) =>
|
||||
|
||||
/**
|
||||
* Every product that used to carry its own `const TABS` — the whole conversion, in
|
||||
* one sweep. For each: the page renders, the rail expands it in place, and the
|
||||
* content strip is present but PAINTS NOTHING at lg+. That is the "no second nav"
|
||||
* invariant, and it is the thing that regresses the moment someone adds a tab bar
|
||||
* back.
|
||||
* one sweep. For each: the page renders, the rail drills into it, and the content
|
||||
* strip is present but PAINTS NOTHING at lg+. That is the "no second nav" invariant,
|
||||
* and it is the thing that regresses the moment someone adds a tab bar back.
|
||||
*/
|
||||
const CONVERTED = [
|
||||
'models', 'evals', 'ai-accounts', 'containers', 'analytics', 'finetuning', 'team',
|
||||
@@ -211,8 +200,8 @@ test('no product paints a second level-2 nav at lg+', async ({ browser }) => {
|
||||
).toBe('none')
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Back to all products' }),
|
||||
`${id}: the rail expands in place — it must never swap itself for one product`,
|
||||
).toHaveCount(0)
|
||||
`${id}: the rail drilled in`,
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
await ctx.close()
|
||||
|
||||
@@ -1,42 +1,47 @@
|
||||
# Canonical CI config for hanzoai/console — read by the hanzoai/ci reusable
|
||||
# (.hanzo/workflows/cicd.yml) and platform.hanzo.ai. hanzoai/ci pushes to `repo:`
|
||||
# (GHCR) and server-side-mirrors to registry.hanzo.ai automatically.
|
||||
# (.hanzo/workflows/cicd.yml) and platform.hanzo.ai.
|
||||
#
|
||||
# TWO artifacts, one bundle. The console is a static SPA export; what differs is
|
||||
# only who serves it:
|
||||
# The frontend ships as a SITE RELEASE — .hanzo/workflows/site.yml. A push to main
|
||||
# builds the static export and promotes it on /v1/sites with an IAM bearer, and that
|
||||
# is the whole path: no image, no cloud rebuild, rollback is a pointer flip. It is
|
||||
# NOT the hanzoai/ci `site:` lane — that lane uploads with `mc mirror` and refuses
|
||||
# to publish without static S3 admin keys, which is a second, non-IAM credential.
|
||||
#
|
||||
# console-embed the bundle alone at /dist. hanzoai/cloud does
|
||||
# `COPY --from=console /dist/` so a cloud release never rebuilds
|
||||
# npm+Next. Needed only while cloud go:embeds the console.
|
||||
# console the bundle behind hanzoai/static, serving itself. This is how
|
||||
# a console change ships WITHOUT a cloud release: move image.tag
|
||||
# in a universe values file and cd rolls it.
|
||||
# It used to ship welded INTO the cloud binary via the console-embed image below
|
||||
# (cloud `COPY --from=console /dist/` → go:embed), which priced a CSS fix at a
|
||||
# ~22-minute cloud build and a 2m15s api.hanzo.ai outage. Nobody pays that for a
|
||||
# frontend change, so nobody shipped: the live console ran 13 commits behind this
|
||||
# branch. The embed image stays declared until cloud stops consuming it.
|
||||
#
|
||||
# The Next.js SERVER image that used to be the second entry is gone. It was
|
||||
# already doing nothing a file server could not — every host it served sent /v1
|
||||
# and /zap to cloud-api at the ingress, so its BFF was never reached — and its own
|
||||
# auth routes stopped mattering when identity became a client-held IAM token.
|
||||
# hanzoai/ci pushes each `images:` entry to `repo:` (GHCR) and server-side-mirrors
|
||||
# to registry.hanzo.ai automatically.
|
||||
#
|
||||
# TAGS: the shared builder publishes `sha-<sha7>-amd64` on every main push AND the
|
||||
# bare semver on a cut v* tag. PIN THE SEMVER — it says which console RELEASE a
|
||||
# deployment carries, which a sha cannot. The discipline that keeps that honest is
|
||||
# that a cut tag is never re-pointed (`:v8.4.118` once was): cut the next patch
|
||||
# instead.
|
||||
# BOTH console images are declared here — one config, read by whichever runner
|
||||
# executes it. The Next.js SERVER image (admin.hanzo.ai, operator CR
|
||||
# universe:crs/console.yaml) was built by .github/workflows/build-image.yml until
|
||||
# that file was neutralized on 2026-07-24 in favour of a native pipeline that
|
||||
# could not run: hanzoai/console had the forge Actions unit DISABLED
|
||||
# (`has_actions: false`, zero runs), so nothing built it — v8.5.23 and 8.5.24
|
||||
# shipped no image, and the CR still pins the last one built, v8.5.22. Declaring
|
||||
# both images here puts them on the ONE pipeline, wherever it executes.
|
||||
#
|
||||
# Tag shape changes with the builder, deliberately: the shared builder publishes
|
||||
# the immutable `sha-<sha7>-amd64` per main push (plus the bare semver on a v*
|
||||
# tag), not the `:v<X.Y.Z>` receipt the old bespoke workflow minted. Pin the CR to
|
||||
# the sha tag — that is what hanzoai/cloud does, and an immutable digest-shaped
|
||||
# tag cannot be re-pushed to different bytes the way `:v8.4.118` once was.
|
||||
images:
|
||||
# DEPRECATED — superseded by the `site:` release below. Kept only because
|
||||
# hanzoai/cloud still does `FROM ...console-embed AS console`; dropping it here
|
||||
# (or deleting Dockerfile.embed) breaks cloud's build. Remove both once the
|
||||
# cloud-side PR that stops consuming it merges.
|
||||
- name: console-embed
|
||||
context: .
|
||||
dockerfile: Dockerfile.embed
|
||||
repo: ghcr.io/hanzoai/console-embed
|
||||
# The console. It is static — that is not a variant, it is what the console IS,
|
||||
# so the image is `console` and there is no adjective in the name. Dockerfile
|
||||
# builds the SPA export and puts hanzoai/static in front of it.
|
||||
#
|
||||
# This REPLACES the Next.js server image that used to be published here. It was
|
||||
# already doing nothing a file server could not: every host it serves
|
||||
# (admin.lux.cloud, admin.lux.network, admin.zoo.cloud) sends /v1 and /zap to
|
||||
# cloud-api at the ingress, so the server's BFF at /v1/* was never reached on any
|
||||
# of them. Its own auth routes went the same way when identity became a
|
||||
# client-held IAM token. One console, one image.
|
||||
# The brand-agnostic Next.js server image: brand resolves at RUNTIME from the
|
||||
# request hostname, so no NEXT_PUBLIC_* may be baked (baking pins the image to
|
||||
# one brand). SOURCE_COMMIT is the only build arg it ever took.
|
||||
- name: console
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "@hanzo/console",
|
||||
"version": "8.5.63",
|
||||
"version": "8.5.50",
|
||||
"packageManager": "pnpm@11.17.0",
|
||||
"private": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"author": "Hanzo AI <dev@hanzo.ai>",
|
||||
"description": "Hanzo Cloud Console — unified admin console for Hanzo Cloud and all cloud products.",
|
||||
"description": "Hanzo Cloud Console \u2014 unified admin console for Hanzo Cloud and all cloud products.",
|
||||
"scripts": {
|
||||
"dev": "next dev -p 4000",
|
||||
"build": "next build",
|
||||
|
||||
@@ -58,32 +58,18 @@ const CUSTOM = '__custom__'
|
||||
|
||||
export function AgentBuilder({
|
||||
loaders,
|
||||
initial,
|
||||
onCreated,
|
||||
onCancel,
|
||||
submitLabel = 'Create agent',
|
||||
}: {
|
||||
loaders: AgentBuilderLoaders
|
||||
/**
|
||||
* A spec to start from — a template's preset, or what a description drafted.
|
||||
* Read ONCE, at mount: the form is the user's from that point on, so a seed can
|
||||
* never overwrite something they have already typed. A host that swaps seeds
|
||||
* (the quickstart, when a different template is picked) remounts with a `key`,
|
||||
* which states the intent — a new starting point — instead of hiding it in an
|
||||
* effect that races the user's keystrokes.
|
||||
*/
|
||||
initial?: Partial<AgentSpec>
|
||||
/**
|
||||
* Called after a successful create, with the NAME the agent was created under
|
||||
* (the handle every `/v1/agents/:ref` route is keyed by) so the host can go
|
||||
* straight to running it rather than looking it back up.
|
||||
*/
|
||||
onCreated: (name: string) => void
|
||||
/** Called after a successful create (the host reloads its list + closes the form). */
|
||||
onCreated: () => void
|
||||
/** Called when the user cancels (optional — omit for an always-open form). */
|
||||
onCancel?: () => void
|
||||
submitLabel?: string
|
||||
}) {
|
||||
const [spec, setSpec] = useState<AgentSpec>(() => ({ ...emptySpec(), ...initial }))
|
||||
const [spec, setSpec] = useState<AgentSpec>(emptySpec)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [unavailable, setUnavailable] = useState(false)
|
||||
@@ -179,9 +165,8 @@ export function AgentBuilder({
|
||||
setError(null)
|
||||
setUnavailable(false)
|
||||
try {
|
||||
const body = toCreateBody(spec)
|
||||
await loaders.createAgent(body)
|
||||
onCreated(body.name)
|
||||
await loaders.createAgent(toCreateBody(spec))
|
||||
onCreated()
|
||||
} catch (e) {
|
||||
const c = classifyBuilderError(e)
|
||||
if (c.kind === 'unavailable') setUnavailable(true)
|
||||
@@ -217,10 +202,7 @@ export function AgentBuilder({
|
||||
loading={models.phase === 'loading'}
|
||||
error={models.phase === 'error' ? `Model catalog unavailable — type a model id. (${models.message})` : null}
|
||||
onRetry={loadModels}
|
||||
// A placeholder is an example, and an example that does not exist is a lie
|
||||
// the user only discovers at the agent's first run. These are ids the live
|
||||
// catalog actually serves; the field itself offers the real list.
|
||||
placeholder="zen5 · zen5-mini · claude-sonnet-5"
|
||||
placeholder="zen-omni · gpt-4o-mini · claude-sonnet-4-5"
|
||||
/>
|
||||
</FieldRow>
|
||||
|
||||
@@ -257,14 +239,7 @@ export function AgentBuilder({
|
||||
loading={tools.phase === 'loading'}
|
||||
error={tools.phase === 'error' ? `Tool catalog unavailable — type a tool id.` : null}
|
||||
onRetry={loadTools}
|
||||
// No invented examples here either: the tool plane is per-org, so nobody
|
||||
// can name a tool that is certain to exist. The field offers what the org
|
||||
// has actually activated, and stays typeable for what it has not.
|
||||
placeholder={
|
||||
tools.phase === 'ready' && tools.options.length === 0
|
||||
? 'No tools activated yet — type one to use it anyway'
|
||||
: 'Search your tools'
|
||||
}
|
||||
placeholder="add a tool — e.g. web.search, code.exec"
|
||||
emptyText="Press Add to include what you typed."
|
||||
/>
|
||||
<XStack gap="$2">
|
||||
|
||||
@@ -1,532 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AgentQuickstart — the guided way into the ONE builder: describe an agent in your
|
||||
* own words or start from a template, configure it, run it, and take the call away.
|
||||
*
|
||||
* FOUR STEPS, AND EVERY ONE IS A REAL CALL. That is the whole design constraint. A
|
||||
* ladder of steps is a promise about what happens; a step that only draws a checkmark
|
||||
* turns the promise into decoration. So:
|
||||
*
|
||||
* 1 Describe → `POST /v1/chat/completions` drafts a spec from a sentence
|
||||
* (`draftAgent`), or a template fills the form with a preset
|
||||
* 2 Configure → the SAME `AgentBuilder` every other surface uses, seeded
|
||||
* 3 Run → `POST /v1/agents/:ref/run` executes it and shows the recorded run
|
||||
* 4 Integrate → the request that just worked, as code
|
||||
*
|
||||
* Steps 1 and 3 are OPTIONAL by construction: their loaders (`draftAgent`, `runAgent`)
|
||||
* may be absent, and the step then says exactly what is missing instead of miming it.
|
||||
* Step 2 is the only one that cannot be skipped, because creating the agent is the
|
||||
* point and the builder is the one thing that does it.
|
||||
*
|
||||
* Host-agnostic like the rest of the module: everything arrives through
|
||||
* `AgentBuilderLoaders`, so chat, app and bot mount this over the same `/v1/agents`.
|
||||
*/
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Button, Card, Input, ScrollView, Spinner, Text, TextArea, XStack, YStack } from '@hanzo/gui'
|
||||
import { ArrowRight, Bot, Check, Play, Search, Terminal, X } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { AgentBuilder } from './AgentBuilder'
|
||||
import { defaultConfig, emptySpec, proposeName } from './logic'
|
||||
import { AGENT_TEMPLATES, searchTemplates, specFromTemplate, type AgentTemplate } from './templates'
|
||||
import type { AgentBuilderLoaders, AgentRunResult, AgentSpec } from './types'
|
||||
|
||||
/** The four steps, in order. The id is what the component switches on. */
|
||||
const STEPS = [
|
||||
{ id: 'describe', label: 'Describe', endpoint: 'POST /v1/agents' },
|
||||
{ id: 'configure', label: 'Configure', endpoint: '' },
|
||||
{ id: 'run', label: 'Run', endpoint: 'POST /v1/agents/:ref/run' },
|
||||
{ id: 'integrate', label: 'Integrate', endpoint: '' },
|
||||
] as const
|
||||
|
||||
type StepId = (typeof STEPS)[number]['id']
|
||||
|
||||
/**
|
||||
* The step ladder. A step reached earlier is a real link back — going back to change
|
||||
* the prompt is the most common thing a person wants here, and a ladder you cannot
|
||||
* climb down is a worse version of a heading.
|
||||
*/
|
||||
function StepLadder({ current, onGo }: { current: StepId; onGo: (s: StepId) => void }) {
|
||||
const index = STEPS.findIndex((s) => s.id === current)
|
||||
return (
|
||||
<XStack items="center" gap="$2" flexWrap="wrap" role="list" aria-label="Quickstart steps">
|
||||
{STEPS.map((s, i) => {
|
||||
const done = i < index
|
||||
const active = i === index
|
||||
return (
|
||||
<XStack key={s.id} items="center" gap="$2" role="listitem">
|
||||
{i > 0 ? <XStack width={20} height={1} bg="$borderColor" $md={{ width: 32 }} /> : null}
|
||||
<Button
|
||||
size="$2"
|
||||
chromeless
|
||||
px="$2"
|
||||
disabled={i > index}
|
||||
onPress={() => onGo(s.id)}
|
||||
opacity={i > index ? 0.45 : 1}
|
||||
aria-current={active ? 'step' : undefined}
|
||||
aria-label={`Step ${i + 1}: ${s.label}${done ? ' (done)' : ''}`}
|
||||
>
|
||||
<XStack items="center" gap="$2">
|
||||
<XStack
|
||||
width={20}
|
||||
height={20}
|
||||
rounded="$10"
|
||||
items="center"
|
||||
justify="center"
|
||||
bg={done || active ? '$color12' : 'transparent'}
|
||||
borderWidth={done || active ? 0 : 1}
|
||||
borderColor="$borderColor"
|
||||
>
|
||||
{done ? (
|
||||
<Check size={12} color="$color1" />
|
||||
) : (
|
||||
<Text fontSize="$1" fontWeight="700" color={active ? '$color1' : '$color10'}>
|
||||
{i + 1}
|
||||
</Text>
|
||||
)}
|
||||
</XStack>
|
||||
<Text fontSize="$2" fontWeight={active ? '700' : '500'} color={active ? '$color12' : '$color10'}>
|
||||
{s.label}
|
||||
</Text>
|
||||
{active && s.endpoint ? (
|
||||
<Text fontSize="$1" color="$color9" fontFamily="$mono" display="none" $md={{ display: 'flex' }}>
|
||||
{s.endpoint}
|
||||
</Text>
|
||||
) : null}
|
||||
</XStack>
|
||||
</Button>
|
||||
</XStack>
|
||||
)
|
||||
})}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** One template card in the gallery. The whole card is the control. */
|
||||
function TemplateCard({ template, onPick }: { template: AgentTemplate; onPick: () => void }) {
|
||||
return (
|
||||
<YStack
|
||||
onPress={onPick}
|
||||
cursor="pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
focusable
|
||||
onKeyDown={(e: { key?: string; preventDefault?: () => void }) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault?.()
|
||||
onPick()
|
||||
}
|
||||
}}
|
||||
gap="$1.5"
|
||||
p="$3"
|
||||
rounded="$4"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
bg="$color2"
|
||||
hoverStyle={{ bg: '$color3', borderColor: '$color8' }}
|
||||
aria-label={`Start from ${template.title}`}
|
||||
>
|
||||
<Text fontSize="$3" fontWeight="700" color="$color12">
|
||||
{template.title}
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color11">
|
||||
{template.summary}
|
||||
</Text>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** A short, quiet note — used wherever a step has to say what is missing. */
|
||||
function Note({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Text fontSize="$2" color="$color10">
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
export function AgentQuickstart({
|
||||
loaders,
|
||||
onFinished,
|
||||
apiBase = 'https://api.hanzo.ai',
|
||||
}: {
|
||||
loaders: AgentBuilderLoaders
|
||||
/** Called when the user leaves the quickstart with an agent created (host reloads). */
|
||||
onFinished?: (name: string) => void
|
||||
/** The API origin the integrate snippet should show. */
|
||||
apiBase?: string
|
||||
}) {
|
||||
const [step, setStep] = useState<StepId>('describe')
|
||||
const [seed, setSeed] = useState<Partial<AgentSpec>>({})
|
||||
// Bumped whenever a NEW starting point is chosen, so the builder remounts on it
|
||||
// rather than an effect racing whatever the user has already typed.
|
||||
const [seedKey, setSeedKey] = useState(0)
|
||||
const [created, setCreated] = useState<string | null>(null)
|
||||
|
||||
// ── Step 1: describe ──────────────────────────────────────────────────────
|
||||
const [description, setDescription] = useState('')
|
||||
const [drafting, setDrafting] = useState(false)
|
||||
const [draftError, setDraftError] = useState<string | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const templates = useMemo(() => searchTemplates(query), [query])
|
||||
|
||||
const start = (next: Partial<AgentSpec>) => {
|
||||
setSeed(next)
|
||||
setSeedKey((k) => k + 1)
|
||||
setStep('configure')
|
||||
}
|
||||
|
||||
const pickTemplate = (t: AgentTemplate) => start(specFromTemplate(t, emptySpec(), defaultConfig()))
|
||||
|
||||
const describe = async () => {
|
||||
const text = description.trim()
|
||||
if (!text || drafting) return
|
||||
// Whatever happens next, the user's own words are already worth something: they
|
||||
// are the description, and they propose the handle. A draft only ever ADDS to
|
||||
// this, so a failed or absent draft still lands them in a part-filled form.
|
||||
const fallback: Partial<AgentSpec> = { description: text, name: proposeName(text) }
|
||||
if (!loaders.draftAgent) {
|
||||
start(fallback)
|
||||
return
|
||||
}
|
||||
setDrafting(true)
|
||||
setDraftError(null)
|
||||
try {
|
||||
const drafted = await loaders.draftAgent(text)
|
||||
start({ ...fallback, ...drafted })
|
||||
} catch (e) {
|
||||
// Say why, and still go — being stranded on a spinner is worse than writing
|
||||
// the prompt yourself.
|
||||
setDraftError(e instanceof Error ? e.message : 'Could not draft this one — write the prompt yourself.')
|
||||
start(fallback)
|
||||
} finally {
|
||||
setDrafting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 3: run ───────────────────────────────────────────────────────────
|
||||
const [input, setInput] = useState('')
|
||||
const [running, setRunning] = useState(false)
|
||||
const [run, setRun] = useState<AgentRunResult | null>(null)
|
||||
const [runError, setRunError] = useState<string | null>(null)
|
||||
|
||||
const doRun = async () => {
|
||||
const text = input.trim()
|
||||
if (!text || !created || !loaders.runAgent || running) return
|
||||
setRunning(true)
|
||||
setRunError(null)
|
||||
setRun(null)
|
||||
try {
|
||||
setRun(await loaders.runAgent(created, text))
|
||||
} catch (e) {
|
||||
// A failed run answers 502 with the RUN as its body, so this message is the
|
||||
// run's own reason — not a generic transport failure.
|
||||
setRunError(e instanceof Error ? e.message : 'The run did not complete.')
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const snippet = useMemo(
|
||||
() =>
|
||||
[
|
||||
`curl ${apiBase}/v1/agents/${created ?? 'your-agent'}/run \\`,
|
||||
` -H "Authorization: Bearer $HANZO_API_KEY" \\`,
|
||||
` -H "Content-Type: application/json" \\`,
|
||||
` -d '{"input":"${(input.trim() || 'your message here').replace(/'/g, "'\\''").replace(/"/g, '\\"')}"}'`,
|
||||
].join('\n'),
|
||||
[apiBase, created, input],
|
||||
)
|
||||
|
||||
return (
|
||||
<YStack gap="$4">
|
||||
<StepLadder current={step} onGo={setStep} />
|
||||
|
||||
{/* ── 1 · Describe ─────────────────────────────────────────────────── */}
|
||||
{step === 'describe' ? (
|
||||
<XStack gap="$4" items="flex-start" flexWrap="wrap">
|
||||
<YStack flex={2} minW={320} gap="$3" py="$6">
|
||||
<YStack gap="$2" items="center" py="$4">
|
||||
<Text fontSize="$8" fontWeight="800" color="$color12" style={{ textAlign: 'center' }}>
|
||||
What do you want to build?
|
||||
</Text>
|
||||
<Text fontSize="$3" color="$color11" style={{ textAlign: 'center' }}>
|
||||
Describe your agent, or start from a template.
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
<YStack
|
||||
bg="$color2"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
rounded="$7"
|
||||
px="$3"
|
||||
py="$2.5"
|
||||
gap="$2"
|
||||
data-field-box
|
||||
>
|
||||
<XStack gap="$2" items="flex-end">
|
||||
<TextArea
|
||||
flex={1}
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder="Describe your agent…"
|
||||
numberOfLines={3}
|
||||
disabled={drafting}
|
||||
borderWidth={0}
|
||||
bg="transparent"
|
||||
px="$1"
|
||||
py="$1"
|
||||
aria-label="Describe your agent"
|
||||
// Enter sends, Shift+Enter is a newline, and a key mid-IME-composition
|
||||
// is never a send — an open candidate window must not submit the turn.
|
||||
onKeyDown={(e) => {
|
||||
const ev = e as unknown as {
|
||||
key?: string
|
||||
shiftKey?: boolean
|
||||
preventDefault?: () => void
|
||||
nativeEvent?: { isComposing?: boolean }
|
||||
}
|
||||
if (ev.key === 'Enter' && !ev.shiftKey && !ev.nativeEvent?.isComposing) {
|
||||
ev.preventDefault?.()
|
||||
void describe()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="$2"
|
||||
circular
|
||||
theme="light"
|
||||
disabled={!description.trim() || drafting}
|
||||
onPress={() => void describe()}
|
||||
icon={drafting ? undefined : <ArrowRight size={16} />}
|
||||
aria-label="Draft this agent"
|
||||
>
|
||||
{drafting ? <Spinner size="small" /> : undefined}
|
||||
</Button>
|
||||
</XStack>
|
||||
</YStack>
|
||||
|
||||
{!loaders.draftAgent ? (
|
||||
<Note>
|
||||
Drafting isn’t connected here, so your words become the agent’s description and handle and you
|
||||
write the prompt in the next step.
|
||||
</Note>
|
||||
) : null}
|
||||
{draftError ? (
|
||||
<Text fontSize="$2" color="$red10">
|
||||
{draftError}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
|
||||
{/* Templates — a real gallery, searchable, each card a preset the builder
|
||||
can already express. */}
|
||||
<YStack flex={1} minW={280} gap="$2.5" p="$3" rounded="$5" borderWidth={1} borderColor="$borderColor">
|
||||
<Text fontSize="$4" fontWeight="700" color="$color12">
|
||||
Browse templates
|
||||
</Text>
|
||||
<XStack
|
||||
items="center"
|
||||
gap="$2"
|
||||
px="$2.5"
|
||||
height={34}
|
||||
rounded="$3"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
bg="$color2"
|
||||
data-field-box
|
||||
>
|
||||
<Search size={14} opacity={0.6} />
|
||||
<Input
|
||||
flex={1}
|
||||
unstyled
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder="Search templates"
|
||||
fontSize="$3"
|
||||
color="$color12"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
aria-label="Search templates"
|
||||
/>
|
||||
{query ? (
|
||||
<Button size="$1" chromeless icon={<X size={13} />} onPress={() => setQuery('')} aria-label="Clear search" />
|
||||
) : null}
|
||||
</XStack>
|
||||
<ScrollView maxH={520}>
|
||||
<YStack gap="$2">
|
||||
{templates.map((t) => (
|
||||
<TemplateCard key={t.id} template={t} onPick={() => pickTemplate(t)} />
|
||||
))}
|
||||
{templates.length === 0 ? (
|
||||
<Note>No template matches “{query.trim()}”. Describe it instead — that always works.</Note>
|
||||
) : null}
|
||||
</YStack>
|
||||
</ScrollView>
|
||||
</YStack>
|
||||
</XStack>
|
||||
) : null}
|
||||
|
||||
{/* ── 2 · Configure ────────────────────────────────────────────────── */}
|
||||
{step === 'configure' ? (
|
||||
<YStack gap="$3" maxW={720}>
|
||||
<AgentBuilder
|
||||
key={seedKey}
|
||||
loaders={loaders}
|
||||
initial={seed}
|
||||
onCancel={() => setStep('describe')}
|
||||
onCreated={(name) => {
|
||||
setCreated(name)
|
||||
setStep('run')
|
||||
onFinished?.(name)
|
||||
}}
|
||||
/>
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
{/* ── 3 · Run ──────────────────────────────────────────────────────── */}
|
||||
{step === 'run' && created ? (
|
||||
<YStack gap="$3" maxW={720}>
|
||||
<XStack items="center" gap="$2">
|
||||
<Bot size={16} />
|
||||
<Text fontSize="$5" fontWeight="800" color="$color12">
|
||||
{created}
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color10">
|
||||
is live
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$2" color="$color11">
|
||||
Send it something. This runs the agent for real and bills the run to your organization.
|
||||
</Text>
|
||||
|
||||
{loaders.runAgent ? (
|
||||
<>
|
||||
<YStack
|
||||
bg="$color2"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
rounded="$5"
|
||||
px="$3"
|
||||
py="$2.5"
|
||||
data-field-box
|
||||
>
|
||||
<XStack gap="$2" items="flex-end">
|
||||
<TextArea
|
||||
flex={1}
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
placeholder="Your message to the agent…"
|
||||
numberOfLines={3}
|
||||
disabled={running}
|
||||
borderWidth={0}
|
||||
bg="transparent"
|
||||
px="$1"
|
||||
py="$1"
|
||||
aria-label="Message to the agent"
|
||||
onKeyDown={(e) => {
|
||||
const ev = e as unknown as {
|
||||
key?: string
|
||||
shiftKey?: boolean
|
||||
preventDefault?: () => void
|
||||
nativeEvent?: { isComposing?: boolean }
|
||||
}
|
||||
if (ev.key === 'Enter' && !ev.shiftKey && !ev.nativeEvent?.isComposing) {
|
||||
ev.preventDefault?.()
|
||||
void doRun()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="$2"
|
||||
theme="light"
|
||||
disabled={!input.trim() || running}
|
||||
onPress={() => void doRun()}
|
||||
icon={running ? undefined : <Play size={15} />}
|
||||
>
|
||||
{running ? <Spinner size="small" /> : 'Run'}
|
||||
</Button>
|
||||
</XStack>
|
||||
</YStack>
|
||||
|
||||
{runError ? (
|
||||
<Card gap="$1.5" p="$3" rounded="$4" bg="$color2" borderWidth={1} borderColor="$borderColor">
|
||||
<Text fontSize="$3" fontWeight="700" color="$red10">
|
||||
The run failed
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color11">
|
||||
{runError}
|
||||
</Text>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{run ? (
|
||||
<Card gap="$2" p="$3" rounded="$4" bg="$color2" borderWidth={1} borderColor="$borderColor">
|
||||
<XStack items="center" gap="$2" flexWrap="wrap">
|
||||
<Text fontSize="$2" fontWeight="700" color={run.status === 'ok' ? '$green10' : '$red10'}>
|
||||
{run.status === 'ok' ? 'ok' : run.status || 'error'}
|
||||
</Text>
|
||||
{run.model ? (
|
||||
<Text fontSize="$1" color="$color10">
|
||||
{run.model}
|
||||
</Text>
|
||||
) : null}
|
||||
{run.durationMs != null ? (
|
||||
<Text fontSize="$1" color="$color10">
|
||||
{run.durationMs} ms
|
||||
</Text>
|
||||
) : null}
|
||||
</XStack>
|
||||
<Text fontSize="$3" color="$color12">
|
||||
{run.output || run.error || 'The run recorded no output.'}
|
||||
</Text>
|
||||
</Card>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<Card gap="$1.5" p="$3" rounded="$4" bg="$color2" borderWidth={1} borderColor="$borderColor">
|
||||
<XStack items="center" gap="$2">
|
||||
<Terminal size={14} />
|
||||
<Text fontSize="$3" fontWeight="700" color="$color12">
|
||||
Running from here isn’t connected on this deployment
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$2" color="$color11">
|
||||
The agent exists and `POST /v1/agents/{created}/run` is its endpoint — the next step shows the
|
||||
call.
|
||||
</Text>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<XStack gap="$2">
|
||||
<Button flex={1} theme="light" iconAfter={<ArrowRight size={15} />} onPress={() => setStep('integrate')}>
|
||||
Integrate
|
||||
</Button>
|
||||
</XStack>
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
{/* ── 4 · Integrate ────────────────────────────────────────────────── */}
|
||||
{step === 'integrate' && created ? (
|
||||
<YStack gap="$3" maxW={720}>
|
||||
<Text fontSize="$5" fontWeight="800" color="$color12">
|
||||
Call it from your code
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color11">
|
||||
The same request the Run step just made. Mint a key under API keys and set it as `HANZO_API_KEY`.
|
||||
</Text>
|
||||
<YStack p="$3" rounded="$4" bg="$color2" borderWidth={1} borderColor="$borderColor">
|
||||
<Text fontSize="$2" fontFamily="$mono" color="$color12" style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{snippet}
|
||||
</Text>
|
||||
</YStack>
|
||||
<Note>
|
||||
It answers with the recorded run — its id, status, model, output and duration. A model failure comes
|
||||
back as a run with `status: "error"` and the reason, never as silence.
|
||||
</Note>
|
||||
</YStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -9,12 +9,10 @@
|
||||
* lifts cleanly into a published `@hanzo/agent-builder` package.
|
||||
*/
|
||||
export { AgentBuilder } from './AgentBuilder'
|
||||
export { AgentQuickstart } from './Quickstart'
|
||||
export type {
|
||||
AgentSpec,
|
||||
AgentConfig,
|
||||
AgentCreateBody,
|
||||
AgentRunResult,
|
||||
ReasoningEffort,
|
||||
AgentBuilderLoaders,
|
||||
BuilderOption,
|
||||
@@ -36,10 +34,4 @@ export {
|
||||
promptBodyFromRow,
|
||||
promptOptions,
|
||||
classifyBuilderError,
|
||||
draftInstruction,
|
||||
parseDraft,
|
||||
proposeName,
|
||||
toHandle,
|
||||
} from './logic'
|
||||
export { AGENT_TEMPLATES, matchTemplate, searchTemplates, templateById, specFromTemplate } from './templates'
|
||||
export type { AgentTemplate } from './templates'
|
||||
|
||||
@@ -14,9 +14,6 @@ import {
|
||||
promptBodyFromRow,
|
||||
promptOptions,
|
||||
classifyBuilderError,
|
||||
proposeName,
|
||||
toHandle,
|
||||
parseDraft,
|
||||
} from './logic'
|
||||
import type { AgentConfig, AgentSpec, BuilderOption, BuilderPrompt } from './types'
|
||||
|
||||
@@ -35,21 +32,13 @@ describe('defaultModel', () => {
|
||||
expect(defaultModel([])).toBe('')
|
||||
})
|
||||
|
||||
it('prefers the exact zen5 default when present', () => {
|
||||
expect(defaultModel([opt('gpt-4o'), opt('zen5'), opt('claude')])).toBe('zen5')
|
||||
it('prefers the exact zen-omni default when present', () => {
|
||||
expect(defaultModel([opt('gpt-4o'), opt('zen-omni'), opt('claude')])).toBe('zen-omni')
|
||||
})
|
||||
|
||||
it('falls back to another Zen TEXT model', () => {
|
||||
expect(defaultModel([opt('gpt-4o'), opt('zen5-coder')])).toBe('zen5-coder')
|
||||
})
|
||||
|
||||
// The defect this rule exists to prevent: a catalog arrives sorted, so a loose
|
||||
// `^zen[-.]` test selected `zen-embedding` — an embeddings SKU that cannot hold a
|
||||
// conversation — as the default model for every new agent.
|
||||
it('never defaults to a modality SKU over a text model', () => {
|
||||
const live = [opt('zen-embedding'), opt('zen-image'), opt('zen-rerank'), opt('zen-vl'), opt('zen5'), opt('zen5-mini')]
|
||||
expect(defaultModel(live)).toBe('zen5')
|
||||
expect(defaultModel(live.filter((o) => o.value !== 'zen5'))).toBe('zen5-mini')
|
||||
it('falls back to the first Zen-family model (prefix or provider hint)', () => {
|
||||
expect(defaultModel([opt('gpt-4o'), opt('zen-coder')])).toBe('zen-coder')
|
||||
expect(defaultModel([opt('gpt-4o'), opt('some-model', 'Zen')])).toBe('some-model')
|
||||
})
|
||||
|
||||
it('falls back to the first catalog id when no Zen model exists', () => {
|
||||
@@ -200,69 +189,3 @@ describe('classifyBuilderError', () => {
|
||||
expect(classifyBuilderError('boom')).toEqual({ kind: 'error', message: 'Could not create the agent.' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('proposeName', () => {
|
||||
it('makes a handle out of the words a person actually typed', () => {
|
||||
expect(proposeName('An agent that triages support tickets')).toBe('triages-support-tickets')
|
||||
})
|
||||
it('drops noise words and punctuation', () => {
|
||||
expect(proposeName('The agent for our billing!! questions')).toBe('billing-questions')
|
||||
})
|
||||
it('is empty when there is nothing usable', () => {
|
||||
expect(proposeName(' ')).toBe('')
|
||||
expect(proposeName('a the it')).toBe('')
|
||||
})
|
||||
it('caps the length so the handle stays a handle', () => {
|
||||
expect(proposeName('extraordinarily verbose descriptive nomenclature').length).toBeLessThanOrEqual(32)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toHandle', () => {
|
||||
it('reshapes without re-wording — a handle survives intact', () => {
|
||||
expect(toHandle('support-agent')).toBe('support-agent')
|
||||
expect(toHandle('Support Triage Bot!')).toBe('support-triage-bot')
|
||||
})
|
||||
it('collapses runs and trims the edges', () => {
|
||||
expect(toHandle(' --a // b-- ')).toBe('a-b')
|
||||
})
|
||||
it('caps the length and never ends on a hyphen', () => {
|
||||
const h = toHandle('extraordinarily verbose descriptive nomenclature here')
|
||||
expect(h.length).toBeLessThanOrEqual(32)
|
||||
expect(h.endsWith('-')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseDraft', () => {
|
||||
it('reads the three fields it asked for', () => {
|
||||
const d = parseDraft('{"name":"support-triage","description":"Triages tickets.","systemPrompt":"You triage."}')
|
||||
expect(d).toEqual({ name: 'support-triage', description: 'Triages tickets.', systemPrompt: 'You triage.' })
|
||||
})
|
||||
|
||||
// The two things models actually do to JSON.
|
||||
it('survives a code fence and surrounding prose', () => {
|
||||
const answer = 'Sure! Here you go:\n```json\n{"name":"helper","systemPrompt":"You help."}\n```\nHope that works.'
|
||||
expect(parseDraft(answer)).toEqual({ name: 'helper', systemPrompt: 'You help.' })
|
||||
})
|
||||
|
||||
it('normalizes a handle the backend would refuse', () => {
|
||||
expect(parseDraft('{"name":"Support Triage Bot!"}')?.name).toBe('support-triage-bot')
|
||||
})
|
||||
|
||||
it('accepts the snake_case and bare spellings of the prompt', () => {
|
||||
expect(parseDraft('{"system_prompt":"You help."}')?.systemPrompt).toBe('You help.')
|
||||
expect(parseDraft('{"prompt":"You help."}')?.systemPrompt).toBe('You help.')
|
||||
})
|
||||
|
||||
// A creative answer may only ever produce LESS than asked, never a field the
|
||||
// builder cannot express.
|
||||
it('drops every key it does not recognize', () => {
|
||||
const d = parseDraft('{"name":"a-b","model":"gpt-9","tools":["rm -rf"],"webhook":"http://evil"}')
|
||||
expect(d).toEqual({ name: 'a-b' })
|
||||
})
|
||||
|
||||
it('is null when there is no object, or only empty fields', () => {
|
||||
expect(parseDraft('I could not do that.')).toBeNull()
|
||||
expect(parseDraft('{ not json }')).toBeNull()
|
||||
expect(parseDraft('{"name":" ","description":""}')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,30 +23,23 @@ export function defaultConfig(): AgentConfig {
|
||||
return { temperature: 0.7, topP: 1, topK: 0, stream: true, thinking: false, useTools: true, webSearch: false }
|
||||
}
|
||||
|
||||
/** The Zen text model to preselect when the catalog offers it. */
|
||||
const ZEN_DEFAULT = 'zen5'
|
||||
/** The default Zen model to preselect when the catalog offers one. */
|
||||
const ZEN_DEFAULT = 'zen-omni'
|
||||
|
||||
/**
|
||||
* Pick a sensible default model from a live catalog: the Zen default if present,
|
||||
* else another model from the Zen TEXT family, else the first catalog id, else ''
|
||||
* (nothing to default to — the field stays empty/typeable). PURE. Never invents an
|
||||
* id — only returns one the catalog actually lists.
|
||||
*
|
||||
* The text-family test is `zen5…`, and that specificity is load-bearing. Zen's naming
|
||||
* splits cleanly: `zen5`, `zen5-mini`, `zen5-flash`, `zen5-coder`, `zen5-pro` are the
|
||||
* text models, while `zen-<noun>` names a MODALITY — zen-embedding, zen-image,
|
||||
* zen-video, zen-rerank, zen-voice, zen-vl. A looser `^zen[-.]` test matched both, and
|
||||
* since a catalog arrives sorted it selected `zen-embedding`: every agent created
|
||||
* without touching the model field was pointed at an embeddings SKU that cannot hold a
|
||||
* conversation. (It went unnoticed because the exact-match arm named `zen-omni`, which
|
||||
* the live catalog does not carry, so the fallback was always the arm that ran.)
|
||||
* else the first Zen (`hanzo`-owned / `zen-` prefixed) model, else the first
|
||||
* catalog id, else '' (nothing to default to — the field stays empty/typeable).
|
||||
* PURE. Never invents an id — only returns one the catalog actually lists.
|
||||
*/
|
||||
export function defaultModel(options: BuilderOption[]): string {
|
||||
if (options.length === 0) return ''
|
||||
const exact = options.find((o) => o.value === ZEN_DEFAULT)
|
||||
if (exact) return exact.value
|
||||
const zenText = options.find((o) => /^zen\d/i.test(o.value))
|
||||
return (zenText ?? options[0]).value
|
||||
const zen = options.find(
|
||||
(o) => /^zen[-.]/i.test(o.value) || (o.hint ?? '').toLowerCase().includes('zen'),
|
||||
)
|
||||
return (zen ?? options[0]).value
|
||||
}
|
||||
|
||||
/** True iff the spec can be submitted (a non-empty trimmed name is the only requirement). */
|
||||
@@ -157,111 +150,6 @@ export function promptOptions(prompts: BuilderPrompt[]): BuilderOption[] {
|
||||
return prompts.map((p) => ({ value: p.name, label: p.label ?? p.name, hint: p.hint }))
|
||||
}
|
||||
|
||||
// ── Drafting an agent from a sentence ───────────────────────────────────────
|
||||
//
|
||||
// The quickstart lets someone describe an agent in their own words. That is a
|
||||
// model call, so the EFFECT is an injected loader (`draftAgent`) like every other;
|
||||
// what lives here is the pure half — the instruction we send, and the parse of what
|
||||
// comes back. Both are pure so the fragile part (reading a model's JSON) is tested
|
||||
// against real malformed answers rather than trusted.
|
||||
|
||||
/**
|
||||
* The instruction that turns a description into a spec. It asks for the three
|
||||
* fields a person would otherwise type and NOTHING else — deliberately not `model`
|
||||
* or `tools`: a model id must exist in the org's live catalog and a tool must exist
|
||||
* in its tool plane, and a model asked to name one will happily invent it. Those two
|
||||
* fields stay with the pickers that know the real answers. PURE.
|
||||
*/
|
||||
export function draftInstruction(): string {
|
||||
return [
|
||||
'You turn a description of an agent into its definition.',
|
||||
'',
|
||||
'Reply with ONE JSON object and nothing else — no prose, no code fence. Keys:',
|
||||
' "name" a short lowercase handle, words joined by hyphens (e.g. support-triage)',
|
||||
' "description" one sentence on what the agent does',
|
||||
' "systemPrompt" the agent\'s own instructions, written in the second person',
|
||||
'',
|
||||
'The system prompt is the real work: state what the agent does, what it must not do,',
|
||||
'and how it should behave when it is unsure. Write it as instructions to the agent,',
|
||||
'not as a description of it.',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Stop-words that carry no meaning in a handle. */
|
||||
const NOISE = new Set(['a', 'an', 'the', 'that', 'this', 'my', 'our', 'for', 'to', 'of', 'and', 'is', 'it', 'agent'])
|
||||
|
||||
/**
|
||||
* Put any string into handle FORM: lowercase, letters and digits kept, everything
|
||||
* else a hyphen, no repeated or trailing hyphens, capped. It reshapes and never
|
||||
* re-words — `support-agent` stays `support-agent`. PURE.
|
||||
*/
|
||||
export function toHandle(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 32)
|
||||
.replace(/-+$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* A handle proposed from PROSE — the user's own sentence — so the form is never left
|
||||
* with an empty required field even when the draft call fails. Drops the words that
|
||||
* carry no meaning in a handle, keeps the first three that do, and puts the result in
|
||||
* handle form. Returns '' when the text carries nothing usable.
|
||||
*
|
||||
* Distinct from `toHandle` on purpose, and the two must not be confused: this one
|
||||
* REWORDS, which is right for a sentence and wrong for a handle. Running it over an
|
||||
* already-formed handle silently renames it — `support-agent` would come back as
|
||||
* `support`, because "agent" is noise in a sentence and load-bearing in a name. PURE.
|
||||
*/
|
||||
export function proposeName(description: string): string {
|
||||
const words = description
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, ' ')
|
||||
.split(/[\s-]+/)
|
||||
.filter((w) => w.length > 1 && !NOISE.has(w))
|
||||
return toHandle(words.slice(0, 3).join('-'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a drafted spec out of a model's answer. Tolerant of the two things models
|
||||
* actually do — wrapping the object in a ```json fence, and adding a sentence before
|
||||
* or after it — by taking the outermost braces. Every field is validated and
|
||||
* anything unrecognized is DROPPED, so a creative answer can only ever produce less
|
||||
* than asked, never a field the builder does not understand. Returns null when there
|
||||
* is no object at all. PURE.
|
||||
*/
|
||||
export function parseDraft(answer: string): Partial<AgentSpec> | null {
|
||||
const start = answer.indexOf('{')
|
||||
const end = answer.lastIndexOf('}')
|
||||
if (start < 0 || end <= start) return null
|
||||
let raw: unknown
|
||||
try {
|
||||
raw = JSON.parse(answer.slice(start, end + 1))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
||||
const r = raw as Record<string, unknown>
|
||||
const text = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v.trim() : undefined)
|
||||
|
||||
const out: Partial<AgentSpec> = {}
|
||||
const name = text(r.name)
|
||||
// A handle the backend would refuse is worse than none, so reshape it — but with
|
||||
// `toHandle`, which only changes the FORM. `proposeName` would also re-word it, and
|
||||
// the model was asked for a handle, not a sentence.
|
||||
if (name) {
|
||||
const handle = toHandle(name)
|
||||
if (handle) out.name = handle
|
||||
}
|
||||
const description = text(r.description)
|
||||
if (description) out.description = description
|
||||
const systemPrompt = text(r.systemPrompt) ?? text(r.system_prompt) ?? text(r.prompt)
|
||||
if (systemPrompt) out.systemPrompt = systemPrompt
|
||||
return Object.keys(out).length ? out : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a create failure. A 404 (or an explicit "unavailable" BackendState kind)
|
||||
* means the `/v1/agents` route isn't bound on this deployment — an honest "not
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { AGENT_TEMPLATES, matchTemplate, searchTemplates, specFromTemplate, templateById } from './templates'
|
||||
import { defaultConfig, emptySpec, toCreateBody } from './logic'
|
||||
|
||||
describe('AGENT_TEMPLATES', () => {
|
||||
it('has unique ids and a handle for every entry', () => {
|
||||
const ids = AGENT_TEMPLATES.map((t) => t.id)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
for (const t of AGENT_TEMPLATES) {
|
||||
expect(t.name.trim()).not.toBe('')
|
||||
expect(t.title.trim()).not.toBe('')
|
||||
expect(t.summary.trim()).not.toBe('')
|
||||
}
|
||||
})
|
||||
|
||||
it('leads with the blank one — starting from nothing is the honest default', () => {
|
||||
expect(AGENT_TEMPLATES[0].id).toBe('blank')
|
||||
expect(AGENT_TEMPLATES[0].systemPrompt).toBe('')
|
||||
})
|
||||
|
||||
// The whole point of the module doc: a template is a preset, never a promise. It
|
||||
// may only carry fields the create body can already express, so picking one can
|
||||
// never produce an agent the builder itself could not.
|
||||
it('carries nothing the create body cannot express', () => {
|
||||
const allowed = new Set(['id', 'title', 'summary', 'name', 'systemPrompt', 'config'])
|
||||
for (const t of AGENT_TEMPLATES) {
|
||||
for (const key of Object.keys(t)) expect(allowed.has(key)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
// A hardcoded tool id would name something the org may not have activated, and it
|
||||
// would fail at the agent's FIRST invocation rather than here. Tools come from the
|
||||
// live tool plane or not at all.
|
||||
it('names no tools — those come from the live tool plane', () => {
|
||||
for (const t of AGENT_TEMPLATES) expect(t).not.toHaveProperty('tools')
|
||||
})
|
||||
|
||||
it('every template produces a submittable body', () => {
|
||||
for (const t of AGENT_TEMPLATES) {
|
||||
const body = toCreateBody(specFromTemplate(t, emptySpec(), defaultConfig()))
|
||||
expect(body.name).toBe(t.name)
|
||||
expect(body.description).toBe(t.summary)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchTemplate / searchTemplates', () => {
|
||||
const t = AGENT_TEMPLATES.find((x) => x.id === 'researcher')!
|
||||
|
||||
it('an empty query matches everything', () => {
|
||||
expect(matchTemplate(t, '')).toBe(true)
|
||||
expect(matchTemplate(t, ' ')).toBe(true)
|
||||
expect(searchTemplates('')).toHaveLength(AGENT_TEMPLATES.length)
|
||||
})
|
||||
|
||||
it('matches title, summary and id, case-insensitively', () => {
|
||||
expect(matchTemplate(t, 'DEEP')).toBe(true)
|
||||
expect(matchTemplate(t, 'sources')).toBe(true)
|
||||
expect(matchTemplate(t, 'researcher')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns nothing for a query nothing carries', () => {
|
||||
expect(searchTemplates('quantum tuba')).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps gallery order', () => {
|
||||
const found = searchTemplates('a').map((x) => x.id)
|
||||
expect(found).toEqual(AGENT_TEMPLATES.filter((x) => matchTemplate(x, 'a')).map((x) => x.id))
|
||||
})
|
||||
})
|
||||
|
||||
describe('templateById', () => {
|
||||
it('finds one, and is null for an unknown id', () => {
|
||||
expect(templateById('blank')?.title).toBe('Blank agent')
|
||||
expect(templateById('nope')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('specFromTemplate', () => {
|
||||
const t = AGENT_TEMPLATES.find((x) => x.id === 'extractor')!
|
||||
|
||||
it('fills name, description and prompt from the template', () => {
|
||||
const s = specFromTemplate(t, emptySpec(), defaultConfig())
|
||||
expect(s.name).toBe(t.name)
|
||||
expect(s.description).toBe(t.summary)
|
||||
expect(s.systemPrompt).toBe(t.systemPrompt)
|
||||
})
|
||||
|
||||
// The template owns the agent's character; the MODEL is the org's own decision and
|
||||
// its tool list is the org's too, so neither is overwritten by picking one.
|
||||
it('keeps a model and tools the user already chose', () => {
|
||||
const current = { ...emptySpec(), model: 'zen5-pro', tools: ['already.picked'] }
|
||||
const s = specFromTemplate(t, current, defaultConfig())
|
||||
expect(s.model).toBe('zen5-pro')
|
||||
expect(s.tools).toEqual(['already.picked'])
|
||||
})
|
||||
|
||||
it('merges the template config over the defaults, leaving the rest alone', () => {
|
||||
const s = specFromTemplate(t, emptySpec(), defaultConfig())
|
||||
expect(s.config?.temperature).toBe(0)
|
||||
expect(s.config?.stream).toBe(defaultConfig().stream)
|
||||
})
|
||||
|
||||
it('posts no config for a template that needs none', () => {
|
||||
const blank = templateById('blank')!
|
||||
expect(specFromTemplate(blank, emptySpec(), defaultConfig()).config).toBeUndefined()
|
||||
expect(toCreateBody(specFromTemplate(blank, emptySpec(), defaultConfig()))).not.toHaveProperty('config')
|
||||
})
|
||||
})
|
||||
@@ -1,175 +0,0 @@
|
||||
/**
|
||||
* Agent templates — starting points for the ONE builder, shared by every surface.
|
||||
*
|
||||
* A template is a PRESET, never a promise: every field it carries maps to something
|
||||
* `POST /v1/agents` already accepts (`name`, `description`, `systemPrompt`, and the
|
||||
* `config` knobs in `AgentConfig`). Picking one fills the builder and nothing else
|
||||
* happens — the user still sees, edits and submits the same form, so a template can
|
||||
* never create an agent the builder itself could not.
|
||||
*
|
||||
* Deliberately NO tool ids. Tools come from the live tool plane (`GET /v1/tools`),
|
||||
* which knows what an org has actually activated; a hardcoded `web.search` here would
|
||||
* name something that may not exist and would fail on the agent's first invocation.
|
||||
* What a template CAN say about tools is the truth: `useTools` and `webSearch` are
|
||||
* real switches in the agent contract, so a template that needs them turns them on
|
||||
* and the builder's live tool picker fills in the specifics.
|
||||
*
|
||||
* Pure data + pure helpers — no React, no I/O — so this lifts into
|
||||
* `@hanzo/agent-builder` with the rest of the module.
|
||||
*/
|
||||
import type { AgentConfig, AgentSpec } from './types'
|
||||
|
||||
/** A named starting point: what it is, and the spec it fills the builder with. */
|
||||
export type AgentTemplate = {
|
||||
/** Stable id — the URL/search key. */
|
||||
id: string
|
||||
/** What it is called in the gallery. */
|
||||
title: string
|
||||
/** One line on what the agent does. Shown on the card and searched. */
|
||||
summary: string
|
||||
/** The seed handle; the user renames freely before submitting. */
|
||||
name: string
|
||||
/** The system prompt this template starts from ('' for the blank one). */
|
||||
systemPrompt: string
|
||||
/** Only the knobs this template genuinely needs; the rest stay at their defaults. */
|
||||
config?: Partial<AgentConfig>
|
||||
}
|
||||
|
||||
/**
|
||||
* The gallery, in display order. `blank` leads because starting from nothing is the
|
||||
* honest default — everything after it is a real, specific job.
|
||||
*/
|
||||
export const AGENT_TEMPLATES: readonly AgentTemplate[] = [
|
||||
{
|
||||
id: 'blank',
|
||||
title: 'Blank agent',
|
||||
summary: 'A starting point with nothing assumed — name it, pick a model, write the prompt.',
|
||||
name: 'my-agent',
|
||||
systemPrompt: '',
|
||||
},
|
||||
{
|
||||
id: 'researcher',
|
||||
title: 'Deep researcher',
|
||||
summary: 'Researches a question across the web and answers with the sources it used.',
|
||||
name: 'researcher',
|
||||
systemPrompt:
|
||||
'You research questions and report what you found.\n\n' +
|
||||
'Work in steps: decide what you need to know, search for it, read the results, and only then answer. ' +
|
||||
'Prefer primary sources over summaries of them.\n\n' +
|
||||
'Every claim that came from a source carries that source. When sources disagree, say so and give both. ' +
|
||||
'When you could not find something, say that plainly instead of filling the gap — an honest gap is more ' +
|
||||
'useful than a confident guess.',
|
||||
config: { webSearch: true, thinking: true, reasoningEffort: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'extractor',
|
||||
title: 'Structured extractor',
|
||||
summary: 'Reads unstructured text and returns one typed JSON object, or says which fields were absent.',
|
||||
name: 'extractor',
|
||||
systemPrompt:
|
||||
'You turn unstructured text into one JSON object matching the schema the caller gives you.\n\n' +
|
||||
'Return the object and nothing else — no prose, no code fence, no explanation.\n\n' +
|
||||
'Copy values from the text; never infer one that is not there. A field the text does not support is null, ' +
|
||||
'and a guessed value is a defect. If the schema is ambiguous about a field, choose the reading that the ' +
|
||||
'text supports literally.',
|
||||
config: { temperature: 0, topP: 1 },
|
||||
},
|
||||
{
|
||||
id: 'support',
|
||||
title: 'Support answerer',
|
||||
summary: 'Answers product questions from your own material, and escalates the ones it cannot.',
|
||||
name: 'support',
|
||||
systemPrompt:
|
||||
'You answer product questions for customers, using the material available to you.\n\n' +
|
||||
'Answer from that material only. When it does not cover the question, say so and hand off rather than ' +
|
||||
'improvising — a wrong answer costs more than a slow one.\n\n' +
|
||||
'Lead with the answer, then the steps. Keep it short enough to act on. Never promise a behaviour, a date ' +
|
||||
'or a refund you cannot point to in the material.',
|
||||
config: { useTools: true, temperature: 0.3 },
|
||||
},
|
||||
{
|
||||
id: 'reviewer',
|
||||
title: 'Code reviewer',
|
||||
summary: 'Reads a diff and reports what will actually break, most severe first.',
|
||||
name: 'reviewer',
|
||||
systemPrompt:
|
||||
'You review code changes.\n\n' +
|
||||
'Report only defects you can name concretely: the input or state that triggers them, and the wrong output ' +
|
||||
'or crash that results. Correctness and security first, then clarity. Rank by severity.\n\n' +
|
||||
'Style preferences are not findings. Neither is a concern you cannot demonstrate — if you are unsure a ' +
|
||||
'thing is real, say you are unsure rather than listing it as a defect. Finding nothing is a valid review.',
|
||||
config: { thinking: true, reasoningEffort: 'high', temperature: 0.2 },
|
||||
},
|
||||
{
|
||||
id: 'analyst',
|
||||
title: 'Data analyst',
|
||||
summary: 'Explains a dataset — what is in it, what stands out, and what to check next.',
|
||||
name: 'analyst',
|
||||
systemPrompt:
|
||||
'You explain datasets to people who have to make a decision from them.\n\n' +
|
||||
'Start with the shape: how many rows, which columns, what period, and what is missing. Then the two or ' +
|
||||
'three things that genuinely stand out. Then what you would check next and why.\n\n' +
|
||||
'Every number you state comes from the data. Distinguish what the data shows from what you suspect, and ' +
|
||||
'name the limits — a sample too small to conclude from is the finding, not an obstacle to one.',
|
||||
config: { useTools: true, temperature: 0.2 },
|
||||
},
|
||||
{
|
||||
id: 'summarizer',
|
||||
title: 'Meeting summarizer',
|
||||
summary: 'Turns a transcript into decisions, owners and the questions still open.',
|
||||
name: 'summarizer',
|
||||
systemPrompt:
|
||||
'You turn meeting transcripts into something the people who missed it can act on.\n\n' +
|
||||
'Three sections: decisions made, actions with their owner, and questions left open. Nothing else.\n\n' +
|
||||
'Only record a decision that was actually reached — a topic discussed without resolution belongs under ' +
|
||||
'open questions. Attribute an action to a person only when the transcript names them; otherwise leave the ' +
|
||||
'owner unassigned and say so.',
|
||||
config: { temperature: 0.2 },
|
||||
},
|
||||
{
|
||||
id: 'triage',
|
||||
title: 'Incident triager',
|
||||
summary: 'Classifies an incoming report by severity and area, and drafts the first reply.',
|
||||
name: 'triage',
|
||||
systemPrompt:
|
||||
'You triage incoming incident reports.\n\n' +
|
||||
'For each one give: severity, the area it belongs to, what is affected, and a first reply to the reporter.\n\n' +
|
||||
'Severity follows blast radius, not tone — a calm report of data loss outranks an urgent one about a ' +
|
||||
'typo. When the report lacks what you need to classify it, the first reply asks for exactly that and the ' +
|
||||
'severity stays provisional. Never guess an area to avoid leaving one blank.',
|
||||
config: { temperature: 0.2, reasoningEffort: 'medium' },
|
||||
},
|
||||
]
|
||||
|
||||
/** Case-insensitive, whitespace-tolerant match over the fields a person would type. */
|
||||
export function matchTemplate(t: AgentTemplate, query: string): boolean {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return true
|
||||
return `${t.title} ${t.summary} ${t.id}`.toLowerCase().includes(q)
|
||||
}
|
||||
|
||||
/** The templates matching a query, in gallery order. */
|
||||
export function searchTemplates(query: string, templates: readonly AgentTemplate[] = AGENT_TEMPLATES): AgentTemplate[] {
|
||||
return templates.filter((t) => matchTemplate(t, query))
|
||||
}
|
||||
|
||||
/** The template with this id, or null. */
|
||||
export function templateById(id: string, templates: readonly AgentTemplate[] = AGENT_TEMPLATES): AgentTemplate | null {
|
||||
return templates.find((t) => t.id === id) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* The builder state a template starts from. Merged over the CURRENT spec so a model
|
||||
* the user already chose survives picking a template — the template owns the prompt
|
||||
* and the character of the agent, never the model, which is the org's own decision.
|
||||
*/
|
||||
export function specFromTemplate(t: AgentTemplate, current: AgentSpec, defaults: AgentConfig): AgentSpec {
|
||||
return {
|
||||
...current,
|
||||
name: t.name,
|
||||
description: t.summary,
|
||||
systemPrompt: t.systemPrompt,
|
||||
tools: current.tools,
|
||||
config: t.config ? { ...defaults, ...t.config } : undefined,
|
||||
}
|
||||
}
|
||||
@@ -138,24 +138,6 @@ export type AgentBuilderLoaders = {
|
||||
loadPromptBody?: (name: string) => Promise<string>
|
||||
/** The live tool catalog. Rejects → typeable-only tools. */
|
||||
loadTools?: () => Promise<BuilderOption[]>
|
||||
/**
|
||||
* Draft a spec from a plain-English description — the quickstart's "describe your
|
||||
* agent" box. A model call, so it is an effect like the rest; the instruction and
|
||||
* the parse of the answer are pure (`draftInstruction`, `parseDraft`) and shared.
|
||||
* Absent → the quickstart still works: the description seeds the handle and the
|
||||
* description field, and the user writes the prompt. Rejects → the same fallback,
|
||||
* with the reason shown, so a drafting failure never blocks building an agent.
|
||||
*/
|
||||
draftAgent?: (description: string) => Promise<Partial<AgentSpec>>
|
||||
/**
|
||||
* Run the agent once (`POST /v1/agents/:ref/run`) and return the RECORDED run.
|
||||
* The quickstart's third step — proving the thing that was just created actually
|
||||
* answers, which is the only step that can prove it. Absent → the step says so and
|
||||
* points at the endpoint instead of pretending. THIS SPENDS: the backend authorizes
|
||||
* the org's balance before any inference, so an unfunded org is refused rather than
|
||||
* given free compute.
|
||||
*/
|
||||
runAgent?: (name: string, input: string) => Promise<AgentRunResult>
|
||||
/**
|
||||
* Create the agent from the pruned body (`toCreateBody(spec)`). This is the ONE
|
||||
* mutation — it MUST target the unified agent backend (`POST /v1/agents`), which
|
||||
@@ -165,21 +147,6 @@ export type AgentBuilderLoaders = {
|
||||
createAgent: (body: AgentCreateBody) => Promise<unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* One recorded run, as the quickstart needs it. Deliberately the small half of the
|
||||
* backend's run view: what happened, which model did it, and what came out. A
|
||||
* `status` other than `ok` is a run that REALLY failed — the backend records the
|
||||
* failure as a run rather than hiding it — so `error` is a fact about the execution,
|
||||
* not a transport problem to guess at.
|
||||
*/
|
||||
export type AgentRunResult = {
|
||||
status: string
|
||||
model?: string
|
||||
output?: string
|
||||
error?: string
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
/** The reason a create failed, distinguished so the UI reacts correctly. */
|
||||
export type BuilderErrorKind =
|
||||
/** The `/v1/agents` route isn't bound on this deployment yet (404). */
|
||||
|
||||
@@ -14,13 +14,12 @@
|
||||
* facade (`AgentsApi.metrics`); until that route is bound they show a truthful "not
|
||||
* connected" note, never a placeholder trend. When the org has ZERO agents (or the
|
||||
* `/v1/agents` route isn't bound yet) the board is replaced by a polished
|
||||
* "create your first agent" empty state that opens the QUICKSTART — the one way to
|
||||
* create an agent here — never the mockup's sample data.
|
||||
* "create your first agent" empty state with the real New-Agent flow — never the
|
||||
* mockup's sample data.
|
||||
*
|
||||
* Style props use the @hanzo/gui v5 shorthand set (bg/p/px/py/gap/rounded/items/…).
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button, Card, Input, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { useAnalytics } from '@hanzo/event/react'
|
||||
import { EVENTS } from '@hanzo/event'
|
||||
@@ -78,9 +77,7 @@ import {
|
||||
TopAgents,
|
||||
VersionBadge,
|
||||
} from './agents/parts'
|
||||
import { AgentDetailView } from './agents/forms'
|
||||
import { agentBuilderLoaders } from './agents/loaders'
|
||||
import { AgentQuickstart } from '~/components/agent-builder'
|
||||
import { AgentDetailView, NewAgentForm } from './agents/forms'
|
||||
import { BackendStateCard, DataTable, EmptyState, PageHeader, classifyBackend, type BackendState, type Column } from '@hanzo/ui/product'
|
||||
|
||||
const PAGE_SIZE = 8
|
||||
@@ -137,7 +134,6 @@ function useAgents() {
|
||||
}
|
||||
|
||||
export function AgentsModule(props: { params: Record<string, string> }) {
|
||||
const router = useRouter()
|
||||
const detail = useDetailPane()
|
||||
const { agents, loading, error, live, activity, reload, setAgents } = useAgents()
|
||||
|
||||
@@ -230,11 +226,26 @@ export function AgentsModule(props: { params: Record<string, string> }) {
|
||||
|
||||
const analytics = useAnalytics()
|
||||
|
||||
// ONE way to create an agent, and it is the quickstart. This used to open the
|
||||
// builder in a side pane — the same component, reached by a different shape, with
|
||||
// no templates, no drafting and nowhere to run what it made. Two entrances to one
|
||||
// builder is two things to keep in step; the pane was the lesser of them.
|
||||
const openNew = useCallback(() => router.push('/agents/quickstart'), [router])
|
||||
const openNew = useCallback(
|
||||
() =>
|
||||
detail.open({
|
||||
title: 'New agent',
|
||||
subtitle: 'Define a model, prompt, and tools',
|
||||
icon: Bot,
|
||||
iconColor: agentColor,
|
||||
content: (
|
||||
<NewAgentForm
|
||||
onCancel={detail.close}
|
||||
onCreated={() => {
|
||||
analytics.capture(EVENTS.AGENT_CREATED)
|
||||
detail.close()
|
||||
void reload()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[detail, agentColor, reload, analytics],
|
||||
)
|
||||
|
||||
const header = (
|
||||
<PageHeader
|
||||
@@ -268,36 +279,6 @@ export function AgentsModule(props: { params: Record<string, string> }) {
|
||||
/>
|
||||
)
|
||||
|
||||
// Owned sub-pages: Status/Logs/Metrics render focused slices of the agents' OWN
|
||||
// runs (from /v1/agents), so they are never the empty generic o11y/ledger subpage.
|
||||
// Overview ('') shows everything. Metrics = counts + invocation trend + resource;
|
||||
// Status = health donut + agents table; Logs = the invocation activity feed.
|
||||
const routeTab = props.params?.tab ?? ''
|
||||
|
||||
// ── Quickstart ──────────────────────────────────────────────────────────────
|
||||
// Its own surface, and it answers BEFORE the list's loading/error/empty states on
|
||||
// purpose: building an agent does not depend on reading the ones that exist, and
|
||||
// the moments you most need it — no agents yet, or the registry not answering —
|
||||
// are exactly the ones those early returns would have swallowed it in.
|
||||
if (routeTab === 'quickstart') {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Build an agent"
|
||||
subtitle="Describe what you want, or start from a template. Four steps, and every one is a real call."
|
||||
/>
|
||||
<AgentQuickstart
|
||||
loaders={agentBuilderLoaders}
|
||||
apiBase={config.apiUrl}
|
||||
onFinished={() => {
|
||||
analytics.capture(EVENTS.AGENT_CREATED)
|
||||
void reload()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Initial loading ─────────────────────────────────────────────────────────
|
||||
if (loading && agents.length === 0 && !error) {
|
||||
return (
|
||||
@@ -416,6 +397,11 @@ export function AgentsModule(props: { params: Record<string, string> }) {
|
||||
const tabs: StatusTab[] = ['all', ...AGENT_STATUSES]
|
||||
const tabCount = (t: StatusTab): number => (t === 'all' ? agents.length : health[t])
|
||||
|
||||
// Owned sub-pages: Status/Logs/Metrics render focused slices of the agents' OWN
|
||||
// runs (from /v1/agents), so they are never the empty generic o11y/ledger subpage.
|
||||
// Overview ('') shows everything. Metrics = counts + invocation trend + resource;
|
||||
// Status = health donut + agents table; Logs = the invocation activity feed.
|
||||
const routeTab = props.params?.tab ?? ''
|
||||
const showMetrics = routeTab === '' || routeTab === 'metrics'
|
||||
const showStatus = routeTab === '' || routeTab === 'status'
|
||||
const showLogs = routeTab === '' || routeTab === 'logs'
|
||||
|
||||
@@ -59,8 +59,8 @@ const CHANNELS: Channel[] = [
|
||||
icon: MessageCircle,
|
||||
title: 'Join Our Discord',
|
||||
body: 'Chat live with developers and other users from our community.',
|
||||
href: 'https://discord.gg/CJCyAsm9Vr',
|
||||
cta: 'discord.gg/CJCyAsm9Vr',
|
||||
href: 'https://discord.gg/hanzo',
|
||||
cta: 'discord.gg/hanzo',
|
||||
},
|
||||
{
|
||||
icon: Linkedin,
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Agents — the per-agent detail view, rendered inside the shared right-side
|
||||
* `DetailPane`. It shows the agent's REAL facts, its REAL cost from the charged
|
||||
* commerce ledger, and a best-effort activity feed from `GET /v1/agents/:name`;
|
||||
* never fabricated telemetry.
|
||||
* Agents — the New-Agent form and the per-agent detail view, both rendered inside
|
||||
* the shared right-side `DetailPane`.
|
||||
*
|
||||
* CREATING an agent does not live here. It used to — a thin `NewAgentForm` adapter
|
||||
* that opened the canonical `AgentBuilder` in this same pane — and that made two
|
||||
* differently-shaped entrances to one builder, only one of which could offer
|
||||
* templates, drafting, or anywhere to run what it made. The quickstart
|
||||
* (`/agents/quickstart`) is the one way now, and the board's New-Agent button goes
|
||||
* there.
|
||||
* The New-Agent form is now a THIN adapter over the CANONICAL, shareable
|
||||
* `AgentBuilder` (`~/components/agent-builder`) — the ONE agent builder across every
|
||||
* Hanzo surface. console2 supplies its live `/v1` sources via `agentBuilderLoaders`
|
||||
* (model catalog → `/v1/models`, saved prompts → `/v1/prompts`, create →
|
||||
* `/v1/agents`); the builder owns the form, the LIVE model + prompt dropdowns, and
|
||||
* the honest states. The detail view renders the agent's REAL facts (+ best-effort
|
||||
* recent activity from `GET /v1/agents/:name`), never fabricated telemetry.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
} from '~/lib/api/agents'
|
||||
import { fetchUsageRecords, agentUsageFor, type AgentUsage } from '~/lib/api/aimetrics'
|
||||
import { AgentBuilder } from '~/components/agent-builder'
|
||||
import { agentBuilderLoaders } from './loaders'
|
||||
import { StatusPill, ActivityFeed } from './parts'
|
||||
|
||||
const DASH = '—'
|
||||
@@ -48,6 +48,16 @@ function Fact({ label, value }: { label: string; value: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The New-Agent form — the canonical `AgentBuilder` wired to console2's live `/v1`
|
||||
* sources. On a successful `POST /v1/agents` it calls `onCreated` (the board
|
||||
* reloads + the pane closes); a 404/unavailable backend degrades to the builder's
|
||||
* own honest "not connected — create with the CLI" note.
|
||||
*/
|
||||
export function NewAgentForm({ onCreated, onCancel }: { onCreated: () => void; onCancel: () => void }) {
|
||||
return <AgentBuilder loaders={agentBuilderLoaders} onCreated={onCreated} onCancel={onCancel} />
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-agent detail view — REAL facts from the row, the agent's REAL cost from
|
||||
* the charged commerce ledger (grouped by `metadata.agent`, NOT a hardcoded/registry
|
||||
|
||||
@@ -12,9 +12,7 @@
|
||||
*/
|
||||
import { PlaygroundApi, PromptsApi } from '~/lib/api'
|
||||
import { AgentsApi } from '~/lib/api/agents'
|
||||
import { ToolsApi } from '~/lib/api/tools'
|
||||
import { defaultModel, draftInstruction, parseDraft } from '~/components/agent-builder/logic'
|
||||
import type { AgentBuilderLoaders, AgentSpec, BuilderOption, BuilderPrompt } from '~/components/agent-builder/types'
|
||||
import type { AgentBuilderLoaders, BuilderOption, BuilderPrompt } from '~/components/agent-builder/types'
|
||||
|
||||
/** The live model catalog as builder options (id → {value,label,hint}). */
|
||||
async function loadModels(): Promise<BuilderOption[]> {
|
||||
@@ -22,52 +20,6 @@ async function loadModels(): Promise<BuilderOption[]> {
|
||||
return ids.map((id) => ({ value: id, label: id }))
|
||||
}
|
||||
|
||||
/**
|
||||
* The org's REAL callable tools (`GET /v1/tools`) as builder options. The plane spans
|
||||
* every source — connector actions, functions, zap-service routes, agents, skills and
|
||||
* the org's own MCP servers — already deduplicated by name.
|
||||
*
|
||||
* A tool that lists but is NOT activated says so in its hint, because listing it
|
||||
* silently would offer a name that resolves and then refuses at invocation time. An
|
||||
* org with nothing activated gets an empty list, which is a real answer: the field
|
||||
* stays typeable and the user is not shown a tool that does not exist.
|
||||
*/
|
||||
async function loadTools(): Promise<BuilderOption[]> {
|
||||
const tools = await ToolsApi.list()
|
||||
return tools.map((t) => ({
|
||||
value: t.name,
|
||||
label: t.name,
|
||||
hint: [t.source, t.description, t.activated ? undefined : 'not activated'].filter(Boolean).join(' · ') || undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Draft a spec from a plain-English description — one real completion through the
|
||||
* gateway, with the shared instruction and the shared parse (both pure, both tested).
|
||||
*
|
||||
* Deliberately cheap and deterministic: this is a formatting job, not a reasoning one.
|
||||
* `parseDraft` drops anything it does not recognize, so a creative answer can only
|
||||
* ever fill FEWER fields than asked — never inject one the builder cannot express.
|
||||
*/
|
||||
async function draftAgent(description: string): Promise<Partial<AgentSpec>> {
|
||||
// The model comes from the LIVE catalog through the same `defaultModel` rule the
|
||||
// builder's own picker uses — never a literal id here. A hardcoded one drifts
|
||||
// silently the moment the catalog changes, which is exactly how `zen-omni` came to
|
||||
// be named in a rule that could no longer match anything.
|
||||
const model = defaultModel(await loadModels())
|
||||
if (!model) throw new Error('No model is available to draft with — pick one and write the prompt yourself.')
|
||||
const completion = await PlaygroundApi.chat({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: draftInstruction() },
|
||||
{ role: 'user', content: description },
|
||||
],
|
||||
temperature: 0.2,
|
||||
})
|
||||
const answer = completion?.choices?.[0]?.message?.content ?? ''
|
||||
return parseDraft(answer) ?? {}
|
||||
}
|
||||
|
||||
/** The org's saved prompts as builder rows (names; bodies fetched lazily on select). */
|
||||
async function loadPrompts(): Promise<BuilderPrompt[]> {
|
||||
const prompts = await PromptsApi.list()
|
||||
@@ -120,8 +72,8 @@ export const agentBuilderLoaders: AgentBuilderLoaders = {
|
||||
loadModels,
|
||||
loadPrompts,
|
||||
loadPromptBody,
|
||||
loadTools,
|
||||
draftAgent,
|
||||
// No live tool catalog endpoint on this deployment yet — the builder's tools
|
||||
// field stays typeable-only (honest), never a fabricated tool list. Wire
|
||||
// `loadTools` here when `/v1/agents/tools` (or the MCP tool catalog) is bound.
|
||||
createAgent: (body) => AgentsApi.create(body),
|
||||
runAgent: (name, input) => AgentsApi.run(name, input),
|
||||
}
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Deploy — the front door for shipping something.
|
||||
*
|
||||
* One section over the two things an org deploys (container apps and static
|
||||
* sites) plus readings of the three planes a deploy touches (CD, CI, Storage) and
|
||||
* the hosts it publishes on. The level-2 slugs come from the ONE registry
|
||||
* declaration, so the sidebar rail, this strip, and ⌘K cannot disagree about
|
||||
* which tabs exist.
|
||||
*
|
||||
* It COMPOSES; it does not re-implement. Apps come from `PaasApi`, sites from
|
||||
* `PlatformSitesApi`, reconciliation from `GitopsApi`, builds from `BuildsApi`,
|
||||
* buckets from `StorageApi` — the same typed clients the deeper products already
|
||||
* use, over the same same-origin `/v1` bearer proxy, which resolves the org from
|
||||
* the token owner server-side. No new backend path, no new credential, and no
|
||||
* client-side org filter standing in for a boundary.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { Button, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { AppWindow, Globe, Layers, Plus, RefreshCw, Rocket } from '@hanzogui/lucide-icons-2'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
import { DataTable, MetricCard, PageHeader, PrimaryButton, StatusTag, type Column } from '@hanzo/ui/product'
|
||||
import { SubNav } from '~/components/ui/SubNav'
|
||||
import { productSubpageSlug } from '~/lib/products/match'
|
||||
import { partialNote, summarize, type DeployKind, type DeployRow } from '~/lib/deploy/board'
|
||||
import { PlatformStateCard } from '../platform/state'
|
||||
import { useBoard } from './useBoard'
|
||||
import { NewDeploy } from './NewDeploy'
|
||||
import { CdPanel, CiPanel, DomainsPanel, StoragePanel } from './panels'
|
||||
|
||||
const SUBTITLE: Record<string, string> = {
|
||||
'': 'Ship an app or a static site, and watch it go live.',
|
||||
apps: 'Container workloads the operator reconciles for you.',
|
||||
sites: 'Static builds served straight from object storage.',
|
||||
}
|
||||
|
||||
export function DeployModule({ params }: { params: Record<string, string> }) {
|
||||
const tab = productSubpageSlug('deploy', params.tab)
|
||||
const board = useBoard()
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const panel = (() => {
|
||||
switch (tab) {
|
||||
case 'cd':
|
||||
return <CdPanel />
|
||||
case 'ci':
|
||||
return <CiPanel />
|
||||
case 'storage':
|
||||
return <StoragePanel />
|
||||
case 'domains':
|
||||
return (
|
||||
<DomainsPanel
|
||||
rows={board.rows}
|
||||
loading={board.loading}
|
||||
incomplete={board.incomplete}
|
||||
onRefresh={() => void board.reload()}
|
||||
/>
|
||||
)
|
||||
case 'apps':
|
||||
return <BoardView board={board} only="app" />
|
||||
case 'sites':
|
||||
return <BoardView board={board} only="site" />
|
||||
default:
|
||||
return <BoardView board={board} />
|
||||
}
|
||||
})()
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Deploy"
|
||||
subtitle={SUBTITLE[tab] ?? 'Everything you have shipped, and the planes that carry it.'}
|
||||
actions={
|
||||
<XStack gap="$2">
|
||||
<Button size="$2" icon={<RefreshCw size={15} />} onPress={() => void board.reload()}>
|
||||
Refresh
|
||||
</Button>
|
||||
<PrimaryButton size="$2" icon={<Plus size={15} />} onPress={() => setCreating((c) => !c)}>
|
||||
New deployment
|
||||
</PrimaryButton>
|
||||
</XStack>
|
||||
}
|
||||
/>
|
||||
|
||||
<SubNav id="deploy" />
|
||||
|
||||
{creating ? (
|
||||
<NewDeploy
|
||||
onCancel={() => setCreating(false)}
|
||||
onDeployed={() => {
|
||||
setCreating(false)
|
||||
void board.reload()
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{panel}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** The unified board, optionally narrowed to one kind (the Apps / Sites tabs). */
|
||||
function BoardView({ board, only }: { board: ReturnType<typeof useBoard>; only?: DeployKind }) {
|
||||
const router = useRouter()
|
||||
const rows = only ? board.rows.filter((r) => r.kind === only) : board.rows
|
||||
const totals = summarize(board.rows)
|
||||
const note = partialNote(board.incomplete)
|
||||
// A count over a source that did not fully load is a FLOOR, not a total. Showing
|
||||
// "Apps 0" beside "apps could not be loaded" states a number the board does not
|
||||
// know, so the unreliable tiles read an em dash instead.
|
||||
const missingApps = board.incomplete.includes('app')
|
||||
const missingSites = board.incomplete.includes('site')
|
||||
const count = (n: number, unreliable: boolean): string => (unreliable ? '—' : String(n))
|
||||
|
||||
const columns: Column<DeployRow>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Name',
|
||||
render: (r) => (
|
||||
<YStack gap="$0.5" minW={0}>
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
|
||||
{r.name}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color10" numberOfLines={1}>
|
||||
{r.kind === 'app' ? (r.project ? `App · ${r.project}` : 'App') : 'Static site'}
|
||||
</Text>
|
||||
</YStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'host',
|
||||
header: 'Host',
|
||||
width: 260,
|
||||
render: (r) =>
|
||||
r.host ? (
|
||||
<Text fontSize="$3" color="$color11" numberOfLines={1}>
|
||||
{r.host}
|
||||
</Text>
|
||||
) : (
|
||||
<Text fontSize="$3" color="$color10">
|
||||
—
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{ key: 'status', header: 'Status', width: 120, render: (r) => <StatusTag status={r.status} /> },
|
||||
{
|
||||
key: 'health',
|
||||
header: 'Health',
|
||||
width: 120,
|
||||
// The platform populates phase/health on a listing only while an app is
|
||||
// live or deploying, so an em dash here means "not reported", not "sick".
|
||||
render: (r) => (r.health ? <StatusTag status={r.health} /> : <Text fontSize="$3" color="$color10">—</Text>),
|
||||
},
|
||||
{
|
||||
key: 'updatedAt',
|
||||
header: 'Updated',
|
||||
width: 190,
|
||||
render: (r) => (
|
||||
<Text fontSize="$3" color="$color11" numberOfLines={1}>
|
||||
{r.updatedAt ? new Date(r.updatedAt).toLocaleString() : '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
if (board.error) return <PlatformStateCard error={board.error} onRetry={() => void board.reload()} />
|
||||
|
||||
return (
|
||||
<YStack gap="$3.5" data-testid={only ? `deploy-panel-${only}s` : 'deploy-board'}>
|
||||
{only ? null : (
|
||||
<XStack gap="$3" flexWrap="wrap">
|
||||
<MetricCard
|
||||
icon={<Rocket size={15} color="$color10" />}
|
||||
label="Deployments"
|
||||
value={count(totals.total, missingApps || missingSites)}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={<Layers size={15} color="$color10" />}
|
||||
label="Live"
|
||||
value={count(totals.live, missingApps || missingSites)}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={<AppWindow size={15} color="$color10" />}
|
||||
label="Apps"
|
||||
value={count(totals.apps, missingApps)}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={<Globe size={15} color="$color10" />}
|
||||
label="Sites"
|
||||
value={count(totals.sites, missingSites)}
|
||||
/>
|
||||
</XStack>
|
||||
)}
|
||||
|
||||
{note ? (
|
||||
<Text fontSize="$2" color="$color11" role="status">
|
||||
{note}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
loading={board.loading}
|
||||
rowKey={(r) => `${r.kind}:${r.project ?? ''}:${r.slug}`}
|
||||
onRowPress={(r) =>
|
||||
router.push(r.kind === 'app' ? `/app-platform/${encodeURIComponent(r.slug)}` : `/platform/${encodeURIComponent(r.slug)}`)
|
||||
}
|
||||
empty="Nothing deployed yet — start with New deployment."
|
||||
/>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* New deployment — pick a repo, a host, and env, then ship.
|
||||
*
|
||||
* Two destinations behind one form, because "deploy this repo" is one intent:
|
||||
* - APP → `POST /v1/platform/projects/:project/apps` (201, `status: "draft"`)
|
||||
* then `POST …/apps/:app/deploy` (202). Creating an app does NOT start
|
||||
* it, so a form that stopped at 201 would report success over a thing
|
||||
* that never ran. Both calls are made, and a failure names its step.
|
||||
* - SITE → `POST /v1/platform/sites` (201). A static build is published by an
|
||||
* upload or a git deploy afterwards, so this creates the target and the
|
||||
* board shows it as `draft` until something is published to it.
|
||||
*
|
||||
* Env values are typed here and POSTed straight to cloud. They are never logged,
|
||||
* never persisted by the browser, and never read back into this form — cloud
|
||||
* masks a sealed value on read, so re-submitting what a read returned would blank
|
||||
* it. That is why this form only CREATES env and has no edit mode.
|
||||
*
|
||||
* Secrecy is DECLARED per variable, not guessed. Every variable is sealed unless
|
||||
* the person deploying marks it Public, and the form says exactly that. The
|
||||
* earlier version inferred secrecy from the key NAME, which let `STRIPE_SK`,
|
||||
* `GH_PAT` and `DB_PASS` through as public while the help text promised they were
|
||||
* sealed — a false promise is worse than no promise, so the copy now claims only
|
||||
* what THIS form decides: the default, and the mark.
|
||||
*
|
||||
* It deliberately does NOT promise that cloud re-seals a Public value whose shape
|
||||
* looks like a credential. That server-side check is not deployed yet, and a
|
||||
* safety net described before it exists is the same defect in a new place — it
|
||||
* invites someone to mark a credential Public believing something downstream will
|
||||
* catch it. Restore that sentence when the backend seal actually ships.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Rocket } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { PaasApi, type PaasProject } from '~/lib/api/paas'
|
||||
import { PlatformSitesApi, SITE_FRAMEWORKS } from '~/lib/api/platform-sites'
|
||||
import { FieldRow, FieldText, FieldTextArea, PrimaryButton } from '@hanzo/ui/product'
|
||||
import { FieldOptionSelect } from '~/components/ui/Field'
|
||||
import {
|
||||
envVars,
|
||||
formError,
|
||||
prunePublicKeys,
|
||||
repoName,
|
||||
toAppInput,
|
||||
toSiteInput,
|
||||
type DeployForm,
|
||||
} from '~/lib/deploy/board'
|
||||
import { interpretPlatformError, PlatformStateCard, type PlatformError } from '../platform/state'
|
||||
|
||||
const EMPTY: DeployForm = {
|
||||
kind: 'app',
|
||||
name: '',
|
||||
repo: '',
|
||||
branch: '',
|
||||
host: '',
|
||||
env: '',
|
||||
publicKeys: [],
|
||||
framework: 'static',
|
||||
}
|
||||
|
||||
export function NewDeploy({ onCancel, onDeployed }: { onCancel: () => void; onDeployed: () => void }) {
|
||||
const [form, setForm] = useState<DeployForm>(EMPTY)
|
||||
const [projects, setProjects] = useState<PaasProject[]>([])
|
||||
const [project, setProject] = useState<string>('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [failure, setFailure] = useState<PlatformError | null>(null)
|
||||
// Validation stays quiet until something has been typed — an untouched form is
|
||||
// incomplete, not wrong.
|
||||
const [touched, setTouched] = useState(false)
|
||||
|
||||
// Any edit counts as touched: the reason a disabled Deploy button is disabled
|
||||
// must appear as soon as someone starts filling the form, not only once they
|
||||
// happen to touch the repo field.
|
||||
const set = (patch: Partial<DeployForm>) => {
|
||||
setTouched(true)
|
||||
setForm((f) => {
|
||||
const next = { ...f, ...patch }
|
||||
// Editing the env text re-derives which marks still have a variable, so a
|
||||
// Public mark can never outlive the line it was made on and be inherited by
|
||||
// a later variable that happens to reuse the name.
|
||||
if (patch.env !== undefined) next.publicKeys = prunePublicKeys(next.env ?? '', next.publicKeys ?? [])
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
PaasApi.listProjects()
|
||||
.then((p) => {
|
||||
if (!live) return
|
||||
setProjects(p)
|
||||
setProject((cur) => cur || p[0]?.slug || '')
|
||||
})
|
||||
.catch(() => live && setProjects([]))
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const problem = useMemo(() => formError(form, project || null), [form, project])
|
||||
|
||||
/** The variables as they will be SENT — each carrying its sealed/public state. */
|
||||
const vars = useMemo(() => envVars(form), [form])
|
||||
|
||||
/**
|
||||
* Name (or un-name) a key as public. Only keys the person opened are listed —
|
||||
* and `set` prunes that list on every env edit, so a mark cannot outlive its
|
||||
* line: delete a variable and its mark goes with it, so a later variable
|
||||
* reusing the name arrives sealed like any other.
|
||||
*/
|
||||
const markPublic = (key: string, isPublic: boolean) =>
|
||||
set({
|
||||
publicKeys: isPublic
|
||||
? [...new Set([...(form.publicKeys ?? []), key])]
|
||||
: (form.publicKeys ?? []).filter((k) => k !== key),
|
||||
})
|
||||
|
||||
const submit = async () => {
|
||||
setTouched(true)
|
||||
if (problem) return
|
||||
setBusy(true)
|
||||
setFailure(null)
|
||||
try {
|
||||
if (form.kind === 'site') {
|
||||
await PlatformSitesApi.create(toSiteInput(form))
|
||||
} else {
|
||||
// `project` is non-empty here by `formError`; that is the contract between
|
||||
// the two, not an assumption about the fetch above.
|
||||
const app = await PaasApi.createApp(project, toAppInput(form))
|
||||
await PaasApi.deploy(project, app.slug)
|
||||
}
|
||||
onDeployed()
|
||||
} catch (e) {
|
||||
setFailure(interpretPlatformError(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$3.5" data-testid="new-deploy">
|
||||
<XStack items="center" gap="$2">
|
||||
<Rocket size={16} color="$color10" />
|
||||
<Text fontSize="$5" fontWeight="600" color="$color12">
|
||||
New deployment
|
||||
</Text>
|
||||
</XStack>
|
||||
|
||||
<FieldRow label="Type">
|
||||
<XStack gap="$2">
|
||||
{(['app', 'site'] as const).map((k) => (
|
||||
<Button
|
||||
key={k}
|
||||
size="$2"
|
||||
bg={form.kind === k ? '$color5' : 'transparent'}
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
aria-pressed={form.kind === k}
|
||||
onPress={() => set({ kind: k })}
|
||||
>
|
||||
{k === 'app' ? 'App' : 'Static site'}
|
||||
</Button>
|
||||
))}
|
||||
</XStack>
|
||||
</FieldRow>
|
||||
|
||||
{form.kind === 'app' ? (
|
||||
<FieldRow label="Project">
|
||||
<FieldOptionSelect
|
||||
value={project}
|
||||
options={projects.map((p) => ({ value: p.slug, label: p.name || p.slug }))}
|
||||
placeholder={projects.length ? 'Select a project' : 'No projects yet'}
|
||||
onChange={setProject}
|
||||
/>
|
||||
</FieldRow>
|
||||
) : null}
|
||||
|
||||
<FieldRow label={form.kind === 'app' ? 'Repository' : 'Repository (optional)'}>
|
||||
<FieldText
|
||||
value={form.repo}
|
||||
placeholder="https://git.hanzo.ai/hanzoai/console.git"
|
||||
onChange={(v) => {
|
||||
// The name follows the repo until it is edited by hand; once the two
|
||||
// differ, typing a URL stops overwriting a deliberate name.
|
||||
const derived = repoName(form.repo)
|
||||
set({ repo: v, ...(form.name === '' || form.name === derived ? { name: repoName(v) } : {}) })
|
||||
}}
|
||||
/>
|
||||
</FieldRow>
|
||||
|
||||
<FieldRow label="Name">
|
||||
<FieldText value={form.name} placeholder="console" onChange={(v) => set({ name: v })} />
|
||||
</FieldRow>
|
||||
|
||||
<FieldRow label="Branch">
|
||||
<FieldText value={form.branch ?? ''} placeholder="main" onChange={(v) => set({ branch: v })} />
|
||||
</FieldRow>
|
||||
|
||||
{form.kind === 'site' ? (
|
||||
<FieldRow label="Framework">
|
||||
<FieldOptionSelect
|
||||
value={form.framework ?? 'static'}
|
||||
options={SITE_FRAMEWORKS.map((f) => ({ value: f, label: f }))}
|
||||
onChange={(v) => set({ framework: v })}
|
||||
/>
|
||||
</FieldRow>
|
||||
) : (
|
||||
<>
|
||||
<FieldRow label="Custom host">
|
||||
<YStack gap="$1.5">
|
||||
<FieldText
|
||||
value={form.host ?? ''}
|
||||
placeholder="app.example.com"
|
||||
onChange={(v) => set({ host: v })}
|
||||
/>
|
||||
<Text fontSize="$1" color="$color10">
|
||||
Optional — every app is born with a host on hanzo.app. A custom host stays pending until you
|
||||
prove ownership with the DNS record shown under Domains.
|
||||
</Text>
|
||||
</YStack>
|
||||
</FieldRow>
|
||||
|
||||
<FieldRow label="Environment">
|
||||
<YStack gap="$2">
|
||||
<FieldTextArea value={form.env ?? ''} rows={4} onChange={(v) => set({ env: v })} />
|
||||
<Text fontSize="$1" color="$color10">
|
||||
One KEY=VALUE per line. Every variable is <Text fontWeight="600">sealed by default</Text> — mark
|
||||
one Public to keep it readable later. A sealed value is masked on read, so this form can set one
|
||||
but never read one back.
|
||||
</Text>
|
||||
|
||||
{vars.length ? (
|
||||
<YStack
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
rounded="$4"
|
||||
overflow="hidden"
|
||||
data-testid="env-vars"
|
||||
>
|
||||
{vars.map((v, i) => (
|
||||
<XStack
|
||||
key={v.key}
|
||||
items="center"
|
||||
justify="space-between"
|
||||
gap="$3"
|
||||
px="$3"
|
||||
py="$2"
|
||||
flexWrap="wrap"
|
||||
borderTopWidth={i === 0 ? 0 : 1}
|
||||
borderColor="$borderColor"
|
||||
>
|
||||
<Text fontSize="$2" color="$color12" numberOfLines={1} className="hz-mono" flex={1} minW={0}>
|
||||
{v.key}
|
||||
</Text>
|
||||
<XStack gap="$1.5">
|
||||
{(
|
||||
[
|
||||
['Sealed', true],
|
||||
['Public', false],
|
||||
] as const
|
||||
).map(([label, sealed]) => (
|
||||
<Button
|
||||
key={label}
|
||||
size="$1"
|
||||
bg={v.secret === sealed ? '$color5' : 'transparent'}
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
aria-pressed={v.secret === sealed}
|
||||
aria-label={`${v.key} ${label}`}
|
||||
onPress={() => markPublic(v.key, !sealed)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</XStack>
|
||||
</XStack>
|
||||
))}
|
||||
</YStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
</FieldRow>
|
||||
</>
|
||||
)}
|
||||
|
||||
{failure ? <PlatformStateCard error={failure} /> : null}
|
||||
|
||||
<XStack gap="$2" items="center" flexWrap="wrap">
|
||||
<PrimaryButton disabled={busy || !!problem} onPress={() => void submit()}>
|
||||
{busy ? 'Deploying…' : 'Deploy'}
|
||||
</PrimaryButton>
|
||||
<Button disabled={busy} onPress={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
{touched && problem ? (
|
||||
<Text fontSize="$2" color="$color11" role="alert">
|
||||
{problem}
|
||||
</Text>
|
||||
) : null}
|
||||
</XStack>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* The three neighbouring planes a deploy touches, and the domains it publishes on.
|
||||
*
|
||||
* Each reads the ONE canonical head for its subject — there is no `/v1/platform/cd`,
|
||||
* `/v1/platform/ci`, or `/v1/platform/s3`, and inventing those aliases would give
|
||||
* the estate two paths to the same data:
|
||||
*
|
||||
* CD → `GET /v1/deploy/applications` (`GitopsApi`). The reconciliation
|
||||
* projection over the operator's App CRs — sync + health per app.
|
||||
* Tenant-scoped server-side: a member sees only its own org's apps.
|
||||
* CI → `GET /v1/builds` (`BuildsApi`). The native build record written by
|
||||
* git push → Actions → image. There is no forge-runs endpoint.
|
||||
* Storage → `GET /v1/s3/buckets` (`StorageApi`). Org-scoped object storage.
|
||||
* Domains → the hosts already bound to this org's apps and sites, folded from
|
||||
* the board rather than re-fetched per app.
|
||||
*
|
||||
* Each panel is a READING, deep-linking to the product that owns the subject for
|
||||
* anything deeper. None of them duplicates that product's controls, so there is
|
||||
* still exactly one place to sync a CR, browse an object, or edit a build.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Button, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { RefreshCw, ExternalLink } from '@hanzogui/lucide-icons-2'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
import { GitopsApi, type Application } from '~/lib/api/gitops'
|
||||
import { BuildsApi, type Build } from '~/lib/api/builds'
|
||||
import { StorageApi, type Bucket } from '~/lib/api/storage'
|
||||
import { DataTable, StatusTag, type Column } from '@hanzo/ui/product'
|
||||
import { interpretPlatformError, PlatformStateCard, type PlatformError } from '../platform/state'
|
||||
import { hostRows, partialNote, type DeployKind, type DeployRow, type HostRow } from '~/lib/deploy/board'
|
||||
|
||||
/** Epoch ms → local string; an em dash when the backend had no timestamp. */
|
||||
const when = (ms?: number): string => (ms ? new Date(ms).toLocaleString() : '—')
|
||||
/** An ISO/RFC3339 string → local; the raw value when it will not parse. */
|
||||
const whenIso = (iso?: string): string => {
|
||||
if (!iso) return '—'
|
||||
const t = Date.parse(iso)
|
||||
return Number.isNaN(t) ? iso : new Date(t).toLocaleString()
|
||||
}
|
||||
|
||||
/**
|
||||
* One load of one list. Every panel here has the same shape — read a head, keep
|
||||
* the rows, classify a failure honestly — so it is written once.
|
||||
*/
|
||||
function useList<T>(read: () => Promise<T[]>): {
|
||||
rows: T[]
|
||||
loading: boolean
|
||||
error: PlatformError | null
|
||||
reload: () => Promise<void>
|
||||
} {
|
||||
const [rows, setRows] = useState<T[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<PlatformError | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
setRows(await read())
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(interpretPlatformError(e))
|
||||
setRows([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
// `read` is a stable module-level call in every caller; re-running on identity
|
||||
// would refetch on each render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
return { rows, loading, error, reload: load }
|
||||
}
|
||||
|
||||
/** Header strip shared by the panels: one sentence of context + Refresh. */
|
||||
function PanelHead({ title, note, onRefresh }: { title: string; note: string; onRefresh: () => void }) {
|
||||
return (
|
||||
<XStack justify="space-between" items="flex-start" gap="$3" flexWrap="wrap">
|
||||
<YStack flex={1} minW={0} gap="$1">
|
||||
<Text fontSize="$6" fontWeight="600" color="$color12">
|
||||
{title}
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color10">
|
||||
{note}
|
||||
</Text>
|
||||
</YStack>
|
||||
<Button size="$2" icon={<RefreshCw size={15} />} onPress={onRefresh}>
|
||||
Refresh
|
||||
</Button>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
// ── CD ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function CdPanel() {
|
||||
const { rows, loading, error, reload } = useList<Application>(GitopsApi.applications)
|
||||
|
||||
const columns: Column<Application>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Application',
|
||||
render: (a) => (
|
||||
<YStack gap="$0.5" minW={0}>
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
|
||||
{a.name}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color10" numberOfLines={1}>
|
||||
{a.namespace}
|
||||
</Text>
|
||||
</YStack>
|
||||
),
|
||||
},
|
||||
{ key: 'sync', header: 'Sync', width: 120, render: (a) => <StatusTag status={a.sync} /> },
|
||||
{ key: 'health', header: 'Health', width: 120, render: (a) => <StatusTag status={a.health} /> },
|
||||
{
|
||||
key: 'tag',
|
||||
header: 'Declared → running',
|
||||
width: 220,
|
||||
mono: true,
|
||||
render: (a) => (
|
||||
<Text fontSize="$2" color="$color11" numberOfLines={1} className="hz-mono">
|
||||
{a.image.tag || '—'}
|
||||
{a.liveTag && a.liveTag !== a.image.tag ? ` → ${a.liveTag}` : ''}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'replicas',
|
||||
header: 'Ready',
|
||||
width: 90,
|
||||
align: 'right',
|
||||
render: (a) => (
|
||||
<Text fontSize="$3" color="$color11" className="hz-mono">
|
||||
{a.readyReplicas}/{a.replicas}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<YStack gap="$3.5" data-testid="deploy-panel-cd">
|
||||
<PanelHead
|
||||
title="CD"
|
||||
note="Reconciliation of your apps, read from the operator's App CRs — the same plane cd.hanzo.ai serves."
|
||||
onRefresh={() => void reload()}
|
||||
/>
|
||||
{error ? (
|
||||
<PlatformStateCard error={error} onRetry={() => void reload()} />
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
loading={loading}
|
||||
rowKey={(a) => `${a.namespace}/${a.name}`}
|
||||
empty="Nothing reconciled here yet."
|
||||
/>
|
||||
)}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
// ── CI ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function CiPanel() {
|
||||
const { rows, loading, error, reload } = useList<Build>(BuildsApi.list)
|
||||
|
||||
const columns: Column<Build>[] = [
|
||||
{
|
||||
key: 'repo',
|
||||
header: 'Repository',
|
||||
render: (b) => (
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
|
||||
{b.repo || b.id || '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'commit',
|
||||
header: 'Commit',
|
||||
width: 130,
|
||||
mono: true,
|
||||
render: (b) => (
|
||||
<Text fontSize="$2" color="$color11" numberOfLines={1} className="hz-mono">
|
||||
{b.commit ? b.commit.slice(0, 12) : '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tag',
|
||||
header: 'Tag',
|
||||
width: 150,
|
||||
mono: true,
|
||||
render: (b) => (
|
||||
<Text fontSize="$2" color="$color11" numberOfLines={1} className="hz-mono">
|
||||
{b.tag || '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{ key: 'status', header: 'Status', width: 120, render: (b) => <StatusTag status={b.status || 'unknown'} /> },
|
||||
{
|
||||
key: 'startedAt',
|
||||
header: 'Started',
|
||||
width: 190,
|
||||
render: (b) => (
|
||||
<Text fontSize="$3" color="$color11" numberOfLines={1}>
|
||||
{whenIso(b.startedAt)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'duration',
|
||||
header: 'Took',
|
||||
width: 90,
|
||||
align: 'right',
|
||||
render: (b) => (
|
||||
<Text fontSize="$3" color="$color11">
|
||||
{b.duration || '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<YStack gap="$3.5" data-testid="deploy-panel-ci">
|
||||
<PanelHead
|
||||
title="CI"
|
||||
note="Builds your pushes produced — commit to image, on the native runners."
|
||||
onRefresh={() => void reload()}
|
||||
/>
|
||||
{error ? (
|
||||
<PlatformStateCard error={error} onRetry={() => void reload()} />
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
loading={loading}
|
||||
rowKey={(b) => b.id || `${b.repo}@${b.commit}`}
|
||||
empty="No builds recorded yet."
|
||||
/>
|
||||
)}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Storage ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function StoragePanel() {
|
||||
const router = useRouter()
|
||||
const { rows, loading, error, reload } = useList<Bucket>(StorageApi.buckets)
|
||||
|
||||
const columns: Column<Bucket>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Bucket',
|
||||
render: (b) => (
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
|
||||
{b.name}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
header: 'Created',
|
||||
width: 200,
|
||||
// The backend carries Unix SECONDS here, unlike the ms timestamps the
|
||||
// platform rows use.
|
||||
render: (b) => (
|
||||
<Text fontSize="$3" color="$color11">
|
||||
{when(b.createdAt ? b.createdAt * 1000 : undefined)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<YStack gap="$3.5" data-testid="deploy-panel-storage">
|
||||
<PanelHead
|
||||
title="Storage"
|
||||
note="Object storage in your org — where a static site's build is served from."
|
||||
onRefresh={() => void reload()}
|
||||
/>
|
||||
{error ? (
|
||||
<PlatformStateCard error={error} onRetry={() => void reload()} />
|
||||
) : (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
loading={loading}
|
||||
rowKey={(b) => b.name}
|
||||
onRowPress={(b) => router.push(`/s3?bucket=${encodeURIComponent(b.name)}`)}
|
||||
empty="No buckets yet."
|
||||
/>
|
||||
<Button size="$2" self="flex-start" icon={<ExternalLink size={15} />} onPress={() => router.push('/s3')}>
|
||||
Open the file manager
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Domains ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function DomainsPanel({
|
||||
rows,
|
||||
loading,
|
||||
incomplete,
|
||||
onRefresh,
|
||||
}: {
|
||||
rows: DeployRow[]
|
||||
loading: boolean
|
||||
/** Sources the board could not fully read — this list inherits their gaps. */
|
||||
incomplete: DeployKind[]
|
||||
onRefresh: () => void
|
||||
}) {
|
||||
const hosts = hostRows(rows)
|
||||
// The host list is derived from the board's rows, so a half-loaded board is a
|
||||
// half-loaded domain list. Saying so matters more here than anywhere else: a
|
||||
// missing host reads as "nothing is bound", which is the opposite of the truth.
|
||||
const note = partialNote(incomplete)
|
||||
|
||||
const columns: Column<HostRow>[] = [
|
||||
{
|
||||
key: 'host',
|
||||
header: 'Host',
|
||||
render: (h) => (
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
|
||||
{h.host}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'owner',
|
||||
header: 'Serves',
|
||||
width: 220,
|
||||
render: (h) => (
|
||||
<Text fontSize="$3" color="$color11" numberOfLines={1}>
|
||||
{h.owner}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'kind',
|
||||
header: 'Type',
|
||||
width: 110,
|
||||
render: (h) => (
|
||||
<Text fontSize="$3" color="$color11">
|
||||
{h.kind === 'app' ? 'App' : 'Site'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{ key: 'status', header: 'Status', width: 120, render: (h) => <StatusTag status={h.status} /> },
|
||||
]
|
||||
|
||||
return (
|
||||
<YStack gap="$3.5" data-testid="deploy-panel-domains">
|
||||
<PanelHead
|
||||
title="Domains"
|
||||
note="Every host serving one of your deployments. Bind a custom host when you deploy; it stays pending until DNS proves ownership."
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
{note ? (
|
||||
<Text fontSize="$2" color="$color11" role="status">
|
||||
{note}
|
||||
</Text>
|
||||
) : null}
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={hosts}
|
||||
loading={loading}
|
||||
rowKey={(h) => h.host}
|
||||
empty="No hosts bound yet."
|
||||
/>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
/**
|
||||
* The Deploy front door's catalog declaration, pinned against the real source.
|
||||
*
|
||||
* `registry.tsx` cannot be imported here — it pulls the icon ESM the runner can't
|
||||
* load — so the house pattern applies (see lib/products/sentry-scope.test.ts):
|
||||
* read the entry's own flags off the SOURCE, and prove the predicates that consume
|
||||
* them with fixtures elsewhere (`match-core.test.ts` owns the routing algorithm).
|
||||
* A fixture proves the predicate; only the source proves the DATA, and the data is
|
||||
* what a later edit breaks.
|
||||
*
|
||||
* Each fact here has already been a bug shape in this console:
|
||||
* - a `deploy` SLUG_ALIAS makes the front door unreachable, because
|
||||
* `canonicalSlug` rewrites the head before any lookup happens;
|
||||
* - `admin: true` on a customer surface hides it from every customer;
|
||||
* - two entries labelled "Deploy" put identically-named rows in one nav section,
|
||||
* one of them the admin ESTATE map;
|
||||
* - a sub-page with no `:tab` route renders an honest stub, and nothing fails.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { SLUG_ALIASES } from '~/lib/products/match-core'
|
||||
|
||||
const SRC = readFileSync(join(__dirname, '..', '..', '..', 'lib', 'products', 'registry.tsx'), 'utf8')
|
||||
|
||||
/** One catalog entry's source text: from its `id:` to the next entry's. */
|
||||
function entrySrc(id: string): string {
|
||||
const at = SRC.indexOf(`id: '${id}',`)
|
||||
expect(at, `catalog entry '${id}' not found in registry.tsx`).toBeGreaterThan(-1)
|
||||
const next = SRC.indexOf("\n id: '", at + 1)
|
||||
return SRC.slice(at, next === -1 ? SRC.length : next)
|
||||
}
|
||||
|
||||
describe('the Deploy product', () => {
|
||||
const src = entrySrc('deploy')
|
||||
|
||||
it('is a Platform module labelled Deploy', () => {
|
||||
expect(src).toMatch(/^\s*label: 'Deploy',\s*$/m)
|
||||
expect(src).toMatch(/^\s*category: 'Platform',\s*$/m)
|
||||
expect(src).toMatch(/^\s*kind: 'module',\s*$/m)
|
||||
})
|
||||
|
||||
it('is NOT admin-gated — a customer ships their own code', () => {
|
||||
expect(/^\s*admin:\s*true,\s*$/m.test(src)).toBe(false)
|
||||
})
|
||||
|
||||
it('declares the index and the :tab route its sub-pages need', () => {
|
||||
expect(src).toMatch(/\{ path: '', component: DeployModule \}/)
|
||||
expect(src).toMatch(/\{ path: ':tab', component: DeployModule \}/)
|
||||
})
|
||||
|
||||
it('declares exactly the six sub-pages the module dispatches on', () => {
|
||||
const slugs = [...src.matchAll(/\{ slug: '([a-z]+)', label:/g)].map((m) => m[1])
|
||||
expect(slugs).toEqual(['apps', 'sites', 'domains', 'cd', 'ci', 'storage'])
|
||||
})
|
||||
|
||||
it('is the ONE entry labelled Deploy in the whole catalog', () => {
|
||||
expect([...SRC.matchAll(/^\s*label: 'Deploy',\s*$/gm)]).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('/deploy resolves to the front door', () => {
|
||||
it('has no alias shadowing the head', () => {
|
||||
// `canonicalSlug` rewrites the FIRST segment unconditionally, so an alias
|
||||
// named `deploy` would route the front door's own URL somewhere else.
|
||||
expect(SLUG_ALIASES.deploy).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves App Platform addressable under its own id', () => {
|
||||
expect(SLUG_ALIASES['app-platform']).toBeUndefined()
|
||||
expect(entrySrc('app-platform')).toMatch(/^\s*label: 'App Platform',\s*$/m)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the estate fleet map stays admin-only, under its own name', () => {
|
||||
const src = entrySrc('gitops')
|
||||
|
||||
it('is labelled Fleet, not Deploy', () => {
|
||||
expect(src).toMatch(/^\s*label: 'Fleet',\s*$/m)
|
||||
})
|
||||
|
||||
it('is still admin-gated — it shows every org, not just yours', () => {
|
||||
expect(/^\s*admin:\s*true,\s*$/m.test(src)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,95 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* The deploy board's ONE read: the org's container apps and its static sites,
|
||||
* folded into a single `DeployRow` list.
|
||||
*
|
||||
* Two independent backends answer here (`/v1/platform/projects/:p/apps` and
|
||||
* `/v1/platform/sites`), so they are read with `allSettled` and reported
|
||||
* separately. A partial read stays a partial read: the rows that loaded render,
|
||||
* and `incomplete` names the source that did not, so a caller can say which
|
||||
* number on the screen is now a lie. Collapsing that into one error would hide
|
||||
* working data; collapsing it into silence would show a short list as if it were
|
||||
* the whole truth. Only when BOTH fail is there nothing honest to draw, and then
|
||||
* `error` carries the state card.
|
||||
*
|
||||
* The apps fan-out is done HERE rather than through `PaasApi.listAllApps`, which
|
||||
* swallows a per-project failure with `.catch(() => [])`. That is a fine default
|
||||
* for a board that only wants rows, but it makes "you have 3 apps" indistinguishable
|
||||
* from "you have 3 apps that we could see", and this board's whole job is to say
|
||||
* which of the two it is showing.
|
||||
*
|
||||
* Org scoping is the bearer proxy's job on the server side. This hook sends no
|
||||
* org and filters by none.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { PaasApi } from '~/lib/api/paas'
|
||||
import { PlatformSitesApi } from '~/lib/api/platform-sites'
|
||||
import { byRecency, rowOfApp, rowOfSite, type DeployKind, type DeployRow } from '~/lib/deploy/board'
|
||||
import { interpretPlatformError, type PlatformError } from '../platform/state'
|
||||
|
||||
export type Board = {
|
||||
rows: DeployRow[]
|
||||
loading: boolean
|
||||
/** Set only when BOTH sources failed — there is nothing truthful to render. */
|
||||
error: PlatformError | null
|
||||
/** Sources whose rows are missing or partial. Empty when the board is whole. */
|
||||
incomplete: DeployKind[]
|
||||
reload: () => Promise<void>
|
||||
}
|
||||
|
||||
/** Rows for every project the org owns, and whether every project answered. */
|
||||
async function readApps(): Promise<{ rows: DeployRow[]; whole: boolean }> {
|
||||
// A failure HERE is total — no project list means no apps at all — so it
|
||||
// propagates and the caller marks the whole source missing.
|
||||
const projects = await PaasApi.listProjects()
|
||||
const settled = await Promise.allSettled(
|
||||
projects.map((project) =>
|
||||
PaasApi.listApps(project.slug || project.id).then((apps) => apps.map((app) => rowOfApp({ ...app, project }))),
|
||||
),
|
||||
)
|
||||
return {
|
||||
rows: settled.flatMap((s) => (s.status === 'fulfilled' ? s.value : [])),
|
||||
whole: settled.every((s) => s.status === 'fulfilled'),
|
||||
}
|
||||
}
|
||||
|
||||
export function useBoard(): Board {
|
||||
const [rows, setRows] = useState<DeployRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<PlatformError | null>(null)
|
||||
const [incomplete, setIncomplete] = useState<DeployKind[]>([])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const [apps, sites] = await Promise.allSettled([readApps(), PlatformSitesApi.list()])
|
||||
|
||||
const next: DeployRow[] = []
|
||||
if (apps.status === 'fulfilled') next.push(...apps.value.rows)
|
||||
if (sites.status === 'fulfilled') next.push(...sites.value.map(rowOfSite))
|
||||
setRows(byRecency(next))
|
||||
|
||||
if (apps.status === 'rejected' && sites.status === 'rejected') {
|
||||
setError(interpretPlatformError(apps.reason))
|
||||
setIncomplete([])
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setError(null)
|
||||
setIncomplete([
|
||||
// Rejected = the source is missing entirely; fulfilled-but-not-whole = some
|
||||
// projects answered and some did not. Both make the app counts a floor.
|
||||
...(apps.status === 'rejected' || !apps.value.whole ? (['app'] as const) : []),
|
||||
...(sites.status === 'rejected' ? (['site'] as const) : []),
|
||||
])
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
return { rows, loading, error, incomplete, reload: load }
|
||||
}
|
||||
@@ -107,7 +107,7 @@ function NeedHelpCard() {
|
||||
return (
|
||||
<SectionCard title="Need help?" p="$3">
|
||||
<YStack gap="$0.5">
|
||||
<ActionRow icon={<MessageSquare size={15} />} label="Join Discord" onPress={() => openExternal('https://discord.gg/CJCyAsm9Vr')} />
|
||||
<ActionRow icon={<MessageSquare size={15} />} label="Join Discord" onPress={() => openExternal('https://discord.gg/hanzo')} />
|
||||
<ActionRow icon={<LifeBuoy size={15} />} label="Contact Support" onPress={() => openExternal(`mailto:support@${apex}`)} />
|
||||
</YStack>
|
||||
</SectionCard>
|
||||
|
||||
@@ -54,7 +54,7 @@ export function ResourceRail({ config, onViewCode }: { config: ProductLandingCon
|
||||
|
||||
<LandingCard title="Need help?" p="$3">
|
||||
<YStack gap="$0.5">
|
||||
<ActionRow icon={<MessageSquare size={15} />} label="Community" sub="Join the Discord" onPress={() => openExternal('https://discord.gg/CJCyAsm9Vr')} />
|
||||
<ActionRow icon={<MessageSquare size={15} />} label="Community" sub="Join the Discord" onPress={() => openExternal('https://discord.gg/hanzo')} />
|
||||
<ActionRow icon={<LifeBuoy size={15} />} label="Contact Support" sub={`support@${apex}`} onPress={() => openExternal(supportMailto(docs))} />
|
||||
</YStack>
|
||||
</LandingCard>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { CheckCircle2, TriangleAlert } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { ApiError } from '~/lib/api'
|
||||
|
||||
export type PlatformErrorKind = 'not-configured' | 'forbidden' | 'payment' | 'unavailable' | 'error'
|
||||
export type PlatformErrorKind = 'not-configured' | 'forbidden' | 'unavailable' | 'error'
|
||||
|
||||
export type PlatformError = { kind: PlatformErrorKind; message: string }
|
||||
|
||||
@@ -27,12 +27,6 @@ export function interpretPlatformError(e: unknown): PlatformError {
|
||||
// the infra PAAS_SERVICE_TOKEN message (that would be a false claim to a customer).
|
||||
if (status === 501) return { kind: 'not-configured', message }
|
||||
if (status === 401 || status === 403) return { kind: 'forbidden', message }
|
||||
// 402 = the spend gate refused: no active subscription and no prepaid credit, or
|
||||
// a spend cap was reached. The control plane is reachable and the caller is
|
||||
// authorized — it is a BILLING answer, so it must not read as an outage. The
|
||||
// backend's own sentence already names the cure ("Add credits at …"), so it is
|
||||
// shown verbatim rather than replaced with a guess about which cure applies.
|
||||
if (status === 402) return { kind: 'payment', message }
|
||||
if (status === 404) return { kind: 'unavailable', message }
|
||||
// 503 = the route is mounted but its runtime/dependency is not configured on
|
||||
// THIS deployment (e.g. zt networking fail-closed until ZT_CLIENT_* is set).
|
||||
@@ -46,7 +40,6 @@ export function interpretPlatformError(e: unknown): PlatformError {
|
||||
const TITLES: Record<PlatformErrorKind, string> = {
|
||||
'not-configured': 'PaaS control plane not configured',
|
||||
forbidden: 'Connected · managed by Hanzo',
|
||||
payment: 'Billing required',
|
||||
unavailable: 'Endpoint not served here',
|
||||
error: 'Could not reach the platform',
|
||||
}
|
||||
@@ -58,8 +51,6 @@ const BODIES: Record<PlatformErrorKind, string> = {
|
||||
'Your workloads run on managed Hanzo Cloud — no cluster to operate. The full control-plane fleet view (clusters, nodes, raw workloads) is an admin surface; deploy and scale through Functions, Agents, and the platform.',
|
||||
unavailable:
|
||||
'The platform backend on this deployment does not serve this endpoint (it ships as a separate service). This view reads live data wherever the endpoint is served; nothing is fabricated here.',
|
||||
// Empty → the card shows the backend's own sentence, which names the cure.
|
||||
payment: '',
|
||||
error: '',
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ export function ModelSelect({
|
||||
ids,
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder = 'model id, e.g. zen5-mini',
|
||||
placeholder = 'model id, e.g. zen-omni',
|
||||
}: {
|
||||
value: string
|
||||
ids: string[]
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { EXAMPLES } from './examples'
|
||||
|
||||
describe('EXAMPLES', () => {
|
||||
it('has unique ids and fills every field', () => {
|
||||
const ids = EXAMPLES.map((e) => e.id)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
for (const e of EXAMPLES) {
|
||||
expect(e.label.trim()).not.toBe('')
|
||||
expect(e.system.trim()).not.toBe('')
|
||||
expect(e.user.trim()).not.toBe('')
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* The defect this guards: all six examples once suggested `zen-omni` or `zen-coder`,
|
||||
* and the gateway serves neither. Applying an example falls back to the selected
|
||||
* model when the suggestion is absent, so nothing threw and nothing was logged — the
|
||||
* card just advertised a model that could never be the one that ran.
|
||||
*
|
||||
* The test is on the SHAPE, not on a list of ids, because a hardcoded catalog would
|
||||
* rot the same way the suggestions did. Zen's naming splits cleanly: `zen5…` are the
|
||||
* text models; `zen-<noun>` names a modality (embedding, image, video, rerank, voice,
|
||||
* vl, guard) and cannot hold a chat turn. A chat example must suggest a text model.
|
||||
*/
|
||||
it('suggests only Zen TEXT models — never a modality SKU or a retired id', () => {
|
||||
for (const e of EXAMPLES) {
|
||||
expect(e.model, `${e.id} suggests "${e.model}"`).toMatch(/^zen\d/)
|
||||
}
|
||||
})
|
||||
|
||||
it('never suggests the ids that were wrong', () => {
|
||||
const retired = new Set(['zen-omni', 'zen-coder'])
|
||||
for (const e of EXAMPLES) expect(retired.has(e.model)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -3,15 +3,8 @@
|
||||
*
|
||||
* Each is a clearly-labelled STARTER (not fabricated history): picking one fills
|
||||
* the System + User message and, when the suggested model is in the live catalog,
|
||||
* selects it. Pure data; no network. The `model` is a suggestion shown as a chip.
|
||||
*
|
||||
* EVERY MODEL HERE MUST BE ONE THE GATEWAY SERVES. All six once named `zen-omni` or
|
||||
* `zen-coder`, and the catalog carries neither — zen's text models are `zen5…`, while
|
||||
* `zen-<noun>` names a modality (embedding, image, video, rerank, voice, vl). Applying
|
||||
* an example falls back to the currently-selected model when the suggestion is absent
|
||||
* (`models.byId.has(...)`), so nothing broke and nothing was logged: the card simply
|
||||
* advertised a model that would never be the one that ran. `examples.test.ts` bites on
|
||||
* the shape now, so a modality SKU or a retired id cannot come back.
|
||||
* selects it. Pure data; no network. The `model` is a suggestion shown as a chip,
|
||||
* exactly like the mockup ("Explain quantum computing · zen-omni").
|
||||
*/
|
||||
export type Example = {
|
||||
id: string
|
||||
@@ -27,42 +20,42 @@ export const EXAMPLES: Example[] = [
|
||||
{
|
||||
id: 'quantum',
|
||||
label: 'Explain quantum computing',
|
||||
model: 'zen5-mini',
|
||||
model: 'zen-omni',
|
||||
system: 'You are a patient teacher. Explain clearly for a curious beginner.',
|
||||
user: 'Explain quantum computing in simple terms, with one everyday analogy.',
|
||||
},
|
||||
{
|
||||
id: 'debounce',
|
||||
label: 'Write a debounce function',
|
||||
model: 'zen5-coder',
|
||||
model: 'zen-coder',
|
||||
system: 'You are an expert TypeScript engineer. Return only the code, no prose.',
|
||||
user: 'Write a typed debounce<T> function with a cancel() method.',
|
||||
},
|
||||
{
|
||||
id: 'summarize',
|
||||
label: 'Summarize a paragraph',
|
||||
model: 'zen5-mini',
|
||||
model: 'zen-omni',
|
||||
system: 'Summarize the user text in exactly three bullet points.',
|
||||
user: 'Hanzo Cloud is a unified AI gateway exposing hundreds of models behind one OpenAI-compatible API, with built-in retrieval, billing and per-org keys, so orgs switch models without changing code.',
|
||||
},
|
||||
{
|
||||
id: 'json',
|
||||
label: 'Extract structured JSON',
|
||||
model: 'zen5-mini',
|
||||
model: 'zen-omni',
|
||||
system: 'Respond with a single minified JSON object and nothing else.',
|
||||
user: 'Extract name, role and company as JSON from: "Aoi Tanaka, the CTO at Hanzo, presented today."',
|
||||
},
|
||||
{
|
||||
id: 'reasoning',
|
||||
label: 'Step-by-step reasoning',
|
||||
model: 'zen5',
|
||||
model: 'zen-omni',
|
||||
system: 'Think step by step, then give the final answer on its own line.',
|
||||
user: 'A bat and a ball cost $1.10 in total. The bat costs $1.00 more than the ball. How much does the ball cost?',
|
||||
},
|
||||
{
|
||||
id: 'sql',
|
||||
label: 'Write a SQL query',
|
||||
model: 'zen5-coder',
|
||||
model: 'zen-coder',
|
||||
system: 'You are a senior data engineer. Return only the SQL.',
|
||||
user: 'Given users(id, created_at) and orders(id, user_id, total), write SQL for the top 5 users by total spend in 2026.',
|
||||
},
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
* SubNav — the ONE level-2 nav for a product.
|
||||
*
|
||||
* Clicking into a product reveals ITS options rather than replacing the screen.
|
||||
* That second level lives in the SIDEBAR (`SubRows`), expanded beneath the product's
|
||||
* own row — the house pattern. This strip is the SAME nav, rendered from
|
||||
* That second level lives in the SIDEBAR (`DrillNav`) — the house pattern, the one
|
||||
* the whole console already drills with. This strip is the SAME nav, rendered from
|
||||
* the SAME source (`productSubpages` over the registry), for the viewports where
|
||||
* the sidebar is not on screen: below `lg` the sidebar is a drawer, so the strip
|
||||
* carries level 2 in the content column and hides itself at `lg+` where the rail
|
||||
@@ -75,7 +75,7 @@ export function SubNav({
|
||||
const to = href ?? ((slug: string) => subpageHref(id, slug))
|
||||
|
||||
return (
|
||||
// Hidden at lg+ — the sidebar's SubRows is the level-2 nav there. Purely a
|
||||
// Hidden at lg+ — the sidebar's DrillNav is the level-2 nav there. Purely a
|
||||
// CSS media style prop (not a JS media branch), so SSR and first paint match.
|
||||
<XStack
|
||||
gap="$1.5"
|
||||
|
||||
@@ -50,15 +50,6 @@ export type ConsoleConfig = {
|
||||
brandName: string
|
||||
/** Unified cloud backend base URL (hanzoai/cloud /v1) — shared across brands. */
|
||||
cloudUrl: string
|
||||
/**
|
||||
* The PUBLIC gated API host — what a customer's own code calls, and the only base
|
||||
* that belongs in a copyable snippet. Distinct from `cloudUrl`, which is SAME-ORIGIN
|
||||
* in the browser: printing that would hand someone `https://console.<brand>/v1/…`,
|
||||
* a URL that works for the SPA's proxied session and not for their API key. Shared
|
||||
* across brands, because the cloud backend is one multi-tenant `/v1` scoped by the
|
||||
* brand JWT's org — there is no per-brand API host to resolve.
|
||||
*/
|
||||
apiUrl: string
|
||||
/** PaaS base URL (DOKS cluster control plane) — shared. */
|
||||
platformUrl: string
|
||||
/**
|
||||
@@ -478,7 +469,6 @@ export function resolveConfig(host: string = currentHost()): ConsoleConfig {
|
||||
brand,
|
||||
brandName: b.brandName,
|
||||
cloudUrl: cloudUrl(),
|
||||
apiUrl: trimSlash(process.env.NEXT_PUBLIC_API_URL ?? 'https://api.hanzo.ai'),
|
||||
iamUrl: trimSlash(process.env.NEXT_PUBLIC_IAM_URL ?? b.iamUrl),
|
||||
iamOrgName: process.env.NEXT_PUBLIC_IAM_ORG_NAME ?? org,
|
||||
iamAppName: process.env.NEXT_PUBLIC_IAM_APP_NAME ?? app,
|
||||
|
||||
+121
-127
@@ -1,27 +1,19 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Dashboard shell — a TWO-LEVEL sidebar (products, each expanding its own sub-pages
|
||||
* in place) + top bar + content, responsive across phone / tablet / laptop / desktop.
|
||||
* Dashboard shell — a TWO-LEVEL sidebar (product list ⇄ drill into a product) + top
|
||||
* bar + content, responsive across phone / tablet / laptop / desktop.
|
||||
*
|
||||
* Level 1 (the product list) renders from the catalog: fixed Overview/Docs, a
|
||||
* Pinned section the user curates, then every product grouped by category. Each
|
||||
* CATEGORY is an INDEPENDENTLY collapsible section that renders EXPANDED by default
|
||||
* (nothing auto-collapses); the header is flush-left with the top-level items and
|
||||
* carries an OPTIONAL collapse chevron whose state persists per-user.
|
||||
*
|
||||
* Level 2 — a product's sub-pages (Overview + specifics + the uniform base set:
|
||||
* Settings · Status · Logs · Metrics) — expands BENEATH that product's own row, so
|
||||
* its options appear without the rest of the catalog going away. The label
|
||||
* navigates; the chevron beside it only expands or collapses, and that choice
|
||||
* persists per-user (the product you are IN is open unless you closed it). Sub-pages
|
||||
* with no backend yet are dimmed and open an honest placeholder, never a dead link.
|
||||
*
|
||||
* This replaced a DRILL: clicking a product used to swap the whole rail for that
|
||||
* product's sub-nav, behind a "Back to all products" button. The options were the
|
||||
* same either way — what the drill took away was every OTHER product, which is
|
||||
* precisely what someone needs when the reason they opened the rail was to go
|
||||
* somewhere else.
|
||||
* carries an OPTIONAL collapse chevron whose state persists per-user. Clicking a
|
||||
* PRODUCT that has sub-pages DRILLS the sidebar INTO that product's sub-nav
|
||||
* (Overview + specifics + the uniform base set: Settings · Status · Logs · Metrics)
|
||||
* with a clear BACK affordance to the full list — Level 2. A product with only an
|
||||
* Overview navigates directly (no drill). Sub-pages with no backend yet are dimmed
|
||||
* and open an honest placeholder (never a dead link).
|
||||
*
|
||||
* Level 2 is DECLARED once, in the registry (`subpages` + `indexLabel`), and read
|
||||
* here and by `SubNav` (the same nav, for the viewports where this sidebar is a
|
||||
@@ -50,10 +42,11 @@
|
||||
* NOT a JS media branch, so SSR and first paint match. The nav body (`SidebarNav`) is
|
||||
* shared by the sidebar, the flyout, and the drawer (DRY) — one definition, many mounts.
|
||||
*/
|
||||
import { useMemo, useState, type ComponentType, type ReactNode } from 'react'
|
||||
import { useEffect, useMemo, useState, type ComponentType, type ReactNode } from 'react'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { Button, Input, ScrollView, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import {
|
||||
ArrowLeft,
|
||||
BarChart3,
|
||||
Bell,
|
||||
BookOpen,
|
||||
@@ -68,6 +61,7 @@ import {
|
||||
Lock,
|
||||
Menu,
|
||||
PanelLeft,
|
||||
Plus,
|
||||
Repeat,
|
||||
ScrollText,
|
||||
Search,
|
||||
@@ -95,17 +89,9 @@ import { entryMatches } from '~/lib/products/search'
|
||||
import { usePins, useProductColors } from '~/lib/products/pins'
|
||||
import { useAppsBeta } from '~/lib/products/beta'
|
||||
import { orderEntries } from '~/lib/products/order'
|
||||
import {
|
||||
categoryIsOpen,
|
||||
toggleCategory,
|
||||
productIsOpen,
|
||||
toggleProduct,
|
||||
NAV_OPEN_PREF,
|
||||
NAV_PRODUCT_OPEN_PREF,
|
||||
EMPTY_OPEN,
|
||||
type CategoryOpen,
|
||||
} from '~/lib/products/nav-accordion'
|
||||
import { categoryIsOpen, toggleCategory, NAV_OPEN_PREF, EMPTY_OPEN, type CategoryOpen } from '~/lib/products/nav-accordion'
|
||||
import { usePreferences } from '~/lib/products/preferences'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { useEntitlements } from '~/lib/entitlements-context'
|
||||
import { AddProductPanel } from '~/components/AddProductPanel'
|
||||
@@ -210,18 +196,14 @@ function FixedRow({
|
||||
)
|
||||
}
|
||||
|
||||
/** A level-1 catalog row — colored product icon, opens the product; trailing is an
|
||||
* expansion chevron (products with sub-pages) then a pin star (catalog rows) or a
|
||||
* color/customize dot (pinned rows). */
|
||||
/** A level-1 catalog row — colored product icon, opens the product; trailing is a
|
||||
* pin star (catalog rows) or a color/customize dot (pinned rows). */
|
||||
function NavRow({
|
||||
entry,
|
||||
active,
|
||||
color,
|
||||
collapsed,
|
||||
pinned,
|
||||
expandable,
|
||||
expanded,
|
||||
onExpand,
|
||||
onOpen,
|
||||
onToggle,
|
||||
onCustomize,
|
||||
@@ -231,10 +213,6 @@ function NavRow({
|
||||
color: string
|
||||
collapsed: boolean
|
||||
pinned?: boolean
|
||||
/** True when the product has sub-pages to expand beneath it. */
|
||||
expandable?: boolean
|
||||
expanded?: boolean
|
||||
onExpand?: () => void
|
||||
onOpen: () => void
|
||||
onToggle?: () => void
|
||||
onCustomize?: () => void
|
||||
@@ -272,27 +250,6 @@ function NavRow({
|
||||
>
|
||||
{entry.label}
|
||||
</Button>
|
||||
{/* Expansion is its OWN control, separate from the row: the label navigates,
|
||||
the chevron only opens or closes. One target that did both would make
|
||||
"show me what's in here" and "take me there" the same gesture. */}
|
||||
{expandable && onExpand ? (
|
||||
<Button
|
||||
size="$2"
|
||||
chromeless
|
||||
onPress={onExpand}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={`nav-sub-${entry.id}`}
|
||||
aria-label={`${expanded ? 'Collapse' : 'Expand'} ${entry.label}`}
|
||||
icon={
|
||||
<span
|
||||
className="hz-chevron"
|
||||
style={{ display: 'inline-flex', transform: expanded ? 'rotate(90deg)' : undefined }}
|
||||
>
|
||||
<ChevronRight size={14} color="$color9" />
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{onCustomize ? (
|
||||
<ColorDot color={color} onPress={onCustomize} label={`Customize ${entry.label}`} />
|
||||
) : onToggle ? (
|
||||
@@ -310,32 +267,58 @@ function NavRow({
|
||||
}
|
||||
|
||||
/**
|
||||
* Level 2 — a product's sub-pages, expanded IN PLACE beneath its own row. The list
|
||||
* is `productSubpages(entry)` (Overview + specifics + the uniform base set), with an
|
||||
* unwired sub-page dimmed but honest: it opens a placeholder, never a dead link.
|
||||
*
|
||||
* Indented under the product and hung on a hairline, so the nesting is legible
|
||||
* without a second heading — the product's own row above IS the heading. Collapsed,
|
||||
* the rows are `inert`, so hidden options leave the tab order.
|
||||
* Level 2 — the drilled product's sub-nav. Reached by clicking a product with
|
||||
* sub-pages; a BACK affordance returns to the full product list (Level 1). The
|
||||
* header shows the product (colored icon + name) under a category breadcrumb; the
|
||||
* body is `productSubpages(entry)` — Overview + specifics + the uniform base set —
|
||||
* with an unwired sub-page dimmed but honest (opens a placeholder, never a dead link).
|
||||
*/
|
||||
function SubRows({
|
||||
function DrillNav({
|
||||
entry,
|
||||
subs,
|
||||
pathname,
|
||||
open,
|
||||
color,
|
||||
onBack,
|
||||
onGo,
|
||||
}: {
|
||||
entry: CatalogEntry
|
||||
subs: ProductSubpage[]
|
||||
pathname: string
|
||||
open: boolean
|
||||
color: string
|
||||
onBack: () => void
|
||||
onGo: (path: string) => void
|
||||
}) {
|
||||
const activeSlug = activeSubpage(pathname, entry.id)
|
||||
const Icon = entry.icon
|
||||
return (
|
||||
<div className="hz-acc" data-open={open ? 'true' : 'false'} id={`nav-sub-${entry.id}`} inert={!open}>
|
||||
<div className="hz-acc-inner">
|
||||
<YStack gap="$0.5" ml="$4" pl="$2" pt="$0.5" borderLeftWidth={1} borderColor="$borderColor">
|
||||
<>
|
||||
{/* Back to the full product list, with the category as a quiet breadcrumb. */}
|
||||
<Button
|
||||
chromeless
|
||||
size="$2"
|
||||
height={34}
|
||||
px="$2"
|
||||
justify="flex-start"
|
||||
icon={<ArrowLeft size={17} />}
|
||||
onPress={onBack}
|
||||
hoverStyle={{ bg: '$color3' }}
|
||||
aria-label="Back to all products"
|
||||
>
|
||||
<Text fontSize="$1" color="$color10" fontWeight="500">
|
||||
{entry.category}
|
||||
</Text>
|
||||
</Button>
|
||||
|
||||
{/* The product header. */}
|
||||
<XStack items="center" gap="$2.5" px="$2" py="$1.5" mb="$1">
|
||||
<ProductIcon icon={Icon} color={color} size={22} />
|
||||
<Text flex={1} fontSize="$5" fontWeight="800" color="$color12" numberOfLines={1}>
|
||||
{entry.label}
|
||||
</Text>
|
||||
</XStack>
|
||||
|
||||
<ScrollView flex={1} minH={0}>
|
||||
<YStack gap="$0.5">
|
||||
{subs.map((sp) => {
|
||||
const wired = subpageWired(entry.id, sp.slug)
|
||||
const active = sp.slug === activeSlug
|
||||
@@ -346,9 +329,9 @@ function SubRows({
|
||||
onPress={() => onGo(sp.slug ? `/${entry.id}/${sp.slug}` : `/${entry.id}`)}
|
||||
bg={active ? '$color4' : 'transparent'}
|
||||
justify="flex-start"
|
||||
icon={<SubIcon size={15} />}
|
||||
icon={<SubIcon size={17} />}
|
||||
iconAfter={!wired ? <Circle size={7} opacity={0.5} /> : undefined}
|
||||
size="$2"
|
||||
size="$3"
|
||||
opacity={wired ? 1 : 0.6}
|
||||
aria-label={wired ? sp.label : `${sp.label} (not available yet)`}
|
||||
>
|
||||
@@ -357,8 +340,8 @@ function SubRows({
|
||||
)
|
||||
})}
|
||||
</YStack>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollView>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -486,23 +469,31 @@ function SidebarNav({
|
||||
const navOpen = prefs.get<CategoryOpen>(NAV_OPEN_PREF, EMPTY_OPEN)
|
||||
const toggleSection = (category: string) => prefs.set(NAV_OPEN_PREF, toggleCategory(navOpen, category))
|
||||
|
||||
// ── Level 2, in place ─────────────────────────────────────────────────────
|
||||
// A product's sub-pages expand beneath its own row; nothing replaces the list.
|
||||
// ── Drill-in state (Level 1 ⇄ Level 2) ────────────────────────────────────
|
||||
const activeId = activeModuleId(pathname)
|
||||
const activeEntry = activeId ? findEntry(activeId) ?? null : null
|
||||
const activeSubs = useMemo(
|
||||
() => (activeEntry && activeEntry.kind === 'module' ? productSubpages(activeEntry, showAdmin) : []),
|
||||
[activeEntry, showAdmin],
|
||||
)
|
||||
// `manualList` = the user hit BACK — force the Level-1 list even though the active
|
||||
// route is a drillable product. Entering a DIFFERENT product resets it (auto-drill).
|
||||
const [manualList, setManualList] = useState(false)
|
||||
useEffect(() => {
|
||||
setManualList(false)
|
||||
}, [activeId])
|
||||
const canDrill = Boolean(activeEntry) && activeSubs.length > 1
|
||||
const drilled = canDrill && !manualList && !collapsed
|
||||
const isActive = (id: string) => pathname === `/${id}` || pathname.startsWith(`/${id}/`)
|
||||
const productOpen = prefs.get<CategoryOpen>(NAV_PRODUCT_OPEN_PREF, EMPTY_OPEN)
|
||||
const toggleExpand = (id: string) =>
|
||||
prefs.set(NAV_PRODUCT_OPEN_PREF, toggleProduct(productOpen, id, { active: id === activeId }))
|
||||
|
||||
// Navigate to a LEAF (a sub-page or a no-sub-page product) — closes the drawer.
|
||||
const go = (path: string) => {
|
||||
router.push(path)
|
||||
onNavigate()
|
||||
}
|
||||
// Open a product from the list. One with sub-pages keeps the drawer open, because
|
||||
// becoming active expands it in place and its options are the next thing to read;
|
||||
// a leaf navigates and closes. An external launch tile opens its deployed app in a
|
||||
// new tab.
|
||||
// Open a product from the list: DRILL if it has sub-pages (keep the drawer open so
|
||||
// the sub-nav shows), else navigate directly (leaf → close the drawer). An external
|
||||
// launch tile opens its deployed app in a new tab.
|
||||
const open = (entry: CatalogEntry) => {
|
||||
if (entry.kind === 'external') {
|
||||
openProduct(entry, go)
|
||||
@@ -512,48 +503,12 @@ function SidebarNav({
|
||||
setFilter('')
|
||||
const subs = productSubpages(entry, showAdmin)
|
||||
if (subs.length > 1) {
|
||||
router.push(`/${entry.id}`) // its sub-pages open beneath it
|
||||
setManualList(false)
|
||||
router.push(`/${entry.id}`) // DRILL — keep the drawer open for the sub-nav
|
||||
} else {
|
||||
go(`/${entry.id}`) // leaf — navigate + close
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ONE product row — the row itself plus, for a product that has them, its sub-pages
|
||||
* expanded beneath. Both the Pinned group and the category groups render through
|
||||
* this, so a product looks and behaves identically wherever it appears.
|
||||
*/
|
||||
const productRow = (entry: CatalogEntry, opts: { pinned?: boolean } = {}) => {
|
||||
const subs = productSubpages(entry, showAdmin)
|
||||
// A pinned product appears TWICE — once under Pinned, once in its category — and
|
||||
// only ONE of those may carry the sub-pages. Two copies of the same list is two
|
||||
// navs painting at once, which is the very thing this rail exists to avoid, and
|
||||
// it doubles the rail's height for no information. The PINNED copy owns it: the
|
||||
// user put it up there, and it is the one they read first.
|
||||
const owns = opts.pinned || !isPinned(entry.id)
|
||||
const expandable = owns && entry.kind === 'module' && subs.length > 1
|
||||
const expanded = expandable && productIsOpen(productOpen, entry.id, { filtering, active: entry.id === activeId })
|
||||
return (
|
||||
<YStack key={`${opts.pinned ? 'pin' : 'cat'}-${entry.id}`} gap="$0.5">
|
||||
<NavRow
|
||||
entry={entry}
|
||||
active={isActive(entry.id)}
|
||||
color={colorOf(entry.id)}
|
||||
collapsed={false}
|
||||
pinned={opts.pinned ?? isPinned(entry.id)}
|
||||
expandable={expandable}
|
||||
expanded={expanded}
|
||||
onExpand={expandable ? () => toggleExpand(entry.id) : undefined}
|
||||
onOpen={() => open(entry)}
|
||||
onToggle={opts.pinned ? undefined : () => toggle(entry.id)}
|
||||
onCustomize={opts.pinned ? () => customize(entry) : undefined}
|
||||
/>
|
||||
{expandable ? (
|
||||
<SubRows entry={entry} subs={subs} pathname={pathname} open={expanded} onGo={go} />
|
||||
) : null}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
const openDocs = () => {
|
||||
if (typeof window !== 'undefined') window.open(config.docsUrl, '_blank', 'noopener')
|
||||
onNavigate()
|
||||
@@ -725,9 +680,26 @@ function SidebarNav({
|
||||
)
|
||||
}
|
||||
|
||||
// ── The product list: brand; filter; Overview/Docs; Pinned; every category
|
||||
// (EXPANDED by default, collapsible), each product expanding its own sub-pages
|
||||
// in place; All-products; identity + wallet. ──
|
||||
// ── Level 2 — drilled into a product's sub-nav, with a BACK affordance ──
|
||||
if (drilled && activeEntry) {
|
||||
return (
|
||||
<>
|
||||
<DrillNav
|
||||
entry={activeEntry}
|
||||
subs={activeSubs}
|
||||
pathname={pathname}
|
||||
color={colorOf(activeEntry.id)}
|
||||
onBack={() => setManualList(true)}
|
||||
onGo={go}
|
||||
/>
|
||||
<SidebarAccount collapsed={false} />
|
||||
<SidebarWallet collapsed={false} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Level 1 — the full product list: brand; filter; Overview/Docs; Pinned; every
|
||||
// category (EXPANDED by default, collapsible); All-products; identity + wallet. ──
|
||||
return (
|
||||
// The product rail is a NAVIGATION LANDMARK. It had no role at all, so a
|
||||
// screen-reader user had no way to jump to the product list and no way to
|
||||
@@ -805,7 +777,18 @@ function SidebarNav({
|
||||
{group.entries.map((e) => {
|
||||
const entry = findEntry(e.id)
|
||||
if (!entry) return null
|
||||
return productRow(entry, { pinned: true })
|
||||
return (
|
||||
<NavRow
|
||||
key={`pin-${e.id}`}
|
||||
entry={entry}
|
||||
active={isActive(e.id)}
|
||||
color={colorOf(e.id)}
|
||||
collapsed={false}
|
||||
pinned
|
||||
onOpen={() => open(entry)}
|
||||
onCustomize={() => customize(entry)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</YStack>
|
||||
))}
|
||||
@@ -820,7 +803,18 @@ function SidebarNav({
|
||||
open={categoryIsOpen(navOpen, group.category, { filtering })}
|
||||
onToggle={() => toggleSection(group.category)}
|
||||
>
|
||||
{group.entries.map((entry) => productRow(entry))}
|
||||
{group.entries.map((entry) => (
|
||||
<NavRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
active={isActive(entry.id)}
|
||||
color={colorOf(entry.id)}
|
||||
collapsed={false}
|
||||
pinned={isPinned(entry.id)}
|
||||
onOpen={() => open(entry)}
|
||||
onToggle={() => toggle(entry.id)}
|
||||
/>
|
||||
))}
|
||||
</CategorySection>
|
||||
))}
|
||||
|
||||
|
||||
@@ -531,40 +531,6 @@ export type NewAgentBody = {
|
||||
config?: Partial<AgentConfig>
|
||||
}
|
||||
|
||||
/**
|
||||
* One recorded run of an agent — what `POST /v1/agents/:ref/run` answers with.
|
||||
*
|
||||
* Every run this carries reflects an execution that ACTUALLY happened: the backend
|
||||
* records a model failure as a run with `status: "error"` and its message, and
|
||||
* answers 502 with that same run as the body. So a caller reads the run either way,
|
||||
* and a failure is a fact about the run rather than a transport error to guess at.
|
||||
*/
|
||||
export type AgentRun = {
|
||||
id: string
|
||||
/** `ok` when the completion came back, `error` when it did not. */
|
||||
status: string
|
||||
/** The model actually used — a failover run reports the one it fell over to. */
|
||||
model: string
|
||||
/** The completion, when there was one. */
|
||||
output?: string
|
||||
/** The failure, when there was one. */
|
||||
error?: string
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
/** Normalize a run payload defensively — a renamed field degrades, never throws. */
|
||||
export function normalizeRun(payload: unknown): AgentRun {
|
||||
const r = asRecord(payload)
|
||||
return {
|
||||
id: str(r.id) ?? '',
|
||||
status: str(r.status) ?? '',
|
||||
model: str(r.model) ?? '',
|
||||
output: str(r.output),
|
||||
error: str(r.error),
|
||||
durationMs: num(r.durationMs),
|
||||
}
|
||||
}
|
||||
|
||||
export const AgentsApi = {
|
||||
/** The agent registry (`GET /v1/agents`). Honest-empty/error until bound. */
|
||||
list: (): Promise<Agent[]> => restGet<unknown>(originV1Url(BASE)).then(normalizeAgents),
|
||||
@@ -585,19 +551,6 @@ export const AgentsApi = {
|
||||
/** Create an agent (`POST /v1/agents`) — only called when the backend is live. */
|
||||
create: (body: NewAgentBody): Promise<unknown> => restPost<unknown>(originV1Url(BASE), body),
|
||||
|
||||
/**
|
||||
* Run an agent once (`POST /v1/agents/:ref/run`) and get the recorded run back.
|
||||
* `ref` is the agent's name or its `agent_…` id — either resolves the same agent.
|
||||
*
|
||||
* THIS MOVES MONEY: the backend authorizes the org's balance BEFORE any inference,
|
||||
* so an unfunded org gets 402 and no free compute. A model failure answers 502 with
|
||||
* the RUN as the body, whose `error` field the transport surfaces as the thrown
|
||||
* message — so a caller that shows `e.message` is already showing the run's own
|
||||
* reason, not a generic transport failure.
|
||||
*/
|
||||
run: (ref: string, input: string): Promise<AgentRun> =>
|
||||
restPost<unknown>(originV1Url(`${agentPath(ref)}/run`), { input }).then(normalizeRun),
|
||||
|
||||
/** Delete an agent (`DELETE /v1/agents/:name`) — keyed by the agent's NAME, never the
|
||||
* display `id`. Only called when the backend is live. */
|
||||
remove: (name: string): Promise<void> => restDelete(originV1Url(agentPath(name))),
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* Tools API — the unified tool plane (`GET /v1/tools`).
|
||||
*
|
||||
* ONE flat set of callable names spanning every source the org can reach —
|
||||
* connector actions, user functions, zap-service routes, agents, skills, and the
|
||||
* org's own external MCP servers — deduplicated by name with source precedence
|
||||
* applied server-side. It LISTS; dispatch is `POST /v1/tools/call` and belongs to
|
||||
* whatever runs the agent, not to a form.
|
||||
*
|
||||
* Called SAME-ORIGIN with no prefix (`originV1Url('tools')`), so it rides the
|
||||
* console's own user-bearer proxy: a short-lived user-bound IAM token is minted
|
||||
* server-side and the cloud plane scopes the listing to the caller's org and
|
||||
* project. No credential reaches the browser.
|
||||
*
|
||||
* An org with nothing activated gets `{"tools":[]}` — a REAL empty answer, not a
|
||||
* failure. The agent builder shows that honestly (the field stays typeable) rather
|
||||
* than inventing a tool that would 404 on the first invocation.
|
||||
*/
|
||||
import { restGet, originV1Url } from './client'
|
||||
|
||||
/** Where a tool came from — the plane dedupes across these by name. */
|
||||
export type ToolSource = 'connector' | 'function' | 'zap-service' | 'agent' | 'skill' | 'mcp'
|
||||
|
||||
/** One callable tool as discovery reports it. */
|
||||
export type Tool = {
|
||||
/** The dispatch name — exactly what an agent's `tools` list must carry. */
|
||||
name: string
|
||||
/** Which plane provides it. */
|
||||
source?: ToolSource
|
||||
/** One line on what it does, when the provider carries one. */
|
||||
description?: string
|
||||
/** Whether it is activated for this org+project (an inactive tool lists but won't run). */
|
||||
activated: boolean
|
||||
}
|
||||
|
||||
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined)
|
||||
|
||||
/**
|
||||
* Defensive normalizer. The listing is read from whichever envelope key the plane
|
||||
* uses, and a row missing `name` is dropped — a nameless tool cannot be dispatched,
|
||||
* so surfacing it would offer the user something that cannot work.
|
||||
*/
|
||||
export function normalizeTools(payload: unknown): Tool[] {
|
||||
const rows = Array.isArray(payload)
|
||||
? payload
|
||||
: payload && typeof payload === 'object'
|
||||
? ((payload as Record<string, unknown>).tools ??
|
||||
(payload as Record<string, unknown>).data ??
|
||||
(payload as Record<string, unknown>).items)
|
||||
: undefined
|
||||
if (!Array.isArray(rows)) return []
|
||||
const out: Tool[] = []
|
||||
for (const raw of rows) {
|
||||
if (!raw || typeof raw !== 'object') continue
|
||||
const r = raw as Record<string, unknown>
|
||||
const name = str(r.name)
|
||||
if (!name) continue
|
||||
out.push({
|
||||
name,
|
||||
source: str(r.source) as ToolSource | undefined,
|
||||
description: str(r.description),
|
||||
activated: r.activated === true,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The tool plane's read side. */
|
||||
export const ToolsApi = {
|
||||
/**
|
||||
* Every tool the caller's org and project can reach, each flagged `activated`.
|
||||
* `activatedOnly` narrows to the callable set — the plane tests the raw query
|
||||
* against the literal string "true", so this sends that or nothing at all.
|
||||
*/
|
||||
list: (activatedOnly = false): Promise<Tool[]> =>
|
||||
restGet<unknown>(originV1Url(activatedOnly ? 'tools?activated=true' : 'tools')).then(normalizeTools),
|
||||
}
|
||||
@@ -1,388 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
rowOfApp,
|
||||
rowOfSite,
|
||||
primaryHost,
|
||||
boundHosts,
|
||||
hostOf,
|
||||
byRecency,
|
||||
summarize,
|
||||
repoName,
|
||||
envVars,
|
||||
prunePublicKeys,
|
||||
partialNote,
|
||||
parseEnv,
|
||||
hostError,
|
||||
envError,
|
||||
toAppInput,
|
||||
toSiteInput,
|
||||
formError,
|
||||
hostRows,
|
||||
type DeployForm,
|
||||
} from './board'
|
||||
import type { PaasAppWithProject } from '~/lib/api/paas'
|
||||
import type { Site } from '~/lib/api/platform-sites'
|
||||
|
||||
const app = (over: Partial<PaasAppWithProject> = {}): PaasAppWithProject =>
|
||||
({
|
||||
id: 'a1',
|
||||
org: 'acme',
|
||||
projectId: 'p1',
|
||||
slug: 'api',
|
||||
name: 'API',
|
||||
status: 'live',
|
||||
domains: ['api.acme.hanzo.app'],
|
||||
updatedAt: 200,
|
||||
project: { id: 'p1', org: 'acme', slug: 'web', name: 'Web' },
|
||||
...over,
|
||||
}) as PaasAppWithProject
|
||||
|
||||
const site = (over: Partial<Site> = {}): Site =>
|
||||
({
|
||||
id: 's1',
|
||||
org: 'acme',
|
||||
slug: 'docs',
|
||||
name: 'Docs',
|
||||
repo: {},
|
||||
framework: 'static',
|
||||
status: 'live',
|
||||
liveUrl: 'https://docs.acme.hanzo.app',
|
||||
createdAt: 50,
|
||||
updatedAt: 100,
|
||||
...over,
|
||||
}) as Site
|
||||
|
||||
describe('rows', () => {
|
||||
it('folds an app onto the board with its project and operator state', () => {
|
||||
const r = rowOfApp(app({ phase: 'Running', health: 'healthy' }))
|
||||
expect(r).toMatchObject({
|
||||
kind: 'app',
|
||||
slug: 'api',
|
||||
name: 'API',
|
||||
project: 'web',
|
||||
host: 'api.acme.hanzo.app',
|
||||
status: 'live',
|
||||
phase: 'Running',
|
||||
health: 'healthy',
|
||||
updatedAt: 200,
|
||||
})
|
||||
})
|
||||
|
||||
it('folds a site onto the same shape, host taken from the live URL', () => {
|
||||
const r = rowOfSite(site())
|
||||
expect(r).toMatchObject({ kind: 'site', slug: 'docs', host: 'docs.acme.hanzo.app', status: 'live' })
|
||||
// A site has no project scope and no operator CR — absent, not faked.
|
||||
expect(r.project).toBeUndefined()
|
||||
expect(r.phase).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves host undefined when nothing is bound, rather than inventing one', () => {
|
||||
expect(rowOfApp(app({ domains: [] })).host).toBeUndefined()
|
||||
expect(rowOfApp(app({ domains: undefined })).host).toBeUndefined()
|
||||
expect(rowOfSite(site({ liveUrl: undefined })).host).toBeUndefined()
|
||||
expect(rowOfApp(app({ domains: [] })).hosts).toEqual([])
|
||||
expect(rowOfSite(site({ liveUrl: undefined })).hosts).toEqual([])
|
||||
})
|
||||
|
||||
it('carries EVERY bound host, not just the primary', () => {
|
||||
// An app is born with its *.hanzo.app host, so a custom domain is the SECOND
|
||||
// entry — folding to the primary would hide the domain someone bound.
|
||||
const r = rowOfApp(app({ domains: ['api.acme.hanzo.app', 'api.example.com'] }))
|
||||
expect(r.host).toBe('api.acme.hanzo.app')
|
||||
expect(r.hosts).toEqual(['api.acme.hanzo.app', 'api.example.com'])
|
||||
})
|
||||
|
||||
it('drops blank domain entries from the host list', () => {
|
||||
expect(boundHosts(['', ' a.example.com ', ' '])).toEqual(['a.example.com'])
|
||||
expect(boundHosts(undefined)).toEqual([])
|
||||
})
|
||||
|
||||
it('skips blank domain entries when picking the primary host', () => {
|
||||
expect(primaryHost(['', ' ', 'real.example.com'])).toBe('real.example.com')
|
||||
expect(primaryHost([])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to the raw value when a live URL is not parseable', () => {
|
||||
expect(hostOf('docs.example.com')).toBe('docs.example.com')
|
||||
expect(hostOf('https://x.example.com/path')).toBe('x.example.com')
|
||||
})
|
||||
|
||||
it('defaults a missing status to draft and a missing timestamp to 0', () => {
|
||||
const r = rowOfApp(app({ status: undefined, updatedAt: undefined, createdAt: undefined }))
|
||||
expect(r.status).toBe('draft')
|
||||
expect(r.updatedAt).toBe(0)
|
||||
})
|
||||
|
||||
it('uses createdAt when the row was never updated', () => {
|
||||
expect(rowOfApp(app({ updatedAt: undefined, createdAt: 42 })).updatedAt).toBe(42)
|
||||
})
|
||||
})
|
||||
|
||||
describe('board', () => {
|
||||
it('orders newest first without mutating the input', () => {
|
||||
const rows = [rowOfSite(site()), rowOfApp(app())]
|
||||
const sorted = byRecency(rows)
|
||||
expect(sorted.map((r) => r.slug)).toEqual(['api', 'docs'])
|
||||
expect(rows.map((r) => r.slug)).toEqual(['docs', 'api'])
|
||||
})
|
||||
|
||||
it('counts only what the backend calls live', () => {
|
||||
const rows = [rowOfApp(app()), rowOfApp(app({ id: 'a2', slug: 'w', status: 'building' })), rowOfSite(site())]
|
||||
expect(summarize(rows)).toEqual({ total: 3, live: 2, apps: 2, sites: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('repoName', () => {
|
||||
it('takes the last segment and drops the .git suffix', () => {
|
||||
expect(repoName('https://git.hanzo.ai/hanzoai/console.git')).toBe('console')
|
||||
expect(repoName('git@github.com:hanzoai/cloud.git')).toBe('cloud')
|
||||
expect(repoName('https://git.hanzo.ai/hanzoai/console/')).toBe('console')
|
||||
})
|
||||
|
||||
it('slugifies a segment that is not already a slug', () => {
|
||||
expect(repoName('https://example.com/org/My App')).toBe('my-app')
|
||||
})
|
||||
|
||||
it('is empty for a URL with no usable segment, so the form asks', () => {
|
||||
expect(repoName('')).toBe('')
|
||||
expect(repoName('///')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseEnv', () => {
|
||||
it('splits on the FIRST = so a value may contain one', () => {
|
||||
expect(parseEnv('URL=postgres://u:p@h/db?x=1')).toEqual([
|
||||
{ key: 'URL', value: 'postgres://u:p@h/db?x=1', secret: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('skips blanks, comments, and lines with no assignment', () => {
|
||||
expect(parseEnv('\n# a comment\nPORT=8080\ngarbage\n \n')).toEqual([
|
||||
{ key: 'PORT', value: '8080', secret: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('drops a line that starts with = rather than storing an empty key', () => {
|
||||
expect(parseEnv('=novalue')).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps an empty value when the key is real', () => {
|
||||
expect(parseEnv('EMPTY=')).toEqual([{ key: 'EMPTY', value: '', secret: true }])
|
||||
})
|
||||
|
||||
it('does not unquote or expand — a credential is stored verbatim', () => {
|
||||
expect(parseEnv('TOKEN="ab$HOME"')).toEqual([{ key: 'TOKEN', value: '"ab$HOME"', secret: true }])
|
||||
})
|
||||
|
||||
// The defect this replaced: a key-NAME regex decided secrecy, so these three
|
||||
// real credential names sailed through as public while the form's help text
|
||||
// promised they were sealed.
|
||||
it.each(['STRIPE_SK', 'GH_PAT', 'DB_PASS', 'PORT', 'NODE_ENV'])('seals %s by default', (key) => {
|
||||
expect(parseEnv(`${key}=x`)[0].secret).toBe(true)
|
||||
})
|
||||
|
||||
it('opens ONLY the keys named public', () => {
|
||||
const got = parseEnv('PORT=8080\nSTRIPE_SK=sk_live_x', new Set(['PORT']))
|
||||
expect(got.map((e) => [e.key, e.secret])).toEqual([
|
||||
['PORT', false],
|
||||
['STRIPE_SK', true],
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores a public key that is not present, and never opens by prefix', () => {
|
||||
expect(parseEnv('DB_PASSWORD=x', new Set(['DB_PASS', 'NOPE']))[0].secret).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('envVars', () => {
|
||||
it('reads the public list off the form', () => {
|
||||
const form: DeployForm = { kind: 'app', name: 'a', repo: 'r', env: 'A=1\nB=2', publicKeys: ['A'] }
|
||||
expect(envVars(form).map((e) => [e.key, e.secret])).toEqual([
|
||||
['A', false],
|
||||
['B', true],
|
||||
])
|
||||
})
|
||||
|
||||
it('seals everything when the form names nothing public', () => {
|
||||
expect(envVars({ kind: 'app', name: 'a', repo: 'r', env: 'A=1' })[0].secret).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('prunePublicKeys', () => {
|
||||
it('keeps a mark whose variable is still there', () => {
|
||||
expect(prunePublicKeys('PORT=8080\nDEBUG=1', ['PORT'])).toEqual(['PORT'])
|
||||
})
|
||||
|
||||
it('drops a mark whose variable was deleted', () => {
|
||||
expect(prunePublicKeys('DEBUG=1', ['PORT'])).toEqual([])
|
||||
expect(prunePublicKeys('', ['PORT'])).toEqual([])
|
||||
})
|
||||
|
||||
// The attack this closes: mark a harmless DATABASE_URL Public, delete it, then
|
||||
// type a new one carrying a password. Without pruning the stale mark is
|
||||
// inherited and the credential ships unsealed.
|
||||
it('does not let a mark be inherited by a later variable reusing the name', () => {
|
||||
const marked = ['DATABASE_URL']
|
||||
const afterDelete = prunePublicKeys('PORT=8080', marked)
|
||||
expect(afterDelete).toEqual([])
|
||||
const retyped = 'PORT=8080\nDATABASE_URL=postgres://u:secret@h/db'
|
||||
expect(parseEnv(retyped, new Set(afterDelete)).find((e) => e.key === 'DATABASE_URL')?.secret).toBe(true)
|
||||
})
|
||||
|
||||
it('matches exactly, so a case twin never inherits the mark', () => {
|
||||
expect(prunePublicKeys('db_url=x', ['DB_URL'])).toEqual([])
|
||||
expect(parseEnv('db_url=x', new Set(['DB_URL']))[0].secret).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores a commented-out line — a mark cannot survive on a comment', () => {
|
||||
expect(prunePublicKeys('# PORT=8080', ['PORT'])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('partialNote', () => {
|
||||
it('names WHICH source is incomplete, so a reader knows which number lies', () => {
|
||||
expect(partialNote(['app'])).toMatch(/^Apps could not/)
|
||||
expect(partialNote(['site'])).toMatch(/^Sites could not/)
|
||||
expect(partialNote(['app', 'site'])).toMatch(/^Apps and sites could not/)
|
||||
})
|
||||
|
||||
it('is silent for a whole board', () => {
|
||||
expect(partialNote([])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('envError', () => {
|
||||
it('accepts the keys the platform accepts', () => {
|
||||
expect(envError('PORT=8080\n_PRIVATE=x\nA1=y')).toBeNull()
|
||||
expect(envError('')).toBeNull()
|
||||
})
|
||||
|
||||
it('names the first key the backend would reject, instead of letting it 400', () => {
|
||||
expect(envError('PORT=8080\nMY-KEY=x')).toMatch(/"MY-KEY"/)
|
||||
expect(envError('1BAD=x')).toMatch(/"1BAD"/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hostError', () => {
|
||||
it('accepts a plain hostname', () => {
|
||||
expect(hostError('app.example.com')).toBeNull()
|
||||
expect(hostError('a-b.c.example.co.uk')).toBeNull()
|
||||
})
|
||||
|
||||
it('is silent while the field is empty', () => {
|
||||
expect(hostError('')).toBeNull()
|
||||
expect(hostError(' ')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a URL, a path, and a port with a specific message', () => {
|
||||
expect(hostError('https://app.example.com')).toMatch(/https:\/\//)
|
||||
expect(hostError('app.example.com/x')).toMatch(/no path/)
|
||||
expect(hostError('app.example.com:8080')).toMatch(/no port/)
|
||||
})
|
||||
|
||||
it('rejects malformed hostnames', () => {
|
||||
expect(hostError('example')).not.toBeNull()
|
||||
expect(hostError('-bad.example.com')).not.toBeNull()
|
||||
expect(hostError('app..example.com')).not.toBeNull()
|
||||
expect(hostError('app example.com')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a hostname over the 253-character limit', () => {
|
||||
expect(hostError(`${'a'.repeat(60)}.${'b'.repeat(60)}.${'c'.repeat(60)}.${'d'.repeat(60)}.example.com`))
|
||||
.not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('form → create input', () => {
|
||||
const base: DeployForm = { kind: 'app', name: 'api', repo: 'https://git.hanzo.ai/hanzoai/api.git' }
|
||||
|
||||
it('maps a repo + host + env to the app create body, sealing env by default', () => {
|
||||
expect(toAppInput({ ...base, branch: 'main', host: 'API.Example.COM ', env: 'PORT=8080' })).toEqual({
|
||||
name: 'api',
|
||||
source: 'git',
|
||||
repo: { url: 'https://git.hanzo.ai/hanzoai/api.git', branch: 'main' },
|
||||
env: [{ key: 'PORT', value: '8080', secret: true }],
|
||||
domains: ['api.example.com'],
|
||||
})
|
||||
})
|
||||
|
||||
it('carries the per-variable public choice into the create body', () => {
|
||||
const out = toAppInput({ ...base, env: 'PORT=8080\nSTRIPE_SK=sk_live', publicKeys: ['PORT'] })
|
||||
expect(out.env).toEqual([
|
||||
{ key: 'PORT', value: '8080', secret: false },
|
||||
{ key: 'STRIPE_SK', value: 'sk_live', secret: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('omits branch, env, and domains rather than sending empty ones', () => {
|
||||
expect(toAppInput(base)).toEqual({
|
||||
name: 'api',
|
||||
source: 'git',
|
||||
repo: { url: 'https://git.hanzo.ai/hanzoai/api.git' },
|
||||
})
|
||||
expect(toAppInput({ ...base, branch: ' ', host: ' ', env: '\n#c\n' })).toEqual({
|
||||
name: 'api',
|
||||
source: 'git',
|
||||
repo: { url: 'https://git.hanzo.ai/hanzoai/api.git' },
|
||||
})
|
||||
})
|
||||
|
||||
it('builds a site body with its framework, and omits an absent repo', () => {
|
||||
expect(toSiteInput({ kind: 'site', name: 'docs', repo: '', framework: 'next' })).toEqual({
|
||||
name: 'docs',
|
||||
framework: 'next',
|
||||
})
|
||||
expect(toSiteInput({ kind: 'site', name: 'docs', repo: 'https://x/y.git', branch: 'main' })).toEqual({
|
||||
name: 'docs',
|
||||
framework: 'static',
|
||||
repo: { url: 'https://x/y.git', branch: 'main' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('formError', () => {
|
||||
const app_: DeployForm = { kind: 'app', name: 'api', repo: 'https://x/y.git' }
|
||||
|
||||
it('passes a complete app form', () => {
|
||||
expect(formError(app_, 'web')).toBeNull()
|
||||
})
|
||||
|
||||
it('demands a name, a project, and a repo for an app', () => {
|
||||
expect(formError({ ...app_, name: ' ' }, 'web')).toMatch(/Name/)
|
||||
expect(formError(app_, null)).toMatch(/project/)
|
||||
expect(formError({ ...app_, repo: '' }, 'web')).toMatch(/Repository/)
|
||||
})
|
||||
|
||||
it('surfaces a bad host', () => {
|
||||
expect(formError({ ...app_, host: 'https://x.com' }, 'web')).toMatch(/https:\/\//)
|
||||
})
|
||||
|
||||
it('surfaces a bad env key before the request is made', () => {
|
||||
expect(formError({ ...app_, env: 'MY-KEY=x' }, 'web')).toMatch(/"MY-KEY"/)
|
||||
})
|
||||
|
||||
it('needs neither project nor repo for a site', () => {
|
||||
expect(formError({ kind: 'site', name: 'docs', repo: '' }, null)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('hostRows', () => {
|
||||
it('expands a row into ONE row per bound host, so a custom domain shows', () => {
|
||||
const rows = [rowOfApp(app({ domains: ['api.acme.hanzo.app', 'api.example.com'] })), rowOfSite(site())]
|
||||
expect(hostRows(rows).map((h) => h.host)).toEqual([
|
||||
'api.acme.hanzo.app',
|
||||
'api.example.com',
|
||||
'docs.acme.hanzo.app',
|
||||
])
|
||||
})
|
||||
|
||||
it('attributes every host to its owner and kind', () => {
|
||||
expect(hostRows([rowOfSite(site())])).toEqual([
|
||||
{ host: 'docs.acme.hanzo.app', owner: 'Docs', kind: 'site', status: 'live' },
|
||||
])
|
||||
})
|
||||
|
||||
it('omits a row with nothing bound', () => {
|
||||
expect(hostRows([rowOfApp(app({ domains: [] }))])).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,292 +0,0 @@
|
||||
/**
|
||||
* The deploy board — one row shape over the two things an org deploys.
|
||||
*
|
||||
* An APP is a container workload the operator reconciles (`/v1/platform/projects/
|
||||
* :project/apps`, `PaasApi`); a SITE is a static build served from object storage
|
||||
* (`/v1/platform/sites`, `PlatformSitesApi`). They are separate backends on
|
||||
* purpose, but a person reading "what have I shipped" wants ONE list, so this
|
||||
* module folds both into `DeployRow` and nothing downstream branches on which
|
||||
* store a row came from.
|
||||
*
|
||||
* Pure by construction: no React, no fetch, no clock. The panels do the I/O with
|
||||
* the existing typed clients and hand the results here, which is what makes the
|
||||
* mapping (form → create input, env text → env vars, app → row) testable without
|
||||
* a browser or a server.
|
||||
*
|
||||
* ORG SCOPING IS NOT DONE HERE, and that is deliberate. Every read/write goes
|
||||
* through the same-origin `/v1` bearer proxy, which resolves the org from the
|
||||
* token owner server-side. This module never sees, sends, or filters by an org —
|
||||
* a client-side filter would read like a boundary while being none.
|
||||
*/
|
||||
import { slugify } from '~/lib/framework/fields'
|
||||
import type { PaasAppWithProject, PaasEnvVar, CreateAppInput } from '~/lib/api/paas'
|
||||
import type { Site, CreateSiteInput } from '~/lib/api/platform-sites'
|
||||
|
||||
/** What a row deploys: a reconciled container workload, or a static build. */
|
||||
export type DeployKind = 'app' | 'site'
|
||||
|
||||
/** One shipped thing, whichever store holds it. */
|
||||
export type DeployRow = {
|
||||
kind: DeployKind
|
||||
id: string
|
||||
/** Backend key: an app's slug (unique within its project) or a site's slug. */
|
||||
slug: string
|
||||
name: string
|
||||
/** Owning project — apps only; a site is scoped to the org directly. */
|
||||
project?: string
|
||||
/** Primary public host, absent until one is bound — the board's Host column. */
|
||||
host?: string
|
||||
/**
|
||||
* EVERY host bound to this row, primary first. Kept alongside `host` because a
|
||||
* custom domain is usually the second entry (an app is born with its
|
||||
* `*.hanzo.app` host), so a Domains view built from `host` alone would hide
|
||||
* exactly the domain someone went to the trouble of binding.
|
||||
*/
|
||||
hosts: string[]
|
||||
/** Backend lifecycle: draft | building | deploying | live | stopped | error. */
|
||||
status: string
|
||||
/** Operator reconciliation, read straight off the App CR. Apps only. */
|
||||
phase?: string
|
||||
health?: string
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/** Every non-blank bound host, order preserved. */
|
||||
export const boundHosts = (domains?: string[]): string[] =>
|
||||
(domains ?? []).map((d) => d.trim()).filter((d) => d.length > 0)
|
||||
|
||||
/** The first bound host, or undefined — never a fabricated default. */
|
||||
export const primaryHost = (domains?: string[]): string | undefined => boundHosts(domains)[0]
|
||||
|
||||
export function rowOfApp(app: PaasAppWithProject): DeployRow {
|
||||
const hosts = boundHosts(app.domains)
|
||||
return {
|
||||
kind: 'app',
|
||||
id: app.id,
|
||||
slug: app.slug,
|
||||
name: app.name || app.slug,
|
||||
project: app.project?.slug,
|
||||
host: hosts[0],
|
||||
hosts,
|
||||
status: app.status || 'draft',
|
||||
phase: app.phase,
|
||||
health: app.health,
|
||||
updatedAt: app.updatedAt ?? app.createdAt ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function rowOfSite(site: Site): DeployRow {
|
||||
// A site reports ONE public host (its live URL); custom hosts are bound through
|
||||
// the site's own domains endpoint, which this board does not read per row.
|
||||
const host = site.liveUrl ? hostOf(site.liveUrl) : undefined
|
||||
return {
|
||||
kind: 'site',
|
||||
id: site.id,
|
||||
slug: site.slug,
|
||||
name: site.name || site.slug,
|
||||
host,
|
||||
hosts: host ? [host] : [],
|
||||
status: site.status || 'draft',
|
||||
updatedAt: site.updatedAt ?? site.createdAt ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** Hostname out of a URL; the input unchanged when it isn't one. */
|
||||
export function hostOf(url: string): string {
|
||||
try {
|
||||
return new URL(url).host
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/** Newest first — the order a deploy board is read in. */
|
||||
export const byRecency = (rows: DeployRow[]): DeployRow[] =>
|
||||
[...rows].sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
|
||||
/**
|
||||
* The sentence for a board that loaded only part of itself, or null when it is
|
||||
* whole. Named per source, because "some data is missing" leaves a reader unable
|
||||
* to tell which number on the screen is now a lie.
|
||||
*/
|
||||
export function partialNote(incomplete: readonly DeployKind[]): string | null {
|
||||
const apps = incomplete.includes('app')
|
||||
const sites = incomplete.includes('site')
|
||||
if (apps && sites) return 'Apps and sites could not be fully loaded — this list is incomplete.'
|
||||
if (apps) return 'Apps could not be fully loaded — this list is incomplete.'
|
||||
if (sites) return 'Sites could not be fully loaded — this list is incomplete.'
|
||||
return null
|
||||
}
|
||||
|
||||
/** Board headline counts. `live` is the backend's own word, never inferred. */
|
||||
export function summarize(rows: DeployRow[]): { total: number; live: number; apps: number; sites: number } {
|
||||
return {
|
||||
total: rows.length,
|
||||
live: rows.filter((r) => r.status === 'live').length,
|
||||
apps: rows.filter((r) => r.kind === 'app').length,
|
||||
sites: rows.filter((r) => r.kind === 'site').length,
|
||||
}
|
||||
}
|
||||
|
||||
/** One published host, and the app or site it belongs to. */
|
||||
export type HostRow = { host: string; owner: string; kind: DeployKind; status: string }
|
||||
|
||||
/**
|
||||
* Every host on the board, one row each — EXPANDED over `hosts`, not folded to
|
||||
* the primary. An app is born with its `*.hanzo.app` host, so a custom domain is
|
||||
* the second entry; listing only the primary would hide exactly the domain
|
||||
* someone bound on purpose.
|
||||
*/
|
||||
export function hostRows(rows: DeployRow[]): HostRow[] {
|
||||
return rows
|
||||
.flatMap((r) => r.hosts.map((host) => ({ host, owner: r.name, kind: r.kind, status: r.status })))
|
||||
.sort((a, b) => a.host.localeCompare(b.host))
|
||||
}
|
||||
|
||||
// ── Deploy form → backend create input ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Default app/site name from a repo URL: the last path segment, `.git` dropped.
|
||||
* `https://git.hanzo.ai/hanzoai/console.git` → `console`. Empty when the URL has
|
||||
* no usable segment, so the form asks rather than inventing a name.
|
||||
*/
|
||||
export function repoName(url: string): string {
|
||||
const last = url.trim().replace(/\/+$/, '').split('/').pop() ?? ''
|
||||
return slugify(last.replace(/\.git$/i, ''))
|
||||
}
|
||||
|
||||
/**
|
||||
* `KEY=VALUE` lines → env vars. Blank lines and `#` comments are skipped, the
|
||||
* first `=` splits (so a value may contain `=`), and a line without one is
|
||||
* dropped rather than stored as a key with an empty value.
|
||||
*
|
||||
* EVERY variable is secret unless its key appears in `publicKeys` — an explicit
|
||||
* choice the person deploying makes per variable. This replaced a key-NAME regex
|
||||
* (`/SECRET|TOKEN|KEY|…/`) that guessed, and guessed wrong in both directions:
|
||||
* `STRIPE_SK`, `GH_PAT` and `DB_PASS` all name credentials and all escaped it, while
|
||||
* the form's own help text promised they would be sealed. A default that fails
|
||||
* open under a promise of safety is worse than no default, so the default is now
|
||||
* sealed and the exceptions are named.
|
||||
*
|
||||
* Values are passed through verbatim — no trimming beyond surrounding
|
||||
* whitespace, no unquoting, no expansion. Interpreting `$VAR` or stripping
|
||||
* quotes here would silently change a credential.
|
||||
*/
|
||||
export function parseEnv(text: string, publicKeys: ReadonlySet<string> = new Set()): PaasEnvVar[] {
|
||||
const out: PaasEnvVar[] = []
|
||||
for (const raw of text.split('\n')) {
|
||||
const line = raw.trim()
|
||||
if (!line || line.startsWith('#')) continue
|
||||
const eq = line.indexOf('=')
|
||||
if (eq <= 0) continue
|
||||
const key = line.slice(0, eq).trim()
|
||||
if (!key) continue
|
||||
out.push({ key, value: line.slice(eq + 1).trim(), secret: !publicKeys.has(key) })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The env-key rule the platform enforces (`^[A-Za-z_][A-Za-z0-9_]*$`). Checked here
|
||||
* so a stray `MY-KEY` is named in the form instead of coming back as a bare 400.
|
||||
*/
|
||||
const ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/
|
||||
|
||||
/** Message naming the first key the backend would reject, else null. */
|
||||
export function envError(text: string): string | null {
|
||||
const bad = parseEnv(text).find((e) => !ENV_KEY.test(e.key))
|
||||
return bad ? `"${bad.key}" is not a valid env name — letters, digits, and _ only, not starting with a digit.` : null
|
||||
}
|
||||
|
||||
/** A hostname is 1–253 chars of dot-separated LDH labels; no scheme, port, or path. */
|
||||
const HOST = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/
|
||||
|
||||
/**
|
||||
* Message when `host` is not a bindable hostname, else null.
|
||||
*
|
||||
* UX validation only. The server is the authority on whether this org may bind
|
||||
* this host (it demands ownership verification and 409s a host another org
|
||||
* holds); this only stops an obviously malformed value from becoming a request.
|
||||
*/
|
||||
export function hostError(host: string): string | null {
|
||||
const v = host.trim()
|
||||
if (!v) return null
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(v)) return 'Hostname only — drop the https:// prefix.'
|
||||
if (v.includes('/')) return 'Hostname only — no path.'
|
||||
if (v.includes(':')) return 'Hostname only — no port.'
|
||||
if (!HOST.test(v.toLowerCase())) return 'Not a valid hostname (e.g. app.example.com).'
|
||||
return null
|
||||
}
|
||||
|
||||
/** What the deploy form collects, before it is shaped for either backend. */
|
||||
export type DeployForm = {
|
||||
kind: DeployKind
|
||||
name: string
|
||||
/** Git URL to build from; a site always builds from git. */
|
||||
repo: string
|
||||
branch?: string
|
||||
/** Custom host to bind. Optional — every app is born with a default host. */
|
||||
host?: string
|
||||
/** Raw `KEY=VALUE` lines from the textarea. Apps only. */
|
||||
env?: string
|
||||
/**
|
||||
* Keys the person explicitly marked PUBLIC. Everything else is sealed — the
|
||||
* list names the exceptions, so forgetting to touch a variable leaves it safe.
|
||||
*/
|
||||
publicKeys?: string[]
|
||||
framework?: string
|
||||
}
|
||||
|
||||
/** The variables a form will send, each carrying its sealed/public choice. */
|
||||
export const envVars = (form: DeployForm): PaasEnvVar[] =>
|
||||
parseEnv(form.env ?? '', new Set(form.publicKeys ?? []))
|
||||
|
||||
/**
|
||||
* The public marks that still have a variable, given the current env text.
|
||||
*
|
||||
* A mark must not outlive the line it was made on. Without this, deleting
|
||||
* `DATABASE_URL=postgres://safe` (marked Public) and later typing a new
|
||||
* `DATABASE_URL=` carrying a password would silently inherit the old mark and
|
||||
* ship the credential unsealed — the mark would be a property of a NAME rather
|
||||
* than of the variable someone actually looked at.
|
||||
*
|
||||
* Matching is exact, so `db_url` never inherits `DB_URL`'s mark: a case twin is a
|
||||
* different key to the backend, and failing closed on the ambiguity is correct.
|
||||
*/
|
||||
export function prunePublicKeys(env: string, publicKeys: readonly string[]): string[] {
|
||||
const present = new Set(parseEnv(env).map((e) => e.key))
|
||||
return publicKeys.filter((k) => present.has(k))
|
||||
}
|
||||
|
||||
/** The app create body (`POST /v1/platform/projects/:project/apps`). */
|
||||
export function toAppInput(form: DeployForm): CreateAppInput {
|
||||
const host = form.host?.trim().toLowerCase()
|
||||
const env = envVars(form)
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
source: 'git',
|
||||
repo: { url: form.repo.trim(), ...(form.branch?.trim() ? { branch: form.branch.trim() } : {}) },
|
||||
...(env.length ? { env } : {}),
|
||||
...(host ? { domains: [host] } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** The site create body (`POST /v1/platform/sites`). */
|
||||
export function toSiteInput(form: DeployForm): CreateSiteInput {
|
||||
const repo = form.repo.trim()
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
framework: form.framework || 'static',
|
||||
...(repo ? { repo: { url: repo, ...(form.branch?.trim() ? { branch: form.branch.trim() } : {}) } } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Message when the form cannot be submitted yet, else null. */
|
||||
export function formError(form: DeployForm, project: string | null): string | null {
|
||||
if (!form.name.trim()) return 'Name is required.'
|
||||
if (form.kind === 'app' && !project) return 'Pick a project.'
|
||||
if (form.kind === 'app' && !form.repo.trim()) return 'Repository URL is required.'
|
||||
const env = form.env ? envError(form.env) : null
|
||||
if (env) return env
|
||||
return form.host ? hostError(form.host) : null
|
||||
}
|
||||
@@ -42,12 +42,6 @@ export const ALWAYS_ON_PRODUCTS: readonly string[] = [
|
||||
'profile', // the signed-in user's own profile
|
||||
'api-keys', // credentials to call the API
|
||||
'platform', // the project HUB — create/deploy/ship a project (first-class, every org)
|
||||
// The deploy FRONT DOOR (apps, sites, domains, and the CD/CI/storage readings).
|
||||
// Always-on for the same reason 'platform' is: shipping your own code is not a
|
||||
// separate SKU, and it grants no backend reach — every head it reads was already
|
||||
// reachable, and cloud still enforces authz and the spend gate server-side. Gating
|
||||
// it would hide the primary section of platform.<brand> behind "Add product".
|
||||
'deploy',
|
||||
'tracker', // native @hanzo/gui issue tracker — first-class work surface, every org (peer of the HUB; replaces the retired Huly/hanzo.team)
|
||||
'guide', // the Business AI launch checklist — foundational onboarding, every org (like 'platform')
|
||||
'company', // self-service incorporation — the formation wizard, foundational for every org (peer of 'platform')
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ function consented(): boolean {
|
||||
|
||||
// ── No publishable ingest key is passed, and that is DELIBERATE ──────────────
|
||||
//
|
||||
// Do not "fix" this by adding a build arg or by reading NEXT_PUBLIC_PUBLISHABLE_KEY
|
||||
// Do not "fix" this by adding a build arg or by reading NEXT_PUBLIC_EVENT_INGEST_KEY
|
||||
// here.
|
||||
//
|
||||
// A `pk-` resolves to exactly ONE org (cloud stamps the tenant from the key), and
|
||||
|
||||
@@ -322,6 +322,7 @@ describe('canonicalSlug — conventional URLs map to the canonical entry id', ()
|
||||
it('maps the human product slugs the console/e2e/bookmarks use to the canonical id', () => {
|
||||
// These six were the biggest "blank" source: a human slug ≠ registry id → 404.
|
||||
expect(canonicalSlug(['traces'])).toEqual(['o11y'])
|
||||
expect(canonicalSlug(['deploy'])).toEqual(['app-platform'])
|
||||
expect(canonicalSlug(['plans-pricing'])).toEqual(['plans'])
|
||||
expect(canonicalSlug(['wallets'])).toEqual(['wallet'])
|
||||
expect(canonicalSlug(['model-catalog'])).toEqual(['models'])
|
||||
@@ -360,6 +361,7 @@ describe('resolveProductView — aliases + external resolve (never a 404 nav ite
|
||||
it('every human product slug resolves to its real module (never a 404 blank)', () => {
|
||||
const cases: [string, string][] = [
|
||||
['traces', 'o11y'],
|
||||
['deploy', 'app-platform'],
|
||||
['plans-pricing', 'plans'],
|
||||
['wallets', 'wallet'],
|
||||
['model-catalog', 'models'],
|
||||
|
||||
@@ -82,11 +82,7 @@ export const SLUG_ALIASES: Record<string, string> = {
|
||||
auto: 'automations',
|
||||
automation: 'automations',
|
||||
traces: 'o11y',
|
||||
// `deploy` used to alias App Platform, back when the PaaS canvas was the only
|
||||
// place a deploy happened. It is a real product now — the front door over apps,
|
||||
// sites, domains, CD, CI, and storage — so the slug resolves to itself and App
|
||||
// Platform keeps its own id. An alias to a sibling would make the front door
|
||||
// unreachable: `canonicalSlug` rewrites the head before any lookup.
|
||||
deploy: 'app-platform',
|
||||
'plans-pricing': 'plans',
|
||||
wallets: 'wallet',
|
||||
'model-catalog': 'models',
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
categoryIsOpen,
|
||||
toggleCategory,
|
||||
productIsOpen,
|
||||
toggleProduct,
|
||||
NAV_OPEN_PREF,
|
||||
NAV_PRODUCT_OPEN_PREF,
|
||||
type CategoryOpen,
|
||||
} from './nav-accordion'
|
||||
import { categoryIsOpen, toggleCategory, type CategoryOpen } from './nav-accordion'
|
||||
|
||||
const ctx = (filtering = false) => ({ filtering })
|
||||
|
||||
@@ -76,41 +68,3 @@ describe('toggleCategory (independent per-section)', () => {
|
||||
expect(next).not.toBe(stored)
|
||||
})
|
||||
})
|
||||
|
||||
describe('productIsOpen / toggleProduct', () => {
|
||||
// Where you are is the one product whose options you are certain to want.
|
||||
it('opens the ACTIVE product and leaves the others closed', () => {
|
||||
expect(productIsOpen({}, 'agents', { filtering: false, active: true })).toBe(true)
|
||||
expect(productIsOpen({}, 'models', { filtering: false, active: false })).toBe(false)
|
||||
})
|
||||
|
||||
it('respects an explicit choice over the active default, in both directions', () => {
|
||||
expect(productIsOpen({ agents: false }, 'agents', { filtering: false, active: true })).toBe(false)
|
||||
expect(productIsOpen({ models: true }, 'models', { filtering: false, active: false })).toBe(true)
|
||||
})
|
||||
|
||||
// The filter narrows PRODUCTS; a matched product's sub-pages are not themselves
|
||||
// matches, so expanding them would push the other hits off screen.
|
||||
it('closes everything while filtering, active or not', () => {
|
||||
expect(productIsOpen({}, 'agents', { filtering: true, active: true })).toBe(false)
|
||||
expect(productIsOpen({ agents: true }, 'agents', { filtering: true, active: true })).toBe(false)
|
||||
})
|
||||
|
||||
// Whichever way the chevron points, the click does that.
|
||||
it('first click on the active one collapses it; on any other one expands it', () => {
|
||||
expect(toggleProduct({}, 'agents', { active: true })).toEqual({ agents: false })
|
||||
expect(toggleProduct({}, 'models', { active: false })).toEqual({ models: true })
|
||||
})
|
||||
|
||||
it('leaves every other product untouched and never mutates the input', () => {
|
||||
const stored = { models: true }
|
||||
expect(toggleProduct(stored, 'agents', { active: false })).toEqual({ models: true, agents: true })
|
||||
expect(stored).toEqual({ models: true })
|
||||
})
|
||||
|
||||
// Products and categories share a preference SHAPE but not a default, and they are
|
||||
// stored under different keys — a product must never inherit a category's open-by-default.
|
||||
it('is keyed apart from the category accordion', () => {
|
||||
expect(NAV_PRODUCT_OPEN_PREF).not.toBe(NAV_OPEN_PREF)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -57,49 +57,3 @@ export function toggleCategory(stored: CategoryOpen, category: string): Category
|
||||
const current = stored[category] === undefined ? true : stored[category]
|
||||
return { ...stored, [category]: !current }
|
||||
}
|
||||
|
||||
// ── Products, which expand IN PLACE ─────────────────────────────────────────
|
||||
//
|
||||
// A product with sub-pages expands beneath its own row, so its options appear
|
||||
// without the rest of the catalog disappearing. This replaced a DRILL — clicking a
|
||||
// product used to swap the entire rail for that product's sub-nav, behind a "Back to
|
||||
// all products" button. The options were the same either way; what the drill took
|
||||
// away was every other product, which is exactly what a person needs to see when the
|
||||
// reason they clicked was to compare or to move on somewhere else.
|
||||
//
|
||||
// The default here is the OPPOSITE of a category's, and deliberately: categories are
|
||||
// few and describe the whole catalog, so they open; products are many and each brings
|
||||
// four to eight rows, so opening them all would bury the catalog under its own detail.
|
||||
|
||||
/** Preference key for which products are expanded in the rail. */
|
||||
export const NAV_PRODUCT_OPEN_PREF = 'navProductsOpen'
|
||||
|
||||
/**
|
||||
* Whether a product's sub-pages render EXPANDED:
|
||||
* - while FILTERING: closed. The filter narrows PRODUCTS, and a matched product's
|
||||
* sub-pages are not themselves matches — expanding them would push the other hits
|
||||
* off screen;
|
||||
* - the ACTIVE product: open, unless the user explicitly collapsed it. Where you are
|
||||
* is the one place whose options you are certain to want;
|
||||
* - otherwise: the user's explicit choice, else CLOSED.
|
||||
*/
|
||||
export function productIsOpen(
|
||||
stored: CategoryOpen,
|
||||
id: string,
|
||||
ctx: { filtering: boolean; active: boolean },
|
||||
): boolean {
|
||||
if (ctx.filtering) return false
|
||||
const v = stored[id]
|
||||
return v === undefined ? ctx.active : v
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle one product's expansion (pure + immutable). The stored value is what the
|
||||
* product is being toggled AWAY from, so the first click on the active product
|
||||
* collapses it and the first click on any other one expands it — in both cases the
|
||||
* click does the thing the chevron was pointing at.
|
||||
*/
|
||||
export function toggleProduct(stored: CategoryOpen, id: string, ctx: { active: boolean }): CategoryOpen {
|
||||
const current = stored[id] === undefined ? ctx.active : stored[id]
|
||||
return { ...stored, [id]: !current }
|
||||
}
|
||||
|
||||
@@ -305,7 +305,6 @@ import { ProjectsModule as FleetProjectsModule } from '~/components/products/adm
|
||||
import { BetaFeaturesModule } from '~/components/products/BetaFeaturesModule'
|
||||
// GitOps — the native ArgoCD replacement (SuperAdmin operator surface).
|
||||
import { GitOpsModule } from '~/components/products/gitops/GitOpsModule'
|
||||
import { DeployModule } from '~/components/products/deploy/DeployModule'
|
||||
import { ContactModule } from '~/components/products/ContactModule'
|
||||
|
||||
/** A Hanzo GUI icon component (e.g. `Server` from `@hanzogui/lucide-icons-2`). */
|
||||
@@ -1237,10 +1236,6 @@ export const catalog: CatalogEntry[] = [
|
||||
{ path: ':tab', component: AgentsModule },
|
||||
],
|
||||
subpages: [
|
||||
// The guided way in: describe an agent or start from a template, configure it in
|
||||
// the ONE builder, run it, and take the call away. Leads the sub-nav because it
|
||||
// is where someone with no agents yet should land.
|
||||
{ slug: 'quickstart', label: 'Quickstart' },
|
||||
{ slug: 'status', label: 'Status' },
|
||||
{ slug: 'logs', label: 'Logs' },
|
||||
{ slug: 'metrics', label: 'Metrics' },
|
||||
@@ -2112,44 +2107,7 @@ export const catalog: CatalogEntry[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
// Deploy — the front door for shipping. ONE section over the two things an org
|
||||
// deploys (container apps via `/v1/platform/projects/:p/apps`, static sites via
|
||||
// `/v1/platform/sites`) plus readings of the three planes a deploy touches:
|
||||
// CD (`/v1/deploy/applications` — reconciliation of the caller's own App CRs),
|
||||
// CI (`/v1/builds`), and Storage (`/v1/s3/buckets`). Each is the ONE canonical
|
||||
// head for its subject; there is deliberately no `/v1/platform/cd|ci|s3` alias,
|
||||
// which would give the estate two paths to the same data.
|
||||
//
|
||||
// It COMPOSES the existing typed clients rather than re-implementing them, and
|
||||
// deep-links to the product that owns each subject for anything deeper — so App
|
||||
// Platform stays the place to operate one app, and S3 to browse objects.
|
||||
// NOT admin-gated: shipping your own code is the customer's own business, and
|
||||
// every read is org-scoped server-side from the bearer proxy's token owner.
|
||||
id: 'deploy',
|
||||
label: 'Deploy',
|
||||
icon: Rocket,
|
||||
description:
|
||||
'Ship an app or a static site — pick a repo, bind a host, set env, deploy. Then watch CD reconcile it, CI build it, and storage serve it.',
|
||||
gcp: 'Cloud Deploy',
|
||||
category: 'Platform',
|
||||
status: 'enabled',
|
||||
repo: 'hanzoai/cloud',
|
||||
kind: 'module',
|
||||
routes: [
|
||||
{ path: '', component: DeployModule },
|
||||
{ path: ':tab', component: DeployModule },
|
||||
],
|
||||
subpages: [
|
||||
{ slug: 'apps', label: 'Apps', icon: AppWindow },
|
||||
{ slug: 'sites', label: 'Sites', icon: Globe },
|
||||
{ slug: 'domains', label: 'Domains', icon: Cable },
|
||||
{ slug: 'cd', label: 'CD', icon: GitBranch },
|
||||
{ slug: 'ci', label: 'CI', icon: Hammer },
|
||||
{ slug: 'storage', label: 'Storage', icon: HardDrive },
|
||||
],
|
||||
},
|
||||
{
|
||||
// The native ArgoCD replacement, rendered as a Railway-grade fleet MAP
|
||||
// Deploy — the native ArgoCD replacement, rendered as a Railway-grade fleet MAP
|
||||
// (the surface cd.hanzo.ai serves). A PLATFORM surface (admin: true → hidden from
|
||||
// every customer's nav/palette today; the org-scoped projection opens it
|
||||
// per-org) that reads the live operator App CRs through cloud's /v1/deploy/* — the
|
||||
@@ -2158,12 +2116,8 @@ export const catalog: CatalogEntry[] = [
|
||||
// sync, owned-resource topology, CI builds, logs, and confirm-gated Sync/Rollback
|
||||
// (rollback pins the CR image tag to a prior clean-semver release → the operator
|
||||
// reconciles). The map/drawer are the shared @hanzo/canvas primitive.
|
||||
// Labelled `Fleet`, not `Deploy`: this is the ESTATE map (every org's App CRs,
|
||||
// admin-only), while `deploy` above is the customer's own front door. Two
|
||||
// entries labelled "Deploy" would have sat side by side in a SuperAdmin's
|
||||
// Platform section, one of them showing somebody else's workloads.
|
||||
id: 'gitops',
|
||||
label: 'Fleet',
|
||||
label: 'Deploy',
|
||||
icon: GitBranch,
|
||||
description: 'The fleet deploy map — every operator App CR as a live node with reconciled health, sync, resource topology, CI builds, logs, and one-click rollback. The Hanzo operator reconciles.',
|
||||
gcp: 'Cloud Deploy',
|
||||
|
||||
@@ -80,19 +80,6 @@ describe('allowCloudSurface', () => {
|
||||
expect(allowCloudSurface('v1/agents/agent-1/runs')).toBe(true)
|
||||
})
|
||||
|
||||
// The tool plane is admitted so the agent builder can offer an org's REAL tool
|
||||
// names. Discovery only: a head admits every sub-path, and `POST /v1/tools/call`
|
||||
// RUNS a tool — that belongs to whatever runs an agent, never to a browser tab.
|
||||
it('admits tool discovery but refuses the dispatch door', () => {
|
||||
expect(CLOUD_HEADS).toContain('tools')
|
||||
expect(allowCloudSurface('v1/tools')).toBe(true)
|
||||
expect(allowCloudSurface('v1/tools?activated=true')).toBe(true)
|
||||
expect(allowCloudSurface('v1/tools/catalog')).toBe(true)
|
||||
expect(allowCloudSurface('v1/tools/call')).toBe(false)
|
||||
expect(allowCloudSurface('/v1/tools/call')).toBe(false)
|
||||
expect(allowCloudSurface('v1/tools/call?x=1')).toBe(false)
|
||||
})
|
||||
|
||||
it('admits the evals facade (scores/datasets/rubrics/evaluators/runs)', () => {
|
||||
expect(CLOUD_HEADS).toContain('evals')
|
||||
for (const sub of ['scores', 'datasets', 'rubrics', 'evaluators', 'runs']) {
|
||||
|
||||
@@ -33,13 +33,6 @@ export const CLOUD_HEADS: readonly string[] = [
|
||||
'functions',
|
||||
'prompts',
|
||||
'agents',
|
||||
// The unified tool plane (cloud apps/tools): /v1/tools — discovery across every
|
||||
// source (connector actions, functions, zap-service routes, agents, skills, the
|
||||
// org's own MCP servers), deduplicated by name. `scopeOf` derives the org+project
|
||||
// from the Bearer owner and 403s a cookie-only call, so it routes through /v1
|
||||
// exactly like agents/prompts. Discovery only — `/v1/tools/call` is refused below,
|
||||
// because running a tool belongs to whatever runs an agent, not to a browser tab.
|
||||
'tools',
|
||||
// Login manager (cloud clients/link): /v1/links[/…] — the org+user-scoped registry
|
||||
// of which AI provider accounts are signed in on which machines + their usage. The
|
||||
// handler resolves org from the Bearer owner + the user from the validated subject
|
||||
@@ -402,15 +395,7 @@ export function v1Head(path: string): string | null {
|
||||
* rule would break a live surface while claiming to preserve a property that never
|
||||
* covered it. Defense in depth — the backend gates cross-tenant reads on its own.
|
||||
*/
|
||||
const REFUSED_SUBPATHS: readonly RegExp[] = [
|
||||
/^v1\/ai\/stores\/global(?:$|[/?#])/,
|
||||
// The tool plane's DISPATCH door. `tools` is allow-listed for discovery — the agent
|
||||
// builder needs to offer the org's real tool names — but a head admits every
|
||||
// sub-path, and `POST /v1/tools/call` RUNS a tool. Executing one belongs to whatever
|
||||
// runs an agent, never to a form in a browser tab, so the console's proxy is a
|
||||
// read-only window onto the plane.
|
||||
/^v1\/tools\/call(?:$|[/?#])/,
|
||||
]
|
||||
const REFUSED_SUBPATHS: readonly RegExp[] = [/^v1\/ai\/stores\/global(?:$|[/?#])/]
|
||||
|
||||
export function allowCloudSurface(path: string): boolean {
|
||||
const rel = path.replace(/^\/+/, '')
|
||||
|
||||
Reference in New Issue
Block a user