Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e2718f98d | ||
|
|
482251e389 | ||
|
|
79d495efe8 | ||
|
|
3595ad2644 | ||
|
|
e2baba7c8e | ||
|
|
af7ae5a085 | ||
|
|
6dfa9c0598 | ||
|
|
2d7f5b1512 | ||
|
|
bb88a5bdbc | ||
|
|
d00eef1853 | ||
|
|
90c7501c8e | ||
|
|
38a588f905 | ||
|
|
98fcb41e13 | ||
|
|
49eff594c1 | ||
|
|
eca3c36e9f | ||
|
|
098a236645 | ||
|
|
8c95f1ba33 | ||
|
|
14401da5ca | ||
|
|
edd24e3f31 | ||
|
|
0b86e0dd67 | ||
|
|
e4b4ce0605 | ||
|
|
3fe306224b | ||
|
|
178e447f04 | ||
|
|
d1ae703521 | ||
|
|
87341083d9 | ||
|
|
5b05304535 | ||
|
|
249edbf5e0 | ||
|
|
9b1d45ca7c | ||
|
|
47310fd53a | ||
|
|
4c722b11bc | ||
|
|
ef8f531319 | ||
|
|
7962ca05e6 | ||
|
|
ea47eb0246 | ||
|
|
fc96e45617 | ||
|
|
88d729ed01 | ||
|
|
a2551bf302 | ||
|
|
ccbf455f53 | ||
|
|
e3b174dac8 | ||
|
|
32146f8cea | ||
|
|
6b838994e6 | ||
|
|
f7d8d118c0 | ||
|
|
c8a4e2db16 | ||
|
|
9f54156829 | ||
|
|
3d93e81f73 | ||
|
|
e970fa4828 | ||
|
|
c5ea2daa53 | ||
|
|
b39a3ae95c | ||
|
|
d94e62e53d | ||
|
|
21a72fdd0f | ||
|
|
bf8bd89f1e | ||
|
|
11b622d454 | ||
|
|
87c3df0de3 | ||
|
|
d7d142fc04 | ||
|
|
b7ecab0c2f | ||
|
|
daa24fa7a7 | ||
|
|
406aaa54bc | ||
|
|
35e9d66247 | ||
|
|
b4e3996a8c | ||
|
|
872d5179df | ||
|
|
77ad2ec7af | ||
|
|
2fdd72162c | ||
|
|
9daefa19bf | ||
|
|
acdecfbef2 | ||
|
|
0825f9b328 | ||
|
|
9e7600529e | ||
|
|
dbd42ef40b | ||
|
|
f5a82a152d | ||
|
|
d30108ec04 | ||
|
|
cb6d3418ae | ||
|
|
cb4be5be13 | ||
|
|
773d1a6522 | ||
|
|
6307b49d3d | ||
|
|
d47424a5a7 | ||
|
|
7821fda0f1 | ||
|
|
84bebb264e | ||
|
|
4ad6691a4b | ||
|
|
c5c72cd8e9 | ||
|
|
b647d9832d | ||
|
|
9cff39e59f | ||
|
|
0e20f0322e | ||
|
|
e19aea466d | ||
|
|
897a0986c7 | ||
|
|
095c2250a6 | ||
|
|
99e600d5fd | ||
|
|
592bf16a03 | ||
|
|
896d6584f5 | ||
|
|
0fbcdad02b | ||
|
|
d758305ee3 | ||
|
|
089a58cba2 | ||
|
|
62644a072a | ||
|
|
a409e8a71b | ||
|
|
7983dfb24c | ||
|
|
b1096575f5 | ||
|
|
3c6a3dddd4 | ||
|
|
a7a50312e9 | ||
|
|
618c7339ff | ||
|
|
abb385b461 | ||
|
|
11ae61d541 | ||
|
|
25550dd595 | ||
|
|
85ef4cdfe6 | ||
|
|
a7d1938fd6 | ||
|
|
7f3ddfd709 | ||
|
|
647eeb6eee | ||
|
|
9bb582d8cf | ||
|
|
69a9af6d51 | ||
|
|
638baf7fb5 | ||
|
|
69eef199e7 | ||
|
|
16246565b7 | ||
|
|
a24a14e1ae | ||
|
|
c61e422c86 | ||
|
|
5339d28657 | ||
|
|
d4a87ddfee | ||
|
|
fddf5f3aec | ||
|
|
509daa10e9 | ||
|
|
54f8182467 | ||
|
|
23f12107e0 | ||
|
|
e23b9722a4 | ||
|
|
aa507916ad | ||
|
|
738ebee6e6 | ||
|
|
29b9587914 | ||
|
|
7d6b2c8e25 | ||
|
|
c72ee8d5f0 | ||
|
|
9ce9a78f14 | ||
|
|
f9e6c28e91 | ||
|
|
f3e32fa480 | ||
|
|
10c9dad2cd | ||
|
|
7e0683b4c1 | ||
|
|
6143c61c2f | ||
|
|
a2ac5244d5 | ||
|
|
4af523b87b | ||
|
|
6dabff3e77 | ||
|
|
493dba9d0f | ||
|
|
2da0f024f4 | ||
|
|
e135c81422 | ||
|
|
f9ced604dd | ||
|
|
373385b0b0 | ||
|
|
3cff611498 | ||
|
|
40001fb502 | ||
|
|
dbae61c06e | ||
|
|
0cb0763e9b | ||
|
|
4e6ba0fbce | ||
|
|
5817ff5c92 | ||
|
|
6adacc7e4e | ||
|
|
d13dc49e69 | ||
|
|
ec75af5859 | ||
|
|
8cc9baa514 | ||
|
|
10fef57400 | ||
|
|
bfad0b8bf6 | ||
|
|
b27f7165a0 | ||
|
|
4038efd556 | ||
|
|
1ca63e1836 | ||
|
|
865833e049 | ||
|
|
cbfe1c090b | ||
|
|
3e7db2c7e1 | ||
|
|
23e9df90aa | ||
|
|
c18cada283 | ||
|
|
087f35d0d0 | ||
|
|
c654b714b2 | ||
|
|
4a9ba2af8f | ||
|
|
098b04b75d | ||
|
|
9ea65311a5 | ||
|
|
987dee1e08 | ||
|
|
bcc6d41bba | ||
|
|
19536e947a | ||
|
|
078cebdcf1 | ||
|
|
755389b935 | ||
|
|
f44b575c45 | ||
|
|
bc5b35c0b8 | ||
|
|
a1e0f8e792 | ||
|
|
1608de5fd9 | ||
|
|
8f60abb1a6 | ||
|
|
3318ca32a9 | ||
|
|
03523a317c | ||
|
|
289d51f37f | ||
|
|
b736e97595 | ||
|
|
5e1ea787fe | ||
|
|
63acec3982 | ||
|
|
bd2641feb4 | ||
|
|
cc19ebb8db | ||
|
|
5eed0f5a2f | ||
|
|
e50efd1f43 | ||
|
|
d5e94b9a7a | ||
|
|
2c352a373b | ||
|
|
03a6167613 | ||
|
|
8ceffc76e8 | ||
|
|
4863c4f1e7 | ||
|
|
f472e6385c | ||
|
|
355112b403 | ||
|
|
864788b42f | ||
|
|
f6b8b51daa | ||
|
|
35627404fc | ||
|
|
5a0db2c6dd | ||
|
|
215354b47a | ||
|
|
f0ad2e0a91 | ||
|
|
81af20b738 | ||
|
|
48efb20cfa | ||
|
|
cfef594484 | ||
|
|
3b4a66999e | ||
|
|
41b5385f73 | ||
|
|
bdd27ec829 | ||
|
|
bd68c0ee23 | ||
|
|
20f1097810 | ||
|
|
f3cb76e35b | ||
|
|
1a8061bc34 | ||
|
|
c91a641fa9 | ||
|
|
0ca75cae70 | ||
|
|
cbffe1dde4 | ||
|
|
d1a537c32c | ||
|
|
2f1ae6b7c2 | ||
|
|
48f3c170a0 | ||
|
|
9144dc6aba | ||
|
|
277abb5e15 | ||
|
|
b7595715ff | ||
|
|
592e575ae3 | ||
|
|
68cfbc1cd5 | ||
|
|
fa52cc74e1 | ||
|
|
48e84ee58d | ||
|
|
11763882f8 | ||
|
|
6a74c141d5 | ||
|
|
df5bf34218 | ||
|
|
3c54be90f7 | ||
|
|
38c035022f | ||
|
|
b9e200f58a | ||
|
|
1552bb1874 | ||
|
|
ca8017f958 | ||
|
|
1d2b2b040d | ||
|
|
e3a400b1ac | ||
|
|
62fad7cb48 | ||
|
|
6470f8e41b | ||
|
|
7a6e3f3f62 | ||
|
|
890d98d32d | ||
|
|
ef8e8fbdf6 | ||
|
|
d78dd1ff52 | ||
|
|
cf4c66e977 | ||
|
|
15b2dedd42 | ||
|
|
117f19e6cf | ||
|
|
69c2b3535d | ||
|
|
e703c25bc6 | ||
|
|
8435eb6fa1 | ||
|
|
a5da0bd863 | ||
|
|
47f296b2fb | ||
|
|
584a73ff53 | ||
|
|
c4ba6846b8 | ||
|
|
286b1af431 | ||
|
|
884772402c | ||
|
|
53f188d131 | ||
|
|
7f0f7a683f | ||
|
|
ddd5aeda18 | ||
|
|
b78f48e0b3 | ||
|
|
c75da83217 | ||
|
|
4679d1af14 | ||
|
|
f084d89918 | ||
|
|
d284a8e076 | ||
|
|
55e14e85f3 | ||
|
|
44435ca291 | ||
|
|
39d88d0cfd | ||
|
|
1fc3c6eca6 | ||
|
|
86e26e5e54 | ||
|
|
a2c925ab7b | ||
|
|
a3f7412653 | ||
|
|
d3a47c44d6 | ||
|
|
937ef0f0d8 | ||
|
|
75064e8d42 | ||
|
|
a464a12efb | ||
|
|
1823b4e94f | ||
|
|
f5ff37fc34 | ||
|
|
39d690072f | ||
|
|
d4c361f3b5 | ||
|
|
330c247bfb | ||
|
|
43062cfa5e | ||
|
|
8c5d4e5902 | ||
|
|
a382542fd7 | ||
|
|
92c8155ee9 | ||
|
|
fb2a276496 | ||
|
|
9b59decccf | ||
|
|
9289698825 | ||
|
|
ad4d6ca8aa | ||
|
|
790c65b951 | ||
|
|
55819f81d4 | ||
|
|
76a4380d9e | ||
|
|
6c745bc4dc | ||
|
|
8da1f80314 | ||
|
|
55409ef473 | ||
|
|
ad5f082926 | ||
|
|
88d4c68c2a | ||
|
|
3e0325bb14 | ||
|
|
ce67bb16e8 | ||
|
|
75f78e1a17 | ||
|
|
98a7088be7 | ||
|
|
28c186b508 | ||
|
|
bba56046e4 | ||
|
|
948a627c72 | ||
|
|
bdda312f5e | ||
|
|
4cfcd1f779 | ||
|
|
b3fc76186f | ||
|
|
fecaf7d49d | ||
|
|
1d9c2a9225 | ||
|
|
7a3b13d55e | ||
|
|
bc7f92665e | ||
|
|
509e511b6e | ||
|
|
ab9f8751b7 | ||
|
|
5ea5cde253 | ||
|
|
f28d79673a | ||
|
|
40a6236aa3 | ||
|
|
97c385f5ca | ||
|
|
7cf8015c07 | ||
|
|
5874ec5acc | ||
|
|
01844deb23 | ||
|
|
f7a90ffe65 | ||
|
|
7f7a9341da | ||
|
|
13978c0ca9 | ||
|
|
d564d3c488 | ||
|
|
bd933e5984 | ||
|
|
c0da836191 | ||
|
|
abb45c9481 | ||
|
|
a09e9483c8 | ||
|
|
5a61a9571c | ||
|
|
7b9a02ab9a | ||
|
|
03d80356bf | ||
|
|
35e0ea4c30 | ||
|
|
cf44eff091 | ||
|
|
e3c4d52bb4 | ||
|
|
da83b67e89 | ||
|
|
a13de116e8 | ||
|
|
725beb1d98 | ||
|
|
21c1016292 | ||
|
|
bc76e6fc76 | ||
|
|
24f9b14e69 | ||
|
|
ac0ecfcb84 | ||
|
|
e547011c96 | ||
|
|
164f073b50 | ||
|
|
6df405adcd | ||
|
|
f50dbdd9a6 | ||
|
|
b4274bda4a | ||
|
|
c9adc16cad | ||
|
|
f30cdbbee5 | ||
|
|
a307d64b07 | ||
|
|
c32bb30154 | ||
|
|
c27a62d271 | ||
|
|
102a37a48c | ||
|
|
6beb670744 | ||
|
|
82e81ddf75 | ||
|
|
1ad8ff3fa0 | ||
|
|
631dc417b2 | ||
|
|
57371b1c9d |
@@ -9,6 +9,10 @@ NEXT_PUBLIC_CLOUD_URL=https://cloud.hanzo.ai
|
||||
# Hanzo PaaS (platform.hanzo.ai) — DOKS cluster control plane for the Clusters module.
|
||||
NEXT_PUBLIC_PLATFORM_URL=https://platform.hanzo.ai
|
||||
|
||||
# hanzo.app builder — target of the Templates gallery "Open in builder" deep-link
|
||||
# (fork a starter → customize by prompt in the builder). Default: production.
|
||||
NEXT_PUBLIC_APP_URL=https://hanzo.app
|
||||
|
||||
# Hanzo IAM (OIDC authority). Canonical issuer is https://hanzo.id — tokens are
|
||||
# minted with iss=https://hanzo.id, which the cloud /v1 backend validates against.
|
||||
# iam.hanzo.ai is the legacy zone (iss=https://iam.hanzo.ai) and MUST NOT be used
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640" role="img" aria-label="console2">
|
||||
<rect width="1280" height="640" fill="#0A0A0A"/>
|
||||
<svg x="96" y="215" width="210" height="210" viewBox="0 0 67 67"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#fff"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#fff"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#fff"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#fff"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#fff"/></svg>
|
||||
<text x="378" y="276" font-family="Inter,system-ui,-apple-system,sans-serif" font-size="78" font-weight="800" letter-spacing="-2" fill="#ffffff">console2</text>
|
||||
|
||||
<rect x="378" y="338" width="806" height="3" rx="1.5" fill="#ffffff" opacity=".9"/>
|
||||
<text x="378" y="390" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">github.com/hanzoai</text>
|
||||
<text x="1184" y="390" text-anchor="end" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">hanzo.ai</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -1,11 +1,21 @@
|
||||
name: Build Docker Image
|
||||
# Builds + pushes ghcr.io/hanzoai/console2 on the self-hosted ARC runner. ONE
|
||||
# brand-agnostic image serves every brand: console2 resolves the brand at RUNTIME
|
||||
# Builds + pushes ghcr.io/hanzoai/console on the self-hosted ARC runner. ONE
|
||||
# brand-agnostic image serves every brand: console resolves the brand at RUNTIME
|
||||
# from the request hostname (console.hanzo.ai → hanzo, console.lux.cloud → lux,
|
||||
# console.zoo.cloud → zoo; src/config/index.ts), and /v1 is same-origin per host.
|
||||
# So NO NEXT_PUBLIC_* are baked — baking them would pin the image to one brand.
|
||||
# Tags: SEMVER ONLY (no sha, no :latest) — a `v*` git tag publishes that exact
|
||||
# version; a main push publishes `v<package.json version>` (bump to release).
|
||||
#
|
||||
# Build muscle: RAW `docker buildx build` on the host builder — the canonical
|
||||
# hanzoai/ci pattern. We deliberately do NOT use docker/setup-buildx-action +
|
||||
# docker/build-push-action: that pair spins up an EPHEMERAL buildkit container
|
||||
# that cannot see the host's image cache, so it re-pulled `node:22-alpine` from
|
||||
# Docker Hub every run and tripped the unauthenticated 429 pull-rate limit (and
|
||||
# its build-summary artifact upload hit the Actions storage quota). The host
|
||||
# builder reuses the cached base layer instead — no re-pull, no artifact upload.
|
||||
# The Dockerfile uses the ECR Public Docker-library mirror for the Node base image
|
||||
# so a cold runner does not depend on Docker Hub's unauthenticated pull budget.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -31,21 +41,38 @@ jobs:
|
||||
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=v$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
|
||||
# node is NOT on the ARC runner PATH — resolve the version with grep/sed
|
||||
# (a missing `node` silently produced the tag `:v` and a 404 deploy).
|
||||
ver=$(grep -m1 '"version"' package.json | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
|
||||
[ -n "$ver" ] || { echo "could not resolve version from package.json"; exit 1; }
|
||||
echo "tag=v${ver}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
provenance: false
|
||||
sbom: false
|
||||
tags: |
|
||||
ghcr.io/hanzoai/console2:${{ steps.ver.outputs.tag }}
|
||||
- name: Ensure base image (reuse host cache)
|
||||
# No-op on a warm runner (the host builder reuses the cached base). Only a
|
||||
# cold runner pulls. Use the same ECR Public Docker-library mirror as the
|
||||
# Dockerfile to avoid Docker Hub's unauthenticated pull-rate limits.
|
||||
run: |
|
||||
set -u
|
||||
base=public.ecr.aws/docker/library/node:24-alpine
|
||||
if docker image inspect "$base" >/dev/null 2>&1; then
|
||||
echo "base $base already cached on runner"; exit 0
|
||||
fi
|
||||
for i in 1 2 3 4 5; do
|
||||
if docker pull "$base"; then exit 0; fi
|
||||
echo "pull failed (attempt $i/5) — backing off"; sleep $((i * 30))
|
||||
done
|
||||
echo "could not pull $base after retries"; exit 1
|
||||
- name: Build & push (host builder — reuses base cache, no artifact upload)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker buildx build \
|
||||
--platform linux/amd64 \
|
||||
--push \
|
||||
-t ghcr.io/hanzoai/console:${{ steps.ver.outputs.tag }} \
|
||||
-f Dockerfile .
|
||||
|
||||
@@ -15,3 +15,8 @@ next-env.d.ts
|
||||
.vscode/
|
||||
.idea/
|
||||
*.log
|
||||
e2e/screenshots/
|
||||
e2e-shots/
|
||||
test-results/
|
||||
playwright-report/
|
||||
.claude/
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# AGENTS — console2
|
||||
|
||||
Read [LLM.md](./LLM.md) first; it is the canonical design doc. Highlights for
|
||||
agents working here:
|
||||
|
||||
- **Stack:** Next.js 15 (app router) + @hanzo/gui (npm), consumed at runtime via
|
||||
`transpilePackages` (the `@hanzogui/next-plugin` is broken on npm). Next 15
|
||||
(not 14) because @hanzo/gui needs React 19. Pin patch versions; never lazily
|
||||
major-bump.
|
||||
- **Gui style props:** the v5 config is `onlyShorthandStyleProps` — use
|
||||
shorthands (`p`, `px`, `bg`, `items`, `justify`, `self`, `rounded`, `minH`),
|
||||
never longhands (`padding`, `backgroundColor`, …). Keep `tsc` clean.
|
||||
- **One way:** all backend calls go through `src/lib/api` (never raw `fetch`);
|
||||
all selects/inputs through `src/components/ui/Field.tsx`; all nav/routing
|
||||
through the registry in `src/lib/products`.
|
||||
- **Extensibility:** add a cloud product by appending a `ProductModule` to
|
||||
`src/lib/products/registry.tsx` and writing its module component — do not add
|
||||
per-product routes or touch the shell.
|
||||
- **Auth:** Hanzo IAM (OIDC) via `@hanzo/iam-js-sdk`; session cookie minted by
|
||||
the backend at `/v1/signin`. Never store credentials client-side.
|
||||
- **Boundaries:** frontend only. No DB. No Docker builds locally (CI/CD builds
|
||||
images). No secrets in the repo — config is `NEXT_PUBLIC_*` only.
|
||||
- **Verify:** `npm run typecheck` and `npm run build` must pass. Show output.
|
||||
+18
-17
@@ -1,29 +1,30 @@
|
||||
# console2 — Hanzo Cloud Console (Next.js 15 + @hanzo/gui). BSD-3-Clause.
|
||||
# NEXT_PUBLIC_* are inlined at build time (browser config), so they are build args.
|
||||
FROM node:22-alpine AS deps
|
||||
FROM public.ecr.aws/docker/library/node:24-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
# npm install (not ci): @hanzo/gui pulls a react-native dep tree whose
|
||||
# platform/optional packages (e.g. react-native-worklets) resolve differently
|
||||
# across npm versions, so a lockfile generated by one npm fails `npm ci` under
|
||||
# another (EUSAGE "Missing: react-native-worklets@... from lock file"). install
|
||||
# reconciles the tree deterministically for the build platform.
|
||||
RUN npm install --no-audit --no-fund
|
||||
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
# Copy ALL source FIRST, then install — order matters under Kaniko --single-snapshot:
|
||||
# a `COPY` that FOLLOWS `RUN npm install` in the same stage drops the 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
|
||||
# npm install (not ci): @hanzo/gui pulls a react-native dep tree whose platform/
|
||||
# optional packages resolve differently across npm versions, so a lockfile generated
|
||||
# by one npm fails `npm ci` under another. install reconciles the tree for the
|
||||
# build platform; retry-hardened against registry throttling.
|
||||
RUN npm install --no-audit --no-fund --fetch-retries=5 --fetch-retry-mintimeout=20000 --fetch-timeout=120000
|
||||
# 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 —
|
||||
# so nothing brand-specific is baked.
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# 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 npm run build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
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
|
||||
@@ -34,4 +35,4 @@ COPY --from=build /app/package.json ./package.json
|
||||
COPY --from=build /app/next.config.mjs ./next.config.mjs
|
||||
USER app
|
||||
EXPOSE 4000
|
||||
CMD ["npm", "run", "start"]
|
||||
CMD ["node", "node_modules/next/dist/bin/next", "start", "-p", "4000"]
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
Hanzo Cloud Console (console2)
|
||||
Copyright (c) Hanzo AI, Inc. Licensed BSD-3-Clause (see LICENSE).
|
||||
|
||||
------------------------------------------------------------------------
|
||||
Third-party attribution
|
||||
------------------------------------------------------------------------
|
||||
|
||||
Observe surface — Langfuse (MIT License)
|
||||
|
||||
The console's Observe screens (Traces, Trace detail with the span-tree /
|
||||
latency-waterfall, Observations, Sessions, Scores, Score Configs, Datasets,
|
||||
Dataset Items, Dataset Runs / Experiments, and the observability Dashboards /
|
||||
Metrics) reproduce the SCREEN LAYOUT AND USER FLOWS of Langfuse's observability
|
||||
product.
|
||||
|
||||
This is a clean-room reimplementation in our own code (React + @hanzo/gui),
|
||||
wired to the native Hanzo Cloud /v1/evals contract. No Langfuse source code is
|
||||
copied. Only the MIT-licensed layout/flow concepts inform the design; the
|
||||
Langfuse EE / commercial ("ee") code is neither used nor referenced.
|
||||
|
||||
Langfuse — https://github.com/langfuse/langfuse
|
||||
Copyright (c) Langfuse GmbH
|
||||
Licensed under the MIT License.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,3 +1,5 @@
|
||||
<p align="center"><img src=".github/hero.svg" alt="console2" width="880"></p>
|
||||
|
||||
# Hanzo Cloud Console
|
||||
|
||||
Unified admin console for **Hanzo Cloud** and all Hanzo cloud products. Built on
|
||||
|
||||
@@ -3,18 +3,67 @@
|
||||
import { use } from 'react'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
import { matchRoute } from '~/lib/products/match'
|
||||
import { resolveView, isAdminRoute } from '~/lib/products/match'
|
||||
import { findEntry } from '~/lib/products/registry'
|
||||
import { useIsGlobalAdmin } from '~/lib/auth/admin'
|
||||
import { ProductSubpageStub } from '~/components/products/ProductSubpageStub'
|
||||
import { ProductSubpageModule } from '~/components/products/subpage/ProductSubpageModule'
|
||||
import { AdminManagedNotice } from '~/components/products/AdminManagedNotice'
|
||||
import { ProductErrorBoundary } from '~/components/errors/ProductErrorBoundary'
|
||||
|
||||
/**
|
||||
* Catch-all product route. Resolves the module + route from the registry and
|
||||
* renders its component. Adding a product anywhere in the registry makes its
|
||||
* routes live here — no per-product page files.
|
||||
*
|
||||
* Two honest gates on top of the resolver:
|
||||
* - A known product SUB-PAGE (a declared specific or a uniform base sub-page:
|
||||
* Overview · Settings · Status · Logs · Metrics) with no backend route yet
|
||||
* renders a placeholder stub — never a 404, never a fabricated surface.
|
||||
* - A CUSTOMER (non-global-admin) reaching an admin-only surface (cross-tenant
|
||||
* IAM/KMS, provider + routing config) gets a graceful "managed by Hanzo" notice
|
||||
* instead of the module's hostile 403 red error. Access is enforced
|
||||
* server-side regardless.
|
||||
*
|
||||
* The resolved module renders inside `ProductErrorBoundary`: modules mount
|
||||
* client-only (the authed shell renders a loader during SSR), so a throw in one
|
||||
* module's first render had no boundary and white-screened the whole console
|
||||
* ("Application error: a client-side exception") on a direct load / refresh. The
|
||||
* boundary keeps the shell + nav and shows an honest, retryable card instead —
|
||||
* one place, every product route (DRY).
|
||||
*/
|
||||
export default function ProductPage({ params }: { params: Promise<{ slug: string[] }> }) {
|
||||
const { slug } = use(params)
|
||||
const matched = matchRoute(slug)
|
||||
if (!matched) notFound()
|
||||
const showAdmin = useIsGlobalAdmin()
|
||||
const view = resolveView(slug)
|
||||
|
||||
const Component = matched.route.component
|
||||
return <Component params={matched.params} />
|
||||
if (view.kind === 'notfound') notFound()
|
||||
|
||||
if (!showAdmin && isAdminRoute(slug)) {
|
||||
const entry = findEntry(slug[0])
|
||||
if (entry && entry.kind === 'module') {
|
||||
const seg = slug[1]
|
||||
const subpage = seg ? (entry.subpages ?? []).find((s) => s.slug === seg && s.admin) : undefined
|
||||
return <AdminManagedNotice entry={entry} subpage={subpage} />
|
||||
}
|
||||
}
|
||||
|
||||
if (view.kind === 'stub') return <ProductSubpageStub entry={view.entry} subpage={view.subpage} />
|
||||
|
||||
// A uniform base sub-page (Status/Logs/Metrics/Settings) → the shared per-product
|
||||
// sub-page system (real feed or honest state), inside the same error boundary so
|
||||
// a data fetch that throws shows the retryable card, never a white screen.
|
||||
if (view.kind === 'subpage')
|
||||
return (
|
||||
<ProductErrorBoundary resetKey={slug.join('/')}>
|
||||
<ProductSubpageModule entry={view.entry} subpage={view.subpage} />
|
||||
</ProductErrorBoundary>
|
||||
)
|
||||
|
||||
const Component = view.matched.route.component
|
||||
return (
|
||||
<ProductErrorBoundary resetKey={slug.join('/')}>
|
||||
<Component params={view.matched.params} />
|
||||
</ProductErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Dashboard route error backstop (Next App Router).
|
||||
*
|
||||
* `ProductErrorBoundary` catches throws inside a resolved product module; this
|
||||
* catches anything above it in the dashboard page tree (the resolver itself, a
|
||||
* non-catch-all dashboard page). It renders in the layout's content slot, so the
|
||||
* shell + nav stay mounted — never a white-screened "Application error". Next's
|
||||
* `reset()` re-renders the segment; `notFound()`/`redirect()` are control flow and
|
||||
* do not reach here.
|
||||
*/
|
||||
import { useEffect } from 'react'
|
||||
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { RefreshCw, TriangleAlert } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { isChunkLoadError, shouldReloadForChunk } from '~/components/errors/boundary-logic'
|
||||
|
||||
/** Shared once-per-window guard key (same as ProductErrorBoundary — never double-reload). */
|
||||
const RELOAD_AT_KEY = 'hz.console.chunkReloadAt'
|
||||
|
||||
export default function DashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const chunk = isChunkLoadError(error)
|
||||
|
||||
useEffect(() => {
|
||||
console.error('[console] dashboard route error:', error)
|
||||
// A chunk skew self-heals: reload ONCE per window to pull the fresh HTML +
|
||||
// current chunks (same recovery the product boundary does), so a stale-deploy
|
||||
// crash at the segment level auto-recovers instead of stranding a manual card.
|
||||
if (!chunk || typeof window === 'undefined') return
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(RELOAD_AT_KEY)
|
||||
const last = raw ? Number(raw) : null
|
||||
if (shouldReloadForChunk(Date.now(), last)) {
|
||||
window.sessionStorage.setItem(RELOAD_AT_KEY, String(Date.now()))
|
||||
window.location.reload()
|
||||
}
|
||||
} catch {
|
||||
/* sessionStorage blocked (private mode) — fall through to the manual card */
|
||||
}
|
||||
}, [error, chunk])
|
||||
|
||||
return (
|
||||
<YStack p="$4">
|
||||
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$3" maxWidth={640} bg="$color1">
|
||||
<XStack gap="$2" items="center">
|
||||
<TriangleAlert size={16} />
|
||||
<Text fontSize="$4" fontWeight="700">
|
||||
{chunk ? 'Updating to the latest version' : 'This page hit an unexpected error'}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$3" color="$color11">
|
||||
{chunk
|
||||
? 'A newer version of the console just shipped. Reload to load the latest.'
|
||||
: 'The rest of the console still works. Try again, or reload the page.'}
|
||||
</Text>
|
||||
<XStack gap="$2">
|
||||
{!chunk ? (
|
||||
<Button size="$2" icon={<RefreshCw size={14} />} onPress={() => reset()}>
|
||||
Try again
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="$2"
|
||||
chromeless={!chunk}
|
||||
icon={<RefreshCw size={14} />}
|
||||
onPress={() => { if (typeof window !== 'undefined') window.location.reload() }}
|
||||
>
|
||||
Reload
|
||||
</Button>
|
||||
</XStack>
|
||||
</Card>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,39 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { AuthGate } from '~/components/AuthGate'
|
||||
import { OrgGate } from '~/components/OrgGate'
|
||||
import { DashboardShell } from '~/components/DashboardShell'
|
||||
import { PreferencesProvider } from '~/lib/products/preferences'
|
||||
import { ScopeProvider } from '~/lib/scope-context'
|
||||
import { ToastProvider } from '~/components/ui/Toast'
|
||||
import { CommandPaletteProvider } from '~/components/CommandPalette'
|
||||
import { AppLauncherProvider } from '~/components/AppLauncher'
|
||||
import { DetailPaneProvider } from '~/components/DetailPane'
|
||||
import { FloatingChatProvider } from '~/components/FloatingChat'
|
||||
|
||||
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<AuthGate>
|
||||
<PreferencesProvider>
|
||||
<DashboardShell>{children}</DashboardShell>
|
||||
</PreferencesProvider>
|
||||
<OrgGate>
|
||||
<ScopeProvider>
|
||||
<PreferencesProvider>
|
||||
<ToastProvider>
|
||||
{/* AppLauncher wraps the palette so the palette can open the launcher. */}
|
||||
<AppLauncherProvider>
|
||||
<CommandPaletteProvider>
|
||||
{/* FloatingChat floats the assistant bubble over every page. */}
|
||||
<FloatingChatProvider>
|
||||
{/* DetailPane hosts the ONE right-side item detail/edit pane. */}
|
||||
<DetailPaneProvider>
|
||||
<DashboardShell>{children}</DashboardShell>
|
||||
</DetailPaneProvider>
|
||||
</FloatingChatProvider>
|
||||
</CommandPaletteProvider>
|
||||
</AppLauncherProvider>
|
||||
</ToastProvider>
|
||||
</PreferencesProvider>
|
||||
</ScopeProvider>
|
||||
</OrgGate>
|
||||
</AuthGate>
|
||||
)
|
||||
}
|
||||
|
||||
+131
-80
@@ -1,48 +1,31 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Product catalog — the unified console home. Every Hanzo product, grouped by
|
||||
* category, with its enablement state. `enabled` products open straight in;
|
||||
* `available` products offer a "Get started" onboarding affordance. Each card
|
||||
* can be pinned to the sidebar (persisted to the account). Rendered entirely
|
||||
* from the catalog registry.
|
||||
* Product catalog — the unified console home. Every Hanzo product, grouped by the
|
||||
* ten canonical categories, with its Google Cloud equivalent. Every product is
|
||||
* open-for-all: each card opens straight into its native in-console surface and
|
||||
* carries a "Learn more" affordance to its docs — there is no enablement gate and
|
||||
* no external bounce. Each card can be pinned to the sidebar (persisted to the
|
||||
* account). Rendered entirely from the catalog registry.
|
||||
*/
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Star, Lock, ExternalLink, ArrowRight, Info } from '@hanzogui/lucide-icons-2'
|
||||
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Star, Lock, ArrowRight, BookOpen, KeyRound } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { branding, config } from '~/config'
|
||||
import { catalogByCategory, type CatalogEntry } from '~/lib/products/registry'
|
||||
import { config } from '~/config'
|
||||
import { visibleCatalogByCategory, categorySlug, type CatalogEntry } from '~/lib/products/registry'
|
||||
import { openProduct } from '~/lib/products/open'
|
||||
import { useFavorites } from '~/lib/products/favorites'
|
||||
import { useIsGlobalAdmin } from '~/lib/auth/admin'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { FadeIn } from '~/components/ui/FadeIn'
|
||||
import { livingOverviewModule } from '~/components/products/overview/living/LivingOverviewModule'
|
||||
|
||||
function StatusBadge({ entry }: { entry: CatalogEntry }) {
|
||||
const label =
|
||||
entry.status === 'enabled'
|
||||
? 'Enabled'
|
||||
: entry.status === 'soon'
|
||||
? 'Soon'
|
||||
: entry.status === 'waitlist'
|
||||
? 'Waitlist'
|
||||
: 'Available'
|
||||
const bg =
|
||||
entry.status === 'enabled'
|
||||
? '$color5'
|
||||
: entry.status === 'waitlist'
|
||||
? '$color4'
|
||||
: entry.status === 'soon'
|
||||
? '$color4'
|
||||
: '$color3'
|
||||
return (
|
||||
<XStack bg={bg} px="$2" py="$1" rounded="$10" items="center" gap="$1">
|
||||
{entry.admin ? <Lock size={11} opacity={0.6} /> : null}
|
||||
<Text fontSize="$1" color={entry.status === 'enabled' ? '$color12' : '$color11'} fontWeight="600">
|
||||
{label}
|
||||
</Text>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
// The home centerpiece is the reusable LivingOverview (count-up KPIs, live
|
||||
// sparklines, streaming activity) — the SAME component every product overview uses.
|
||||
const OverviewDashboard = livingOverviewModule('overview')
|
||||
|
||||
function ProductCard({
|
||||
entry,
|
||||
@@ -58,25 +41,24 @@ function ProductCard({
|
||||
onLearnMore: () => void
|
||||
}) {
|
||||
const Icon = entry.icon
|
||||
const enabled = entry.status === 'enabled'
|
||||
return (
|
||||
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$3" width={272}>
|
||||
<XStack justify="space-between" items="flex-start">
|
||||
<XStack gap="$2" items="center" flex={1}>
|
||||
<Icon size={20} />
|
||||
<Text fontSize="$5" fontWeight="700">
|
||||
{entry.label}
|
||||
</Text>
|
||||
<YStack flex={1}>
|
||||
<Text fontSize="$5" fontWeight="700">
|
||||
{entry.label}
|
||||
</Text>
|
||||
{entry.gcp ? (
|
||||
<Text fontSize="$1" color="$color10">
|
||||
{entry.gcp}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
</XStack>
|
||||
<XStack gap="$1" items="center">
|
||||
<Button
|
||||
size="$2"
|
||||
chromeless
|
||||
opacity={0.4}
|
||||
icon={<Info size={15} />}
|
||||
onPress={onLearnMore}
|
||||
aria-label={`Learn about ${entry.label}`}
|
||||
/>
|
||||
{entry.admin ? <Lock size={13} opacity={0.45} /> : null}
|
||||
<Button
|
||||
size="$2"
|
||||
chromeless
|
||||
@@ -93,55 +75,124 @@ function ProductCard({
|
||||
</Text>
|
||||
|
||||
<XStack justify="space-between" items="center">
|
||||
<StatusBadge entry={entry} />
|
||||
<Button
|
||||
size="$2"
|
||||
bg={enabled ? '$color5' : 'transparent'}
|
||||
chromeless
|
||||
icon={<BookOpen size={14} />}
|
||||
onPress={onLearnMore}
|
||||
aria-label={`Learn more about ${entry.label}`}
|
||||
>
|
||||
Learn more
|
||||
</Button>
|
||||
<Button
|
||||
size="$2"
|
||||
bg="$color5"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
onPress={enabled ? onOpen : onLearnMore}
|
||||
iconAfter={
|
||||
enabled && entry.kind === 'external' ? <ExternalLink size={14} /> : <ArrowRight size={14} />
|
||||
}
|
||||
onPress={onOpen}
|
||||
iconAfter={<ArrowRight size={14} />}
|
||||
>
|
||||
{enabled ? 'Open' : 'Get started'}
|
||||
Open
|
||||
</Button>
|
||||
</XStack>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Prominent, always-visible "Get API key" call-to-action at the top of the home.
|
||||
* A cold customer must reach "New key" in one obvious click from landing — the
|
||||
* api-keys page is otherwise buried in the collapsed Dev nav group. Routes to the
|
||||
* real ApiKeysModule (`/api-keys`), where the `hk-` key is created/copied/rotated.
|
||||
*/
|
||||
function GetApiKeyCta({ onOpen }: { onOpen: () => void }) {
|
||||
return (
|
||||
<Card borderWidth={1} borderColor="$borderColor" bg="$color2" p="$4">
|
||||
<XStack items="center" justify="space-between" gap="$4" flexWrap="wrap">
|
||||
<XStack items="center" gap="$3" flex={1} minW={240}>
|
||||
<YStack bg="$color5" rounded="$4" p="$2.5" items="center" justify="center">
|
||||
<KeyRound size={20} />
|
||||
</YStack>
|
||||
<YStack flex={1} minW={180}>
|
||||
<Text fontSize="$5" fontWeight="800">
|
||||
Get your API key
|
||||
</Text>
|
||||
<Text fontSize="$3" color="$color11">
|
||||
Call {config.brandName} models from your apps, SDKs, and CLI with a personal key.
|
||||
</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
<PrimaryButton size="$4" iconAfter={<ArrowRight size={16} />} onPress={onOpen}>
|
||||
Get API key
|
||||
</PrimaryButton>
|
||||
</XStack>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DashboardHome() {
|
||||
const router = useRouter()
|
||||
const { toggle, isPinned } = useFavorites()
|
||||
const showAdmin = useIsGlobalAdmin()
|
||||
const push = (path: string) => router.push(path)
|
||||
const groups = catalogByCategory()
|
||||
const groups = visibleCatalogByCategory(showAdmin)
|
||||
|
||||
// Billing-only shell (billing.<brand> / NEXT_PUBLIC_BILLING_ONLY): the default
|
||||
// route IS the Billing Center — redirect the catalog home to the billing overview
|
||||
// so people who only ever see billing.hanzo.ai land straight on billing.
|
||||
useEffect(() => {
|
||||
if (config.billingOnly) router.replace('/billing')
|
||||
}, [router])
|
||||
if (config.billingOnly) {
|
||||
return (
|
||||
<XStack flex={1} justify="center" items="center" p="$8">
|
||||
<Spinner size="large" color="$color11" />
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={branding.name}
|
||||
subtitle={`See, enable, and manage every ${config.brandName} product from one place.`}
|
||||
/>
|
||||
{groups.map((group) => (
|
||||
<YStack key={group.category} gap="$3">
|
||||
<Text fontSize="$5" fontWeight="800" color="$color12">
|
||||
{group.category}
|
||||
</Text>
|
||||
<XStack flexWrap="wrap" gap="$3">
|
||||
{group.entries.map((entry) => (
|
||||
<ProductCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
pinned={isPinned(entry.id)}
|
||||
onOpen={() => openProduct(entry, push)}
|
||||
onToggle={() => toggle(entry.id)}
|
||||
onLearnMore={() => push(`/discover/${entry.id}`)}
|
||||
/>
|
||||
))}
|
||||
</XStack>
|
||||
</YStack>
|
||||
))}
|
||||
</>
|
||||
<YStack gap="$7">
|
||||
<GetApiKeyCta onOpen={() => push('/api-keys')} />
|
||||
<OverviewDashboard params={{}} />
|
||||
<YStack gap="$4">
|
||||
<PageHeader
|
||||
title="Explore products"
|
||||
subtitle={`Open and manage every ${config.brandName} product from one place.`}
|
||||
/>
|
||||
{groups.map((group, i) => (
|
||||
<FadeIn key={group.category} index={i} style={{ width: '100%' }}>
|
||||
<YStack gap="$3">
|
||||
<XStack
|
||||
self="flex-start"
|
||||
items="center"
|
||||
gap="$2"
|
||||
cursor="pointer"
|
||||
hoverStyle={{ opacity: 0.75 }}
|
||||
onPress={() => push(`/category/${categorySlug(group.category)}`)}
|
||||
aria-label={`${group.category} overview`}
|
||||
>
|
||||
<Text fontSize="$5" fontWeight="800" color="$color12">
|
||||
{group.category}
|
||||
</Text>
|
||||
<ArrowRight size={16} opacity={0.5} />
|
||||
</XStack>
|
||||
<XStack flexWrap="wrap" gap="$3">
|
||||
{group.entries.map((entry) => (
|
||||
<ProductCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
pinned={isPinned(entry.id)}
|
||||
onOpen={() => openProduct(entry, push)}
|
||||
onToggle={() => toggle(entry.id)}
|
||||
onLearnMore={() => push(`/discover/${entry.id}`)}
|
||||
/>
|
||||
))}
|
||||
</XStack>
|
||||
</YStack>
|
||||
</FadeIn>
|
||||
))}
|
||||
</YStack>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Server-gated GLOBAL admin aggregate proxy — the cross-tenant business/platform
|
||||
* reads (`/v1/admin/{overview,usage,orgs,audit,products,finance,compute,providers}`)
|
||||
* AND the few GLOBAL-admin mutations that ride the same god-view gate
|
||||
* (`POST /v1/admin/providers/{toggle,primary}` — flip shared-gateway provider
|
||||
* routing that affects every org).
|
||||
*
|
||||
* The admin business board is an ALL-ORGS god view (`?org=all`) over IAM + commerce
|
||||
* + o11y. So — unlike the per-tenant `/cloud` proxy, which authorizes on the bearer
|
||||
* `owner` claim and is safe for any authenticated user — this MUST be gated to a
|
||||
* GLOBAL admin BEFORE anything is forwarded: a tenant customer (even one who is
|
||||
* `isAdmin` of their own org) must NOT read another org's revenue/spend/customers,
|
||||
* must NOT trigger the `org=all` aggregate at all, and must NOT flip a shared
|
||||
* provider's enabled/primary state.
|
||||
*
|
||||
* Defense in depth (RED H1 — the cloud-side gate for `/v1/admin/*` is a separate
|
||||
* backend contract we cannot see or test from this repo): `getAdminGate` enforces the
|
||||
* SAME policy the IAM/KMS admin proxies use — a VERIFIED `@<brand.adminDomain>` email
|
||||
* AND an IAM global-admin flag, fail-closed (→ 403) on any miss. Only then does the
|
||||
* shared `forwardWithUserBearer` mint a short-lived user bearer and forward to
|
||||
* cloud-api, applying the usual path-traversal + same-origin-CSRF hardening. On a
|
||||
* mutating method (POST), that CSRF gate (Sec-Fetch-Site ≠ cross-site AND Origin/
|
||||
* Referer host == Host, fail-closed 403 BEFORE resolving the user) means a
|
||||
* cross-site page can never flip a provider on the victim admin's behalf. The
|
||||
* browser holds no cloud credential and cannot reach this endpoint without passing
|
||||
* the gate; the client-side `admin: true` nav gate + `AdminManagedNotice` is UI-only
|
||||
* defense-in-depth, never the boundary.
|
||||
*
|
||||
* Least privilege: only the admin aggregate heads are reachable, NOT `iam`/`kms`
|
||||
* (those keep their own gated proxies with their own tenant-scoping semantics) — this
|
||||
* is not a general cloud-api tunnel. `allowAdminSurface` admits `v1/admin/<head>[/...]`
|
||||
* (the exact forwarded upstream shape), so `providers` covers the GET list and the
|
||||
* `providers/{toggle,primary}` POSTs and nothing else. `next.config.mjs` rewrites
|
||||
* `/v1/admin/<head>[/...]` here for BOTH GET and POST (dropping the `/v1/` into the
|
||||
* internal Next route path); this handler re-adds `v1/` for the upstream cloud call,
|
||||
* and the client calls the clean same-origin `/v1/admin/*` form (unchanged).
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAdminGate } from '~/lib/server/identity'
|
||||
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
|
||||
import { allowAdminSurface } from '~/lib/server/admin-aggregate'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
/** The unified cloud backend (hanzoai/cloud). In-cluster ClusterIP — public egress is CF-403'd.
|
||||
* `|| default` (not `??`) so an env reconciled to an EMPTY string still resolves the service. */
|
||||
const CLOUD_API_URL = trim(process.env.CLOUD_API_URL?.trim() || 'http://cloud-api.hanzo.svc.cluster.local:8000')
|
||||
|
||||
const forbidden = () => NextResponse.json({ status: 'error', msg: 'forbidden' }, { status: 403 })
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
async function handle(req: NextRequest, ctx: Ctx): Promise<NextResponse> {
|
||||
// AUTHORIZE FIRST — global-admin only, fail-closed. A non-global-admin (tenant
|
||||
// customer, org-level isAdmin) gets a 403 and never triggers the org=all aggregate.
|
||||
const gate = await getAdminGate(req)
|
||||
if (!gate) return forbidden()
|
||||
|
||||
// The rewrite feeds the tail after `/v1/admin/` (e.g. `overview`, `audit`); rebuild
|
||||
// the FULL cloud path, which is `/v1/admin/<head>` — cloud serves every admin route
|
||||
// under `/v1/admin/*` (the beego `/v1/*` glob in hanzoai/ai + cloud's own
|
||||
// `clients/admin` `app.Get("/v1/admin/…")`), and `forwardWithUserBearer` forwards to
|
||||
// `target/path` VERBATIM (no `/v1` prepend), so the `v1/` MUST be part of the path
|
||||
// here or the request lands on a non-existent bare `/admin/*` and 404s. The rewrite
|
||||
// destination (`app/admin/aggregate/<head>`) is the internal Next route, not the
|
||||
// upstream — it deliberately carries no `v1/`; this handler adds it.
|
||||
// `forwardWithUserBearer` re-validates the exact forwarded path via `allow`
|
||||
// (`allowAdminSurface`, keyed on the `v1/admin/<head>` shape) + `pathIsClean`.
|
||||
const path = `v1/admin/${(await ctx.params).path.join('/')}`.replace(/\/+$/, '')
|
||||
return forwardWithUserBearer(req, {
|
||||
target: CLOUD_API_URL,
|
||||
path,
|
||||
allow: allowAdminSurface,
|
||||
// The AdminApi client unwraps the casibase `{status,msg,data}` envelope, so this
|
||||
// proxy's own 401/404 must speak the same shape (an honest state, never a throw).
|
||||
errorShape: 'casibase',
|
||||
unauthorizedMessage: 'Sign in as an administrator.',
|
||||
})
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* POST — the GLOBAL-admin mutations that ride the same god-view gate
|
||||
* (`/v1/admin/providers/{toggle,primary}`). Identical path through `getAdminGate`
|
||||
* (fail-closed 403) → `forwardWithUserBearer`, which applies the same-origin CSRF
|
||||
* check to this mutating method BEFORE resolving the user, streams the JSON body
|
||||
* through, and re-validates the path against `allowAdminSurface` (so a POST can only
|
||||
* ever reach an allowed head — never `iam`/`kms`, never a traversal).
|
||||
*/
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT — the GLOBAL-admin enablement set (`PUT /v1/admin/enablement`, flip an item
|
||||
* off|beta|ga + grant orgs). Same gate + same CSRF/traversal hardening as POST;
|
||||
* `allowAdminSurface` admits only `v1/admin/enablement`, nothing else.
|
||||
*/
|
||||
export async function PUT(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Server-gated GLOBAL IAM admin proxy — cross-tenant IAM ops (any org).
|
||||
*
|
||||
* The browser holds no IAM credential. It calls this SAME-ORIGIN route with just
|
||||
* its session cookie; the handler enforces the GLOBAL admin gate (`getAdminGate`:
|
||||
* verified @<adminDomain> email AND a global-admin flag), then the shared
|
||||
* `forwardIam` applies the allow-list + tenant scoping (a global admin may act on
|
||||
* any org) and forwards to IAM as the user. A CUSTOMER managing their OWN org uses
|
||||
* `/org/iam` instead — this route is global-only.
|
||||
*
|
||||
* Least privilege: only an explicit allow-list of admin segments is reachable
|
||||
* (GET reads / POST mutations); every owner the request references — including
|
||||
* the mutation BODY owner — is validated by `forwardIam`.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAdminGate } from '~/lib/server/identity'
|
||||
import { forwardIam } from '~/lib/server/iam-proxy'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
/** Read segments — reachable via GET only. */
|
||||
const GET_SEGMENTS = new Set([
|
||||
'get-organizations',
|
||||
'get-organization',
|
||||
'get-users',
|
||||
'get-user',
|
||||
'get-applications',
|
||||
'get-application',
|
||||
'get-providers',
|
||||
'get-provider',
|
||||
'get-roles',
|
||||
'get-records',
|
||||
])
|
||||
|
||||
/** Mutation segments — reachable via POST only (JSON body forwarded). */
|
||||
const POST_SEGMENTS = new Set([
|
||||
'add-user',
|
||||
'update-user',
|
||||
'delete-user',
|
||||
'add-application',
|
||||
'update-application',
|
||||
'delete-application',
|
||||
'add-provider',
|
||||
'update-provider',
|
||||
'delete-provider',
|
||||
'add-role',
|
||||
'update-role',
|
||||
'delete-role',
|
||||
])
|
||||
|
||||
/**
|
||||
* Organization objects are owned by IAM's built-in `admin`, and the org
|
||||
* list/get endpoints scope results to the caller's org server-side — so `admin`
|
||||
* is an acceptable owner THERE (never for tenant data like users/roles).
|
||||
*/
|
||||
const ORG_ENDPOINTS = new Set(['get-organizations', 'get-organization'])
|
||||
|
||||
const forbidden = () => NextResponse.json({ error: 'forbidden' }, { status: 403 })
|
||||
|
||||
async function handle(req: NextRequest, path: string[], method: 'GET' | 'POST'): Promise<NextResponse> {
|
||||
const gate = await getAdminGate(req)
|
||||
if (!gate) return forbidden()
|
||||
return forwardIam(
|
||||
req,
|
||||
{ user: gate.user, isGlobalAdmin: gate.user.isGlobalAdmin, orgScope: gate.orgScope },
|
||||
{
|
||||
segment: path.join('/'),
|
||||
method,
|
||||
allowed: method === 'GET' ? GET_SEGMENTS : POST_SEGMENTS,
|
||||
orgMetaSegments: ORG_ENDPOINTS,
|
||||
// The gate is already global-only; global admins may write to any org.
|
||||
requireAdminForWrite: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, (await ctx.params).path, 'GET')
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, (await ctx.params).path, 'POST')
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Server-gated KMS admin proxy — the ONLY way the browser reaches Hanzo KMS.
|
||||
*
|
||||
* Same trust boundary as the IAM proxy: the browser sends only its session
|
||||
* cookie, this handler enforces the brand-admin gate, then forwards to kmsd as
|
||||
* the user (short-lived user-bound bearer) so KMS enforces org isolation from the
|
||||
* verified `owner` claim (`canActOnOrg`). Secrets are scoped to the brand org by
|
||||
* default; a global admin may target another org with `?org=`.
|
||||
*
|
||||
* Zero-knowledge discipline: this route NEVER logs a secret value or any request
|
||||
* body, and never derives or stores key material — it is a faithful pass-through
|
||||
* of kmsd's JSON + status code. One resource path (`/admin/kms/secrets`); the
|
||||
* verb + query select the operation:
|
||||
* GET ?path=&name=&env= → reveal one value → GET .../secrets/<path>/<name>?env=
|
||||
* GET ?prefix=&env= → list metadata → GET .../secrets?prefix=&env=
|
||||
* POST {path,name,env,value} → create/upsert → POST .../secrets
|
||||
* PATCH ?path=&name= {value,version,env} → rotate → PATCH .../secrets/<path>/<name>
|
||||
* DELETE ?path=&name=&env= → delete → DELETE .../secrets/<path>/<name>?env=
|
||||
*
|
||||
* kmsd has no list endpoint yet — the list GET returns 404, which the KMS module
|
||||
* renders as an honest "listing requires kmsd ≥ next release" state.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAdminGate, adminBearer, kmsBaseUrl, type AdminGate } from '~/lib/server/identity'
|
||||
import { orgFor as policyOrgFor } from '~/lib/server/admin-policy'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const forbidden = () => NextResponse.json({ error: 'forbidden' }, { status: 403 })
|
||||
const notFound = () => NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||
|
||||
/** `<path>/<name>` for the kmsd route, each segment encoded, slashes preserved. */
|
||||
function secretRest(path: string, name: string): string {
|
||||
return [...path.split('/').filter(Boolean), name].map(encodeURIComponent).join('/')
|
||||
}
|
||||
|
||||
/** Org the operator acts on — the brand org, unless a global admin passes ?org=
|
||||
* (the pure `admin-policy` predicate, tested in admin-policy.test.ts). */
|
||||
function orgFor(gate: AdminGate, req: NextRequest): string {
|
||||
return policyOrgFor(
|
||||
{ isGlobalAdmin: gate.user.isGlobalAdmin, orgScope: gate.orgScope },
|
||||
req.nextUrl.searchParams.get('org'),
|
||||
)
|
||||
}
|
||||
|
||||
async function handle(req: NextRequest, segments: string[]): Promise<NextResponse> {
|
||||
// CSRF: a cross-site page carrying the admin's auto-sent cookie must never be able
|
||||
// to create / rotate / delete a KMS secret. Refuse a cross-origin MUTATION before
|
||||
// the admin gate or any body read (safe GET reveals pass). Defense in depth on top
|
||||
// of the session cookie's own SameSite attribute.
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
|
||||
const gate = await getAdminGate(req)
|
||||
if (!gate) return forbidden()
|
||||
if (segments.length !== 1 || segments[0] !== 'secrets') return notFound()
|
||||
|
||||
const org = orgFor(gate, req)
|
||||
const base = `${kmsBaseUrl()}/v1/kms/orgs/${encodeURIComponent(org)}/secrets`
|
||||
const q = req.nextUrl.searchParams
|
||||
const name = q.get('name') ?? ''
|
||||
const path = q.get('path') ?? ''
|
||||
const env = q.get('env') ?? ''
|
||||
|
||||
let target: string
|
||||
let body: string | undefined
|
||||
if (req.method === 'GET') {
|
||||
if (name) {
|
||||
const params = new URLSearchParams()
|
||||
if (env) params.set('env', env)
|
||||
target = `${base}/${secretRest(path, name)}${params.toString() ? `?${params}` : ''}`
|
||||
} else {
|
||||
const params = new URLSearchParams()
|
||||
const prefix = q.get('prefix')
|
||||
if (prefix) params.set('prefix', prefix)
|
||||
if (env) params.set('env', env)
|
||||
target = `${base}${params.toString() ? `?${params}` : ''}`
|
||||
}
|
||||
} else if (req.method === 'POST') {
|
||||
target = base
|
||||
body = await req.text() // {path,name,env,value} — forwarded verbatim, never logged
|
||||
} else if (req.method === 'PATCH') {
|
||||
if (!name) return notFound()
|
||||
target = `${base}/${secretRest(path, name)}`
|
||||
body = await req.text() // {value,version,env} — forwarded verbatim, never logged
|
||||
} else if (req.method === 'DELETE') {
|
||||
if (!name) return notFound()
|
||||
const params = new URLSearchParams()
|
||||
if (env) params.set('env', env)
|
||||
target = `${base}/${secretRest(path, name)}${params.toString() ? `?${params}` : ''}`
|
||||
} else {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
let bearer: string
|
||||
try {
|
||||
bearer = await adminBearer(gate.user)
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ message: `Could not authorize the request: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { Authorization: `Bearer ${bearer}`, Accept: 'application/json' }
|
||||
const init: RequestInit = { method: req.method, headers, cache: 'no-store' }
|
||||
if (body !== undefined) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
init.body = body
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetchWithTimeout(target, init)
|
||||
const text = await res.text()
|
||||
return new NextResponse(text, {
|
||||
status: res.status,
|
||||
headers: { 'Content-Type': res.headers.get('content-type') ?? 'application/json' },
|
||||
})
|
||||
} catch (e) {
|
||||
// Surface only the transport failure — never the request body/value.
|
||||
return NextResponse.json(
|
||||
{ message: `KMS unreachable: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, (await ctx.params).path)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, (await ctx.params).path)
|
||||
}
|
||||
export async function PATCH(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, (await ctx.params).path)
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, (await ctx.params).path)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Keyless AI proxy — the ONE path the console uses to reach the model gateway.
|
||||
*
|
||||
* `/v1/chat/completions` (and friends) REQUIRE an `Authorization: Bearer` token; a
|
||||
* browser session cookie alone is rejected. Rather than ship the user's durable
|
||||
* `hk-` key to the browser, the console calls its OWN origin (`/ai/v1/...`) with just
|
||||
* the session cookie; `forwardWithUserBearer` resolves the user, mints a SHORT-LIVED,
|
||||
* user-bound IAM token (shared per-user cache in identity.ts), and forwards to the
|
||||
* gateway with that token. No key in the browser, no rotation on a chat turn, and
|
||||
* every call is billed to the user's own org. The response STREAMS through, so
|
||||
* `chat/completions` SSE (and the multi-model TTFT measurement) is preserved.
|
||||
*
|
||||
* Least privilege: only the read/inference AI endpoints are proxied (the ALLOWED
|
||||
* allow-list); anything else 404s, so this is not a general gateway tunnel. The RAG
|
||||
* retrieval switch (`X-Retrieval`/`X-Retrieval-Store`) is the ONE client-header
|
||||
* passthrough (allow-listed in `ai-proxy`).
|
||||
*/
|
||||
import { type NextRequest } from 'next/server'
|
||||
|
||||
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
|
||||
import { retrievalHeaders } from '~/lib/server/ai-proxy'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
/** Gateway the proxied AI calls are forwarded to (gated/priced api.hanzo.ai). */
|
||||
const AI_GATEWAY_URL = trim(process.env.AI_GATEWAY_URL ?? 'https://api.hanzo.ai')
|
||||
|
||||
/** The exact `/v1/<...>` endpoints the console is allowed to reach. */
|
||||
const ALLOWED = new Set([
|
||||
'v1/models',
|
||||
'v1/pricing/models', // the rich model+provider catalog (context, pricing, specs, tier) for Models/Providers pages
|
||||
'v1/plans', // the subscription tiers + entitlements (rpm/tpm/quota) for the catalog plan badges
|
||||
'v1/chat',
|
||||
'v1/chat/completions',
|
||||
'v1/embeddings',
|
||||
'v1/rerank',
|
||||
'v1/audio/speech', // text-to-speech (JSON in → audio bytes out) for the Playground Audio tab
|
||||
'v1/images/generations', // text-to-image (JSON in → image url/b64 out) for the Playground Image tab
|
||||
'v1/videos/generations', // text-to-video (JSON in → base64 MP4 out) for the Playground Video tab
|
||||
])
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
function handle(req: NextRequest, ctx: Ctx) {
|
||||
return (async () => {
|
||||
const path = (await ctx.params).path.join('/')
|
||||
return forwardWithUserBearer(req, {
|
||||
target: AI_GATEWAY_URL,
|
||||
path,
|
||||
allow: (p) => ALLOWED.has(p),
|
||||
// Forward the RAG retrieval switch when present; the store's org owner is still
|
||||
// resolved server-side from the session (the bearer), never the browser.
|
||||
extraHeaders: retrievalHeaders((h) => req.headers.get(h)),
|
||||
errorShape: 'openai',
|
||||
unauthorizedMessage: 'Sign in to use AI.',
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
/**
|
||||
* IAM OAuth callback. IAM redirects here with `?code&state`; we exchange them
|
||||
* for a backend session (`/v1/signin`) and land on the dashboard. On failure we
|
||||
* for a backend session (`/v1/iam/signin`) and land on the dashboard. On failure we
|
||||
* surface the error and offer a retry.
|
||||
*/
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
@@ -12,6 +12,7 @@ import { Button, Text, YStack } from '@hanzo/gui'
|
||||
import { ApiError } from '~/lib/api'
|
||||
import { Loader } from '~/components/ui/Loader'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { takeReturnTo } from '~/lib/auth/iam'
|
||||
|
||||
function Callback() {
|
||||
const params = useSearchParams()
|
||||
@@ -27,7 +28,8 @@ function Callback() {
|
||||
return
|
||||
}
|
||||
completeSignIn(code, state)
|
||||
.then(() => router.replace('/'))
|
||||
// Land the user back where a mid-task expiry interrupted them (default home).
|
||||
.then(() => router.replace(takeReturnTo()))
|
||||
.catch((e: unknown) => setError(e instanceof ApiError ? e.message : 'Sign-in failed.'))
|
||||
}, [params, completeSignIn, router])
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* /auth/refresh — silently renew the console session (server-side, BFF).
|
||||
*
|
||||
* The browser calls this with just its httpOnly cookies (no token in the body, none
|
||||
* in the URL). The route reassembles + reads the sealed refresh token, calls IAM with
|
||||
* `grant_type=refresh_token`, and re-seals the ROTATED token set (IAM issues a new
|
||||
* one-time-use refresh token every refresh — we always persist the NEW one). It
|
||||
* returns only the new lifetime, never a token.
|
||||
*
|
||||
* Called two ways, both single-flight on the client (`lib/auth/refresh`): proactively
|
||||
* on a timer at ~80% of the access lifetime, and reactively on a 401 from any cloud/BFF
|
||||
* call. On failure (the refresh token is truly expired/revoked, or a replay of a
|
||||
* rotated one) it 401s WITHOUT clearing the cookies (multi-tab rotating-token race
|
||||
* safety — a lost-race 401 must not nuke another tab's freshly-rotated cookie); the
|
||||
* client then re-reads the session (seeing a winner's fresh cookie if any) or falls
|
||||
* through to graceful re-auth. Only explicit sign-out clears the cookies.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { readRefreshToken, refreshGrant, sealSession, setCookies, SessionError, type CookieDirective } from '~/lib/server/session'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
function withCookies(res: NextResponse, dirs: CookieDirective[]): NextResponse {
|
||||
for (const d of dirs) {
|
||||
res.cookies.set(d.name, d.value, {
|
||||
httpOnly: d.httpOnly,
|
||||
secure: d.secure,
|
||||
sameSite: d.sameSite,
|
||||
path: d.path,
|
||||
maxAge: d.maxAge,
|
||||
})
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
/** 401 WITHOUT clearing the cookies (see the multi-tab note above). */
|
||||
const fail = () => NextResponse.json({ error: 'refresh failed' }, { status: 401 })
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
// CSRF: uniform same-origin gate on every mutating BFF route (the hz_rt cookie is
|
||||
// already SameSite=lax + Path=/auth, so this is belt-and-suspenders).
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
|
||||
const rt = readRefreshToken(req)
|
||||
if (!rt) return fail()
|
||||
|
||||
let tokens
|
||||
try {
|
||||
tokens = await refreshGrant(rt)
|
||||
} catch (e) {
|
||||
// A 502 (endpoint unreachable) is transient — surface it distinctly so the client
|
||||
// can retry, and never touch the cookies.
|
||||
if (e instanceof SessionError && e.status === 502) {
|
||||
return NextResponse.json({ error: 'refresh unavailable' }, { status: 502 })
|
||||
}
|
||||
return fail()
|
||||
}
|
||||
// A rotated set with no new refresh token would strand us next cycle — require it
|
||||
// (fail-closed: never persist a session we cannot refresh again).
|
||||
if (!tokens.refreshToken) return fail()
|
||||
|
||||
const sealed = sealSession(tokens)
|
||||
if (!sealed) return fail()
|
||||
const res = NextResponse.json({ expiresIn: Math.floor(sealed.expiresInMs / 1000) })
|
||||
return withCookies(res, setCookies(sealed.identity, sealed.refresh))
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* /auth/session — the console's OWN durable, refreshable OAuth session (BFF).
|
||||
*
|
||||
* POST establish the console session for the SIGNED-IN user (first-party
|
||||
* confidential-client password grant WITH offline_access → access +
|
||||
* rotating refresh token, sealed into the httpOnly cookies).
|
||||
* GET the current account resolved from that session (what the AuthGate reads
|
||||
* FIRST — durable + silently refreshed, so it survives the casibase
|
||||
* session's own lifetime and never bounces the user mid-task).
|
||||
* DELETE sign out — best-effort revoke the refresh token + clear the cookies.
|
||||
*
|
||||
* SECURITY. POST is GATED: it mints a console session ONLY for a caller who is
|
||||
* ALREADY authenticated (a valid casibase/console session — the full login incl. any
|
||||
* MFA), AND only when the password grant resolves to the SAME principal — so it can
|
||||
* never be driven standalone with a stolen password, and never bypasses MFA (an MFA
|
||||
* account never reaches this call). Tokens live only inside the sealed httpOnly
|
||||
* cookies — never returned to the browser, never logged, never in a URL.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { type Account } from '~/lib/api/types'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
import {
|
||||
clearCookies,
|
||||
consoleSession,
|
||||
passwordGrant,
|
||||
readRefreshToken,
|
||||
revokeRefreshToken,
|
||||
sameSubject,
|
||||
sealSession,
|
||||
sessionConfigured,
|
||||
setCookies,
|
||||
SessionError,
|
||||
type ConsoleClaims,
|
||||
type CookieDirective,
|
||||
} from '~/lib/server/session'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
/** Build the client-facing Account from console claims (display + admin fields only;
|
||||
* never the secret material Casdoor also packs into the token). `isGlobalAdmin` is
|
||||
* carried so the client nav/org gates (`isGlobalAdminAccount`) match the casibase
|
||||
* path; `owner === 'admin'` also implies it. */
|
||||
function accountOf(c: ConsoleClaims): Account {
|
||||
return {
|
||||
owner: c.owner ?? '',
|
||||
name: c.name ?? '',
|
||||
type: c.type,
|
||||
displayName: c.displayName,
|
||||
email: c.email,
|
||||
avatar: c.avatar,
|
||||
isAdmin: c.isAdmin,
|
||||
isGlobalAdmin: c.isGlobalAdmin || c.owner === 'admin',
|
||||
properties: c.properties,
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply cookie directives to a NextResponse. */
|
||||
function withCookies(res: NextResponse, dirs: CookieDirective[]): NextResponse {
|
||||
for (const d of dirs) {
|
||||
res.cookies.set(d.name, d.value, {
|
||||
httpOnly: d.httpOnly,
|
||||
secure: d.secure,
|
||||
sameSite: d.sameSite,
|
||||
path: d.path,
|
||||
maxAge: d.maxAge,
|
||||
})
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
/** GET — the account + remaining access lifetime from the live console session, or
|
||||
* 401 when there is none (the client then falls back to the casibase session). */
|
||||
export async function GET(req: NextRequest): Promise<NextResponse> {
|
||||
const sess = consoleSession(req)
|
||||
if (!sess || !sess.claims.name) {
|
||||
return NextResponse.json({ error: 'no session' }, { status: 401 })
|
||||
}
|
||||
return NextResponse.json({ account: accountOf(sess.claims), expiresIn: sess.expiresInSec })
|
||||
}
|
||||
|
||||
/** POST { username, password } — establish the console session for the signed-in user. */
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
// CSRF: refuse a cross-origin login (login-CSRF fixes the victim into an attacker's
|
||||
// session) before touching credentials.
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
|
||||
if (!sessionConfigured()) {
|
||||
// No confidential client wired: the console still runs on the casibase session;
|
||||
// report "not configured" so the client silently skips the console session.
|
||||
return NextResponse.json({ error: 'session not configured' }, { status: 501 })
|
||||
}
|
||||
|
||||
let body: { username?: unknown; password?: unknown }
|
||||
try {
|
||||
body = (await req.json()) as typeof body
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'bad request' }, { status: 400 })
|
||||
}
|
||||
const username = typeof body.username === 'string' ? body.username.trim() : ''
|
||||
const password = typeof body.password === 'string' ? body.password : ''
|
||||
if (!username || !password) {
|
||||
return NextResponse.json({ error: 'missing credentials' }, { status: 400 })
|
||||
}
|
||||
|
||||
// GATE: the caller must already be authenticated (they just completed the casibase
|
||||
// login incl. any MFA). This binds the console session to a real, MFA-cleared
|
||||
// session and blocks standalone password abuse.
|
||||
const authed = await resolveUser(req)
|
||||
if (!authed) {
|
||||
return NextResponse.json({ error: 'not authenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
let tokens
|
||||
try {
|
||||
tokens = await passwordGrant(username, password)
|
||||
} catch (e) {
|
||||
const status = e instanceof SessionError ? e.status : 502
|
||||
return NextResponse.json({ error: 'grant failed' }, { status })
|
||||
}
|
||||
|
||||
const sealed = sealSession(tokens)
|
||||
// The grant MUST resolve to the same principal as the established session — the
|
||||
// console session is for the already-authenticated user, never a third party.
|
||||
const grantId =
|
||||
sealed && sealed.claims.owner && sealed.claims.name ? `${sealed.claims.owner}/${sealed.claims.name}` : ''
|
||||
if (!sealed || !grantId || !sameSubject(grantId, authed.id)) {
|
||||
return NextResponse.json({ error: 'identity mismatch' }, { status: 401 })
|
||||
}
|
||||
|
||||
const res = NextResponse.json({
|
||||
account: accountOf(sealed.claims),
|
||||
expiresIn: Math.floor(sealed.expiresInMs / 1000),
|
||||
})
|
||||
return withCookies(res, setCookies(sealed.identity, sealed.refresh))
|
||||
}
|
||||
|
||||
/** DELETE — sign out: best-effort revoke the refresh token, then clear the cookies. */
|
||||
export async function DELETE(req: NextRequest): Promise<NextResponse> {
|
||||
// CSRF: refuse a cross-origin forced sign-out.
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
|
||||
const rt = readRefreshToken(req)
|
||||
if (rt) await revokeRefreshToken(rt)
|
||||
return withCookies(NextResponse.json({ ok: true }), clearCookies())
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Email self-serve signup (HIP-0111) — create a brand-new account + its own org.
|
||||
*
|
||||
* This is the ONE unauthenticated BFF route (the caller has no account yet). It
|
||||
* acts as the confidential `hanzo-console` client to mint, in one shot:
|
||||
* 1. a personal organization (owner=`admin`, password/locale cloned from the
|
||||
* brand org so the account hashes with the brand's argon2id policy), and
|
||||
* 2. the user as that org's ADMIN (IAM hashes the password server-side).
|
||||
* The client then signs in with the same credentials and lands as admin — no
|
||||
* separate onboarding step (IAM users always belong to an org, so "create then
|
||||
* onboard" is not possible against casibase; the org is minted here).
|
||||
*
|
||||
* Email uniqueness without a global user-lookup endpoint: the org slug is a
|
||||
* DETERMINISTIC, injective function of the email (`personalOrgFromEmail`), so a
|
||||
* repeat signup with the same email resolves to the same slug and is caught by
|
||||
* `getOrganization` (409) — two different emails never false-collide.
|
||||
*
|
||||
* Honest states: 501 when the IAM client is unwired, 400 on bad input, 409 when
|
||||
* the account already exists, 502 on an IAM failure.
|
||||
*
|
||||
* NOTE (hardening, flagged not done here): this endpoint creates accounts from the
|
||||
* open internet. It validates input but has NO captcha / rate-limit / email-
|
||||
* verification gate yet — those are follow-ups (email verification especially
|
||||
* would add friction the go-live conversion goal explicitly avoids).
|
||||
*/
|
||||
import { createHash } from 'node:crypto'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { brandFromHost } from '~/config'
|
||||
import { BRANDS } from '~/lib/branding/brands'
|
||||
import { createOrganization, createUser, getOrganization, mintConfigured } from '~/lib/server/identity'
|
||||
import {
|
||||
deriveUsername,
|
||||
displayNameFromEmail,
|
||||
personalOrgFromEmail,
|
||||
validateSignup,
|
||||
} from '~/lib/server/onboarding'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
// Same-origin gate: this route creates accounts from the open internet with no
|
||||
// captcha/rate-limit yet, so refuse cross-origin scripted signups (a mild anti-abuse
|
||||
// measure; the console's own signup form is same-origin).
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
|
||||
if (!mintConfigured()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Account creation is not configured on this deployment (IAM client unset).' },
|
||||
{ status: 501 },
|
||||
)
|
||||
}
|
||||
|
||||
const body = (await req.json().catch(() => ({}))) as { email?: string; password?: string }
|
||||
const v = validateSignup(body.email ?? '', body.password ?? '')
|
||||
if (!v.ok) return NextResponse.json({ error: v.error }, { status: 400 })
|
||||
|
||||
const brand = BRANDS[brandFromHost(req.headers.get('host'))]
|
||||
const brandOrg = brand.id // hanzo/lux/zoo/pars — cloned for password/locale policy
|
||||
const signupApplication = `${brand.id}-cloud` // hanzo-cloud, lux-cloud, …
|
||||
|
||||
const digest = createHash('sha256').update(v.email).digest('hex')
|
||||
const orgSlug = personalOrgFromEmail(v.email, digest)
|
||||
|
||||
if (await getOrganization(orgSlug)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'An account with this email already exists. Sign in instead.' },
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
|
||||
const displayName = displayNameFromEmail(v.email)
|
||||
try {
|
||||
await createOrganization({ name: orgSlug, displayName, personal: true, sourceOwner: brandOrg })
|
||||
await createUser({
|
||||
org: orgSlug,
|
||||
username: deriveUsername(v.email),
|
||||
email: v.email,
|
||||
password: v.password,
|
||||
displayName,
|
||||
signupApplication,
|
||||
})
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: `Could not create your account: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, org: orgSlug })
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Per-tenant billing DATA proxy → commerce. The browser calls console2's OWN origin
|
||||
* (`/billing/v1/...`); this server handler forwards to commerce's `/v1/billing/...`,
|
||||
* injecting the commerce SERVICE token from server-only env (never `NEXT_PUBLIC_`,
|
||||
* never in the browser bundle) AND scoping every request to the caller's OWN org.
|
||||
*
|
||||
* Namespaced under `/billing/v1/` (NOT bare `/billing/`) so the data plane never
|
||||
* shadows the billing UI tab URLs (`/billing/reports`, `/billing/invoices`, …): a
|
||||
* route handler always wins over the catch-all page for a matching path segment, so
|
||||
* the tab slugs and the data endpoints must live in disjoint path space. The tab
|
||||
* URLs now fall through to the SPA (`app/(dashboard)/[...slug]`).
|
||||
*
|
||||
* Same trust boundary as the `/admin/iam` + `/admin/kms` proxies, but the authz is
|
||||
* PER-TENANT, not admin: any authenticated session may read/act on ITS OWN billing
|
||||
* (balance / usage / invoices / credit-grants / subscriptions / payment-methods).
|
||||
* The org is resolved server-side from the validated session (`resolveUser`) and
|
||||
* stamped as `X-Org-Id` (the header commerce's service-token path actually reads —
|
||||
* `commerce/middleware/accesstoken.go`), and the server-resolved billing subject is
|
||||
* pinned onto the FULL commerce subject-key set (`user`/`userId`/`customerId`, via
|
||||
* `scopedBillingSearch`) while `?org=` is dropped. The client CANNOT widen scope: a
|
||||
* forged `?userId=`/`?customerId=`/`?org=` is overwritten, and because EVERY subject
|
||||
* param is pinned, no billing endpoint is left unfiltered regardless of which one it
|
||||
* reads (subscriptions filter `userId`, payment-methods `customerId`). So commerce's
|
||||
* per-tenant isolation can never be crossed from the browser. No session → 401.
|
||||
*
|
||||
* Billing-subject mirrors `object.BillingSubject` (hanzoai/ai) + chat's
|
||||
* `billingSubject`: a member of a PERSONAL-billing org (default the shared `hanzo`
|
||||
* catch-all) bills per-user as `<org>/<name>`; a dedicated org (maxpower, …) bills
|
||||
* per-org as `<org>`. The SAME subject the gateway debits — so the console shows
|
||||
* the exact balance/usage that gets charged.
|
||||
*
|
||||
* `COMMERCE_TOKEN` unset → honest 501 (the UI shows a truthful "not configured"
|
||||
* state; it never fabricates a balance).
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
import { billingSubject, scopedBillingSearch, scopedBillingBody } from '~/lib/server/billing-scope'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const isSafeSegment = (s: string): boolean =>
|
||||
s.length > 0 && s !== '.' && s !== '..' && !s.includes('/') && !s.includes('\\') && !s.includes('\0')
|
||||
|
||||
function commerceBaseUrl(): string {
|
||||
return (process.env.COMMERCE_URL ?? 'http://commerce.hanzo.svc:8001').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
async function forward(req: NextRequest, path: string[]): Promise<NextResponse> {
|
||||
// CSRF: a mutating billing write (spend-alert/budget) authenticates from the
|
||||
// auto-sent cookie, so refuse a cross-origin one before any work (safe reads pass).
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
|
||||
// Per-tenant authz: any valid session may see ITS OWN billing (no admin gate).
|
||||
const user = await resolveUser(req)
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Sign in to view billing.' }, { status: 401 })
|
||||
}
|
||||
if (!path.every(isSafeSegment)) {
|
||||
return NextResponse.json({ error: 'Invalid billing path.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const token = process.env.COMMERCE_TOKEN ?? process.env.COMMERCE_SERVICE_TOKEN ?? ''
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Billing is not configured (COMMERCE_TOKEN missing).' },
|
||||
{ status: 501 },
|
||||
)
|
||||
}
|
||||
|
||||
// Scope to the caller's OWN org — server-resolved, never client-supplied.
|
||||
const org = user.owner.trim()
|
||||
const subject = billingSubject(org, user.name)
|
||||
|
||||
// Pin the FULL billing-subject key set to the server-resolved subject, and
|
||||
// strip `org`, so the browser can never read another tenant's ledger. Commerce
|
||||
// filters each endpoint on a DIFFERENT param — subscriptions on `userId`,
|
||||
// payment-methods on `customerId` (or `user`), usage on `user` — so pinning
|
||||
// only ONE param leaves the others unfiltered (a cross-tenant read). This
|
||||
// mirrors commerce's own edge-auth `billingSubjectKeys`
|
||||
// (commerce/middleware/edgeauth.go: {"user","userId","customerId"}) exactly, so
|
||||
// every billing endpoint is scoped no matter which param it reads.
|
||||
const qs = scopedBillingSearch(req.nextUrl.search, subject)
|
||||
const url = `${commerceBaseUrl()}/v1/billing/${path.join('/')}${qs ? `?${qs}` : ''}`
|
||||
|
||||
const init: RequestInit = {
|
||||
method: req.method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
// Commerce resolves the tenant namespace from `X-Org-Id` on the service-token
|
||||
// path (commerce/middleware/accesstoken.go). It does NOT read `X-Hanzo-Org`,
|
||||
// so sending that alone silently falls back to the service org — every tenant
|
||||
// sharing one namespace. Send `X-Org-Id`, matching the `/ai` proxy.
|
||||
'X-Org-Id': org,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
}
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
// Scope the WRITE body to the caller's OWN subject too (not just the query):
|
||||
// commerce reads the subject from the JSON body on writes like create-spend-alert
|
||||
// (`userId`), so pin it server-side — the browser needn't know its subject and a
|
||||
// forged body subject cannot widen scope. Mirrors `scopedBillingSearch`.
|
||||
init.body = scopedBillingBody(await req.text(), subject)
|
||||
}
|
||||
try {
|
||||
const res = await fetchWithTimeout(url, init)
|
||||
const text = await res.text()
|
||||
return new NextResponse(text, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
'Content-Type': res.headers.get('content-type') ?? 'application/json',
|
||||
// A per-tenant money response (balance/usage/invoices) must NEVER be cached
|
||||
// by the browser or any intermediary — otherwise the wallet shows a stale
|
||||
// number after a completion or a top-up. The live-balance store still polls,
|
||||
// but this guarantees each fetch hits commerce, not a cache.
|
||||
'Cache-Control': 'no-store, must-revalidate',
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: `Billing upstream unreachable: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Same-origin user-bearer proxy to the unified cloud-api `/v1/*` — the ONE path the
|
||||
* browser uses to reach the cloud surfaces that authorize on a Bearer JWT.
|
||||
*
|
||||
* The managed data resources (vector/sql/kv/s3/docdb/datastore/search) and the
|
||||
* serverless / prompt / agent surfaces resolve the org from the token's `owner`
|
||||
* claim and 403 a cookie-only call ("X-Org-Id required"). So — exactly like the
|
||||
* `/ai` proxy — the browser calls this OWN-origin route with just its session
|
||||
* cookie; `forwardWithUserBearer` resolves the user, mints a short-lived user-bound
|
||||
* IAM token (shared per-user cache), and forwards to cloud-api with that Bearer. No
|
||||
* credential reaches the browser, org is server-authoritative (never the browser's
|
||||
* claim), and every read/write is billed + scoped to the user's own org.
|
||||
*
|
||||
* Least privilege: only the data + serverless HEADS are reachable (`allowCloudSurface`);
|
||||
* `v1/iam/*`, `v1/admin/*`, etc. 404 here — this is not a general cloud-api tunnel.
|
||||
*/
|
||||
import { type NextRequest } from 'next/server'
|
||||
|
||||
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
|
||||
import { allowCloudSurface } from '~/lib/server/proxy-allow'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
/** The unified cloud backend (hanzoai/cloud). In-cluster ClusterIP — public egress is CF-403'd.
|
||||
* `|| default` (not `??`) so an env accidentally reconciled to an EMPTY string still falls
|
||||
* back to the in-cluster service (a blank CLOUD_API_URL would otherwise break every cloud page). */
|
||||
const CLOUD_API_URL = trim(process.env.CLOUD_API_URL?.trim() || 'http://cloud-api.hanzo.svc.cluster.local:8000')
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
function handle(req: NextRequest, ctx: Ctx) {
|
||||
return (async () => {
|
||||
const path = (await ctx.params).path.join('/')
|
||||
return forwardWithUserBearer(req, {
|
||||
target: CLOUD_API_URL,
|
||||
path,
|
||||
allow: allowCloudSurface,
|
||||
// Org is authoritative (Bearer owner). Do NOT forward the browser-controlled
|
||||
// X-Project-Id/X-Environment sub-scopes — the data/serverless resources are
|
||||
// org-keyed, and forwarding an unvalidated project id is an attack surface
|
||||
// (RED MEDIUM). A project-scoped feature must validate membership first.
|
||||
unauthorizedMessage: 'Sign in to use Hanzo Cloud.',
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function PUT(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function PATCH(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Same-origin user-bearer proxy to commerce (`commerce.hanzo.svc`) — the store /
|
||||
* merchant admin surface (products / orders / customers / collections / variants /
|
||||
* discounts / store settings). The browser calls this OWN-origin route
|
||||
* (`/commerce/v1/...`) with just its session cookie; `forwardWithUserBearer` resolves
|
||||
* the user, mints a short-lived user-bound IAM token, and forwards to commerce with
|
||||
* that Bearer. Commerce's EdgeAuth validates the JWT and resolves the org from its
|
||||
* `owner` claim (`middleware.TokenRequired` fast-paths IAM auth), so the store is
|
||||
* org-scoped SERVER-SIDE — a merchant only ever sees their OWN org's catalog/orders/
|
||||
* customers. No token reaches the browser, and the org is never browser-supplied.
|
||||
*
|
||||
* This is the TENANT store surface (any signed-in org member acts on their own org's
|
||||
* store), so it is user-scoped (`resolveUser`), NOT the `/paas` god-mode service-token
|
||||
* path. It is also DISTINCT from the `/billing` proxy: money (balance/usage/invoices/
|
||||
* Square) stays on `/billing` with its own per-tenant subject scoping — this proxy
|
||||
* carries only the store catalog/orders/customers. Least privilege on the path:
|
||||
* `allowCommerceSurface` admits only the merchant REST heads (product/order/user/…),
|
||||
* so `/v1/billing`, `/v1/checkout`, `/_/commerce/tenants` are NOT reachable here.
|
||||
*/
|
||||
import { type NextRequest } from 'next/server'
|
||||
|
||||
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
|
||||
import { allowCommerceSurface } from '~/lib/server/proxy-allow'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
/** Commerce API (commerce.hanzo.ai). In-cluster ClusterIP on :8001; the CR already
|
||||
* wires `COMMERCE_URL` (public egress is CF-gated). Override per-deploy with COMMERCE_URL. */
|
||||
const COMMERCE_URL = trim(process.env.COMMERCE_URL ?? 'http://commerce.hanzo.svc:8001')
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
function handle(req: NextRequest, ctx: Ctx) {
|
||||
return (async () => {
|
||||
const path = (await ctx.params).path.join('/')
|
||||
return forwardWithUserBearer(req, {
|
||||
target: COMMERCE_URL,
|
||||
path,
|
||||
allow: allowCommerceSurface,
|
||||
// Org is authoritative (Bearer owner). Do NOT forward browser X-Project-Id/
|
||||
// X-Environment — the store is org-keyed and commerce re-scopes on the token.
|
||||
unauthorizedMessage: 'Sign in to manage your store.',
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function PUT(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function PATCH(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* `/docs` → the brand documentation site (docs.hanzo.ai / docs.lux.network / …),
|
||||
* resolved CLIENT-side (task #41, "True 1-binary FE").
|
||||
*
|
||||
* Docs are an EXTERNAL product on their own domain, never an in-app route — so a
|
||||
* typed or bookmarked `<console-host>/docs` must land on the real docs, not the
|
||||
* catch-all not-found. The old app/docs/route.ts issued a server 308; in the
|
||||
* one-binary there is no Next runtime (the static export has no server, and a static
|
||||
* export cannot rewrite), so the redirect is resolved from the per-host brand
|
||||
* (`config.docsUrl`) in the browser — exactly what the sidebar "Docs" link and the
|
||||
* header "?" already open. One way, both topologies (embed + standalone).
|
||||
*
|
||||
* The target is set in an effect (not during render) so there is no SSR/CSR
|
||||
* hydration mismatch on the per-brand host between the build-time default and the
|
||||
* real browser host.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { config } from '~/config'
|
||||
|
||||
export default function DocsRedirect() {
|
||||
const [url, setUrl] = useState('')
|
||||
useEffect(() => {
|
||||
const target = config.docsUrl
|
||||
setUrl(target)
|
||||
window.location.replace(target)
|
||||
}, [])
|
||||
return (
|
||||
<main style={{ padding: 24, fontFamily: 'system-ui, sans-serif' }}>
|
||||
Opening documentation… {url ? <a href={url}>Continue</a> : null}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
+324
-3
@@ -1,3 +1,26 @@
|
||||
/* Geist Mono — canonical Hanzo mono face (code/data). */
|
||||
@import url('https://cdn.jsdelivr.net/npm/geist@1.3.1/dist/fonts/geist-mono/style.css');
|
||||
|
||||
/* Basel Grotesk — canonical Hanzo UI/body/display/heading face (self-hosted). */
|
||||
@font-face {
|
||||
font-family: 'Basel';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src:
|
||||
url('/fonts/Basel-Grotesk-Book.woff2') format('woff2'),
|
||||
url('/fonts/Basel-Grotesk-Book.woff') format('woff');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Basel';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src:
|
||||
url('/fonts/Basel-Grotesk-Medium.woff2') format('woff2'),
|
||||
url('/fonts/Basel-Grotesk-Medium.woff') format('woff');
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#__next {
|
||||
@@ -6,12 +29,310 @@ body,
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: var(--background, #070b13);
|
||||
color: var(--color, #f2f2f2);
|
||||
background-color: var(--background, #000000);
|
||||
color: var(--color, #ededf1);
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
'Basel', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
/* Calm type rendering — crisp, low-glare, comfortable rhythm for a full workday. */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
code,
|
||||
pre,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
/* Tabular numerals — metrics, prices, contexts and IDs align on a fixed advance
|
||||
width so columns of numbers read cleanly (the dashboard-grade detail). */
|
||||
.hz-tnum {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-feature-settings: 'tnum' 1;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ── Console dark theme — TRUE-BLACK canvas + calm text/borders. The ONE place the
|
||||
console's dark/light palette is set. Overrides the generated @hanzo/gui (Tamagui)
|
||||
theme variables; the `html:root.t_*` selector is one step more specific than the
|
||||
library's runtime `:root.t_*` block so it wins regardless of stylesheet insertion
|
||||
order. DRY: every surface, border and text color in the app reads from these
|
||||
tokens, so this one block sets the whole product.
|
||||
|
||||
Design intent (dark): a TRUE-BLACK #000 canvas (matches hanzo.ai marketing +
|
||||
hanzo.chat OLED) with a Linear/Vercel-caliber surface-depth ladder above it —
|
||||
resting panels #050505, the next surface #0a0a0a, interactive/elevated #171717 →
|
||||
#1f1f1f — so cards read with real depth, never flat voids. Above the surface
|
||||
ladder the CALM neutral scale (color5–12) gives premium, low-glare off-white text
|
||||
and quiet hairline borders (never harsh pure #fff on pure #000). Neutral grey,
|
||||
monochrome-first; text contrast stays WCAG-AA+ on every surface. */
|
||||
html:root.t_dark {
|
||||
--background: #000000;
|
||||
--backgroundStrong: #000000;
|
||||
--backgroundHover: #171717;
|
||||
--backgroundPress: #050505;
|
||||
--backgroundFocus: #171717;
|
||||
|
||||
/* Surface depth ladder over the true-black canvas — panels/cards step up from
|
||||
#050505 so they separate cleanly without heavy borders (Linear-grade depth). */
|
||||
--color1: #050505;
|
||||
--color2: #0a0a0a;
|
||||
--color3: #171717;
|
||||
--color4: #1f1f1f;
|
||||
--color5: hsl(220 6% 16%);
|
||||
--color6: hsl(220 6% 22%);
|
||||
--color7: hsl(220 6% 30%);
|
||||
--color8: hsl(219 6% 42%);
|
||||
--color9: hsl(220 6% 55%);
|
||||
--color10: hsl(219 7% 68%);
|
||||
--color11: hsl(214 9% 83%);
|
||||
--color12: hsl(210 12% 95%);
|
||||
--color: hsl(210 12% 95%);
|
||||
|
||||
/* Gentle hairlines — present enough to define, quiet enough to disappear on black. */
|
||||
--borderColor: hsl(220 8% 15%);
|
||||
--borderColorHover: hsl(220 7% 23%);
|
||||
--borderColorPress: hsl(220 8% 13%);
|
||||
--borderColorFocus: hsl(220 7% 23%);
|
||||
}
|
||||
|
||||
/* Light theme — the calm parallel: a warm off-white base (not stark #fff), soft
|
||||
ink text (not pure black), and quiet hairlines. Lighter touch than dark, since
|
||||
the console defaults to dark, but kept consistent for the theme toggle. */
|
||||
html:root.t_light {
|
||||
--background: hsl(220 20% 99%);
|
||||
--color1: hsl(220 24% 100%);
|
||||
--color2: hsl(220 20% 98%);
|
||||
--color3: hsl(220 18% 95.5%);
|
||||
--color4: hsl(220 16% 92.5%);
|
||||
--color5: hsl(220 15% 89%);
|
||||
--color9: hsl(220 9% 46%);
|
||||
--color10: hsl(220 10% 38%);
|
||||
--color11: hsl(220 14% 22%);
|
||||
--color12: hsl(220 22% 12%);
|
||||
--color: hsl(220 22% 12%);
|
||||
--borderColor: hsl(220 16% 90%);
|
||||
--borderColorHover: hsl(220 14% 82%);
|
||||
}
|
||||
|
||||
/* Motion — a single fade-up entrance (matches the hanzo.ai marketing feel:
|
||||
~0.4s ease-out, small upward travel, staggered by the consumer). One place
|
||||
defines it; <FadeIn> applies the class + per-item delay. */
|
||||
@keyframes hz-fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.hz-fade-up {
|
||||
animation: hz-fade-up 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
/* Honor the user's reduced-motion preference — no entrance animation. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hz-fade-up {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shell chrome motion — the sidebar collapse (width) and the Linear-style
|
||||
two-level nav slide (transform). One place defines the easing; the shell
|
||||
applies the class. `className` forwards to the underlying DOM node on web, so
|
||||
the browser transitions the Gui-driven inline width/transform. */
|
||||
.hz-collapse {
|
||||
transition: width 220ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
will-change: width;
|
||||
}
|
||||
|
||||
.hz-slide {
|
||||
transition: transform 260ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Backdrop cross-fade behind a SlideOver / dialog. */
|
||||
.hz-fade {
|
||||
transition: opacity 240ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
/* Drag-to-reorder — a pinned row while it is being dragged (pointer DnD). The
|
||||
lifted row gets a subtle lift; siblings ease into place via `.hz-slide`. */
|
||||
.hz-drag-item {
|
||||
touch-action: none;
|
||||
transition: transform 180ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.hz-drag-item[data-dragging='true'] {
|
||||
transition: none;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
|
||||
opacity: 0.96;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hz-collapse,
|
||||
.hz-slide,
|
||||
.hz-fade,
|
||||
.hz-drag-item {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sidebar category accordion — a collapsible level-1 section. The body animates
|
||||
its HEIGHT via grid-template-rows 0fr↔1fr (no magic max-height — the row
|
||||
resolves to the real content height) plus a short opacity fade; the header
|
||||
chevron rotates ▸→▾. Collapsed content stays in the DOM (so both directions
|
||||
animate) but is `inert` (out of tab order + a11y tree). One place defines the
|
||||
easing; the shell toggles `data-open`. */
|
||||
.hz-acc {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 220ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.hz-acc[data-open='true'] {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
.hz-acc-inner {
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 180ms ease;
|
||||
}
|
||||
.hz-acc[data-open='true'] .hz-acc-inner {
|
||||
opacity: 1;
|
||||
}
|
||||
.hz-chevron {
|
||||
transition: transform 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hz-acc,
|
||||
.hz-acc-inner,
|
||||
.hz-chevron {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* LivingOverview motion — the "videogame-like" living dashboard. The count-up +
|
||||
live sparkline are driven in JS (rAF, gated by prefers-reduced-motion in the
|
||||
hooks); these are the pure-CSS bits: a loading shimmer, a live-feed pulse, and
|
||||
a brief highlight when a tile's number changes. One place defines the easing. */
|
||||
|
||||
/* Skeleton shimmer — an honest "loading", never fabricated content. */
|
||||
@keyframes hz-shimmer {
|
||||
0% {
|
||||
background-position: -160px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 160px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.hz-skeleton {
|
||||
background-color: var(--color3, rgba(148, 163, 184, 0.14));
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
var(--color4, rgba(148, 163, 184, 0.22)) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 160px 100%;
|
||||
background-repeat: no-repeat;
|
||||
animation: hz-shimmer 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Live-feed pulse — the "Live" dot on a streaming panel. */
|
||||
@keyframes hz-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
transform: scale(0.82);
|
||||
}
|
||||
}
|
||||
|
||||
/* Entrance for a freshly-arrived activity row (staggerless — one row at a time). */
|
||||
@keyframes hz-row-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.hz-row-in {
|
||||
animation: hz-row-in 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hz-skeleton {
|
||||
animation: none;
|
||||
}
|
||||
.hz-row-in {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* RailwayDeploy — the deployment pipeline. A smooth flowing gradient marches along the
|
||||
active leg of the track (stroke-dashoffset), a soft halo pulses out from the current
|
||||
station, and the status dot breathes. All reduced-motion-guarded (→ static). One place
|
||||
defines the easing; RailwayDeploy applies the classes. */
|
||||
@keyframes hz-rail-flow {
|
||||
to {
|
||||
stroke-dashoffset: -28;
|
||||
}
|
||||
}
|
||||
.hz-rail-flow {
|
||||
stroke-dasharray: 5 9;
|
||||
animation: hz-rail-flow 0.85s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes hz-rail-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.34;
|
||||
}
|
||||
70% {
|
||||
transform: scale(2.1);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2.1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.hz-rail-pulse {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: hz-rail-pulse 1.7s ease-out infinite;
|
||||
}
|
||||
|
||||
.hz-rail-dot {
|
||||
animation: hz-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hz-rail-flow,
|
||||
.hz-rail-pulse,
|
||||
.hz-rail-dot {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
+17
-5
@@ -3,13 +3,24 @@ import './globals.css'
|
||||
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import type { ReactNode } from 'react'
|
||||
import { headers } from 'next/headers'
|
||||
|
||||
import { Provider } from '~/components/Provider'
|
||||
import { branding } from '~/config'
|
||||
import { ChunkGuard } from '~/components/ChunkGuard'
|
||||
import { resolveConfig } from '~/config'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: branding.name,
|
||||
description: 'Unified admin console for Hanzo Cloud and all cloud products.',
|
||||
// The document <title> is SSR metadata, so it must reflect the REQUEST host's
|
||||
// brand (console.lux.cloud -> "Lux Cloud Console"), not the build-time default.
|
||||
// The visible shell resolves the brand client-side from window.location, but the
|
||||
// tab title is server-rendered — without reading the Host header here the browser
|
||||
// tab leaks "Hanzo Cloud Console" on Lux/Zoo hosts, a white-label violation.
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const host = (await headers()).get('host') ?? undefined
|
||||
const { brandName } = resolveConfig(host)
|
||||
return {
|
||||
title: `${brandName} Console`,
|
||||
description: 'Unified admin console for Hanzo Cloud and all cloud products.',
|
||||
}
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
@@ -18,8 +29,9 @@ export const viewport: Viewport = {
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className="t_dark" style={{ backgroundColor: '#070b13', colorScheme: 'dark' }} suppressHydrationWarning>
|
||||
<html lang="en" className="t_dark" style={{ backgroundColor: '#000000', colorScheme: 'dark' }} suppressHydrationWarning>
|
||||
<body style={{ margin: 0 }}>
|
||||
<ChunkGuard />
|
||||
<Provider>{children}</Provider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Per-user proxy to the REAL luxd node RPC — the Nodes module's ONE transport.
|
||||
* The browser calls console2's OWN origin (`/nodes/v1/inventory`) with just the
|
||||
* session cookie; this handler resolves the caller, resolves the BRAND from the
|
||||
* request host, and fetches the allowlisted luxd RPC methods server-side for each
|
||||
* network that brand may see, returning NORMALIZED per-node rows. No RPC host or
|
||||
* method ever reaches the browser, and the browser can never choose either.
|
||||
*
|
||||
* Security (mirrors app/bootnode/[...path]/route.ts):
|
||||
* - Session-gated: an unauthenticated caller gets 401 (the RPC data is public,
|
||||
* but the console surface is authenticated, same as every other module).
|
||||
* - Org/brand-aware: the network set is scoped by `nodeNetworksForBrand(brand)`,
|
||||
* brand resolved from the host — so cloud.lux.cloud sees only Lux networks,
|
||||
* console.hanzo.ai (hanzo) sees all.
|
||||
* - Least privilege: the ONLY path is `v1/inventory`, and the ONLY luxd methods
|
||||
* called are the four read methods below — this is not a general RPC tunnel.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { brandFromHost } from '~/config'
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
import { nodeNetworksForBrand, type NodeNetworkId } from '~/lib/products/brand-scope'
|
||||
import {
|
||||
NODE_NETWORK_META,
|
||||
combineInventory,
|
||||
parseHeight,
|
||||
type NetworkInventory,
|
||||
type RawPeer,
|
||||
type RawValidator,
|
||||
} from '~/lib/api/nodes'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
|
||||
/**
|
||||
* Public luxd RPC host per network — the ONLY endpoints this proxy will call.
|
||||
* These are PUBLIC RPC hosts (not secrets); each is overridable per-deploy so a
|
||||
* network can be repointed or disabled without a code change. A host that is
|
||||
* unset/unreachable yields an honest `not-reporting` network — never fake rows.
|
||||
*/
|
||||
const HOSTS: Record<NodeNetworkId, string> = {
|
||||
'lux-mainnet': trim(process.env.LUX_MAINNET_RPC ?? 'https://api.lux.network'),
|
||||
'lux-testnet': trim(process.env.LUX_TESTNET_RPC ?? 'https://api.lux-test.network'),
|
||||
'lux-devnet': trim(process.env.LUX_DEVNET_RPC ?? 'https://api.lux-dev.network'),
|
||||
'pars-mainnet': trim(process.env.PARS_MAINNET_RPC ?? 'https://api.pars.network'),
|
||||
// Zoo has no confirmed public primary-network host yet; the default is the
|
||||
// conventional host (api.<brand>.network) and reports honestly when unreachable.
|
||||
'zoo-mainnet': trim(process.env.ZOO_MAINNET_RPC ?? 'https://api.zoo.network'),
|
||||
}
|
||||
|
||||
/** Per-network probe timeout (ms). */
|
||||
const TIMEOUT_MS = Number(process.env.NODES_RPC_TIMEOUT_MS ?? 8000)
|
||||
|
||||
/** A single allowlisted luxd JSON-RPC call. `path` and `method` are fixed here. */
|
||||
async function rpc<T>(
|
||||
host: string,
|
||||
path: '/ext/bc/P' | '/ext/info',
|
||||
method: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${host}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method }),
|
||||
cache: 'no-store',
|
||||
signal,
|
||||
})
|
||||
if (!res.ok) throw new Error(`${method} ${res.status}`)
|
||||
const json = (await res.json()) as { result?: T; error?: { message?: string } }
|
||||
if (json?.error) throw new Error(json.error.message ?? `${method} error`)
|
||||
return json.result as T
|
||||
}
|
||||
|
||||
/** Probe ONE network: validators + peers + version + height, normalized. */
|
||||
async function probe(net: NodeNetworkId): Promise<NetworkInventory> {
|
||||
const meta = NODE_NETWORK_META[net]
|
||||
const host = HOSTS[net]
|
||||
const base: NetworkInventory = {
|
||||
id: net,
|
||||
chain: meta.chain,
|
||||
env: meta.env,
|
||||
label: meta.label,
|
||||
status: 'not-reporting',
|
||||
validators: 0,
|
||||
peers: 0,
|
||||
nodes: [],
|
||||
}
|
||||
|
||||
const ctrl = new AbortController()
|
||||
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS)
|
||||
try {
|
||||
const [valR, peerR, verR, hgtR] = await Promise.allSettled([
|
||||
rpc<{ validators?: RawValidator[] }>(host, '/ext/bc/P', 'platform.getCurrentValidators', ctrl.signal),
|
||||
rpc<{ numPeers?: string; peers?: RawPeer[] }>(host, '/ext/info', 'info.peers', ctrl.signal),
|
||||
rpc<{ version?: string }>(host, '/ext/info', 'info.getNodeVersion', ctrl.signal),
|
||||
rpc<{ height?: string }>(host, '/ext/bc/P', 'platform.getHeight', ctrl.signal),
|
||||
])
|
||||
|
||||
const reachable = valR.status === 'fulfilled' || peerR.status === 'fulfilled'
|
||||
if (!reachable) {
|
||||
const reason = valR.status === 'rejected' ? valR.reason : peerR.status === 'rejected' ? peerR.reason : null
|
||||
base.error = reason instanceof Error ? reason.message : 'unreachable'
|
||||
return base
|
||||
}
|
||||
|
||||
const validators = valR.status === 'fulfilled' ? valR.value?.validators : undefined
|
||||
const peers = peerR.status === 'fulfilled' ? peerR.value?.peers : undefined
|
||||
const nodes = combineInventory(validators, peers, net)
|
||||
|
||||
base.status = 'reporting'
|
||||
base.nodes = nodes
|
||||
base.validators = nodes.filter((n) => n.role === 'validator').length
|
||||
base.peers = nodes.filter((n) => n.role === 'peer').length
|
||||
if (verR.status === 'fulfilled') base.version = verR.value?.version
|
||||
if (hgtR.status === 'fulfilled') base.height = parseHeight(hgtR.value?.height)
|
||||
return base
|
||||
} catch (e) {
|
||||
base.error = e instanceof Error ? e.message : String(e)
|
||||
return base
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
async function forward(req: NextRequest, path: string[]): Promise<NextResponse> {
|
||||
// ONE endpoint — the inventory. No arbitrary RPC pass-through.
|
||||
if (path.join('/') !== 'v1/inventory') {
|
||||
return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const user = await resolveUser(req)
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Sign in to view node infrastructure.' }, { status: 401 })
|
||||
}
|
||||
|
||||
const brand = brandFromHost(req.headers.get('host'))
|
||||
let networks = nodeNetworksForBrand(brand)
|
||||
|
||||
// Optional single-network scope, still gated by the brand's allowed set.
|
||||
const only = req.nextUrl.searchParams.get('network') as NodeNetworkId | null
|
||||
if (only) networks = networks.filter((n) => n === only)
|
||||
|
||||
const inventory = await Promise.all(networks.map(probe))
|
||||
return NextResponse.json({ brand, networks: inventory })
|
||||
}
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Server-gated SELF-SERVICE org member proxy — a CUSTOMER managing their OWN org.
|
||||
*
|
||||
* The `/admin/iam` proxy is GLOBAL-admin only, so a tenant org owner (e.g.
|
||||
* Dave/maxpower) could never manage their own members through it. This proxy
|
||||
* closes that: it admits ANY authenticated user with an org (`getOrgGate`), then
|
||||
* the shared `forwardIam`:
|
||||
* - scopes every reference (query `owner`, `id` owner, and the mutation BODY
|
||||
* owner) to the caller's OWN org — a global admin may cross, a customer never;
|
||||
* - guards get-organization by org NAME (no reading another org's settings);
|
||||
* - requires an ORG ADMIN for writes (invite / change-role / remove), while any
|
||||
* member may READ the roster.
|
||||
* IAM enforces its own checks on the user-bound bearer too — this is the matching,
|
||||
* fail-closed server gate, not the only one.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getOrgGate } from '~/lib/server/identity'
|
||||
import { forwardIam } from '~/lib/server/iam-proxy'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
/** Reads — any member of the org (own org only, unless global). */
|
||||
const GET_SEGMENTS = new Set([
|
||||
'get-users',
|
||||
'get-user',
|
||||
'get-roles',
|
||||
'get-organization',
|
||||
// Projects live under the org (IAM-served); the console host's /v1 sends /v1/iam/*
|
||||
// to the cloud binary → 404, so the Projects page routes here (Bearer → IAM).
|
||||
'get-organization-projects',
|
||||
])
|
||||
|
||||
/** Writes — org admin only, own org only (unless global). */
|
||||
const POST_SEGMENTS = new Set([
|
||||
'add-user',
|
||||
'update-user',
|
||||
'delete-user',
|
||||
// Org branding/settings — org-admin only, pinned to the caller's OWN org by both
|
||||
// the `?id` name AND the body name (below), so a brand admin can't retarget another.
|
||||
'update-organization',
|
||||
// Project CRUD — org-admin only (requireAdminForWrite), pinned to the caller's org.
|
||||
'add-project',
|
||||
'delete-project',
|
||||
])
|
||||
|
||||
/** Org objects are owned by the `admin` metadata org (name guarded separately). */
|
||||
const ORG_META = new Set(['get-organization', 'update-organization'])
|
||||
/** Segments carrying an org NAME to pin to the caller's scope (read id + write body). */
|
||||
const ORG_NAME = new Set(['get-organization', 'update-organization'])
|
||||
/** Segments keyed by `organization` (projects) — pin it to the caller's own org so an
|
||||
* omitted/empty organization can't enumerate/pollute across tenants. */
|
||||
const ORG_PARAM = new Set(['get-organization-projects', 'add-project', 'delete-project'])
|
||||
|
||||
const forbidden = () => NextResponse.json({ error: 'forbidden' }, { status: 403 })
|
||||
|
||||
async function handle(req: NextRequest, path: string[], method: 'GET' | 'POST'): Promise<NextResponse> {
|
||||
const gate = await getOrgGate(req)
|
||||
if (!gate) return forbidden()
|
||||
return forwardIam(req, gate, {
|
||||
segment: path.join('/'),
|
||||
method,
|
||||
allowed: method === 'GET' ? GET_SEGMENTS : POST_SEGMENTS,
|
||||
orgMetaSegments: ORG_META,
|
||||
orgNameSegments: ORG_NAME,
|
||||
orgParamSegments: ORG_PARAM,
|
||||
requireAdminForWrite: true,
|
||||
})
|
||||
}
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, (await ctx.params).path, 'GET')
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, (await ctx.params).path, 'POST')
|
||||
}
|
||||
@@ -5,21 +5,67 @@
|
||||
* from server-only env (sourced via KMS — never `NEXT_PUBLIC_`, never in the
|
||||
* browser bundle). This is the real control-plane API, not an iframe stub.
|
||||
*
|
||||
* SECURITY: the forwarded token is a PLATFORM SERVICE token — full control-plane
|
||||
* authority, NOT tenant-scoped. So this route is gated to brand admins exactly
|
||||
* like the IAM/KMS admin proxies: `getAdminGate` resolves the caller from their
|
||||
* own session and requires a verified brand-admin (no gate → 403). Without this,
|
||||
* any authenticated browser could drive the whole control plane through the
|
||||
* service token. The gate is the control, NOT a deploy-time env toggle.
|
||||
*
|
||||
* When `PAAS_SERVICE_TOKEN` is unset the proxy returns an honest 501 so the UI
|
||||
* can show a truthful "not configured" state — it never fabricates apps/deploys.
|
||||
*
|
||||
* SCOPE: the browser stamps the active tenant path (X-Org-Id / X-Project-Id /
|
||||
* X-Environment) on every call. We forward it to the control plane so PaaS
|
||||
* resources scope by org → project → environment like the rest of the console —
|
||||
* but the ORG is re-resolved server-side through the admin policy (`orgFor`): a
|
||||
* global admin's switched org is honored, a brand admin is PINNED to their own,
|
||||
* so the forwarded X-Org-Id is authoritative and never the spoofable claim.
|
||||
* Project + environment are sub-scopes the admin picks WITHIN that org, passed
|
||||
* through verbatim.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAdminGate } from '~/lib/server/identity'
|
||||
import { orgFor as policyOrgFor } from '~/lib/server/admin-policy'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const PLATFORM_URL = (process.env.PLATFORM_URL ?? 'https://platform.hanzo.ai').replace(/\/+$/, '')
|
||||
const TOKEN = process.env.PAAS_SERVICE_TOKEN ?? ''
|
||||
|
||||
async function forward(req: NextRequest, path: string[]): Promise<NextResponse> {
|
||||
// CSRF FIRST — the service token below is control-plane god-mode, so a cross-site
|
||||
// page carrying the admin's auto-sent cookie must never drive a deploy/scale/delete.
|
||||
// Refuse a cross-origin MUTATION before the admin gate or any body read (safe reads
|
||||
// pass). Defense in depth on top of the session cookie's own SameSite attribute.
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
|
||||
// Brand-admin gate — the service token below is control-plane god-mode.
|
||||
const gate = await getAdminGate(req)
|
||||
if (!gate) {
|
||||
return NextResponse.json({ error: 'forbidden' }, { status: 403 })
|
||||
}
|
||||
if (!TOKEN) {
|
||||
return NextResponse.json(
|
||||
{ error: 'PaaS control plane is not configured (PAAS_SERVICE_TOKEN missing).' },
|
||||
{ status: 501 },
|
||||
)
|
||||
}
|
||||
// Resolve the authoritative tenant path. Org: the admin policy honors a global
|
||||
// admin's switched org (the X-Org-Id the browser sends = currentOrg()) and pins
|
||||
// a brand admin to their own — so we forward the resolved org, never the raw
|
||||
// claim. Project + environment are sub-scopes within that org, forwarded as-is.
|
||||
const org = policyOrgFor(
|
||||
{ isGlobalAdmin: gate.user.isGlobalAdmin, orgScope: gate.orgScope },
|
||||
req.headers.get('X-Org-Id'),
|
||||
)
|
||||
const projectId = req.headers.get('X-Project-Id')
|
||||
const environment = req.headers.get('X-Environment')
|
||||
|
||||
const search = req.nextUrl.search
|
||||
const url = `${PLATFORM_URL}/v1/${path.join('/')}${search}`
|
||||
const init: RequestInit = {
|
||||
@@ -28,6 +74,9 @@ async function forward(req: NextRequest, path: string[]): Promise<NextResponse>
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-Org-Id': org,
|
||||
...(projectId ? { 'X-Project-Id': projectId } : {}),
|
||||
...(environment ? { 'X-Environment': environment } : {}),
|
||||
},
|
||||
// Never cache control-plane reads.
|
||||
cache: 'no-store',
|
||||
@@ -36,7 +85,7 @@ async function forward(req: NextRequest, path: string[]): Promise<NextResponse>
|
||||
init.body = await req.text()
|
||||
}
|
||||
try {
|
||||
const res = await fetch(url, init)
|
||||
const res = await fetchWithTimeout(url, init)
|
||||
const text = await res.text()
|
||||
return new NextResponse(text, {
|
||||
status: res.status,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Design-reference route — renders the ProductLanding kit + the RailwayDeploy pipeline
|
||||
* in its lifecycle states OFFLINE (static status props, no backend, no auth), so the
|
||||
* landing/pipeline design can be reviewed and screenshotted from `next dev` without a
|
||||
* live session. Data-free by construction; not linked from the product nav.
|
||||
*/
|
||||
import { Boxes, DollarSign, FileText, Gauge, Layers, Plus, Search, Sparkles } from '@hanzogui/lucide-icons-2'
|
||||
import { Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
|
||||
import { ProductLanding, apiBaseFromDocs, type LandingMetric, type ProductLandingConfig } from '~/components/products/landing'
|
||||
import { RailwayDeploy } from '~/components/products/paas/RailwayDeploy'
|
||||
import { embeddingsCodeSamples } from '~/components/products/embeddings/logic'
|
||||
|
||||
const metrics: LandingMetric[] = [
|
||||
{ key: 'collections', label: 'Collections', value: 12, format: (n) => Math.round(n).toLocaleString(), icon: <Boxes size={14} opacity={0.6} /> },
|
||||
{ key: 'documents', label: 'Documents indexed', value: 3420, format: (n) => Math.round(n).toLocaleString(), icon: <FileText size={14} opacity={0.6} /> },
|
||||
{ key: 'vectors', label: 'Total vectors', value: 184213, format: (n) => Math.round(n).toLocaleString(), series: [120, 138, 150, 171, 184], deltaPct: 12, icon: <Layers size={14} opacity={0.6} /> },
|
||||
{ key: 'queries', label: 'Queries (7D)', value: 8241, format: (n) => Math.round(n).toLocaleString(), series: [900, 1100, 1050, 1300, 1450], deltaPct: 8, icon: <Search size={14} opacity={0.6} /> },
|
||||
{ key: 'latency', label: 'Avg latency', value: 42, format: (n) => `${Math.round(n)} ms`, series: [55, 50, 47, 44, 42], deltaPct: -6, icon: <Gauge size={14} opacity={0.6} /> },
|
||||
{ key: 'cost', label: 'Cost (7D)', value: null, format: (n) => `$${(n / 100).toFixed(2)}`, icon: <DollarSign size={14} opacity={0.6} />, hint: 'Awaiting metering' },
|
||||
]
|
||||
|
||||
const landingConfig: ProductLandingConfig = {
|
||||
productId: 'embeddings',
|
||||
title: 'Vector embeddings & semantic search',
|
||||
tagline: 'Generate, store, and search embeddings at scale — one API for semantic search and RAG, powered by Zen embedding models.',
|
||||
icon: Boxes,
|
||||
docsProduct: 'embeddings',
|
||||
primary: { label: 'Create collection', icon: <Plus size={16} />, onPress: () => {} },
|
||||
secondary: { label: 'Try search', icon: <Search size={15} />, onPress: () => {} },
|
||||
metrics,
|
||||
samples: embeddingsCodeSamples(apiBaseFromDocs('https://docs.hanzo.ai'), 'zen-embedding'),
|
||||
run: { label: 'Generate in console', icon: <Sparkles size={14} />, onPress: () => {} },
|
||||
actions: [
|
||||
{ label: 'Create collection', icon: <Plus size={15} />, onPress: () => {} },
|
||||
{ label: 'Explore search', icon: <Search size={15} />, onPress: () => {} },
|
||||
{ label: 'Generate embeddings', icon: <Sparkles size={15} />, onPress: () => {} },
|
||||
],
|
||||
}
|
||||
|
||||
function RailCard({ title, status }: { title: string; status: string }) {
|
||||
return (
|
||||
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor" bg="$color2" flex={1} minW={320}>
|
||||
<Text fontSize="$3" fontWeight="700" color="$color12">
|
||||
{title}
|
||||
</Text>
|
||||
<RailwayDeploy status={status} />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RailwayDemoPage() {
|
||||
return (
|
||||
<YStack gap="$6" p="$5" maxW={1180} self="center" width="100%">
|
||||
<Text fontSize="$9" fontWeight="900">RailwayDeploy pipeline</Text>
|
||||
<XStack gap="$4" flexWrap="wrap">
|
||||
<RailCard title="Building (in progress)" status="building" />
|
||||
<RailCard title="Deploying (in progress)" status="deploying" />
|
||||
<RailCard title="Live" status="live" />
|
||||
<RailCard title="Failed" status="error" />
|
||||
</XStack>
|
||||
|
||||
<Text fontSize="$9" fontWeight="900">Embeddings landing (ProductLanding kit)</Text>
|
||||
<ProductLanding config={landingConfig} />
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Per-user proxy to the Hanzo Base control plane (base.hanzo.ai) — the embedded
|
||||
* Base module's ONE transport. The browser calls console2's OWN origin
|
||||
* (`/superbase/v1/...`) with just the session cookie; `forwardWithUserBearer`
|
||||
* resolves the user, mints a short-lived user-bound IAM token (shared per-user
|
||||
* cache), and forwards to base.hanzo.ai with that token. No token ever reaches the
|
||||
* browser, and the SAME @hanzo/superbase-dashboard screens render here and standalone.
|
||||
*
|
||||
* NOT the PaaS pattern: PaaS forwards a god-mode SERVICE token and is gated to brand
|
||||
* admins. Base authorizes PER USER itself — the `tenants` collection's
|
||||
* `ListRule = "owner_iam_user = @request.auth.id"` and admin-only mutations are
|
||||
* enforced by Base against the forwarded user identity. So here we forward the
|
||||
* USER's own minted bearer (least privilege, tenant-scoped by Base), and the only
|
||||
* gate is "must be signed in" (resolveUser → 401). A non-admin simply sees their own
|
||||
* tenants and gets Base's 403 on a mutation — honest, not faked.
|
||||
*
|
||||
* Least privilege on the path too: only the Base DATA PLANE is proxied — the
|
||||
* collection schemas (read) and any collection's records (list/get/create/update/
|
||||
* delete), via `allowBaseSurface`. Base's admin/settings/backup/log surfaces 404,
|
||||
* so this stays a data-plane proxy, not a general Base tunnel. Base still authorizes
|
||||
* every read/write per-user and per-collection itself, so a non-admin sees only what
|
||||
* a collection's rules permit and gets Base's own honest 403 on a denied mutation.
|
||||
* (The tenants manager rides this same proxy — records/tenants is one such path.)
|
||||
*/
|
||||
import { type NextRequest } from 'next/server'
|
||||
|
||||
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
|
||||
import { allowBaseSurface } from '~/lib/server/proxy-allow'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
/** The Base control plane the proxied calls are forwarded to. */
|
||||
const BASE_URL = trim(process.env.BASE_DASHBOARD_URL ?? 'https://base.hanzo.ai')
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
function handle(req: NextRequest, ctx: Ctx) {
|
||||
return (async () => {
|
||||
const path = (await ctx.params).path.join('/')
|
||||
return forwardWithUserBearer(req, {
|
||||
target: BASE_URL,
|
||||
path,
|
||||
allow: allowBaseSurface,
|
||||
unauthorizedMessage: 'Sign in to manage Base records.',
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function PATCH(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Same-origin proxy to the durable task engine (hanzoai/tasks `tasksd`, the native
|
||||
* Temporal-style HTTP surface at `/v1/tasks/*`).
|
||||
*
|
||||
* `tasksd` runs `TASKSD_REQUIRE_IDENTITY=true`: it validates an IAM **Bearer JWT**
|
||||
* against the IAM JWKS, unconditionally STRIPS inbound `X-Org-Id`, and mints org +
|
||||
* user from the JWT claims (`owner`→org). So — like the `/ai` proxy — the console
|
||||
* calls its OWN origin (`/tasksd/...`) with just the session cookie;
|
||||
* `forwardWithUserBearer` resolves the signed-in user, mints a short-lived
|
||||
* user-bound IAM token (shared per-user cache), and forwards it as the Bearer. No
|
||||
* key in the browser, and every read is org-scoped by the JWT server-side.
|
||||
*
|
||||
* READ-ONLY: only GET is proxied (the console never mutates workflows here), scoped
|
||||
* to the `v1/tasks/*` subtree. When the engine is unreachable the UI shows an honest
|
||||
* BackendStateCard — never fabricated workflows.
|
||||
*/
|
||||
import { type NextRequest } from 'next/server'
|
||||
|
||||
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
/** The durable task engine. Public TLS is not live yet → default to the in-cluster
|
||||
* service. The tasks Service exposes REST on :7243 (http port); there is NO :80,
|
||||
* so target :7243 explicitly. Override with TASKS_URL. `|| default` (not `??`) so a
|
||||
* blank env still falls back to the in-cluster service. */
|
||||
const TASKS_URL = trim(process.env.TASKS_URL?.trim() || 'http://tasks.hanzo.svc.cluster.local:7243')
|
||||
|
||||
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
const rel = (await ctx.params).path.join('/')
|
||||
return forwardWithUserBearer(req, {
|
||||
target: TASKS_URL,
|
||||
path: `v1/tasks/${rel}`,
|
||||
allow: (p) => p === 'v1/tasks' || p.startsWith('v1/tasks/'),
|
||||
unauthorizedMessage: 'Sign in to view tasks.',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Same-origin READ-ONLY proxy to VictoriaMetrics — the live platform telemetry
|
||||
* store (Prometheus-compatible TSDB, `vmsingle-victoria-metrics-single-server`).
|
||||
*
|
||||
* The browser calls this OWN-origin route (`/telemetry/api/v1/query?query=up`) with
|
||||
* just its first-party session cookie; this handler resolves the caller
|
||||
* (`resolveUser`) and — only for a signed-in user — forwards the READ query to
|
||||
* VictoriaMetrics. It powers Status (real `up{}` service health) and Metrics (real
|
||||
* infra time-series), replacing the empty `/paas/apps` board and the unwired
|
||||
* Metrics overview. VictoriaMetrics has no per-request auth of its own (it is an
|
||||
* internal ClusterIP service), so this route IS the access boundary — hence three
|
||||
* hard limits, all fail-closed:
|
||||
*
|
||||
* 1. Authenticated only — `resolveUser` (401 otherwise). Platform status/metrics
|
||||
* is not tenant-customer data; it is the health of the Hanzo Cloud platform the
|
||||
* user is signed into (a status-page concern), appropriate for any signed-in
|
||||
* console user and strictly READ-ONLY — no service token, far weaker than the
|
||||
* admin `/paas` control plane.
|
||||
* 2. READ-only — GET only, and only the allow-listed VictoriaMetrics query
|
||||
* endpoints (`allowTelemetrySurface`): `/api/v1/query`, `/query_range`,
|
||||
* `/series`, `/labels`, `/label/<name>/values`, `/status/tsdb`, `/metadata`.
|
||||
* Never `/api/v1/write`, `/import`, `/-/reload`, or any admin/mutating path.
|
||||
* 3. Traversal-hardened — `pathIsClean` rejects `.`/`..`/`%XX`/`;` segments and the
|
||||
* forward re-validates the WHATWG-normalized path, exactly like the bearer
|
||||
* proxies.
|
||||
*
|
||||
* Honest 501 when `VM_URL` is unset, so the UI shows a truthful "telemetry not
|
||||
* configured" state — never fabricated metrics.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
import { pathIsClean } from '~/lib/server/bearer-proxy'
|
||||
import { allowTelemetrySurface } from '~/lib/server/proxy-allow'
|
||||
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trimR = (s: string) => s.replace(/\/+$/, '')
|
||||
const trimL = (s: string) => s.replace(/^\/+/, '')
|
||||
const msgOf = (e: unknown) => (e instanceof Error ? e.message : String(e))
|
||||
|
||||
/** VictoriaMetrics single-node read API. In-cluster ClusterIP :8428 (headless).
|
||||
* Override with VM_URL. `|| default` (not `??`) so a blank/whitespace env still
|
||||
* resolves the in-cluster service (the same env-drift guard the /vm proxy uses). */
|
||||
const VM_URL = trimR(
|
||||
process.env.VM_URL?.trim() || 'http://vmsingle-victoria-metrics-single-server.hanzo.svc:8428',
|
||||
)
|
||||
|
||||
const json = (body: unknown, status: number) => NextResponse.json(body, { status })
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx): Promise<NextResponse> {
|
||||
const rawPath = trimL((await ctx.params).path.join('/')).replace(/\/+$/, '')
|
||||
|
||||
// Traversal + least-privilege on the RAW path (literal `.`/`..`/`%XX`/`;` rejected).
|
||||
if (!pathIsClean(rawPath) || !allowTelemetrySurface(rawPath)) {
|
||||
return json({ status: 'error', errorType: 'not_allowed', error: 'Not a telemetry read endpoint.' }, 404)
|
||||
}
|
||||
|
||||
if (!process.env.VM_URL?.trim() && !VM_URL) {
|
||||
return json(
|
||||
{ status: 'error', errorType: 'not_configured', error: 'Telemetry store is not configured (VM_URL missing).' },
|
||||
501,
|
||||
)
|
||||
}
|
||||
|
||||
// Authenticated only — the query surface is the access boundary (VM has no auth).
|
||||
const user = await resolveUser(req)
|
||||
if (!user) {
|
||||
return json({ status: 'error', errorType: 'unauthenticated', error: 'Sign in to view platform telemetry.' }, 401)
|
||||
}
|
||||
|
||||
// Re-validate the WHATWG-normalized destination (undici resolves %2e/double-encoded
|
||||
// dot-segments a raw check can't see) — validate AND fetch the exact same URL.
|
||||
let dest: URL
|
||||
try {
|
||||
dest = new URL(`${VM_URL}/${rawPath}${req.nextUrl.search}`)
|
||||
} catch {
|
||||
return json({ status: 'error', errorType: 'not_allowed', error: 'Bad telemetry path.' }, 404)
|
||||
}
|
||||
const normPath = trimL(dest.pathname).replace(/\/+$/, '')
|
||||
if (!pathIsClean(normPath) || !allowTelemetrySurface(normPath)) {
|
||||
return json({ status: 'error', errorType: 'not_allowed', error: 'Not a telemetry read endpoint.' }, 404)
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetchWithTimeout(dest, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
signal: req.signal,
|
||||
})
|
||||
return new NextResponse(res.body, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
'Content-Type': res.headers.get('content-type') ?? 'application/json',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('telemetry-proxy: VictoriaMetrics unreachable:', msgOf(e))
|
||||
return json({ status: 'error', errorType: 'upstream_error', error: 'Telemetry store is unavailable.' }, 502)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Same-origin proxy to the cloud ML/training surface on hanzoai/ai (`/v1/train/*`,
|
||||
* `/v1/ml/models`, and the fine-tuning broker `/v1/finetune/*`).
|
||||
*
|
||||
* The console's Training page calls its OWN origin (`/training/...`) with just the
|
||||
* first-party session cookie; this server handler resolves the signed-in user from
|
||||
* that cookie and forwards to the cloud backend's `/v1/...` surface, passing the
|
||||
* cookie through (the proven `get-account` server-to-server pattern in
|
||||
* lib/server/identity.ts) plus the active `X-Org-Id`. Training is a TENANT action —
|
||||
* any signed-in org user may run it — so this is user-scoped (resolveUser), NOT the
|
||||
* control-plane admin gate the `/paas` proxy uses. The cloud backend scopes by org
|
||||
* (GetEffectiveOrg / the X-Org-Id the plain-REST train sub-service requires), so a
|
||||
* caller can only ever touch their own org's jobs. `POST /v1/train/jobs` is
|
||||
* billing-gated by the live ResourceMeter and returns 402 on an unfunded org — that
|
||||
* status flows straight back so the UI can surface it honestly.
|
||||
*
|
||||
* Least privilege: only the explicit ML/training sub-paths are forwarded; anything
|
||||
* else 404s, so this is not a general backend tunnel. No secret ever reaches the
|
||||
* browser — the HuggingFace token (for private repos) is resolved from KMS
|
||||
* server-side inside the broker, never here.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
import { orgFor } from '~/lib/server/admin-policy'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
/** Cloud `/v1` backend (hanzoai/ai) — same target lib/server/identity.ts resolves. */
|
||||
const CLOUD_API_URL = trim(process.env.CLOUD_API_URL ?? 'http://cloud.hanzo.svc.cluster.local:8000')
|
||||
|
||||
/** The exact `/v1/<...>` ML/training sub-paths the console is allowed to reach. */
|
||||
const ALLOWED = new Set([
|
||||
// mlsvc — the canonical training surface (task #40 ResourceMeter gates POST jobs).
|
||||
'train/jobs',
|
||||
'train/experiments',
|
||||
'ml/models',
|
||||
// Real Kubeflow control-plane probe (which operators/CRDs are actually served).
|
||||
// Read-only; 503 + body flows through so the UI can report a degraded plane.
|
||||
'train/health',
|
||||
// fine-tuning broker (custom-data runs, HF search) — sibling surface.
|
||||
'finetune/jobs',
|
||||
'finetune/job',
|
||||
'finetune/cancel',
|
||||
'finetune/deploy',
|
||||
'finetune/presets',
|
||||
'finetune/hf/models',
|
||||
'finetune/hf/datasets',
|
||||
'finetune/hf/repo',
|
||||
])
|
||||
|
||||
async function forward(req: NextRequest, path: string[]): Promise<NextResponse> {
|
||||
const rel = path.join('/')
|
||||
if (!ALLOWED.has(rel)) {
|
||||
return NextResponse.json({ status: 'error', msg: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// CSRF: `POST /train/jobs` mutates (and bills) from the auto-sent cookie — refuse a
|
||||
// cross-origin one before any work (safe reads pass).
|
||||
const csrf = csrfRefusal(req, 'casibase')
|
||||
if (csrf) return csrf
|
||||
|
||||
const user = await resolveUser(req)
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ status: 'error', msg: 'Sign in to manage training.' },
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
|
||||
const cookie = req.headers.get('cookie') ?? ''
|
||||
const url = `${CLOUD_API_URL}/v1/${rel}${req.nextUrl.search}`
|
||||
const headers: Record<string, string> = {
|
||||
cookie,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
// Org is SERVER-RESOLVED, not the raw browser header: a global admin's switched
|
||||
// org (?/X-Org-Id) is honored, a non-global caller is PINNED to their own — so a
|
||||
// brand admin can't drive another tenant's training jobs even if the backend
|
||||
// trusted the forwarded header. Matches the /paas + /admin/kms orgFor pin.
|
||||
'X-Org-Id': orgFor({ isGlobalAdmin: user.isGlobalAdmin, orgScope: user.owner }, req.headers.get('X-Org-Id')),
|
||||
}
|
||||
const projectId = req.headers.get('X-Project-Id')
|
||||
const environment = req.headers.get('X-Environment')
|
||||
if (projectId) headers['X-Project-Id'] = projectId
|
||||
if (environment) headers['X-Environment'] = environment
|
||||
|
||||
const init: RequestInit = { method: req.method, headers, cache: 'no-store' }
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
init.body = await req.text()
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetchWithTimeout(url, init)
|
||||
const text = await res.text()
|
||||
return new NextResponse(text, {
|
||||
status: res.status,
|
||||
headers: { 'Content-Type': res.headers.get('content-type') ?? 'application/json' },
|
||||
})
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ status: 'error', msg: `Fine-tuning backend unreachable: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Same-origin user-bearer proxy to Visor (vm.hanzo.ai) — the compute control plane
|
||||
* (regions / gpus / machines / instances). The browser calls this OWN-origin route
|
||||
* (`/vm/v1/...`) with just its session cookie; `forwardWithUserBearer` resolves the
|
||||
* user, mints a short-lived user-bound IAM token, and forwards to visor with that
|
||||
* Bearer. Visor mints org + user from the JWT claims, so compute is org-scoped
|
||||
* server-side — a caller only ever sees their own org's machines. No token reaches
|
||||
* the browser.
|
||||
*
|
||||
* NOT the `/paas` pattern: `/paas` forwards a god-mode control-plane SERVICE token
|
||||
* and is gated to brand admins. Compute is a TENANT action (any signed-in org user
|
||||
* may list/manage their own machines), so this is user-scoped (resolveUser), and
|
||||
* visor itself authorizes the forwarded user bearer.
|
||||
*
|
||||
* Least privilege on the path: only the visor `v1/*` surface is reachable
|
||||
* (`allowVisorSurface`); anything else 404s.
|
||||
*/
|
||||
import { type NextRequest } from 'next/server'
|
||||
|
||||
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
|
||||
import { allowVisorSurface } from '~/lib/server/proxy-allow'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
/** Visor (vm.hanzo.ai). In-cluster ClusterIP on :19000 (its Service has NO :80) — public
|
||||
* egress is CF-403'd. Override with VISOR_URL (the CR sets visor.hanzo.svc:19000).
|
||||
* `|| default` (not `??`): if the env is reconciled to an EMPTY string (observed drift on
|
||||
* the live pod), `??` would keep the blank and every machines/GPUs call would fail —
|
||||
* `|| default` treats blank/whitespace as unset so visor ALWAYS resolves. */
|
||||
const VISOR_URL = trim(process.env.VISOR_URL?.trim() || 'http://visor.hanzo.svc:19000')
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
function handle(req: NextRequest, ctx: Ctx) {
|
||||
return (async () => {
|
||||
const path = (await ctx.params).path.join('/')
|
||||
return forwardWithUserBearer(req, {
|
||||
target: VISOR_URL,
|
||||
path,
|
||||
allow: allowVisorSurface,
|
||||
// Org is authoritative (Bearer owner). Don't forward browser X-Project-Id/
|
||||
// X-Environment (unvalidated sub-scopes) — RED MEDIUM.
|
||||
unauthorizedMessage: 'Sign in to manage compute.',
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function PUT(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function PATCH(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* e2e: two-tenant BILLING ISOLATION through the `/billing/*` proxy.
|
||||
*
|
||||
* The proxy (app/billing/v1/[...path]/route.ts) resolves the billing subject from the
|
||||
* session server-side and pins the full subject-key set (user/userId/customerId) +
|
||||
* the X-Org-Id header, so a tenant can only ever read its OWN commerce ledger. This
|
||||
* spec proves that end-to-end against the LIVE proxy: two accounts in DIFFERENT orgs
|
||||
* each fetch `/billing/subscriptions` (and `/payment-methods`), and we assert the
|
||||
* two result sets are disjoint — neither tenant can see the other's rows.
|
||||
*
|
||||
* This is the regression guard for the IDOR RED found (the proxy previously pinned
|
||||
* only `?user=` while commerce filters subscriptions on `?userId=`, so subscriptions
|
||||
* were returned across the whole namespace).
|
||||
*
|
||||
* Credentials (env, never in repo). Skips unless BOTH tenants are provided:
|
||||
* TENANT_A_EMAIL / TENANT_A_PASSWORD (org A)
|
||||
* TENANT_B_EMAIL / TENANT_B_PASSWORD (org B, a DIFFERENT org)
|
||||
* BASE_URL default https://console.hanzo.ai
|
||||
*
|
||||
* Run: TENANT_A_EMAIL=.. TENANT_A_PASSWORD=.. TENANT_B_EMAIL=.. TENANT_B_PASSWORD=.. pnpm e2e billing-isolation.spec.ts
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'https://console.hanzo.ai'
|
||||
const A = { email: process.env.TENANT_A_EMAIL ?? '', password: process.env.TENANT_A_PASSWORD ?? '' }
|
||||
const B = { email: process.env.TENANT_B_EMAIL ?? '', password: process.env.TENANT_B_PASSWORD ?? '' }
|
||||
|
||||
async function signIn(page: Page, email: string, password: string) {
|
||||
await page.goto(`${BASE_URL}/signin`)
|
||||
await page.waitForSelector('input[placeholder="Email"]', { timeout: 20_000 })
|
||||
await page.fill('input[placeholder="Email"]', email)
|
||||
await page.fill('input[placeholder="Password"]', password)
|
||||
await page.click('button:has-text("Sign in")')
|
||||
const base = new URL(BASE_URL).origin
|
||||
await page.waitForURL((url) => url.origin === base && url.pathname === '/', { timeout: 30_000 })
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
}
|
||||
|
||||
/** Fetch a billing path through the same-origin DATA proxy (`/billing/v1/*`), as the
|
||||
* signed-in browser. (`/billing/<slug>` without `v1/` is a UI tab, served by the SPA.) */
|
||||
async function billing(page: Page, path: string): Promise<{ status: number; ids: string[] }> {
|
||||
return page.evaluate(async (p) => {
|
||||
const res = await fetch(`/billing/v1/${p}`, { credentials: 'include', headers: { Accept: 'application/json' } })
|
||||
let ids: string[] = []
|
||||
try {
|
||||
const body = await res.json()
|
||||
const rows = Array.isArray(body)
|
||||
? body
|
||||
: (body?.subscriptions ?? body?.paymentMethods ?? body?.payment_methods ?? body?.data ?? [])
|
||||
ids = (Array.isArray(rows) ? rows : [])
|
||||
.map((r: { id?: unknown }) => (typeof r?.id === 'string' ? r.id : ''))
|
||||
.filter(Boolean)
|
||||
} catch {
|
||||
/* non-JSON (e.g. 501 not-configured) — ids stays empty */
|
||||
}
|
||||
return { status: res.status, ids }
|
||||
}, path)
|
||||
}
|
||||
|
||||
test.describe('billing is isolated per tenant through the proxy', () => {
|
||||
test.skip(
|
||||
!A.email || !A.password || !B.email || !B.password,
|
||||
'TENANT_A_* / TENANT_B_* not set — skipping two-tenant billing isolation',
|
||||
)
|
||||
|
||||
test('two distinct-org tenants never see each other’s subscriptions or payment methods', async ({ browser }) => {
|
||||
const ctxA = await browser.newContext()
|
||||
const ctxB = await browser.newContext()
|
||||
const pageA = await ctxA.newPage()
|
||||
const pageB = await ctxB.newPage()
|
||||
|
||||
await signIn(pageA, A.email, A.password)
|
||||
await signIn(pageB, B.email, B.password)
|
||||
|
||||
for (const path of ['subscriptions', 'payment-methods']) {
|
||||
const a = await billing(pageA, path)
|
||||
const b = await billing(pageB, path)
|
||||
|
||||
// A 401 would mean the session broke; a 501 means commerce isn't configured
|
||||
// on this deployment (isolation is vacuously safe — nothing is returned).
|
||||
expect(a.status, `tenant A /${path} not authorized`).not.toBe(401)
|
||||
expect(b.status, `tenant B /${path} not authorized`).not.toBe(401)
|
||||
|
||||
if (a.status === 501 || b.status === 501) continue
|
||||
|
||||
// The core isolation assertion: the two tenants' row-id sets are disjoint.
|
||||
const overlap = a.ids.filter((id) => b.ids.includes(id))
|
||||
expect(overlap, `/${path} leaked ${overlap.length} shared rows across tenants`).toEqual([])
|
||||
}
|
||||
|
||||
await ctxA.close()
|
||||
await ctxB.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* e2e: Hanzo Cloud Console — login → API key → AI inference
|
||||
*
|
||||
* z@hanzo.ai is in the `hanzo` org (isGlobalAdmin), so the OrgGate now shows
|
||||
* a dismissible admin banner and renders the full console on console.hanzo.ai.
|
||||
* Admin ops still live at admin.hanzo.ai.
|
||||
*
|
||||
* Credentials (env, never in repo):
|
||||
* HANZO_EMAIL default z@hanzo.ai
|
||||
* HANZO_PASSWORD required
|
||||
* HANZO_API_KEY optional; skip UI flow, go straight to inference
|
||||
* HANZO_API_BASE default https://api.hanzo.ai
|
||||
* BASE_URL default https://console.hanzo.ai
|
||||
*
|
||||
* Run:
|
||||
* HANZO_PASSWORD=xxx pnpm e2e
|
||||
* HANZO_PASSWORD=xxx HANZO_API_KEY=hk-xxx pnpm e2e
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
const EMAIL = process.env.HANZO_EMAIL ?? 'z@hanzo.ai'
|
||||
const PASSWORD = process.env.HANZO_PASSWORD ?? ''
|
||||
const API_KEY = process.env.HANZO_API_KEY ?? ''
|
||||
const API_BASE = process.env.HANZO_API_BASE ?? 'https://api.hanzo.ai'
|
||||
const BASE_URL = process.env.BASE_URL ?? 'https://console.hanzo.ai'
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function signIn(page: Page) {
|
||||
await page.goto(`${BASE_URL}/signin`)
|
||||
await page.waitForSelector('input[placeholder="Email"]', { timeout: 20_000 })
|
||||
await page.fill('input[placeholder="Email"]', EMAIL)
|
||||
await page.fill('input[placeholder="Password"]', PASSWORD)
|
||||
await page.click('button:has-text("Sign in")')
|
||||
}
|
||||
|
||||
/** Wait until we're on the dashboard (route replaced to '/'). */
|
||||
async function waitForDashboard(page: Page) {
|
||||
const base = new URL(BASE_URL).origin
|
||||
await page.waitForURL(url => url.origin === base && url.pathname === '/', { timeout: 30_000 })
|
||||
// The dashboard renders product grid cards — wait for at least one visible card/link
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
}
|
||||
|
||||
// ─── tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Public smoke — needs no credentials, so it always runs (in CI, for Dave, etc.)
|
||||
// and catches a dead/blank sign-in gate. The authenticated flows below gate on
|
||||
// HANZO_PASSWORD.
|
||||
test.describe('Hanzo Cloud Console — public', () => {
|
||||
test('sign-in page renders (email/password + OAuth + passkey)', async ({ page }) => {
|
||||
await page.goto(`${BASE_URL}/signin`)
|
||||
await expect(page.locator('input[placeholder="Email"]')).toBeVisible({ timeout: 20_000 })
|
||||
await expect(page.locator('input[placeholder="Password"]')).toBeVisible()
|
||||
await expect(page.locator('button:has-text("Sign in")')).toBeVisible()
|
||||
await expect(page.locator('button:has-text("Continue with GitHub")')).toBeVisible()
|
||||
await expect(page.locator('button:has-text("Continue with Google")')).toBeVisible()
|
||||
await expect(page.locator('text=/passkey/i')).toBeVisible()
|
||||
})
|
||||
|
||||
test('root serves the app (200, dark #0a0a0a) + /base resolves', async ({ page, request }) => {
|
||||
const res = await page.goto(BASE_URL)
|
||||
expect(res?.status()).toBe(200)
|
||||
await expect(page).toHaveTitle(/Hanzo Cloud Console/)
|
||||
await expect(page.locator('meta[name="theme-color"][content="#0a0a0a"]')).toHaveCount(1)
|
||||
expect((await request.get(`${BASE_URL}/base`)).status()).toBe(200)
|
||||
})
|
||||
|
||||
// Security gates — the server proxies must reject unauthenticated calls and
|
||||
// stay inside their allow-list. No credentials (plain request context) so this
|
||||
// proves the production posture in CI. A regression is a real security bug.
|
||||
test('server proxies reject unauthenticated calls (401)', async ({ request }) => {
|
||||
for (const path of [
|
||||
'/superbase/v1/collections/tenants/records',
|
||||
'/keys',
|
||||
]) {
|
||||
const res = await request.get(`${BASE_URL}${path}`)
|
||||
expect(res.status(), `${path} must gate`).toBe(401)
|
||||
}
|
||||
})
|
||||
|
||||
test('proxy allow-lists reject off-list paths (no tunnel)', async ({ request }) => {
|
||||
const res = await request.get(`${BASE_URL}/superbase/v1/collections/secrets/records`)
|
||||
// The point is "no tunnel to the backend": the off-list path must be blocked,
|
||||
// not proxied. The proxy may reject with 404 (off allow-list) or 401 (auth
|
||||
// gate hit first) — both are blocked; a 2xx would be the real bug.
|
||||
expect([401, 404], `off-list path must be blocked, got ${res.status()}`).toContain(res.status())
|
||||
})
|
||||
|
||||
test('unknown route never 5xxs', async ({ request }) => {
|
||||
const res = await request.get(`${BASE_URL}/no-such-surface-xyz`)
|
||||
expect(res.status()).toBeLessThan(500)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Hanzo Cloud Console e2e', () => {
|
||||
test.skip(!PASSWORD, 'HANZO_PASSWORD not set — skipping live authenticated tests')
|
||||
|
||||
test('login as z@hanzo.ai — dashboard renders', async ({ page }) => {
|
||||
await signIn(page)
|
||||
await waitForDashboard(page)
|
||||
// Dashboard renders — sign-in form must be gone
|
||||
await expect(page.locator('input[placeholder="Password"]')).not.toBeVisible({ timeout: 10_000 })
|
||||
// At least one product category or card is visible (Overview / AI / Compute etc.)
|
||||
await expect(
|
||||
page.locator('a, button, [role="link"]').filter({ hasText: /models|providers|overview|AI/i }).first()
|
||||
).toBeVisible({ timeout: 15_000 })
|
||||
console.log('✓ Signed in; dashboard is rendering')
|
||||
})
|
||||
|
||||
test('admin banner visible (z is isAdmin on console.hanzo.ai)', async ({ page }) => {
|
||||
await signIn(page)
|
||||
await waitForDashboard(page)
|
||||
// OrgGate shows the admin banner for admins on the non-admin console host.
|
||||
// The banner may have been dismissed in a prior run (localStorage). Skip softly.
|
||||
const banner = page.locator('text=/Admin ops|admin\\.hanzo\\.ai/i').first()
|
||||
const visible = await banner.isVisible({ timeout: 5_000 }).catch(() => false)
|
||||
if (visible) {
|
||||
console.log('✓ Admin banner visible')
|
||||
await expect(page.locator('button:has-text("Open admin")')).toBeVisible()
|
||||
} else {
|
||||
console.log('ℹ Admin banner was dismissed (localStorage) — OK')
|
||||
}
|
||||
})
|
||||
|
||||
test('create or confirm API key', async ({ page }) => {
|
||||
await signIn(page)
|
||||
await waitForDashboard(page)
|
||||
|
||||
// API Keys module is at /api-keys (catch-all route, id='api-keys')
|
||||
await page.goto(`${BASE_URL}/api-keys`, { waitUntil: 'domcontentloaded' })
|
||||
|
||||
// Wait for the module to hydrate — look for the page header or key cards
|
||||
await expect(
|
||||
page.locator('text=/API Keys/i, text=/Cloud API key/i, text=/Create.*API key/i').first()
|
||||
).toBeVisible({ timeout: 25_000 })
|
||||
|
||||
const hasKey = page.locator('text=/Cloud API key/i')
|
||||
const noKey = page.locator('text=/Create your Cloud API key/i')
|
||||
const createBtn = page.locator('button:has-text("Create API key")')
|
||||
|
||||
const needsCreate = await noKey.isVisible({ timeout: 3_000 }).catch(() => false)
|
||||
|| await createBtn.isVisible({ timeout: 1_000 }).catch(() => false)
|
||||
|
||||
if (needsCreate) {
|
||||
await createBtn.click()
|
||||
// One-time reveal card with the hk- key
|
||||
await expect(page.locator('text=/hk-/')).toBeVisible({ timeout: 25_000 })
|
||||
await expect(page.locator('text=/shown only once/i')).toBeVisible()
|
||||
await expect(page.locator('button:has-text("Copy")')).toBeVisible()
|
||||
console.log('✓ API key created (hk- one-time reveal shown)')
|
||||
} else {
|
||||
// Key already exists
|
||||
await expect(hasKey).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.locator('text=/hk-…|hk-[A-Za-z0-9]{3,}/i')).toBeVisible({ timeout: 5_000 })
|
||||
console.log('✓ API key already exists (prefix shown)')
|
||||
}
|
||||
})
|
||||
|
||||
test('API key works — GET /v1/models', async ({ page }) => {
|
||||
let apiKey = API_KEY
|
||||
|
||||
if (!apiKey) {
|
||||
await signIn(page)
|
||||
await waitForDashboard(page)
|
||||
await page.goto(`${BASE_URL}/api-keys`, { waitUntil: 'domcontentloaded' })
|
||||
await expect(
|
||||
page.locator('text=/API Keys/i').first()
|
||||
).toBeVisible({ timeout: 25_000 })
|
||||
|
||||
// Rotate (or create) to show the full key on-screen
|
||||
const rotateBtn = page.locator('button:has-text("Rotate")')
|
||||
const createBtn = page.locator('button:has-text("Create API key")')
|
||||
if (await rotateBtn.isVisible({ timeout: 3_000 }).catch(() => false)) {
|
||||
await rotateBtn.click()
|
||||
} else if (await createBtn.isVisible({ timeout: 1_000 }).catch(() => false)) {
|
||||
await createBtn.click()
|
||||
}
|
||||
await expect(page.locator('text=/hk-/')).toBeVisible({ timeout: 25_000 })
|
||||
|
||||
// Grab the FULL key from the one-time reveal — never the masked display
|
||||
// (the account card shows `hk-2f18…` with an ellipsis, which is not a
|
||||
// usable credential). Match only a full hk- token (no `…`/`...`).
|
||||
const fullKey = /hk-[A-Za-z0-9._-]{16,}/
|
||||
const keyEl = page.locator('[style*="monospace"]').filter({ hasText: fullKey }).first()
|
||||
apiKey = (((await keyEl.textContent().catch(() => '')) ?? '').match(fullKey) ?? [''])[0]
|
||||
if (!apiKey) {
|
||||
const m = ((await page.textContent('body')) ?? '').match(fullKey)
|
||||
apiKey = m ? m[0] : ''
|
||||
}
|
||||
expect(apiKey, 'Could not extract hk- key from page').toMatch(/^hk-/)
|
||||
console.log(`✓ Extracted key prefix: ${apiKey.slice(0, 11)}…`)
|
||||
}
|
||||
|
||||
// Verify the key works against api.hanzo.ai
|
||||
const resp = await page.request.get(`${API_BASE}/v1/models`, {
|
||||
headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json' },
|
||||
timeout: 30_000,
|
||||
})
|
||||
expect(resp.ok(), `GET /v1/models → ${resp.status()}`).toBe(true)
|
||||
const json = await resp.json()
|
||||
expect(json).toMatchObject({ data: expect.any(Array) })
|
||||
const models: Array<{ id: string }> = json.data
|
||||
expect(models.length).toBeGreaterThan(0)
|
||||
const ids = models.map(m => m.id)
|
||||
console.log(`✓ /v1/models: ${models.length} models`)
|
||||
console.log(` GLM present: ${ids.some(id => id.includes('glm'))}`)
|
||||
console.log(` Claude present: ${ids.some(id => id.includes('claude'))}`)
|
||||
console.log(` DeepSeek present: ${ids.some(id => id.includes('deepseek'))}`)
|
||||
console.log(` First 5: ${ids.slice(0, 5).join(', ')}`)
|
||||
})
|
||||
|
||||
test('OpenAI inference — glm-5.2', async ({ page }) => {
|
||||
test.skip(!API_KEY, 'Set HANZO_API_KEY to run inference tests')
|
||||
|
||||
const resp = await page.request.post(`${API_BASE}/v1/chat/completions`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
data: {
|
||||
model: 'glm-5.2',
|
||||
messages: [{ role: 'user', content: 'Reply with only: PONG' }],
|
||||
max_tokens: 16,
|
||||
stream: false,
|
||||
},
|
||||
timeout: 30_000,
|
||||
})
|
||||
expect(resp.ok(), `glm-5.2 → ${resp.status()}`).toBe(true)
|
||||
const json = await resp.json()
|
||||
const text: string = json.choices?.[0]?.message?.content ?? ''
|
||||
expect(text).toBeTruthy()
|
||||
console.log(`✓ glm-5.2: "${text.trim()}"`)
|
||||
})
|
||||
|
||||
test('OpenAI inference — deepseek-v4-pro', async ({ page }) => {
|
||||
test.skip(!API_KEY, 'Set HANZO_API_KEY to run inference tests')
|
||||
|
||||
const resp = await page.request.post(`${API_BASE}/v1/chat/completions`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
data: {
|
||||
model: 'deepseek-v4-pro',
|
||||
messages: [{ role: 'user', content: 'Reply with only: PONG' }],
|
||||
max_tokens: 16,
|
||||
stream: false,
|
||||
},
|
||||
timeout: 30_000,
|
||||
})
|
||||
expect(resp.ok(), `deepseek-v4-pro → ${resp.status()}`).toBe(true)
|
||||
const json = await resp.json()
|
||||
const text: string = json.choices?.[0]?.message?.content ?? ''
|
||||
expect(text).toBeTruthy()
|
||||
console.log(`✓ deepseek-v4-pro: "${text.trim()}"`)
|
||||
})
|
||||
|
||||
test('Anthropic-compat /v1/messages — live catalog model', async ({ page }) => {
|
||||
test.skip(!API_KEY, 'Set HANZO_API_KEY to run inference tests')
|
||||
|
||||
// Pick a model that is ACTUALLY available right now (the catalog changes;
|
||||
// a hardcoded id like claude-sonnet-4-6 fails when it isn't provisioned).
|
||||
// The /v1/messages Anthropic surface accepts any catalog model.
|
||||
const listed = await page.request.get(`${API_BASE}/v1/models`, {
|
||||
headers: { Authorization: `Bearer ${API_KEY}`, Accept: 'application/json' },
|
||||
timeout: 30_000,
|
||||
})
|
||||
const ids: string[] = (await listed.json()).data?.map((m: { id: string }) => m.id) ?? []
|
||||
const model = ids.find((id) => id.includes('claude')) ?? ids.find((id) => id === 'glm-5.2') ?? ids[0]
|
||||
expect(model, 'no model available in catalog').toBeTruthy()
|
||||
|
||||
const resp = await page.request.post(`${API_BASE}/v1/messages`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
data: {
|
||||
model,
|
||||
messages: [{ role: 'user', content: 'Reply with only: PONG' }],
|
||||
max_tokens: 16,
|
||||
},
|
||||
timeout: 30_000,
|
||||
})
|
||||
expect(resp.ok(), `/v1/messages (${model}) → ${resp.status()}`).toBe(true)
|
||||
const json = await resp.json()
|
||||
const text: string = json.content?.[0]?.text ?? ''
|
||||
expect(text).toBeTruthy()
|
||||
console.log(`✓ Anthropic-compat /v1/messages (${model}): "${text.trim()}"`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* e2e regression: the reported launch bug — product routes must render on a
|
||||
* DIRECT URL load and a browser REFRESH, not only via in-app navigation.
|
||||
*
|
||||
* Product modules mount client-only under the catch-all route, so a throw in one
|
||||
* module's first render used to bubble to Next's root fallback and white-screen
|
||||
* the whole console with "Application error: a client-side exception has
|
||||
* occurred" — but ONLY on a direct load / refresh (in-app nav renders fresh and
|
||||
* hid it). `ProductErrorBoundary` + the dashboard `error.tsx` close that class:
|
||||
* even a module throw now keeps the shell and shows a retryable card, never a
|
||||
* white screen. This spec proves each target route:
|
||||
* 1. direct-loads without a client-exception white-screen,
|
||||
* 2. refreshes (F5) without one,
|
||||
* 3. still deep-links its own real content (shell + main region present).
|
||||
*
|
||||
* Credentials (env, never in repo): HANZO_EMAIL / HANZO_PASSWORD, BASE_URL.
|
||||
* Run: HANZO_PASSWORD=xxx BASE_URL=https://console.hanzo.ai pnpm e2e deeplink-refresh.spec.ts
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
const EMAIL = process.env.HANZO_EMAIL ?? 'z@hanzo.ai'
|
||||
const PASSWORD = process.env.HANZO_PASSWORD ?? ''
|
||||
const BASE_URL = process.env.BASE_URL ?? 'https://console.hanzo.ai'
|
||||
|
||||
// The three routes from the report, plus controls known to deep-link fine.
|
||||
const TARGETS = ['/playground', '/prompts', '/gpus']
|
||||
const CONTROLS = ['/models', '/providers']
|
||||
|
||||
async function signIn(page: Page) {
|
||||
await page.goto(`${BASE_URL}/signin`)
|
||||
await page.waitForSelector('input[placeholder="Email"]', { timeout: 20_000 })
|
||||
// @hanzo/gui Input binds onChangeText — real keystrokes, not fill().
|
||||
await page.locator('input[placeholder="Email"]').pressSequentially(EMAIL, { delay: 12 })
|
||||
await page.locator('input[placeholder="Password"]').pressSequentially(PASSWORD, { delay: 12 })
|
||||
await page.click('button:has-text("Sign in")')
|
||||
const origin = new URL(BASE_URL).origin
|
||||
await page.waitForURL((u) => u.origin === origin && u.pathname === '/', { timeout: 30_000 })
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
}
|
||||
|
||||
/** Assert the page rendered the console (shell + main) and did NOT white-screen. */
|
||||
async function assertRendered(page: Page, route: string, phase: string) {
|
||||
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {})
|
||||
// No client-exception white-screen.
|
||||
await expect(
|
||||
page.locator('text=/Application error|client-side exception|Unhandled Runtime Error/i'),
|
||||
`${route} (${phase}) must not white-screen`,
|
||||
).toHaveCount(0)
|
||||
// Shell survived: the persistent nav ("Overview"/"Apps") is present.
|
||||
const body = (await page.locator('body').innerText().catch(() => '')) || ''
|
||||
expect(body.length, `${route} (${phase}) has content`).toBeGreaterThan(200)
|
||||
expect(body, `${route} (${phase}) kept the shell`).toMatch(/Overview|Apps|Sign out/i)
|
||||
}
|
||||
|
||||
test.describe('deep-link + refresh must not crash', () => {
|
||||
test.skip(!PASSWORD, 'HANZO_PASSWORD not set — skipping authenticated deep-link pass')
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await signIn(page)
|
||||
})
|
||||
|
||||
for (const route of [...TARGETS, ...CONTROLS]) {
|
||||
test(`direct load + refresh: ${route}`, async ({ page }) => {
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', (e) => errors.push(String(e)))
|
||||
|
||||
// 1) DIRECT URL load (full navigation, fresh document).
|
||||
const res = await page.goto(`${BASE_URL}${route}`, { waitUntil: 'domcontentloaded' })
|
||||
expect(res?.status() ?? 0, `${route} HTTP`).toBeLessThan(500)
|
||||
await assertRendered(page, route, 'direct')
|
||||
|
||||
// 2) REFRESH (F5) — the reported failing action.
|
||||
await page.reload({ waitUntil: 'domcontentloaded' })
|
||||
await assertRendered(page, route, 'refresh')
|
||||
|
||||
// An uncaught pageerror on these routes is the regression we are locking out.
|
||||
expect(errors, `${route} uncaught pageerror(s): ${errors.join(' | ').slice(0, 300)}`).toEqual([])
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* LIVE confirmation for feat/billing-usage-admin (v8.4.15) on the deployed cluster.
|
||||
*
|
||||
* Topology (verified live):
|
||||
* - console.hanzo.ai: the console app is directly reachable at `/`; `/v1/*` is
|
||||
* routed by the ingress to cloud-api (hanzoai/gateway), which enforces its OWN
|
||||
* `global admin required` gate. The console's own H1 gate is reachable directly
|
||||
* at `/admin/aggregate/*`. z@hanzo.ai is a global admin on this host.
|
||||
* - admin.hanzo.ai: an EDGE forward-auth (`admin-guard@file`, org=admin, cookie
|
||||
* on `.hanzo.ai`) sits in front of the SAME console image. A browser must carry
|
||||
* a valid `.hanzo.ai` guard/session cookie to pass; a cold hit is 401.
|
||||
*
|
||||
* Three required checks (task bar):
|
||||
* (a) admin business board (LivingOverview: MRR/usage/orgs/top-agents/fleet)
|
||||
* renders for the GLOBAL admin z@hanzo.ai.
|
||||
* (b) an unprivileged caller gets 403 on /v1/admin/* — no cross-org leak
|
||||
* (fail-closed gate); iam/kms are not tunneled.
|
||||
* (c) billing Reports shows the product/agent cost DIMENSION (honest-empty ok).
|
||||
*/
|
||||
import { test, expect, type Page, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const EMAIL = process.env.HANZO_EMAIL ?? 'z@hanzo.ai'
|
||||
const PASSWORD = process.env.HANZO_PASSWORD ?? ''
|
||||
const CONSOLE = process.env.CONSOLE_URL ?? 'https://console.hanzo.ai'
|
||||
const ADMIN = process.env.ADMIN_URL ?? 'https://admin.hanzo.ai'
|
||||
const SHOTS = process.env.SHOT_DIR ?? 'e2e-shots'
|
||||
|
||||
/** Sign in via the console app sign-in form (email/password → cloud /v1/signin). */
|
||||
async function signIn(page: Page, base: string) {
|
||||
await page.goto(`${base}/signin`)
|
||||
await page.waitForSelector('input[placeholder="Email"]', { timeout: 25_000 })
|
||||
await page.fill('input[placeholder="Email"]', EMAIL)
|
||||
await page.fill('input[placeholder="Password"]', PASSWORD)
|
||||
await page.click('button:has-text("Sign in")')
|
||||
await page.waitForFunction(() => !location.pathname.startsWith('/signin'), { timeout: 30_000 })
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
}
|
||||
|
||||
// ─── (b) fail-closed gate — needs no credentials, always runs ─────────────────
|
||||
test.describe('LIVE v8.4.15 — (b) admin gate fail-closed', () => {
|
||||
test('/v1/admin/* → 403 unauthenticated on console.hanzo.ai; iam/kms not tunneled', async ({ request }) => {
|
||||
for (const head of ['overview', 'usage', 'orgs', 'audit', 'products']) {
|
||||
const res = await request.get(`${CONSOLE}/v1/admin/${head}`)
|
||||
expect(res.status(), `${CONSOLE}/v1/admin/${head} must be 403`).toBe(403)
|
||||
}
|
||||
// The console's OWN H1 route is also fail-closed.
|
||||
const own = await request.get(`${CONSOLE}/admin/aggregate/overview`)
|
||||
expect(own.status(), 'console app /admin/aggregate gate must be 403').toBe(403)
|
||||
// Least privilege: iam/kms are NOT reachable through the aggregate rewrite.
|
||||
for (const head of ['iam', 'kms']) {
|
||||
const res = await request.get(`${CONSOLE}/admin/aggregate/${head}`)
|
||||
expect([403, 404], `${head} must not tunnel via aggregate`).toContain(res.status())
|
||||
}
|
||||
// Edge-guarded admin host: cold hit is refused (401 forward-auth) — never open.
|
||||
const edge = await request.get(`${ADMIN}/v1/admin/overview`)
|
||||
expect(edge.status(), 'admin.hanzo.ai must be edge-gated (401/403)').toBeGreaterThanOrEqual(401)
|
||||
expect(edge.status()).toBeLessThan(404)
|
||||
console.log('✓ (b) console /v1/admin/* → 403 unauth; /admin/aggregate/{iam,kms} not tunneled; admin.hanzo.ai edge-gated')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── (a) + (c) authenticated as the global admin z@hanzo.ai ───────────────────
|
||||
test.describe('LIVE v8.4.15 — (a) business board + (c) billing dimension', () => {
|
||||
test.skip(!PASSWORD, 'HANZO_PASSWORD not set')
|
||||
|
||||
test('(a) admin business board renders for global admin z@hanzo.ai', async ({ page }) => {
|
||||
await signIn(page, CONSOLE)
|
||||
|
||||
// The business board (catalog id `business`, admin:true) renders the ONE
|
||||
// LivingOverview for `admin-business`: MRR / revenue / active orgs / customers,
|
||||
// revenue+usage trend, top-agents-by-cost donut, fleet health.
|
||||
await page.goto(`${CONSOLE}/business`, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page).not.toHaveURL(/\/signin/, { timeout: 15_000 })
|
||||
|
||||
// Global admin sees the board (not the admin-only "not authorized" gate).
|
||||
const board = page.locator(
|
||||
'text=/MRR|Revenue|Active orgs|Customers|Usage cost|Top agents|Fleet|Business/i'
|
||||
).first()
|
||||
await expect(board, 'admin business board did not render for the global admin').toBeVisible({ timeout: 30_000 })
|
||||
// The client admin gate did NOT block z (would show a forbidden/hidden state).
|
||||
await expect(page.locator('text=/not authorized|access denied|admin only|forbidden/i')).toHaveCount(0)
|
||||
|
||||
await page.screenshot({ path: `${SHOTS}/a-business-board.png`, fullPage: true })
|
||||
console.log('✓ (a) admin business board rendered for global admin z@hanzo.ai')
|
||||
})
|
||||
|
||||
// (a2) The god-view gate is consistent with the account's ACTUAL grant. z@hanzo.ai
|
||||
// lives in org `hanzo` (a brand/org admin), NOT the global `admin` org, so the
|
||||
// all-orgs god view is correctly refused (403) and the board shows the honest
|
||||
// "managed by Hanzo" fallback — no fabricated cross-org KPIs, no leak. A member of
|
||||
// the `admin` org would instead get 200 and the KPI board. Either way the gate is
|
||||
// fail-closed and matches what the board renders.
|
||||
test('(a2) god-view gate matches the account grant (hanzo org-admin → honest 403)', async ({ page }) => {
|
||||
await signIn(page, CONSOLE)
|
||||
const acct = await (await page.request.get(`${CONSOLE}/v1/get-account`)).json().catch(() => ({}))
|
||||
const owner = acct?.data?.owner
|
||||
const res = await page.request.get(`${CONSOLE}/v1/admin/overview`)
|
||||
if (owner === 'admin') {
|
||||
expect(res.status(), 'global admin must pass the gate').not.toBe(403)
|
||||
console.log(`✓ (a2) global-admin (org=admin) /v1/admin/overview → ${res.status()} (gate passed)`)
|
||||
} else {
|
||||
expect(res.status(), 'non-global-admin must be refused the god view').toBe(403)
|
||||
console.log(`✓ (a2) org-admin (org=${owner}) → 403 on the god view; board shows honest managed fallback (no cross-org leak)`)
|
||||
}
|
||||
})
|
||||
|
||||
// (a3) admin.hanzo.ai: after establishing the shared `.hanzo.ai` session, the
|
||||
// edge guard should admit the global admin and render the same board. If the
|
||||
// guard still refuses (its own OIDC bootstrap), record the honest state — the
|
||||
// board is proven on console.hanzo.ai (same image) and the edge gate is proven
|
||||
// fail-closed above.
|
||||
test('(a3) admin.hanzo.ai admits the global admin (or is honestly edge-gated)', async ({ page }) => {
|
||||
await signIn(page, CONSOLE) // sets the `.hanzo.ai`-scoped session
|
||||
const res = await page.request.get(`${ADMIN}/`)
|
||||
if (res.status() === 200) {
|
||||
await page.goto(`${ADMIN}/business`, { waitUntil: 'domcontentloaded' })
|
||||
const board = page.locator('text=/MRR|Revenue|Active orgs|Customers|Top agents|Business/i').first()
|
||||
await expect(board).toBeVisible({ timeout: 30_000 })
|
||||
await page.screenshot({ path: `${SHOTS}/a3-admin-host-board.png`, fullPage: true })
|
||||
console.log('✓ (a3) admin.hanzo.ai admitted the global admin; board rendered')
|
||||
} else {
|
||||
console.log(`ℹ (a3) admin.hanzo.ai edge-guard returned ${res.status()} for the shared session — board verified on console.hanzo.ai (same image)`)
|
||||
}
|
||||
})
|
||||
|
||||
test('(c) billing Reports renders the cost-dimension surface', async ({ page }) => {
|
||||
await signIn(page, CONSOLE)
|
||||
// v8.4.16: the data proxy moved to /billing/v1/*, so /billing/reports now falls
|
||||
// through to the SPA (was shadowed by the /billing/[...path] proxy → raw JSON).
|
||||
// A hard deep-link must render the Reports UI, not a proxy "not found".
|
||||
await page.goto(`${CONSOLE}/billing/reports`, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page).not.toHaveURL(/\/signin/, { timeout: 15_000 })
|
||||
// The route no longer resolves to the commerce proxy JSON.
|
||||
await expect(page.locator('text=/^\\{"error":"not found"\\}$|could not be found/i'),
|
||||
'reports still shadowed by the /billing proxy').toHaveCount(0, { timeout: 20_000 })
|
||||
|
||||
// The Cost-table Reports surface: the "spend by <dimension>" control. model +
|
||||
// provider are always offered; product + agent appear the moment the commerce
|
||||
// ledger tags a row (honest — never a fabricated column). Assert a dimension
|
||||
// affordance renders (BillingReports mounted).
|
||||
const dim = page.getByText(/by model|by provider|by product|by agent|group by|dimension|spend by|Cost by/i).first()
|
||||
await expect(dim, 'cost dimension control did not render').toBeVisible({ timeout: 30_000 })
|
||||
await expect(page.locator('text=/something went wrong|application error/i')).toHaveCount(0)
|
||||
|
||||
await page.screenshot({ path: `${SHOTS}/c-billing-reports.png`, fullPage: true })
|
||||
console.log('✓ (c) /billing/reports rendered the BillingReports cost-dimension control (unshadowed)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* e2e: the Open Edition (run-for-pay) overview renders its real, product-specific
|
||||
* content — not just "a page mounted".
|
||||
*
|
||||
* `pages.spec.ts` proves /open-edition is reachable + doesn't crash (the generic
|
||||
* sweep). THIS spec proves the run-for-pay board actually rendered the things that
|
||||
* make it the Open Edition board: the "Open Edition" heading, the run-for-pay
|
||||
* framing, the "cost + 25%" margin caption on the Spend KPI, and the spend/tokens
|
||||
* KPI labels — all sourced from the config in
|
||||
* src/components/products/overview/living/registry.ts (id `open-edition`), which
|
||||
* reads the REAL commerce usage ledger scoped to the open-edition product tag.
|
||||
*
|
||||
* These are content assertions on the live surface, not a stub: if the config is
|
||||
* unwired, the route unrouted, or the board silently swapped for another usage
|
||||
* view, a specific assertion fails.
|
||||
*
|
||||
* Credentials (env, never in repo):
|
||||
* HANZO_EMAIL default z@hanzo.ai (global admin — sees every product)
|
||||
* HANZO_PASSWORD required (skips when unset)
|
||||
* BASE_URL default https://console.hanzo.ai
|
||||
*
|
||||
* Run: HANZO_PASSWORD=xxx pnpm e2e open-edition.spec.ts
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
const EMAIL = process.env.HANZO_EMAIL ?? 'z@hanzo.ai'
|
||||
const PASSWORD = process.env.HANZO_PASSWORD ?? ''
|
||||
const BASE_URL = process.env.BASE_URL ?? 'https://console.hanzo.ai'
|
||||
|
||||
async function signIn(page: Page) {
|
||||
await page.goto(`${BASE_URL}/signin`)
|
||||
await page.waitForSelector('input[placeholder="Email"]', { timeout: 20_000 })
|
||||
await page.fill('input[placeholder="Email"]', EMAIL)
|
||||
await page.fill('input[placeholder="Password"]', PASSWORD)
|
||||
await page.click('button:has-text("Sign in")')
|
||||
const base = new URL(BASE_URL).origin
|
||||
await page.waitForURL((url) => url.origin === base && url.pathname === '/', { timeout: 30_000 })
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
}
|
||||
|
||||
test.describe('Open Edition — run-for-pay overview renders real content', () => {
|
||||
test.skip(!PASSWORD, 'HANZO_PASSWORD not set — skipping authenticated Open Edition visual gate')
|
||||
|
||||
let ctx: import('@playwright/test').BrowserContext
|
||||
let page: Page
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
ctx = await browser.newContext()
|
||||
page = await ctx.newPage()
|
||||
await signIn(page)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await ctx?.close()
|
||||
})
|
||||
|
||||
test('the /open-edition board mounts, is reachable, and does not crash', async () => {
|
||||
const errors: string[] = []
|
||||
const onErr = (e: Error) => errors.push(String(e))
|
||||
page.on('pageerror', onErr)
|
||||
|
||||
const res = await page.goto(`${BASE_URL}/open-edition`, { waitUntil: 'domcontentloaded' })
|
||||
expect(res?.status() ?? 0, '/open-edition HTTP').toBeLessThan(500)
|
||||
|
||||
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {})
|
||||
await expect(page.locator('text=/Application error|Unhandled Runtime Error/i')).toHaveCount(0)
|
||||
|
||||
const bodyText = (await page.locator('body').innerText().catch(() => '')) || ''
|
||||
expect(bodyText.trim().length, '/open-edition rendered content').toBeGreaterThan(0)
|
||||
|
||||
page.off('pageerror', onErr)
|
||||
if (errors.length) console.log(`⚠ /open-edition pageerror: ${errors.join(' | ').slice(0, 200)}`)
|
||||
})
|
||||
|
||||
test('renders the Open Edition run-for-pay header + the cost+25% Spend KPI', async () => {
|
||||
await page.goto(`${BASE_URL}/open-edition`, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {})
|
||||
|
||||
// The product heading — this is the Open Edition board, not a generic usage view.
|
||||
await expect(page.getByText('Open Edition', { exact: false }).first()).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
// The run-for-pay framing from the config subtitle (`Run open-source workloads
|
||||
// for pay …`). Matches on the distinctive phrase so it can't pass on another board.
|
||||
await expect(page.getByText(/run open-source workloads for pay/i).first()).toBeVisible()
|
||||
|
||||
// The load-bearing pricing detail: the Spend KPI carries the "cost + 25% margin"
|
||||
// caption (the served revenue R = cost + resell margin). This is the visible
|
||||
// proof the 25% run-for-pay model is surfaced, not just tokens.
|
||||
await expect(page.getByText(/cost \+ 25% margin/i).first()).toBeVisible()
|
||||
|
||||
// The run-for-pay KPI labels the config declares (spend billed, tokens run).
|
||||
await expect(page.getByText(/spend billed/i).first()).toBeVisible()
|
||||
await expect(page.getByText(/tokens run/i).first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('captures a full-page screenshot of the Open Edition board', async () => {
|
||||
await page.goto(`${BASE_URL}/open-edition`, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {})
|
||||
// Wait for the heading so the shot is of the rendered board, not a spinner frame.
|
||||
await expect(page.getByText('Open Edition', { exact: false }).first()).toBeVisible({ timeout: 20_000 })
|
||||
await page.screenshot({ path: 'e2e/screenshots/open-edition.png', fullPage: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* e2e: screenshot every console page + assert each renders (FE↔BE wired).
|
||||
*
|
||||
* Signs in as z@hanzo.ai (global admin, sees every product), then visits all 89
|
||||
* registered product routes. For each page it:
|
||||
* - navigates to /<id>,
|
||||
* - waits for hydration,
|
||||
* - asserts the app shell is present and the page did NOT hit a hard crash
|
||||
* (Next error overlay / "Application error" / a blank body),
|
||||
* - captures a full-page screenshot into e2e/screenshots/<id>.png.
|
||||
*
|
||||
* This is the "screenshot it all, make sure every page is wired" pass. It does
|
||||
* NOT click destructive buttons — it proves each surface mounts, renders real
|
||||
* state (or an honest empty/loading/403), and is reachable end to end. Deeper
|
||||
* per-button flows live in console.spec.ts (API key create/rotate, inference).
|
||||
*
|
||||
* Credentials (env, never in repo):
|
||||
* HANZO_EMAIL default z@hanzo.ai
|
||||
* HANZO_PASSWORD required (skips when unset)
|
||||
* BASE_URL default https://console.hanzo.ai
|
||||
*
|
||||
* Run: HANZO_PASSWORD=xxx pnpm e2e pages.spec.ts
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
const EMAIL = process.env.HANZO_EMAIL ?? 'z@hanzo.ai'
|
||||
const PASSWORD = process.env.HANZO_PASSWORD ?? ''
|
||||
const BASE_URL = process.env.BASE_URL ?? 'https://console.hanzo.ai'
|
||||
|
||||
/**
|
||||
* Every product route id in the registry (src/lib/products/registry.tsx).
|
||||
* Kept as a literal list so the spec has zero import coupling to the app bundle;
|
||||
* a page added to the registry gets a screenshot by adding its id here.
|
||||
*/
|
||||
const PAGES: string[] = [
|
||||
'overview', 'dashboards', 'status',
|
||||
// AI
|
||||
'models', 'model-catalog' /* alias */, 'providers', 'playground', 'chat', 'inference',
|
||||
'agents', 'prompts', 'finetuning', 'embeddings', 'evals', 'datasets', 'experiments',
|
||||
'annotation-queues', 'scores', 'score-configs', 'observations', 'traces', 'sessions',
|
||||
// Compute / hypervisor
|
||||
'machines', 'gpus', 'clusters', 'kubernetes', 'containers', 'networks', 'vpc',
|
||||
'load-balancer', 'service-mesh', 'edge', 'zero-trust', 'dns', 'cdn',
|
||||
// Data / storage
|
||||
's3', 'sql', 'vector', 'datastore', 'kv', 'search', 'docdb', 'base', 'memory', 'indexer',
|
||||
// Platform
|
||||
'functions', 'pipelines', 'builds', 'releases', 'environments', 'projects', 'oracles',
|
||||
// Identity / security
|
||||
'iam', 'users', 'team', 'authz', 'kms', 'secrets', 'mpc', 'hsm', 'attestations',
|
||||
'api-keys', 'tokens', 'applications',
|
||||
// Business / analytics — the unified Billing Center + its sub-pages (Cost /
|
||||
// Subscriptions / Payment-methods are now tabs of /billing, not top-level routes).
|
||||
'billing', 'billing/reports', 'billing/budgets', 'billing/invoices',
|
||||
'billing/subscriptions', 'billing/payment-methods', 'billing/credits',
|
||||
'ai-metrics', 'open-edition', 'metrics', 'settlement', 'wallet', 'plans', 'referrals', 'marketplace',
|
||||
// Ops / tooling
|
||||
'logs', 'alerts', 'o11y', 'gateway', 'tasks', 'integrations', 'registry', 'audit',
|
||||
'sdks', 'cli', 'ide', 'studio', 'desktop', 'bot', 'profile', 'settings',
|
||||
]
|
||||
|
||||
async function signIn(page: Page) {
|
||||
await page.goto(`${BASE_URL}/signin`)
|
||||
await page.waitForSelector('input[placeholder="Email"]', { timeout: 20_000 })
|
||||
await page.fill('input[placeholder="Email"]', EMAIL)
|
||||
await page.fill('input[placeholder="Password"]', PASSWORD)
|
||||
await page.click('button:has-text("Sign in")')
|
||||
const base = new URL(BASE_URL).origin
|
||||
await page.waitForURL((url) => url.origin === base && url.pathname === '/', { timeout: 30_000 })
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
}
|
||||
|
||||
// Sign in ONCE and reuse the session for all 94 pages. A per-test login (94×)
|
||||
// trips IAM's "too many login attempts" rate-limit around page 35, which is a
|
||||
// SECURITY FEATURE working correctly — not a page failure. One shared context
|
||||
// signs in once, then every page reuses the cookie. Serial so they share it.
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.describe('Hanzo Cloud Console — every page renders + screenshot', () => {
|
||||
test.skip(!PASSWORD, 'HANZO_PASSWORD not set — skipping authenticated screenshot pass')
|
||||
|
||||
let ctx: import('@playwright/test').BrowserContext
|
||||
let page: Page
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
ctx = await browser.newContext()
|
||||
page = await ctx.newPage()
|
||||
await signIn(page)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await ctx?.close()
|
||||
})
|
||||
|
||||
for (const id of PAGES) {
|
||||
test(`page: /${id}`, async () => {
|
||||
const errors: string[] = []
|
||||
const onErr = (e: Error) => errors.push(String(e))
|
||||
page.on('pageerror', onErr)
|
||||
|
||||
const res = await page.goto(`${BASE_URL}/${id}`, { waitUntil: 'domcontentloaded' })
|
||||
// Route must not 5xx.
|
||||
expect(res?.status() ?? 0, `/${id} HTTP`).toBeLessThan(500)
|
||||
|
||||
// App shell mounted (the console renders a nav + main region on every page).
|
||||
await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {})
|
||||
|
||||
// Hard-crash guards: no Next error overlay, no generic crash banner.
|
||||
await expect(page.locator('text=/Application error|Unhandled Runtime Error/i')).toHaveCount(0)
|
||||
|
||||
// The body must have real content (not a blank white page).
|
||||
const bodyText = (await page.locator('body').innerText().catch(() => '')) || ''
|
||||
expect(bodyText.trim().length, `/${id} rendered content`).toBeGreaterThan(0)
|
||||
|
||||
await page.screenshot({ path: `e2e/screenshots/${id}.png`, fullPage: true })
|
||||
|
||||
page.off('pageerror', onErr)
|
||||
// Surface (don't fail on) any console page errors for triage.
|
||||
if (errors.length) console.log(`⚠ /${id} pageerror: ${errors.join(' | ').slice(0, 200)}`)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* LIVE probe of the o11y (SigNoz) backend through the console's own /cloud bearer
|
||||
* proxy. Logs in as z@hanzo.ai and, from the AUTHENTICATED page context, fetches
|
||||
* each candidate endpoint exactly as the new Observe modules will — same-origin
|
||||
* `<origin>/v1/o11y/*` (rewritten to `/cloud/v1/o11y/*`, cloud rewrites to the o11y
|
||||
* runtime's `/api/*`). Prints the HTTP status + a body snippet per endpoint so we
|
||||
* know what returns real data vs 404 (which must be flagged) before we build.
|
||||
*
|
||||
* Not a pass/fail test — a discovery harness. Run:
|
||||
* BASE_URL=https://console.hanzo.ai HANZO_PASSWORD='…' npx playwright test probe-o11y --reporter=line
|
||||
*/
|
||||
import { test, type Page } from '@playwright/test'
|
||||
|
||||
const EMAIL = process.env.HANZO_EMAIL ?? 'z@hanzo.ai'
|
||||
const PASSWORD = process.env.HANZO_PASSWORD ?? ''
|
||||
const BASE_URL = process.env.BASE_URL ?? 'https://console.hanzo.ai'
|
||||
|
||||
async function signIn(page: Page) {
|
||||
await page.goto(`${BASE_URL}/signin`)
|
||||
await page.waitForSelector('input[placeholder="Email"]', { timeout: 20_000 })
|
||||
await page.fill('input[placeholder="Email"]', EMAIL)
|
||||
await page.fill('input[placeholder="Password"]', PASSWORD)
|
||||
await page.click('button:has-text("Sign in")')
|
||||
const base = new URL(BASE_URL).origin
|
||||
// Resilient: the post-login target may be '/' (dashboard) OR '/onboard' (org gate)
|
||||
// OR it may just set the session cookie while staying put briefly. Wait for the
|
||||
// sign-in form to DISAPPEAR (we left the /signin gate), whatever the destination.
|
||||
await page
|
||||
.waitForFunction(() => !document.querySelector('input[placeholder="Password"]'), { timeout: 90_000 })
|
||||
.catch(() => {})
|
||||
// Give the session cookie + any redirect a moment to settle, then land on '/'.
|
||||
await page.goto(base, { waitUntil: 'domcontentloaded' }).catch(() => {})
|
||||
await page.waitForTimeout(2500)
|
||||
}
|
||||
|
||||
/** now() epoch — SigNoz wants ns for services/errors, ms for infra. */
|
||||
const nowMs = Date.now()
|
||||
const endNs = String(nowMs * 1_000_000)
|
||||
const startNs = String((nowMs - 60 * 60 * 1000) * 1_000_000) // 1h window
|
||||
const endMs = nowMs
|
||||
const startMs = nowMs - 60 * 60 * 1000
|
||||
|
||||
type Probe = { name: string; path: string; method: 'GET' | 'POST'; body?: unknown }
|
||||
|
||||
const PROBES: Probe[] = [
|
||||
// ── Dashboards (SigNoz) ──
|
||||
{ name: 'dashboards.list', path: 'o11y/v1/dashboards', method: 'GET' },
|
||||
{ name: 'dashboards.v2', path: 'o11y/v2/dashboards', method: 'GET' },
|
||||
// ── Service map / APM ──
|
||||
{ name: 'services.list', path: 'o11y/v1/services/list', method: 'GET' },
|
||||
{
|
||||
name: 'services',
|
||||
path: 'o11y/v1/services',
|
||||
method: 'POST',
|
||||
body: { start: startNs, end: endNs, tags: [] },
|
||||
},
|
||||
{
|
||||
name: 'dependency_graph',
|
||||
path: 'o11y/v1/dependency_graph',
|
||||
method: 'POST',
|
||||
body: { start: startNs, end: endNs, tags: [] },
|
||||
},
|
||||
{
|
||||
name: 'service.top_operations',
|
||||
path: 'o11y/v1/service/top_operations',
|
||||
method: 'POST',
|
||||
body: { start: startNs, end: endNs, service: '' },
|
||||
},
|
||||
// ── Infra ──
|
||||
{
|
||||
name: 'hosts.list',
|
||||
path: 'o11y/v1/hosts/list',
|
||||
method: 'POST',
|
||||
body: { start: startMs, end: endMs, filters: { op: 'AND', items: [] } },
|
||||
},
|
||||
{
|
||||
name: 'pods.list',
|
||||
path: 'o11y/v1/pods/list',
|
||||
method: 'POST',
|
||||
body: { start: startMs, end: endMs, filters: { op: 'AND', items: [] } },
|
||||
},
|
||||
{
|
||||
name: 'nodes.list',
|
||||
path: 'o11y/v1/nodes/list',
|
||||
method: 'POST',
|
||||
body: { start: startMs, end: endMs, filters: { op: 'AND', items: [] } },
|
||||
},
|
||||
{
|
||||
name: 'namespaces.list',
|
||||
path: 'o11y/v1/namespaces/list',
|
||||
method: 'POST',
|
||||
body: { start: startMs, end: endMs, filters: { op: 'AND', items: [] } },
|
||||
},
|
||||
{
|
||||
name: 'clusters.list',
|
||||
path: 'o11y/v1/clusters/list',
|
||||
method: 'POST',
|
||||
body: { start: startMs, end: endMs, filters: { op: 'AND', items: [] } },
|
||||
},
|
||||
// ── Exceptions ──
|
||||
{
|
||||
name: 'listErrors',
|
||||
path: 'o11y/v1/listErrors',
|
||||
method: 'POST',
|
||||
body: { start: startNs, end: endNs, limit: 50, order: 'descending', orderParam: 'exceptionCount' },
|
||||
},
|
||||
{
|
||||
name: 'countErrors',
|
||||
path: 'o11y/v1/countErrors',
|
||||
method: 'POST',
|
||||
body: { start: startNs, end: endNs },
|
||||
},
|
||||
// ── Health (sanity: proves the runtime is reachable) ──
|
||||
{ name: 'health', path: 'o11y/v1/health', method: 'GET' },
|
||||
{ name: 'version', path: 'o11y/v1/version', method: 'GET' },
|
||||
// ── Alerts (known-good baseline — AlertsModule already uses this) ──
|
||||
{ name: 'rules', path: 'o11y/v1/rules', method: 'GET' },
|
||||
]
|
||||
|
||||
test('probe o11y endpoints (live, authenticated)', async ({ page }) => {
|
||||
test.setTimeout(180_000)
|
||||
if (!PASSWORD) throw new Error('HANZO_PASSWORD required for the live probe')
|
||||
await signIn(page)
|
||||
|
||||
const results = await page.evaluate(
|
||||
async ({ probes }: { probes: Probe[] }) => {
|
||||
const out: { name: string; status: number; ok: boolean; snippet: string }[] = []
|
||||
for (const p of probes) {
|
||||
try {
|
||||
const res = await fetch(`${window.location.origin}/v1/${p.path}`, {
|
||||
method: p.method,
|
||||
credentials: 'include',
|
||||
headers: p.body !== undefined ? { 'Content-Type': 'application/json' } : {},
|
||||
body: p.body !== undefined ? JSON.stringify(p.body) : undefined,
|
||||
})
|
||||
const text = await res.text()
|
||||
out.push({ name: p.name, status: res.status, ok: res.ok, snippet: text.slice(0, 240) })
|
||||
} catch (e) {
|
||||
out.push({ name: p.name, status: -1, ok: false, snippet: String(e).slice(0, 240) })
|
||||
}
|
||||
}
|
||||
return out
|
||||
},
|
||||
{ probes: PROBES },
|
||||
)
|
||||
|
||||
console.log('\n================ O11Y LIVE PROBE ================')
|
||||
for (const r of results) {
|
||||
console.log(`\n[${r.status}] ${r.name}`)
|
||||
console.log(` ${r.snippet.replace(/\n/g, ' ')}`)
|
||||
}
|
||||
console.log('\n================ END PROBE ================\n')
|
||||
})
|
||||
+16
-1
@@ -1,7 +1,22 @@
|
||||
import { defaultConfig } from '@hanzogui/config/v5'
|
||||
import { createGui } from '@hanzo/gui'
|
||||
|
||||
export const config = createGui(defaultConfig)
|
||||
// Canonical Hanzo UI face: Basel Grotesk (self-hosted via app/globals.css @font-face),
|
||||
// paired with Geist Mono for code/data. Override the @hanzo/gui (Tamagui) v5 default
|
||||
// system-font family on the body + heading fonts so every <Text>/<Paragraph>/<H*>
|
||||
// renders Basel — one place, whole product (DRY). Size/line-height/weight scales are
|
||||
// inherited from the default config; only the family swaps.
|
||||
const BASEL =
|
||||
"'Basel', -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
|
||||
|
||||
export const config = createGui({
|
||||
...defaultConfig,
|
||||
fonts: {
|
||||
...defaultConfig.fonts,
|
||||
body: { ...defaultConfig.fonts.body, family: BASEL },
|
||||
heading: { ...defaultConfig.fonts.heading, family: BASEL },
|
||||
},
|
||||
})
|
||||
|
||||
export default config
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
/**
|
||||
* Registers our Gui config with the type system so component style props
|
||||
* (tokens, themes, shorthands) are typed. `GuiCustomConfig` is declared in
|
||||
* `@hanzogui/web` and re-exported across the Gui packages; augmenting it there
|
||||
* flows the types through every `@hanzo/gui` component.
|
||||
* Registers our Gui config with the type system so shorthand style props
|
||||
* (tokens, themes, bg/px/py/items/justify etc.) are typed correctly.
|
||||
* GuiCustomConfig is declared in @hanzogui/web and flows through @hanzo/gui.
|
||||
*/
|
||||
import type { Conf } from './gui.config'
|
||||
|
||||
declare module '@hanzogui/web' {
|
||||
interface GuiCustomConfig extends Conf {}
|
||||
}
|
||||
|
||||
declare module '@hanzogui/core' {
|
||||
interface GuiCustomConfig extends Conf {}
|
||||
}
|
||||
|
||||
+129
-2
@@ -1,4 +1,4 @@
|
||||
import { readdirSync } from 'node:fs'
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
@@ -20,6 +20,12 @@ import { dirname, join } from 'node:path'
|
||||
*/
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
// Single source of the app version: package.json. Exposed to the browser as
|
||||
// NEXT_PUBLIC_APP_VERSION so the shell can render the shared "Hanzo Cloud <MAJOR.MINOR>"
|
||||
// product-release label (console app-semver; cloud ships its own Go v1.x under the
|
||||
// same umbrella). No second place holds the version.
|
||||
const pkgVersion = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8')).version
|
||||
|
||||
/** Every installed `@hanzogui/*` package, discovered (not hardcoded). */
|
||||
function guiPackages() {
|
||||
const dir = join(__dirname, 'node_modules', '@hanzogui')
|
||||
@@ -29,13 +35,134 @@ function guiPackages() {
|
||||
} catch {
|
||||
scoped = []
|
||||
}
|
||||
return ['@hanzo/gui', '@hanzo/iam-js-sdk', 'react-native-web', ...scoped]
|
||||
// @hanzo/dash and @hanzo/data ship their screens/components as ESM/TSX
|
||||
// source (no compiled dist), so the shared Base UI is transpiled here the same
|
||||
// way Gui is.
|
||||
return ['@hanzo/gui', '@hanzo/iam-js-sdk', '@hanzo/dash', '@hanzo/data', 'react-native-web', ...scoped]
|
||||
}
|
||||
|
||||
/**
|
||||
* Same-origin `/v1/*` for the AI product surface — ZERO client-visible prefix.
|
||||
*
|
||||
* The CTO contract is "no prefix before /v1/ in any API call": the browser calls
|
||||
* its OWN origin at a clean `/v1/<head>/...`, never `/cloud/...` or `/ai/...`.
|
||||
* These rewrites map exactly the AI-surface heads to the console's already-hardened
|
||||
* server-side bearer proxies (`app/cloud`, `app/ai`) — so the URL the client builds
|
||||
* is `/v1/prompts` while the request still terminates at OUR Next origin, which
|
||||
* mints a short-lived user bearer and forwards it (the raw session cookie NEVER
|
||||
* reaches cloud-api, so cloud-api carries no cookie-CSRF surface). This gives the
|
||||
* one-endpoint-form goal WITHOUT weakening the bearer trust boundary.
|
||||
*
|
||||
* Scope is deliberately the CLOSED head list the AI clients use (prompts/agents/
|
||||
* evals via /cloud, models/chat/embeddings/rerank via /ai) — a blanket `/v1/:path*`
|
||||
* would shadow paths meant for other backends. Each destination handler still
|
||||
* enforces its own least-privilege allow-list (`proxy-allow.ts`), so a rewrite can
|
||||
* never widen what the proxy admits. `beforeFiles` so these win over any route.
|
||||
*
|
||||
* The admin AGGREGATE reads (`/v1/admin/{overview,usage,orgs,audit,products}` — the
|
||||
* cross-tenant business/platform board) map to the GLOBAL-ADMIN-GATED proxy
|
||||
* (`app/admin/aggregate`), which runs `getAdminGate` (fail-closed 403) BEFORE
|
||||
* forwarding. This is the console-side server gate for the all-orgs god view (RED
|
||||
* H1) — NOT the ungated `/cloud` proxy. `admin/iam` + `admin/kms` are deliberately
|
||||
* NOT rewritten here: they keep their own gated proxies with their own tenant
|
||||
* scoping, and are reached by the client's explicit `/admin/*` origin path.
|
||||
*/
|
||||
const CLOUD_V1_HEADS = ['prompts', 'agents', 'evals', 'analytics', 'templates', 'projects', 'platform', 'crm', 'ml', 'vpcs', 'load-balancers', 'networks', 'mesh', 'edge', 'indexers', 'oracles', 'authz', 'o11y', 'websearch', 'enablement']
|
||||
const AI_V1_HEADS = ['models', 'chat', 'embeddings', 'rerank', 'audio', 'images', 'videos']
|
||||
// The admin aggregate heads rewritten to the GLOBAL-ADMIN-GATED proxy. `providers`
|
||||
// is the AI-provider control board — its GET (the list) AND its POST mutations
|
||||
// (`providers/toggle`, `providers/primary`) both match the `/:path*` rewrite below,
|
||||
// which is method-agnostic (Next matches on the URL), so POST is covered without a
|
||||
// second entry. Keep this in sync with `admin-aggregate.ts` ADMIN_AGGREGATE_HEADS.
|
||||
const ADMIN_V1_HEADS = ['overview', 'usage', 'orgs', 'audit', 'products', 'finance', 'compute', 'providers', 'customers', 'revenue', 'analytics', 'enablement']
|
||||
/**
|
||||
* DEV-ONLY: proxy the client's direct-cloud `/v1/{iam,o11y}/*` calls (get-account,
|
||||
* annotation-queues/users) to a real cloud backend so `npm run dev` renders the
|
||||
* authenticated shell locally. Enabled ONLY when `DEV_CLOUD_ORIGIN` is set (never in
|
||||
* the built image), so production is unchanged — there the console host's edge routes
|
||||
* `/v1` to cloud-api. The request cookie is forwarded by the rewrite, so the local
|
||||
* dev session resolves against the real cloud.
|
||||
*/
|
||||
const DEV_CLOUD_ORIGIN = process.env.DEV_CLOUD_ORIGIN?.replace(/\/+$/, '')
|
||||
const devCloudRewrites = () =>
|
||||
DEV_CLOUD_ORIGIN
|
||||
? [
|
||||
{ source: '/v1/iam/:path*', destination: `${DEV_CLOUD_ORIGIN}/v1/iam/:path*` },
|
||||
{ source: '/v1/o11y/:path*', destination: `${DEV_CLOUD_ORIGIN}/v1/o11y/:path*` },
|
||||
]
|
||||
: []
|
||||
|
||||
// Native cloud INFRA + managed-data heads the data-product clients call at a clean
|
||||
// `/v1/<head>` (nothing before /v1/); each is rewritten to the same-origin user-
|
||||
// bearer `/cloud` proxy (app/cloud) — which mints a per-user token and forwards to
|
||||
// cloud-api — and is allow-listed in proxy-allow.ts CLOUD_HEADS (defense in depth).
|
||||
const CLOUD_INFRA_V1_HEADS = ['machines', 'gpus', 'clusters', 'org', 'sql', 'vector', 'datastore', 'kv', 'search', 's3', 'docdb']
|
||||
// Public compute CATALOG (regions / CPU sizes) → the same-origin visor `/vm` proxy
|
||||
// (app/vm). The GPU-accelerator catalog is the DISTINCT head `/v1/gpu-sizes` so it
|
||||
// never collides with the cloud-api GPU INVENTORY at `/v1/gpus`.
|
||||
const VM_V1_HEADS = ['regions', 'sizes']
|
||||
|
||||
const aiSurfaceRewrites = () => ({
|
||||
beforeFiles: [
|
||||
...CLOUD_V1_HEADS.map((h) => ({ source: `/v1/${h}`, destination: `/cloud/v1/${h}` })),
|
||||
...CLOUD_V1_HEADS.map((h) => ({ source: `/v1/${h}/:path*`, destination: `/cloud/v1/${h}/:path*` })),
|
||||
...AI_V1_HEADS.map((h) => ({ source: `/v1/${h}`, destination: `/ai/v1/${h}` })),
|
||||
...AI_V1_HEADS.map((h) => ({ source: `/v1/${h}/:path*`, destination: `/ai/v1/${h}/:path*` })),
|
||||
...ADMIN_V1_HEADS.map((h) => ({ source: `/v1/admin/${h}`, destination: `/admin/aggregate/${h}` })),
|
||||
...ADMIN_V1_HEADS.map((h) => ({ source: `/v1/admin/${h}/:path*`, destination: `/admin/aggregate/${h}/:path*` })),
|
||||
// Data-product clients (compute / visor / platform / provisioning / storage) —
|
||||
// clean `/v1/<head>` → the user-bearer `/cloud` proxy (org from the Bearer owner).
|
||||
...CLOUD_INFRA_V1_HEADS.map((h) => ({ source: `/v1/${h}`, destination: `/cloud/v1/${h}` })),
|
||||
...CLOUD_INFRA_V1_HEADS.map((h) => ({ source: `/v1/${h}/:path*`, destination: `/cloud/v1/${h}/:path*` })),
|
||||
// Public compute catalog → the visor `/vm` proxy.
|
||||
...VM_V1_HEADS.map((h) => ({ source: `/v1/${h}`, destination: `/vm/v1/${h}` })),
|
||||
...VM_V1_HEADS.map((h) => ({ source: `/v1/${h}/:path*`, destination: `/vm/v1/${h}/:path*` })),
|
||||
{ source: `/v1/gpu-sizes`, destination: `/vm/v1/gpus` },
|
||||
// Per-tenant billing DATA → the service-token commerce proxy (app/billing/v1).
|
||||
{ source: `/v1/billing/:path*`, destination: `/billing/v1/:path*` },
|
||||
...devCloudRewrites(),
|
||||
],
|
||||
})
|
||||
|
||||
/**
|
||||
* EMBED MODE (the "True 1-binary FE" target — task #41).
|
||||
*
|
||||
* `npm run build:embed` sets CONSOLE_EMBED=1 and produces a STATIC EXPORT (`out/`)
|
||||
* that the hanzoai/cloud Go binary go:embeds and serves at its own web root. In this
|
||||
* mode the console SPA is same-origin with the cloud `/v1` API (config.cloudUrl
|
||||
* defaults to window.location.origin), so:
|
||||
*
|
||||
* - `output: 'export'` — emit a pure static bundle, no Node server.
|
||||
* - NO `rewrites` — a static export cannot run rewrites, and it does not need
|
||||
* them: the clean `/v1/<head>` calls the SPA already builds now terminate
|
||||
* DIRECTLY at the embedded cloud's mounted subsystems (prompts/agents/evals/…,
|
||||
* models/chat/embeddings/…, admin/*), which is exactly what the rewrites used
|
||||
* to forward to via the Next BFF. The BFF proxy routes (app/cloud, app/ai,
|
||||
* app/commerce, …) are the server, and in one-binary the cloud binary IS the
|
||||
* server — so they are simply absent from the export (see below).
|
||||
* - `images.unoptimized` — the export has no Image Optimization server.
|
||||
*
|
||||
* PRECONDITION for a clean `output:'export'`: the app/ tree must contain NO dynamic
|
||||
* server route handlers (a static export has no server runtime to run them).
|
||||
* Those handlers are the BFF proxies + the two standalone routes; the latter
|
||||
* (keys/onboard) are ported to cloud `/v1/console/*`, and the proxies collapse to
|
||||
* the cloud `/v1/*` the SPA calls directly. The embed build therefore runs against
|
||||
* a tree with every app route handler removed (the build:embed script prunes the
|
||||
* "route" files into a scratch stash so the server build on `main` is untouched).
|
||||
*
|
||||
* The normal `npm run build` is UNCHANGED (server build with rewrites) so nothing
|
||||
* regresses for the standalone console deployment during the transition.
|
||||
*/
|
||||
const EMBED = process.env.CONSOLE_EMBED === '1'
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
env: { NEXT_PUBLIC_APP_VERSION: pkgVersion },
|
||||
transpilePackages: guiPackages(),
|
||||
...(EMBED
|
||||
? { output: 'export', images: { unoptimized: true } }
|
||||
: { rewrites: aiSurfaceRewrites }),
|
||||
experimental: {
|
||||
esmExternals: true,
|
||||
},
|
||||
|
||||
Generated
+1681
-84
File diff suppressed because it is too large
Load Diff
+22
-9
@@ -1,36 +1,49 @@
|
||||
{
|
||||
"name": "@hanzo/console2",
|
||||
"version": "0.1.3",
|
||||
"name": "@hanzo/console",
|
||||
"version": "8.4.66",
|
||||
"private": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"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",
|
||||
"build:embed": "node scripts/build-embed.mjs",
|
||||
"start": "next start -p 4000",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"e2e": "playwright test",
|
||||
"e2e:headed": "playwright test --headed"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/dash": "0.3.0",
|
||||
"@hanzo/data": "^1.2.0",
|
||||
"@hanzo/gui": "7.3.0",
|
||||
"@hanzo/iam-js-sdk": "0.19.1",
|
||||
"@zap-proto/web": "1.0.0",
|
||||
"@zap-proto/zap": "1.6.0",
|
||||
"superjson": "2.2.2",
|
||||
"@hanzo/logo": "^1.0.7",
|
||||
"@hanzogui/config": "7.3.0",
|
||||
"@hanzogui/core": "7.3.0",
|
||||
"@hanzogui/lucide-icons-2": "7.3.0",
|
||||
"@hanzogui/next-theme": "7.3.0",
|
||||
"@luxfi/logo": "^1.0.1",
|
||||
"@zap-proto/web": "1.0.0",
|
||||
"@zap-proto/zap": "1.6.0",
|
||||
"@zooai/logo": "^1.0.2",
|
||||
"axe-core": "4.12.1",
|
||||
"ethers": "6.17.0",
|
||||
"next": "15.5.19",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"react-native-web": "0.21.2"
|
||||
"react-native-web": "0.21.2",
|
||||
"superjson": "2.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@types/node": "22.20.0",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"react-native": "0.83.9",
|
||||
"typescript": "5.9.3"
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Playwright e2e config targeting console.hanzo.ai (live) and localhost:4000 (dev).
|
||||
* Run against production: BASE_URL=https://console.hanzo.ai pnpm e2e
|
||||
* Run against dev: pnpm dev # then pnpm e2e (default)
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: 1,
|
||||
reporter: 'list',
|
||||
timeout: 60_000,
|
||||
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL ?? 'https://console.hanzo.ai',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
// Needed for Tamagui/RNW rendering (blocks by default on non-Chromium)
|
||||
headless: true,
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
})
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* build-embed.mjs — produce the STATIC console bundle the hanzoai/cloud Go binary
|
||||
* go:embeds (task #41, "True 1-binary FE"). Invoked by `npm run build:embed`.
|
||||
*
|
||||
* A Next `output: 'export'` build imposes two constraints this app does not meet on
|
||||
* `main` (it is a server build there). This script makes the app export-clean for
|
||||
* the duration of ONE build and ALWAYS restores it (a finally block; in CI the
|
||||
* checkout is disposable, locally it keeps the tree pristine), so nothing on `main`
|
||||
* and the normal `npm run build` are unaffected:
|
||||
*
|
||||
* 1) NO server route handlers. A static export has no runtime to run an
|
||||
* app/route.ts. This repo's route handlers are (a) BFF reverse-proxies that in
|
||||
* one-binary collapse to the cloud `/v1/*` the SPA calls directly, and (b) the
|
||||
* two standalone routes (keys/onboard) now ported to cloud `/v1/console/*`.
|
||||
* Either way they must be absent from the export → we STASH them.
|
||||
*
|
||||
* 2) Every dynamic page segment needs `generateStaticParams()`. The console's two
|
||||
* dynamic pages ([...slug] and discover/[id]) are `'use client'` catch-alls that
|
||||
* resolve their view CLIENT-side from the product registry — so the correct
|
||||
* static-export shape is "pre-render NONE; serve the SPA shell for every deep
|
||||
* link" (cloud/webui.go's serveIndex is exactly that fallback). A `'use client'`
|
||||
* module may not itself export the server-only generateStaticParams, so we
|
||||
* OVERLAY each dynamic page with a tiny SERVER wrapper (generateStaticParams →
|
||||
* [] + dynamicParams=false) that renders the original client body, which we move
|
||||
* beside it as `_body.tsx` (a leading-underscore dir/file is not a route).
|
||||
*
|
||||
* 3) NO request-time dynamic API in the ROOT LAYOUT. `output: 'export'` prerenders
|
||||
* EVERY page THROUGH app/layout.tsx, so a request-time read there — the root
|
||||
* `generateMetadata` calling `headers()` for a per-host <title> — throws in the
|
||||
* Server Components render for ALL pages (it surfaces on /_not-found and aborts
|
||||
* the export). The embed is same-origin and resolves brand CLIENT-side from
|
||||
* window.location, so the SSR title can safely fall back to the build-time
|
||||
* default; the browser re-renders the correct brand. So we NEUTRALIZE the
|
||||
* layout's `next/headers` read for the export (drop the import, host → undefined)
|
||||
* and restore the pristine layout after. Without this, the export FAILS and the
|
||||
* cloud image degrades to the committed fallback shell (the 1-binary console
|
||||
* silently ships as a stub).
|
||||
*
|
||||
* Steps: stash route handlers → overlay dynamic pages → neutralize root-layout
|
||||
* dynamic APIs → `CONSOLE_EMBED=1 next build` (emits out/) → restore everything.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { basename, dirname, join, relative } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const appDir = join(root, 'app')
|
||||
const stashDir = join(root, '.embed-stash')
|
||||
|
||||
/** Recursively collect files under dir whose basename matches re. */
|
||||
function findFiles(dir, re) {
|
||||
const out = []
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = join(dir, entry.name)
|
||||
if (entry.isDirectory()) out.push(...findFiles(p, re))
|
||||
else if (re.test(entry.name)) out.push(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Move a file, creating the destination's parent dirs. */
|
||||
function move(from, to) {
|
||||
mkdirSync(dirname(to), { recursive: true })
|
||||
renameSync(from, to)
|
||||
}
|
||||
|
||||
/** The dynamic segment of a page dir: `[id]` → "id", `[...slug]` → "slug", or "". */
|
||||
function dynamicSegment(file) {
|
||||
const m = basename(dirname(file)).match(/^\[(?:\.\.\.)?([^\]]+)\]$/)
|
||||
return m ? m[1] : ''
|
||||
}
|
||||
|
||||
/** Whether the dir name is a catch-all (`[...seg]`) vs a single segment (`[seg]`). */
|
||||
function isCatchAll(file) {
|
||||
return /^\[\.\.\./.test(basename(dirname(file)))
|
||||
}
|
||||
|
||||
/** A dynamic page needing generateStaticParams under output:export. */
|
||||
function isDynamicPage(file) {
|
||||
return basename(file) === 'page.tsx' && dynamicSegment(file) !== ''
|
||||
}
|
||||
|
||||
/**
|
||||
* The server wrapper written in place of a dynamic client page for the export.
|
||||
*
|
||||
* - `dynamic = 'force-static'`: the export prerender step treats the body's
|
||||
* dynamic reads (use(params)/searchParams) as static — it renders a placeholder
|
||||
* instead of throwing. The route's REAL client component still ships in the JS
|
||||
* bundle, so client-side navigation hydrates the true page; direct deep links
|
||||
* hit the embedding host's SPA fallback (cloud/webui.go serveIndex → index.html)
|
||||
* which re-resolves the route client-side.
|
||||
* - `generateStaticParams` returns ONE placeholder param (output:export requires a
|
||||
* non-empty set) keyed to the segment; a catch-all takes an array value.
|
||||
* - `dynamicParams = false`: no on-demand params (there is no server); everything
|
||||
* else is the SPA fallback. The real body lives beside this as `_body.tsx`.
|
||||
*/
|
||||
function wrapperSource(seg, catchAll) {
|
||||
const value = catchAll ? `['index']` : `'index'`
|
||||
return `// AUTO-GENERATED for \`output: 'export'\` by scripts/build-embed.mjs — do not commit.
|
||||
// The real (client) page body is ./_body.tsx (still bundled → client nav hydrates it);
|
||||
// this server wrapper only satisfies the static-export contract. See the script.
|
||||
import Body from './_body'
|
||||
|
||||
export const dynamic = 'force-static'
|
||||
export const dynamicParams = false
|
||||
export function generateStaticParams() {
|
||||
return [{ ${seg}: ${value} }]
|
||||
}
|
||||
export default function Page(props: any) {
|
||||
return <Body {...props} />
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite the root layout to be export-clean: `output: 'export'` prerenders EVERY
|
||||
* page through app/layout.tsx, so a request-time dynamic API there (next/headers'
|
||||
* `headers()`) throws in the Server Components render for ALL pages and aborts the
|
||||
* export (it surfaces on /_not-found). The root `generateMetadata` reads the Host
|
||||
* header only to brand the SSR <title>; in the same-origin embed the brand is
|
||||
* resolved CLIENT-side from window.location, so we drop the `next/headers` import
|
||||
* and resolve the host to `undefined` (→ the build-time default brand; the browser
|
||||
* re-renders the correct brand). Returns the pristine source (to restore in the
|
||||
* finally), or null when the layout has no request-time read to neutralize.
|
||||
*/
|
||||
function neutralizeLayout(file) {
|
||||
if (!existsSync(file)) return null
|
||||
const src = readFileSync(file, 'utf8')
|
||||
if (!/from ['"]next\/headers['"]/.test(src)) return null
|
||||
const patched = src
|
||||
.replace(/^\s*import\s*\{[^}]*\}\s*from\s*['"]next\/headers['"];?[^\n]*\n/m, '')
|
||||
.replace(/\(await\s+headers\(\)\)\.get\([^)]*\)\s*\?\?\s*undefined/g, 'undefined')
|
||||
.replace(/\(await\s+headers\(\)\)\.get\([^)]*\)/g, 'undefined')
|
||||
writeFileSync(file, patched, 'utf8')
|
||||
return src
|
||||
}
|
||||
|
||||
const routes = existsSync(appDir) ? findFiles(appDir, /^route\.(ts|tsx|js)$/) : []
|
||||
const dynamicPages = existsSync(appDir) ? findFiles(appDir, /^page\.tsx$/).filter(isDynamicPage) : []
|
||||
|
||||
const stashedRoutes = []
|
||||
const wrappedPages = [] // { page, body }
|
||||
const layoutFile = join(appDir, 'layout.tsx')
|
||||
let layoutPristine = null // pristine app/layout.tsx source while neutralized for the export
|
||||
|
||||
if (existsSync(stashDir)) rmSync(stashDir, { recursive: true, force: true })
|
||||
|
||||
try {
|
||||
// 1) Stash server route handlers out of the export.
|
||||
for (const r of routes) {
|
||||
const rel = relative(appDir, r)
|
||||
move(r, join(stashDir, 'routes', rel))
|
||||
stashedRoutes.push(rel)
|
||||
}
|
||||
|
||||
// 2) Overlay each dynamic page: keep a copy of the original, move the original to
|
||||
// _body.tsx, write the server wrapper as page.tsx.
|
||||
for (const page of dynamicPages) {
|
||||
const rel = relative(appDir, page)
|
||||
const pristine = join(stashDir, 'pages', rel)
|
||||
const body = join(dirname(page), '_body.tsx')
|
||||
mkdirSync(dirname(pristine), { recursive: true })
|
||||
copyFileSync(page, pristine) // pristine original for restore
|
||||
renameSync(page, body) // client body → non-route sibling
|
||||
writeFileSync(page, wrapperSource(dynamicSegment(page), isCatchAll(page)), 'utf8')
|
||||
wrappedPages.push({ page, body })
|
||||
}
|
||||
|
||||
// 3) Neutralize the root layout's request-time dynamic APIs (next/headers) so the
|
||||
// export prerender doesn't throw for every page through the layout.
|
||||
layoutPristine = neutralizeLayout(layoutFile)
|
||||
|
||||
console.log(
|
||||
`[build:embed] stashed ${stashedRoutes.length} route handler(s), wrapped ${wrappedPages.length} dynamic page(s)${
|
||||
layoutPristine ? ', neutralized root-layout headers()' : ''
|
||||
}; building static export…`,
|
||||
)
|
||||
|
||||
// The project pins Next 15 (webpack is its default builder; the custom `webpack()`
|
||||
// config in next.config.mjs applies). No Turbopack flag is passed.
|
||||
execFileSync('npx', ['next', 'build'], {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, CONSOLE_EMBED: '1' },
|
||||
})
|
||||
|
||||
const out = join(root, 'out')
|
||||
if (!existsSync(out) || !statSync(out).isDirectory()) {
|
||||
throw new Error('next build did not emit out/ (static export failed)')
|
||||
}
|
||||
console.log('[build:embed] static export ready at out/')
|
||||
} finally {
|
||||
// ALWAYS restore: put the pristine root layout back; remove wrappers + _body, put
|
||||
// the original page back; un-stash routes.
|
||||
if (layoutPristine != null) writeFileSync(layoutFile, layoutPristine, 'utf8')
|
||||
for (const { page, body } of wrappedPages) {
|
||||
if (existsSync(body)) rmSync(body, { force: true })
|
||||
const rel = relative(appDir, page)
|
||||
const pristine = join(stashDir, 'pages', rel)
|
||||
if (existsSync(pristine)) {
|
||||
rmSync(page, { force: true })
|
||||
move(pristine, page)
|
||||
}
|
||||
}
|
||||
for (const rel of stashedRoutes) move(join(stashDir, 'routes', rel), join(appDir, rel))
|
||||
if (existsSync(stashDir)) rmSync(stashDir, { recursive: true, force: true })
|
||||
if (stashedRoutes.length || wrappedPages.length) {
|
||||
console.log(
|
||||
`[build:embed] restored ${stashedRoutes.length} route handler(s) + ${wrappedPages.length} dynamic page(s)`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* App launcher — a fullscreen, Launchpad-style grid of every product, with a
|
||||
* live filter. Opened from the header affordance and from the command palette.
|
||||
*
|
||||
* Renders entirely from the catalog registry (DRY): with no query it groups by
|
||||
* the canonical categories; while filtering it shows a flat ranked grid (the same
|
||||
* `searchCatalog` scorer the palette uses). A tile opens the product the one way
|
||||
* (`openProduct` — in-console route or external tab) and closes the launcher.
|
||||
*/
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Dialog, Input, ScrollView, Text, VisuallyHidden, XStack, YStack } from '@hanzo/gui'
|
||||
import { Lock, Search } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { visibleCatalogByCategory, type CatalogEntry } from '~/lib/products/registry'
|
||||
import { searchCatalog } from '~/lib/products/search'
|
||||
import { useProductColors } from '~/lib/products/pins'
|
||||
import { asColor } from '~/components/ui/color'
|
||||
import { openProduct } from '~/lib/products/open'
|
||||
import { useIsGlobalAdmin } from '~/lib/auth/admin'
|
||||
|
||||
type LauncherApi = { isOpen: boolean; open: () => void; close: () => void }
|
||||
|
||||
const Ctx = createContext<LauncherApi | null>(null)
|
||||
|
||||
export function useAppLauncher(): LauncherApi {
|
||||
const ctx = useContext(Ctx)
|
||||
if (!ctx) throw new Error('useAppLauncher must be used within <AppLauncherProvider>')
|
||||
return ctx
|
||||
}
|
||||
|
||||
function Tile({ entry, color, onPress }: { entry: CatalogEntry; color: string; onPress: () => void }) {
|
||||
const Icon = entry.icon
|
||||
return (
|
||||
<YStack
|
||||
onPress={onPress}
|
||||
cursor="pointer"
|
||||
width={132}
|
||||
height={124}
|
||||
p="$3"
|
||||
gap="$2.5"
|
||||
items="center"
|
||||
justify="center"
|
||||
rounded="$6"
|
||||
hoverStyle={{ bg: '$color3' }}
|
||||
>
|
||||
<XStack
|
||||
width={56}
|
||||
height={56}
|
||||
items="center"
|
||||
justify="center"
|
||||
rounded="$7"
|
||||
position="relative"
|
||||
style={{ backgroundColor: `${color}22` }}
|
||||
>
|
||||
<Icon size={26} color={asColor(color)} />
|
||||
{entry.admin ? (
|
||||
<XStack position="absolute" t={-4} r={-4} bg="$color2" rounded="$10" p="$1">
|
||||
<Lock size={11} opacity={0.7} />
|
||||
</XStack>
|
||||
) : null}
|
||||
</XStack>
|
||||
<Text fontSize="$2" fontWeight="600" color="$color12" numberOfLines={1}>
|
||||
{entry.label}
|
||||
</Text>
|
||||
{entry.status === 'soon' ? (
|
||||
<YStack px="$1.5" py={1} rounded="$10" bg="$color4" position="absolute" b="$2">
|
||||
<Text fontSize={8} fontWeight="800" letterSpacing={0.5} color="$color11">
|
||||
SOON
|
||||
</Text>
|
||||
</YStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
function LauncherDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {
|
||||
const router = useRouter()
|
||||
const showAdmin = useIsGlobalAdmin()
|
||||
const { colorOf } = useProductColors()
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const groups = useMemo(() => visibleCatalogByCategory(showAdmin), [showAdmin])
|
||||
const filtered = useMemo(
|
||||
() => (query.trim() ? searchCatalog(query).filter((e) => showAdmin || !e.admin) : null),
|
||||
[query, showAdmin],
|
||||
)
|
||||
|
||||
const activate = useCallback(
|
||||
(entry: CatalogEntry) => {
|
||||
onOpenChange(false)
|
||||
openProduct(entry, (p) => router.push(p))
|
||||
},
|
||||
[onOpenChange, router],
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog modal open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay key="launcher-overlay" bg="rgba(0,0,0,0.6)" />
|
||||
<Dialog.Content
|
||||
key="launcher-content"
|
||||
bordered
|
||||
elevate
|
||||
width="92vw"
|
||||
height="88vh"
|
||||
maxW={1180}
|
||||
p="$0"
|
||||
gap="$0"
|
||||
overflow="hidden"
|
||||
>
|
||||
<VisuallyHidden>
|
||||
<Dialog.Title>All products</Dialog.Title>
|
||||
</VisuallyHidden>
|
||||
|
||||
{/* Search row */}
|
||||
<XStack
|
||||
items="center"
|
||||
gap="$2.5"
|
||||
px="$4"
|
||||
py="$3.5"
|
||||
borderBottomWidth={1}
|
||||
borderColor="$borderColor"
|
||||
>
|
||||
<Search size={18} opacity={0.7} />
|
||||
<Input
|
||||
flex={1}
|
||||
unstyled
|
||||
autoFocus
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder="Filter products…"
|
||||
fontSize="$5"
|
||||
color="$color12"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<Text fontSize="$1" color="$color10">
|
||||
esc
|
||||
</Text>
|
||||
</XStack>
|
||||
|
||||
{/* Grid */}
|
||||
<ScrollView flex={1}>
|
||||
<YStack p="$4" gap="$5">
|
||||
{filtered ? (
|
||||
filtered.length === 0 ? (
|
||||
<YStack p="$8" items="center">
|
||||
<Text color="$color10">No products match “{query.trim()}”.</Text>
|
||||
</YStack>
|
||||
) : (
|
||||
<XStack flexWrap="wrap" gap="$2">
|
||||
{filtered.map((entry) => (
|
||||
<Tile key={entry.id} entry={entry} color={colorOf(entry.id)} onPress={() => activate(entry)} />
|
||||
))}
|
||||
</XStack>
|
||||
)
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<YStack key={group.category} gap="$2">
|
||||
<Text fontSize="$2" color="$color10" fontWeight="800" textTransform="uppercase" px="$2">
|
||||
{group.category}
|
||||
</Text>
|
||||
<XStack flexWrap="wrap" gap="$2">
|
||||
{group.entries.map((entry) => (
|
||||
<Tile key={entry.id} entry={entry} color={colorOf(entry.id)} onPress={() => activate(entry)} />
|
||||
))}
|
||||
</XStack>
|
||||
</YStack>
|
||||
))
|
||||
)}
|
||||
</YStack>
|
||||
</ScrollView>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppLauncherProvider({ children }: { children: ReactNode }) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const open = useCallback(() => setIsOpen(true), [])
|
||||
const close = useCallback(() => setIsOpen(false), [])
|
||||
return (
|
||||
<Ctx.Provider value={{ isOpen, open, close }}>
|
||||
{children}
|
||||
<LauncherDialog open={isOpen} onOpenChange={setIsOpen} />
|
||||
</Ctx.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* ChunkGuard — recover gracefully from a stale-deploy chunk error.
|
||||
*
|
||||
* After a console deploy, an open tab still references the previous build's
|
||||
* hashed chunks. Those chunk URLs no longer exist, so the request falls through
|
||||
* to the app shell (HTML), and the browser throws `ChunkLoadError` /
|
||||
* "Unexpected token '<'" trying to parse HTML as JS — an unrecoverable blank
|
||||
* screen. This catches that exact failure and does ONE full reload, which pulls
|
||||
* the fresh HTML + current chunks. A sessionStorage flag prevents reload loops
|
||||
* if the failure is genuine (not a stale deploy); it clears on the next load.
|
||||
*/
|
||||
import { useEffect } from 'react'
|
||||
|
||||
const FLAG = 'hz_chunk_reloaded'
|
||||
const PATTERN = /ChunkLoadError|Loading chunk [\d]+ failed|Loading CSS chunk|Importing a module script failed|Unexpected token '<'/i
|
||||
|
||||
export function ChunkGuard() {
|
||||
useEffect(() => {
|
||||
// A clean load means any prior stale-chunk reload worked — reset the guard.
|
||||
try {
|
||||
sessionStorage.removeItem(FLAG)
|
||||
} catch {
|
||||
/* sessionStorage may be unavailable (private mode) — best-effort only */
|
||||
}
|
||||
|
||||
const recover = (message: string) => {
|
||||
if (!PATTERN.test(message)) return
|
||||
try {
|
||||
if (sessionStorage.getItem(FLAG)) return // already tried once — let the error surface
|
||||
sessionStorage.setItem(FLAG, '1')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const onError = (e: ErrorEvent) => recover(e?.message ?? String(e?.error ?? ''))
|
||||
const onRejection = (e: PromiseRejectionEvent) => {
|
||||
const r = e?.reason
|
||||
recover(typeof r === 'string' ? r : (r?.message ?? ''))
|
||||
}
|
||||
|
||||
window.addEventListener('error', onError)
|
||||
window.addEventListener('unhandledrejection', onRejection)
|
||||
return () => {
|
||||
window.removeEventListener('error', onError)
|
||||
window.removeEventListener('unhandledrejection', onRejection)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,707 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Command palette — ONE command surface for the whole console (⌘K / Ctrl+K).
|
||||
*
|
||||
* It is one widget with modes, not four. The query string selects the mode:
|
||||
* - default fuzzy-filters the product catalog; ↵ jumps to the product
|
||||
* (in-console route or external tab) — instant across every product.
|
||||
* - `>` prefix asks the AI (one cloud `/v1` backend) to find a product or
|
||||
* answer; a clear product match becomes a "Go to …" jump.
|
||||
* - `?` prefix asks the docs knowledge store (RAG, store=`docs`) and shows the
|
||||
* grounded answer with any links it cites.
|
||||
*
|
||||
* Beyond navigation it also runs ACTIONS — toggle theme, browse all apps, open
|
||||
* settings, switch organization, ask AI / search docs, sign out — ranked by the
|
||||
* same query, so ⌘K is ONE surface for "go somewhere" and "do something".
|
||||
*
|
||||
* Everything composes existing pieces: the catalog registry (`searchCatalog` +
|
||||
* `openProduct`), the AI client (`AiApi`), the chrome hooks (theme/launcher/
|
||||
* session/org-scope), and the honest backend-state mapper. Nothing is fabricated —
|
||||
* AI/RAG failures degrade to a truthful state card.
|
||||
*
|
||||
* Keyboard is handled on `window`: ⌘K toggles from anywhere; while open, ↑/↓ move
|
||||
* the selection (over actions then products), ↵ activates, Esc closes. The header
|
||||
* search box opens it; type `>` for AI, `?` for docs.
|
||||
*/
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useThemeSetting } from '@hanzogui/next-theme'
|
||||
import {
|
||||
Anchor,
|
||||
Dialog,
|
||||
Input,
|
||||
ScrollView,
|
||||
Spinner,
|
||||
Text,
|
||||
VisuallyHidden,
|
||||
XStack,
|
||||
YStack,
|
||||
} from '@hanzo/gui'
|
||||
import {
|
||||
ArrowRight,
|
||||
Building2,
|
||||
Command,
|
||||
CornerDownLeft,
|
||||
House,
|
||||
LayoutGrid,
|
||||
Lock,
|
||||
LogOut,
|
||||
Moon,
|
||||
Search,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Sun,
|
||||
Zap,
|
||||
} from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { AiApi, IamAdminApi, type Organization } from '~/lib/api'
|
||||
import { findEntry, type CatalogEntry } from '~/lib/products/registry'
|
||||
import { commandBarSystemPrompt, hanzoAssistantSystemPrompt } from '~/lib/assistant'
|
||||
import { searchDestinations, type Destination } from '~/lib/products/search'
|
||||
import { useProductColors } from '~/lib/products/pins'
|
||||
import { asColor } from '~/components/ui/color'
|
||||
import { ProductIcon } from '~/components/ui/ProductIcon'
|
||||
import { openProduct } from '~/lib/products/open'
|
||||
import { currentOrg, switchOrg } from '~/lib/org-scope'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { useIsGlobalAdmin } from '~/lib/auth/admin'
|
||||
import { useAppLauncher } from '~/components/AppLauncher'
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
|
||||
const titleCase = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
|
||||
|
||||
/**
|
||||
* A non-navigation command: a verb the palette can run (toggle theme, browse all
|
||||
* apps, switch org, sign out, …). Orthogonal to catalog entries — both are ranked
|
||||
* by the same query so ⌘K is ONE surface for "go somewhere" AND "do something".
|
||||
*/
|
||||
type PaletteAction = {
|
||||
id: string
|
||||
label: string
|
||||
hint: string
|
||||
/** Extra text the query matches against (synonyms). */
|
||||
keywords: string
|
||||
icon: ComponentType<{ size?: number }>
|
||||
/** Runs on activate. Closing the palette (if wanted) is the action's own job. */
|
||||
run: () => void
|
||||
}
|
||||
|
||||
/** Substring match over an action's label + synonyms (lowercased query). */
|
||||
function actionMatches(a: PaletteAction, q: string): boolean {
|
||||
if (!q) return false
|
||||
return `${a.label} ${a.keywords}`.toLowerCase().includes(q)
|
||||
}
|
||||
|
||||
type Mode = 'catalog' | 'ai' | 'help'
|
||||
|
||||
type PaletteApi = {
|
||||
isOpen: boolean
|
||||
open: () => void
|
||||
close: () => void
|
||||
}
|
||||
|
||||
const Ctx = createContext<PaletteApi | null>(null)
|
||||
|
||||
export function useCommandPalette(): PaletteApi {
|
||||
const ctx = useContext(Ctx)
|
||||
if (!ctx) throw new Error('useCommandPalette must be used within <CommandPaletteProvider>')
|
||||
return ctx
|
||||
}
|
||||
|
||||
type RunState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'loading' }
|
||||
| { status: 'nav'; entry: CatalogEntry }
|
||||
| { status: 'text'; text: string }
|
||||
| { status: 'error'; state: BackendState }
|
||||
|
||||
/** Render an answer as lines, then surface any URLs it cites as real links. */
|
||||
function Answer({ text }: { text: string }) {
|
||||
const urls = Array.from(new Set(text.match(/https?:\/\/[^\s)]+/g) ?? []))
|
||||
return (
|
||||
<YStack gap="$2.5">
|
||||
<YStack>
|
||||
{text.split('\n').map((line, i) => (
|
||||
<Text key={i} fontSize="$3" color="$color12">
|
||||
{line === '' ? ' ' : line}
|
||||
</Text>
|
||||
))}
|
||||
</YStack>
|
||||
{urls.length > 0 ? (
|
||||
<YStack gap="$1" borderTopWidth={1} borderColor="$borderColor" pt="$2">
|
||||
<Text fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
|
||||
Links
|
||||
</Text>
|
||||
{urls.map((u) => (
|
||||
<Anchor key={u} href={u} target="_blank" fontSize="$2" color="$color12" textDecorationLine="underline">
|
||||
{u}
|
||||
</Anchor>
|
||||
))}
|
||||
</YStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
function CatalogRow({
|
||||
entry,
|
||||
active,
|
||||
color,
|
||||
onPress,
|
||||
}: {
|
||||
entry: CatalogEntry
|
||||
active: boolean
|
||||
color?: string
|
||||
onPress: () => void
|
||||
}) {
|
||||
const Icon = entry.icon
|
||||
return (
|
||||
<XStack
|
||||
onPress={onPress}
|
||||
cursor="pointer"
|
||||
items="center"
|
||||
gap="$3"
|
||||
px="$3"
|
||||
py="$2.5"
|
||||
rounded="$3"
|
||||
id={active ? 'cmdk-active' : undefined}
|
||||
bg={active ? '$color5' : 'transparent'}
|
||||
hoverStyle={{ bg: active ? '$color5' : '$color3' }}
|
||||
>
|
||||
<ProductIcon icon={Icon} color={color} size={24} />
|
||||
<YStack flex={1}>
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12">
|
||||
{entry.label}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color10">
|
||||
{entry.category}
|
||||
{entry.gcp ? ` · ${entry.gcp}` : ''}
|
||||
</Text>
|
||||
</YStack>
|
||||
{entry.admin ? <Lock size={13} opacity={0.45} /> : null}
|
||||
<ArrowRight size={13} opacity={active ? 0.8 : 0.3} />
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** A ⌘K result — a product (via CatalogRow) or a deep sub-page jump. */
|
||||
function DestinationRow({
|
||||
dest,
|
||||
active,
|
||||
colorOf,
|
||||
onPress,
|
||||
}: {
|
||||
dest: Destination
|
||||
active: boolean
|
||||
colorOf: (id: string) => string
|
||||
onPress: () => void
|
||||
}) {
|
||||
if (dest.kind === 'product')
|
||||
return <CatalogRow entry={dest.entry} active={active} color={colorOf(dest.entry.id)} onPress={onPress} />
|
||||
const { entry, subpage } = dest
|
||||
const Icon = subpage.icon ?? entry.icon
|
||||
return (
|
||||
<XStack
|
||||
onPress={onPress}
|
||||
cursor="pointer"
|
||||
items="center"
|
||||
gap="$3"
|
||||
px="$3"
|
||||
py="$2.5"
|
||||
rounded="$3"
|
||||
id={active ? 'cmdk-active' : undefined}
|
||||
bg={active ? '$color5' : 'transparent'}
|
||||
hoverStyle={{ bg: active ? '$color5' : '$color3' }}
|
||||
>
|
||||
<Icon size={17} color={asColor(colorOf(entry.id))} />
|
||||
<YStack flex={1}>
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12">
|
||||
{entry.label} › {subpage.label}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color10">
|
||||
{entry.category} · {entry.label}
|
||||
</Text>
|
||||
</YStack>
|
||||
<ArrowRight size={13} opacity={active ? 0.8 : 0.3} />
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** Stable key for a destination (product id, or `id/slug` for a sub-page). */
|
||||
const destKey = (d: Destination): string => (d.kind === 'product' ? d.entry.id : `${d.entry.id}/${d.subpage.slug}`)
|
||||
|
||||
function ActionRow({
|
||||
action,
|
||||
active,
|
||||
onPress,
|
||||
}: {
|
||||
action: PaletteAction
|
||||
active: boolean
|
||||
onPress: () => void
|
||||
}) {
|
||||
const Icon = action.icon
|
||||
return (
|
||||
<XStack
|
||||
onPress={onPress}
|
||||
cursor="pointer"
|
||||
items="center"
|
||||
gap="$3"
|
||||
px="$3"
|
||||
py="$2.5"
|
||||
rounded="$3"
|
||||
id={active ? 'cmdk-active' : undefined}
|
||||
bg={active ? '$color5' : 'transparent'}
|
||||
hoverStyle={{ bg: active ? '$color5' : '$color3' }}
|
||||
>
|
||||
<Icon size={17} />
|
||||
<YStack flex={1}>
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12">
|
||||
{action.label}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color10">
|
||||
{action.hint}
|
||||
</Text>
|
||||
</YStack>
|
||||
<CornerDownLeft size={13} opacity={active ? 0.8 : 0.3} />
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** A small uppercase section label inside the palette result list. */
|
||||
function SectionLabel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Text px="$3" pt="$2" pb="$1" fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
function PaletteDialog({
|
||||
open,
|
||||
seed,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean
|
||||
seed: string
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const launcher = useAppLauncher()
|
||||
const { signOut } = useSession()
|
||||
const showAdmin = useIsGlobalAdmin()
|
||||
const { colorOf } = useProductColors()
|
||||
const { current, resolvedTheme, set: setTheme } = useThemeSetting()
|
||||
const isDark = (resolvedTheme ?? current ?? 'dark') !== 'light'
|
||||
const [query, setQuery] = useState(seed)
|
||||
const [sel, setSel] = useState(0)
|
||||
const [run, setRun] = useState<RunState>({ status: 'idle' })
|
||||
const [orgs, setOrgs] = useState<Organization[]>([])
|
||||
|
||||
const mode: Mode = query.startsWith('>') ? 'ai' : query.startsWith('?') ? 'help' : 'catalog'
|
||||
const sub = (mode === 'catalog' ? query : query.slice(1)).trim()
|
||||
|
||||
// The org list powers the "Switch to <org>" actions. Only an admin who can see
|
||||
// more than one org gets switch actions; everyone else just gets the verbs. The
|
||||
// list comes from the cross-tenant `/admin/iam/get-organizations?owner=admin`
|
||||
// aggregate, which is server-gated to global admins — so don't fire it for a tenant
|
||||
// user (it only 403s); they just get the verbs, no switch actions.
|
||||
useEffect(() => {
|
||||
if (!open || !showAdmin) return
|
||||
let live = true
|
||||
IamAdminApi.organizations()
|
||||
.then((p) => {
|
||||
if (live) setOrgs(p.rows ?? [])
|
||||
})
|
||||
.catch(() => {
|
||||
if (live) setOrgs([])
|
||||
})
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [open, showAdmin])
|
||||
|
||||
// Every command the palette can RUN (verbs + per-org switches). Composed from the
|
||||
// same pieces the chrome uses (router, launcher, theme, session, org scope) — no
|
||||
// dead entries: each `run` is wired.
|
||||
const actions = useMemo<PaletteAction[]>(() => {
|
||||
const cur = currentOrg()
|
||||
const verbs: PaletteAction[] = [
|
||||
{ id: 'home', label: 'Go to Overview', hint: 'Dashboard home', keywords: 'home start dashboard root overview', icon: House, run: () => { onOpenChange(false); router.push('/') } },
|
||||
{ id: 'apps', label: 'Browse all apps', hint: 'Open the app launcher', keywords: 'launcher grid all products everything apps', icon: LayoutGrid, run: () => { onOpenChange(false); launcher.open() } },
|
||||
{ id: 'settings', label: 'Open Settings', hint: 'Account, organization, branding', keywords: 'preferences account profile settings', icon: SlidersHorizontal, run: () => { onOpenChange(false); router.push('/settings') } },
|
||||
{ id: 'theme', label: isDark ? 'Switch to light theme' : 'Switch to dark theme', hint: 'Toggle appearance', keywords: 'dark light appearance theme mode color', icon: isDark ? Sun : Moon, run: () => setTheme(isDark ? 'light' : 'dark') },
|
||||
{ id: 'ai', label: 'Ask AI', hint: 'Find or do something with AI', keywords: 'assistant zen gpt ask question ai', icon: Sparkles, run: () => setQuery('> ') },
|
||||
{ id: 'docs', label: 'Search the docs', hint: 'Ask the documentation', keywords: 'help docs documentation manual guide', icon: Zap, run: () => setQuery('? ') },
|
||||
{ id: 'signout', label: 'Sign out', hint: 'End your session', keywords: 'logout sign out exit leave', icon: LogOut, run: () => { onOpenChange(false); void signOut() } },
|
||||
]
|
||||
const orgVerbs: PaletteAction[] = orgs
|
||||
.filter((o) => o.name !== cur)
|
||||
.map((o) => ({
|
||||
id: `org:${o.name}`,
|
||||
label: `Switch to ${o.displayName || titleCase(o.name)}`,
|
||||
hint: 'Switch organization',
|
||||
keywords: `org organization tenant switch ${o.name}`,
|
||||
icon: Building2,
|
||||
run: () => switchOrg(o.name),
|
||||
}))
|
||||
return [...verbs, ...orgVerbs]
|
||||
}, [isDark, orgs, router, launcher, signOut, setTheme, onOpenChange])
|
||||
|
||||
// Every jump target — products AND deep sub-pages ("queues" → Tasks › Queues) —
|
||||
// gated so a customer never sees an admin-only surface.
|
||||
const destResults = useMemo(
|
||||
() => (mode === 'catalog' ? searchDestinations(query, showAdmin).slice(0, 50) : []),
|
||||
[mode, query, showAdmin],
|
||||
)
|
||||
|
||||
const matchedActions = useMemo(
|
||||
() => (mode === 'catalog' && sub ? actions.filter((a) => actionMatches(a, sub.toLowerCase())) : []),
|
||||
[mode, sub, actions],
|
||||
)
|
||||
|
||||
// One ordered list (actions first, then destinations) so ↑/↓/↵ traverse both.
|
||||
type Item = { kind: 'action'; action: PaletteAction } | { kind: 'dest'; dest: Destination }
|
||||
const items = useMemo<Item[]>(
|
||||
() => [
|
||||
...matchedActions.map((action) => ({ kind: 'action' as const, action })),
|
||||
...destResults.map((dest) => ({ kind: 'dest' as const, dest })),
|
||||
],
|
||||
[matchedActions, destResults],
|
||||
)
|
||||
|
||||
// Seed the query each time the palette opens.
|
||||
useEffect(() => {
|
||||
if (open) setQuery(seed)
|
||||
}, [open, seed])
|
||||
|
||||
// A new query resets selection + any prior AI run.
|
||||
useEffect(() => {
|
||||
setSel(0)
|
||||
setRun({ status: 'idle' })
|
||||
}, [query])
|
||||
|
||||
const activate = useCallback(
|
||||
(entry: CatalogEntry) => {
|
||||
onOpenChange(false)
|
||||
openProduct(entry, (p) => router.push(p))
|
||||
},
|
||||
[onOpenChange, router],
|
||||
)
|
||||
|
||||
/** Activate a destination — a product (open) or a sub-page (navigate deep). */
|
||||
const activateDest = useCallback(
|
||||
(dest: Destination) => {
|
||||
onOpenChange(false)
|
||||
if (dest.kind === 'subpage') router.push(dest.path)
|
||||
else openProduct(dest.entry, (p) => router.push(p))
|
||||
},
|
||||
[onOpenChange, router],
|
||||
)
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
if (!sub) return
|
||||
setRun({ status: 'loading' })
|
||||
try {
|
||||
if (mode === 'ai') {
|
||||
// Same grounded expert prompt as the chat, plus the nav contract: a clear
|
||||
// "open X" jumps to the product; anything else gets a real, accurate answer.
|
||||
const ans = (await AiApi.chat({ question: sub, system: commandBarSystemPrompt({ showAdmin }) })).trim()
|
||||
const m = ans.match(/^NAV\s+([a-z0-9-]+)/i)
|
||||
const entry = m ? findEntry(m[1]) : undefined
|
||||
if (entry) setRun({ status: 'nav', entry })
|
||||
else setRun({ status: 'text', text: ans })
|
||||
} else {
|
||||
// Docs mode: retrieval grounded in the same expert context.
|
||||
const ans = await AiApi.ragChat({
|
||||
question: sub,
|
||||
store: 'docs',
|
||||
system: hanzoAssistantSystemPrompt({ showAdmin }),
|
||||
})
|
||||
setRun({ status: 'text', text: ans })
|
||||
}
|
||||
} catch (e) {
|
||||
setRun({ status: 'error', state: classifyBackend(e) })
|
||||
}
|
||||
}, [mode, sub, showAdmin])
|
||||
|
||||
// Keyboard while open: ↑/↓ select, ↵ activate/ask, Esc close.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onOpenChange(false)
|
||||
return
|
||||
}
|
||||
if (mode === 'catalog') {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSel((s) => Math.min(s + 1, Math.max(items.length - 1, 0)))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSel((s) => Math.max(s - 1, 0))
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const it = items[sel]
|
||||
if (it) {
|
||||
if (it.kind === 'action') it.action.run()
|
||||
else activateDest(it.dest)
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (run.status === 'loading') return
|
||||
if (run.status === 'nav') activate(run.entry)
|
||||
else void submit()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [open, mode, items, sel, run, submit, activate, activateDest, onOpenChange])
|
||||
|
||||
// Keep the ↑/↓-selected row visible: as selection moves past the fold, scroll
|
||||
// the active row into view (the list can hold 50 results — well beyond 420px).
|
||||
useEffect(() => {
|
||||
if (!open || mode !== 'catalog' || typeof document === 'undefined') return
|
||||
document.getElementById('cmdk-active')?.scrollIntoView({ block: 'nearest' })
|
||||
}, [sel, open, mode])
|
||||
|
||||
const placeholder =
|
||||
mode === 'ai'
|
||||
? 'Ask AI to find or do something…'
|
||||
: mode === 'help'
|
||||
? 'Ask the docs…'
|
||||
: 'Search products, or type > for AI, ? for docs'
|
||||
|
||||
return (
|
||||
<Dialog modal open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay key="palette-overlay" bg="rgba(0,0,0,0.5)" />
|
||||
{/* Full-screen on mobile (fills the viewport, reachable from the mobile
|
||||
menu); a floating 640 box at lg+. */}
|
||||
<Dialog.Content
|
||||
key="palette-content"
|
||||
bordered
|
||||
elevate
|
||||
width="100vw"
|
||||
height="100dvh"
|
||||
maxW="100vw"
|
||||
rounded="$0"
|
||||
$lg={{ width: 640, height: 'auto', maxW: '90%', rounded: '$6' }}
|
||||
p="$0"
|
||||
gap="$0"
|
||||
overflow="hidden"
|
||||
>
|
||||
<VisuallyHidden>
|
||||
<Dialog.Title>Command palette</Dialog.Title>
|
||||
</VisuallyHidden>
|
||||
|
||||
{/* Query row */}
|
||||
<XStack items="center" gap="$2.5" px="$3.5" py="$3" borderBottomWidth={1} borderColor="$borderColor">
|
||||
{mode === 'ai' ? (
|
||||
<Sparkles size={18} opacity={0.7} />
|
||||
) : (
|
||||
<Search size={18} opacity={0.7} />
|
||||
)}
|
||||
<Input
|
||||
flex={1}
|
||||
unstyled
|
||||
autoFocus
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder={placeholder}
|
||||
fontSize="$4"
|
||||
color="$color12"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<XStack items="center" gap="$1" opacity={0.5}>
|
||||
<Text fontSize="$1" color="$color10">
|
||||
esc
|
||||
</Text>
|
||||
</XStack>
|
||||
</XStack>
|
||||
|
||||
{/* Body — fills the viewport on mobile, capped at lg+. */}
|
||||
<YStack flex={1} minH={0} overflow="hidden" $lg={{ flex: 0, minH: 120, maxH: 420 }}>
|
||||
{mode === 'catalog' ? (
|
||||
items.length === 0 ? (
|
||||
<YStack p="$5" items="center">
|
||||
<Text color="$color10">No commands or products match “{sub}”.</Text>
|
||||
</YStack>
|
||||
) : (
|
||||
<ScrollView flex={1} p="$2" showsVerticalScrollIndicator keyboardShouldPersistTaps="handled">
|
||||
<YStack gap="$0.5">
|
||||
{matchedActions.length > 0 ? <SectionLabel>Actions</SectionLabel> : null}
|
||||
{matchedActions.map((action, i) => (
|
||||
<ActionRow key={`action-${action.id}`} action={action} active={i === sel} onPress={action.run} />
|
||||
))}
|
||||
{destResults.length > 0 && matchedActions.length > 0 ? <SectionLabel>Go to</SectionLabel> : null}
|
||||
{destResults.map((dest, j) => {
|
||||
const i = matchedActions.length + j
|
||||
return (
|
||||
<DestinationRow
|
||||
key={destKey(dest)}
|
||||
dest={dest}
|
||||
active={i === sel}
|
||||
colorOf={colorOf}
|
||||
onPress={() => activateDest(dest)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</YStack>
|
||||
</ScrollView>
|
||||
)
|
||||
) : (
|
||||
<YStack p="$4" gap="$3">
|
||||
{run.status === 'idle' ? (
|
||||
<XStack gap="$2" items="center">
|
||||
<CornerDownLeft size={15} opacity={0.6} />
|
||||
<Text color="$color10" fontSize="$3">
|
||||
{mode === 'ai'
|
||||
? 'Press ↵ to ask AI to find a product or answer.'
|
||||
: 'Press ↵ to search the docs.'}
|
||||
</Text>
|
||||
</XStack>
|
||||
) : run.status === 'loading' ? (
|
||||
<XStack gap="$2.5" items="center">
|
||||
<Spinner color="$color11" />
|
||||
<Text color="$color11" fontSize="$3">
|
||||
{mode === 'ai' ? 'Thinking…' : 'Searching the docs…'}
|
||||
</Text>
|
||||
</XStack>
|
||||
) : run.status === 'nav' ? (
|
||||
<CatalogRow entry={run.entry} active color={colorOf(run.entry.id)} onPress={() => activate(run.entry)} />
|
||||
) : run.status === 'text' ? (
|
||||
<Answer text={run.text} />
|
||||
) : (
|
||||
<BackendStateCard state={run.state} onRetry={() => void submit()} />
|
||||
)}
|
||||
</YStack>
|
||||
)}
|
||||
</YStack>
|
||||
|
||||
{/* Legend */}
|
||||
<XStack
|
||||
px="$3.5"
|
||||
py="$2"
|
||||
gap="$3"
|
||||
borderTopWidth={1}
|
||||
borderColor="$borderColor"
|
||||
bg="$color1"
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<Legend keys="↑↓" label="navigate" />
|
||||
<Legend keys="↵" label="open" />
|
||||
<Legend keys=">" label="AI" />
|
||||
<Legend keys="?" label="docs" />
|
||||
<XStack flex={1} />
|
||||
<XStack
|
||||
onPress={() => {
|
||||
onOpenChange(false)
|
||||
launcher.open()
|
||||
}}
|
||||
cursor="pointer"
|
||||
items="center"
|
||||
gap="$1.5"
|
||||
opacity={0.8}
|
||||
hoverStyle={{ opacity: 1 }}
|
||||
>
|
||||
<LayoutGrid size={13} />
|
||||
<Text fontSize="$1" color="$color11" fontWeight="600">
|
||||
Browse all apps
|
||||
</Text>
|
||||
</XStack>
|
||||
</XStack>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function Legend({ keys, label }: { keys: string; label: string }) {
|
||||
return (
|
||||
<XStack items="center" gap="$1.5" opacity={0.6}>
|
||||
<Text fontSize="$1" color="$color12" fontWeight="700">
|
||||
{keys}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color10">
|
||||
{label}
|
||||
</Text>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
export function CommandPaletteProvider({ children }: { children: ReactNode }) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [seed, setSeed] = useState('')
|
||||
|
||||
const open = useCallback(() => {
|
||||
setSeed('')
|
||||
setIsOpen(true)
|
||||
}, [])
|
||||
|
||||
const close = useCallback(() => setIsOpen(false), [])
|
||||
|
||||
// ⌘K / Ctrl+K toggles the palette from anywhere.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
|
||||
e.preventDefault()
|
||||
setSeed('')
|
||||
setIsOpen((v) => !v)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Ctx.Provider value={{ isOpen, open, close }}>
|
||||
{children}
|
||||
<PaletteDialog open={isOpen} seed={seed} onOpenChange={setIsOpen} />
|
||||
</Ctx.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/** Header trigger — a search box that opens the palette. */
|
||||
export function CommandSearchBox() {
|
||||
const { open } = useCommandPalette()
|
||||
return (
|
||||
<XStack
|
||||
onPress={open}
|
||||
cursor="pointer"
|
||||
items="center"
|
||||
gap="$2"
|
||||
px="$3"
|
||||
height={36}
|
||||
flex={1}
|
||||
maxW={420}
|
||||
bg="$color2"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
rounded="$4"
|
||||
hoverStyle={{ borderColor: '$color8' }}
|
||||
>
|
||||
<Search size={15} opacity={0.6} />
|
||||
<Text flex={1} fontSize="$3" color="$color10" numberOfLines={1}>
|
||||
Search or jump to…
|
||||
</Text>
|
||||
<XStack items="center" gap="$1" opacity={0.6}>
|
||||
<Command size={12} />
|
||||
<Text fontSize="$2" color="$color10">
|
||||
K
|
||||
</Text>
|
||||
</XStack>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
+1009
-100
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,117 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* DetailPane — the ONE reusable right-side pane for viewing / editing the details
|
||||
* of an item, anywhere in the console. A product does NOT build its own pane: it
|
||||
* writes a DESCRIPTOR (`{ title, subtitle?, icon?, content, footer? }`) and calls
|
||||
* `useDetailPane().open(descriptor)`. The pane slides out smoothly from the right
|
||||
* (full-screen on mobile) via the shared `SlideOver`, so every "open item detail"
|
||||
* looks and behaves identically (DRY).
|
||||
*
|
||||
* Rendered ONCE at the shell root (via `DetailPaneProvider`), so any module — a
|
||||
* machine row, a provider, a pinned product's customize form — opens the same
|
||||
* pane. Interactive content closes itself with `useDetailPane().close()`.
|
||||
*
|
||||
* The last descriptor stays mounted through the close transition, so the exit
|
||||
* animates with its content (no flash of empty pane). Opening a new descriptor
|
||||
* replaces it.
|
||||
*/
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { Button, ScrollView, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { X } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { SlideOver } from '~/components/ui/SlideOver'
|
||||
import { asColor, type IconLike } from '~/components/ui/color'
|
||||
|
||||
export type DetailDescriptor = {
|
||||
/** Pane title (the item's name). */
|
||||
title: ReactNode
|
||||
/** Optional secondary line under the title (type, id, status…). */
|
||||
subtitle?: ReactNode
|
||||
/** Optional title icon, tinted with `iconColor`. */
|
||||
icon?: IconLike
|
||||
iconColor?: string
|
||||
/** Desktop pane width (px). Mobile is always full-screen. Default 460. */
|
||||
size?: number
|
||||
/** The body — the detail view or edit form for the item. */
|
||||
content: ReactNode
|
||||
/** Optional sticky footer (primary actions), pinned below the scroll body. */
|
||||
footer?: ReactNode
|
||||
}
|
||||
|
||||
export type DetailPaneApi = {
|
||||
open: (descriptor: DetailDescriptor) => void
|
||||
close: () => void
|
||||
isOpen: boolean
|
||||
}
|
||||
|
||||
const Ctx = createContext<DetailPaneApi | null>(null)
|
||||
|
||||
export function useDetailPane(): DetailPaneApi {
|
||||
const ctx = useContext(Ctx)
|
||||
if (!ctx) throw new Error('useDetailPane must be used within <DetailPaneProvider>')
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function DetailPaneProvider({ children }: { children: ReactNode }) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [desc, setDesc] = useState<DetailDescriptor | null>(null)
|
||||
|
||||
const open = useCallback((descriptor: DetailDescriptor) => {
|
||||
setDesc(descriptor)
|
||||
setIsOpen(true)
|
||||
}, [])
|
||||
const close = useCallback(() => setIsOpen(false), [])
|
||||
|
||||
const api = useMemo<DetailPaneApi>(() => ({ open, close, isOpen }), [open, close, isOpen])
|
||||
const Icon = desc?.icon
|
||||
|
||||
return (
|
||||
<Ctx.Provider value={api}>
|
||||
{children}
|
||||
<SlideOver
|
||||
open={isOpen}
|
||||
onClose={close}
|
||||
side="right"
|
||||
size={desc?.size ?? 460}
|
||||
ariaLabel={typeof desc?.title === 'string' ? desc?.title : 'Details'}
|
||||
zIndex={1100}
|
||||
>
|
||||
{/* Bare-mode layout: fixed header · scroll body · optional sticky footer. */}
|
||||
<XStack items="center" gap="$2.5" px="$4" height={56} borderBottomWidth={1} borderColor="$borderColor">
|
||||
{Icon ? <Icon size={18} color={desc?.iconColor ? asColor(desc.iconColor) : undefined} /> : null}
|
||||
<YStack flex={1} minW={0}>
|
||||
<Text fontSize="$5" fontWeight="700" color="$color12" numberOfLines={1}>
|
||||
{desc?.title}
|
||||
</Text>
|
||||
{desc?.subtitle !== undefined ? (
|
||||
<Text fontSize="$2" color="$color10" numberOfLines={1}>
|
||||
{desc?.subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
<Button size="$2" chromeless icon={<X size={18} />} onPress={close} aria-label="Close" />
|
||||
</XStack>
|
||||
|
||||
<ScrollView flex={1} minH={0}>
|
||||
<YStack p="$4" gap="$3">
|
||||
{desc?.content}
|
||||
</YStack>
|
||||
</ScrollView>
|
||||
|
||||
{desc?.footer !== undefined ? (
|
||||
<XStack px="$4" py="$3" gap="$2" borderTopWidth={1} borderColor="$borderColor" bg="$color1">
|
||||
{desc?.footer}
|
||||
</XStack>
|
||||
) : null}
|
||||
</SlideOver>
|
||||
</Ctx.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Floating AI chat — a bubble fixed bottom-right on every dashboard page, so the
|
||||
* assistant is one tap away from any view (the "where's chat on mobile" ask).
|
||||
*
|
||||
* It REUSES the one working chat surface (`ChatConversation` → `AiApi.chat` → the
|
||||
* keyless `/ai` proxy → /v1/chat/completions). Nothing about AI is rebuilt here;
|
||||
* this is purely a container that mounts that conversation in an overlay:
|
||||
* - phone/tablet (<1024px): a full-screen sheet.
|
||||
* - laptop/desktop: a ~380×560 popover anchored bottom-right.
|
||||
*
|
||||
* "History" inside the conversation deep-links to the full `/chat` page (the
|
||||
* bubble is a quick-ask surface, not the session browser) and closes the bubble.
|
||||
*
|
||||
* Mounted once in the dashboard layout (like the app launcher), so a single
|
||||
* instance floats over all children — lightweight, no per-page wiring.
|
||||
*/
|
||||
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button, Dialog, Text, VisuallyHidden, XStack, YStack } from '@hanzo/gui'
|
||||
import { MessageCircle, Sparkles, X } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { ChatConversation } from '~/components/products/chat/ChatConversation'
|
||||
|
||||
type FloatingChatApi = { isOpen: boolean; open: () => void; close: () => void; toggle: () => void }
|
||||
|
||||
const Ctx = createContext<FloatingChatApi | null>(null)
|
||||
|
||||
/** Open/close the floating assistant from anywhere (e.g. an empty-state CTA). */
|
||||
export function useFloatingChat(): FloatingChatApi {
|
||||
const ctx = useContext(Ctx)
|
||||
if (!ctx) throw new Error('useFloatingChat must be used within <FloatingChatProvider>')
|
||||
return ctx
|
||||
}
|
||||
|
||||
function ChatSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
onHistory,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (o: boolean) => void
|
||||
onHistory: () => void
|
||||
}) {
|
||||
// Size is CSS-driven (media props), not a JS branch: base = mobile full-bleed
|
||||
// sheet; `$lg` = a compact popover pinned bottom-right. The scrim dims the page
|
||||
// on mobile and goes transparent on desktop (popover, no full-screen dim).
|
||||
return (
|
||||
<Dialog modal open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay key="chat-overlay" bg="rgba(0,0,0,0.5)" $lg={{ bg: 'transparent' }} />
|
||||
<Dialog.Content
|
||||
key="chat-content"
|
||||
bordered
|
||||
elevate
|
||||
position="absolute"
|
||||
bg="$color1"
|
||||
overflow="hidden"
|
||||
p="$0"
|
||||
// Mobile/tablet: full-bleed.
|
||||
t={0}
|
||||
l={0}
|
||||
r={0}
|
||||
b={0}
|
||||
width="100vw"
|
||||
height="100dvh"
|
||||
rounded="$0"
|
||||
// Desktop (≥lg): a compact popover bottom-right, above the bubble.
|
||||
$lg={{
|
||||
t: 'auto',
|
||||
l: 'auto',
|
||||
b: 88,
|
||||
r: 24,
|
||||
width: 380,
|
||||
height: 560,
|
||||
rounded: '$6',
|
||||
}}
|
||||
>
|
||||
<VisuallyHidden>
|
||||
<Dialog.Title>Assistant</Dialog.Title>
|
||||
</VisuallyHidden>
|
||||
|
||||
<YStack flex={1} minH={0}>
|
||||
<XStack
|
||||
items="center"
|
||||
justify="space-between"
|
||||
px="$3"
|
||||
py="$2.5"
|
||||
borderBottomWidth={1}
|
||||
borderColor="$borderColor"
|
||||
bg="$color2"
|
||||
>
|
||||
<XStack items="center" gap="$2">
|
||||
<Sparkles size={16} opacity={0.8} />
|
||||
<Text fontSize="$4" fontWeight="700" color="$color12">
|
||||
Assistant
|
||||
</Text>
|
||||
</XStack>
|
||||
<Button
|
||||
size="$2"
|
||||
chromeless
|
||||
icon={<X size={18} />}
|
||||
onPress={() => onOpenChange(false)}
|
||||
aria-label="Close assistant"
|
||||
/>
|
||||
</XStack>
|
||||
|
||||
{/* The ONE working conversation, given a flex container to fill. */}
|
||||
<YStack flex={1} minH={0} p="$3">
|
||||
<ChatConversation compact onShowHistory={onHistory} />
|
||||
</YStack>
|
||||
</YStack>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function FloatingChatProvider({ children }: { children: ReactNode }) {
|
||||
const router = useRouter()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const open = useCallback(() => setIsOpen(true), [])
|
||||
const close = useCallback(() => setIsOpen(false), [])
|
||||
const toggle = useCallback(() => setIsOpen((v) => !v), [])
|
||||
|
||||
const onHistory = useCallback(() => {
|
||||
setIsOpen(false)
|
||||
router.push('/chat')
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<Ctx.Provider value={{ isOpen, open, close, toggle }}>
|
||||
{children}
|
||||
|
||||
{/* The bubble — fixed bottom-right over every page (rendered last in this
|
||||
provider so DOM order keeps it above normal-flow content; the chat sheet
|
||||
portals above it). Hidden while open so the sheet's own close control is
|
||||
the single dismiss affordance. */}
|
||||
{!isOpen ? (
|
||||
<YStack position="fixed" b={24} r={24}>
|
||||
<Button
|
||||
circular
|
||||
size="$6"
|
||||
bg="$color5"
|
||||
hoverStyle={{ bg: '$color6' }}
|
||||
pressStyle={{ bg: '$color7' }}
|
||||
icon={<MessageCircle size={24} />}
|
||||
onPress={open}
|
||||
shadowColor="rgba(0,0,0,0.35)"
|
||||
shadowRadius={16}
|
||||
shadowOffset={{ width: 0, height: 4 }}
|
||||
aria-label="Open AI assistant"
|
||||
/>
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
<ChatSheet open={isOpen} onOpenChange={setIsOpen} onHistory={onHistory} />
|
||||
</Ctx.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Home summary strip — the at-a-glance account header on the console home, the
|
||||
* way DigitalOcean/Vercel open with balance + spend before the resource grid.
|
||||
*
|
||||
* Real per-tenant data over the same `/billing/*` proxy the Cost page and sidebar
|
||||
* wallet use (server-injected token, scoped to the caller's org). It is purely
|
||||
* additive and fail-quiet: if billing isn't configured/routed on a deployment the
|
||||
* strip simply doesn't render (the catalog below is the home's substance) — it
|
||||
* never shows a fabricated balance and never blocks the page.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { ArrowRight, CreditCard, TrendingUp } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { BillingApi } from '~/lib/api/billing'
|
||||
|
||||
const usd = (cents: number): string => `$${(cents / 100).toFixed(2)}`
|
||||
|
||||
type Stats = { availableCents: number | null; spendCents: number | null }
|
||||
|
||||
function Stat({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
value: string
|
||||
hint?: string
|
||||
}) {
|
||||
return (
|
||||
<Card p="$3.5" gap="$1.5" borderWidth={1} borderColor="$borderColor" flex={1} minW={200}>
|
||||
<XStack items="center" gap="$1.5">
|
||||
{icon}
|
||||
<Text fontSize="$2" color="$color10">
|
||||
{label}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$8" fontWeight="900" color="$color12">
|
||||
{value}
|
||||
</Text>
|
||||
{hint ? (
|
||||
<Text fontSize="$2" color="$color10">
|
||||
{hint}
|
||||
</Text>
|
||||
) : null}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function HomeSummary() {
|
||||
const router = useRouter()
|
||||
const { account } = useSession()
|
||||
const signedIn = Boolean(account?.owner)
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!signedIn) return
|
||||
let alive = true
|
||||
void (async () => {
|
||||
const [balance, usage] = await Promise.allSettled([BillingApi.balance(), BillingApi.usage()])
|
||||
if (!alive) return
|
||||
const availableCents = balance.status === 'fulfilled' ? balance.value.available : null
|
||||
const spendCents = usage.status === 'fulfilled' ? usage.value.totalCents : null
|
||||
// Only render the strip if at least one real number came back — otherwise it
|
||||
// stays hidden (no fabricated stats, no empty scaffold).
|
||||
if (availableCents === null && spendCents === null) return
|
||||
setStats({ availableCents, spendCents })
|
||||
})()
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [signedIn])
|
||||
|
||||
if (!signedIn || !stats) return null
|
||||
|
||||
return (
|
||||
<XStack flexWrap="wrap" gap="$3" items="stretch">
|
||||
<Stat
|
||||
icon={<CreditCard size={14} opacity={0.7} />}
|
||||
label="Cloud credit"
|
||||
value={stats.availableCents != null ? usd(stats.availableCents) : '—'}
|
||||
hint="available to spend"
|
||||
/>
|
||||
<Stat
|
||||
icon={<TrendingUp size={14} opacity={0.7} />}
|
||||
label="Spend this period"
|
||||
value={stats.spendCents != null ? usd(stats.spendCents) : '—'}
|
||||
hint="across every product"
|
||||
/>
|
||||
<Card
|
||||
p="$3.5"
|
||||
gap="$2"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
bg="$color2"
|
||||
flex={1}
|
||||
minW={220}
|
||||
justify="center"
|
||||
>
|
||||
<Text fontSize="$3" color="$color11">
|
||||
Manage balance, usage, and invoices.
|
||||
</Text>
|
||||
<XStack gap="$2">
|
||||
<Button size="$2" iconAfter={<ArrowRight size={14} />} onPress={() => router.push('/billing')}>
|
||||
View cost
|
||||
</Button>
|
||||
<Button size="$2" chromeless onPress={() => router.push('/billing/credits')}>
|
||||
Add credits
|
||||
</Button>
|
||||
</XStack>
|
||||
</Card>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Org accent applier — reads the signed-in org's persisted brand color and applies it
|
||||
* to the live console accent on EVERY load (so a custom theme survives reload), and
|
||||
* clears it on sign-out. This is the load-time half of the "apply the org's theme
|
||||
* color" fix; Settings → Branding applies it immediately on save via the same
|
||||
* `applyOrgAccent`, so the two share one mechanism.
|
||||
*
|
||||
* It fetches the active org's `themeData` (`TeamApi.organization`, the org-scoped IAM
|
||||
* read an org admin can make) once the session resolves, then applies. Best-effort: a
|
||||
* failed fetch simply leaves the default monochrome accent (never throws, never blocks
|
||||
* render). Renders nothing.
|
||||
*/
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { TeamApi } from '~/lib/api'
|
||||
import { currentOrg } from '~/lib/org-scope'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { setOrgAccent } from '~/lib/theme/accent'
|
||||
|
||||
export function OrgAccentProvider() {
|
||||
const { account } = useSession()
|
||||
// Re-resolve when the signed-in identity changes (sign-in / sign-out / org switch,
|
||||
// which reloads with a new `currentOrg()`).
|
||||
const owner = account?.owner ?? null
|
||||
|
||||
useEffect(() => {
|
||||
if (!owner) {
|
||||
setOrgAccent(null) // signed out → default monochrome accent
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
TeamApi.organization(currentOrg())
|
||||
.then((org) => {
|
||||
if (!cancelled) setOrgAccent(org.themeData)
|
||||
})
|
||||
.catch(() => {
|
||||
// Org theme unreadable on this host → keep the default accent (no fabrication).
|
||||
if (!cancelled) setOrgAccent(null)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [owner])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Org gate — the console operates inside the user's organization.
|
||||
*
|
||||
* Behaviours:
|
||||
* 1. isAdmin on a non-admin host → show a dismissible amber banner linking to
|
||||
* admin.hanzo.ai (IAM/KMS ops), but render the full console. Admins use the
|
||||
* console for all normal cloud work (models, API keys, AI, etc.).
|
||||
* 2. Any non-admin user in any org → render console normally.
|
||||
* 3. No org yet → first-run org onboarding.
|
||||
*
|
||||
* Switching orgs at runtime is the OrgSwitcher's job; this gate only covers
|
||||
* the "no org" degenerate case and the admin hint.
|
||||
*
|
||||
* Last org: restored from localStorage on sign-in so the scope remembers where
|
||||
* the user left off.
|
||||
*/
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { Button, Text, XStack, YStack } from '@hanzo/gui'
|
||||
|
||||
import { config } from '~/config'
|
||||
import { getBrand } from '~/lib/branding/brands'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { isGlobalAdminAccount } from '~/lib/auth/admin'
|
||||
import { currentOrg, setCurrentOrg } from '~/lib/org-scope'
|
||||
import { OrgOnboarding } from '~/components/OrgOnboarding'
|
||||
|
||||
const LS_LAST_ORG = 'hz_last_org'
|
||||
const LS_BANNER_DISMISSED = 'hz_admin_banner_dismissed'
|
||||
|
||||
function onAdminHost(): boolean {
|
||||
return typeof window !== 'undefined' && window.location.hostname.startsWith('admin.')
|
||||
}
|
||||
|
||||
function AdminBanner({ onDismiss }: { onDismiss: () => void }) {
|
||||
const brand = getBrand()
|
||||
const adminUrl = `https://admin.${brand.adminDomain}`
|
||||
return (
|
||||
<XStack
|
||||
bg="$yellow2"
|
||||
borderBottomWidth={1}
|
||||
borderColor="$yellow7"
|
||||
px="$4"
|
||||
py="$2"
|
||||
items="center"
|
||||
justify="space-between"
|
||||
gap="$3"
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<Text fontSize="$2" color="$yellow11" flex={1}>
|
||||
{'Admin ops (IAM · KMS · orgs) → '}
|
||||
<Text fontSize="$2" color="$yellow12" fontWeight="700">
|
||||
{`admin.${brand.adminDomain}`}
|
||||
</Text>
|
||||
</Text>
|
||||
<XStack gap="$2" items="center">
|
||||
<Button
|
||||
size="$2"
|
||||
bg="$yellow4"
|
||||
borderColor="$yellow7"
|
||||
onPress={() => window.location.assign(adminUrl)}
|
||||
>
|
||||
{`Open admin`}
|
||||
</Button>
|
||||
<Button size="$2" chromeless theme="yellow" onPress={onDismiss}>
|
||||
✕
|
||||
</Button>
|
||||
</XStack>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
export function OrgGate({ children }: { children: ReactNode }) {
|
||||
const { account } = useSession()
|
||||
const owner = account?.owner ?? ''
|
||||
// GLOBAL (cross-tenant) admin — the only one who may use admin.hanzo.ai. The
|
||||
// decision (membership in the reserved `admin` org, or an explicit isGlobalAdmin
|
||||
// claim) lives in ONE place — `isGlobalAdminAccount` — shared with the nav gate.
|
||||
// A tenant org owner (e.g. Dave/maxpower) has owner!=='admin' → never global.
|
||||
const isGlobalAdmin = isGlobalAdminAccount(account)
|
||||
const [bannerDismissed, setBannerDismissed] = useState(true) // start hidden to avoid flash
|
||||
|
||||
// Restore banner dismissed state and last org on mount
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
const dismissed = localStorage.getItem(LS_BANNER_DISMISSED) === '1'
|
||||
setBannerDismissed(dismissed)
|
||||
}, [])
|
||||
|
||||
// Seed org scope on sign-in. A non-global admin can ONLY act in their OWN org —
|
||||
// the server pins them there and every cross-tenant call 403s — so they are
|
||||
// ALWAYS scoped to `owner`, ignoring a stale/switched org left in localStorage
|
||||
// (e.g. a leftover `adnexus` from a prior global-admin switch, which would make
|
||||
// the whole console 403 against a tenant they can't read). Only a global admin
|
||||
// restores a previously-switched org.
|
||||
useEffect(() => {
|
||||
if (!owner || typeof window === 'undefined') return
|
||||
if (!isGlobalAdmin) {
|
||||
// Hard-pin to own org. If a stale cross-tenant scope was active, reset it and
|
||||
// reload so every module refetches under the correct X-Org-Id. The reload is
|
||||
// guarded on `currentOrg() !== owner`, so once the scope is right it never
|
||||
// fires again — no loop.
|
||||
localStorage.removeItem(LS_LAST_ORG)
|
||||
if (currentOrg() !== owner) {
|
||||
setCurrentOrg(owner)
|
||||
window.location.reload()
|
||||
}
|
||||
return
|
||||
}
|
||||
const lastOrg = localStorage.getItem(LS_LAST_ORG)
|
||||
if (currentOrg() === config.iamOrgName) {
|
||||
const target = (lastOrg && lastOrg !== config.iamOrgName) ? lastOrg : owner
|
||||
if (target !== config.iamOrgName) setCurrentOrg(target)
|
||||
}
|
||||
// Persist current org whenever it updates
|
||||
const cur = currentOrg()
|
||||
if (cur && cur !== config.iamOrgName) localStorage.setItem(LS_LAST_ORG, cur)
|
||||
}, [owner, isGlobalAdmin])
|
||||
|
||||
const dismissBanner = () => {
|
||||
setBannerDismissed(true)
|
||||
if (typeof window !== 'undefined') localStorage.setItem(LS_BANNER_DISMISSED, '1')
|
||||
}
|
||||
|
||||
// admin.hanzo.ai is GLOBAL-admin-only (cross-tenant IAM/KMS/orgs ops). A
|
||||
// non-global-admin who lands here — e.g. an org owner like Dave/maxpower — is
|
||||
// bounced to the regular console host; they never reach the admin surfaces (the
|
||||
// server /admin/* proxies also fail-closed, this is the matching UI gate).
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
if (onAdminHost() && owner && !isGlobalAdmin) {
|
||||
const consoleHost = window.location.hostname.replace(/^admin\./, 'console.')
|
||||
window.location.replace(`https://${consoleHost}${window.location.pathname}${window.location.search}`)
|
||||
}
|
||||
}, [owner, isGlobalAdmin])
|
||||
|
||||
// No org yet → first-run onboarding
|
||||
if (!owner) {
|
||||
return <OrgOnboarding />
|
||||
}
|
||||
|
||||
// On the admin host but not a global admin → render nothing while the redirect
|
||||
// above fires, so the admin console never flashes for an unauthorized user.
|
||||
if (onAdminHost() && !isGlobalAdmin) {
|
||||
return null
|
||||
}
|
||||
|
||||
// GLOBAL admin on a non-admin host: show dismissible banner, render console
|
||||
// normally. Org-level admins (org owners) never see it — admin.hanzo.ai is
|
||||
// cross-tenant ops they cannot use.
|
||||
const showBanner = isGlobalAdmin && !onAdminHost() && !bannerDismissed
|
||||
|
||||
return (
|
||||
<YStack flex={1}>
|
||||
{showBanner && <AdminBanner onDismiss={dismissBanner} />}
|
||||
{children}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Org onboarding — the first-run screen for a user who isn't in an organization.
|
||||
*
|
||||
* Shown by {@link OrgGate} when the session has no org. Two predictable paths:
|
||||
* - "Create your organization": type a name; we show the exact slug it becomes,
|
||||
* then create the org and make you its admin.
|
||||
* - "Skip — use a personal organization": one click creates a `<username>` org
|
||||
* so EVERYONE always has an org and lands straight in the console.
|
||||
*
|
||||
* The mutation is the server route `/onboard` (it acts as the confidential
|
||||
* console client; the browser only sends its cookie). Because an IAM user's
|
||||
* org IS their identity, creating the org moves the user into it — so on success
|
||||
* we re-authenticate (sign out → sign in) to mint a session for the new org, and
|
||||
* the user arrives in their console. Honest inline errors; never a fake success.
|
||||
*/
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { Button, Card, Input, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Building2, ArrowRight, Sparkles } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { slugifyOrg, validateOrgName } from '~/lib/server/onboarding'
|
||||
import { v1Url } from '~/lib/api/client'
|
||||
import { FadeIn } from '~/components/ui/FadeIn'
|
||||
|
||||
type Phase = 'form' | 'done'
|
||||
|
||||
export function OrgOnboarding() {
|
||||
const { signIn, signOut } = useSession()
|
||||
const [name, setName] = useState('')
|
||||
const [busy, setBusy] = useState<false | 'create' | 'personal'>(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [phase, setPhase] = useState<Phase>('form')
|
||||
|
||||
const slug = slugifyOrg(name)
|
||||
const named = validateOrgName(name)
|
||||
const canCreate = named.ok && !busy
|
||||
|
||||
async function onboard(payload: { name: string } | { personal: true }, which: 'create' | 'personal') {
|
||||
setError(null)
|
||||
setBusy(which)
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(v1Url('console/onboard'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
} catch {
|
||||
setError('Network error — please try again.')
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
const json = (await res.json().catch(() => null)) as { org?: string; error?: string } | null
|
||||
if (!res.ok || !json?.org) {
|
||||
setError(json?.error || `Could not create the organization (HTTP ${res.status}).`)
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
// Created + joined. Re-authenticate so the new session carries the new org.
|
||||
setPhase('done')
|
||||
try {
|
||||
await signOut()
|
||||
} catch {
|
||||
// ignore — we re-auth regardless
|
||||
}
|
||||
signIn()
|
||||
}
|
||||
|
||||
if (phase === 'done') {
|
||||
return (
|
||||
<Center>
|
||||
<FadeIn style={CENTER_STYLE}>
|
||||
<Card p="$5" gap="$4" width={440} borderWidth={1} borderColor="$borderColor" bg="$color1" items="center">
|
||||
<Spinner size="large" color="$color11" />
|
||||
<YStack gap="$1" items="center">
|
||||
<Text fontSize="$6" fontWeight="800">
|
||||
Organization ready
|
||||
</Text>
|
||||
<Text fontSize="$3" color="$color11" text="center">
|
||||
Signing you in to your new organization…
|
||||
</Text>
|
||||
</YStack>
|
||||
<Button size="$3" chromeless onPress={() => signIn()}>
|
||||
Continue
|
||||
</Button>
|
||||
</Card>
|
||||
</FadeIn>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Center>
|
||||
<FadeIn style={CENTER_STYLE}>
|
||||
<Card p="$5" gap="$4" width={440} borderWidth={1} borderColor="$borderColor" bg="$color1">
|
||||
<YStack gap="$2">
|
||||
<XStack gap="$2" items="center">
|
||||
<Building2 size={20} />
|
||||
<Text fontSize="$7" fontWeight="800">
|
||||
Create your organization
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$3" color="$color11">
|
||||
Your account isn’t in an organization yet. Create one to get started — you can
|
||||
rename it and invite teammates later.
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
<YStack gap="$2">
|
||||
<Text fontSize="$2" color="$color11" fontWeight="600">
|
||||
Organization name
|
||||
</Text>
|
||||
<Input
|
||||
value={name}
|
||||
onChangeText={(v) => {
|
||||
setName(v)
|
||||
if (error) setError(null)
|
||||
}}
|
||||
placeholder="Acme Inc"
|
||||
autoCapitalize="words"
|
||||
autoFocus
|
||||
onSubmitEditing={() => canCreate && void onboard({ name }, 'create')}
|
||||
/>
|
||||
{slug ? (
|
||||
<Text fontSize="$2" color="$color10">
|
||||
Identifier: <Text color="$color12">{slug}</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Text fontSize="$2" color="$color10">
|
||||
Letters and numbers — spaces become hyphens.
|
||||
</Text>
|
||||
)}
|
||||
</YStack>
|
||||
|
||||
{error ? (
|
||||
<Text fontSize="$2" color="$red10">
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
size="$4"
|
||||
theme="light"
|
||||
disabled={!canCreate}
|
||||
iconAfter={busy === 'create' ? <Spinner color="$color1" /> : <ArrowRight size={16} />}
|
||||
onPress={() => void onboard({ name }, 'create')}
|
||||
>
|
||||
{busy === 'create' ? 'Creating…' : 'Create organization'}
|
||||
</Button>
|
||||
|
||||
<XStack items="center" gap="$3">
|
||||
<YStack flex={1} height={1} bg="$borderColor" />
|
||||
<Text fontSize="$1" color="$color10">
|
||||
OR
|
||||
</Text>
|
||||
<YStack flex={1} height={1} bg="$borderColor" />
|
||||
</XStack>
|
||||
|
||||
<Button
|
||||
size="$3"
|
||||
disabled={!!busy}
|
||||
icon={busy === 'personal' ? <Spinner color="$color11" /> : <Sparkles size={16} />}
|
||||
onPress={() => void onboard({ personal: true }, 'personal')}
|
||||
>
|
||||
{busy === 'personal' ? 'Setting up…' : 'Skip — use a personal organization'}
|
||||
</Button>
|
||||
|
||||
<Button size="$2" chromeless disabled={!!busy} onPress={() => void signOut()}>
|
||||
Sign out
|
||||
</Button>
|
||||
</Card>
|
||||
</FadeIn>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
const CENTER_STYLE = { display: 'flex', justifyContent: 'center', width: '100%' } as const
|
||||
|
||||
function Center({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<YStack flex={1} minH="100vh" items="center" justify="center" p="$4">
|
||||
{children}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Org switcher — shows the organization the console is scoped to, lets the user
|
||||
* switch between the orgs they can see, and CREATE a new one.
|
||||
*
|
||||
* The org LIST comes from IAM (`get-organizations`, via the gated `/admin/iam`
|
||||
* proxy): a global admin sees every org; a tenant whose account can't list gets
|
||||
* an empty result and simply sees their current org. Either way the trigger is
|
||||
* ALWAYS interactive (so "Create organization" is reachable even with one org).
|
||||
* Selecting one re-scopes the console IN PLACE — `switchOrg` persists the choice
|
||||
* and reloads so every module refetches under the new `X-Org-Id`.
|
||||
*
|
||||
* Create posts to the same-origin `/onboard` route: a zero-org user is created +
|
||||
* joined as admin; a user who already has an org gets an ADDITIONAL org (created
|
||||
* without moving them) and is scope-switched into it. We never fabricate orgs.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Button, Input, Popover, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Building2, Check, ChevronsUpDown, Plus, Search } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { currentOrg, switchOrg, filterOrgs } from '~/lib/org-scope'
|
||||
import { IamAdminApi, type Organization } from '~/lib/api'
|
||||
import { v1Url } from '~/lib/api/client'
|
||||
import { useIsGlobalAdmin } from '~/lib/auth/admin'
|
||||
|
||||
const titleCase = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
|
||||
|
||||
export function OrgSwitcher() {
|
||||
const currentId = currentOrg()
|
||||
const isGlobalAdmin = useIsGlobalAdmin()
|
||||
const [orgs, setOrgs] = useState<Organization[]>([])
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// The cross-tenant org list (`/admin/iam/get-organizations?owner=admin`) is
|
||||
// server-gated to global admins; a tenant user only ever 403s it. Don't fire it
|
||||
// for them — they simply see their current org (synthesized below) and can still
|
||||
// "Create organization". Global admins get the full switchable list.
|
||||
if (!isGlobalAdmin) {
|
||||
setOrgs([])
|
||||
return
|
||||
}
|
||||
let live = true
|
||||
IamAdminApi.organizations()
|
||||
.then((p) => {
|
||||
if (live) setOrgs(p.rows ?? [])
|
||||
})
|
||||
.catch(() => {
|
||||
// IAM org listing not available to this account (tenant) — current-org only.
|
||||
if (live) setOrgs([])
|
||||
})
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [isGlobalAdmin])
|
||||
|
||||
// Always include the current org so the switcher is meaningful even when the
|
||||
// (admin-gated) list is empty for a tenant.
|
||||
const allOrgs = useMemo(() => {
|
||||
if (orgs.some((o) => o.name === currentId)) return orgs
|
||||
return [{ owner: 'admin', name: currentId, displayName: titleCase(currentId) } as Organization, ...orgs]
|
||||
}, [orgs, currentId])
|
||||
|
||||
const currentName = allOrgs.find((o) => o.name === currentId)?.displayName || titleCase(currentId)
|
||||
const filtered = useMemo(() => filterOrgs(allOrgs, query), [allOrgs, query])
|
||||
|
||||
const select = (org: Organization) => {
|
||||
setOpen(false)
|
||||
switchOrg(org.name)
|
||||
}
|
||||
|
||||
const create = async () => {
|
||||
const name = newName.trim()
|
||||
if (!name) return
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
const res = await fetch(v1Url('console/onboard'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
const data = (await res.json().catch(() => ({}))) as { org?: string; error?: string }
|
||||
if (!res.ok || !data.org) throw new Error(data.error || `Could not create organization (${res.status}).`)
|
||||
// Scope into the new org (persists + reloads so every module refetches).
|
||||
switchOrg(data.org)
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Could not create the organization.')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen} placement="bottom-end">
|
||||
<Popover.Trigger asChild>
|
||||
<Button size="$2" chromeless icon={<Building2 size={14} />} iconAfter={<ChevronsUpDown size={13} />}>
|
||||
{currentName}
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content bordered elevate p="$2" width={300} bg="$color2" borderColor="$borderColor">
|
||||
{creating ? (
|
||||
<YStack gap="$2">
|
||||
<Text fontSize="$2" color="$color12" fontWeight="700">
|
||||
Create organization
|
||||
</Text>
|
||||
<Input
|
||||
size="$3"
|
||||
placeholder="Organization name"
|
||||
value={newName}
|
||||
onChangeText={setNewName}
|
||||
autoCapitalize="words"
|
||||
onSubmitEditing={() => void create()}
|
||||
/>
|
||||
{err ? (
|
||||
<Text fontSize="$1" color="$red10">
|
||||
{err}
|
||||
</Text>
|
||||
) : null}
|
||||
<XStack gap="$2" justify="flex-end">
|
||||
<Button
|
||||
size="$2"
|
||||
chromeless
|
||||
onPress={() => {
|
||||
setCreating(false)
|
||||
setErr(null)
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="$2"
|
||||
onPress={() => void create()}
|
||||
disabled={busy || !newName.trim()}
|
||||
icon={busy ? <Spinner size="small" /> : <Plus size={14} />}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</XStack>
|
||||
</YStack>
|
||||
) : (
|
||||
<YStack gap="$1">
|
||||
<XStack items="center" gap="$2" px="$2" py="$1" rounded="$3" borderWidth={1} borderColor="$borderColor">
|
||||
<Search size={13} opacity={0.6} />
|
||||
<Input
|
||||
flex={1}
|
||||
size="$2"
|
||||
borderWidth={0}
|
||||
bg="transparent"
|
||||
placeholder="Filter organizations…"
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</XStack>
|
||||
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
|
||||
Organizations · {allOrgs.length}
|
||||
</Text>
|
||||
<YStack gap="$0.5" maxH={300} overflow="scroll">
|
||||
{filtered.length === 0 ? (
|
||||
<Text px="$2" py="$2" fontSize="$2" color="$color10">
|
||||
No organizations match “{query}”.
|
||||
</Text>
|
||||
) : (
|
||||
filtered.map((org) => {
|
||||
const isCurrent = org.name === currentId
|
||||
return (
|
||||
<XStack
|
||||
key={`${org.owner}/${org.name}`}
|
||||
onPress={() => select(org)}
|
||||
cursor="pointer"
|
||||
items="center"
|
||||
gap="$2"
|
||||
px="$2"
|
||||
py="$2"
|
||||
rounded="$3"
|
||||
bg={isCurrent ? '$color4' : 'transparent'}
|
||||
hoverStyle={{ bg: '$color5' }}
|
||||
>
|
||||
<Building2 size={14} opacity={0.7} />
|
||||
<Text flex={1} fontSize="$2" color="$color12" numberOfLines={1}>
|
||||
{org.displayName || titleCase(org.name)}
|
||||
</Text>
|
||||
{isCurrent ? <Check size={14} /> : null}
|
||||
</XStack>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</YStack>
|
||||
<XStack
|
||||
onPress={() => {
|
||||
setCreating(true)
|
||||
setErr(null)
|
||||
}}
|
||||
cursor="pointer"
|
||||
items="center"
|
||||
gap="$2"
|
||||
px="$2"
|
||||
py="$2"
|
||||
mt="$1"
|
||||
rounded="$3"
|
||||
borderTopWidth={1}
|
||||
borderColor="$borderColor"
|
||||
hoverStyle={{ bg: '$color5' }}
|
||||
>
|
||||
<Plus size={14} />
|
||||
<Text fontSize="$2" color="$color12">
|
||||
Create organization
|
||||
</Text>
|
||||
</XStack>
|
||||
</YStack>
|
||||
)}
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -7,9 +7,21 @@
|
||||
import { useMemo, type ReactNode } from 'react'
|
||||
import { GuiProvider } from '@hanzo/gui'
|
||||
import { NextThemeProvider, useRootTheme } from '@hanzogui/next-theme'
|
||||
import { registerDefaultFields } from '@hanzo/data'
|
||||
|
||||
import config from '../../gui.config'
|
||||
import { SessionProvider } from '~/lib/auth/session'
|
||||
import { OrgAccentProvider } from './OrgAccentProvider'
|
||||
|
||||
// @hanzo/data populates its field-INPUT registry via an import SIDE EFFECT, but the
|
||||
// package ships `"sideEffects": false`, so production tree-shaking (we consume it via
|
||||
// `transpilePackages`) PRUNES that registration — leaving the registry empty. Then
|
||||
// `FieldInput` returns null for every field, so a record CREATE/EDIT form (Base +
|
||||
// Records) renders its labels with ZERO inputs and "Create" persists a BLANK row. An
|
||||
// explicit call is a USED binding webpack cannot drop; it lights up every field input.
|
||||
// Idempotent (guarded internally), no window/DOM — safe at module scope (SSR + client).
|
||||
// ONE place, DRY — fixes every editable @hanzo/data surface, current and future.
|
||||
registerDefaultFields()
|
||||
|
||||
function Themed({ children }: { children: ReactNode }) {
|
||||
// `dark` fallback so the server render and the client's initial state agree
|
||||
@@ -26,6 +38,17 @@ function Themed({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
export function Provider({ children }: { children: ReactNode }) {
|
||||
const tree = useMemo(() => <SessionProvider>{children}</SessionProvider>, [children])
|
||||
// OrgAccentProvider lives INSIDE SessionProvider (it reads the session to resolve the
|
||||
// org's accent) and applies it at the document root — so every accent surface picks
|
||||
// up the org's brand color on load, DRY.
|
||||
const tree = useMemo(
|
||||
() => (
|
||||
<SessionProvider>
|
||||
<OrgAccentProvider />
|
||||
{children}
|
||||
</SessionProvider>
|
||||
),
|
||||
[children],
|
||||
)
|
||||
return <Themed>{tree}</Themed>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Scope switcher — the project + environment pickers that scope every module.
|
||||
*
|
||||
* Two chips next to the org switcher: the active PROJECT (or "All projects" for
|
||||
* org-level scope) and the active ENVIRONMENT (mainnet/testnet/devnet + custom).
|
||||
* Both write through `useScope`, which updates the module-level scope the API
|
||||
* client reads — so changing either re-scopes every product at once. A "New
|
||||
* project" affordance routes to the Projects module; we never fabricate a project.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button, Popover, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Check, ChevronsUpDown, FolderGit2, Layers, Plus } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { useScope } from '~/lib/scope-context'
|
||||
import { STOCK_ENVIRONMENTS } from '~/lib/scope'
|
||||
|
||||
/** A small colored dot keyed to the environment's network tier. */
|
||||
type DotColor = '$green10' | '$yellow10' | '$blue10' | '$purple10'
|
||||
const ENV_DOT: Record<string, DotColor> = {
|
||||
mainnet: '$green10',
|
||||
testnet: '$yellow10',
|
||||
devnet: '$blue10',
|
||||
}
|
||||
const envDot = (env: string): DotColor => ENV_DOT[env] ?? '$purple10'
|
||||
const titleCase = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
|
||||
|
||||
function ProjectPicker() {
|
||||
const router = useRouter()
|
||||
const { scope, projects, loadingProjects, selectProject } = useScope()
|
||||
const label = scope.project ? scope.project : 'All projects'
|
||||
|
||||
return (
|
||||
<Popover placement="bottom-end">
|
||||
<Popover.Trigger asChild>
|
||||
<Button size="$2" chromeless icon={<FolderGit2 size={14} />} iconAfter={<ChevronsUpDown size={13} />}>
|
||||
{label}
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content bordered elevate p="$2" width={260} bg="$color2" borderColor="$borderColor">
|
||||
<YStack gap="$0.5">
|
||||
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
|
||||
Project
|
||||
</Text>
|
||||
|
||||
{/* Org-level scope — no X-Project-Id sent. */}
|
||||
<Row
|
||||
label="All projects"
|
||||
sub="Org-level"
|
||||
active={!scope.project}
|
||||
onPress={() => selectProject(undefined)}
|
||||
/>
|
||||
|
||||
{projects.map((p) => (
|
||||
<Row
|
||||
key={p.name}
|
||||
label={p.displayName || p.name}
|
||||
active={scope.project === p.name}
|
||||
onPress={() => selectProject(p.name)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{projects.length === 0 && !loadingProjects ? (
|
||||
<Text px="$2" py="$1.5" fontSize="$2" color="$color10">
|
||||
No projects yet.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<XStack height={1} bg="$borderColor" my="$1" />
|
||||
<Row
|
||||
label="New project"
|
||||
icon={<Plus size={14} />}
|
||||
onPress={() => router.push('/projects')}
|
||||
/>
|
||||
</YStack>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function EnvironmentPicker() {
|
||||
const { scope, environments, selectEnvironment } = useScope()
|
||||
|
||||
return (
|
||||
<Popover placement="bottom-end">
|
||||
<Popover.Trigger asChild>
|
||||
<Button size="$2" chromeless icon={<Layers size={14} />} iconAfter={<ChevronsUpDown size={13} />}>
|
||||
<XStack items="center" gap="$2">
|
||||
<YStack width={8} height={8} rounded={999} bg={envDot(scope.environment)} />
|
||||
<Text fontSize="$2" color="$color12">
|
||||
{scope.environment}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content bordered elevate p="$2" width={220} bg="$color2" borderColor="$borderColor">
|
||||
<YStack gap="$0.5">
|
||||
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
|
||||
Environment
|
||||
</Text>
|
||||
{environments.map((env) => {
|
||||
const stock = (STOCK_ENVIRONMENTS as readonly string[]).includes(env)
|
||||
return (
|
||||
<Row
|
||||
key={env}
|
||||
label={titleCase(env)}
|
||||
sub={stock ? undefined : 'Custom'}
|
||||
dot={envDot(env)}
|
||||
active={scope.environment === env}
|
||||
onPress={() => selectEnvironment(env)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</YStack>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
/** One selectable row in a picker popover. */
|
||||
function Row({
|
||||
label,
|
||||
sub,
|
||||
dot,
|
||||
icon,
|
||||
active,
|
||||
onPress,
|
||||
}: {
|
||||
label: string
|
||||
sub?: string
|
||||
dot?: DotColor
|
||||
icon?: ReactNode
|
||||
active?: boolean
|
||||
onPress: () => void
|
||||
}) {
|
||||
return (
|
||||
<XStack
|
||||
onPress={onPress}
|
||||
cursor="pointer"
|
||||
items="center"
|
||||
gap="$2"
|
||||
px="$2"
|
||||
py="$2"
|
||||
rounded="$3"
|
||||
hoverStyle={{ bg: '$color4' }}
|
||||
>
|
||||
{dot ? <YStack width={8} height={8} rounded={999} bg={dot} /> : icon}
|
||||
<YStack flex={1}>
|
||||
<Text fontSize="$2" color="$color12" numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
{sub ? (
|
||||
<Text fontSize="$1" color="$color10">
|
||||
{sub}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
{active ? <Check size={14} /> : null}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** Project + environment pickers as a unit (topbar). */
|
||||
export function ScopeSwitcher() {
|
||||
return (
|
||||
<XStack items="center" gap="$1">
|
||||
<ProjectPicker />
|
||||
<EnvironmentPicker />
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Sidebar customization panes — the CONTENT the DetailPane renders when a user
|
||||
* customizes their sidebar. Two surfaces, both backed by the ONE account-persisted
|
||||
* store (`usePins` + `useProductColors`), so every choice follows the user:
|
||||
*
|
||||
* - `ProductCustomize` — set a single product's icon COLOR, PIN it, and file it
|
||||
* into a GROUP. Opened from a product row's color dot.
|
||||
* - `ManagePins` — the full manager: DRAG to reorder within a group, create /
|
||||
* rename / remove groups, move a pin between groups, unpin. Opened from the
|
||||
* "Manage" affordance on the Pinned header.
|
||||
*
|
||||
* These are pane bodies (rendered inside `DetailPane`), not their own overlays —
|
||||
* the pane chrome (slide, backdrop, close) is the shared `SlideOver`.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { Button, Input, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Check, GripVertical, Plus, Star, X } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { COLOR_SWATCHES } from '~/lib/products/colors'
|
||||
import { DEFAULT_GROUP, DEFAULT_GROUP_LABEL, type PinGroupView } from '~/lib/products/pins-core'
|
||||
import { usePins, useProductColors } from '~/lib/products/pins'
|
||||
import { findEntry } from '~/lib/products/registry'
|
||||
import { Reorder } from '~/components/ui/Reorder'
|
||||
import { asColor } from '~/components/ui/color'
|
||||
|
||||
/** A round color swatch button; ringed + checked when selected. */
|
||||
function SwatchButton({ hex, selected, onPress }: { hex: string; selected: boolean; onPress: () => void }) {
|
||||
return (
|
||||
<XStack
|
||||
onPress={onPress}
|
||||
cursor="pointer"
|
||||
width={32}
|
||||
height={32}
|
||||
rounded="$10"
|
||||
items="center"
|
||||
justify="center"
|
||||
borderWidth={2}
|
||||
borderColor={selected ? '$color12' : 'transparent'}
|
||||
hoverStyle={{ borderColor: selected ? '$color12' : '$color8' }}
|
||||
style={{ backgroundColor: hex }}
|
||||
aria-label={selected ? 'Selected color' : 'Set color'}
|
||||
>
|
||||
{selected ? <Check size={16} color={asColor('#ffffff')} /> : null}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** A pressable group chip (default bucket, a named group, or the active state). */
|
||||
function GroupChip({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
|
||||
return (
|
||||
<XStack
|
||||
onPress={onPress}
|
||||
cursor="pointer"
|
||||
px="$2.5"
|
||||
py="$1.5"
|
||||
rounded="$10"
|
||||
borderWidth={1}
|
||||
borderColor={active ? '$color8' : '$borderColor'}
|
||||
bg={active ? '$color5' : 'transparent'}
|
||||
hoverStyle={{ bg: active ? '$color5' : '$color3' }}
|
||||
>
|
||||
<Text fontSize="$2" fontWeight="600" color="$color12">
|
||||
{label}
|
||||
</Text>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** New-group input + add — shared by both panes. */
|
||||
function NewGroup({ onAdd }: { onAdd: (name: string) => void }) {
|
||||
const [name, setName] = useState('')
|
||||
const add = () => {
|
||||
const n = name.trim()
|
||||
if (!n) return
|
||||
onAdd(n)
|
||||
setName('')
|
||||
}
|
||||
return (
|
||||
<XStack items="center" gap="$2">
|
||||
<XStack
|
||||
flex={1}
|
||||
items="center"
|
||||
gap="$2"
|
||||
px="$2.5"
|
||||
height={36}
|
||||
rounded="$3"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
bg="$color2"
|
||||
>
|
||||
<Input
|
||||
flex={1}
|
||||
unstyled
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="New group…"
|
||||
fontSize="$3"
|
||||
color="$color12"
|
||||
autoCapitalize="none"
|
||||
onSubmitEditing={add}
|
||||
/>
|
||||
</XStack>
|
||||
<Button size="$3" icon={<Plus size={16} />} onPress={add} disabled={!name.trim()}>
|
||||
Add
|
||||
</Button>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Single-product customize ─────────────────────────────────────────────────
|
||||
|
||||
export function ProductCustomize({ id }: { id: string }) {
|
||||
const entry = findEntry(id)
|
||||
const { keyOf, setColor, resetColor } = useProductColors()
|
||||
const { isPinned, toggle, assign, view, manageView, addGroup } = usePins()
|
||||
const activeKey = keyOf(id)
|
||||
const pinned = isPinned(id)
|
||||
const groupOf =
|
||||
view.flatMap((g) => g.entries.map((e) => ({ id: e.id, group: g.name }))).find((e) => e.id === id)?.group ??
|
||||
DEFAULT_GROUP
|
||||
const groups = manageView.map((g) => g.name)
|
||||
|
||||
if (!entry) return <Text color="$color10">Unknown product.</Text>
|
||||
|
||||
return (
|
||||
<YStack gap="$5">
|
||||
{/* Color */}
|
||||
<YStack gap="$2.5">
|
||||
<XStack items="center" justify="space-between">
|
||||
<Text fontSize="$2" color="$color11" fontWeight="700" textTransform="uppercase">
|
||||
Icon color
|
||||
</Text>
|
||||
<Button size="$1" chromeless onPress={() => resetColor(id)}>
|
||||
<Text fontSize="$1" color="$color10">
|
||||
Reset
|
||||
</Text>
|
||||
</Button>
|
||||
</XStack>
|
||||
<XStack flexWrap="wrap" gap="$2.5">
|
||||
{COLOR_SWATCHES.map((s) => (
|
||||
<SwatchButton key={s.key} hex={s.hex} selected={s.key === activeKey} onPress={() => setColor(id, s.key)} />
|
||||
))}
|
||||
</XStack>
|
||||
</YStack>
|
||||
|
||||
{/* Pin */}
|
||||
<YStack gap="$2.5">
|
||||
<Text fontSize="$2" color="$color11" fontWeight="700" textTransform="uppercase">
|
||||
Pin
|
||||
</Text>
|
||||
<Button
|
||||
justify="flex-start"
|
||||
icon={<Star size={16} />}
|
||||
onPress={() => toggle(id)}
|
||||
bg={pinned ? '$color5' : 'transparent'}
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
>
|
||||
{pinned ? 'Pinned to sidebar' : 'Pin to sidebar'}
|
||||
</Button>
|
||||
</YStack>
|
||||
|
||||
{/* Group (only meaningful once pinned) */}
|
||||
{pinned ? (
|
||||
<YStack gap="$2.5">
|
||||
<Text fontSize="$2" color="$color11" fontWeight="700" textTransform="uppercase">
|
||||
Group
|
||||
</Text>
|
||||
<XStack flexWrap="wrap" gap="$2">
|
||||
<GroupChip label={DEFAULT_GROUP_LABEL} active={groupOf === DEFAULT_GROUP} onPress={() => assign(id, DEFAULT_GROUP)} />
|
||||
{groups
|
||||
.filter((g) => g !== DEFAULT_GROUP)
|
||||
.map((g) => (
|
||||
<GroupChip key={g} label={g} active={groupOf === g} onPress={() => assign(id, g)} />
|
||||
))}
|
||||
</XStack>
|
||||
<NewGroup
|
||||
onAdd={(name) => {
|
||||
addGroup(name)
|
||||
assign(id, name)
|
||||
}}
|
||||
/>
|
||||
</YStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Full pins manager ────────────────────────────────────────────────────────
|
||||
|
||||
/** One draggable pinned row inside a group (icon + label + move-to-group + unpin). */
|
||||
function PinRow({
|
||||
id,
|
||||
groups,
|
||||
currentGroup,
|
||||
colorOf,
|
||||
onAssign,
|
||||
onUnpin,
|
||||
handle,
|
||||
}: {
|
||||
id: string
|
||||
groups: string[]
|
||||
currentGroup: string
|
||||
colorOf: (id: string) => string
|
||||
onAssign: (id: string, group: string) => void
|
||||
onUnpin: (id: string) => void
|
||||
handle: { onPointerDown: (e: never) => void }
|
||||
}) {
|
||||
const entry = findEntry(id)
|
||||
const Icon = entry?.icon
|
||||
return (
|
||||
<XStack items="center" gap="$2" px="$2" height={44} rounded="$3" bg="$color2" borderWidth={1} borderColor="$borderColor">
|
||||
<XStack
|
||||
{...handle}
|
||||
cursor="grab"
|
||||
px="$1"
|
||||
py="$2"
|
||||
opacity={0.6}
|
||||
hoverStyle={{ opacity: 1 }}
|
||||
aria-label="Drag to reorder"
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
</XStack>
|
||||
{Icon ? <Icon size={16} color={asColor(colorOf(id))} /> : null}
|
||||
<Text flex={1} fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
|
||||
{entry?.label ?? id}
|
||||
</Text>
|
||||
{groups.length > 1 ? (
|
||||
<XStack items="center" gap="$1">
|
||||
{groups.map((g) => (
|
||||
<XStack
|
||||
key={g || 'default'}
|
||||
onPress={() => onAssign(id, g)}
|
||||
cursor="pointer"
|
||||
width={18}
|
||||
height={18}
|
||||
rounded="$10"
|
||||
items="center"
|
||||
justify="center"
|
||||
borderWidth={1}
|
||||
borderColor={g === currentGroup ? '$color12' : '$borderColor'}
|
||||
bg={g === currentGroup ? '$color12' : 'transparent'}
|
||||
aria-label={`Move to ${g || DEFAULT_GROUP_LABEL}`}
|
||||
/>
|
||||
))}
|
||||
</XStack>
|
||||
) : null}
|
||||
<Button size="$1" chromeless icon={<X size={15} />} onPress={() => onUnpin(id)} aria-label="Unpin" />
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
function ManageGroup({
|
||||
group,
|
||||
groups,
|
||||
colorOf,
|
||||
onReorder,
|
||||
onAssign,
|
||||
onUnpin,
|
||||
onRename,
|
||||
onRemove,
|
||||
}: {
|
||||
group: PinGroupView
|
||||
groups: string[]
|
||||
colorOf: (id: string) => string
|
||||
onReorder: (group: string, from: number, to: number) => void
|
||||
onAssign: (id: string, group: string) => void
|
||||
onUnpin: (id: string) => void
|
||||
onRename: (from: string, to: string) => void
|
||||
onRemove: (name: string) => void
|
||||
}) {
|
||||
const [rename, setRename] = useState(group.label)
|
||||
const named = group.name !== DEFAULT_GROUP
|
||||
return (
|
||||
<YStack gap="$2">
|
||||
<XStack items="center" gap="$2">
|
||||
{named ? (
|
||||
<XStack flex={1} items="center" gap="$2" px="$2.5" height={32} rounded="$3" borderWidth={1} borderColor="$borderColor" bg="$color2">
|
||||
<Input
|
||||
flex={1}
|
||||
unstyled
|
||||
value={rename}
|
||||
onChangeText={setRename}
|
||||
onSubmitEditing={() => onRename(group.name, rename)}
|
||||
onBlur={() => onRename(group.name, rename)}
|
||||
fontSize="$2"
|
||||
fontWeight="700"
|
||||
color="$color12"
|
||||
/>
|
||||
</XStack>
|
||||
) : (
|
||||
<Text flex={1} fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
|
||||
{group.label}
|
||||
</Text>
|
||||
)}
|
||||
{named ? (
|
||||
<Button size="$1" chromeless icon={<X size={14} />} onPress={() => onRemove(group.name)} aria-label={`Remove ${group.label}`} />
|
||||
) : null}
|
||||
</XStack>
|
||||
|
||||
{group.entries.length === 0 ? (
|
||||
<Text px="$2" py="$2" fontSize="$2" color="$color10">
|
||||
Empty — move a pin here.
|
||||
</Text>
|
||||
) : (
|
||||
<Reorder
|
||||
items={group.entries}
|
||||
keyOf={(e) => e.id}
|
||||
rowHeight={48}
|
||||
onReorder={(from, to) => onReorder(group.name, from, to)}
|
||||
renderItem={(e, h) => (
|
||||
<PinRow
|
||||
id={e.id}
|
||||
groups={groups}
|
||||
currentGroup={group.name}
|
||||
colorOf={colorOf}
|
||||
onAssign={onAssign}
|
||||
onUnpin={onUnpin}
|
||||
handle={h as { onPointerDown: (e: never) => void }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
export function ManagePins() {
|
||||
const { manageView, move, assign, unpin, addGroup, removeGroup, renameGroup } = usePins()
|
||||
const { colorOf } = useProductColors()
|
||||
const groupNames = manageView.map((g) => g.name)
|
||||
|
||||
return (
|
||||
<YStack gap="$5">
|
||||
<Text fontSize="$2" color="$color10">
|
||||
Drag the handle to reorder. Use the dots to move a pin between groups. Create groups to organize your sidebar.
|
||||
</Text>
|
||||
|
||||
{manageView.map((group) => (
|
||||
<ManageGroup
|
||||
key={group.name || 'default'}
|
||||
group={group}
|
||||
groups={groupNames}
|
||||
colorOf={colorOf}
|
||||
onReorder={(g, from, to) => {
|
||||
const entry = manageView.find((v) => v.name === g)?.entries[from]
|
||||
if (entry) move(entry.id, g, to)
|
||||
}}
|
||||
onAssign={assign}
|
||||
onUnpin={unpin}
|
||||
onRename={renameGroup}
|
||||
onRemove={removeGroup}
|
||||
/>
|
||||
))}
|
||||
|
||||
<YStack gap="$2">
|
||||
<Text fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
|
||||
New group
|
||||
</Text>
|
||||
<NewGroup onAdd={addGroup} />
|
||||
</YStack>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Always-visible identity + wallet — pinned to the bottom of the sidebar so a
|
||||
* customer's account, balance, top-up, and sign-out are one glance / one click
|
||||
* away on every page.
|
||||
*
|
||||
* Identity (avatar + display name) comes from the signed-in IAM account. Three
|
||||
* destinations, one way each:
|
||||
* - The user row → the **Profile** page (`/profile`): account, security, keys.
|
||||
* - The balance row → the in-console **Billing** module (`/billing`): balance,
|
||||
* usage, invoices — the org's own data, scoped to the active org.
|
||||
* - **Top up** → the brand billing portal (billing.hanzo.ai) — payment is never
|
||||
* rebuilt here. **Sign out** sits directly beneath it.
|
||||
*
|
||||
* The balance comes from the per-tenant `/billing/*` server proxy, scoped to the
|
||||
* caller's OWN org — the exact credit the gateway debits.
|
||||
*/
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Avatar, Button, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { ChevronRight, LogOut, Wallet } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { useCloudBalance, spendableCents } from '~/lib/billing/live-balance'
|
||||
|
||||
const fmtUsd = (cents: number): string => `$${(cents / 100).toFixed(2)}`
|
||||
|
||||
/** Up-to-two-letter initials from a display name / handle (avatar fallback). */
|
||||
function initials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean)
|
||||
if (parts.length === 0) return '?'
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
||||
}
|
||||
|
||||
/** The account avatar — IAM photo when present, initials circle otherwise. */
|
||||
function IdentityAvatar({ name, avatar, size }: { name: string; avatar?: string; size: number }) {
|
||||
return (
|
||||
<Avatar circular size={size}>
|
||||
{avatar ? <Avatar.Image accessibilityLabel={name} src={avatar} /> : null}
|
||||
<Avatar.Fallback bg="$color5" items="center" justify="center">
|
||||
<Text fontSize={size <= 28 ? '$1' : '$2'} fontWeight="800" color="$color12">
|
||||
{initials(name)}
|
||||
</Text>
|
||||
</Avatar.Fallback>
|
||||
</Avatar>
|
||||
)
|
||||
}
|
||||
|
||||
export function SidebarWallet({ collapsed }: { collapsed: boolean }) {
|
||||
const { account, signOut } = useSession()
|
||||
const router = useRouter()
|
||||
const owner = account?.owner ?? ''
|
||||
// ONE shared live balance (same value the Wallet/Cost pages show): refetches on
|
||||
// mount, on window focus/visibility, on a 30s poll, and after any completion or
|
||||
// top-up — so spend/credit changes reflect here without a reload.
|
||||
const { balance } = useCloudBalance()
|
||||
const cents = spendableCents(balance)
|
||||
|
||||
if (!owner) return null
|
||||
|
||||
const name = account?.displayName || account?.name || 'Account'
|
||||
const avatar = typeof account?.avatar === 'string' ? account.avatar : undefined
|
||||
const balanceText = cents === null ? '—' : fmtUsd(cents)
|
||||
const openProfile = () => router.push('/profile')
|
||||
const openCost = () => router.push('/billing')
|
||||
// In-console card top-up (Square) — the /billing/credits page. Replaces the old
|
||||
// window.open to the external billing.hanzo.ai portal.
|
||||
const openTopUp = () => router.push('/billing/credits')
|
||||
|
||||
if (collapsed) {
|
||||
// Three stacked affordances: identity (→ Profile), top-up, sign out.
|
||||
return (
|
||||
<YStack items="center" gap="$2">
|
||||
<YStack onPress={openProfile} cursor="pointer" hoverStyle={{ opacity: 0.85 }} aria-label={`${name} — open Profile`}>
|
||||
<IdentityAvatar name={name} avatar={avatar} size={32} />
|
||||
</YStack>
|
||||
<Button size="$2" chromeless onPress={openTopUp} icon={<Wallet size={18} />} aria-label={`Wallet ${balanceText} — top up`} />
|
||||
<Button size="$2" chromeless onPress={() => void signOut()} icon={<LogOut size={18} />} aria-label="Sign out" />
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack gap="$2" px="$2.5" py="$2.5" rounded="$3" bg="$color2" borderWidth={1} borderColor="$borderColor">
|
||||
{/* Identity → Profile */}
|
||||
<XStack items="center" gap="$2.5" onPress={openProfile} cursor="pointer" hoverStyle={{ opacity: 0.85 }} aria-label={`${name} — open Profile`}>
|
||||
<IdentityAvatar name={name} avatar={avatar} size={36} />
|
||||
<YStack flex={1} minW={0}>
|
||||
<Text fontSize="$3" fontWeight="700" color="$color12" numberOfLines={1}>{name}</Text>
|
||||
<Text fontSize="$1" color="$color10" numberOfLines={1}>{account?.email || 'View profile'}</Text>
|
||||
</YStack>
|
||||
<ChevronRight size={16} opacity={0.5} />
|
||||
</XStack>
|
||||
|
||||
{/* Balance → Cost */}
|
||||
<XStack
|
||||
items="center"
|
||||
justify="space-between"
|
||||
onPress={openCost}
|
||||
cursor="pointer"
|
||||
hoverStyle={{ opacity: 0.85 }}
|
||||
px="$1"
|
||||
aria-label={`Balance ${balanceText} — open Cost`}
|
||||
>
|
||||
<XStack items="center" gap="$1.5">
|
||||
<Wallet size={13} opacity={0.7} />
|
||||
<Text fontSize="$2" color="$color11">{balanceText}</Text>
|
||||
</XStack>
|
||||
<ChevronRight size={14} opacity={0.4} />
|
||||
</XStack>
|
||||
|
||||
<Button size="$2" onPress={openTopUp}>Top up</Button>
|
||||
<Button size="$2" chromeless icon={<LogOut size={15} />} onPress={() => void signOut()} justify="center">
|
||||
Sign out
|
||||
</Button>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
+225
-34
@@ -1,24 +1,43 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Sign-in card — adapted from the @hanzo/gui `sign-in-form` recipe.
|
||||
* Sign-in / create-account card — multi-tenant credential entry (HIP-0111).
|
||||
*
|
||||
* Authentication is delegated to Hanzo IAM (OIDC, hanzo.id). The console never
|
||||
* collects credentials or reconstructs provider OAuth URLs: every button starts
|
||||
* the IAM authorize redirect — the social buttons hint a provider, the primary
|
||||
* button opens IAM's email / passkey flow — and IAM owns the rest (provider
|
||||
* client ids, callbacks), returning to `/auth/callback`.
|
||||
* The console resolves a user's ORG from their email (not the brand's own org),
|
||||
* so a customer in any org signs in here with email + password: we POST the
|
||||
* canonical IAM login with `organization: ""` (see `lib/auth/iam-login`), get an
|
||||
* OAuth code, and complete the SAME `/v1/iam/signin` exchange the redirect flow uses.
|
||||
*
|
||||
* CREATE ACCOUNT (self-serve email signup): a net-new stranger toggles to signup,
|
||||
* which POSTs `/auth/signup` — the BFF mints an account + its own org (as admin) —
|
||||
* then this form signs them in with the same credentials via the identical login
|
||||
* path, so they land in the console as the admin of their new workspace. Social
|
||||
* signup for a brand-new user needs the IAM app's signup enabled (separate); email
|
||||
* signup is the guaranteed path.
|
||||
*
|
||||
* Social buttons start IAM's hosted provider flow (IAM owns each provider's
|
||||
* OAuth — client id, scope, callback). Accounts that require two-factor finish
|
||||
* on IAM's same-site hosted page (the IAM session cookie is `SameSite=Lax`, so a
|
||||
* cross-site fetch can't carry the MFA challenge) — we hand off with a redirect
|
||||
* rather than fake an inline step.
|
||||
*/
|
||||
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Anchor, Button, Card, Input, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Github } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { branding } from '~/config'
|
||||
import { HanzoMark } from '~/components/ui/Loader'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { getSigninUrl } from '~/lib/auth/iam'
|
||||
import { loginState, loginWithPassword } from '~/lib/auth/iam-login'
|
||||
import { signUp } from '~/lib/auth/signup'
|
||||
|
||||
/** Monochrome Google "G" — the canonical mark, filled with the current text
|
||||
* color so it stays black/white with the rest of the console chrome. */
|
||||
type Mode = 'signin' | 'signup'
|
||||
|
||||
/** Monochrome Google "G" — filled with the current text color so it tracks the
|
||||
* console's black/white chrome. */
|
||||
function GoogleMark({ size = 18 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" role="img" aria-label="Google">
|
||||
@@ -30,8 +49,7 @@ function GoogleMark({ size = 18 }: { size?: number }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function SignInForm() {
|
||||
const { signIn, signInWith } = useSession()
|
||||
function CardShell({ subtitle, children }: { subtitle: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<YStack flex={1} minH="100vh" items="center" justify="center" p="$4">
|
||||
<Card p="$5" gap="$4" width={380} borderWidth={1} borderColor="$borderColor" bg="$color1">
|
||||
@@ -41,33 +59,206 @@ export function SignInForm() {
|
||||
<Text fontSize="$7" fontWeight="800">
|
||||
{branding.name}
|
||||
</Text>
|
||||
<Text fontSize="$3" color="$color11">
|
||||
Sign in to manage your cloud.
|
||||
<Text fontSize="$3" color="$color11" text="center">
|
||||
{subtitle}
|
||||
</Text>
|
||||
</YStack>
|
||||
</YStack>
|
||||
|
||||
<YStack gap="$2.5">
|
||||
<Button size="$4" icon={<Github size={18} />} onPress={() => signInWith('provider-github')}>
|
||||
Continue with GitHub
|
||||
</Button>
|
||||
<Button size="$4" icon={<GoogleMark />} onPress={() => signInWith('provider-google')}>
|
||||
Continue with Google
|
||||
</Button>
|
||||
|
||||
<XStack items="center" gap="$3" my="$1">
|
||||
<YStack flex={1} height={1} bg="$borderColor" />
|
||||
<Text fontSize="$2" color="$color10">
|
||||
or
|
||||
</Text>
|
||||
<YStack flex={1} height={1} bg="$borderColor" />
|
||||
</XStack>
|
||||
|
||||
<PrimaryButton size="$4" onPress={signIn}>
|
||||
Continue with Hanzo ID
|
||||
</PrimaryButton>
|
||||
</YStack>
|
||||
{children}
|
||||
</Card>
|
||||
<Text fontSize="$1" color="$color9" text="center" mt="$3">
|
||||
{branding.productLine}
|
||||
</Text>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
export function SignInForm() {
|
||||
const { completeSignIn, signInWith, establishConsoleSession } = useSession()
|
||||
const router = useRouter()
|
||||
const [mode, setMode] = useState<Mode>('signin')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [mfa, setMfa] = useState(false)
|
||||
|
||||
// The ONE credential login path — shared by sign-in and by post-signup auto-login.
|
||||
async function logInWithCredentials(): Promise<void> {
|
||||
const res = await loginWithPassword(email.trim(), password)
|
||||
if (res.kind === 'code') {
|
||||
await completeSignIn(res.code, loginState())
|
||||
// Upgrade to the console's OWN durable, silently-refreshed session (server-side
|
||||
// password grant, gated by the casibase session we just established). Best-effort:
|
||||
// it never throws, and a failure leaves the user signed in on the casibase session
|
||||
// — so login is never blocked by this enhancement. MFA accounts don't reach here
|
||||
// (they hand off to the hosted flow), so this never bypasses MFA.
|
||||
await establishConsoleSession(email.trim(), password)
|
||||
router.replace('/')
|
||||
} else if (res.kind === 'mfa') {
|
||||
setMfa(true)
|
||||
} else {
|
||||
setError(res.message)
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitSignIn() {
|
||||
if (busy || !email || !password) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await logInWithCredentials()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Sign-in failed.')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitSignUp() {
|
||||
if (busy || !email || !password) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const r = await signUp(email.trim(), password)
|
||||
if (r.kind === 'exists') {
|
||||
setError('An account with this email already exists — sign in below.')
|
||||
setMode('signin')
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
if (r.kind === 'error') {
|
||||
setError(r.message)
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
// Account + org created — sign in with the same credentials so the new admin
|
||||
// lands straight in their workspace (fresh accounts have no MFA to satisfy).
|
||||
await logInWithCredentials()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Could not create your account.')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const submit = () => (mode === 'signup' ? submitSignUp() : submitSignIn())
|
||||
|
||||
function switchMode(next: Mode) {
|
||||
setMode(next)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
// Two-factor accounts finish on IAM's same-site hosted page (cross-site cookie
|
||||
// limitation). Honest hand-off — not a faked inline step.
|
||||
if (mfa) {
|
||||
return (
|
||||
<CardShell subtitle="Sign in to manage your cloud.">
|
||||
<YStack gap="$3" items="center">
|
||||
<Text fontSize="$5" fontWeight="700">
|
||||
Two-factor required
|
||||
</Text>
|
||||
<Text fontSize="$3" color="$color11" text="center">
|
||||
Your account uses two-factor authentication. Continue on the secure Hanzo ID page to
|
||||
enter your code.
|
||||
</Text>
|
||||
<PrimaryButton size="$4" width="100%" onPress={() => window.location.assign(getSigninUrl())}>
|
||||
Continue on Hanzo ID
|
||||
</PrimaryButton>
|
||||
<Button size="$3" chromeless onPress={() => { setMfa(false); setBusy(false) }}>
|
||||
Back
|
||||
</Button>
|
||||
</YStack>
|
||||
</CardShell>
|
||||
)
|
||||
}
|
||||
|
||||
const signup = mode === 'signup'
|
||||
|
||||
return (
|
||||
<CardShell subtitle={signup ? `Create your ${branding.name} account.` : 'Sign in to manage your cloud.'}>
|
||||
<YStack gap="$2.5">
|
||||
<Button size="$4" icon={<Github size={18} />} onPress={() => signInWith('provider-github')}>
|
||||
Continue with GitHub
|
||||
</Button>
|
||||
<Button size="$4" icon={<GoogleMark />} onPress={() => signInWith('provider-google')}>
|
||||
Continue with Google
|
||||
</Button>
|
||||
|
||||
<XStack items="center" gap="$3" my="$1">
|
||||
<YStack flex={1} height={1} bg="$borderColor" />
|
||||
<Text fontSize="$2" color="$color10">
|
||||
or
|
||||
</Text>
|
||||
<YStack flex={1} height={1} bg="$borderColor" />
|
||||
</XStack>
|
||||
|
||||
<Input
|
||||
size="$4"
|
||||
placeholder="Email"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
disabled={busy}
|
||||
/>
|
||||
<Input
|
||||
size="$4"
|
||||
placeholder="Password"
|
||||
// secureTextEntry alone did not mask in this @hanzo/gui build; set the
|
||||
// web input type explicitly so the password is masked (RNW passthrough).
|
||||
secureTextEntry
|
||||
{...{ type: 'password' }}
|
||||
autoComplete={signup ? 'new-password' : 'current-password'}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
disabled={busy}
|
||||
onSubmitEditing={submit}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Text fontSize="$2" color="$red10" role="alert">
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<PrimaryButton
|
||||
size="$4"
|
||||
disabled={busy || !email || !password}
|
||||
icon={busy ? <Spinner size="small" /> : undefined}
|
||||
onPress={submit}
|
||||
>
|
||||
{busy ? (signup ? 'Creating account…' : 'Signing in…') : signup ? 'Create account' : 'Sign in'}
|
||||
</PrimaryButton>
|
||||
|
||||
{signup ? (
|
||||
<Text fontSize="$2" color="$color10" text="center">
|
||||
Already have an account?{' '}
|
||||
<Anchor fontSize="$2" color="$color11" onPress={() => switchMode('signin')}>
|
||||
Sign in
|
||||
</Anchor>
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text fontSize="$2" color="$color10" text="center">
|
||||
New to {branding.name}?{' '}
|
||||
<Anchor fontSize="$2" color="$color11" onPress={() => switchMode('signup')}>
|
||||
Create an account
|
||||
</Anchor>
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color10" text="center">
|
||||
Trouble signing in?{' '}
|
||||
<Anchor
|
||||
fontSize="$2"
|
||||
color="$color11"
|
||||
onPress={() => window.location.assign(getSigninUrl())}
|
||||
>
|
||||
Use a passkey or recovery
|
||||
</Anchor>
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</YStack>
|
||||
</CardShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AgentBuilder — the CANONICAL, shareable Hanzo agent builder.
|
||||
*
|
||||
* ONE builder, one and only one way to define an agent (name · model · prompt ·
|
||||
* tools · description), used by every surface. It is self-contained and injected:
|
||||
* the host passes `AgentBuilderLoaders` (the live model catalog, the saved-prompt
|
||||
* library, a prompt-body fetch, and the create effect) — the builder owns the form,
|
||||
* the LIVE dropdowns, validation, and the honest states, but knows NOTHING about
|
||||
* any host's API client. That is what lets console2, chat, app, bot, and team all
|
||||
* import this exact component over the SAME backend (`POST /cloud/v1/agents`, org
|
||||
* resolved server-side from the caller's bearer) instead of each rebuilding a form.
|
||||
*
|
||||
* Dynamic by construction:
|
||||
* - Model → a ComboBox: type any id OR pick from the LIVE `/v1/models` catalog.
|
||||
* - Prompt → a selector of the org's saved prompts (fills the system prompt), with
|
||||
* a "Custom" option for free text — "the system prompt is selectable
|
||||
* from saved prompts OR typed".
|
||||
* - Tools → a ComboBox with the live tool catalog, added as chips (typeable too).
|
||||
* - Advanced → the hanzo.chat power-user generation config (temperature · top-p ·
|
||||
* top-k · reasoning effort · use-tools · web-search · thinking · stream),
|
||||
* folded into the ONE builder. Hidden by default; any knob left at its
|
||||
* default is pruned, so opening it never changes a simple agent's body.
|
||||
* Every option set is REAL (from a loader) or the field degrades to typeable — never
|
||||
* a fabricated model/prompt/tool. This is the SUPERSET builder: console2's decoupled
|
||||
* injected-loader seam + hanzo.chat's advanced config — one component, both surfaces.
|
||||
*
|
||||
* Deps: only `@hanzo/gui`, the shared `ComboBox`/`Field` UI primitives, and this
|
||||
* module's own `./types`/`./logic`. No `~/lib/api` — so it lifts cleanly into
|
||||
* `@hanzo/agent-builder`.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Bot, Plus, Terminal, X } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { ComboBox, type ComboOption } from '~/components/ui/ComboBox'
|
||||
import { FieldRow, FieldSelect, FieldSlider, FieldSwitch, FieldText, FieldTextArea } from '~/components/ui/Field'
|
||||
import { SelectMenu, type SelectOption } from '~/components/ui/SelectMenu'
|
||||
import {
|
||||
canSubmit,
|
||||
classifyBuilderError,
|
||||
defaultConfig,
|
||||
defaultModel,
|
||||
emptySpec,
|
||||
promptBodyFromRow,
|
||||
promptOptions,
|
||||
toCreateBody,
|
||||
} from './logic'
|
||||
import type { AgentBuilderLoaders, AgentConfig, AgentSpec, BuilderOption, BuilderPrompt, ReasoningEffort } from './types'
|
||||
|
||||
/** Async option-list state for the live pickers (model/tool). */
|
||||
type OptState =
|
||||
| { phase: 'idle' }
|
||||
| { phase: 'loading' }
|
||||
| { phase: 'error'; message: string }
|
||||
| { phase: 'ready'; options: BuilderOption[] }
|
||||
|
||||
const CUSTOM = '__custom__'
|
||||
|
||||
export function AgentBuilder({
|
||||
loaders,
|
||||
onCreated,
|
||||
onCancel,
|
||||
submitLabel = 'Create agent',
|
||||
}: {
|
||||
loaders: AgentBuilderLoaders
|
||||
/** 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)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [unavailable, setUnavailable] = useState(false)
|
||||
|
||||
const [models, setModels] = useState<OptState>({ phase: 'idle' })
|
||||
const [tools, setTools] = useState<OptState>({ phase: 'idle' })
|
||||
const [prompts, setPrompts] = useState<BuilderPrompt[] | null>(null)
|
||||
const [promptPick, setPromptPick] = useState<string | null>(null)
|
||||
|
||||
const set = <K extends keyof AgentSpec>(k: K, v: AgentSpec[K]) => setSpec((s) => ({ ...s, [k]: v }))
|
||||
|
||||
// Advanced generation config — bound lazily to `spec.config ?? defaultConfig()`;
|
||||
// a knob left at its default is pruned by `toCreateBody`, so a simple agent still
|
||||
// posts no `config`. Functional update = no stale-closure read.
|
||||
const [advanced, setAdvanced] = useState(false)
|
||||
const cfg = spec.config ?? defaultConfig()
|
||||
const setCfg = <K extends keyof AgentConfig>(k: K, v: AgentConfig[K]) =>
|
||||
setSpec((s) => ({ ...s, config: { ...(s.config ?? defaultConfig()), [k]: v } }))
|
||||
|
||||
// Preselect a Zen default ONLY if the user hasn't typed a model yet — refs so the
|
||||
// loader doesn't re-run on every keystroke.
|
||||
const specRef = useRef(spec)
|
||||
specRef.current = spec
|
||||
|
||||
const loadModels = useCallback(() => {
|
||||
if (!loaders.loadModels) return
|
||||
setModels({ phase: 'loading' })
|
||||
loaders
|
||||
.loadModels()
|
||||
.then((options) => {
|
||||
setModels({ phase: 'ready', options })
|
||||
if (!specRef.current.model) {
|
||||
const d = defaultModel(options)
|
||||
if (d) set('model', d)
|
||||
}
|
||||
})
|
||||
.catch((e) => setModels({ phase: 'error', message: msg(e) }))
|
||||
}, [loaders])
|
||||
|
||||
const loadTools = useCallback(() => {
|
||||
if (!loaders.loadTools) return
|
||||
setTools({ phase: 'loading' })
|
||||
loaders
|
||||
.loadTools()
|
||||
.then((options) => setTools({ phase: 'ready', options }))
|
||||
.catch((e) => setTools({ phase: 'error', message: msg(e) }))
|
||||
}, [loaders])
|
||||
|
||||
useEffect(() => {
|
||||
loadModels()
|
||||
loadTools()
|
||||
if (loaders.loadPrompts) {
|
||||
loaders
|
||||
.loadPrompts()
|
||||
.then((rows) => setPrompts(rows))
|
||||
.catch(() => setPrompts([])) // prompts optional — hide the selector on failure
|
||||
} else {
|
||||
setPrompts([])
|
||||
}
|
||||
}, [loadModels, loadTools, loaders])
|
||||
|
||||
// ── Prompt selector: pick a saved prompt → fill systemPrompt (Custom = free) ──
|
||||
const onPickPrompt = async (name: string | null) => {
|
||||
setPromptPick(name)
|
||||
if (!name || name === CUSTOM) return // Custom → leave the textarea as the user's own
|
||||
const inline = promptBodyFromRow(prompts ?? [], name)
|
||||
if (inline != null) {
|
||||
set('systemPrompt', inline)
|
||||
return
|
||||
}
|
||||
if (loaders.loadPromptBody) {
|
||||
try {
|
||||
const body = await loaders.loadPromptBody(name)
|
||||
set('systemPrompt', body)
|
||||
} catch {
|
||||
// couldn't fetch the body — keep whatever's typed; the pick still records intent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tools as chips ────────────────────────────────────────────────────────
|
||||
const [toolDraft, setToolDraft] = useState('')
|
||||
const addTool = (v: string) => {
|
||||
const t = v.trim()
|
||||
if (t && !spec.tools.includes(t)) set('tools', [...spec.tools, t])
|
||||
setToolDraft('')
|
||||
}
|
||||
const removeTool = (t: string) => set('tools', spec.tools.filter((x) => x !== t))
|
||||
|
||||
const submit = async () => {
|
||||
if (!canSubmit(spec) || busy) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
setUnavailable(false)
|
||||
try {
|
||||
await loaders.createAgent(toCreateBody(spec))
|
||||
onCreated()
|
||||
} catch (e) {
|
||||
const c = classifyBuilderError(e)
|
||||
if (c.kind === 'unavailable') setUnavailable(true)
|
||||
else setError(c.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const modelOptions: ComboOption[] = models.phase === 'ready' ? models.options : []
|
||||
const toolOptions: ComboOption[] = tools.phase === 'ready' ? tools.options : []
|
||||
const promptSelectOptions: SelectOption<string>[] = [
|
||||
{ key: CUSTOM, label: 'Custom (type your own)' },
|
||||
...promptOptions(prompts ?? []).map((o) => ({ key: o.value, label: o.label ?? o.value })),
|
||||
]
|
||||
|
||||
return (
|
||||
<YStack gap="$3">
|
||||
<Text fontSize="$2" color="$color11">
|
||||
An agent is a model, a system prompt, and a set of tools that runs on Hanzo compute and calls your APIs on
|
||||
its own.
|
||||
</Text>
|
||||
|
||||
<FieldRow label="Name">
|
||||
<FieldText value={spec.name} onChange={(v) => set('name', v)} placeholder="support-triage" />
|
||||
</FieldRow>
|
||||
|
||||
<FieldRow label="Model">
|
||||
<ComboBox
|
||||
value={spec.model}
|
||||
onChange={(v) => set('model', v)}
|
||||
options={modelOptions}
|
||||
loading={models.phase === 'loading'}
|
||||
error={models.phase === 'error' ? `Model catalog unavailable — type a model id. (${models.message})` : null}
|
||||
onRetry={loadModels}
|
||||
placeholder="zen-omni · gpt-4o-mini · claude-sonnet-4-5"
|
||||
/>
|
||||
</FieldRow>
|
||||
|
||||
{/* Prompt selector — only when a prompt library is wired + has entries. */}
|
||||
{prompts && prompts.length > 0 ? (
|
||||
<FieldRow label="Load prompt">
|
||||
<SelectMenu
|
||||
options={promptSelectOptions}
|
||||
value={promptPick}
|
||||
onChange={(v) => void onPickPrompt(v)}
|
||||
allLabel="Custom (type your own)"
|
||||
minWidth={240}
|
||||
/>
|
||||
</FieldRow>
|
||||
) : null}
|
||||
|
||||
<FieldRow label="System prompt">
|
||||
<FieldTextArea
|
||||
value={spec.systemPrompt}
|
||||
onChange={(v) => {
|
||||
set('systemPrompt', v)
|
||||
if (promptPick && promptPick !== CUSTOM) setPromptPick(CUSTOM) // editing => now custom
|
||||
}}
|
||||
rows={6}
|
||||
/>
|
||||
</FieldRow>
|
||||
|
||||
<FieldRow label="Tools">
|
||||
<YStack gap="$2">
|
||||
<ComboBox
|
||||
value={toolDraft}
|
||||
onChange={setToolDraft}
|
||||
options={toolOptions}
|
||||
loading={tools.phase === 'loading'}
|
||||
error={tools.phase === 'error' ? `Tool catalog unavailable — type a tool id.` : null}
|
||||
onRetry={loadTools}
|
||||
placeholder="add a tool — e.g. web.search, code.exec"
|
||||
emptyText="Press Add to include what you typed."
|
||||
/>
|
||||
<XStack gap="$2">
|
||||
<Button size="$2" chromeless icon={<Plus size={14} />} onPress={() => addTool(toolDraft)} disabled={!toolDraft.trim()}>
|
||||
Add tool
|
||||
</Button>
|
||||
</XStack>
|
||||
{spec.tools.length > 0 ? (
|
||||
<XStack gap="$1.5" flexWrap="wrap">
|
||||
{spec.tools.map((t) => (
|
||||
<XStack key={t} items="center" gap="$1" px="$2" py="$1" rounded="$3" bg="$color3" borderWidth={1} borderColor="$borderColor">
|
||||
<Text fontSize="$1" color="$color12">
|
||||
{t}
|
||||
</Text>
|
||||
<Button size="$1" chromeless icon={<X size={11} />} onPress={() => removeTool(t)} aria-label={`Remove ${t}`} />
|
||||
</XStack>
|
||||
))}
|
||||
</XStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
</FieldRow>
|
||||
|
||||
<FieldRow label="Description">
|
||||
<FieldText value={spec.description} onChange={(v) => set('description', v)} placeholder="What this agent does" />
|
||||
</FieldRow>
|
||||
|
||||
{/* Advanced generation config — the hanzo.chat power-user knobs, folded into
|
||||
the ONE builder. Hidden by default; every value that stays at its default
|
||||
is pruned, so opening this never changes what a simple agent posts. */}
|
||||
<YStack gap="$2">
|
||||
<Button size="$2" chromeless self="flex-start" onPress={() => setAdvanced((a) => !a)}>
|
||||
{advanced ? 'Hide advanced settings' : 'Advanced settings'}
|
||||
</Button>
|
||||
{advanced ? (
|
||||
<YStack gap="$3" p="$3" rounded="$4" bg="$color2" borderWidth={1} borderColor="$borderColor">
|
||||
<FieldRow label="Temperature">
|
||||
<FieldSlider value={cfg.temperature} min={0} max={2} step={0.1} onChange={(v) => setCfg('temperature', v)} />
|
||||
</FieldRow>
|
||||
<FieldRow label="Top-p">
|
||||
<FieldSlider value={cfg.topP} min={0} max={1} step={0.05} onChange={(v) => setCfg('topP', v)} />
|
||||
</FieldRow>
|
||||
<FieldRow label="Top-k">
|
||||
<FieldSlider value={cfg.topK} min={0} max={100} step={1} onChange={(v) => setCfg('topK', v)} />
|
||||
</FieldRow>
|
||||
<FieldRow label="Reasoning effort">
|
||||
<FieldSelect
|
||||
value={cfg.reasoningEffort ?? 'default'}
|
||||
options={['default', 'low', 'medium', 'high', 'xhigh', 'max', 'ultracode']}
|
||||
onChange={(v) => setCfg('reasoningEffort', v === 'default' ? undefined : (v as ReasoningEffort))}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label="Use tools">
|
||||
<FieldSwitch checked={cfg.useTools} onChange={(v) => setCfg('useTools', v)} />
|
||||
</FieldRow>
|
||||
<FieldRow label="Web search">
|
||||
<FieldSwitch checked={cfg.webSearch} onChange={(v) => setCfg('webSearch', v)} />
|
||||
</FieldRow>
|
||||
<FieldRow label="Thinking">
|
||||
<FieldSwitch checked={cfg.thinking} onChange={(v) => setCfg('thinking', v)} />
|
||||
</FieldRow>
|
||||
<FieldRow label="Stream">
|
||||
<FieldSwitch checked={cfg.stream} onChange={(v) => setCfg('stream', v)} />
|
||||
</FieldRow>
|
||||
</YStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
|
||||
{unavailable ? (
|
||||
<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">
|
||||
Agents API isn’t connected on this deployment yet
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$2" color="$color11">
|
||||
Your definition wasn’t saved (the `/v1/agents` route isn’t bound here yet). Create with the CLI —{' '}
|
||||
<Text fontSize="$1" bg="$color3" px="$1" py="$0.5" rounded="$2">hanzo agents create</Text> — and it appears here once the backend is live.
|
||||
</Text>
|
||||
</Card>
|
||||
) : null}
|
||||
{error ? (
|
||||
<Text fontSize="$2" color="$red10">
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<XStack gap="$2" pt="$1">
|
||||
{onCancel ? (
|
||||
<Button flex={1} chromeless onPress={onCancel} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
flex={1}
|
||||
theme="light"
|
||||
icon={busy ? undefined : <Bot size={15} />}
|
||||
onPress={() => void submit()}
|
||||
disabled={!canSubmit(spec) || busy}
|
||||
>
|
||||
{busy ? <Spinner size="small" /> : submitLabel}
|
||||
</Button>
|
||||
</XStack>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
const msg = (e: unknown): string =>
|
||||
e && typeof e === 'object' && typeof (e as { message?: unknown }).message === 'string'
|
||||
? (e as { message: string }).message
|
||||
: 'unavailable'
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Canonical Hanzo agent builder — the ONE agent builder, shareable across every
|
||||
* surface (console, chat, app, bot, team).
|
||||
*
|
||||
* A host imports `AgentBuilder` and supplies `AgentBuilderLoaders` (its own live
|
||||
* `/v1` sources — model catalog, saved prompts, create) over the SAME agent backend
|
||||
* (`POST /v1/agents`, org resolved server-side). The component, its schema
|
||||
* (`AgentSpec`), and all pure logic live here with NO host coupling, so this module
|
||||
* lifts cleanly into a published `@hanzo/agent-builder` package.
|
||||
*/
|
||||
export { AgentBuilder } from './AgentBuilder'
|
||||
export type {
|
||||
AgentSpec,
|
||||
AgentConfig,
|
||||
AgentCreateBody,
|
||||
ReasoningEffort,
|
||||
AgentBuilderLoaders,
|
||||
BuilderOption,
|
||||
BuilderPrompt,
|
||||
BuilderError,
|
||||
BuilderErrorKind,
|
||||
} from './types'
|
||||
export {
|
||||
emptySpec,
|
||||
defaultConfig,
|
||||
defaultModel,
|
||||
canSubmit,
|
||||
normalizeList,
|
||||
normalizeTools,
|
||||
normalizeKnowledge,
|
||||
clampConfig,
|
||||
pruneConfig,
|
||||
toCreateBody,
|
||||
promptBodyFromRow,
|
||||
promptOptions,
|
||||
classifyBuilderError,
|
||||
} from './logic'
|
||||
@@ -0,0 +1,191 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import {
|
||||
emptySpec,
|
||||
defaultConfig,
|
||||
defaultModel,
|
||||
canSubmit,
|
||||
normalizeList,
|
||||
normalizeTools,
|
||||
normalizeKnowledge,
|
||||
clampConfig,
|
||||
pruneConfig,
|
||||
toCreateBody,
|
||||
promptBodyFromRow,
|
||||
promptOptions,
|
||||
classifyBuilderError,
|
||||
} from './logic'
|
||||
import type { AgentConfig, AgentSpec, BuilderOption, BuilderPrompt } from './types'
|
||||
|
||||
const spec = (over: Partial<AgentSpec> = {}): AgentSpec => ({ ...emptySpec(), ...over })
|
||||
|
||||
describe('emptySpec', () => {
|
||||
it('is a clean, empty spec', () => {
|
||||
expect(emptySpec()).toEqual({ name: '', model: '', description: '', systemPrompt: '', tools: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('defaultModel', () => {
|
||||
const opt = (value: string, hint?: string): BuilderOption => ({ value, hint })
|
||||
|
||||
it('returns "" for an empty catalog (nothing to default to)', () => {
|
||||
expect(defaultModel([])).toBe('')
|
||||
})
|
||||
|
||||
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 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', () => {
|
||||
expect(defaultModel([opt('gpt-4o'), opt('claude')])).toBe('gpt-4o')
|
||||
})
|
||||
|
||||
it('never invents an id outside the catalog', () => {
|
||||
const d = defaultModel([opt('only-model')])
|
||||
expect(['only-model']).toContain(d)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canSubmit', () => {
|
||||
it('requires a non-empty trimmed name', () => {
|
||||
expect(canSubmit(spec({ name: '' }))).toBe(false)
|
||||
expect(canSubmit(spec({ name: ' ' }))).toBe(false)
|
||||
expect(canSubmit(spec({ name: 'triage' }))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeList / normalizeTools / normalizeKnowledge', () => {
|
||||
it('trims, drops blanks, and de-duplicates preserving first-seen order', () => {
|
||||
expect(normalizeList([' a ', 'b', 'a', '', ' '])).toEqual(['a', 'b'])
|
||||
})
|
||||
it('normalizeTools + normalizeKnowledge share the one behavior (DRY)', () => {
|
||||
const raw = [' web.search ', 'code.exec', 'web.search', '', ' ']
|
||||
expect(normalizeTools(raw)).toEqual(['web.search', 'code.exec'])
|
||||
expect(normalizeKnowledge([' kb:1 ', 'kb:2', 'kb:1'])).toEqual(['kb:1', 'kb:2'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('defaultConfig', () => {
|
||||
it('is the documented generation default', () => {
|
||||
expect(defaultConfig()).toEqual({
|
||||
temperature: 0.7,
|
||||
topP: 1,
|
||||
topK: 0,
|
||||
stream: true,
|
||||
thinking: false,
|
||||
useTools: true,
|
||||
webSearch: false,
|
||||
})
|
||||
})
|
||||
it('is a fresh object each call (no shared mutable state)', () => {
|
||||
const a = defaultConfig()
|
||||
a.temperature = 2
|
||||
expect(defaultConfig().temperature).toBe(0.7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clampConfig', () => {
|
||||
const base = defaultConfig()
|
||||
it('clamps temperature into [0,2]', () => {
|
||||
expect(clampConfig({ ...base, temperature: 9 }).temperature).toBe(2)
|
||||
expect(clampConfig({ ...base, temperature: -1 }).temperature).toBe(0)
|
||||
})
|
||||
it('clamps topP into [0,1] and topK to a non-negative integer', () => {
|
||||
expect(clampConfig({ ...base, topP: 5 }).topP).toBe(1)
|
||||
expect(clampConfig({ ...base, topP: -0.5 }).topP).toBe(0)
|
||||
expect(clampConfig({ ...base, topK: -3 }).topK).toBe(0)
|
||||
expect(clampConfig({ ...base, topK: 7.9 }).topK).toBe(7)
|
||||
})
|
||||
it('maps NaN → min but clamps ±∞ to the nearest bound', () => {
|
||||
expect(clampConfig({ ...base, temperature: NaN }).temperature).toBe(0) // garbage → min
|
||||
expect(clampConfig({ ...base, topP: Infinity }).topP).toBe(1) // slider-to-top → max
|
||||
expect(clampConfig({ ...base, temperature: -Infinity }).temperature).toBe(0) // → min bound
|
||||
})
|
||||
})
|
||||
|
||||
describe('pruneConfig', () => {
|
||||
it('returns undefined when every knob is at its default (simple agent posts no config)', () => {
|
||||
expect(pruneConfig(defaultConfig())).toBeUndefined()
|
||||
})
|
||||
it('returns ONLY the knobs that differ from default', () => {
|
||||
expect(pruneConfig({ ...defaultConfig(), temperature: 0.2, thinking: true })).toEqual({
|
||||
temperature: 0.2,
|
||||
thinking: true,
|
||||
})
|
||||
})
|
||||
it('clamps before diffing (an out-of-range value that clamps to default prunes away)', () => {
|
||||
// topP default is 1; a value of 5 clamps to 1 → same as default → pruned out.
|
||||
expect(pruneConfig({ ...defaultConfig(), topP: 5 })).toBeUndefined()
|
||||
})
|
||||
it('carries reasoningEffort across the full ladder when set (it has no default)', () => {
|
||||
expect(pruneConfig({ ...defaultConfig(), reasoningEffort: 'high' })).toEqual({ reasoningEffort: 'high' })
|
||||
// The top tier — "xhigh + workflows" — survives pruning like any other level.
|
||||
expect(pruneConfig({ ...defaultConfig(), reasoningEffort: 'ultracode' })).toEqual({ reasoningEffort: 'ultracode' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('toCreateBody', () => {
|
||||
it('trims name and omits empty optional fields', () => {
|
||||
expect(toCreateBody(spec({ name: ' triage ' }))).toEqual({ name: 'triage' })
|
||||
})
|
||||
|
||||
it('includes only the non-empty fields, with normalized tools', () => {
|
||||
const body = toCreateBody(
|
||||
spec({ name: 'triage', model: ' zen-omni ', description: '', systemPrompt: ' be terse ', tools: ['a', 'a', ' '] }),
|
||||
)
|
||||
expect(body).toEqual({ name: 'triage', model: 'zen-omni', systemPrompt: 'be terse', tools: ['a'] })
|
||||
expect(body).not.toHaveProperty('description')
|
||||
})
|
||||
|
||||
it('omits an empty tools array (no key the backend didn’t need)', () => {
|
||||
expect(toCreateBody(spec({ name: 'x', tools: [] }))).not.toHaveProperty('tools')
|
||||
})
|
||||
})
|
||||
|
||||
describe('promptBodyFromRow', () => {
|
||||
const rows: BuilderPrompt[] = [
|
||||
{ name: 'with-body', body: 'you are helpful' },
|
||||
{ name: 'no-body' },
|
||||
]
|
||||
it('returns the inline body when present', () => {
|
||||
expect(promptBodyFromRow(rows, 'with-body')).toBe('you are helpful')
|
||||
})
|
||||
it('returns null when the row has no body (caller must fetch it)', () => {
|
||||
expect(promptBodyFromRow(rows, 'no-body')).toBeNull()
|
||||
})
|
||||
it('returns null for an unknown name', () => {
|
||||
expect(promptBodyFromRow(rows, 'missing')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('promptOptions', () => {
|
||||
it('maps prompts to picker rows (value=name, label defaults to name)', () => {
|
||||
expect(promptOptions([{ name: 'triage' }, { name: 'router', label: 'Router', hint: 'chat' }])).toEqual([
|
||||
{ value: 'triage', label: 'triage', hint: undefined },
|
||||
{ value: 'router', label: 'Router', hint: 'chat' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyBuilderError', () => {
|
||||
it('classifies a 404 / 501 as "unavailable" (route not bound)', () => {
|
||||
expect(classifyBuilderError({ status: 404 }).kind).toBe('unavailable')
|
||||
expect(classifyBuilderError({ status: 501 }).kind).toBe('unavailable')
|
||||
})
|
||||
it('classifies an explicit "unavailable" BackendState kind as unavailable', () => {
|
||||
expect(classifyBuilderError({ kind: 'unavailable', message: 'x' }).kind).toBe('unavailable')
|
||||
})
|
||||
it('classifies other errors as real errors and surfaces the message', () => {
|
||||
const c = classifyBuilderError({ status: 400, message: 'name is required' })
|
||||
expect(c.kind).toBe('error')
|
||||
expect(c.message).toBe('name is required')
|
||||
})
|
||||
it('has a safe default message for an opaque error', () => {
|
||||
expect(classifyBuilderError('boom')).toEqual({ kind: 'error', message: 'Could not create the agent.' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Agent-builder pure logic — the decisions the form makes, with no React and no
|
||||
* network, so they are unit-tested in isolation (the UI just renders these).
|
||||
*
|
||||
* The create body is trimmed + pruned here: empty optional fields are dropped so
|
||||
* the backend stores clean values, and only a non-empty `name` makes a spec valid.
|
||||
* A saved-prompt pick fills the system prompt; the tools list is de-duplicated and
|
||||
* blank-stripped. Everything here is a value transform — no side effects.
|
||||
*/
|
||||
import type { AgentConfig, AgentCreateBody, AgentSpec, BuilderError, BuilderOption, BuilderPrompt } from './types'
|
||||
|
||||
/** A fresh, empty spec (the New-Agent form's initial state). PURE. */
|
||||
export function emptySpec(): AgentSpec {
|
||||
return { name: '', model: '', description: '', systemPrompt: '', tools: [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* The default generation config — the value the advanced controls bind to until
|
||||
* the user changes something. Kept in ONE place so `pruneConfig` can drop knobs
|
||||
* still at their default (a simple agent posts no `config`). PURE.
|
||||
*/
|
||||
export function defaultConfig(): AgentConfig {
|
||||
return { temperature: 0.7, topP: 1, topK: 0, stream: true, thinking: false, useTools: true, webSearch: false }
|
||||
}
|
||||
|
||||
/** 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 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 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). */
|
||||
export function canSubmit(spec: AgentSpec): boolean {
|
||||
return spec.name.trim().length > 0
|
||||
}
|
||||
|
||||
/** De-duplicate + blank-strip a string list, preserving first-seen order. PURE. */
|
||||
export function normalizeList(items: string[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
for (const raw of items) {
|
||||
const t = raw.trim()
|
||||
if (t && !seen.has(t)) {
|
||||
seen.add(t)
|
||||
out.push(t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** De-duplicate + blank-strip a tool list, preserving first-seen order. PURE. */
|
||||
export function normalizeTools(tools: string[]): string[] {
|
||||
return normalizeList(tools)
|
||||
}
|
||||
|
||||
/** De-duplicate + blank-strip a knowledge-source list. PURE. */
|
||||
export function normalizeKnowledge(sources: string[]): string[] {
|
||||
return normalizeList(sources)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp a number into [min,max]. NaN (garbage) → min; ±∞ clamp to the nearest
|
||||
* bound naturally (dragging a slider to the top lands on max, not min). PURE.
|
||||
*/
|
||||
function clampNum(v: number, min: number, max: number): number {
|
||||
if (Number.isNaN(v)) return min
|
||||
return Math.min(max, Math.max(min, v))
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp a config into valid ranges (temperature 0–2, topP 0–1, topK a
|
||||
* non-negative integer) so a bad slider/typed value can never post an
|
||||
* out-of-range generation param. PURE.
|
||||
*/
|
||||
export function clampConfig(c: AgentConfig): AgentConfig {
|
||||
return {
|
||||
...c,
|
||||
temperature: clampNum(c.temperature, 0, 2),
|
||||
topP: clampNum(c.topP, 0, 1),
|
||||
topK: Math.max(0, Math.floor(Number.isFinite(c.topK) ? c.topK : 0)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce a config to only the knobs that DIFFER from the default — so a simple
|
||||
* agent (all defaults) yields `undefined` (no `config` key posted) while a
|
||||
* power-user agent posts exactly what they changed. Clamps first. PURE.
|
||||
*/
|
||||
export function pruneConfig(c: AgentConfig): Partial<AgentConfig> | undefined {
|
||||
const def = defaultConfig()
|
||||
const cur = clampConfig(c)
|
||||
const out: Partial<AgentConfig> = {}
|
||||
;(Object.keys(def) as (keyof AgentConfig)[]).forEach((k) => {
|
||||
if (cur[k] !== def[k]) (out as Record<string, unknown>)[k] = cur[k]
|
||||
})
|
||||
if (cur.reasoningEffort) out.reasoningEffort = cur.reasoningEffort
|
||||
return Object.keys(out).length ? out : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The clean create body for `POST /cloud/v1/agents`: name is trimmed (required);
|
||||
* every other field is trimmed and OMITTED when empty, and tools are normalized —
|
||||
* so the backend never stores a blank model/description/prompt or a `[]` tools
|
||||
* key it didn't need. PURE.
|
||||
*/
|
||||
export function toCreateBody(spec: AgentSpec): AgentCreateBody {
|
||||
const name = spec.name.trim()
|
||||
const model = spec.model.trim()
|
||||
const description = spec.description.trim()
|
||||
const systemPrompt = spec.systemPrompt.trim()
|
||||
const tools = normalizeTools(spec.tools)
|
||||
const knowledge = normalizeKnowledge(spec.knowledge ?? [])
|
||||
const config = spec.config ? pruneConfig(spec.config) : undefined
|
||||
return {
|
||||
name,
|
||||
...(model ? { model } : {}),
|
||||
...(description ? { description } : {}),
|
||||
...(systemPrompt ? { systemPrompt } : {}),
|
||||
...(tools.length ? { tools } : {}),
|
||||
...(knowledge.length ? { knowledge } : {}),
|
||||
...(config ? { config } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the body a saved-prompt pick should put into `systemPrompt`: the row's
|
||||
* own `body` when present, else null (the caller must `loadPromptBody(name)`).
|
||||
* PURE — no fetch here.
|
||||
*/
|
||||
export function promptBodyFromRow(prompts: BuilderPrompt[], name: string): string | null {
|
||||
const row = prompts.find((p) => p.name === name)
|
||||
return row?.body != null ? row.body : null
|
||||
}
|
||||
|
||||
/** Map saved prompts to picker rows (label = name, hint = any provided hint). PURE. */
|
||||
export function promptOptions(prompts: BuilderPrompt[]): BuilderOption[] {
|
||||
return prompts.map((p) => ({ value: p.name, label: p.label ?? p.name, hint: p.hint }))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* connected, use the CLI" state, NOT a scary error. Anything else is a real error
|
||||
* whose message is surfaced. PURE (reads status/kind/message off the error object).
|
||||
*/
|
||||
export function classifyBuilderError(e: unknown): BuilderError {
|
||||
const status = statusOf(e)
|
||||
const kind = kindOf(e)
|
||||
if (status === 404 || status === 501 || kind === 'unavailable') {
|
||||
return { kind: 'unavailable', message: 'The agents API is not connected on this deployment yet.' }
|
||||
}
|
||||
return { kind: 'error', message: messageOf(e) }
|
||||
}
|
||||
|
||||
// ── error-shape readers (defensive; the builder is client-agnostic) ──────────
|
||||
|
||||
function statusOf(e: unknown): number | undefined {
|
||||
if (e && typeof e === 'object' && 'status' in e) {
|
||||
const s = (e as { status?: unknown }).status
|
||||
if (typeof s === 'number') return s
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function kindOf(e: unknown): string | undefined {
|
||||
if (e && typeof e === 'object' && 'kind' in e) {
|
||||
const k = (e as { kind?: unknown }).kind
|
||||
if (typeof k === 'string') return k
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function messageOf(e: unknown): string {
|
||||
if (e && typeof e === 'object') {
|
||||
const m = (e as { message?: unknown }).message
|
||||
if (typeof m === 'string' && m) return m
|
||||
}
|
||||
return 'Could not create the agent.'
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Canonical agent-builder contract — the ONE way to describe and build a Hanzo
|
||||
* agent, shared across every surface (console, chat, app, bot, team).
|
||||
*
|
||||
* This file has NO console2 coupling on purpose: it imports nothing from `~/lib`
|
||||
* or any host app. The builder takes its data + effects as INJECTED async
|
||||
* loaders (`AgentBuilderLoaders`) — the model catalog, the saved-prompt library,
|
||||
* a prompt body, and the create effect — so a surface wires it to whatever client
|
||||
* it already has, as long as that client speaks the ONE agent backend
|
||||
* (`POST /cloud/v1/agents`, org resolved server-side from the caller's bearer).
|
||||
* That is what makes the builder truly shareable (extractable to
|
||||
* `@hanzo/agent-builder`) rather than a console2 one-off.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Reasoning budget for models that expose one (maps to `reasoning_effort`). The
|
||||
* canonical Hanzo/Claude effort ladder, faster→smarter:
|
||||
*
|
||||
* low · medium · high · xhigh · max · ultracode
|
||||
*
|
||||
* `ultracode` is the top tier — "xhigh + workflows" — and may use excessive tokens
|
||||
* (long responses / overthinking); use it sparingly for the hardest tasks. The
|
||||
* gateway maps each level onto the target model's native effort/thinking budget.
|
||||
* (Supersedes the old 3-level low/medium/high.)
|
||||
*/
|
||||
export type ReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultracode'
|
||||
|
||||
/**
|
||||
* The agent's generation config — the advanced knobs the hanzo.chat builder
|
||||
* exposes, folded into the canonical contract so ONE builder covers both the
|
||||
* simple (name·model·prompt·tools) and the power-user surface. Every field has a
|
||||
* sane default (`defaultConfig`); `toCreateBody` prunes anything left at default,
|
||||
* so a simple agent still posts a clean `{name,…}` with no `config` key.
|
||||
*
|
||||
* Field names mirror the cloud `/v1/agents` body (camelCase, like `systemPrompt`);
|
||||
* they correspond 1:1 to hanzo.chat's `temperature/top_p/top_k/stream/thinking/
|
||||
* use_tools/web_search_enabled/reasoning_effort`.
|
||||
*/
|
||||
export type AgentConfig = {
|
||||
/** Sampling temperature, 0–2 (clamped). Higher = more random. */
|
||||
temperature: number
|
||||
/** Nucleus sampling, 0–1 (clamped). */
|
||||
topP: number
|
||||
/** Top-k sampling; 0 = disabled. Non-negative integer (clamped). */
|
||||
topK: number
|
||||
/** Stream tokens as they generate. */
|
||||
stream: boolean
|
||||
/** Expose the model's thinking/reasoning trace. */
|
||||
thinking: boolean
|
||||
/** Let the agent call its tools during a turn. */
|
||||
useTools: boolean
|
||||
/** Enable the built-in web-search tool. */
|
||||
webSearch: boolean
|
||||
/** Reasoning budget, when the model supports it (omitted = model default). */
|
||||
reasoningEffort?: ReasoningEffort
|
||||
}
|
||||
|
||||
/** The editable shape of an agent under construction (the form's state). */
|
||||
export type AgentSpec = {
|
||||
/** Org-unique handle (also the URL segment). Required. */
|
||||
name: string
|
||||
/** Backing model id (a live catalog id or a custom one). */
|
||||
model: string
|
||||
/** One-line description of what the agent does. */
|
||||
description: string
|
||||
/** The system prompt — typed free-form OR loaded from a saved prompt. */
|
||||
systemPrompt: string
|
||||
/** Tool ids the agent may call (live catalog and/or custom). */
|
||||
tools: string[]
|
||||
/**
|
||||
* Knowledge sources (vector-store / file ids) the agent retrieves from.
|
||||
* Optional — absent until the user adds one; normalized + omitted when empty.
|
||||
*/
|
||||
knowledge?: string[]
|
||||
/**
|
||||
* Advanced generation config. Optional — the builder binds it lazily
|
||||
* (`spec.config ?? defaultConfig()`), and `toCreateBody` includes only the
|
||||
* non-default knobs, so a simple agent never carries a `config` key.
|
||||
*/
|
||||
config?: AgentConfig
|
||||
}
|
||||
|
||||
/** One option in a live picker (model / prompt / tool). Mirrors the ComboBox option. */
|
||||
export type BuilderOption = {
|
||||
/** The value committed to the spec when chosen. */
|
||||
value: string
|
||||
/** Display label (defaults to `value`). */
|
||||
label?: string
|
||||
/** Optional secondary text (provider, description) — shown + searched. */
|
||||
hint?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A saved prompt as the builder needs it: a name to pick by, and — when the list
|
||||
* carries it — the body to fill the system prompt. When `body` is absent the
|
||||
* builder fetches it lazily via `loadPromptBody(name)`.
|
||||
*/
|
||||
export type BuilderPrompt = {
|
||||
name: string
|
||||
/** The prompt body, if the list row already carries it (else fetched on select). */
|
||||
body?: string
|
||||
/** Optional label (defaults to name) + hint (labels/type). */
|
||||
label?: string
|
||||
hint?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The pruned wire body the builder emits for a create — `toCreateBody(spec)`. Every
|
||||
* field but `name` is optional and OMITTED when empty/default, so a simple agent
|
||||
* posts a clean `{name,…}` and a power-user agent posts exactly the knobs they set.
|
||||
* This is what the injected `createAgent` effect receives (NOT the raw form spec) —
|
||||
* so a host serializes nothing itself; the shared pure logic already did.
|
||||
*/
|
||||
export type AgentCreateBody = {
|
||||
name: string
|
||||
model?: string
|
||||
description?: string
|
||||
systemPrompt?: string
|
||||
tools?: string[]
|
||||
knowledge?: string[]
|
||||
config?: Partial<AgentConfig>
|
||||
}
|
||||
|
||||
/**
|
||||
* The effects + data the builder needs, injected by the host surface. Every loader
|
||||
* is async and may reject; the builder renders honest loading/error states and
|
||||
* NEVER fabricates an option. All are optional except `createAgent`:
|
||||
* - no `loadModels` → the model field is a plain typeable input (still works).
|
||||
* - no `loadPrompts` → the prompt selector is hidden (system prompt stays free-text).
|
||||
* - no `loadTools` → the tools field is typeable-only (no live options).
|
||||
*/
|
||||
export type AgentBuilderLoaders = {
|
||||
/** The live model catalog (ids the gateway accepts). Rejects → typeable fallback. */
|
||||
loadModels?: () => Promise<BuilderOption[]>
|
||||
/** The org's saved prompts (names + optional bodies). Rejects → selector hidden. */
|
||||
loadPrompts?: () => Promise<BuilderPrompt[]>
|
||||
/** Fetch ONE saved prompt's body by name (used when the list row lacks it). */
|
||||
loadPromptBody?: (name: string) => Promise<string>
|
||||
/** The live tool catalog. Rejects → typeable-only tools. */
|
||||
loadTools?: () => Promise<BuilderOption[]>
|
||||
/**
|
||||
* 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
|
||||
* resolves the org from the caller's bearer server-side. Rejects with the backend
|
||||
* error (the builder classifies 404 as "not connected" honestly).
|
||||
*/
|
||||
createAgent: (body: AgentCreateBody) => Promise<unknown>
|
||||
}
|
||||
|
||||
/** 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). */
|
||||
| 'unavailable'
|
||||
/** A real error (validation, auth, upstream) — show the message. */
|
||||
| 'error'
|
||||
|
||||
/** A classified builder error: a kind plus a human message. */
|
||||
export type BuilderError = { kind: BuilderErrorKind; message: string }
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* CollectionView — the Twenty-grade records surface for ONE Hanzo Base collection.
|
||||
*
|
||||
* It loads the collection schema (→ @hanzo/data `FieldDefinition[]` via
|
||||
* `baseCollectionToFields`) and the records, then renders them through @hanzo/data's
|
||||
* `RecordsView` — the shared table ⇆ board view with filter / sort / group, inline
|
||||
* cell editing, row selection, and a detail hand-off. Every field type shows the
|
||||
* right Display/Input with zero per-type code here, so a new Base field just works.
|
||||
*
|
||||
* Persistence is REAL and honest: an inline cell edit or a board card move calls
|
||||
* `BaseDataApi.updateRecord` and reflects the server's response (no optimistic
|
||||
* fakery beyond the board's follow-the-pointer, which reverts on failure); a failed
|
||||
* mutation surfaces an inline banner. Load failures use the shared backend-state
|
||||
* card; empty + loading are the view's own honest states. No demo rows, ever.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { RefreshCw, TriangleAlert } from '@hanzogui/lucide-icons-2'
|
||||
import { RecordsView, type FieldDefinition } from '@hanzo/data'
|
||||
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import { BaseDataApi, type BaseRecord } from '~/lib/base-data/api'
|
||||
import { baseCollectionToFields } from '~/lib/base-data/fields'
|
||||
|
||||
type LoadState =
|
||||
| { phase: 'loading' }
|
||||
| { phase: 'missing' }
|
||||
| { phase: 'error'; error: BackendState }
|
||||
| { phase: 'ready'; fields: FieldDefinition[]; records: BaseRecord[] }
|
||||
|
||||
export interface CollectionViewProps {
|
||||
/** A configured Base client (transport already wired — e.g. the `/superbase` proxy). */
|
||||
api: BaseDataApi
|
||||
/** Collection name (or id) to render. */
|
||||
collection: string
|
||||
/** Open a record's detail. */
|
||||
onOpen: (record: Record<string, unknown>) => void
|
||||
/** Start creating a record (the host routes to the create form). */
|
||||
onCreate: () => void
|
||||
/** Optional title above the view. */
|
||||
title?: ReactNode
|
||||
}
|
||||
|
||||
export function CollectionView({ api, collection, onOpen, onCreate, title }: CollectionViewProps) {
|
||||
const [state, setState] = useState<LoadState>({ phase: 'loading' })
|
||||
const [mutationError, setMutationError] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(
|
||||
async (signal: { cancelled: boolean }) => {
|
||||
setState({ phase: 'loading' })
|
||||
try {
|
||||
const schema = await api.getCollection(collection)
|
||||
if (!schema) {
|
||||
if (!signal.cancelled) setState({ phase: 'missing' })
|
||||
return
|
||||
}
|
||||
const fields = baseCollectionToFields(schema)
|
||||
const { items } = await api.listRecords(collection, { perPage: 200 })
|
||||
if (!signal.cancelled) setState({ phase: 'ready', fields, records: items })
|
||||
} catch (e) {
|
||||
if (!signal.cancelled) setState({ phase: 'error', error: classifyBackend(e) })
|
||||
}
|
||||
},
|
||||
[api, collection],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const signal = { cancelled: false }
|
||||
void load(signal)
|
||||
return () => { signal.cancelled = true }
|
||||
}, [load])
|
||||
|
||||
const reload = useCallback(() => void load({ cancelled: false }), [load])
|
||||
|
||||
/** Persist one inline field edit (table cell or board move) and reflect the server row. */
|
||||
const onEditCommit = useCallback(
|
||||
async (record: Record<string, unknown>, field: FieldDefinition, value: unknown) => {
|
||||
const id = record.id != null ? String(record.id) : ''
|
||||
if (!id) return
|
||||
setMutationError(null)
|
||||
try {
|
||||
const updated = await api.updateRecord(collection, id, { [field.name]: value })
|
||||
setState((s) => (s.phase === 'ready' ? { ...s, records: s.records.map((r) => (String(r.id) === id ? updated : r)) } : s))
|
||||
} catch (e) {
|
||||
setMutationError(classifyBackend(e).message)
|
||||
throw e // let the board revert its optimistic move
|
||||
}
|
||||
},
|
||||
[api, collection],
|
||||
)
|
||||
|
||||
if (state.phase === 'error') {
|
||||
return <BackendStateCard state={state.error} onRetry={reload} hint={`base · GET /v1/collections/${collection}/records`} />
|
||||
}
|
||||
|
||||
if (state.phase === 'missing') {
|
||||
return (
|
||||
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$2" maxW={620}>
|
||||
<XStack gap="$2" items="center">
|
||||
<TriangleAlert size={16} />
|
||||
<Text fontSize="$4" fontWeight="700">Collection not found</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$3" color="$color11">
|
||||
No collection named “{collection}” is visible on this Base. Check the name, or sign in with an account that can read it.
|
||||
</Text>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const ready = state.phase === 'ready' ? state : undefined
|
||||
return (
|
||||
<YStack gap="$3">
|
||||
{mutationError ? (
|
||||
<Card borderWidth={1} borderColor="$red7" bg="$red2" p="$3" maxW={720}>
|
||||
<Text fontSize="$3" color="$red11">{mutationError}</Text>
|
||||
</Card>
|
||||
) : null}
|
||||
<RecordsView
|
||||
title={title}
|
||||
fields={ready?.fields ?? []}
|
||||
records={ready?.records ?? []}
|
||||
loading={state.phase === 'loading'}
|
||||
onOpen={onOpen}
|
||||
onCreate={onCreate}
|
||||
createLabel="New record"
|
||||
onEditCommit={onEditCommit}
|
||||
toolbarExtra={<Button size="$2" icon={<RefreshCw size={15} />} onPress={reload}>Refresh</Button>}
|
||||
empty={`No records in ${collection} yet.`}
|
||||
/>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
export default CollectionView
|
||||
@@ -0,0 +1,254 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* RecordDetailView — view / edit / create / delete ONE Base record, metadata-driven.
|
||||
*
|
||||
* It loads the collection schema (→ @hanzo/data `FieldDefinition[]`) and the record,
|
||||
* then renders through @hanzo/data's `RecordDetail` (read) and `RecordForm` (edit) —
|
||||
* the same field routers the list uses — so every field type shows the right
|
||||
* Display/Input with zero per-type code here. Create (`recordId === 'new'`) opens
|
||||
* straight into the form over a blank draft. Persistence is the Base record CRUD
|
||||
* (`createRecord` / `updateRecord` / `deleteRecord`) through whatever transport the
|
||||
* `BaseDataApi` was given (the same `/superbase` proxy the list uses).
|
||||
*
|
||||
* States are honest, never fabricated: the shared backend-state card on a load
|
||||
* failure, a "collection not found" note, an inline save error, and a two-step
|
||||
* delete confirmation. No optimistic fakery — the view reflects what the server
|
||||
* actually returned.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { ArrowLeft, Pencil, Save, Trash2, TriangleAlert, X } from '@hanzogui/lucide-icons-2'
|
||||
import { RecordDetail, RecordForm, type FieldDefinition } from '@hanzo/data'
|
||||
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { BaseDataApi, type BaseRecord } from '~/lib/base-data/api'
|
||||
import { baseCollectionToFields } from '~/lib/base-data/fields'
|
||||
import { recordLabel, savePayload } from './records'
|
||||
|
||||
export interface RecordDetailViewProps {
|
||||
/** A configured Base client (transport already wired — e.g. the `/superbase` proxy). */
|
||||
api: BaseDataApi
|
||||
/** Collection name the record belongs to. */
|
||||
collection: string
|
||||
/** Record id, or `'new'` to open the create form. */
|
||||
recordId: string
|
||||
/** Back to the collection's record list. */
|
||||
onBack: () => void
|
||||
/** Open a record's detail (after a create, to land on the new record). */
|
||||
onView: (id: string) => void
|
||||
}
|
||||
|
||||
type LoadState =
|
||||
| { phase: 'loading' }
|
||||
| { phase: 'missing' }
|
||||
| { phase: 'error'; error: BackendState }
|
||||
| { phase: 'ready'; fields: FieldDefinition[]; record: BaseRecord | null }
|
||||
|
||||
export function RecordDetailView({ api, collection, recordId, onBack, onView }: RecordDetailViewProps) {
|
||||
const isNew = recordId === 'new'
|
||||
const [state, setState] = useState<LoadState>({ phase: 'loading' })
|
||||
const [editing, setEditing] = useState(isNew)
|
||||
const [draft, setDraft] = useState<Record<string, unknown>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||
|
||||
const load = useCallback(
|
||||
async (signal: { cancelled: boolean }) => {
|
||||
setState({ phase: 'loading' })
|
||||
try {
|
||||
const schema = await api.getCollection(collection)
|
||||
if (!schema) {
|
||||
if (!signal.cancelled) setState({ phase: 'missing' })
|
||||
return
|
||||
}
|
||||
const fields = baseCollectionToFields(schema)
|
||||
const record = isNew ? null : await api.getRecord(collection, recordId)
|
||||
if (signal.cancelled) return
|
||||
setState({ phase: 'ready', fields, record })
|
||||
setDraft(record ? { ...record } : {})
|
||||
setEditing(isNew)
|
||||
setSaveError(null)
|
||||
setConfirmingDelete(false)
|
||||
} catch (e) {
|
||||
if (!signal.cancelled) setState({ phase: 'error', error: classifyBackend(e) })
|
||||
}
|
||||
},
|
||||
[api, collection, recordId, isNew],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const signal = { cancelled: false }
|
||||
void load(signal)
|
||||
return () => {
|
||||
signal.cancelled = true
|
||||
}
|
||||
}, [load])
|
||||
|
||||
const reload = useCallback(() => void load({ cancelled: false }), [load])
|
||||
|
||||
const ready = state.phase === 'ready' ? state : undefined
|
||||
const fields = ready?.fields ?? []
|
||||
const record = ready?.record ?? null
|
||||
const title = isNew ? `New ${collection} record` : recordLabel(record ?? {}, fields)
|
||||
|
||||
const onChange = useCallback((name: string, value: unknown) => {
|
||||
setDraft((d) => ({ ...d, [name]: value }))
|
||||
}, [])
|
||||
|
||||
const save = useCallback(async () => {
|
||||
setSaving(true)
|
||||
setSaveError(null)
|
||||
try {
|
||||
const payload = savePayload(draft, fields)
|
||||
if (isNew) {
|
||||
const created = await api.createRecord(collection, payload)
|
||||
if (created.id) onView(created.id)
|
||||
else onBack()
|
||||
return
|
||||
}
|
||||
if (!record?.id) throw new Error('This record has no id to update.')
|
||||
const updated = await api.updateRecord(collection, record.id, payload)
|
||||
setState({ phase: 'ready', fields, record: updated })
|
||||
setDraft({ ...updated })
|
||||
setEditing(false)
|
||||
} catch (e) {
|
||||
setSaveError(classifyBackend(e).message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [api, collection, draft, fields, isNew, onBack, onView, record])
|
||||
|
||||
const remove = useCallback(async () => {
|
||||
if (!record?.id) return
|
||||
setSaving(true)
|
||||
setSaveError(null)
|
||||
try {
|
||||
await api.deleteRecord(collection, record.id)
|
||||
onBack()
|
||||
} catch (e) {
|
||||
setSaveError(classifyBackend(e).message)
|
||||
setSaving(false)
|
||||
setConfirmingDelete(false)
|
||||
}
|
||||
}, [api, collection, onBack, record])
|
||||
|
||||
const backButton = useMemo(
|
||||
() => (
|
||||
<Button size="$2" icon={<ArrowLeft size={15} />} onPress={onBack}>
|
||||
Back
|
||||
</Button>
|
||||
),
|
||||
[onBack],
|
||||
)
|
||||
|
||||
if (state.phase === 'error') {
|
||||
return (
|
||||
<YStack gap="$3">
|
||||
{backButton}
|
||||
<BackendStateCard
|
||||
state={state.error}
|
||||
onRetry={reload}
|
||||
hint={`base · ${collection}/${recordId}`}
|
||||
/>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
if (state.phase === 'missing') {
|
||||
return (
|
||||
<YStack gap="$3">
|
||||
{backButton}
|
||||
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$2" maxWidth={620}>
|
||||
<XStack gap="$2" items="center">
|
||||
<TriangleAlert size={16} />
|
||||
<Text fontSize="$4" fontWeight="700">
|
||||
Collection not found
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$3" color="$color11">
|
||||
No collection named “{collection}” is visible on this Base. Check the name, or sign in with an
|
||||
account that can read it.
|
||||
</Text>
|
||||
</Card>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack gap="$3">
|
||||
<XStack items="center" justify="space-between" gap="$4">
|
||||
<XStack items="center" gap="$3" flex={1}>
|
||||
{backButton}
|
||||
<YStack flex={1}>
|
||||
<Text fontSize="$6" fontWeight="800" numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color10">
|
||||
{collection}
|
||||
</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
<XStack items="center" gap="$2">
|
||||
{editing ? (
|
||||
<>
|
||||
{!isNew ? (
|
||||
<Button size="$2" icon={<X size={15} />} onPress={() => { setDraft(record ? { ...record } : {}); setEditing(false); setSaveError(null) }} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
<PrimaryButton size="$2" icon={<Save size={15} />} onPress={save} disabled={saving}>
|
||||
{saving ? 'Saving…' : isNew ? 'Create' : 'Save'}
|
||||
</PrimaryButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button size="$2" theme="red" icon={<Trash2 size={15} />} onPress={() => setConfirmingDelete(true)} disabled={saving}>
|
||||
Delete
|
||||
</Button>
|
||||
<PrimaryButton size="$2" icon={<Pencil size={15} />} onPress={() => setEditing(true)}>
|
||||
Edit
|
||||
</PrimaryButton>
|
||||
</>
|
||||
)}
|
||||
</XStack>
|
||||
</XStack>
|
||||
|
||||
{confirmingDelete ? (
|
||||
<Card borderWidth={1} borderColor="$red7" bg="$red2" p="$3" gap="$2" maxWidth={620}>
|
||||
<Text fontSize="$3" fontWeight="700">
|
||||
Delete “{title}”? This cannot be undone.
|
||||
</Text>
|
||||
<XStack gap="$2">
|
||||
<Button size="$2" theme="red" onPress={remove} disabled={saving}>
|
||||
{saving ? 'Deleting…' : 'Confirm delete'}
|
||||
</Button>
|
||||
<Button size="$2" onPress={() => setConfirmingDelete(false)} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
</XStack>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{saveError ? (
|
||||
<Card borderWidth={1} borderColor="$red7" bg="$red2" p="$3" maxWidth={620}>
|
||||
<Text fontSize="$3" color="$red11">
|
||||
{saveError}
|
||||
</Text>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card borderWidth={1} borderColor="$borderColor" p="$4" maxWidth={720}>
|
||||
{editing ? (
|
||||
<RecordForm fields={fields} values={draft} onChange={onChange} />
|
||||
) : record ? (
|
||||
<RecordDetail title={title} fields={fields} record={record} />
|
||||
) : null}
|
||||
</Card>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
export default RecordDetailView
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import type { FieldDefinition } from '@hanzo/data'
|
||||
import { baseCollectionToFields } from '~/lib/base-data/fields'
|
||||
import { editableFields, isEditable, recordLabel, savePayload } from '~/components/base-data/records'
|
||||
|
||||
/** A realistic mapped field model: id (read-only) + user fields + an autodate. */
|
||||
const fields: FieldDefinition[] = baseCollectionToFields({
|
||||
name: 'contacts',
|
||||
fields: [
|
||||
{ name: 'id', type: 'text', system: true },
|
||||
{ name: 'name', type: 'text' },
|
||||
{ name: 'email', type: 'email' },
|
||||
{ name: 'active', type: 'bool' },
|
||||
{ name: 'tags', type: 'select', maxSelect: 3, values: ['a', 'b'] },
|
||||
{ name: 'updated', type: 'autodate' },
|
||||
],
|
||||
})
|
||||
|
||||
describe('isEditable / editableFields', () => {
|
||||
it('excludes the read-only id + autodate, keeps the user fields in order', () => {
|
||||
expect(editableFields(fields).map((f) => f.name)).toEqual(['name', 'email', 'active', 'tags'])
|
||||
})
|
||||
|
||||
it('isEditable is the single !readOnly predicate', () => {
|
||||
const id = fields.find((f) => f.name === 'id')!
|
||||
const name = fields.find((f) => f.name === 'name')!
|
||||
expect(isEditable(id)).toBe(false)
|
||||
expect(isEditable(name)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('savePayload', () => {
|
||||
it('sends only editable field values — never the server-owned id/timestamps', () => {
|
||||
const values = { id: 'rec_1', name: 'Ada', email: 'ada@x.io', active: true, tags: ['a'], updated: 'yesterday' }
|
||||
expect(savePayload(values, fields)).toEqual({ name: 'Ada', email: 'ada@x.io', active: true, tags: ['a'] })
|
||||
})
|
||||
|
||||
it('drops undefined (untouched) fields but keeps meaningful empties (false / [] / "" / 0)', () => {
|
||||
const values = { name: '', active: false, tags: [] as string[] } // email untouched (undefined)
|
||||
expect(savePayload(values, fields)).toEqual({ name: '', active: false, tags: [] })
|
||||
})
|
||||
|
||||
it('is total for an empty draft — sends nothing', () => {
|
||||
expect(savePayload({}, fields)).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('recordLabel', () => {
|
||||
it('uses the first non-empty text/email/url value (skipping id)', () => {
|
||||
expect(recordLabel({ id: 'rec_1', name: 'Ada Lovelace', email: 'ada@x.io' }, fields)).toBe('Ada Lovelace')
|
||||
})
|
||||
|
||||
it('falls back to the id when no text-ish field has a value', () => {
|
||||
expect(recordLabel({ id: 'rec_9', name: '' }, fields)).toBe('rec_9')
|
||||
})
|
||||
|
||||
it('falls back to a stable placeholder when there is neither', () => {
|
||||
expect(recordLabel({}, fields)).toBe('(untitled)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Pure record-form logic for the Base records surface — the decisions the
|
||||
* create/edit views take, isolated from React + I/O so they are trivially
|
||||
* testable (data in, data out).
|
||||
*
|
||||
* The field model is already the mapped @hanzo/data `FieldDefinition[]`
|
||||
* (`baseCollectionToFields`), so "editable" is one predicate — `!readOnly` — and
|
||||
* read-only-ness lives in ONE place (the mapper marks `id` + autodate timestamps).
|
||||
* The `@hanzo/data` import is `import type` (erased), so this module pulls in none
|
||||
* of its runtime and the unit test runs in plain Node.
|
||||
*/
|
||||
import type { FieldDefinition } from '@hanzo/data'
|
||||
|
||||
/** A field the user can edit — everything except server-owned read-only fields
|
||||
* (`id`, the create/update timestamps). Read-only fields still show in the detail
|
||||
* view; they just never render an input. */
|
||||
export const isEditable = (f: FieldDefinition): boolean => !f.readOnly
|
||||
|
||||
/** The editable subset of a collection's fields — the inputs a create/edit form
|
||||
* renders (order preserved). */
|
||||
export function editableFields(fields: FieldDefinition[]): FieldDefinition[] {
|
||||
return fields.filter(isEditable)
|
||||
}
|
||||
|
||||
/**
|
||||
* The payload to persist for a create/update: the editable field values only, so a
|
||||
* server-owned field (`id`, `created`, `updated`) is NEVER sent back. Fields left
|
||||
* `undefined` are dropped, so a partial edit (or a blank create) doesn't push a
|
||||
* value the user never entered — Base keeps its own default/previous value.
|
||||
*/
|
||||
export function savePayload(
|
||||
values: Record<string, unknown>,
|
||||
fields: FieldDefinition[],
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const f of editableFields(fields)) {
|
||||
const v = values[f.name]
|
||||
if (v !== undefined) out[f.name] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* A human label for a record — the first non-empty text/email/url field value
|
||||
* (skipping the id), else the record id, else a stable placeholder. Used for the
|
||||
* detail header and the delete confirmation, so a row is never referred to as
|
||||
* "[object Object]".
|
||||
*/
|
||||
export function recordLabel(record: Record<string, unknown>, fields: FieldDefinition[]): string {
|
||||
const textish = fields.find(
|
||||
(f) =>
|
||||
f.name !== 'id' &&
|
||||
(f.type === 'text' || f.type === 'email' || f.type === 'url') &&
|
||||
typeof record[f.name] === 'string' &&
|
||||
record[f.name] !== '',
|
||||
)
|
||||
if (textish) return String(record[textish.name])
|
||||
if (typeof record.id === 'string' && record.id) return record.id
|
||||
return '(untitled)'
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* CollectionsBrowser — the app-lane home: the DocTypes of a `module` (a CMS
|
||||
* "collection" IS a framework DocType tagged with the lane's module). It lists
|
||||
* them as cards, offers a first-run "Set up" that installs the lane's fixtures
|
||||
* (POST /v1/framework/modules/:module/install), and a "New collection" that
|
||||
* defines a fresh content DocType. Everything is per-org and honest — an org with
|
||||
* the lane not yet installed sees the setup CTA, never a fabricated collection.
|
||||
*
|
||||
* This is generic over `module`, so CMS (`cms`), ERP (`erp`), and Helpdesk
|
||||
* (`help`) all reuse it — the ONE collections home for every lane.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Button, Card, Input, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Boxes, Plus, TriangleAlert } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { Loader } from '~/components/ui/Loader'
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import type { FrameworkClient } from '~/lib/framework/client'
|
||||
import type { DocType } from '~/lib/framework/types'
|
||||
import { moduleDoctypes, isValidDoctypeName } from '~/lib/framework/fields'
|
||||
|
||||
export interface CollectionsBrowserProps {
|
||||
client: FrameworkClient
|
||||
/** The app lane: 'cms' | 'erp' | 'help' | … */
|
||||
module: string
|
||||
/** Human label for the lane (e.g. "Content"). */
|
||||
label: string
|
||||
subtitle: string
|
||||
/** Open a collection's records. */
|
||||
onOpen: (doctype: string) => void
|
||||
/**
|
||||
* Lane-appropriate copy for the first-run (pre-install) empty state. Optional and
|
||||
* defaulted to the CMS wording, so this component stays generic over the lane: a
|
||||
* CMS caller renders identically, while ERP/Help pass their own description +
|
||||
* bullets. Additive only — no behavior/permission/proxy change.
|
||||
*/
|
||||
setupDescription?: string
|
||||
setupBullets?: string[]
|
||||
}
|
||||
|
||||
type LoadState =
|
||||
| { phase: 'loading' }
|
||||
| { phase: 'error'; error: BackendState }
|
||||
| { phase: 'ready'; collections: DocType[]; registered: boolean }
|
||||
|
||||
export function CollectionsBrowser({ client, module, label, subtitle, onOpen, setupDescription, setupBullets }: CollectionsBrowserProps) {
|
||||
const [state, setState] = useState<LoadState>({ phase: 'loading' })
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(
|
||||
async (signal: { cancelled: boolean }) => {
|
||||
setState({ phase: 'loading' })
|
||||
try {
|
||||
const [dts, mod] = await Promise.all([
|
||||
client.doctypes.list(),
|
||||
client.modules.get(module).catch(() => null), // module may not be registered on older cloud
|
||||
])
|
||||
if (signal.cancelled) return
|
||||
setState({ phase: 'ready', collections: moduleDoctypes(dts, module), registered: Boolean(mod && mod.doctypes.length) })
|
||||
} catch (e) {
|
||||
if (!signal.cancelled) setState({ phase: 'error', error: classifyBackend(e) })
|
||||
}
|
||||
},
|
||||
[client, module],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const signal = { cancelled: false }
|
||||
void load(signal)
|
||||
return () => { signal.cancelled = true }
|
||||
}, [load])
|
||||
|
||||
const reload = useCallback(() => void load({ cancelled: false }), [load])
|
||||
|
||||
const install = useCallback(async () => {
|
||||
setBusy(true)
|
||||
setActionError(null)
|
||||
try {
|
||||
await client.modules.install(module)
|
||||
reload()
|
||||
} catch (e) {
|
||||
setActionError(classifyBackend(e).message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}, [client, module, reload])
|
||||
|
||||
const create = useCallback(async () => {
|
||||
const name = newName.trim()
|
||||
if (!isValidDoctypeName(name)) {
|
||||
setActionError('Use a name with letters, digits, dashes or underscores — no spaces.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setActionError(null)
|
||||
try {
|
||||
await client.doctypes.create(contentCollection(name, module))
|
||||
setCreating(false)
|
||||
setNewName('')
|
||||
onOpen(name)
|
||||
} catch (e) {
|
||||
setActionError(classifyBackend(e).message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}, [client, module, newName, onOpen])
|
||||
|
||||
if (state.phase === 'loading') return <Loader label={`Loading ${label}…`} />
|
||||
if (state.phase === 'error') {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={label} subtitle={subtitle} />
|
||||
<BackendStateCard state={state.error} onRetry={reload} hint="framework · GET /v1/framework/doctypes" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const { collections } = state
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={label}
|
||||
subtitle={subtitle}
|
||||
actions={
|
||||
collections.length ? (
|
||||
<PrimaryButton size="$2" icon={<Plus size={15} />} onPress={() => { setCreating((c) => !c); setActionError(null) }}>New collection</PrimaryButton>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{actionError ? (
|
||||
<Card borderWidth={1} borderColor="$red7" bg="$red2" p="$3" mb="$3" maxWidth={620}>
|
||||
<XStack gap="$2" items="center"><TriangleAlert size={15} /><Text fontSize="$3" color="$red11">{actionError}</Text></XStack>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{creating ? (
|
||||
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$3" mb="$3" maxWidth={620}>
|
||||
<Text fontSize="$4" fontWeight="700">New collection</Text>
|
||||
<Text fontSize="$2" color="$color10">A content type on the framework (title, slug, body, status). Add fields later in Settings.</Text>
|
||||
<XStack gap="$2" flexWrap="wrap">
|
||||
<Input flex={1} minW={220} placeholder="e.g. Recipe or LandingPage" value={newName} onChangeText={setNewName} disabled={busy} />
|
||||
<PrimaryButton size="$2" disabled={busy} onPress={create}>{busy ? 'Creating…' : 'Create'}</PrimaryButton>
|
||||
<Button size="$2" disabled={busy} onPress={() => { setCreating(false); setNewName('') }}>Cancel</Button>
|
||||
</XStack>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{collections.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Boxes}
|
||||
title={`Set up ${label}`}
|
||||
description={setupDescription ?? `${label} is a set of content collections — Pages, Posts, Articles, Media, and Navigation — as DocTypes on the Hanzo Framework, per organization.`}
|
||||
bullets={setupBullets ?? [
|
||||
'Installs the default collections into your organization',
|
||||
'Content is documents on the framework — versioned, permissioned, per-org',
|
||||
'Add your own collections and fields any time',
|
||||
]}
|
||||
primary={state.registered ? { label: busy ? 'Setting up…' : `Set up ${label}`, onPress: install } : undefined}
|
||||
/>
|
||||
) : (
|
||||
<XStack gap="$3" flexWrap="wrap">
|
||||
{collections.map((dt) => (
|
||||
<YStack
|
||||
key={dt.name}
|
||||
onPress={() => onOpen(dt.name)}
|
||||
hoverStyle={{ borderColor: '$color8' }}
|
||||
cursor="pointer"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
rounded="$4"
|
||||
p="$4"
|
||||
gap="$2"
|
||||
width={240}
|
||||
>
|
||||
<XStack gap="$2" items="center">
|
||||
<Boxes size={16} />
|
||||
<Text fontSize="$4" fontWeight="700" numberOfLines={1}>{dt.name}</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$2" color="$color10">
|
||||
{(dt.fields?.length ?? 0)} field{(dt.fields?.length ?? 0) === 1 ? '' : 's'}
|
||||
{dt.isSubmittable ? ' · submittable' : ''}
|
||||
</Text>
|
||||
</YStack>
|
||||
))}
|
||||
</XStack>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The DocType a "New collection" creates: a minimal, immediately-usable content
|
||||
* type (title, URL slug, rich body, publish status) tagged with the lane module.
|
||||
* Secure by default — the engine seeds a System-Manager grant; the owner widens.
|
||||
*/
|
||||
function contentCollection(name: string, module: string): DocType {
|
||||
return {
|
||||
name,
|
||||
module,
|
||||
autoname: 'field:slug',
|
||||
titleField: 'title',
|
||||
fields: [
|
||||
{ fieldname: 'title', fieldtype: 'Data', label: 'Title', reqd: true, inListView: true },
|
||||
{ fieldname: 'slug', fieldtype: 'Data', label: 'Slug', reqd: true, inListView: true },
|
||||
{ fieldname: 'body', fieldtype: 'Text', label: 'Body' },
|
||||
{ fieldname: 'status', fieldtype: 'Select', label: 'Status', options: 'Draft\nPublished', default: 'Draft', inListView: true },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export default CollectionsBrowser
|
||||
@@ -0,0 +1,253 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* DocTypeDetail — view / edit / create / delete ONE framework document, plus the
|
||||
* lifecycle actions the engine exposes: PUBLISH (a status field flips Draft ⇆
|
||||
* Published) and, for a submittable DocType, SUBMIT / CANCEL (docstatus 0→1→2).
|
||||
* All metadata-driven: it loads the schema (→ @hanzo/data `FieldDefinition[]`) and
|
||||
* renders through `RecordDetail` (read) + `RecordForm` (edit) — the SAME field
|
||||
* routers the list uses — so every field type just works with zero per-doctype
|
||||
* code. Create (`name === 'new'`) opens the form over a defaulted blank draft.
|
||||
*
|
||||
* The engine validates the WHOLE document on update, so save/publish always send
|
||||
* the full record (via `savePayload`, which slugifies the URL key + strips the
|
||||
* redacted Password). States are honest: backend-error, inline save error, and a
|
||||
* two-step delete confirmation — never optimistic fakery.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { ArrowLeft, Ban, Globe, Pencil, PenOff, Save, Send, Trash2, TriangleAlert, X } from '@hanzogui/lucide-icons-2'
|
||||
import { RecordDetail, RecordForm, type FieldDefinition, type SelectOption } from '@hanzo/data'
|
||||
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import type { FrameworkClient } from '~/lib/framework/client'
|
||||
import type { DocType, FrameworkDoc } from '~/lib/framework/types'
|
||||
import { docTypeToFields, toRecord, enrichLinks, savePayload, newDraft, statusField, titleOf } from '~/lib/framework/fields'
|
||||
import { loadLinkOptions, makeFieldOptions } from './data'
|
||||
|
||||
export interface DocTypeDetailProps {
|
||||
client: FrameworkClient
|
||||
doctype: string
|
||||
/** Document name, or `'new'` to open the create form. */
|
||||
name: string
|
||||
onBack: () => void
|
||||
/** Land on a document (after create). */
|
||||
onView: (name: string) => void
|
||||
}
|
||||
|
||||
type LoadState =
|
||||
| { phase: 'loading' }
|
||||
| { phase: 'error'; error: BackendState }
|
||||
| { phase: 'ready'; dt: DocType; record: Record<string, unknown> | null; linkOptions: Record<string, SelectOption[]> }
|
||||
|
||||
export function DocTypeDetail({ client, doctype, name, onBack, onView }: DocTypeDetailProps) {
|
||||
const isNew = name === 'new'
|
||||
const [state, setState] = useState<LoadState>({ phase: 'loading' })
|
||||
const [editing, setEditing] = useState(isNew)
|
||||
const [draft, setDraft] = useState<Record<string, unknown>>({})
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||
|
||||
const load = useCallback(
|
||||
async (signal: { cancelled: boolean }) => {
|
||||
setState({ phase: 'loading' })
|
||||
try {
|
||||
const dt = await client.doctypes.get(doctype)
|
||||
const linkOptions = await loadLinkOptions(client, dt)
|
||||
const doc = isNew ? null : await client.records.get(doctype, name)
|
||||
if (signal.cancelled) return
|
||||
const record = doc ? enrichLinks(toRecord(doc, dt), dt, linkOptions) : null
|
||||
setState({ phase: 'ready', dt, record, linkOptions })
|
||||
setDraft(record ? { ...record } : newDraft(dt))
|
||||
setEditing(isNew)
|
||||
setSaveError(null)
|
||||
setConfirmingDelete(false)
|
||||
} catch (e) {
|
||||
if (!signal.cancelled) setState({ phase: 'error', error: classifyBackend(e) })
|
||||
}
|
||||
},
|
||||
[client, doctype, name, isNew],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const signal = { cancelled: false }
|
||||
void load(signal)
|
||||
return () => { signal.cancelled = true }
|
||||
}, [load])
|
||||
|
||||
const reload = useCallback(() => void load({ cancelled: false }), [load])
|
||||
|
||||
const ready = state.phase === 'ready' ? state : undefined
|
||||
const dt = ready?.dt
|
||||
const record = ready?.record ?? null
|
||||
const fields: FieldDefinition[] = useMemo(
|
||||
() => (dt ? docTypeToFields(dt, { editing: !isNew }) : []),
|
||||
[dt, isNew],
|
||||
)
|
||||
const fieldOptions = useMemo(() => (ready ? makeFieldOptions(ready.linkOptions) : undefined), [ready])
|
||||
const title = dt ? (isNew ? `New ${doctype}` : titleOf(record ?? {}, dt)) : doctype
|
||||
|
||||
const onChange = useCallback((field: string, value: unknown) => {
|
||||
setDraft((d) => ({ ...d, [field]: value }))
|
||||
}, [])
|
||||
|
||||
const reflect = useCallback(
|
||||
(saved: FrameworkDoc) => {
|
||||
if (!dt || !ready) return
|
||||
const next = enrichLinks(toRecord(saved, dt), dt, ready.linkOptions)
|
||||
setState({ phase: 'ready', dt, record: next, linkOptions: ready.linkOptions })
|
||||
setDraft({ ...next })
|
||||
},
|
||||
[dt, ready],
|
||||
)
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (!dt) return
|
||||
setBusy(true)
|
||||
setSaveError(null)
|
||||
try {
|
||||
const body = savePayload(draft, dt)
|
||||
if (isNew) {
|
||||
const created = await client.records.create(doctype, body)
|
||||
if (created.name) onView(String(created.name))
|
||||
else onBack()
|
||||
return
|
||||
}
|
||||
if (!record?.name) throw new Error('This record has no name to update.')
|
||||
reflect(await client.records.update(doctype, String(record.name), body))
|
||||
setEditing(false)
|
||||
} catch (e) {
|
||||
setSaveError(classifyBackend(e).message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}, [client, doctype, draft, dt, isNew, onBack, onView, record, reflect])
|
||||
|
||||
/** Lifecycle op on the current record (publish/unpublish/submit/cancel/delete). */
|
||||
const run = useCallback(
|
||||
async (op: () => Promise<FrameworkDoc | void>) => {
|
||||
setBusy(true)
|
||||
setSaveError(null)
|
||||
try {
|
||||
const saved = await op()
|
||||
if (saved) reflect(saved)
|
||||
} catch (e) {
|
||||
setSaveError(classifyBackend(e).message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
},
|
||||
[reflect],
|
||||
)
|
||||
|
||||
const remove = useCallback(async () => {
|
||||
if (!record?.name) return
|
||||
setBusy(true)
|
||||
setSaveError(null)
|
||||
try {
|
||||
await client.records.remove(doctype, String(record.name))
|
||||
onBack()
|
||||
} catch (e) {
|
||||
setSaveError(classifyBackend(e).message)
|
||||
setBusy(false)
|
||||
setConfirmingDelete(false)
|
||||
}
|
||||
}, [client, doctype, onBack, record])
|
||||
|
||||
const backButton = (
|
||||
<Button size="$2" icon={<ArrowLeft size={15} />} onPress={onBack}>Back</Button>
|
||||
)
|
||||
|
||||
if (state.phase === 'error') {
|
||||
return (
|
||||
<YStack gap="$3">
|
||||
{backButton}
|
||||
<BackendStateCard state={state.error} onRetry={reload} hint={`framework · ${doctype}/${name}`} />
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
if (!dt) return <YStack gap="$3">{backButton}</YStack>
|
||||
|
||||
// Publish (status field) + submit/cancel (submittable) are only offered in read
|
||||
// mode on a persisted, draft-status document.
|
||||
const statusF = statusField(dt)
|
||||
const status = record ? String(record[statusF] ?? '') : ''
|
||||
const docstatus = record ? Number(record.docstatus ?? 0) : 0
|
||||
const canPublish = !editing && !isNew && statusF && docstatus === 0
|
||||
const canSubmit = !editing && !isNew && dt.isSubmittable && docstatus === 0
|
||||
const canCancel = !editing && !isNew && dt.isSubmittable && docstatus === 1
|
||||
|
||||
return (
|
||||
<YStack gap="$3">
|
||||
<XStack items="center" justify="space-between" gap="$4" flexWrap="wrap">
|
||||
<XStack items="center" gap="$3" flex={1}>
|
||||
{backButton}
|
||||
<YStack flex={1}>
|
||||
<Text fontSize="$6" fontWeight="800" numberOfLines={1}>{title}</Text>
|
||||
<Text fontSize="$2" color="$color10">
|
||||
{doctype}{status ? ` · ${status}` : ''}{docstatus === 1 ? ' · submitted' : docstatus === 2 ? ' · cancelled' : ''}
|
||||
</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
<XStack items="center" gap="$2" flexWrap="wrap">
|
||||
{editing ? (
|
||||
<>
|
||||
{!isNew ? (
|
||||
<Button size="$2" icon={<X size={15} />} disabled={busy} onPress={() => { setDraft(record ? { ...record } : {}); setEditing(false); setSaveError(null) }}>Cancel</Button>
|
||||
) : null}
|
||||
<PrimaryButton size="$2" icon={<Save size={15} />} disabled={busy} onPress={save}>
|
||||
{busy ? 'Saving…' : isNew ? 'Create' : 'Save'}
|
||||
</PrimaryButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button size="$2" theme="red" icon={<Trash2 size={15} />} disabled={busy} onPress={() => setConfirmingDelete(true)}>Delete</Button>
|
||||
{canCancel ? (
|
||||
<Button size="$2" icon={<Ban size={15} />} disabled={busy} onPress={() => run(() => client.records.cancel(doctype, String(record!.name)))}>Cancel doc</Button>
|
||||
) : null}
|
||||
{canSubmit ? (
|
||||
<Button size="$2" icon={<Send size={15} />} disabled={busy} onPress={() => run(() => client.records.submit(doctype, String(record!.name)))}>Submit</Button>
|
||||
) : null}
|
||||
{canPublish ? (
|
||||
status === 'Published' ? (
|
||||
<Button size="$2" icon={<PenOff size={15} />} disabled={busy} onPress={() => run(async () => client.records.update(doctype, String(record!.name), savePayload({ ...record, [statusF]: 'Draft' }, dt)))}>Unpublish</Button>
|
||||
) : (
|
||||
<PrimaryButton size="$2" icon={<Globe size={15} />} disabled={busy} onPress={() => run(async () => client.records.update(doctype, String(record!.name), savePayload({ ...record, [statusF]: 'Published' }, dt)))}>Publish</PrimaryButton>
|
||||
)
|
||||
) : null}
|
||||
<PrimaryButton size="$2" icon={<Pencil size={15} />} onPress={() => setEditing(true)}>Edit</PrimaryButton>
|
||||
</>
|
||||
)}
|
||||
</XStack>
|
||||
</XStack>
|
||||
|
||||
{confirmingDelete ? (
|
||||
<Card borderWidth={1} borderColor="$red7" bg="$red2" p="$3" gap="$2" maxWidth={620}>
|
||||
<Text fontSize="$3" fontWeight="700">Delete “{title}”? This cannot be undone.</Text>
|
||||
<XStack gap="$2">
|
||||
<Button size="$2" theme="red" disabled={busy} onPress={remove}>{busy ? 'Deleting…' : 'Confirm delete'}</Button>
|
||||
<Button size="$2" disabled={busy} onPress={() => setConfirmingDelete(false)}>Cancel</Button>
|
||||
</XStack>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{saveError ? (
|
||||
<Card borderWidth={1} borderColor="$red7" bg="$red2" p="$3" maxWidth={620}>
|
||||
<XStack gap="$2" items="center"><TriangleAlert size={15} /><Text fontSize="$3" color="$red11">{saveError}</Text></XStack>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card borderWidth={1} borderColor="$borderColor" p="$4" maxWidth={760}>
|
||||
{editing ? (
|
||||
<RecordForm fields={fields} values={draft} onChange={onChange} fieldOptions={fieldOptions} />
|
||||
) : record ? (
|
||||
<RecordDetail title={title} fields={fields} record={record} fieldOptions={fieldOptions} />
|
||||
) : null}
|
||||
</Card>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
export default DocTypeDetail
|
||||
@@ -0,0 +1,129 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* DocTypeRecords — the Twenty-grade records surface for ONE framework DocType,
|
||||
* driven ENTIRELY by DocType metadata. It loads the schema (→ @hanzo/data
|
||||
* `FieldDefinition[]` via `docTypeToFields`), the documents, and each relation's
|
||||
* candidate records, then renders through @hanzo/data's `RecordsView` — the shared
|
||||
* table ⇆ board view with filter/sort/group, inline cell editing, and a detail
|
||||
* hand-off. Every field type shows the right Display/Input with ZERO per-doctype
|
||||
* code, so a CMS Page, an ERP Invoice, or a Helpdesk Ticket all render here.
|
||||
*
|
||||
* Persistence is REAL: an inline cell edit sends the FULL record (the engine
|
||||
* validates the whole document on update) via `savePayload`, and reflects the
|
||||
* server's row. States are honest — loading / empty / backend-error, never a
|
||||
* fabricated row.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { Button } from '@hanzo/gui'
|
||||
import { RefreshCw } from '@hanzogui/lucide-icons-2'
|
||||
import { RecordsView, type FieldDefinition, type SelectOption } from '@hanzo/data'
|
||||
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import type { FrameworkClient } from '~/lib/framework/client'
|
||||
import type { DocType, FrameworkDoc } from '~/lib/framework/types'
|
||||
import { docTypeToFields, toRecord, enrichLinks, savePayload, isMediaDoctype } from '~/lib/framework/fields'
|
||||
import { loadLinkOptions, makeFieldOptions } from './data'
|
||||
import { MediaGrid } from './MediaGrid'
|
||||
|
||||
type LoadState =
|
||||
| { phase: 'loading' }
|
||||
| { phase: 'error'; error: BackendState }
|
||||
| { phase: 'ready'; dt: DocType; fields: FieldDefinition[]; docs: FrameworkDoc[]; records: Record<string, unknown>[]; linkOptions: Record<string, SelectOption[]> }
|
||||
|
||||
export interface DocTypeRecordsProps {
|
||||
client: FrameworkClient
|
||||
/** DocType name to render. */
|
||||
doctype: string
|
||||
/** Open a document's detail (by document name). */
|
||||
onOpen: (name: string) => void
|
||||
/** Start creating a document. */
|
||||
onCreate: () => void
|
||||
title?: ReactNode
|
||||
}
|
||||
|
||||
export function DocTypeRecords({ client, doctype, onOpen, onCreate, title }: DocTypeRecordsProps) {
|
||||
const [state, setState] = useState<LoadState>({ phase: 'loading' })
|
||||
const [mutationError, setMutationError] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(
|
||||
async (signal: { cancelled: boolean }) => {
|
||||
setState({ phase: 'loading' })
|
||||
try {
|
||||
const dt = await client.doctypes.get(doctype)
|
||||
const linkOptions = await loadLinkOptions(client, dt)
|
||||
const docs = await client.records.list(doctype, { limit: 200 })
|
||||
if (signal.cancelled) return
|
||||
const records = docs.map((d) => enrichLinks(toRecord(d, dt), dt, linkOptions))
|
||||
setState({ phase: 'ready', dt, fields: docTypeToFields(dt), docs, records, linkOptions })
|
||||
} catch (e) {
|
||||
if (!signal.cancelled) setState({ phase: 'error', error: classifyBackend(e) })
|
||||
}
|
||||
},
|
||||
[client, doctype],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const signal = { cancelled: false }
|
||||
void load(signal)
|
||||
return () => { signal.cancelled = true }
|
||||
}, [load])
|
||||
|
||||
const reload = useCallback(() => void load({ cancelled: false }), [load])
|
||||
|
||||
const fieldOptions = useMemo(
|
||||
() => (state.phase === 'ready' ? makeFieldOptions(state.linkOptions) : undefined),
|
||||
[state],
|
||||
)
|
||||
|
||||
/** Persist ONE inline edit. The engine validates the whole document on update,
|
||||
* so send the full record with the cell change merged in (never a partial body). */
|
||||
const onEditCommit = useCallback(
|
||||
async (record: Record<string, unknown>, field: FieldDefinition, value: unknown) => {
|
||||
if (state.phase !== 'ready') return
|
||||
const name = typeof record.name === 'string' ? record.name : ''
|
||||
if (!name) return
|
||||
setMutationError(null)
|
||||
try {
|
||||
const body = savePayload({ ...record, [field.name]: value }, state.dt)
|
||||
const saved = await client.records.update(doctype, name, body)
|
||||
const next = enrichLinks(toRecord(saved, state.dt), state.dt, state.linkOptions)
|
||||
setState((s) => (s.phase === 'ready' ? { ...s, records: s.records.map((r) => (r.name === name ? next : r)) } : s))
|
||||
} catch (e) {
|
||||
setMutationError(classifyBackend(e).message)
|
||||
throw e // let the board revert its optimistic move
|
||||
}
|
||||
},
|
||||
[client, doctype, state],
|
||||
)
|
||||
|
||||
if (state.phase === 'error') {
|
||||
return <BackendStateCard state={state.error} onRetry={reload} hint={`framework · GET /v1/framework/${doctype}`} />
|
||||
}
|
||||
|
||||
const refresh = <Button size="$2" icon={<RefreshCw size={15} />} onPress={reload}>Refresh</Button>
|
||||
|
||||
// A media/asset library (a required Attach) gets the gallery instead of the table.
|
||||
if (state.phase === 'ready' && isMediaDoctype(state.dt)) {
|
||||
return <MediaGrid dt={state.dt} docs={state.docs} onOpen={onOpen} onCreate={onCreate} toolbarExtra={refresh} />
|
||||
}
|
||||
|
||||
const ready = state.phase === 'ready' ? state : undefined
|
||||
return (
|
||||
<RecordsView
|
||||
title={title}
|
||||
fields={ready?.fields ?? []}
|
||||
records={ready?.records ?? []}
|
||||
loading={state.phase === 'loading'}
|
||||
onOpen={(r) => onOpen(String((r as { name?: unknown }).name ?? (r as { id?: unknown }).id ?? ''))}
|
||||
onCreate={onCreate}
|
||||
createLabel="New record"
|
||||
onEditCommit={onEditCommit}
|
||||
fieldOptions={fieldOptions}
|
||||
toolbarExtra={refresh}
|
||||
empty={mutationError ?? `No records in ${doctype} yet.`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default DocTypeRecords
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* MediaGrid — the presentational DAM gallery for a media DocType (an Attach-backed
|
||||
* collection). Pure view: it takes the already-loaded documents + schema and
|
||||
* renders asset cards (thumbnail from the primary Attach field). `DocTypeRecords`
|
||||
* renders this instead of the table when `isMediaDoctype(dt)` — so media is just a
|
||||
* DocType with a nicer default view, no separate data path and no bespoke subsystem.
|
||||
*
|
||||
* Assets are per-org: the Attach holds an object URL under the org's own S3 /
|
||||
* SeaweedFS prefix. Honest empty state; never a fabricated tile.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Image as ImageIcon, Plus } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import type { DocType, FrameworkDoc } from '~/lib/framework/types'
|
||||
import { mediaFileField, titleOf } from '~/lib/framework/fields'
|
||||
|
||||
export interface MediaGridProps {
|
||||
dt: DocType
|
||||
docs: FrameworkDoc[]
|
||||
onOpen: (name: string) => void
|
||||
onCreate: () => void
|
||||
toolbarExtra?: ReactNode
|
||||
}
|
||||
|
||||
const looksLikeImage = (url: string): boolean =>
|
||||
/\.(png|jpe?g|gif|webp|svg|avif)(\?|#|$)/i.test(url) || url.startsWith('data:image/')
|
||||
|
||||
export function MediaGrid({ dt, docs, onOpen, onCreate, toolbarExtra }: MediaGridProps) {
|
||||
const fileField = mediaFileField(dt)
|
||||
if (docs.length === 0) {
|
||||
return (
|
||||
<YStack gap="$3">
|
||||
<XStack justify="flex-end" gap="$2">{toolbarExtra}</XStack>
|
||||
<EmptyState
|
||||
icon={ImageIcon}
|
||||
title="No media yet"
|
||||
description="Add an image or file by URL (from your object storage). It becomes a Media document you can reference from any page or post."
|
||||
primary={{ label: 'Add media', onPress: onCreate }}
|
||||
/>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<YStack gap="$3">
|
||||
<XStack justify="flex-end" gap="$2" items="center">
|
||||
{toolbarExtra}
|
||||
<PrimaryButton size="$2" icon={<Plus size={15} />} onPress={onCreate}>Add media</PrimaryButton>
|
||||
</XStack>
|
||||
<XStack gap="$3" flexWrap="wrap">
|
||||
{docs.map((d) => {
|
||||
const url = String(d[fileField] ?? '')
|
||||
const title = titleOf(d, dt)
|
||||
return (
|
||||
<YStack
|
||||
key={d.name}
|
||||
onPress={() => onOpen(String(d.name))}
|
||||
cursor="pointer"
|
||||
hoverStyle={{ borderColor: '$color8' }}
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
rounded="$4"
|
||||
width={200}
|
||||
overflow="hidden"
|
||||
>
|
||||
<YStack height={130} bg="$color3" items="center" justify="center" overflow="hidden">
|
||||
{url && looksLikeImage(url) ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={url} alt={title} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<ImageIcon size={28} />
|
||||
)}
|
||||
</YStack>
|
||||
<YStack p="$3" gap="$1">
|
||||
<Text fontSize="$3" fontWeight="700" numberOfLines={1}>{title}</Text>
|
||||
<Text fontSize="$1" color="$color10" numberOfLines={1}>{String(d.mime ?? (url || 'file'))}</Text>
|
||||
</YStack>
|
||||
</YStack>
|
||||
)
|
||||
})}
|
||||
</XStack>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
export default MediaGrid
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Shared, non-React data helpers for the generic DocType renderer. A relation
|
||||
* (Link) field is a picker over the TARGET doctype's records, so before a view
|
||||
* can render or edit one it must load those records (+ the target's schema for
|
||||
* the human label). This is the ONE place that does it, used by both the records
|
||||
* list and the record detail — no per-view duplication.
|
||||
*/
|
||||
import type { SelectOption, FieldDefinition } from '@hanzo/data'
|
||||
import type { DocType } from '~/lib/framework/types'
|
||||
import type { FrameworkClient } from '~/lib/framework/client'
|
||||
import { linkFields, titleOf } from '~/lib/framework/fields'
|
||||
|
||||
/** The picker options for every Link field of `dt`: value = target id, label = its title. */
|
||||
export async function loadLinkOptions(
|
||||
client: FrameworkClient,
|
||||
dt: DocType,
|
||||
): Promise<Record<string, SelectOption[]>> {
|
||||
const links = linkFields(dt)
|
||||
const out: Record<string, SelectOption[]> = {}
|
||||
await Promise.all(
|
||||
links.map(async (f) => {
|
||||
const target = f.options
|
||||
if (!target) return
|
||||
// A target the caller can't read (403) → no options → the field falls back
|
||||
// to a raw-id input (honest, never a fabricated candidate list).
|
||||
const [tdt, docs] = await Promise.all([
|
||||
client.doctypes.get(target).catch(() => null),
|
||||
client.records.list(target, { limit: 200 }).catch(() => []),
|
||||
])
|
||||
out[f.fieldname] = docs.map((d) => ({
|
||||
value: String(d.name),
|
||||
label: tdt ? titleOf(d, tdt) : String(d.name),
|
||||
}))
|
||||
}),
|
||||
)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The `fieldOptions` callback @hanzo/data's views take: relation fields get their
|
||||
* loaded target records; select fields get the choices declared in metadata (so
|
||||
* the table cell and the form input agree). Everything else → undefined.
|
||||
*/
|
||||
export function makeFieldOptions(
|
||||
linkOptions: Record<string, SelectOption[]>,
|
||||
): (field: FieldDefinition) => SelectOption[] | undefined {
|
||||
return (field) => {
|
||||
if (field.type === 'relation') return linkOptions[field.name] ?? []
|
||||
if (field.type === 'select' || field.type === 'multiSelect') {
|
||||
return (field.metadata as { options?: SelectOption[] } | undefined)?.options
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* ProductErrorBoundary — the ONE boundary that keeps a single product module's
|
||||
* client-render throw from white-screening the whole console.
|
||||
*
|
||||
* Product modules mount client-only under the catch-all route, so before this
|
||||
* boundary a throw in any module (a data edge case, a `ChunkLoadError` from a
|
||||
* rolling deploy, a future regression) bubbled to Next's root fallback and
|
||||
* replaced everything with "Application error: a client-side exception has
|
||||
* occurred" — the shell, the nav, and the URL gone. Wrapped, the shell and nav
|
||||
* survive and only the content region shows an honest, retryable card. One
|
||||
* boundary in the catch-all closes the class for every product route (DRY).
|
||||
*
|
||||
* Decisions live in the pure, unit-tested `boundary-logic`; this class only wires
|
||||
* them to React lifecycle + the browser (reload/sessionStorage).
|
||||
*/
|
||||
import { Component, type ReactNode } from 'react'
|
||||
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { RefreshCw, TriangleAlert } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { isChunkLoadError, isNextControlFlowError, shouldReloadForChunk } from './boundary-logic'
|
||||
|
||||
/** epoch-ms of the last chunk-recovery reload — bounds it to once per window. */
|
||||
const RELOAD_AT_KEY = 'hz.console.chunkReloadAt'
|
||||
|
||||
function reloadPage(): void {
|
||||
if (typeof window !== 'undefined') window.location.reload()
|
||||
}
|
||||
|
||||
type Props = {
|
||||
children: ReactNode
|
||||
/** Changing this (the route slug) clears a prior crash on navigation. */
|
||||
resetKey?: string
|
||||
}
|
||||
type State = { error: Error | null }
|
||||
|
||||
export class ProductErrorBoundary extends Component<Props, State> {
|
||||
state: State = { error: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error): void {
|
||||
// Control flow (notFound/redirect) is re-thrown in render — never a "crash".
|
||||
if (isNextControlFlowError(error)) return
|
||||
// Surface for triage; never swallowed.
|
||||
console.error('[console] product route crashed:', error)
|
||||
if (isChunkLoadError(error)) this.recoverFromChunkSkew()
|
||||
}
|
||||
|
||||
componentDidUpdate(prev: Props): void {
|
||||
// A fresh route mounts fresh — clear a prior route's error on navigation.
|
||||
if (this.state.error && prev.resetKey !== this.props.resetKey) this.reset()
|
||||
}
|
||||
|
||||
private reset = (): void => this.setState({ error: null })
|
||||
|
||||
/** Reload once per window to pick up the shipped chunks; never loop. */
|
||||
private recoverFromChunkSkew(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(RELOAD_AT_KEY)
|
||||
const last = raw ? Number(raw) : null
|
||||
if (shouldReloadForChunk(Date.now(), last)) {
|
||||
window.sessionStorage.setItem(RELOAD_AT_KEY, String(Date.now()))
|
||||
window.location.reload()
|
||||
}
|
||||
} catch {
|
||||
// sessionStorage blocked (private mode) — fall through to the manual card.
|
||||
}
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
const { error } = this.state
|
||||
if (!error) return this.props.children
|
||||
// notFound()/redirect() are Next control flow — re-throw so Next handles them.
|
||||
if (isNextControlFlowError(error)) throw error
|
||||
|
||||
const chunk = isChunkLoadError(error)
|
||||
return (
|
||||
<YStack p="$4">
|
||||
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$3" maxWidth={640} bg="$color1">
|
||||
<XStack gap="$2" items="center">
|
||||
<TriangleAlert size={16} />
|
||||
<Text fontSize="$4" fontWeight="700">
|
||||
{chunk ? 'Updating to the latest version' : 'This page hit an unexpected error'}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$3" color="$color11">
|
||||
{chunk
|
||||
? 'A newer version of the console just shipped. Reload to load the latest — the rest of the console keeps working.'
|
||||
: 'The rest of the console still works. Try again, or reload the page. If it keeps happening, this surface will be looked at.'}
|
||||
</Text>
|
||||
<XStack gap="$2">
|
||||
{!chunk ? (
|
||||
<Button size="$2" icon={<RefreshCw size={14} />} onPress={this.reset}>
|
||||
Try again
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="$2" chromeless={!chunk} icon={<RefreshCw size={14} />} onPress={reloadPage}>
|
||||
Reload
|
||||
</Button>
|
||||
</XStack>
|
||||
</Card>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { isChunkLoadError, isNextControlFlowError, shouldReloadForChunk } from './boundary-logic'
|
||||
|
||||
/** A thrown value shaped like a webpack ChunkLoadError. */
|
||||
function chunkError(message: string): Error {
|
||||
const e = new Error(message)
|
||||
e.name = 'ChunkLoadError'
|
||||
return e
|
||||
}
|
||||
|
||||
/** A thrown value shaped like a Next control-flow error (notFound/redirect). */
|
||||
function nextError(digest: string): Error {
|
||||
const e = new Error('control-flow') as Error & { digest: string }
|
||||
e.digest = digest
|
||||
return e
|
||||
}
|
||||
|
||||
describe('isChunkLoadError', () => {
|
||||
it('matches by error name', () => {
|
||||
expect(isChunkLoadError(chunkError('boom'))).toBe(true)
|
||||
})
|
||||
|
||||
it('matches the webpack "Loading chunk N failed" message', () => {
|
||||
expect(isChunkLoadError(new Error('Loading chunk 4821 failed.\n(error: https://x/_next/static/chunks/4821.js)'))).toBe(true)
|
||||
expect(isChunkLoadError(new Error('Loading CSS chunk app-layout failed'))).toBe(true)
|
||||
})
|
||||
|
||||
it('matches the Next/Turbopack dynamic-import failure messages', () => {
|
||||
expect(isChunkLoadError(new Error('Failed to fetch dynamically imported module: https://x/_next/static/chunks/playground.js'))).toBe(true)
|
||||
expect(isChunkLoadError(new Error('error loading dynamically imported module'))).toBe(true)
|
||||
expect(isChunkLoadError(new Error('Importing a module script failed.'))).toBe(true)
|
||||
})
|
||||
|
||||
it('matches the stale-chunk HTML-as-JS signature (a 404 chunk served the app shell)', () => {
|
||||
// The most common "crash on refresh during a deploy" — the chunk URL 404s and
|
||||
// falls through to the SPA HTML, which the browser then parses as JS.
|
||||
expect(isChunkLoadError(new SyntaxError("Unexpected token '<'"))).toBe(true)
|
||||
expect(isChunkLoadError(new SyntaxError('expected expression, got \'<\''))).toBe(true)
|
||||
expect(isChunkLoadError(new Error('Unexpected token < in JSON at position 0'))).toBe(true)
|
||||
expect(isChunkLoadError(new SyntaxError('Unexpected token \'<\', "<!DOCTYPE "... is not valid JSON'))).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for ordinary render errors', () => {
|
||||
expect(isChunkLoadError(new Error("Cannot read properties of undefined (reading 'map')"))).toBe(false)
|
||||
expect(isChunkLoadError(new TypeError('x is not a function'))).toBe(false)
|
||||
expect(isChunkLoadError(null)).toBe(false)
|
||||
expect(isChunkLoadError(undefined)).toBe(false)
|
||||
expect(isChunkLoadError('some string')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isNextControlFlowError', () => {
|
||||
it('matches notFound() and redirect() digests (must be re-thrown)', () => {
|
||||
expect(isNextControlFlowError(nextError('NEXT_NOT_FOUND'))).toBe(true)
|
||||
expect(isNextControlFlowError(nextError('NEXT_REDIRECT;replace;/signin;307;'))).toBe(true)
|
||||
expect(isNextControlFlowError(nextError('NEXT_HTTP_ERROR_FALLBACK;404'))).toBe(true)
|
||||
})
|
||||
|
||||
it('matches the useSearchParams CSR bailout', () => {
|
||||
expect(isNextControlFlowError(nextError('BAILOUT_TO_CLIENT_SIDE_RENDERING'))).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for real crashes (they should render the fallback, not re-throw)', () => {
|
||||
expect(isNextControlFlowError(new Error('Cannot read properties of undefined'))).toBe(false)
|
||||
expect(isNextControlFlowError(chunkError('Loading chunk 1 failed'))).toBe(false)
|
||||
expect(isNextControlFlowError({ digest: 12345 })).toBe(false)
|
||||
expect(isNextControlFlowError(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldReloadForChunk', () => {
|
||||
it('reloads on the first chunk error (no prior reload)', () => {
|
||||
expect(shouldReloadForChunk(1_000_000, null)).toBe(true)
|
||||
})
|
||||
|
||||
it('does NOT reload again within the window (no reload loop)', () => {
|
||||
expect(shouldReloadForChunk(1_000_000, 995_000)).toBe(false) // 5s ago, window 15s
|
||||
})
|
||||
|
||||
it('reloads again once the window has passed', () => {
|
||||
expect(shouldReloadForChunk(1_000_000, 980_000)).toBe(true) // 20s ago
|
||||
expect(shouldReloadForChunk(1_000_000, 985_000, 15_000)).toBe(true) // exactly 15s
|
||||
})
|
||||
|
||||
it('treats a non-finite/absent last-reload as safe to reload', () => {
|
||||
expect(shouldReloadForChunk(1_000_000, Number.NaN)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Pure decisions for the product-route error boundary.
|
||||
*
|
||||
* A `'use client'` product module mounts CLIENT-ONLY under the catch-all route
|
||||
* (the authed dashboard renders a loader during SSR, so the module never renders
|
||||
* server-side — verified: the /playground server HTML carries no module markup).
|
||||
* That means a throw during a module's first client render has no error boundary
|
||||
* to catch it, so it bubbles to Next's built-in root fallback and white-screens
|
||||
* the whole console with "Application error: a client-side exception has
|
||||
* occurred" — losing the shell, nav, and the URL. `ProductErrorBoundary` is the
|
||||
* one boundary that closes that class for every product route; this module is its
|
||||
* decision logic, kept pure so it is unit-tested without a browser.
|
||||
*
|
||||
* Three orthogonal decisions:
|
||||
* - `isChunkLoadError` — a dynamic-import/chunk fetch failed. On a rolling
|
||||
* deploy the just-served HTML references new content-hashed chunks; a refresh
|
||||
* that lands on the other replica (or a stale CDN edge) can 404 a chunk. This
|
||||
* is the most likely real cause of a "direct-load / refresh only" crash.
|
||||
* - `isNextControlFlowError` — `notFound()` / `redirect()` (and the CSR bailout)
|
||||
* throw a tagged error as CONTROL FLOW. A custom boundary MUST re-throw these
|
||||
* so Next renders the 404/redirect instead of swallowing them into a fallback.
|
||||
* - `shouldReloadForChunk` — recover from chunk skew by reloading ONCE per short
|
||||
* window, never looping when a chunk is genuinely gone.
|
||||
*/
|
||||
|
||||
/**
|
||||
* True for a webpack/Next dynamic chunk (JS or CSS) load failure — including the
|
||||
* stale-deploy case where a 404'd chunk URL falls through to the SPA and the
|
||||
* browser parses HTML as JS ("Unexpected token '<'" / module-script failed). In a
|
||||
* PRODUCTION build user code never throws "Unexpected token '<'" at runtime (that
|
||||
* is a build-time syntax error), so at runtime it is a chunk skew — recover, don't
|
||||
* crash. Kept in sync with `ChunkGuard`'s window-level pattern (one definition of
|
||||
* "this is a chunk skew" for both the boundary and the global listeners).
|
||||
*/
|
||||
export function isChunkLoadError(e: unknown): boolean {
|
||||
if (!e) return false
|
||||
const name = typeof e === 'object' && e !== null && 'name' in e ? String((e as { name?: unknown }).name) : ''
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
return (
|
||||
name === 'ChunkLoadError' ||
|
||||
/ChunkLoadError/i.test(msg) ||
|
||||
/Loading (?:CSS )?chunk [\w./-]+ failed/i.test(msg) ||
|
||||
/(?:Failed to fetch|error loading|Importing a module script failed).*dynamically imported module/i.test(msg) ||
|
||||
/Failed to fetch dynamically imported module/i.test(msg) ||
|
||||
/Importing a module script failed/i.test(msg) ||
|
||||
// Stale chunk URL served the HTML shell → parsed as JS.
|
||||
/Unexpected token '<'|expected expression, got '<'|Unexpected token <|<!DOCTYPE/i.test(msg)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* True for Next.js control-flow throws (`notFound()`, `redirect()`, the
|
||||
* `useSearchParams` CSR bailout). These are tagged with a `digest` and MUST be
|
||||
* re-thrown by a custom error boundary so Next handles them, never rendered as a
|
||||
* crash.
|
||||
*/
|
||||
export function isNextControlFlowError(e: unknown): boolean {
|
||||
const digest = typeof e === 'object' && e !== null && 'digest' in e ? (e as { digest?: unknown }).digest : undefined
|
||||
if (typeof digest !== 'string') return false
|
||||
return digest.startsWith('NEXT_') || digest === 'BAILOUT_TO_CLIENT_SIDE_RENDERING'
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload at most once per `windowMs` to recover from a chunk skew. `lastReloadAt`
|
||||
* is the epoch-ms of the last recovery reload (null when we have not reloaded);
|
||||
* returns true when a fresh reload is safe (no reload in the window → no loop).
|
||||
*/
|
||||
export function shouldReloadForChunk(now: number, lastReloadAt: number | null, windowMs = 15_000): boolean {
|
||||
if (lastReloadAt == null || !Number.isFinite(lastReloadAt)) return true
|
||||
return now - lastReloadAt >= windowMs
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Accessibility — a Wix-style WCAG checker for the site you are building. It runs
|
||||
* Deque's axe-core against the CURRENT page, entirely in the browser: nothing is
|
||||
* sent to a server, no page content leaves the tab. The engine is loaded on demand
|
||||
* (`import('axe-core')` → its own chunk), so it never weighs down the main bundle;
|
||||
* a scan is user-triggered (never on every render) and asks axe for violations only
|
||||
* (`resultTypes: ['violations']`) to keep it fast on large DOMs.
|
||||
*
|
||||
* All processing is the pure, unit-tested `~/lib/a11y/scan` (sort/summarize/WCAG
|
||||
* labels) — this file is only the panel: a Scan button, per-severity count cards,
|
||||
* and an honest table (idle → scanning → results/empty, or a plain error card).
|
||||
*/
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Accessibility, ExternalLink } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { toIssues, summarize, IMPACTS, type A11yIssue, type A11ySummary, type Impact } from '~/lib/a11y/scan'
|
||||
|
||||
type State =
|
||||
| { phase: 'idle' }
|
||||
| { phase: 'scanning' }
|
||||
| { phase: 'done'; issues: A11yIssue[]; summary: A11ySummary }
|
||||
| { phase: 'error'; message: string }
|
||||
|
||||
const IMPACT_COLOR = {
|
||||
critical: '$red10',
|
||||
serious: '$orange10',
|
||||
moderate: '$yellow10',
|
||||
minor: '$color11',
|
||||
} as const satisfies Record<Impact, string>
|
||||
const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
|
||||
|
||||
/** The one axe-core entrypoint we use — declared locally so the dynamic import interop stays typed. */
|
||||
type AxeModule = { run: (ctx: Document, opts?: { resultTypes?: string[] }) => Promise<{ violations: unknown[] }> }
|
||||
|
||||
export function AccessibilityModule(_props: { params: Record<string, string> }) {
|
||||
const [state, setState] = useState<State>({ phase: 'idle' })
|
||||
|
||||
const scan = useCallback(async () => {
|
||||
if (typeof document === 'undefined') return
|
||||
setState({ phase: 'scanning' })
|
||||
try {
|
||||
const mod = (await import('axe-core')) as unknown as { default?: AxeModule } & Partial<AxeModule>
|
||||
const axe = mod.default ?? (mod as AxeModule)
|
||||
if (typeof axe.run !== 'function') throw new Error('axe-core failed to load')
|
||||
const { violations } = await axe.run(document, { resultTypes: ['violations'] })
|
||||
const issues = toIssues(violations)
|
||||
setState({ phase: 'done', issues, summary: summarize(issues) })
|
||||
} catch (e) {
|
||||
setState({ phase: 'error', message: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
}, [])
|
||||
|
||||
const columns: Column<A11yIssue>[] = [
|
||||
{
|
||||
key: 'impact',
|
||||
header: 'Impact',
|
||||
width: 100,
|
||||
render: (r) => (
|
||||
<Text fontSize="$3" fontWeight="700" color={IMPACT_COLOR[r.impact]}>
|
||||
{cap(r.impact)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'help',
|
||||
header: 'Issue',
|
||||
render: (r) => (
|
||||
<YStack>
|
||||
<Text fontSize="$3" fontWeight="600">{r.help}</Text>
|
||||
<Text fontSize="$1" color="$color10">{r.id}</Text>
|
||||
</YStack>
|
||||
),
|
||||
},
|
||||
{ key: 'wcag', header: 'WCAG', width: 160, render: (r) => <Text fontSize="$2" color="$color11">{r.wcag.join(' · ') || '—'}</Text> },
|
||||
{ key: 'nodes', header: 'Elements', width: 90, render: (r) => <Text fontSize="$3" color="$color11">{r.nodes}</Text> },
|
||||
{ key: 'target', header: 'First match', render: (r) => <Text fontSize="$2" color="$color10">{r.target || '—'}</Text> },
|
||||
{
|
||||
key: 'learn',
|
||||
header: '',
|
||||
width: 56,
|
||||
render: (r) =>
|
||||
r.helpUrl ? (
|
||||
<Button
|
||||
size="$2"
|
||||
chromeless
|
||||
aria-label={`How to fix: ${r.help}`}
|
||||
icon={<ExternalLink size={14} />}
|
||||
onPress={() => {
|
||||
if (typeof window !== 'undefined') window.open(r.helpUrl, '_blank', 'noopener,noreferrer')
|
||||
}}
|
||||
/>
|
||||
) : null,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<YStack gap="$4" p="$4">
|
||||
<PageHeader
|
||||
title="Accessibility"
|
||||
subtitle="Scan the current page for WCAG issues — axe-core runs in your browser, nothing leaves the page."
|
||||
actions={
|
||||
<Button
|
||||
size="$3"
|
||||
theme="light"
|
||||
icon={<Accessibility size={15} />}
|
||||
disabled={state.phase === 'scanning'}
|
||||
onPress={() => void scan()}
|
||||
>
|
||||
{state.phase === 'scanning' ? 'Scanning…' : 'Scan this page'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{state.phase === 'idle' ? (
|
||||
<Card p="$4" borderWidth={1} borderColor="$borderColor">
|
||||
<Text color="$color11">
|
||||
Run a scan to check this page against the WCAG 2 A/AA rule set. The engine loads on demand and runs entirely
|
||||
in your browser — no page content is sent anywhere.
|
||||
</Text>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{state.phase === 'error' ? (
|
||||
<Card p="$4" gap="$2" borderWidth={1} borderColor="$borderColor">
|
||||
<Text color="$red10" fontWeight="600">Scan failed</Text>
|
||||
<Text fontSize="$2" color="$color11">{state.message}</Text>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{state.phase === 'done' ? (
|
||||
<>
|
||||
<XStack gap="$3" flexWrap="wrap">
|
||||
{IMPACTS.map((imp) => (
|
||||
<Card key={imp} p="$3" minWidth={150} borderWidth={1} borderColor="$borderColor">
|
||||
<Text fontSize="$2" color="$color10">{cap(imp)}</Text>
|
||||
<Text fontSize="$7" fontWeight="800" color={IMPACT_COLOR[imp]}>{state.summary.byImpact[imp]}</Text>
|
||||
</Card>
|
||||
))}
|
||||
</XStack>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={state.issues}
|
||||
rowKey={(r) => r.id}
|
||||
empty="No accessibility violations found on this page. Nice work."
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Admin-managed notice — the graceful, honest state a CUSTOMER sees if they reach
|
||||
* an admin-only surface directly (a deep link or a stale bookmark). Instead of the
|
||||
* module's hostile 403 "Not authorized" red error, it explains the surface is
|
||||
* managed by the platform administrator, not per organization, and points the way
|
||||
* back. Access is always enforced server-side; this is only the friendly UI gate.
|
||||
*/
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
import type { CatalogEntry, ProductSubpage } from '~/lib/products/registry'
|
||||
import { config } from '~/config'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { FadeIn } from '~/components/ui/FadeIn'
|
||||
|
||||
export function AdminManagedNotice({
|
||||
entry,
|
||||
subpage,
|
||||
}: {
|
||||
entry: CatalogEntry
|
||||
subpage?: ProductSubpage
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const what = subpage ? `${entry.label} · ${subpage.label}` : entry.label
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={what} subtitle={entry.description} />
|
||||
<FadeIn style={{ width: '100%' }}>
|
||||
<EmptyState
|
||||
icon={entry.icon}
|
||||
title="Managed by Hanzo"
|
||||
description={`${what} is a platform-managed surface — it's configured by your ${config.brandName} administrator, not per organization. You have full access to everything your organization owns.`}
|
||||
bullets={[
|
||||
'Nothing is broken — this is intentionally managed at the platform level.',
|
||||
'Need a change here? Ask your platform administrator.',
|
||||
]}
|
||||
primary={{ label: 'Back to Overview', onPress: () => router.push('/') }}
|
||||
secondary={{ label: 'Your API keys', onPress: () => router.push('/api-keys') }}
|
||||
/>
|
||||
</FadeIn>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Identity & access admin — Organizations, Users, Roles (RBAC), and the Audit
|
||||
* trail, served by Hanzo IAM under `/v1/iam/*` (HIP-0111). ONE generic list view
|
||||
* (`AdminListView`) drives every surface over the shared cookie `/v1` envelope
|
||||
* client; the tabbed `IamModule` and the standalone `AuditModule` just supply a
|
||||
* fetcher + columns. No data is ever fabricated: loading, not-available (404),
|
||||
* access-required (401/403/unauthorized), error, and empty are all honest states.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { RefreshCw, ExternalLink, Plus, Trash2, ShieldCheck, ShieldOff } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { ApiError } from '~/lib/api'
|
||||
import {
|
||||
IamAdminApi,
|
||||
type Paged,
|
||||
type Organization,
|
||||
type IamUser,
|
||||
type Role,
|
||||
type AuditRecord,
|
||||
} from '~/lib/api/admin'
|
||||
import { config } from '~/config'
|
||||
import { currentOrg } from '~/lib/org-scope'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { FieldRow, FieldText } from '~/components/ui/Field'
|
||||
import { ErrorState, asApiError, isForbidden, OperatorAccessRequired, type HonestCopy } from '~/components/ui/States'
|
||||
|
||||
/** IAM-specific guidance for the honest 404 / unauthorized states. */
|
||||
const IAM_COPY: HonestCopy = {
|
||||
notFound:
|
||||
'The IAM admin API (/v1/iam) is not routed on this host yet. It appears automatically once the deployment proxies /v1/iam to Hanzo IAM.',
|
||||
unauthorized:
|
||||
'This view requires an admin session. Access is enforced server-side by IAM — sign in with an account that has the right role.',
|
||||
}
|
||||
|
||||
const fmtDate = (v?: string): string => {
|
||||
if (!v) return '—'
|
||||
const d = new Date(v)
|
||||
return Number.isNaN(d.getTime()) ? v : d.toLocaleString()
|
||||
}
|
||||
|
||||
const dim = (v?: string | number | null) =>
|
||||
v === undefined || v === null || v === '' ? (
|
||||
<Text fontSize="$3" color="$color10">
|
||||
—
|
||||
</Text>
|
||||
) : (
|
||||
<Text fontSize="$3" color="$color11" numberOfLines={1}>
|
||||
{String(v)}
|
||||
</Text>
|
||||
)
|
||||
|
||||
type LoadState<T> = { phase: 'loading' } | { phase: 'error'; err: ApiError } | { phase: 'ready'; rows: T[] }
|
||||
|
||||
/** Generic admin list: runs a fetcher, renders an honest state, then a table. */
|
||||
function AdminListView<T>({
|
||||
fetcher,
|
||||
columns,
|
||||
rowKey,
|
||||
empty,
|
||||
}: {
|
||||
fetcher: () => Promise<Paged<T>>
|
||||
columns: Column<T>[]
|
||||
rowKey: (r: T) => string
|
||||
empty: string
|
||||
}) {
|
||||
const [state, setState] = useState<LoadState<T>>({ phase: 'loading' })
|
||||
|
||||
const run = useCallback(() => {
|
||||
setState({ phase: 'loading' })
|
||||
fetcher()
|
||||
.then((p) => setState({ phase: 'ready', rows: p.rows ?? [] }))
|
||||
.catch((e) => setState({ phase: 'error', err: asApiError(e) }))
|
||||
}, [fetcher])
|
||||
|
||||
useEffect(() => {
|
||||
run()
|
||||
}, [run])
|
||||
|
||||
return (
|
||||
<>
|
||||
<XStack justify="flex-end">
|
||||
<Button size="$2" icon={<RefreshCw size={15} />} onPress={run}>
|
||||
Refresh
|
||||
</Button>
|
||||
</XStack>
|
||||
{state.phase === 'error' ? (
|
||||
<ErrorState err={state.err} onRetry={run} copy={IAM_COPY} />
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={state.phase === 'ready' ? state.rows : []}
|
||||
loading={state.phase === 'loading'}
|
||||
rowKey={rowKey}
|
||||
empty={empty}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type Tone = { tone: 'ok' | 'err'; text: string }
|
||||
|
||||
/**
|
||||
* Users with full CRUD — create, promote/demote admin, and delete — over the
|
||||
* ready IamAdminApi mutations (add/update/delete-user) through the server-gated
|
||||
* /admin/iam proxy, scoped to `owner`. This is the casdoor user surface, in
|
||||
* console: no link-out for the common lifecycle. Honest states throughout.
|
||||
*/
|
||||
function UsersAdminView({ owner }: { owner: string }) {
|
||||
const [state, setState] = useState<LoadState<IamUser>>({ phase: 'loading' })
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [msg, setMsg] = useState<Tone | null>(null)
|
||||
|
||||
const run = useCallback(() => {
|
||||
setState({ phase: 'loading' })
|
||||
IamAdminApi.users(owner)
|
||||
.then((p) => setState({ phase: 'ready', rows: p.rows ?? [] }))
|
||||
.catch((e) => setState({ phase: 'error', err: asApiError(e) }))
|
||||
}, [owner])
|
||||
|
||||
useEffect(() => {
|
||||
run()
|
||||
}, [run])
|
||||
|
||||
const create = useCallback(async () => {
|
||||
if (!name.trim() || !password) {
|
||||
setMsg({ tone: 'err', text: 'Name and password are required.' })
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setMsg(null)
|
||||
try {
|
||||
await IamAdminApi.addUser({ owner, name: name.trim(), email: email.trim(), password } as IamUser)
|
||||
setMsg({ tone: 'ok', text: `Created ${owner}/${name.trim()}.` })
|
||||
setName('')
|
||||
setEmail('')
|
||||
setPassword('')
|
||||
run()
|
||||
} catch (e) {
|
||||
setMsg({ tone: 'err', text: asApiError(e).message })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [owner, name, email, password, run])
|
||||
|
||||
const toggleAdmin = useCallback(
|
||||
async (u: IamUser) => {
|
||||
const k = `${u.owner}/${u.name}`
|
||||
setBusy(k)
|
||||
try {
|
||||
await IamAdminApi.updateUser(k, { ...u, isAdmin: !u.isAdmin })
|
||||
run()
|
||||
} catch (e) {
|
||||
setState({ phase: 'error', err: asApiError(e) })
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
},
|
||||
[run],
|
||||
)
|
||||
|
||||
const remove = useCallback(
|
||||
async (u: IamUser) => {
|
||||
if (typeof window !== 'undefined' && !window.confirm(`Delete user ${u.owner}/${u.name}? This cannot be undone.`)) return
|
||||
const k = `${u.owner}/${u.name}`
|
||||
setBusy(k)
|
||||
try {
|
||||
await IamAdminApi.deleteUser(u)
|
||||
run()
|
||||
} catch (e) {
|
||||
setState({ phase: 'error', err: asApiError(e) })
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
},
|
||||
[run],
|
||||
)
|
||||
|
||||
const crudColumns: Column<IamUser>[] = [
|
||||
...userColumns,
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
width: 120,
|
||||
render: (u) => {
|
||||
const k = `${u.owner}/${u.name}`
|
||||
return (
|
||||
<XStack gap="$1.5">
|
||||
<Button
|
||||
size="$1"
|
||||
chromeless
|
||||
icon={u.isAdmin ? <ShieldOff size={14} /> : <ShieldCheck size={14} />}
|
||||
disabled={busy === k}
|
||||
onPress={() => void toggleAdmin(u)}
|
||||
/>
|
||||
<Button size="$1" chromeless icon={<Trash2 size={14} />} disabled={busy === k} onPress={() => void remove(u)} />
|
||||
</XStack>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<XStack justify="flex-end" gap="$2">
|
||||
<Button size="$2" icon={<RefreshCw size={15} />} onPress={run}>Refresh</Button>
|
||||
<Button size="$2" icon={<Plus size={15} />} onPress={() => setShowForm((v) => !v)}>New user</Button>
|
||||
</XStack>
|
||||
|
||||
{showForm && (
|
||||
<Card p="$3.5" gap="$2.5" borderWidth={1} borderColor="$borderColor" maxWidth={820}>
|
||||
<Text fontSize="$4" fontWeight="700">New user in {owner}</Text>
|
||||
<FieldRow label="Name"><FieldText value={name} onChange={setName} placeholder="jdoe" disabled={saving} /></FieldRow>
|
||||
<FieldRow label="Email"><FieldText value={email} onChange={setEmail} placeholder="jdoe@hanzo.ai" disabled={saving} /></FieldRow>
|
||||
<FieldRow label="Password"><FieldText value={password} onChange={setPassword} placeholder="initial password" secure disabled={saving} /></FieldRow>
|
||||
<XStack gap="$2" items="center">
|
||||
<Button self="flex-start" icon={<Plus size={15} />} disabled={saving} onPress={() => void create()}>
|
||||
{saving ? 'Creating…' : 'Create user'}
|
||||
</Button>
|
||||
{msg && <Text fontSize="$2" color={msg.tone === 'ok' ? '$green10' : '$red10'}>{msg.text}</Text>}
|
||||
</XStack>
|
||||
<Text fontSize="$2" color="$color10">Password is hashed by IAM (argon2id). The shield toggles global-admin.</Text>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{state.phase === 'error' ? (
|
||||
isForbidden(state.err) ? (
|
||||
<OperatorAccessRequired />
|
||||
) : (
|
||||
<ErrorState err={state.err} onRetry={run} copy={IAM_COPY} />
|
||||
)
|
||||
) : (
|
||||
<DataTable
|
||||
columns={crudColumns}
|
||||
rows={state.phase === 'ready' ? state.rows : []}
|
||||
loading={state.phase === 'loading'}
|
||||
rowKey={(u) => `${u.owner}/${u.name}`}
|
||||
empty="No users in this organization."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Roles with full CRUD — create and delete RBAC roles over IamAdminApi
|
||||
* (add/update/delete-role → /v1/iam/*), scoped to `owner`. Mirrors
|
||||
* UsersAdminView; membership editing stays in the full IAM app.
|
||||
*/
|
||||
function RolesAdminView({ owner }: { owner: string }) {
|
||||
const [state, setState] = useState<LoadState<Role>>({ phase: 'loading' })
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [msg, setMsg] = useState<Tone | null>(null)
|
||||
|
||||
const run = useCallback(() => {
|
||||
setState({ phase: 'loading' })
|
||||
IamAdminApi.roles(owner)
|
||||
.then((p) => setState({ phase: 'ready', rows: p.rows ?? [] }))
|
||||
.catch((e) => setState({ phase: 'error', err: asApiError(e) }))
|
||||
}, [owner])
|
||||
|
||||
useEffect(() => {
|
||||
run()
|
||||
}, [run])
|
||||
|
||||
const create = useCallback(async () => {
|
||||
if (!name.trim()) {
|
||||
setMsg({ tone: 'err', text: 'Name is required.' })
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setMsg(null)
|
||||
try {
|
||||
await IamAdminApi.addRole({ owner, name: name.trim(), displayName: displayName.trim() || name.trim(), isEnabled: true } as Role)
|
||||
setMsg({ tone: 'ok', text: `Created role ${owner}/${name.trim()}.` })
|
||||
setName('')
|
||||
setDisplayName('')
|
||||
run()
|
||||
} catch (e) {
|
||||
setMsg({ tone: 'err', text: asApiError(e).message })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [owner, name, displayName, run])
|
||||
|
||||
const remove = useCallback(
|
||||
async (r: Role) => {
|
||||
if (typeof window !== 'undefined' && !window.confirm(`Delete role ${r.owner}/${r.name}? This cannot be undone.`)) return
|
||||
const k = `${r.owner}/${r.name}`
|
||||
setBusy(k)
|
||||
try {
|
||||
await IamAdminApi.deleteRole(r)
|
||||
run()
|
||||
} catch (e) {
|
||||
setState({ phase: 'error', err: asApiError(e) })
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
},
|
||||
[run],
|
||||
)
|
||||
|
||||
const crudColumns: Column<Role>[] = [
|
||||
...roleColumns,
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
width: 60,
|
||||
render: (r) => {
|
||||
const k = `${r.owner}/${r.name}`
|
||||
return <Button size="$1" chromeless icon={<Trash2 size={14} />} disabled={busy === k} onPress={() => void remove(r)} />
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<XStack justify="flex-end" gap="$2">
|
||||
<Button size="$2" icon={<RefreshCw size={15} />} onPress={run}>Refresh</Button>
|
||||
<Button size="$2" icon={<Plus size={15} />} onPress={() => setShowForm((v) => !v)}>New role</Button>
|
||||
</XStack>
|
||||
|
||||
{showForm && (
|
||||
<Card p="$3.5" gap="$2.5" borderWidth={1} borderColor="$borderColor" maxWidth={820}>
|
||||
<Text fontSize="$4" fontWeight="700">New role in {owner}</Text>
|
||||
<FieldRow label="Name"><FieldText value={name} onChange={setName} placeholder="deployers" disabled={saving} /></FieldRow>
|
||||
<FieldRow label="Display name"><FieldText value={displayName} onChange={setDisplayName} placeholder="Deployers" disabled={saving} /></FieldRow>
|
||||
<XStack gap="$2" items="center">
|
||||
<Button self="flex-start" icon={<Plus size={15} />} disabled={saving} onPress={() => void create()}>
|
||||
{saving ? 'Creating…' : 'Create role'}
|
||||
</Button>
|
||||
{msg && <Text fontSize="$2" color={msg.tone === 'ok' ? '$green10' : '$red10'}>{msg.text}</Text>}
|
||||
</XStack>
|
||||
<Text fontSize="$2" color="$color10">Assign members in the full IAM app; this manages the role itself.</Text>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{state.phase === 'error' ? (
|
||||
isForbidden(state.err) ? (
|
||||
<OperatorAccessRequired />
|
||||
) : (
|
||||
<ErrorState err={state.err} onRetry={run} copy={IAM_COPY} />
|
||||
)
|
||||
) : (
|
||||
<DataTable
|
||||
columns={crudColumns}
|
||||
rows={state.phase === 'ready' ? state.rows : []}
|
||||
loading={state.phase === 'loading'}
|
||||
rowKey={(r) => `${r.owner}/${r.name}`}
|
||||
empty="No roles defined yet."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** A "manage in the full IAM app" deep-link, shown in the admin headers. */
|
||||
function ManageInIam() {
|
||||
return (
|
||||
<Button
|
||||
icon={<ExternalLink size={15} />}
|
||||
onPress={() => {
|
||||
if (typeof window !== 'undefined') window.open(config.iamUrl, '_blank', 'noopener')
|
||||
}}
|
||||
>
|
||||
IAM console
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Column sets ──────────────────────────────────────────────────────────────
|
||||
|
||||
const orgColumns: Column<Organization>[] = [
|
||||
{ key: 'name', header: 'Name', render: (o) => <Text fontSize="$3" fontWeight="600">{o.name}</Text> },
|
||||
{ key: 'displayName', header: 'Display name', render: (o) => dim(o.displayName) },
|
||||
{ key: 'websiteUrl', header: 'Website', width: 220, render: (o) => dim(o.websiteUrl) },
|
||||
{ key: 'createdTime', header: 'Created', width: 200, render: (o) => dim(fmtDate(o.createdTime)) },
|
||||
]
|
||||
|
||||
const userColumns: Column<IamUser>[] = [
|
||||
{ key: 'name', header: 'Name', render: (u) => <Text fontSize="$3" fontWeight="600">{u.name}</Text> },
|
||||
{ key: 'displayName', header: 'Display name', render: (u) => dim(u.displayName) },
|
||||
{ key: 'email', header: 'Email', render: (u) => dim(u.email) },
|
||||
{
|
||||
key: 'role',
|
||||
header: 'Role',
|
||||
width: 110,
|
||||
render: (u) => (
|
||||
<Text fontSize="$2" px="$2" py="$1" rounded="$2" bg={u.isAdmin ? '$color5' : '$color3'} color={u.isAdmin ? '$color12' : '$color11'}>
|
||||
{u.isAdmin ? 'admin' : u.type || 'member'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{ key: 'createdTime', header: 'Created', width: 200, render: (u) => dim(fmtDate(u.createdTime)) },
|
||||
]
|
||||
|
||||
const roleColumns: Column<Role>[] = [
|
||||
{ key: 'name', header: 'Name', render: (r) => <Text fontSize="$3" fontWeight="600">{r.name}</Text> },
|
||||
{ key: 'displayName', header: 'Display name', render: (r) => dim(r.displayName) },
|
||||
{ key: 'members', header: 'Members', width: 100, render: (r) => dim(r.users?.length ?? 0) },
|
||||
{
|
||||
key: 'isEnabled',
|
||||
header: 'Enabled',
|
||||
width: 100,
|
||||
render: (r) => dim(r.isEnabled === false ? 'no' : 'yes'),
|
||||
},
|
||||
{ key: 'createdTime', header: 'Created', width: 200, render: (r) => dim(fmtDate(r.createdTime)) },
|
||||
]
|
||||
|
||||
const recordColumns: Column<AuditRecord>[] = [
|
||||
{ key: 'createdTime', header: 'Time', width: 200, render: (r) => dim(fmtDate(r.createdTime)) },
|
||||
{ key: 'user', header: 'User', render: (r) => dim(r.user) },
|
||||
{ key: 'action', header: 'Action', width: 150, render: (r) => dim(r.action) },
|
||||
{
|
||||
key: 'request',
|
||||
header: 'Request',
|
||||
render: (r) => dim([r.method, r.requestUri].filter(Boolean).join(' ')),
|
||||
},
|
||||
{ key: 'clientIp', header: 'IP', width: 140, render: (r) => dim(r.clientIp) },
|
||||
]
|
||||
|
||||
// ── Modules ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const IAM_TABS = [
|
||||
{ id: '', label: 'Organizations' },
|
||||
{ id: 'users', label: 'Users' },
|
||||
{ id: 'roles', label: 'Roles' },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Identity & access — Organizations, Users, Roles in one tabbed module. The tab
|
||||
* is the route param (`/iam`, `/iam/users`, `/iam/roles`), mirroring the
|
||||
* ProvidersModule param branch.
|
||||
*/
|
||||
export function IamModule({ params }: { params: Record<string, string> }) {
|
||||
const router = useRouter()
|
||||
const tab = params.tab ?? ''
|
||||
// The ACTIVE org scope (the brand org, or the org a global admin switched to);
|
||||
// users/roles below are read for it. The org LIST itself is unscoped — a global
|
||||
// admin sees every org (what powers the switcher).
|
||||
const org = currentOrg()
|
||||
|
||||
const orgFetcher = useCallback(() => IamAdminApi.organizations(), [])
|
||||
|
||||
const view = useMemo(() => {
|
||||
if (tab === 'users') return <UsersAdminView key="users" owner={org} />
|
||||
if (tab === 'roles') return <RolesAdminView key="roles" owner={org} />
|
||||
return <AdminListView key="orgs" fetcher={orgFetcher} columns={orgColumns} rowKey={(o) => `${o.owner}/${o.name}`} empty="No organizations visible to this account." />
|
||||
}, [tab, org, orgFetcher])
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Identity & Access"
|
||||
subtitle="Organizations, users, and roles (RBAC), served by Hanzo IAM."
|
||||
actions={<ManageInIam />}
|
||||
/>
|
||||
<XStack gap="$1" flexWrap="wrap">
|
||||
{IAM_TABS.map((t) => (
|
||||
<Button
|
||||
key={t.id || 'orgs'}
|
||||
size="$2"
|
||||
bg={t.id === tab ? '$color5' : 'transparent'}
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
onPress={() => router.push(t.id ? `/iam/${t.id}` : '/iam')}
|
||||
>
|
||||
{t.label}
|
||||
</Button>
|
||||
))}
|
||||
</XStack>
|
||||
{view}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Audit — the identity & access event log (`/v1/iam/get-records`). */
|
||||
export function AuditModule(_props: { params: Record<string, string> }) {
|
||||
const org = currentOrg()
|
||||
const fetcher = useCallback(() => IamAdminApi.records(org), [org])
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Audit"
|
||||
subtitle="Identity and access events recorded by Hanzo IAM."
|
||||
actions={<ManageInIam />}
|
||||
/>
|
||||
<AdminListView
|
||||
fetcher={fetcher}
|
||||
columns={recordColumns}
|
||||
rowKey={(r) => String(r.id ?? `${r.createdTime ?? ''}-${r.user ?? ''}-${r.requestUri ?? ''}`)}
|
||||
empty="No audit events recorded yet."
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Agents — the autonomous-agent dashboard (AI / Automation). A rich, at-a-glance
|
||||
* board over the REAL agent registry (`AgentsApi.list` → the user-bearer
|
||||
* `/cloud/v1/agents` proxy, org-scoped server-side): five headline stat cards, an
|
||||
* invocations-over-time area chart, an agent-health donut, the agents table
|
||||
* (status tabs + pagination), a recent-activity feed, a top-agents bar list, and a
|
||||
* 30-day resource-usage panel.
|
||||
*
|
||||
* Honest by construction — EVERY number is real or derived from real rows
|
||||
* (`deriveAgentStats`, `healthBreakdown`, `topByInvocations`, `deriveActivity`); a
|
||||
* metric no row carries reads "—". The chart + resource panel read the time-series
|
||||
* 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 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 { Button, Card, Input, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import {
|
||||
Activity,
|
||||
Bot,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Gauge,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Timer,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { config } from '~/config'
|
||||
import {
|
||||
AgentsApi,
|
||||
deriveActivity,
|
||||
deriveAgentStats,
|
||||
filterAgents,
|
||||
fmtCompact,
|
||||
fmtDuration,
|
||||
fmtInt,
|
||||
fmtPct,
|
||||
fmtRelative,
|
||||
healthBreakdown,
|
||||
paginate,
|
||||
summedSeries,
|
||||
topByInvocations,
|
||||
trendPct,
|
||||
AGENT_STATUSES,
|
||||
AGENT_STATUS_LABEL,
|
||||
METRICS_RANGES,
|
||||
type Agent,
|
||||
type AgentActivity,
|
||||
type AgentsMetrics,
|
||||
type AgentStatus,
|
||||
type MetricsRange,
|
||||
} from '~/lib/api/agents'
|
||||
import { productColorHex } from '~/lib/products/colors'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
import { LineChart, Sparkline, type ChartPoint } from '~/components/ui/Charts'
|
||||
import { useDetailPane } from '~/components/DetailPane'
|
||||
import { MetricCard, Delta } from './functions/parts'
|
||||
import {
|
||||
AgentAvatar,
|
||||
ActivityFeed,
|
||||
HealthDonut,
|
||||
Panel,
|
||||
ResourceUsagePanel,
|
||||
StatusPill,
|
||||
TopAgents,
|
||||
VersionBadge,
|
||||
} from './agents/parts'
|
||||
import { AgentDetailView, NewAgentForm } from './agents/forms'
|
||||
|
||||
const PAGE_SIZE = 8
|
||||
const DASH = '—'
|
||||
const DOCS = `${config.docsUrl}/agents`
|
||||
|
||||
type StatusTab = AgentStatus | 'all'
|
||||
|
||||
/** Compact bucket label from a loose timestamp (month/day/hour). */
|
||||
function bucketLabel(t: string): string {
|
||||
if (!t) return ''
|
||||
const d = new Date(t)
|
||||
if (Number.isNaN(d.getTime())) return t
|
||||
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric' })
|
||||
}
|
||||
|
||||
/** Load the registry once; `live` = it loaded over a real 200 (gates mutations). */
|
||||
function useAgents() {
|
||||
const [agents, setAgents] = useState<Agent[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<BackendState | null>(null)
|
||||
const [live, setLive] = useState(false)
|
||||
const [activity, setActivity] = useState<AgentActivity[]>([])
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const list = await AgentsApi.list()
|
||||
setAgents(list)
|
||||
setLive(true)
|
||||
setError(null)
|
||||
// Prefer a real activity feed; fall back to the agents' own timestamps.
|
||||
try {
|
||||
const feed = await AgentsApi.activity()
|
||||
setActivity(feed.length ? feed : deriveActivity(list))
|
||||
} catch {
|
||||
setActivity(deriveActivity(list))
|
||||
}
|
||||
} catch (e) {
|
||||
setAgents([])
|
||||
setLive(false)
|
||||
setActivity([])
|
||||
setError(classifyBackend(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void reload()
|
||||
}, [reload])
|
||||
|
||||
return { agents, loading, error, live, activity, reload, setAgents }
|
||||
}
|
||||
|
||||
export function AgentsModule(_props: { params: Record<string, string> }) {
|
||||
const detail = useDetailPane()
|
||||
const { agents, loading, error, live, activity, reload, setAgents } = useAgents()
|
||||
|
||||
const [range, setRange] = useState<MetricsRange>('30D')
|
||||
const [metrics, setMetrics] = useState<AgentsMetrics | null>(null)
|
||||
const [metricsConnected, setMetricsConnected] = useState(false)
|
||||
|
||||
const [q, setQ] = useState('')
|
||||
const [tab, setTab] = useState<StatusTab>('all')
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const loadMetrics = useCallback(async (r: MetricsRange) => {
|
||||
try {
|
||||
const m = await AgentsApi.metrics(r)
|
||||
setMetrics(m)
|
||||
setMetricsConnected(true)
|
||||
} catch {
|
||||
setMetrics(null)
|
||||
setMetricsConnected(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadMetrics(range)
|
||||
}, [loadMetrics, range])
|
||||
|
||||
const stats = useMemo(() => deriveAgentStats(agents), [agents])
|
||||
const health = useMemo(() => healthBreakdown(agents), [agents])
|
||||
const top = useMemo(() => topByInvocations(agents, 5), [agents])
|
||||
|
||||
const invTotals = useMemo(() => summedSeries(metrics?.series ?? []), [metrics])
|
||||
const invDelta = trendPct(invTotals)
|
||||
const chartData: ChartPoint[] = useMemo(() => {
|
||||
const series = metrics?.series ?? []
|
||||
if (!series.length) return []
|
||||
const longest = series.reduce((a, b) => (b.points.length > a.points.length ? b : a), series[0])
|
||||
return invTotals.map((v, i) => ({ label: bucketLabel(longest.points[i]?.t ?? ''), value: v }))
|
||||
}, [metrics, invTotals])
|
||||
|
||||
const filtered = useMemo(() => filterAgents(agents, { search: q, status: tab }), [agents, q, tab])
|
||||
const pageState = paginate(filtered, page, PAGE_SIZE)
|
||||
const agentColor = productColorHex('agents')
|
||||
|
||||
const openDetail = useCallback(
|
||||
(a: Agent) =>
|
||||
detail.open({
|
||||
title: a.name,
|
||||
subtitle: `${a.model || 'agent'} · ${AGENT_STATUS_LABEL[a.status]}`,
|
||||
icon: Bot,
|
||||
iconColor: agentColor,
|
||||
content: <AgentDetailView agent={a} />,
|
||||
footer: live ? (
|
||||
<>
|
||||
<Button flex={1} chromeless onPress={detail.close}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
flex={1}
|
||||
theme="red"
|
||||
icon={<Trash2 size={15} />}
|
||||
onPress={() => {
|
||||
if (typeof window !== 'undefined' && !window.confirm(`Delete agent “${a.name}”? This cannot be undone.`)) return
|
||||
// Keyed by NAME — the backend's `DELETE /v1/agents/:name` handle. Passing
|
||||
// the display `id` (`agent_…`) 404s, which is what made this button a
|
||||
// silent no-op.
|
||||
void AgentsApi.remove(a.name)
|
||||
.then(() => {
|
||||
setAgents((rows) => rows.filter((r) => r.id !== a.id))
|
||||
detail.close()
|
||||
})
|
||||
.catch((e) => {
|
||||
// Never a silent failure: surface why the delete didn't take (the row stays).
|
||||
if (typeof window !== 'undefined') {
|
||||
window.alert(`Couldn’t delete “${a.name}”: ${e instanceof Error ? e.message : 'request failed'}`)
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button flex={1} chromeless onPress={detail.close}>
|
||||
Close
|
||||
</Button>
|
||||
),
|
||||
}),
|
||||
[detail, agentColor, live, setAgents],
|
||||
)
|
||||
|
||||
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={() => {
|
||||
detail.close()
|
||||
void reload()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[detail, agentColor, reload],
|
||||
)
|
||||
|
||||
const header = (
|
||||
<PageHeader
|
||||
title="Agents"
|
||||
subtitle="Autonomous agents — a model, a prompt, and tools that run on Hanzo compute."
|
||||
actions={
|
||||
<XStack gap="$2" items="center" flexWrap="wrap">
|
||||
{agents.length > 0 ? (
|
||||
<XStack items="center" gap="$2" px="$3" borderWidth={1} borderColor="$borderColor" rounded="$3" minW={200}>
|
||||
<Search size={15} />
|
||||
<Input
|
||||
flex={1}
|
||||
unstyled
|
||||
value={q}
|
||||
onChangeText={(v: string) => {
|
||||
setQ(v)
|
||||
setPage(1)
|
||||
}}
|
||||
placeholder="Search agents…"
|
||||
autoCapitalize="none"
|
||||
py="$2"
|
||||
/>
|
||||
</XStack>
|
||||
) : null}
|
||||
<Button size="$3" chromeless icon={<RefreshCw size={15} />} onPress={() => void reload()} aria-label="Refresh" />
|
||||
<Button size="$3" theme="light" icon={<Plus size={15} />} onPress={openNew}>
|
||||
New Agent
|
||||
</Button>
|
||||
</XStack>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
// ── Initial loading ─────────────────────────────────────────────────────────
|
||||
if (loading && agents.length === 0 && !error) {
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
<XStack p="$6" justify="center">
|
||||
<Spinner size="large" color="$color11" />
|
||||
</XStack>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Hard backend failure (access / not-initialized / error) ───────────────────
|
||||
if (error && error.kind !== 'unavailable') {
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
<BackendStateCard state={error} onRetry={() => void reload()} hint="endpoint · GET /v1/agents" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Honest empty (zero agents, or the /v1/agents route isn't bound yet) ───────
|
||||
if (agents.length === 0) {
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
{live ? (
|
||||
<XStack items="center" gap="$2" px="$3" py="$2" rounded="$4" bg="$color2" borderWidth={1} borderColor="$borderColor" self="center">
|
||||
<YStack width={8} height={8} rounded="$10" bg="$green10" />
|
||||
<Text fontSize="$2" color="$color11">Connected to Agents · no agents yet</Text>
|
||||
</XStack>
|
||||
) : null}
|
||||
<EmptyState
|
||||
icon={Bot}
|
||||
title="Create your first agent"
|
||||
description="Agents are autonomous workers — a model, a system prompt, and a set of tools that run on Hanzo compute and call your APIs on their own."
|
||||
bullets={[
|
||||
'Define an agent with a model, prompt, and tools',
|
||||
'Give it tools — HTTP, MCP servers, or your own functions',
|
||||
'Deploy to Hanzo compute — invocations, health, and cost show up here',
|
||||
]}
|
||||
primary={{ label: 'New Agent', onPress: openNew }}
|
||||
secondary={{ label: 'Agents docs', href: DOCS }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const spark = invTotals.length >= 2 ? <Sparkline values={invTotals} /> : undefined
|
||||
const now = Date.now()
|
||||
|
||||
const columns: Column<Agent>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Name',
|
||||
render: (a) => (
|
||||
<XStack items="center" gap="$2.5" minW={0}>
|
||||
<AgentAvatar />
|
||||
<YStack minW={0} gap="$0.5">
|
||||
<XStack items="center" gap="$2" minW={0}>
|
||||
<Text fontSize="$3" fontWeight="700" color="$color12" numberOfLines={1}>
|
||||
{a.name}
|
||||
</Text>
|
||||
<VersionBadge version={a.version} />
|
||||
</XStack>
|
||||
<Text fontSize="$1" color="$color10" numberOfLines={1}>
|
||||
{a.model || DASH}
|
||||
</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
header: 'Description',
|
||||
render: (a) => (
|
||||
<Text fontSize="$2" color="$color11" numberOfLines={2}>
|
||||
{a.description || DASH}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{ key: 'status', header: 'Status', width: 104, render: (a) => <StatusPill status={a.status} /> },
|
||||
{
|
||||
key: 'last',
|
||||
header: 'Last invocation',
|
||||
width: 130,
|
||||
render: (a) => (
|
||||
<Text fontSize="$2" color="$color11" numberOfLines={1}>
|
||||
{fmtRelative(a.lastInvocationAt, now)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'invocations',
|
||||
header: 'Invocations 30d',
|
||||
width: 122,
|
||||
render: (a) => (
|
||||
<Text fontSize="$3" color="$color12" fontWeight="600">
|
||||
{fmtCompact(a.invocations30d)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
width: 40,
|
||||
render: () => (
|
||||
<XStack justify="flex-end" opacity={0.6}>
|
||||
<MoreHorizontal size={16} />
|
||||
</XStack>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const tabs: StatusTab[] = ['all', ...AGENT_STATUSES]
|
||||
const tabCount = (t: StatusTab): number => (t === 'all' ? agents.length : health[t])
|
||||
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
|
||||
{/* Row 1 — five headline stat cards (real / derived; spark+delta from series) */}
|
||||
<XStack flexWrap="wrap" gap="$3" items="stretch">
|
||||
<MetricCard icon={Bot} label="Total agents" value={fmtInt(stats.total)} sub="registered" />
|
||||
<MetricCard icon={Activity} label="Active" value={fmtInt(stats.active)} sub={`${stats.idle} idle · ${stats.error} error`} />
|
||||
<MetricCard icon={Gauge} label="Success rate 30d" value={fmtPct(stats.successRate)} sub="weighted" />
|
||||
<MetricCard
|
||||
icon={Zap}
|
||||
label="Invocations 30d"
|
||||
value={fmtCompact(stats.invocations30d)}
|
||||
sub="last 30 days"
|
||||
spark={spark}
|
||||
delta={<Delta pct={invDelta} />}
|
||||
/>
|
||||
<MetricCard icon={Timer} label="Avg latency" value={fmtDuration(stats.avgLatencyMs)} sub="per invocation" />
|
||||
</XStack>
|
||||
|
||||
{/* Row 2 — invocations over time + agent health donut */}
|
||||
<XStack flexWrap="wrap" gap="$3" items="stretch">
|
||||
<Panel
|
||||
title="Invocations over time"
|
||||
flex={2}
|
||||
minW={360}
|
||||
action={
|
||||
<XStack gap="$1">
|
||||
{METRICS_RANGES.map((r) => (
|
||||
<Button
|
||||
key={r}
|
||||
size="$1"
|
||||
bg={r === range ? '$color5' : 'transparent'}
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
onPress={() => setRange(r)}
|
||||
>
|
||||
{r}
|
||||
</Button>
|
||||
))}
|
||||
</XStack>
|
||||
}
|
||||
>
|
||||
{chartData.length >= 2 ? (
|
||||
<LineChart data={chartData} formatValue={(v) => fmtCompact(v)} />
|
||||
) : (
|
||||
<YStack height={210} items="center" justify="center" gap="$1">
|
||||
<Text fontSize="$3" color="$color11" fontWeight="600">
|
||||
No invocation time-series yet
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color10" text="center" maxW={420}>
|
||||
{metricsConnected
|
||||
? 'No agent invocations in this range yet — the headline counts above read from the live registry.'
|
||||
: 'The Agents metrics route isn’t connected on this deployment, so no trend is drawn. Counts above are read from the live registry.'}
|
||||
</Text>
|
||||
</YStack>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Agent health" flex={1} minW={240}>
|
||||
<HealthDonut breakdown={health} />
|
||||
</Panel>
|
||||
</XStack>
|
||||
|
||||
{/* Row 3 — agents table (status tabs + pagination) */}
|
||||
<Panel title="Agents" minW={320}>
|
||||
<XStack gap="$1" flexWrap="wrap">
|
||||
{tabs.map((t) => (
|
||||
<Button
|
||||
key={t}
|
||||
size="$2"
|
||||
bg={t === tab ? '$color5' : 'transparent'}
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
onPress={() => {
|
||||
setTab(t)
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
{t === 'all' ? 'All' : AGENT_STATUS_LABEL[t]} · {tabCount(t)}
|
||||
</Button>
|
||||
))}
|
||||
</XStack>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={pageState.rows}
|
||||
rowKey={(a) => a.id}
|
||||
onRowPress={openDetail}
|
||||
empty="No agents match these filters."
|
||||
/>
|
||||
|
||||
{pageState.totalPages > 1 ? (
|
||||
<XStack justify="space-between" items="center">
|
||||
<Text fontSize="$2" color="$color10">
|
||||
Page {pageState.page} of {pageState.totalPages} · {filtered.length} agents
|
||||
</Text>
|
||||
<XStack gap="$2">
|
||||
<Button size="$2" chromeless icon={<ChevronLeft size={15} />} disabled={pageState.page <= 1} onPress={() => setPage(pageState.page - 1)}>
|
||||
Prev
|
||||
</Button>
|
||||
<Button size="$2" chromeless iconAfter={<ChevronRight size={15} />} disabled={pageState.page >= pageState.totalPages} onPress={() => setPage(pageState.page + 1)}>
|
||||
Next
|
||||
</Button>
|
||||
</XStack>
|
||||
</XStack>
|
||||
) : null}
|
||||
</Panel>
|
||||
|
||||
{/* Row 4 — recent activity · top agents · resource usage */}
|
||||
<XStack flexWrap="wrap" gap="$3" items="stretch">
|
||||
<Panel title="Recent activity" flex={1} minW={280}>
|
||||
<ActivityFeed events={activity} now={now} />
|
||||
</Panel>
|
||||
<Panel title="Top agents by invocations" flex={1} minW={280}>
|
||||
<TopAgents agents={top} />
|
||||
</Panel>
|
||||
<Panel title="Resource usage · 30d" flex={1} minW={260}>
|
||||
<ResourceUsagePanel
|
||||
usage={metrics?.resource ?? { cpuVcpuHours: null, memGbHours: null, storageIoBytes: null, costCents: null }}
|
||||
connected={metricsConnected}
|
||||
/>
|
||||
</Panel>
|
||||
</XStack>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Alerts — alert rules (conditions, severities, current state) evaluated by the
|
||||
* platform observability engine.
|
||||
*
|
||||
* Reads the REAL alert rules from Hanzo o11y through the same-origin user-bearer
|
||||
* `/cloud` proxy: `GET /v1/o11y/v1/rules` (cloud rewrites `/v1/o11y/*` → the o11y
|
||||
* runtime's `/api/v1/rules`, `listRules` → `ListRuleStates`). The o11y envelope is
|
||||
* `{status,data:{rules:[…]}}` where each rule is a flattened `GettableRule`
|
||||
* (`alert`, `state`, `labels.severity`, `description`, …) — normalized here to the
|
||||
* console's flat Alert row. When o11y isn't reachable / authorized the load fails and
|
||||
* the honest not-configured / unavailable card renders instead of an empty grid —
|
||||
* matching every other infra module. Nothing is fabricated.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Button, Text } from '@hanzo/gui'
|
||||
import { RefreshCw } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { restGet, cloudProxyV1Url } from '~/lib/api/client'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { StatusTag } from '~/components/ui/StatusTag'
|
||||
import { interpretPlatformError, PlatformStateCard, type PlatformError } from './platform/state'
|
||||
|
||||
type Alert = {
|
||||
id: string
|
||||
name?: string
|
||||
severity?: string
|
||||
status?: string
|
||||
condition?: string
|
||||
lastFired?: string
|
||||
}
|
||||
|
||||
/** One rule as o11y's `GettableRule` serializes it (PostableRule fields flattened). */
|
||||
type O11yRule = {
|
||||
id?: string
|
||||
/** Evaluated state: inactive | pending | recovering | firing | nodata | disabled. */
|
||||
state?: string
|
||||
/** Alert name (`PostableRule.alert`). */
|
||||
alert?: string
|
||||
description?: string
|
||||
ruleType?: string
|
||||
disabled?: boolean
|
||||
labels?: Record<string, string>
|
||||
}
|
||||
|
||||
/** o11y wraps every read as `{status:"success", data:…}`. */
|
||||
type O11yRulesResponse = { status?: string; data?: { rules?: O11yRule[] } }
|
||||
|
||||
/**
|
||||
* Flatten one o11y rule to the console Alert row. Honest mapping only:
|
||||
* - `status` is the real evaluated `state` (or `disabled` when the rule is off);
|
||||
* - `condition` is the rule's own `description` (o11y's `condition` field is a
|
||||
* composite query object, not a display string — we never stringify it into a
|
||||
* fake human condition);
|
||||
* - `lastFired` is left empty: the rule-list endpoint carries no last-fired
|
||||
* timestamp (that lives in the per-rule history timeline), so we show "—" rather
|
||||
* than mislabel `updateAt` (rule edit time) as a firing time.
|
||||
*/
|
||||
const toAlert = (r: O11yRule): Alert => ({
|
||||
id: r.id || r.alert || '',
|
||||
name: r.alert,
|
||||
severity: r.labels?.severity,
|
||||
status: r.disabled ? 'disabled' : r.state,
|
||||
condition: r.description,
|
||||
})
|
||||
|
||||
export function AlertsModule(_props: { params: Record<string, string> }) {
|
||||
const [rows, setRows] = useState<Alert[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState<PlatformError | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const r = await restGet<O11yRulesResponse>(cloudProxyV1Url('o11y/v1/rules'))
|
||||
setRows((r?.data?.rules ?? []).map(toAlert))
|
||||
setLoadError(null)
|
||||
} catch (e) {
|
||||
setLoadError(interpretPlatformError(e))
|
||||
setRows([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const columns: Column<Alert>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Name',
|
||||
render: (a) => (
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
|
||||
{a.name || a.id}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'severity',
|
||||
header: 'Severity',
|
||||
width: 110,
|
||||
render: (a) => (
|
||||
<Text fontSize="$3" color="$color11">
|
||||
{a.severity || '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'condition',
|
||||
header: 'Condition',
|
||||
width: 220,
|
||||
render: (a) => (
|
||||
<Text fontSize="$3" color="$color11" numberOfLines={1}>
|
||||
{a.condition || '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
width: 120,
|
||||
render: (a) => <StatusTag status={a.status ?? 'unknown'} />,
|
||||
},
|
||||
{
|
||||
key: 'lastFired',
|
||||
header: 'Last fired',
|
||||
width: 190,
|
||||
render: (a) => (
|
||||
<Text fontSize="$3" color="$color11">
|
||||
{a.lastFired ? new Date(a.lastFired).toLocaleString() : '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Alerts"
|
||||
subtitle="Alert rules — conditions, severities, recent firings."
|
||||
actions={
|
||||
<Button icon={<RefreshCw size={16} />} onPress={() => void load()}>
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{loadError ? (
|
||||
<PlatformStateCard error={loadError} onRetry={() => void load()} />
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
loading={loading}
|
||||
rowKey={(a) => a.id}
|
||||
empty="No alert rules yet."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Native Analytics — the per-org analytics module over the unified ClickHouse
|
||||
* warehouse (datastore), read through cloud-api `/v1/analytics/*` via the same-origin
|
||||
* `/cloud` bearer proxy, so every metric is scoped to the caller's own IAM org
|
||||
* (server-authoritative, from the Bearer owner) — the browser holds no datastore
|
||||
* credential.
|
||||
*
|
||||
* Bound to exactly the FOUR routes the backend mounts (overview/timeseries/top/health):
|
||||
* - Overview — the LLM lens (requests/tokens/spend/models/providers/errors) is REAL
|
||||
* live per-org data (hanzo.cloud_usage), charted over time; the Web + Commerce
|
||||
* lenses (hanzo.events) render HONEST-empty ("no events yet") until a collector
|
||||
* emits — never fabricated numbers.
|
||||
* - LLM — the real per-model table + spend donut (top models).
|
||||
*
|
||||
* There is deliberately NO "Real-Time" tab: the backend exposes no realtime feed, so
|
||||
* inventing one would be a fabrication. Every number here is a real query result;
|
||||
* empty is honest-empty ("—" / "no data yet"); a 403 (cookie-only) or 503 (warehouse
|
||||
* unwired) surfaces the shared BackendStateCard.
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Activity, BarChart3, DollarSign, Sparkles, Server, TriangleAlert, Zap } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import {
|
||||
AnalyticsApi,
|
||||
RANGES,
|
||||
type ModelRow,
|
||||
type Overview,
|
||||
type Range,
|
||||
type SeriesPoint,
|
||||
type Top,
|
||||
} from '~/lib/api/analytics'
|
||||
import { fmtUsd, fmtInt } from '~/lib/api/functions'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { LineChart, Donut, CHART_PALETTE, type ChartPoint, type Slice } from '~/components/ui/Charts'
|
||||
import { classifyBackend, BackendStateCard, type BackendState } from '~/components/ui/BackendState'
|
||||
import { MetricCard } from './functions/parts'
|
||||
|
||||
const fmtPct = (n: number): string => (Number.isFinite(n) ? `${(n * 100).toFixed(1)}%` : '—')
|
||||
|
||||
const TABS = [
|
||||
{ id: '', label: 'Overview', icon: BarChart3 },
|
||||
{ id: 'llm', label: 'LLM', icon: Sparkles },
|
||||
] as const
|
||||
|
||||
export function AnalyticsModule({ params }: { params: Record<string, string> }) {
|
||||
const router = useRouter()
|
||||
const tab = useMemo(() => {
|
||||
const t = params.tab ?? ''
|
||||
return TABS.some((x) => x.id === t) ? t : ''
|
||||
}, [params.tab])
|
||||
|
||||
return (
|
||||
<YStack gap="$5">
|
||||
<PageHeader
|
||||
title="Analytics"
|
||||
subtitle="Per-org LLM, web, and commerce analytics over the unified warehouse."
|
||||
actions={
|
||||
<XStack gap="$1" flexWrap="wrap">
|
||||
{TABS.map((t) => (
|
||||
<TabButton key={t.id || 'overview'} active={t.id === tab} label={t.label} Icon={t.icon}
|
||||
onPress={() => router.push(t.id ? `/analytics/${t.id}` : '/analytics')} />
|
||||
))}
|
||||
</XStack>
|
||||
}
|
||||
/>
|
||||
{tab === 'llm' ? <LlmTab /> : <OverviewTab />}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
function TabButton({ active, label, Icon, onPress }: { active: boolean; label: string; Icon: typeof BarChart3; onPress: () => void }) {
|
||||
return (
|
||||
<XStack
|
||||
onPress={onPress}
|
||||
cursor="pointer"
|
||||
items="center"
|
||||
gap="$1.5"
|
||||
px="$3"
|
||||
height={34}
|
||||
rounded="$3"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
bg={active ? '$color5' : 'transparent'}
|
||||
hoverStyle={{ bg: '$color3' }}
|
||||
>
|
||||
<Icon size={15} />
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12">{label}</Text>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
function RangeBar({ range, onChange }: { range: Range; onChange: (r: Range) => void }) {
|
||||
return (
|
||||
<XStack gap="$1">
|
||||
{RANGES.map((r) => (
|
||||
<XStack
|
||||
key={r}
|
||||
onPress={() => onChange(r)}
|
||||
cursor="pointer"
|
||||
px="$2.5"
|
||||
height={30}
|
||||
items="center"
|
||||
rounded="$3"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
bg={r === range ? '$color6' : 'transparent'}
|
||||
hoverStyle={{ bg: '$color3' }}
|
||||
>
|
||||
<Text fontSize="$2" fontWeight="600" color="$color12">{r}</Text>
|
||||
</XStack>
|
||||
))}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Overview ──────────────────────────────────────────────────────────────────
|
||||
type OverviewState =
|
||||
| { phase: 'loading' }
|
||||
| { phase: 'error'; error: BackendState }
|
||||
| { phase: 'ready'; overview: Overview; series: SeriesPoint[] }
|
||||
|
||||
function OverviewTab() {
|
||||
const [range, setRange] = useState<Range>('7d')
|
||||
const [state, setState] = useState<OverviewState>({ phase: 'loading' })
|
||||
|
||||
const load = useCallback(async (r: Range) => {
|
||||
setState({ phase: 'loading' })
|
||||
try {
|
||||
const [overview, series] = await Promise.all([AnalyticsApi.overview(r), AnalyticsApi.timeseries(r)])
|
||||
setState({ phase: 'ready', overview, series })
|
||||
} catch (e) {
|
||||
setState({ phase: 'error', error: classifyBackend(e) })
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => { void load(range) }, [load, range])
|
||||
|
||||
if (state.phase === 'error') {
|
||||
return (
|
||||
<YStack gap="$4">
|
||||
<XStack justify="flex-end"><RangeBar range={range} onChange={setRange} /></XStack>
|
||||
<BackendStateCard state={state.error} onRetry={() => void load(range)} hint="endpoint · GET /v1/analytics/overview" />
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
const o = state.phase === 'ready' ? state.overview : null
|
||||
const series = state.phase === 'ready' ? state.series : []
|
||||
const chartData: ChartPoint[] = series.map((p) => ({ label: p.t, value: p.spendCents }))
|
||||
|
||||
return (
|
||||
<YStack gap="$4">
|
||||
<XStack justify="flex-end"><RangeBar range={range} onChange={setRange} /></XStack>
|
||||
|
||||
{/* LLM lens — REAL per-org data. */}
|
||||
<XStack flexWrap="wrap" gap="$3" items="stretch">
|
||||
<MetricCard icon={Sparkles} label="LLM requests" value={fmtInt(o?.llm.requests)} sub={range} />
|
||||
<MetricCard icon={Activity} label="Tokens" value={fmtInt(o?.llm.tokens)} sub="total" />
|
||||
<MetricCard icon={DollarSign} label="LLM spend" value={fmtUsd(o?.llm.spendCents)} sub={range} />
|
||||
<MetricCard icon={BarChart3} label="Models" value={fmtInt(o?.llm.models)} sub="distinct" />
|
||||
<MetricCard icon={Server} label="Providers" value={fmtInt(o?.llm.providers)} sub="distinct" />
|
||||
<MetricCard icon={TriangleAlert} label="Error rate" value={o ? fmtPct(o.llm.errorRate) : '—'} sub="errors/requests" />
|
||||
</XStack>
|
||||
|
||||
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor">
|
||||
<Text fontSize="$4" fontWeight="800" color="$color12">LLM spend over time</Text>
|
||||
{chartData.some((p) => p.value > 0) ? (
|
||||
<LineChart data={chartData} formatValue={(v) => fmtUsd(v)} />
|
||||
) : (
|
||||
<Text fontSize="$2" color="$color10">No LLM usage in this range yet.</Text>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Web + Commerce lenses — honest-empty until the events collector emits. */}
|
||||
<XStack flexWrap="wrap" gap="$3" items="stretch">
|
||||
<LensCard
|
||||
title="Web"
|
||||
available={Boolean(o?.web.available)}
|
||||
reason={o?.web.reason || 'No web analytics events yet'}
|
||||
metrics={[
|
||||
{ label: 'Pageviews', value: fmtInt(o?.web.pageviews) },
|
||||
{ label: 'Visitors', value: fmtInt(o?.web.visitors) },
|
||||
{ label: 'Sessions', value: fmtInt(o?.web.sessions) },
|
||||
]}
|
||||
/>
|
||||
<LensCard
|
||||
title="Commerce"
|
||||
available={Boolean(o?.commerce.available)}
|
||||
reason={o?.commerce.reason || 'No commerce events yet'}
|
||||
metrics={[
|
||||
{ label: 'Orders', value: fmtInt(o?.commerce.orders) },
|
||||
{ label: 'Revenue', value: o ? `$${o.commerce.revenue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : '—' },
|
||||
{ label: 'AOV', value: o ? `$${o.commerce.aov.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : '—' },
|
||||
]}
|
||||
/>
|
||||
</XStack>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
function LensCard({ title, available, reason, metrics }: { title: string; available: boolean; reason: string; metrics: { label: string; value: string }[] }) {
|
||||
return (
|
||||
<Card flex={1} minW={320} p="$4" gap="$3" borderWidth={1} borderColor="$borderColor">
|
||||
<XStack items="center" gap="$2">
|
||||
<Text fontSize="$4" fontWeight="800" color="$color12">{title}</Text>
|
||||
{!available ? <Text fontSize="$1" color="$color10">· honest-empty</Text> : null}
|
||||
</XStack>
|
||||
{available ? (
|
||||
<XStack flexWrap="wrap" gap="$4">
|
||||
{metrics.map((m) => (
|
||||
<YStack key={m.label} minW={90}>
|
||||
<Text fontSize="$2" color="$color10">{m.label}</Text>
|
||||
<Text fontSize="$6" fontWeight="800" color="$color12">{m.value}</Text>
|
||||
</YStack>
|
||||
))}
|
||||
</XStack>
|
||||
) : (
|
||||
<Text fontSize="$2" color="$color10">
|
||||
{reason}. Install the tracking snippet or connect commerce to start collecting {title.toLowerCase()} events for
|
||||
this organization — this shows real data only, nothing is fabricated.
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ── LLM (top models) ───────────────────────────────────────────────────────────
|
||||
type LlmState = { phase: 'loading' } | { phase: 'error'; error: BackendState } | { phase: 'ready'; top: Top }
|
||||
|
||||
function LlmTab() {
|
||||
const [range, setRange] = useState<Range>('7d')
|
||||
const [state, setState] = useState<LlmState>({ phase: 'loading' })
|
||||
|
||||
const load = useCallback(async (r: Range) => {
|
||||
setState({ phase: 'loading' })
|
||||
try {
|
||||
setState({ phase: 'ready', top: await AnalyticsApi.top(r, 20) })
|
||||
} catch (e) {
|
||||
setState({ phase: 'error', error: classifyBackend(e) })
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => { void load(range) }, [load, range])
|
||||
|
||||
if (state.phase === 'error') {
|
||||
return (
|
||||
<YStack gap="$4">
|
||||
<XStack justify="flex-end"><RangeBar range={range} onChange={setRange} /></XStack>
|
||||
<BackendStateCard state={state.error} onRetry={() => void load(range)} hint="endpoint · GET /v1/analytics/top" />
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
const models = state.phase === 'ready' ? state.top.models.items : []
|
||||
const slices: Slice[] = models.slice(0, 6).map((m, i) => ({
|
||||
label: m.model,
|
||||
value: m.spendCents,
|
||||
color: CHART_PALETTE[i % CHART_PALETTE.length],
|
||||
}))
|
||||
|
||||
const cols: Column<ModelRow>[] = [
|
||||
{ key: 'model', header: 'Model', render: (r) => <Text fontSize="$3" fontWeight="600" color="$color12">{r.model || '—'}</Text> },
|
||||
{ key: 'provider', header: 'Provider', width: 130, render: (r) => <Text fontSize="$3" color="$color11">{r.provider || '—'}</Text> },
|
||||
{ key: 'requests', header: 'Requests', width: 110, render: (r) => <Text fontSize="$3" color="$color11">{fmtInt(r.requests)}</Text> },
|
||||
{ key: 'tokens', header: 'Tokens', width: 120, render: (r) => <Text fontSize="$3" color="$color11">{fmtInt(r.tokens)}</Text> },
|
||||
{ key: 'spend', header: 'Spend', width: 110, render: (r) => <Text fontSize="$3" color="$color11">{fmtUsd(r.spendCents)}</Text> },
|
||||
{ key: 'pct', header: 'Share', width: 90, render: (r) => <Text fontSize="$3" color="$color11">{Number.isFinite(r.pct) ? `${r.pct.toFixed(1)}%` : '—'}</Text> },
|
||||
]
|
||||
|
||||
return (
|
||||
<YStack gap="$4">
|
||||
<XStack justify="flex-end"><RangeBar range={range} onChange={setRange} /></XStack>
|
||||
<XStack flexWrap="wrap" gap="$3" items="stretch">
|
||||
<Card flex={1} minW={280} p="$4" gap="$3" borderWidth={1} borderColor="$borderColor" items="center">
|
||||
<XStack items="center" gap="$2" self="flex-start">
|
||||
<Zap size={16} />
|
||||
<Text fontSize="$3" fontWeight="800" color="$color12">Spend by model</Text>
|
||||
</XStack>
|
||||
{slices.some((s) => s.value > 0) ? (
|
||||
<Donut slices={slices} legend />
|
||||
) : (
|
||||
<Text fontSize="$2" color="$color10">No LLM usage in this range.</Text>
|
||||
)}
|
||||
</Card>
|
||||
<Card flex={2} minW={360} p="$4" gap="$2" borderWidth={1} borderColor="$borderColor">
|
||||
<Text fontSize="$3" fontWeight="800" color="$color12">Usage by model</Text>
|
||||
<DataTable
|
||||
columns={cols}
|
||||
rows={models}
|
||||
loading={state.phase === 'loading'}
|
||||
rowKey={(r) => r.model || '—'}
|
||||
empty="No LLM usage yet. Model usage for this organization appears here as calls are made."
|
||||
/>
|
||||
</Card>
|
||||
</XStack>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Annotation Queues — list of review queues (HIP-0106), native on @hanzo/gui.
|
||||
*
|
||||
* An annotation queue is a named work queue of traces/observations to review and
|
||||
* score against a set of score configs. Reads the REAL `/v1/o11y/annotation-queues`
|
||||
* surface; when the runtime is not initialized (503) or unrouted (404) it shows an
|
||||
* honest RuntimeNotice — never fabricated queues. Read-only list here; items are
|
||||
* worked in the annotation flow.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button, Card, Text, XStack } from '@hanzo/gui'
|
||||
import { ArrowLeft, ChevronRight, RefreshCw } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import {
|
||||
O11yApi,
|
||||
type AnnotationQueue,
|
||||
type AnnotationQueueDetail,
|
||||
type AnnotationQueueItem,
|
||||
type O11yPageMeta,
|
||||
} from '~/lib/api'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { RuntimeNotice } from './observability/RuntimeNotice'
|
||||
import { Pager } from './observability/Pager'
|
||||
import { fmtDate } from './observability/format'
|
||||
|
||||
const PAGE_LIMIT = 50
|
||||
|
||||
function AnnotationQueueListView({ onOpen }: { onOpen: (q: AnnotationQueue) => void }) {
|
||||
const [rows, setRows] = useState<AnnotationQueue[]>([])
|
||||
const [meta, setMeta] = useState<O11yPageMeta | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
|
||||
const load = useCallback(async (p: number) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await O11yApi.annotationQueues({ page: p, limit: PAGE_LIMIT })
|
||||
setRows(res.data ?? [])
|
||||
setMeta(res.meta ?? null)
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e)
|
||||
setRows([])
|
||||
setMeta(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load(page)
|
||||
}, [load, page])
|
||||
|
||||
const columns: Column<AnnotationQueue>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Name',
|
||||
render: (q) => (
|
||||
<Button chromeless px="$0" onPress={() => onOpen(q)}>
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
|
||||
{q.name}
|
||||
</Text>
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{ key: 'description', header: 'Description', render: (q) => <Text fontSize="$3" color="$color11" numberOfLines={1}>{q.description || '—'}</Text> },
|
||||
{ key: 'scoreConfigIds', header: 'Score configs', width: 130, render: (q) => <Text fontSize="$3" color="$color11">{q.scoreConfigIds?.length ?? 0}</Text> },
|
||||
{ key: 'createdAt', header: 'Created', width: 190, render: (q) => <Text fontSize="$3" color="$color11">{fmtDate(q.createdAt)}</Text> },
|
||||
{
|
||||
key: 'action',
|
||||
header: '',
|
||||
width: 120,
|
||||
render: (q) => (
|
||||
<XStack justify="flex-end" flex={1}>
|
||||
<Button size="$2" iconAfter={<ChevronRight size={14} />} onPress={() => onOpen(q)}>
|
||||
Open
|
||||
</Button>
|
||||
</XStack>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Annotation Queues"
|
||||
subtitle="Review queues — traces and observations to score against score configs."
|
||||
actions={
|
||||
<Button icon={<RefreshCw size={16} />} onPress={() => void load(page)}>
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<RuntimeNotice surface="annotation-queues" error={error} />
|
||||
) : (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
loading={loading}
|
||||
rowKey={(q) => q.id}
|
||||
onRowPress={onOpen}
|
||||
empty="No annotation queues yet. Create a queue to organize human review and scoring."
|
||||
/>
|
||||
<Pager meta={meta} onPage={setPage} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AnnotationQueueDetailView({ id, onBack, onOpenTrace }: { id: string; onBack: () => void; onOpenTrace: (traceId: string) => void }) {
|
||||
const [queue, setQueue] = useState<AnnotationQueueDetail | null>(null)
|
||||
const [items, setItems] = useState<AnnotationQueueItem[]>([])
|
||||
const [meta, setMeta] = useState<O11yPageMeta | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
|
||||
const load = useCallback(async (p: number) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [detail, list] = await Promise.all([
|
||||
O11yApi.annotationQueue(id),
|
||||
O11yApi.annotationQueueItems(id, { page: p, limit: PAGE_LIMIT }),
|
||||
])
|
||||
setQueue(detail)
|
||||
setItems(list.data ?? detail.items ?? [])
|
||||
setMeta(list.meta ?? null)
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e)
|
||||
setQueue(null)
|
||||
setItems([])
|
||||
setMeta(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
void load(page)
|
||||
}, [load, page])
|
||||
|
||||
const itemColumns: Column<AnnotationQueueItem>[] = [
|
||||
{ key: 'id', header: 'Item', render: (i) => <Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>{i.id}</Text> },
|
||||
{
|
||||
key: 'traceId',
|
||||
header: 'Trace',
|
||||
render: (i) =>
|
||||
i.traceId ? (
|
||||
<Button chromeless px="$0" onPress={() => onOpenTrace(i.traceId as string)}>
|
||||
<Text fontSize="$3" color="$color12" numberOfLines={1}>{i.traceId}</Text>
|
||||
</Button>
|
||||
) : (
|
||||
<Text fontSize="$3" color="$color10">—</Text>
|
||||
),
|
||||
},
|
||||
{ key: 'observationId', header: 'Observation', render: (i) => <Text fontSize="$3" color="$color11" numberOfLines={1}>{i.observationId || '—'}</Text> },
|
||||
{ key: 'status', header: 'Status', width: 120, render: (i) => <Text fontSize="$3" color="$color11">{i.status || '—'}</Text> },
|
||||
{ key: 'assignee', header: 'Assignee', width: 180, render: (i) => <Text fontSize="$3" color="$color11" numberOfLines={1}>{i.assignee || '—'}</Text> },
|
||||
{ key: 'createdAt', header: 'Created', width: 190, render: (i) => <Text fontSize="$3" color="$color11">{fmtDate(i.createdAt)}</Text> },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={queue?.name ?? id}
|
||||
subtitle="Annotation queue detail and work items."
|
||||
actions={
|
||||
<XStack gap="$2">
|
||||
<Button icon={<ArrowLeft size={16} />} onPress={onBack}>
|
||||
Back
|
||||
</Button>
|
||||
<Button icon={<RefreshCw size={16} />} onPress={() => void load(page)}>
|
||||
Refresh
|
||||
</Button>
|
||||
</XStack>
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<RuntimeNotice surface="annotation-queue" error={error} />
|
||||
) : loading && !queue ? (
|
||||
<Text color="$color11">Loading…</Text>
|
||||
) : (
|
||||
<>
|
||||
{queue ? (
|
||||
<Card p="$4" gap="$2" borderWidth={1} borderColor="$borderColor">
|
||||
<Text fontSize="$5" fontWeight="700">Overview</Text>
|
||||
<Text fontSize="$3" color="$color11">{queue.description || 'No description.'}</Text>
|
||||
<Text fontSize="$2" color="$color10">
|
||||
Score configs: {queue.scoreConfigIds?.length ? queue.scoreConfigIds.join(', ') : '—'}
|
||||
</Text>
|
||||
</Card>
|
||||
) : null}
|
||||
<DataTable
|
||||
columns={itemColumns}
|
||||
rows={items}
|
||||
loading={loading}
|
||||
rowKey={(i) => i.id}
|
||||
empty="No items returned for this queue."
|
||||
/>
|
||||
<Pager meta={meta} onPage={setPage} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function AnnotationQueuesModule({ params }: { params: Record<string, string> }) {
|
||||
const router = useRouter()
|
||||
const id = params.id
|
||||
if (id) {
|
||||
return (
|
||||
<AnnotationQueueDetailView
|
||||
id={decodeURIComponent(id)}
|
||||
onBack={() => router.push('/annotation-queues')}
|
||||
onOpenTrace={(traceId) => router.push(`/o11y/${encodeURIComponent(traceId)}`)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <AnnotationQueueListView onOpen={(q) => router.push(`/annotation-queues/${encodeURIComponent(q.id)}`)} />
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* API Keys — create, copy, rotate, and revoke the per-user `hk-` Cloud API key.
|
||||
*
|
||||
* The key is minted server-side (`/keys` route → IAM, app-on-behalf as the
|
||||
* confidential console client); the browser only sends its session cookie and
|
||||
* never holds a long-lived secret beyond the one-time reveal at creation. This
|
||||
* is the real credential the user presents as `Authorization: Bearer hk-…` to
|
||||
* the SDKs, CLI, and the api.hanzo.ai gateway.
|
||||
*
|
||||
* `ApiKeysView` is the bare surface so Settings can embed it as a tab;
|
||||
* `ApiKeysModule` wraps it with the page header for the standalone Dev route.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Copy, Check, KeyRound, RefreshCw, Trash2, TriangleAlert, Plus } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { ApiError, KeysApi, type KeyStatus } from '~/lib/api'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { ErrorState } from '~/components/ui/States'
|
||||
|
||||
const DOCS_API = 'https://docs.hanzo.ai/api'
|
||||
|
||||
/** Honest date label for the key's last mint/rotate; empty string when unknown. */
|
||||
function fmtKeyDate(iso?: string): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
/** Copy-to-clipboard button with a transient confirmed state. */
|
||||
function CopyButton({ value, label = 'Copy' }: { value: string; label?: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard?.writeText(value)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
/* clipboard blocked (insecure context) — the value is already visible */
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Button size="$2" icon={copied ? <Check size={14} /> : <Copy size={14} />} onPress={() => void copy()}>
|
||||
{copied ? 'Copied' : label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
/** The full secret, shown ONCE right after creation. */
|
||||
function NewKeyCard({ accessKey, onDone }: { accessKey: string; onDone: () => void }) {
|
||||
return (
|
||||
<Card p="$4" gap="$3" borderWidth={1} borderColor="$green8" bg="$green2" maxWidth={720}>
|
||||
<XStack gap="$2" items="center">
|
||||
<KeyRound size={16} />
|
||||
<Text fontSize="$4" fontWeight="700">
|
||||
Your new API key
|
||||
</Text>
|
||||
</XStack>
|
||||
<XStack gap="$2" items="center" flexWrap="wrap">
|
||||
<Text
|
||||
fontSize="$3"
|
||||
color="$color12"
|
||||
flex={1}
|
||||
minW={260}
|
||||
selectable
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
>
|
||||
{accessKey}
|
||||
</Text>
|
||||
<CopyButton value={accessKey} />
|
||||
</XStack>
|
||||
<XStack gap="$2" items="center">
|
||||
<TriangleAlert size={14} color="$yellow10" />
|
||||
<Text fontSize="$2" color="$color11">
|
||||
Copy it now — for your security the full key is shown only once and cannot be retrieved again.
|
||||
</Text>
|
||||
</XStack>
|
||||
<Button size="$2" self="flex-start" onPress={onDone}>
|
||||
Done
|
||||
</Button>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/** The credential surface — embeddable (no page header). */
|
||||
export function ApiKeysView() {
|
||||
const { account, loading: sessionLoading } = useSession()
|
||||
const [status, setStatus] = useState<KeyStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [working, setWorking] = useState<null | 'create' | 'rotate' | 'revoke'>(null)
|
||||
const [newKey, setNewKey] = useState<string>('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
setStatus(await KeysApi.status())
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : 'Failed to load API key status')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionLoading && account) void load()
|
||||
else if (!sessionLoading) setLoading(false)
|
||||
}, [sessionLoading, account, load])
|
||||
|
||||
const mint = useCallback(
|
||||
async (mode: 'create' | 'rotate') => {
|
||||
setWorking(mode)
|
||||
setError(null)
|
||||
try {
|
||||
const { accessKey } = await KeysApi.create()
|
||||
setNewKey(accessKey)
|
||||
setStatus({ hasKey: true, keyPrefix: accessKey.slice(0, 11), createdAt: new Date().toISOString() })
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : 'Failed to create the API key')
|
||||
} finally {
|
||||
setWorking(null)
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const revoke = useCallback(async () => {
|
||||
if (typeof window !== 'undefined' && !window.confirm('Revoke your API key? Any app using it will stop working immediately.')) return
|
||||
setWorking('revoke')
|
||||
setError(null)
|
||||
try {
|
||||
await KeysApi.revoke()
|
||||
setNewKey('')
|
||||
setStatus({ hasKey: false, keyPrefix: '' })
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : 'Failed to revoke the API key')
|
||||
} finally {
|
||||
setWorking(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (sessionLoading || loading) {
|
||||
return (
|
||||
<XStack p="$6" justify="center">
|
||||
<Spinner size="large" color="$color11" />
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
if (!account) {
|
||||
return <ErrorState err={new ApiError('Not authorized', 401)} />
|
||||
}
|
||||
|
||||
const hasKey = status?.hasKey ?? false
|
||||
|
||||
return (
|
||||
<YStack gap="$3" maxW={720}>
|
||||
{error ? (
|
||||
<Card p="$3" gap="$2" borderWidth={1} borderColor="$red8" bg="$red2">
|
||||
<XStack gap="$2" items="center">
|
||||
<TriangleAlert size={15} color="$red10" />
|
||||
<Text fontSize="$3" color="$color12">
|
||||
{error}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{newKey ? <NewKeyCard accessKey={newKey} onDone={() => setNewKey('')} /> : null}
|
||||
|
||||
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor">
|
||||
{hasKey ? (
|
||||
<>
|
||||
<XStack gap="$2" items="center">
|
||||
<KeyRound size={16} />
|
||||
<YStack flex={1}>
|
||||
<Text fontSize="$4" fontWeight="700">
|
||||
Cloud API key
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color10" style={{ fontFamily: 'monospace' }}>
|
||||
{status?.keyPrefix ? `${status.keyPrefix}…` : 'hk-…'}
|
||||
</Text>
|
||||
{fmtKeyDate(status?.createdAt) ? (
|
||||
<Text fontSize="$1" color="$color10">
|
||||
Last created/rotated {fmtKeyDate(status?.createdAt)}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
</XStack>
|
||||
<Text fontSize="$3" color="$color11">
|
||||
Your account has an active key. The full secret is shown only at creation; rotate to
|
||||
replace it (the old key stops working), or revoke it entirely.
|
||||
</Text>
|
||||
<XStack gap="$2" flexWrap="wrap">
|
||||
<Button
|
||||
size="$2"
|
||||
icon={<RefreshCw size={14} />}
|
||||
disabled={working !== null}
|
||||
onPress={() => void mint('rotate')}
|
||||
>
|
||||
{working === 'rotate' ? 'Rotating…' : 'Rotate'}
|
||||
</Button>
|
||||
<Button
|
||||
size="$2"
|
||||
icon={<Trash2 size={14} />}
|
||||
theme="red"
|
||||
disabled={working !== null}
|
||||
onPress={() => void revoke()}
|
||||
>
|
||||
{working === 'revoke' ? 'Revoking…' : 'Revoke'}
|
||||
</Button>
|
||||
</XStack>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XStack gap="$2" items="center">
|
||||
<KeyRound size={16} />
|
||||
<Text fontSize="$4" fontWeight="700">
|
||||
Create your Cloud API key
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$3" color="$color11">
|
||||
One key for your account, scoped to your organization. Use it as{' '}
|
||||
<Text fontSize="$2" style={{ fontFamily: 'monospace' }}>Authorization: Bearer hk-…</Text> with the SDKs,
|
||||
CLI, and the api.hanzo.ai gateway.
|
||||
</Text>
|
||||
<Button
|
||||
size="$3"
|
||||
self="flex-start"
|
||||
bg="$color5"
|
||||
icon={<Plus size={16} />}
|
||||
disabled={working !== null}
|
||||
onPress={() => void mint('create')}
|
||||
>
|
||||
{working === 'create' ? 'Creating…' : 'Create API key'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
size="$2"
|
||||
chromeless
|
||||
self="flex-start"
|
||||
onPress={() => {
|
||||
if (typeof window !== 'undefined') window.open(DOCS_API, '_blank', 'noopener')
|
||||
}}
|
||||
>
|
||||
API documentation
|
||||
</Button>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
export function ApiKeysModule(_props: { params: Record<string, string> }) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="API Keys"
|
||||
subtitle="The cloud API credential for your account. Use it with the SDKs, CLI, and gateway."
|
||||
/>
|
||||
<ApiKeysView />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,33 +1,20 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Applications admin — list + edit with deploy/undeploy, native on @hanzo/gui.
|
||||
* Applications — the org's REAL deployed apps on the live Hanzo PaaS. Thin route
|
||||
* adapter over `PaasApplications`, which drives the per-org `/v1/platform/*`
|
||||
* surface (projects → apps → deployments) through the `/cloud` bearer proxy.
|
||||
*
|
||||
* Logic ported from ApplicationListPage.js + ApplicationEditPage.js +
|
||||
* backend/ApplicationBackend.js: the new-application template, the field set
|
||||
* (template/namespace/parameters/status), and the deploy/undeploy lifecycle. UI
|
||||
* rebuilt clean on GUI primitives (no antd).
|
||||
*
|
||||
* Routing: `/applications` lists; `/applications/<name>` edits one.
|
||||
* This is the Compute "Applications" surface: real deployed apps with their
|
||||
* project, status, source, and live URL — NOT the IAM/OAuth "application"
|
||||
* registry (an identity concern), and no longer the admin-only apps-inventory
|
||||
* board (that stays under Status/Kubernetes). Per-org by construction: cloud
|
||||
* resolves the org from the Bearer owner, so a caller only sees their own apps.
|
||||
*/
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { PaasApplications } from './paas/PaasApplications'
|
||||
|
||||
import { ApplicationListView } from './applications/ApplicationListView'
|
||||
import { ApplicationEditView } from './applications/ApplicationEditView'
|
||||
|
||||
export function ApplicationsModule({ params }: { params: Record<string, string> }) {
|
||||
const router = useRouter()
|
||||
const name = params.name
|
||||
|
||||
if (name) {
|
||||
return (
|
||||
<ApplicationEditView
|
||||
name={decodeURIComponent(name)}
|
||||
onDone={() => router.push('/applications')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ApplicationListView onOpen={(a) => router.push(`/applications/${encodeURIComponent(a.name)}`)} />
|
||||
)
|
||||
export function ApplicationsModule(props: { params: Record<string, string> }) {
|
||||
return <PaasApplications {...props} />
|
||||
}
|
||||
|
||||
export default ApplicationsModule
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Apps (Sites) — the org's BUILDABLE SITES, the projects hanzo.app publishes when a
|
||||
* user ships a site from the conversational builder. This closes the console→app
|
||||
* round-trip: every site the user builds in hanzo.app shows up here, and each row
|
||||
* deep-links straight back into hanzo.app for more conversational editing.
|
||||
*
|
||||
* Read-only over the REAL cloud `/v1/projects` store (cloud `clients/projectsvc`):
|
||||
* every read is same-origin and keyless (`AppsApi` → `originV1Url('projects')` →
|
||||
* `<origin>/v1/projects`); `next.config.mjs` rewrites the `projects` head to the
|
||||
* console's OWN user-bearer `/cloud` proxy, which mints a short-lived user token and
|
||||
* forwards it, so the backend resolves the org from the token owner claim — every row
|
||||
* is org-scoped SERVER-SIDE (switching org in the OrgSwitcher re-lists that org's
|
||||
* sites) and no credential reaches the browser. The EXACT per-tenant path Agents/CRM
|
||||
* use. Distinct from IAM "Projects" (org resource scope) and PaaS "Applications"
|
||||
* (container apps) — this is the hanzo.app buildable-sites store only.
|
||||
*
|
||||
* One list + an optional per-site detail rail (real deployment history). Per row TWO
|
||||
* actions: "Open site" (the live URL) and "Edit in hanzo.app" (`/dev?project=<slug>`,
|
||||
* the deep-link the builder honors). Every state is honest: loading, a true empty
|
||||
* state ("build one in hanzo.app"), and the shared BackendStateCard on 401/403/404/
|
||||
* 503 — never a fabricated row.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { AppWindow, ExternalLink, Pencil, RefreshCw, X } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { config } from '~/config'
|
||||
import { AppsApi, builderEditUrl, type App, type AppDeployment } from '~/lib/api/apps'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { StatusTag } from '~/components/ui/StatusTag'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
|
||||
type Async<T> =
|
||||
| { phase: 'loading' }
|
||||
| { phase: 'error'; error: BackendState }
|
||||
| { phase: 'ready'; data: T }
|
||||
|
||||
const fmtTime = (s?: number) => (s ? new Date(s * 1000).toLocaleString() : '-')
|
||||
const dash = (s: string) => (s.trim() ? s : '-')
|
||||
|
||||
const fmtBytes = (n: number): string => {
|
||||
if (!n) return '-'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let v = n
|
||||
let i = 0
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024
|
||||
i++
|
||||
}
|
||||
return `${v.toFixed(i && v < 10 ? 1 : 0)} ${units[i]}`
|
||||
}
|
||||
|
||||
/** Open an external URL in a new tab (no-opener), guarded for SSR. */
|
||||
const openHref = (href: string) => {
|
||||
if (href && typeof window !== 'undefined') window.open(href, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
/** The hanzo.app builder deep-link for a site, using the configured app base. */
|
||||
const editUrl = (slug: string) => builderEditUrl(slug, config.appUrl)
|
||||
|
||||
// ── Per-row actions: Open site + Edit in hanzo.app ────────────────────────────
|
||||
|
||||
function RowActions({ app }: { app: App }) {
|
||||
return (
|
||||
<XStack gap="$2" items="center">
|
||||
{app.liveUrl ? (
|
||||
<Button
|
||||
size="$2"
|
||||
chromeless
|
||||
aria-label={`Open ${dash(app.name || app.slug)}`}
|
||||
icon={<ExternalLink size={14} />}
|
||||
onPress={() => openHref(app.liveUrl)}
|
||||
>
|
||||
<Text fontSize="$2" color="$color11">Open site</Text>
|
||||
</Button>
|
||||
) : (
|
||||
<Text fontSize="$2" color="$color10">—</Text>
|
||||
)}
|
||||
<Button
|
||||
size="$2"
|
||||
aria-label={`Edit ${dash(app.name || app.slug)} in hanzo.app`}
|
||||
icon={<Pencil size={14} />}
|
||||
onPress={() => openHref(editUrl(app.slug))}
|
||||
>
|
||||
<Text fontSize="$2">Edit in hanzo.app</Text>
|
||||
</Button>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Detail rail: real deployment history + the two actions ────────────────────
|
||||
|
||||
type DetailState =
|
||||
| { phase: 'loading' }
|
||||
| { phase: 'error'; error: BackendState }
|
||||
| { phase: 'ready'; app: App; deployments: AppDeployment[] }
|
||||
|
||||
function Fact({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<XStack justify="space-between" gap="$3" items="flex-start">
|
||||
<Text fontSize="$2" color="$color10">{label}</Text>
|
||||
<Text fontSize="$2" color="$color12" numberOfLines={1} text="right" maxW={220}>{value}</Text>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-contained by slug: loads the site (`GET /v1/projects/:slug`) and its
|
||||
* deploy history (`.../deployments`) so the rail renders identically whether opened
|
||||
* from a row press or a `/apps/:slug` deep link. A deployments failure degrades that
|
||||
* section (the site facts still render); a site-load failure shows the honest error.
|
||||
*/
|
||||
function AppDetail({ slug, onClose }: { slug: string; onClose: () => void }) {
|
||||
const [state, setState] = useState<DetailState>({ phase: 'loading' })
|
||||
|
||||
const load = useCallback(() => {
|
||||
setState({ phase: 'loading' })
|
||||
Promise.all([
|
||||
AppsApi.get(slug),
|
||||
AppsApi.deployments(slug).catch(() => [] as AppDeployment[]),
|
||||
])
|
||||
.then(([app, deployments]) => setState({ phase: 'ready', app, deployments }))
|
||||
.catch((e) => setState({ phase: 'error', error: classifyBackend(e) }))
|
||||
}, [slug])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
return (
|
||||
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor">
|
||||
<XStack items="center" justify="space-between">
|
||||
<Text fontSize="$5" fontWeight="800" numberOfLines={1}>
|
||||
{state.phase === 'ready' ? dash(state.app.name || state.app.slug) : slug}
|
||||
</Text>
|
||||
<Button size="$2" chromeless aria-label="Close details" icon={<X size={16} />} onPress={onClose} />
|
||||
</XStack>
|
||||
|
||||
{state.phase === 'loading' ? (
|
||||
<XStack p="$4" justify="center"><Spinner size="small" color="$color11" /></XStack>
|
||||
) : state.phase === 'error' ? (
|
||||
<BackendStateCard state={state.error} onRetry={load} hint={`endpoint · GET /v1/projects/${slug}`} />
|
||||
) : (
|
||||
<>
|
||||
<XStack gap="$2" items="center">
|
||||
<StatusTag status={state.app.status || 'unknown'} />
|
||||
{state.app.framework ? <Text fontSize="$2" color="$color10">{state.app.framework}</Text> : null}
|
||||
</XStack>
|
||||
|
||||
<XStack gap="$2" flexWrap="wrap">
|
||||
{state.app.liveUrl ? (
|
||||
<Button size="$2" chromeless icon={<ExternalLink size={14} />} onPress={() => openHref(state.app.liveUrl)}>
|
||||
<Text fontSize="$2" color="$color11">Open site</Text>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="$2" icon={<Pencil size={14} />} onPress={() => openHref(editUrl(state.app.slug))}>
|
||||
<Text fontSize="$2">Edit in hanzo.app</Text>
|
||||
</Button>
|
||||
</XStack>
|
||||
|
||||
<YStack gap="$1.5" pt="$1" borderTopWidth={1} borderColor="$borderColor">
|
||||
<Fact label="Slug" value={state.app.slug} />
|
||||
<Fact label="Live URL" value={state.app.liveUrl || '—'} />
|
||||
<Fact label="Repo" value={state.app.repo.url || '—'} />
|
||||
<Fact label="Created" value={fmtTime(state.app.createdAt)} />
|
||||
<Fact label="Updated" value={fmtTime(state.app.updatedAt)} />
|
||||
</YStack>
|
||||
|
||||
<YStack gap="$2" pt="$1" borderTopWidth={1} borderColor="$borderColor">
|
||||
<Text fontSize="$2" fontWeight="700" color="$color11">Deployments</Text>
|
||||
{state.deployments.length === 0 ? (
|
||||
<Text fontSize="$2" color="$color10">No deployments yet.</Text>
|
||||
) : (
|
||||
state.deployments.slice(0, 12).map((d) => (
|
||||
<XStack key={d.id} items="center" justify="space-between" gap="$2" py="$1">
|
||||
<YStack minW={0} flex={1}>
|
||||
<Text fontSize="$2" fontWeight="600" numberOfLines={1}>
|
||||
v{d.version}{d.commit ? ` · ${d.commit.slice(0, 7)}` : ''}{d.source ? ` · ${d.source}` : ''}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color10" numberOfLines={1}>
|
||||
{fmtTime(d.createdAt)} · {d.files || 0} files · {fmtBytes(d.bytes)}
|
||||
</Text>
|
||||
</YStack>
|
||||
<StatusTag status={d.status || 'unknown'} />
|
||||
</XStack>
|
||||
))
|
||||
)}
|
||||
</YStack>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Module ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function AppsModule({ params }: { params: Record<string, string> }) {
|
||||
const router = useRouter()
|
||||
const active = params.slug || null
|
||||
const [state, setState] = useState<Async<App[]>>({ phase: 'loading' })
|
||||
|
||||
const load = useCallback(() => {
|
||||
setState({ phase: 'loading' })
|
||||
AppsApi.list()
|
||||
.then((data) => setState({ phase: 'ready', data }))
|
||||
.catch((e) => setState({ phase: 'error', error: classifyBackend(e) }))
|
||||
}, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const columns: Column<App>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Site',
|
||||
render: (a) => (
|
||||
<XStack items="center" gap="$2" minW={0}>
|
||||
<AppWindow size={15} color="$color10" />
|
||||
<YStack minW={0}>
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>{dash(a.name || a.slug)}</Text>
|
||||
<Text fontSize="$1" color="$color10" numberOfLines={1}>{a.slug}</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
),
|
||||
},
|
||||
{ key: 'framework', header: 'Framework', width: 130, render: (a) => <Text fontSize="$3" color="$color11">{dash(a.framework)}</Text> },
|
||||
{ key: 'status', header: 'Status', width: 120, render: (a) => <StatusTag status={a.status || 'unknown'} /> },
|
||||
{ key: 'updatedAt', header: 'Updated', width: 180, render: (a) => <Text fontSize="$3" color="$color10">{fmtTime(a.updatedAt || a.createdAt)}</Text> },
|
||||
{ key: 'actions', header: '', width: 260, render: (a) => <RowActions app={a} /> },
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const header = (
|
||||
<PageHeader
|
||||
title="Apps"
|
||||
subtitle="The sites you build in hanzo.app — per organization. Open the live site, or jump back into hanzo.app to keep editing."
|
||||
actions={
|
||||
<XStack gap="$2">
|
||||
<Button size="$3" icon={<RefreshCw size={15} />} aria-label="Refresh" onPress={load} />
|
||||
<PrimaryButton size="$3" icon={<AppWindow size={16} />} onPress={() => openHref(`${config.appUrl}/dev`)}>
|
||||
Build in hanzo.app
|
||||
</PrimaryButton>
|
||||
</XStack>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
if (state.phase === 'error') {
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
<BackendStateCard state={state.error} onRetry={load} hint="endpoint · GET /v1/projects" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const apps = state.phase === 'ready' ? state.data : []
|
||||
|
||||
if (state.phase === 'ready' && apps.length === 0) {
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
<EmptyState
|
||||
icon={AppWindow}
|
||||
title="No apps yet"
|
||||
description="Build and publish your first site in hanzo.app — describe what you want and ship it. Every site you publish shows up here, per organization."
|
||||
bullets={[
|
||||
'Build conversationally in hanzo.app — no boilerplate.',
|
||||
'Each published site gets a live URL and a versioned deploy history.',
|
||||
'Come back here to open the live site or jump straight into editing.',
|
||||
]}
|
||||
primary={{ label: 'Build in hanzo.app', href: `${config.appUrl}/dev` }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
<XStack gap="$4" flexWrap="wrap" items="flex-start">
|
||||
<YStack flex={2} minW={320} gap="$2">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={apps}
|
||||
loading={state.phase === 'loading'}
|
||||
rowKey={(a) => a.slug || a.id}
|
||||
onRowPress={(a) => router.push(`/apps/${a.slug}`)}
|
||||
empty="No apps yet — build one in hanzo.app."
|
||||
/>
|
||||
</YStack>
|
||||
{active ? (
|
||||
<YStack flex={1} minW={300}>
|
||||
<AppDetail slug={active} onClose={() => router.push('/apps')} />
|
||||
</YStack>
|
||||
) : null}
|
||||
</XStack>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Attestations — verifiable proofs (TEE/TDX remote attestation, signed build
|
||||
* provenance, on-chain attestations) issued and verified by the platform.
|
||||
*
|
||||
* Reads the attestation ledger from the PaaS via the same-origin `/paas` proxy
|
||||
* (`GET /v1/attestations`), which injects the service token server-side. When the
|
||||
* attestation service isn't provisioned for the org the list load fails and the
|
||||
* honest not-configured / unavailable card renders instead of an empty grid —
|
||||
* matching every other infra module.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Button, Text } from '@hanzo/gui'
|
||||
import { RefreshCw } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { restGet } from '~/lib/api/client'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { DataTable, type Column } from '~/components/ui/DataTable'
|
||||
import { StatusTag } from '~/components/ui/StatusTag'
|
||||
import { interpretPlatformError, PlatformStateCard, type PlatformError } from './platform/state'
|
||||
|
||||
const paas = (path: string) => `/paas/${path.replace(/^\/+/, '')}`
|
||||
|
||||
type Attestation = {
|
||||
id: string
|
||||
type?: string
|
||||
subject?: string
|
||||
status?: string
|
||||
issuer?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export function AttestationsModule(_props: { params: Record<string, string> }) {
|
||||
const [rows, setRows] = useState<Attestation[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState<PlatformError | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const r = await restGet<{ attestations?: Attestation[] }>(paas('attestations'))
|
||||
setRows(r.attestations ?? [])
|
||||
setLoadError(null)
|
||||
} catch (e) {
|
||||
setLoadError(interpretPlatformError(e))
|
||||
setRows([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const columns: Column<Attestation>[] = [
|
||||
{
|
||||
key: 'subject',
|
||||
header: 'Subject',
|
||||
render: (a) => (
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
|
||||
{a.subject || a.id}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
header: 'Type',
|
||||
width: 160,
|
||||
render: (a) => (
|
||||
<Text fontSize="$3" color="$color11">
|
||||
{a.type || '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'issuer',
|
||||
header: 'Issuer',
|
||||
width: 200,
|
||||
render: (a) => (
|
||||
<Text fontSize="$3" color="$color11" numberOfLines={1}>
|
||||
{a.issuer || '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
width: 120,
|
||||
render: (a) => <StatusTag status={a.status ?? 'unknown'} />,
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
header: 'Issued',
|
||||
width: 190,
|
||||
render: (a) => (
|
||||
<Text fontSize="$3" color="$color11">
|
||||
{a.createdAt ? new Date(a.createdAt).toLocaleString() : '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Attestations"
|
||||
subtitle="Verifiable proofs — TEE remote attestation, build provenance, on-chain attestations."
|
||||
actions={
|
||||
<Button icon={<RefreshCw size={16} />} onPress={() => void load()}>
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{loadError ? (
|
||||
<PlatformStateCard error={loadError} onRetry={() => void load()} />
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
loading={loading}
|
||||
rowKey={(a) => a.id}
|
||||
empty="No attestations yet. Proofs issued by the platform appear here."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user