Compare commits

...
51 Commits
Author SHA1 Message Date
zeekay 98a7088be7 feat(console2): models-first, docs-out, collapsible+filterable sidebar, cmd+K actions (v0.7.4)
Build Docker Image / docker (push) Successful in 3m5s
The 'I don't see any models' wave + sidebar/command UX.

- Models is catalog-first: the default tab is the LIVE model list (~49 Zen
  models via the /ai proxy); routing policy moves to a secondary 'Routing' tab
  (/models/routing). Retires the duplicate empty-by-default 'Model Catalog' nav
  entry — one product, real models on the obvious click.
- Docs are EXTERNAL (brand docsUrl, new tab): a top sidebar 'Docs' entry, the
  header '?' icon, and a server /docs -> docs.<brand> redirect (no in-app 404).
  ComingSoon's API-docs link repointed off the 404-ing ${cloudUrl}/docs.
- Sidebar collapses/expands from the brand 'H' mark (icon-only <-> full),
  persisted to the account; the grid launcher stays. A filter box narrows the
  whole sidebar to find any product fast; Overview/Docs are fixed top links.
- cmd+K runs ACTIONS too (toggle theme, browse all apps, open settings, switch
  org, ask AI / search docs, sign out, per-org switch), ranked alongside catalog
  nav — no dead entries. Removed unused openMode.
- DRY: switchOrg() shared by the switcher + palette; pure match-core (resolveRoute
  + entryMatches) is unit-tested without the GUI tree. Default pins fixed
  (dead 'billing' -> 'models','chat'). tsc + 29 vitest + next build clean.
2026-06-28 22:44:30 -07:00
zeekay 28c186b508 fix(console2): customer console scopes to the user's own org (v0.7.3)
The org-scope module defaults the active org to the brand org (for the cross-org
admin). On a customer host, seed the signed-in user's OWN org as the active scope
the first time (only when no explicit switch is in effect), so the OrgSwitcher
chip and X-Org-Id reflect the real tenant (e.g. maxpower) instead of the brand
org. Never clobbers an admin's deliberate switch.
2026-06-28 22:16:30 -07:00
zeekay bba56046e4 fix(console2): mask the password field on the sign-in form (v0.7.2)
The @hanzo/gui Input ignores the RN secureTextEntry/keyboardType props on web,
so the password rendered as type=text (visible while typing). Set the web input
type explicitly (type="password") so it masks; also corrects autoComplete to
current-password.
2026-06-28 21:58:56 -07:00
zeekay 948a627c72 ci(console2): resolve semver tag without node (ARC runner has no node)
The build step used `node -p require('./package.json').version`; node is not on
the ARC runner PATH, so the substitution silently yielded the tag `:v` and the
operator deploy 404'd. Resolve the version with grep/sed and fail-loud if empty.
2026-06-28 21:45:33 -07:00
zeekay bdda312f5e feat(console2): multi-tenant login (org by email) + /paas admin gate (v0.7.1)
Login resolves the user's ORG from their email instead of pinning the brand's
own org: a customer in any org signs into the brand console with email+password.

- iam-login.ts: POST /v1/iam/login with organization: (cross-org email
  resolution) → OAuth code → existing completeSignIn → /v1/signin exchange.
  Replaces the SDK org-pinned redirect that made non-brand-org users
  un-signinable. MFA (NextMfa/RequiredMfa) hands off to IAM's same-site hosted
  flow — IAM sets iam_session_id SameSite=Lax, so cross-site fetch MFA can't
  complete here; no faked inline step.
- SignInForm: email+password state machine; social (getProviderSigninUrl) kept.
- OrgGate: the console runs in the user's CUSTOMER org — blocks the internal
  brand org (owner===config.iamOrgName → admin.<domain>) and the zero-org case.
- SECURITY /paas/[...path]: gate with getAdminGate (403 if not a brand admin) +
  runtime nodejs. The forwarded PAAS_SERVICE_TOKEN is control-plane god-mode and
  was previously reachable by any authenticated browser.
- getAdminGate: orgScope=user.owner (was brand.id) so the IAM/KMS proxies pin a
  non-global admin to their OWN org; require a VERIFIED email (authoritative IAM
  recheck for thin claims). admin-policy + tests updated (14 pass).
2026-06-28 21:37:42 -07:00
zeekay 4cfcd1f779 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	package-lock.json
#	package.json
2026-06-28 21:23:02 -07:00
zeekay b3fc76186f feat(console2): live admin data + org switching (v0.7.0)
Root cause of empty models + broken org switcher: /v1/{iam,kms,models} 404/401
(cookie-only) on the console host. Route every privileged call through console2's
own server proxies (user bearer + admin gate), and make org scope a switchable value.

- models-catalog: repoint /v1/models -> /ai proxy (shared aiV1Url in client.ts);
  catalog now populates with live Zen models. playground reuses the shared aiBase.
- org-scope.ts: currentOrg/setCurrentOrg/isScopedAway/filterOrgs. client.ts stamps
  X-Org-Id: currentOrg() so data modules re-scope on switch.
- OrgSwitcher: lists ALL visible orgs, filter box, in-place re-scope (set+reload).
- IamModule/AuditModule read currentOrg(); KmsModule now a names-only inventory
  over KmsAdminApi.list (the /admin/kms proxy), values never fetched.
- admin-policy.ts: pure gateAllows/ownerAllowed/orgFor extracted from the gate +
  routes (decomplect) so the shipped predicate is the tested one.
- vitest: 22 tests RED->GREEN (gate allow/deny+scoping, scope+filter, catalog /ai).
  tsc --noEmit + next build clean.
2026-06-28 21:19:50 -07:00
hanzo-dev fecaf7d49d fix(console2): Pager props + O11yUser barrel export (tsc clean) 2026-06-28 20:53:43 -07:00
hanzo-dev 1d9c2a9225 feat(console2): port Observations + Users observability views from old console
Two more old-console pages as real o11y modules: Observations (the flat
cross-trace span/generation view, GET /v1/o11y/observations) and Users (per-user
analytics rollup, GET /v1/o11y/users). O11yApi extended with both endpoints +
the O11yUser type. Same paged DataTable + honest RuntimeNotice pattern as the
other o11y modules; registered under Observe. tsc clean.
2026-06-28 20:52:55 -07:00
hanzo-dev 7a3b13d55e feat(console2): build the 25 remaining products into real modules
Flip every 'soon' product (ComingSoon stub) to a real, enabled in-console module
— attestations/oracles/indexer/tokens/settlement, alerts/logs, pipelines/
releases/builds/environments, hsm/authz/service-mesh/load-balancer/vpc, jobs/
edge/functions/containers/machines/gpus, agents/inference/finetuning. Each
follows the established module pattern: typed restGet over the same-origin /paas
proxy, DataTable, honest interpretPlatformError→PlatformStateCard not-configured
state, Refresh. Registry: status soon→enabled + routes wired. Full tsc clean.

Catalog is now 61 enabled modules + 16 external; no 'soon' stubs remain.
2026-06-28 20:06:44 -07:00
z bc7f92665e docs(brand): add hero banner 2026-06-28 20:05:13 -07:00
z 509e511b6e chore(brand): dynamic hero banner 2026-06-28 20:05:12 -07:00
hanzo-dev ab9f8751b7 feat: brand-aware animated logo in console loader/sign-in
The mark now follows config.brand — each brand's OWN published, interactive
animated SVG (@hanzo/logo, @luxfi/logo, @zooai/logo: getAnimatedSVG, load→hover→
press). Static block-H during SSR/first paint (no hydration mismatch). The logo
is now the brand's playable 'AI' on every loading + sign-in surface.
2026-06-28 19:49:01 -07:00
zeekay 5ea5cde253 feat(console2): add MPC product (Security) so console.hanzo.ai shows IAM/KMS/MPC/PaaS
MPC = threshold signing & multi-party computation, external→mpc.hanzo.ai.
Completes the 4 platform apps the launcher surfaces: IAM (module), KMS (module),
MPC (external), PaaS (projects→platform.hanzo.ai, embedded PlatformModule).
Verified locally: dev recompiled clean, tsc --noEmit clean.
2026-06-28 19:26:29 -07:00
zeekay f28d79673a wip(console2): agent admin-console IAM/KMS savepoint (proxy+gate+branding, in progress) 2026-06-28 19:20:38 -07:00
zeekay 40a6236aa3 fix(console2): API keys — drop reload() that unmounted the view via AuthGate (v0.6.1)
Build Docker Image / docker (push) Successful in 2m37s
The mint/revoke success path called useSession().reload(), which flips the
session loading flag -> AuthGate renders its Loader -> the dashboard (and
ApiKeysView) unmounts -> the freshly-minted key (local newKey state) is lost on
remount, so 'Your new API key' never showed. The session masks accessKey anyway,
so the reload provided no benefit. Show the new key from local state only.

Verified live (v0.6.0): /keys POST 200 + /ai chat proxy 200 (real 'PONG'/Hanzo
answer); only the keys UI render was affected.
2026-06-28 18:54:02 -07:00
zeekay 97c385f5ca feat(console2): working AI (keyless proxy) + API keys + chrome polish (v0.6.0)
Build Docker Image / docker (push) Successful in 2m25s
P0 — make it work for Dave:
- Root cause of 'chats/playground don't work': /v1/chat/completions requires
  Authorization: Bearer; the browser sent cookie-only -> rejected. Fixed with a
  keyless server-side AI proxy (app/ai/[...path]) that mints a short-lived
  user-bound IAM token (issue-user-token, hanzo-console app-on-behalf) and
  forwards to the gateway. No key in the browser, no rotation per turn.
- API keys: app/keys route mints/rotates/revokes the per-user hk- key via IAM
  (mint-user-keys/revoke-user-keys); ApiKeysModule is now create/copy/rotate/
  revoke with show-once secret handling.
- Chat is interactive (ChatConversation) over AiApi.chat; honest 402 billing state.
- src/lib/server/identity.ts: server-only trust boundary (resolveUser + IAM ops).

P1 — chrome polish:
- Header/sidebar show the Hanzo H mark + 'Console' (HanzoMark, BrandLogo with
  org-logo fallback).
- Fullscreen Launchpad-style app launcher (AppLauncher) with filter, from the
  header 'Apps' button, sidebar grid icon, and the command palette.
- cmd+K palette gains a 'Browse all apps' affordance.

Verified end-to-end (curl, real creds): minted hk- key + issued user JWT both
200 on api.hanzo.ai/v1/chat/completions. typecheck + next build clean.
2026-06-28 18:19:38 -07:00
zeekay 7cf8015c07 chore(console2): remove unused lib/zap client + @zap-proto deps
Zero importers (grep lib/zap = 0). Drops src/lib/zap/* and the
@zap-proto/{web,zap} deps. typecheck + build clean. One-way hygiene.
2026-06-28 05:45:21 -07:00
zandGitHub 5874ec5acc Merge pull request #4 from hanzoai/feat/memory-tasks-modules
feat: Memory + Tasks modules (v0.5.0) — unify work/tasks/memory in the console
2026-06-27 22:16:00 -07:00
zeekay 01844deb23 catalog: Tasks under appended Async category (Tasks/Temporal)
Async is the canonical home for Tasks per the org taxonomy. Appended as an 11th
category so the ten GCP-equivalent groups keep their exact labels and order
(no reorder); catalogByCategory is data-driven, so it renders generically.
2026-06-27 22:11:27 -07:00
zeekay f7a90ffe65 release: v0.5.0 — Memory + Tasks modules 2026-06-27 22:06:52 -07:00
zeekay 7f7a9341da feat(console): Memory + Tasks modules in the unified catalog
Memory (Data): list + search (kind filter + text) + detail/edit + add/delete,
honest 'initializing' card until /v1/memory deploys. Tasks (Compute, GCP Cloud
Tasks): namespace selector + Workflows/Schedules tabs + cluster strip, workflow
detail + durable history, on the LIVE /v1/tasks engine. Both render honest
states on a gated/absent route; mutations report through the shared toast.
Entries appended (no reorder); grouped by category.
2026-06-27 22:06:48 -07:00
zeekay 13978c0ca9 feat(api): Memory + Tasks /v1 clients
Memory (hanzoai/ai /v1/memory): remember/search/list/recall/update/remove/facts
over restGet/restPost; per-user, server-scoped. Tasks (hanzoai/tasks /v1/tasks):
namespaces/workflows/workflow/history/schedules + cluster health, contract
verified against pkg/tasks/embed.go. Both plain-REST, cookie-credentialed; org
scoping is server-side.
2026-06-27 22:06:41 -07:00
zandGitHub d564d3c488 Merge pull request #3 from hanzoai/feat/shell-foundation
feat: shell foundation v0.4.0 — cmd+K, AI assist, toasts, theme, org-switcher, breadcrumbs
2026-06-27 21:47:44 -07:00
zeekay bd933e5984 chore: v0.4.0 2026-06-27 21:45:52 -07:00
zeekay c0da836191 feat(shell): mount the foundation blocks once in the dashboard shell
Dashboard layout wraps the shell in ToastProvider + CommandPaletteProvider. The
shell top bar gains the command search box (opens the palette), theme toggle, a
help launcher (opens the palette in docs mode), and the org switcher; a breadcrumb
bar sits below it. ResourceModule create/delete now report through the toast, so
the one feedback primitive is actually used.
2026-06-27 21:45:48 -07:00
zeekay abb45c9481 feat(console): command palette + org switcher
CommandPalette is ONE command surface (Cmd/Ctrl+K) with modes selected by the
query: default fuzzy-filters the catalog and jumps to any product; > asks the AI
to find a product (NAV <id>) or answer; ? asks the docs knowledge store (RAG).
searchCatalog is the dependency-free fuzzy ranker. OrgSwitcher shows the account's
org and, only for a real multi-brand membership, switches to that brand's console
host. Honest throughout — AI/RAG/IAM failures degrade to truthful states.
2026-06-27 21:45:41 -07:00
zeekay a09e9483c8 feat(ui): toast, theme toggle, breadcrumbs primitives
Toast is the one feedback primitive — ToastProvider + useToast() with a portalled
top-right viewport, theme-aware accents, auto-dismiss. ThemeToggle flips dark/light
via next-theme. Breadcrumbs derive Home/Category/Product/detail from the route +
catalog, so a new product gets correct crumbs for free. GUI primitives only.
2026-06-27 21:45:35 -07:00
zeekay 5a61a9571c feat(api): one AI client over the cloud /v1 (chat + ragChat docs + listModels)
AiApi composes the single OpenAI-compatible gateway binding (PlaygroundApi):
plain chat, listModels, and ragChat grounded in a knowledge store (docs by
default) via the built-in retrieval path — the X-Retrieval-Store header turns
on RAG and names the store; the org owner is resolved server-side. restPost and
PlaygroundApi.chat gain an optional headers arg so RAG and plain chat share ONE
binding. No parallel AI client; failures throw ApiError for honest states.
2026-06-27 21:45:29 -07:00
zandGitHub 7b9a02ab9a Merge pull request #2 from hanzoai/feat/billing-per-brand-url
Per-brand billingUrl (lux->billing.lux.cloud, zoo->billing.zoo.cloud)
2026-06-27 20:01:54 -07:00
zeekay 03d80356bf feat(config): per-brand billingUrl (lux->billing.lux.cloud, zoo->billing.zoo.cloud)
billingUrl moves from SHARED to the per-brand BRANDS table, resolved like
iamUrl. Each brand's console (Cost product, PlansModule manage-billing) links
to ITS billing host, scoped to ITS org by the brand JWT. Cloud backend stays
shared/multi-tenant.
2026-06-27 19:24:08 -07:00
zeekay 35e0ea4c30 merge: page-port wave 2 (trace-graph tree, score-configs, annotation-queues) — v0.3.0
Build Docker Image / docker (push) Successful in 2m25s
2026-06-27 18:52:04 -07:00
zeekay cf44eff091 feat(console2): port-wave2 — span tree, score-configs, annotation-queues (v0.3.0)
Native @hanzo/gui modules over the REAL /v1/o11y REST surface; honest
RuntimeNotice on 503/404 — no fabricated data.

- Traces detail: flat observations table -> nested span TREE waterfall
  (SpanTree) built from parentObservationId, with a Tree|Table toggle.
  Orphans/cycles surface as roots so every observation appears once.
- Score Configs: new Observe module on /v1/o11y/score-configs (read-only).
- Annotation Queues: new Observe module on /v1/o11y/annotation-queues
  (real public REST list — contradicts the earlier 'no backend' finding).

Skipped (honest, no usable Hanzo backend or would duplicate/fake):
dashboards/widgets + score-analytics (no saved-dashboard REST, charts
would be fabricated), llm-connections/mcp (covered by Providers/ModelCatalog),
agents (backend not routed at gateway — real paths 404), org/projects deep
settings (already covered by Settings + IAM, no write endpoints), and the
Langfuse integration surfaces (feature-flags/entitlements/automations/
batch-exports/developer-tools/slack/mixpanel/blobstorage).

catalog 73 -> 75 entries; appended only (no reorder).
2026-06-27 18:44:19 -07:00
Hanzo AI e3c4d52bb4 build: cap Node heap (NODE_OPTIONS=6144) to fix Next build OOMKill (exit 137) 2026-06-27 18:35:53 -07:00
zeekay da83b67e89 release: v0.2.0 — consolidate page-port wave 1 (o11y traces/sessions/scores, playground/evals/datasets/prompts, settings/models/api-keys)
Build Docker Image / docker (push) Successful in 2m45s
Merges port-{settings-models,prompts-evals,o11y-core}. 73 catalog entries across the 10 categories; new modules wired to real /v1 backends with honest states (o11y 503 until runtime init). tsc + next build green.
2026-06-27 18:19:45 -07:00
zeekay a13de116e8 merge: port-o11y-core (Traces/Sessions/Scores)
# Conflicts:
#	src/lib/api/index.ts
#	src/lib/products/registry.tsx
2026-06-27 18:17:56 -07:00
zeekay 725beb1d98 merge: port-prompts-evals (page-port wave 1)
# Conflicts:
#	src/lib/products/registry.tsx
2026-06-27 18:17:16 -07:00
zeekay 21c1016292 merge: port-settings-models (page-port wave 1) 2026-06-27 18:16:09 -07:00
Hanzo AI bc76e6fc76 release: v0.1.9 — REST client fix over v0.1.8 (working REST, not dead ZAP /zap WS) 2026-06-27 18:10:23 -07:00
Hanzo AI 24f9b14e69 fix(providers): use working REST client, not the dead ZAP /zap WS
ProviderListView/ProviderEditView imported ~/lib/zap, but the cloud /zap
WebSocket face is not served (edge returns SPA HTML, not a WS upgrade — per
lib/zap/client.ts LIVE STATUS), so Providers rendered 'Failed to load
providers'. Switch both to ~/lib/api (identical surface). Part of v0.1.8.
2026-06-27 17:46:04 -07:00
zeekay ac0ecfcb84 feat(console2): port prompts/playground/datasets/evals as native @hanzo/gui modules
Port the old console (Langfuse-fork) eval surfaces into console2 as native
modules on @hanzo/gui + @hanzogui/lucide-icons-2 — no antd/shadcn/tremor.
Wired to the REAL cloud /v1 backend; honest loading/empty/unavailable states
on 404/503 (NEVER fabricated prompts/datasets/scores).

- Playground (AI): GET /v1/models + POST /v1/chat/completions — fully working
  model run (system prompt, message thread, sampling params, token usage).
- Evals (Observe): POST /v1/evals/runs (real per-item run summary) +
  GET /v1/evals/scores (real scores list), Run/Scores tabs.
- Datasets (Observe): POST /v1/evals/datasets + /v1/evals/dataset-items (real
  create); forward-compatible GET list with honest unavailable card.
- Prompts (AI): forward-compatible GET /v1/prompts probe; honest deep-link card
  (no /v1 prompts route mounted yet).

Shared: ModelPicker (one way to pick a gateway model), BackendState (honest
/v1 error → card). API: lib/api/{playground,evals}.ts via the REST client.
Registry: playground+evals stubs upgraded in place (no reorder); prompts+
datasets appended. tsc --noEmit + next build both green.
2026-06-27 17:43:45 -07:00
zeekay e547011c96 feat(console2): native Traces/Sessions/Scores modules under Observe
Port the old console's observability core (Langfuse-shaped) to native
@hanzo/gui modules:
- Traces: list + detail (overview, I/O, observations, scores) at /o11y
- Sessions: list + detail (overview + traces) at /sessions
- Scores: list at /scores

All read the REAL /v1/o11y endpoints and render honest states (loading /
runtime-initializing / empty) on 503/404 — never fabricated traces or
charts. Replaces the Traces deep-link placeholder (ObservabilityModule
removed); appends Sessions + Scores entries (no reorder of existing).
tsc --noEmit and next build both pass.
2026-06-27 17:43:28 -07:00
zeekay 164f073b50 feat(console2): shared observability primitives
DRY pieces for the o11y surfaces: pure formatters (date/latency/cost/
score value/JSON), shared detail parts (DetailRow/Badge/Tags/JsonCard),
the honest RuntimeNotice (503 not-initialized / 404 unrouted / access /
error), and the list Pager. No fabricated data.
2026-06-27 17:43:16 -07:00
zeekay 6df405adcd feat(console2): o11y API client (traces/sessions/scores)
Typed client for the /v1/o11y surface over the plain-REST transport
(restGet/v1Url): list endpoints return { data, meta }, detail endpoints
one object. 503/404 surface as typed ApiError so callers render honest
states. Tenancy is server-side (cookie credentials only).
2026-06-27 17:43:16 -07:00
zeekay f50dbdd9a6 feat(console2): port settings + model catalog pages as native @hanzo/gui modules
Port hanzoai/console's deep settings + models pages into console2 as native
modules wired to the REAL /v1 + /v1/iam endpoints. Honest loading/404/401/503
states everywhere — never fabricates keys, models, or settings.

New modules (registry, appended — no reorder):
- Model Catalog (AI): real GET /v1/models + best-effort /v1/pricing/models
  overlay; provider filter, premium tier, $/Mtok columns. Read-only catalog
  (routing lives in Models, credentials in Providers).
- API Keys (Dev): the account's real cloud credential (accessKey/accessSecret
  from get-account), masked by default with explicit reveal + copy; honest
  "managed by gateway" state when no key material is exposed to the browser.
- Settings (Security): tabbed General / API Keys / Members / Branding. General
  reads real account (get-account) + org (/v1/iam/get-organization); Members
  reads /v1/iam/get-users; API Keys embeds the shared ApiKeysView (DRY);
  Branding shows the real per-host runtime config. Identity mutations deep-link
  to IAM rather than being re-implemented.

Shared + API:
- ui/States.tsx: one honest async-state renderer (honestError + ErrorState +
  asApiError) with per-surface copy overrides. AdminModule refactored onto it
  (removes its duplicate honestError/ErrorCard).
- api/models-catalog.ts: CloudModelApi over the REST (non-envelope) /v1/models.
- api/admin.ts: IamAdminApi.organization(name) single-org getter.

Shell: top-bar account name now links to /settings (canonical account menu).

Gates: tsc --noEmit exit 0; next build exit 0.

Skipped (no real console2 backend — porting as shells would be slop): Langfuse
feature-flags (2 internal flags), developer-tools (Langfuse-branded copy),
automations/batch-exports/integrations (tRPC+Prisma only). Members/Audit/
Secrets/LLM-connections/Billing already exist as IAM/Audit/KMS/Providers/Cost.
2026-06-27 17:40:53 -07:00
Hanzo AI b4274bda4a release: v0.1.8 (cumulative — supersedes parallel v0.1.7) 2026-06-27 17:37:11 -07:00
Hanzo AI c9adc16cad merge: integrate fix/paas-live-data (CTO v0.1.7 paas wiring) into main
main already supersedes it: same real platform contract (/v1/apps +
/v1/org/{org}/cluster) PLUS X-Org-Id (resource modules), Bot/Wallet honest
states, Clusters real-contract rewrite, and the PaaS token fix. Recording the
integration so the line is single; cutting v0.1.8 as the cumulative release.
2026-06-27 17:36:51 -07:00
Hanzo AI f30cdbbee5 fix(modules): wire every embedded module to the real /v1 backend + cut v0.1.7
Live Playwright verification surfaced real wiring bugs; fixed all in console2
(honest states everywhere, no fakes):

- client.ts: stamp X-Org-Id (brand org) on every cloud call — the provisioning
  service 403'd 'X-Org-Id required' on the direct cloud-api path. Fixes the 7
  data modules (vector/sql/kv/s3/datastore/docdb/search) → real data / empty.
- platform.ts: rework to the REAL platform contract — GET /v1/apps (apps
  inventory) + GET|POST /v1/org/{org}/cluster; drop dead /v1/clusters + k8s
  passthrough. Status = real health board; Kubernetes = real workloads per
  cluster; Clusters = real dedicated-DOKS list (honest empty).
- platform/state.tsx: upstream 401/403 → honest 'not configured'.
- BotModule: /v1/bot/health 404 → honest 'not routed on this host'.
- WalletModule: /v1/billing/balance 404 → honest 'not available'.
- StatusTag: understand platform health verdicts (green/yellow/red).

typecheck + build clean. Operator CR token repointed (universe) to the correct
paas-console-token (the old hanzo-paas/MASTERTOKEN is rejected by platform).
2026-06-27 17:34:03 -07:00
zeekay a307d64b07 fix(paas): wire Clusters/Kubernetes/Status to real platform /v1 surface
Build Docker Image / docker (push) Successful in 2m36s
The PaaS modules targeted an assumed platform surface (/v1/clusters,
/v1/org/{org}/cluster/{id}/k8s/{kind}) that does not exist — the live
platform.hanzo.ai/v1 REST API serves /v1/org/{org}/cluster (org-scoped
dedicated clusters) and /v1/apps (the workload/drift board). Re-point the
single data layer (src/lib/api/platform.ts) at the real endpoints:

- listClusters → /v1/org/{org}/cluster (unwrap {clusters}); org from config.
- add AppsApi.listApps → /v1/apps (unwrap {apps}) — the live deploy data.
- drop the dead k8s-browse client (no backend) + CLUSTER_ROUTES guess.

Status + Kubernetes now render the real /v1/apps workloads (cluster,
namespace, image, health) instead of gating on a non-existent k8s-browse
behind a (for hanzo) empty dedicated-cluster list. StatusTag tones the
drift-board health (healthy/warning/down). Auth is unchanged: the /paas
proxy already sends Authorization: Bearer; the fix is the token VALUE
(operator CR → paas-console-token) + these paths.
2026-06-27 17:28:37 -07:00
zeekay c32bb30154 docs: correct catalog home comment for the 3-state enablement model 2026-06-27 16:44:35 -07:00
zeekay c27a62d271 catalog: canonical 10-category Open AI Cloud (GCP-compatible) + all-services Status
Build Docker Image / docker (push) Successful in 2m33s
Rebuild the product catalog to the canonical ten categories, in order:
AI · Compute · Data · Network · Security · Dev · Deploy · Observe · Web3 · Apps.
66 entries; each names its Google Cloud equivalent. Honest 3-state enablement:
enabled (23, in-console modules) / external (16, live Hanzo surfaces) / soon (27).
Every real working module is preserved (recategorized, not broken).

Add Status (Observe): live health of every Hanzo service across clusters, from
REAL data only — composes PlatformApi.listClusters + KubernetesApi deployments
over the /paas control plane, with honest not-configured/unavailable/empty
states and no fabricated dots.

Decomplect: one coming-soon surface for all soon leaves; delete the dead
duplicate PaaS client (PlatformModule + lib/paas) and the unused coming-soon
factory. Release v0.1.6.
2026-06-27 16:43:30 -07:00
136 changed files with 16228 additions and 2001 deletions
+9
View File
@@ -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

+5 -1
View File
@@ -31,7 +31,11 @@ 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
+4 -1
View File
@@ -20,7 +20,10 @@ RUN mkdir -p public
# 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
# Next 15 + @hanzo/gui (large RN dep tree) overflows Node's default heap on the
# build node → OOMKill (exit 137). Cap the heap generously, as every other Hanzo
# Next build does (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
+144 -13
View File
@@ -206,17 +206,148 @@ data with honest empty/not-configured states. No lorem stats, no demo projects,
no placeholder cards.
Build: arcd self-hosted CI (`.github/workflows/build-image.yml`, push to `main`
`ghcr.io/hanzoai/console2:sha-<sha>` + `:latest`). NOTE the per-env `tags` output
uses the GITHUB_OUTPUT **heredoc** form — a bare two-line `key=v1\nv2` is rejected
as "Invalid format" and fails the job at `Compute per-env config` (this silently
broke every build until 2026-06).
`ghcr.io/hanzoai/console2:v<package.json version>`, SEMVER only). The
`hanzo-build-linux-amd64` ARC runner pool is the builder (online; not GHA-hosted).
Deploy reality: console2 is an **undeclared raw Deployment** (ns `hanzo`, no
operator CR / no universe manifest / no `.platform.yml`) and the hanzo-k8s operator
is scaled `0/0`, so platform.hanzo.ai has no drive surface for its image (its
`/v1/apps` is observe-only; `redeploy` only restarts the *declared* image). Per the
internal deploy runbook the interim roll is:
`kubectl --context do-sfo3-hanzo-k8s -n hanzo set image deploy/console2 console2=ghcr.io/hanzoai/console2:sha-<sha>`
then `rollout status`. **Debt:** declare console2 as an operator `Service` CR +
`.platform.yml` so deploys go push→arcd→CR→reconcile (kills the kubectl step).
Verify live with headless Playwright on console2.hanzo.ai (superuser `z@hanzo.ai`).
Deploy: console2 IS an operator `Service` CR now (`hanzo.ai/v1`, `hsvc console2`,
ns `hanzo`) — declared in `universe/infra/k8s/operator/crs/console2-v1.yaml`.
Bump `spec.image.tag`, `kubectl apply`, the operator reconciles. Verify live with
headless Playwright on console2.hanzo.ai.
## Live verification + backend wiring (v0.1.8)
> v0.1.7 was a parallel CTO branch (`fix/paas-live-data`) that wired only
> Clusters/Kubernetes/Status to the platform `/v1` surface; it is integrated here
> (`-s ours`) and superseded — v0.1.8 is the cumulative release with the full set
> below.
Every embedded module was Playwright-verified live against the real `/v1` backend
(authenticated hanzo-org admin). The backend topology console2 actually talks to:
the same-origin `/v1` ingress routes to **cloud-api** directly (NOT the full
api.hanzo.ai gateway), and `/paas/*` is console2's own server route → platform.
Findings + fixes (all in console2; honest states everywhere, no fakes):
- **X-Org-Id (the big one).** The provisioning sub-service (vector/sql/kv/s3/
datastore/docdb/search) requires an `X-Org-Id` header and 403s `"X-Org-Id
required"` without it — cloud-api on the direct path does NOT inject it from the
session. Fix: `lib/api/client.ts` now stamps `X-Org-Id: config.iamOrgName`
(brand org, the user's own) on every cloud call (`baseHeaders`). All 7 data
modules now return real data / honest empty `[]`.
- **PaaS token was wrong.** The CR wired `PAAS_SERVICE_TOKEN` to
`hanzo-paas/MASTERTOKEN` (`hanzo-master-token`), which platform.hanzo.ai
**rejects (401)**. The correct token is in secret **`paas-console-token`** key
`PAAS_SERVICE_TOKEN` (== `platform-service-token`). CR repointed there.
- **Platform contract was wrong.** The real platform serves `GET /v1/apps` (the
apps inventory: declared/running/latest tag + drift + health + cluster +
namespace, ~100 services) and `GET|POST /v1/org/{org}/cluster` — NOT
`/v1/clusters` and NOT any `/k8s/{kind}` passthrough (those 401/404). `lib/api/
platform.ts` reworked to `PlatformApi.apps()` + org-scoped `listClusters`/
`provisionCluster`; dead `KubernetesApi`/`CLUSTER_ROUTES` removed.
- **Status** now reads `/v1/apps` → REAL health board (Services/Healthy/Clusters).
- **Kubernetes** now reads `/v1/apps` → REAL workloads per cluster (picker from
the clusters that actually appear).
- **Clusters** lists real dedicated DOKS via `/v1/org/{org}/cluster` (honest
empty; provision form wired to the real endpoint). Attach-by-kubeconfig dropped
(no backend).
- `interpretPlatformError` maps upstream 401/403 → honest "not configured".
- **Bot** `/v1/bot/health` 404s on cloud-api (bot-gateway runs behind
api.hanzo.ai/hanzo.bot, not this host) → honest "not routed on this host" state
(was a red error).
- **Wallet** cloud-credit `/v1/billing/balance` 404s here (billing ships
separately) → honest "not available on this deployment" (was a scary error).
HUSD balance/top-up already honest "coming" (token unconfigured).
- **Providers was broken** — `ProviderListView`/`ProviderEditView` imported the
ZAP twin (`~/lib/zap`), but the cloud `/zap` WS face is NOT served (the edge
returns SPA HTML, 200 not a WS upgrade — documented in `lib/zap/client.ts`), so
the module showed "Failed to load providers". Switched both back to the working
REST `~/lib/api` (identical surface). The ZAP twin stays as the proof-of-pattern
until `/zap` is bound. Providers now shows real/empty over REST like every module.
- Already-correct honest states (unchanged): IAM/Audit + KMS/Secrets (`/v1/iam`,
`/v1/kms` 404 → "not available on this deployment"); Observability (`/v1/o11y`
503 → "runtime not initialized"). Plans/Embeddings show real data; Models/
Providers/Applications/Chat honest-empty.
`StatusTag` now also understands platform health verdicts (green/yellow/red).
## Working AI + API keys + chrome polish (v0.6.0)
The investor-demo wave. ROOT CAUSE of "chats/playground don't work": the gateway
chat endpoints REQUIRE `Authorization: Bearer` — a session cookie alone is
rejected ("Invalid API key format"). The browser sent cookie-only, so every AI
call (chat, playground, cmd+K `>`/`?`) failed. Fixed with two server routes that
keep all credentials server-side (the browser only ever sends its session cookie):
- **`app/ai/[...path]/route.ts`** — keyless AI proxy. Resolves the user from the
session cookie (cloud `/v1/get-account`), mints a SHORT-LIVED user-bound IAM
token (`/v1/iam/issue-user-token`, cached per-user until ~60s pre-expiry) as the
confidential `hanzo-console` client, and forwards to `AI_GATEWAY_URL/v1/<path>`
with `Bearer <token>`. Allow-listed to `v1/models|chat|chat/completions|
embeddings|rerank` (not a general tunnel). `playground.ts` now points at this
proxy (`<origin>/ai`), so Models/Playground/Chat/cmd+K all work with no key in
the browser and no rotation on a chat turn.
- **`app/keys/route.ts`** — per-user `hk-` Cloud API key. POST mint/rotate, DELETE
revoke, GET status (no secret). Same app-on-behalf pattern via
`/v1/iam/mint-user-keys` + `/v1/iam/revoke-user-keys`. The `hk-` secret is shown
ONCE (POST). `ApiKeysModule` is now create/copy/rotate/revoke.
- Shared trust boundary: `src/lib/server/identity.ts` (server-only) — `resolveUser`
+ `mintUserKey`/`revokeUserKey`/`issueUserToken`. The `hanzo-console` client is
allow-listed in IAM `IAM_KEY_MINT_ALLOWED_APPS`; verified end-to-end that a
minted `hk-` key and an issued user JWT both 200 on `api.hanzo.ai/v1/chat/
completions`.
- **Chat is interactive** (`chat/ChatConversation.tsx`): a real multi-turn
conversation over `AiApi.chat` (→ the `/ai` proxy), with a Zen default model,
honest 402 "add credits" state, and a "History" toggle to the old session list.
- **Chrome**: the sidebar/header show the Hanzo **H mark + "Console"**
(`ui/HanzoMark.tsx` + `ui/BrandLogo.tsx`; `BrandLogo` shows the org's IAM logo
when set, else the H). A fullscreen **app launcher** (`components/AppLauncher.tsx`,
Launchpad-style grid + filter) opens from the header "Apps" button, the sidebar
grid icon, and the command palette's "Browse all apps". cmd+K stays the palette.
Server-only env the routes need (added to `console2-v1.yaml`, never `NEXT_PUBLIC_`):
`IAM_URL`, `CLOUD_API_URL` (in-cluster cloud-api), `AI_GATEWAY_URL` (api.hanzo.ai),
and `IAM_MINT_CLIENT_ID`/`IAM_MINT_CLIENT_SECRET` from secret `hanzo-console-iam-creds`.
## Admin console live data + org switching (v0.7.0)
The "models empty" + "org switcher broken" wave. ONE root cause: `/v1/iam`,
`/v1/kms`, `/v1/models` 404 (or 401 cookie-only) on the console host, so the
catalog, switcher, IAM, and KMS modules rendered honest-empty. Fix = route every
privileged call through console2's OWN server proxies (which add the user bearer +
the admin gate), and make org scope a first-class, switchable value.
- **Model catalog (the "models missing" bug).** `CloudModelApi.list()` hit cloud
`/v1/models` with a cookie only → 401 → empty. Repointed at the `/ai` proxy via
the shared `aiV1Url('models')` (`lib/api/client.ts` now owns `aiBase`/`aiV1Url`
ONE place defines the proxy origin; `playground.ts` uses it too). The proxy mints
a short-lived user token, so the catalog populates with the live Zen models.
Pricing stays best-effort on the cloud origin (degrades to "—", never fabricated).
- **Org scope is a value, not a place** (`lib/org-scope.ts`). `currentOrg()` /
`setCurrentOrg()` / `isScopedAway()` / `filterOrgs()`. Default = the brand org;
a global admin (z@hanzo.ai) can switch to ANY org. Brand identity (host wordmark/
logo) is orthogonal and unchanged — only the DATA scope moves. `client.ts`
`baseHeaders` now stamps `X-Org-Id: currentOrg()` (was the fixed brand org), so
every cloud-data module re-scopes on switch.
- **OrgSwitcher** lists ALL visible orgs (`IamAdminApi.organizations()` via the
`/admin/iam` proxy → global admin sees every org), adds a **filter** box
(`filterOrgs`), and **switches in place**: `setCurrentOrg` + reload refetches
every module under the new `X-Org-Id`. The IAM/KMS proxies authorize a global
admin for any org and pin a brand admin to their own, so the re-scope is safe.
- **IAM module** (`AdminModule.tsx` `IamModule`/`AuditModule`) reads users/roles/
records for `currentOrg()` (the org list itself is unscoped — what powers the
switcher). **KMS module** (`KmsModule.tsx`) was a dead cloud-path probe; now a
names-only inventory over `KmsAdminApi.list({ org })` (the `/admin/kms` proxy +
kmsd's metadata-list endpoint, v0.159.4+). Values are NEVER fetched/rendered;
honest states: loading, operator-access-required (403), listing-unavailable (404),
empty.
- **Decomplected gate** (`lib/server/admin-policy.ts`, pure, tested). `gateAllows`
(`@<adminDomain>` email AND IAM admin), `ownerAllowed`, `orgFor` — extracted from
`getAdminGate` + the IAM/KMS routes so the SAME predicate that ships is the one
unit-tested. A brand admin can never `orgFor` to another org's KMS (no secret
leak across orgs).
- **Tests** (vitest, `npm test`): `admin-policy.test.ts` (gate allow/deny + tenant
scoping), `org-scope.test.ts` (default→switch→reset + filter), `models-catalog.
test.ts` (catalog fetches `<origin>/ai/v1/models`, never the cookie-only cloud
path). RED→GREEN, 22 tests. `tsc --noEmit` + `next build` clean.
- The console2 CR already carries the env the proxies need (`IAM_URL`,
`CLOUD_API_URL`, `AI_GATEWAY_URL`, `IAM_MINT_CLIENT_*`); `KMS_URL` defaults to
`http://kms.hanzo.svc`. `admin.hanzo.ai` added to the CR ingress hosts.
+2
View File
@@ -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
+16 -3
View File
@@ -1,15 +1,28 @@
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 { ToastProvider } from '~/components/ui/Toast'
import { CommandPaletteProvider } from '~/components/CommandPalette'
import { AppLauncherProvider } from '~/components/AppLauncher'
export default function DashboardLayout({ children }: { children: ReactNode }) {
return (
<AuthGate>
<PreferencesProvider>
<DashboardShell>{children}</DashboardShell>
</PreferencesProvider>
<OrgGate>
<PreferencesProvider>
<ToastProvider>
{/* AppLauncher wraps the palette so the palette can open the launcher. */}
<AppLauncherProvider>
<CommandPaletteProvider>
<DashboardShell>{children}</DashboardShell>
</CommandPaletteProvider>
</AppLauncherProvider>
</ToastProvider>
</PreferencesProvider>
</OrgGate>
</AuthGate>
)
}
+27 -34
View File
@@ -2,43 +2,31 @@
/**
* 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.
* the ten canonical categories, with its enablement state and Google Cloud
* equivalent. `enabled` and `external` products open straight in (in-console or
* a new tab); `soon` products link to their discover screen. Each card can be
* pinned to the sidebar (persisted to the account). Rendered entirely from the
* catalog registry.
*/
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 { branding, config } from '~/config'
import { config } from '~/config'
import { catalogByCategory, type CatalogEntry } from '~/lib/products/registry'
import { openProduct } from '~/lib/products/open'
import { useFavorites } from '~/lib/products/favorites'
import { PageHeader } from '~/components/ui/PageHeader'
const STATUS_LABEL = { enabled: 'Enabled', external: 'External', soon: 'Soon' } as const
const STATUS_BG = { enabled: '$color5', external: '$color3', soon: '$color4' } as const
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">
<XStack bg={STATUS_BG[entry.status]} 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}
{STATUS_LABEL[entry.status]}
</Text>
</XStack>
)
@@ -58,15 +46,22 @@ function ProductCard({
onLearnMore: () => void
}) {
const Icon = entry.icon
const enabled = entry.status === 'enabled'
const openable = entry.status === 'enabled' || entry.status === 'external'
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
@@ -96,15 +91,13 @@ function ProductCard({
<StatusBadge entry={entry} />
<Button
size="$2"
bg={enabled ? '$color5' : 'transparent'}
bg={openable ? '$color5' : 'transparent'}
borderWidth={1}
borderColor="$borderColor"
onPress={enabled ? onOpen : onLearnMore}
iconAfter={
enabled && entry.kind === 'external' ? <ExternalLink size={14} /> : <ArrowRight size={14} />
}
onPress={openable ? onOpen : onLearnMore}
iconAfter={entry.kind === 'external' ? <ExternalLink size={14} /> : <ArrowRight size={14} />}
>
{enabled ? 'Open' : 'Get started'}
{openable ? 'Open' : 'Learn more'}
</Button>
</XStack>
</Card>
@@ -120,7 +113,7 @@ export default function DashboardHome() {
return (
<>
<PageHeader
title={branding.name}
title={config.brandName}
subtitle={`See, enable, and manage every ${config.brandName} product from one place.`}
/>
{groups.map((group) => (
+131
View File
@@ -0,0 +1,131 @@
/**
* Server-gated IAM admin proxy — the ONLY way the browser reaches IAM admin ops.
*
* The browser holds no IAM credential and never calls IAM directly. It calls this
* SAME-ORIGIN route with just its session cookie; the handler resolves the user,
* enforces the brand-admin gate (`getAdminGate`: verified @<adminDomain> email AND
* IAM admin), then forwards to `${IAM_URL}/v1/iam/<segment>` as the user (a
* short-lived user-bound bearer) so IAM enforces tenant isolation from the
* verified `owner` claim. The IAM `{status,msg,data,data2}` envelope is returned
* verbatim with its status code.
*
* Least privilege: only an explicit allow-list of admin segments is reachable
* (GET reads / POST mutations), and a non-global admin may reference ONLY their
* own org — closing the casdoor `GetUsers(owner)` cross-tenant read gap, which is
* NOT scoped by the caller server-side.
*/
import { type NextRequest, NextResponse } from 'next/server'
import { getAdminGate, adminBearer, iamBaseUrl, type AdminGate } from '~/lib/server/identity'
import { ownerAllowed as policyOwnerAllowed } from '~/lib/server/admin-policy'
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',
])
/**
* Organization objects are owned by casdoor'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. It is never acceptable for tenant data (users,
* roles, ...), where the owner must be the caller's own org.
*/
const ORG_ENDPOINTS = new Set(['get-organizations', 'get-organization'])
const forbidden = () => NextResponse.json({ error: 'forbidden' }, { status: 403 })
const notFound = () => NextResponse.json({ error: 'not found' }, { status: 404 })
/** The `<owner>` in an `<owner>/<name>` id, or null. */
function idOwner(id: string | null): string | null {
if (!id) return null
const slash = id.indexOf('/')
return slash > 0 ? id.slice(0, slash) : id
}
/**
* A non-global admin may reference ONLY their own org. casdoor's read endpoints
* (GetUsers/GetUser) do not enforce this, so the proxy must. The decision is the
* pure `admin-policy` predicate (tested in admin-policy.test.ts); this wrapper
* just supplies the per-segment org-metadata exception (`ORG_ENDPOINTS`).
*/
function ownerAllowed(segment: string, owner: string | null, gate: AdminGate): boolean {
return policyOwnerAllowed(owner, {
isGlobalAdmin: gate.user.isGlobalAdmin,
orgScope: gate.orgScope,
orgMetadataOk: ORG_ENDPOINTS.has(segment),
})
}
async function forward(req: NextRequest, segment: string, allowed: Set<string>): Promise<NextResponse> {
const gate = await getAdminGate(req)
if (!gate) return forbidden()
if (!allowed.has(segment)) return notFound()
const url = req.nextUrl
if (!ownerAllowed(segment, url.searchParams.get('owner'), gate)) return forbidden()
if (!ownerAllowed(segment, idOwner(url.searchParams.get('id')), gate)) return forbidden()
let bearer: string
try {
bearer = await adminBearer(gate.user)
} catch (e) {
return NextResponse.json(
{ status: 'error', msg: `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 (req.method === 'POST') {
headers['Content-Type'] = 'application/json'
init.body = await req.text()
}
try {
const res = await fetch(`${iamBaseUrl()}/v1/iam/${segment}${url.search}`, 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: `IAM 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.join('/'), GET_SEGMENTS)
}
export async function POST(req: NextRequest, ctx: Ctx) {
return forward(req, (await ctx.params).path.join('/'), POST_SEGMENTS)
}
+135
View File
@@ -0,0 +1,135 @@
/**
* 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'
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> {
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 fetch(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)
}
+114
View File
@@ -0,0 +1,114 @@
/**
* 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; this server handler resolves the user, mints a
* SHORT-LIVED, user-bound IAM token (issue-user-token, cached until just before
* expiry), 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.
*
* Least privilege: only the read/inference AI endpoints are proxied; anything
* else 404s, so this is not a general gateway tunnel.
*/
import { type NextRequest, NextResponse } from 'next/server'
import { resolveUser, issueUserToken, type SessionUser } from '~/lib/server/identity'
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/chat',
'v1/chat/completions',
'v1/embeddings',
'v1/rerank',
])
// ── Short-lived user-token cache ─────────────────────────────────────────────
// issue-user-token is an IAM round-trip; cache the JWT per user until ~60s before
// it expires so a chat session reuses one token instead of issuing per turn.
type CachedToken = { token: string; expMs: number }
const tokenCache = new Map<string, CachedToken>()
const SKEW_MS = 60_000
const FALLBACK_TTL_MS = 5 * 60_000
async function tokenFor(user: SessionUser): Promise<string> {
const hit = tokenCache.get(user.id)
if (hit && hit.expMs > Date.now()) return hit.token
const { accessToken, expiresIn } = await issueUserToken(user)
const ttl = expiresIn > 0 ? expiresIn * 1000 : FALLBACK_TTL_MS
tokenCache.set(user.id, { token: accessToken, expMs: Date.now() + ttl - SKEW_MS })
return accessToken
}
async function forward(req: NextRequest, path: string[]): Promise<NextResponse> {
const rel = path.join('/')
if (!ALLOWED.has(rel)) {
return NextResponse.json({ error: { message: 'Not found', type: 'not_found' } }, { status: 404 })
}
const user = await resolveUser(req)
if (!user) {
return NextResponse.json(
{ error: { message: 'Sign in to use AI.', type: 'auth_error', code: 'unauthenticated' } },
{ status: 401 },
)
}
let token: string
try {
token = await tokenFor(user)
} catch (e) {
return NextResponse.json(
{ error: { message: `Could not authorize the request: ${e instanceof Error ? e.message : String(e)}`, type: 'auth_error' } },
{ status: 502 },
)
}
const url = `${AI_GATEWAY_URL}/${rel}${req.nextUrl.search}`
const init: RequestInit = {
method: req.method,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
Accept: 'application/json',
// The gateway derives org/user from the JWT; pass the resolved org too so a
// pooled-org backend scopes correctly either way (it strips client headers).
'X-Org-Id': user.owner,
},
cache: 'no-store',
}
if (req.method !== 'GET' && req.method !== 'HEAD') {
init.body = await req.text()
}
try {
const res = await fetch(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(
{ error: { message: `AI gateway unreachable: ${e instanceof Error ? e.message : String(e)}`, type: 'upstream_error' } },
{ 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)
}
+20
View File
@@ -0,0 +1,20 @@
/**
* `/docs` → the brand documentation site (new origin), server-side redirect.
*
* Docs are an EXTERNAL product on their own domain (docs.hanzo.ai / docs.lux.network
* / …), never an in-app route — so a typed or bookmarked `console.<brand>/docs`
* must land on the real docs, not the catch-all `notFound()` (the old 404). The
* brand is resolved from the request Host so a lux/zoo console redirects to ITS
* docs. The sidebar "Docs" entry and the header "?" open the same URL in a new tab;
* this handler only covers the direct-navigation case.
*/
import { type NextRequest, NextResponse } from 'next/server'
import { resolveConfig } from '~/config'
export const runtime = 'nodejs'
export function GET(req: NextRequest) {
const host = req.headers.get('host')
return NextResponse.redirect(resolveConfig(host ?? undefined).docsUrl, 308)
}
+70
View File
@@ -0,0 +1,70 @@
/**
* Cloud API key management — the per-user `hk-` credential, minted server-side.
*
* Same-origin (`/keys`): the browser sends only its first-party session cookie;
* this server handler resolves the user from that cookie and calls IAM as the
* confidential `hanzo-console` client on the user's behalf. The `hk-` secret is
* returned to the browser ONLY at creation (POST) — standard show-once handling —
* and is otherwise never echoed. Authorization is the user's own session: a
* caller can only ever mint/revoke their OWN key (IAM binds the id to the
* resolved session), never another tenant's.
*
* POST /keys → mint (or rotate) the key; returns { accessKey } once.
* DELETE /keys → revoke the key (the old key stops working).
* GET /keys → whether a key exists (no secret material).
*/
import { type NextRequest, NextResponse } from 'next/server'
import { resolveUser, mintUserKey, revokeUserKey, mintConfigured } from '~/lib/server/identity'
export const runtime = 'nodejs'
const unauthorized = () =>
NextResponse.json({ error: 'Sign in to manage API keys.' }, { status: 401 })
const notConfigured = () =>
NextResponse.json(
{ error: 'API key minting is not configured on this deployment (IAM client unset).' },
{ status: 501 },
)
export async function GET(req: NextRequest): Promise<NextResponse> {
const user = await resolveUser(req)
if (!user) return unauthorized()
// No secret material on GET — only whether a key exists and its public prefix.
const hasKey = Boolean(user.accessKey)
return NextResponse.json({
hasKey,
keyPrefix: hasKey ? user.accessKey.slice(0, 11) : '',
})
}
export async function POST(req: NextRequest): Promise<NextResponse> {
const user = await resolveUser(req)
if (!user) return unauthorized()
if (!mintConfigured()) return notConfigured()
try {
const accessKey = await mintUserKey(user)
return NextResponse.json({ accessKey })
} catch (e) {
return NextResponse.json(
{ error: `Could not mint an API key: ${e instanceof Error ? e.message : String(e)}` },
{ status: 502 },
)
}
}
export async function DELETE(req: NextRequest): Promise<NextResponse> {
const user = await resolveUser(req)
if (!user) return unauthorized()
if (!mintConfigured()) return notConfigured()
try {
await revokeUserKey(user)
return NextResponse.json({ ok: true })
} catch (e) {
return NextResponse.json(
{ error: `Could not revoke the API key: ${e instanceof Error ? e.message : String(e)}` },
{ status: 502 },
)
}
}
+15
View File
@@ -5,15 +5,30 @@
* 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.
*/
import { type NextRequest, NextResponse } from 'next/server'
import { getAdminGate } from '~/lib/server/identity'
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> {
// Brand-admin gate FIRST — the service token below is control-plane god-mode.
if (!(await getAdminGate(req))) {
return NextResponse.json({ error: 'forbidden' }, { status: 403 })
}
if (!TOKEN) {
return NextResponse.json(
{ error: 'PaaS control plane is not configured (PAAS_SERVICE_TOKEN missing).' },
+1533 -81
View File
File diff suppressed because it is too large Load Diff
+12 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/console2",
"version": "0.1.5",
"version": "0.7.4",
"private": true,
"license": "BSD-3-Clause",
"author": "Hanzo AI <dev@hanzo.ai>",
@@ -9,29 +9,34 @@
"dev": "next dev -p 4000",
"build": "next build",
"start": "next start -p 4000",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@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",
"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": {
"@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"
}
}
+195
View File
@@ -0,0 +1,195 @@
'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 { ExternalLink, Lock, Search } from '@hanzogui/lucide-icons-2'
import { catalogByCategory, type CatalogEntry } from '~/lib/products/registry'
import { searchCatalog } from '~/lib/products/search'
import { openProduct } from '~/lib/products/open'
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, onPress }: { entry: CatalogEntry; 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"
bg="$color4"
position="relative"
>
<Icon size={26} />
{entry.admin ? (
<XStack position="absolute" t={-4} r={-4} bg="$color2" rounded="$10" p="$1">
<Lock size={11} opacity={0.7} />
</XStack>
) : entry.kind === 'external' ? (
<XStack position="absolute" t={-4} r={-4} bg="$color2" rounded="$10" p="$1">
<ExternalLink 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 [query, setQuery] = useState('')
const groups = useMemo(() => catalogByCategory(), [])
const filtered = useMemo(() => (query.trim() ? searchCatalog(query) : null), [query])
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} 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} 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>
)
}
+623
View File
@@ -0,0 +1,623 @@
'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,
ExternalLink,
House,
LayoutGrid,
Lock,
LogOut,
Moon,
Search,
SlidersHorizontal,
Sparkles,
Sun,
Zap,
} from '@hanzogui/lucide-icons-2'
import { AiApi, IamAdminApi, type Organization } from '~/lib/api'
import { catalog, findEntry, type CatalogEntry } from '~/lib/products/registry'
import { searchCatalog } from '~/lib/products/search'
import { openProduct } from '~/lib/products/open'
import { currentOrg, switchOrg } from '~/lib/org-scope'
import { useSession } from '~/lib/auth/session'
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
}
/** System prompt for `>` mode: lets the model map a request to ONE product id. */
function navSystemPrompt(): string {
const list = catalog.map((e) => `${e.id}${e.label}${e.category}`).join('\n')
return [
'You are the command bar for the Hanzo Cloud Console.',
'The user wants to find or open a product, or asks a question.',
'Products (id — label — category):',
list,
'',
'If the request clearly refers to ONE product above, reply with exactly: NAV <id>',
'using an id from the list and nothing else. Otherwise answer concisely in plain text.',
].join('\n')
}
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,
onPress,
}: {
entry: CatalogEntry
active: boolean
onPress: () => void
}) {
const Icon = entry.icon
return (
<XStack
onPress={onPress}
cursor="pointer"
items="center"
gap="$3"
px="$3"
py="$2.5"
rounded="$3"
bg={active ? '$color5' : 'transparent'}
hoverStyle={{ bg: active ? '$color5' : '$color3' }}
>
<Icon size={17} />
<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}
{entry.kind === 'external' ? <ExternalLink size={13} opacity={0.45} /> : <ArrowRight size={13} opacity={active ? 0.8 : 0.3} />}
</XStack>
)
}
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"
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 { 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.
useEffect(() => {
if (!open) return
let live = true
IamAdminApi.organizations()
.then((p) => {
if (live) setOrgs(p.rows ?? [])
})
.catch(() => {
if (live) setOrgs([])
})
return () => {
live = false
}
}, [open])
// 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])
const entryResults = useMemo(
() => (mode === 'catalog' ? searchCatalog(query).slice(0, 50) : []),
[mode, query],
)
const matchedActions = useMemo(
() => (mode === 'catalog' && sub ? actions.filter((a) => actionMatches(a, sub.toLowerCase())) : []),
[mode, sub, actions],
)
// One ordered list (actions first, then products) so ↑/↓/↵ traverse both.
type Item = { kind: 'action'; action: PaletteAction } | { kind: 'entry'; entry: CatalogEntry }
const items = useMemo<Item[]>(
() => [
...matchedActions.map((action) => ({ kind: 'action' as const, action })),
...entryResults.map((entry) => ({ kind: 'entry' as const, entry })),
],
[matchedActions, entryResults],
)
// 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],
)
const submit = useCallback(async () => {
if (!sub) return
setRun({ status: 'loading' })
try {
if (mode === 'ai') {
const ans = (await AiApi.chat({ question: sub, system: navSystemPrompt() })).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 {
const ans = await AiApi.ragChat({ question: sub, store: 'docs' })
setRun({ status: 'text', text: ans })
}
} catch (e) {
setRun({ status: 'error', state: classifyBackend(e) })
}
}, [mode, sub])
// 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 activate(it.entry)
}
}
} 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, onOpenChange])
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)" />
<Dialog.Content
key="palette-content"
bordered
elevate
width={640}
maxW="90%"
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 */}
<YStack 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 p="$2">
<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} />
))}
{entryResults.length > 0 && matchedActions.length > 0 ? <SectionLabel>Products</SectionLabel> : null}
{entryResults.map((entry, j) => {
const i = matchedActions.length + j
return <CatalogRow key={entry.id} entry={entry} active={i === sel} onPress={() => activate(entry)} />
})}
</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 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>
)
}
+249 -43
View File
@@ -1,40 +1,111 @@
'use client'
/**
* Dashboard shell — sidebar (Pinned + categorized catalog) + top bar + content.
* Dashboard shell — sidebar (Overview + Docs + Pinned + categorized catalog) + top
* bar + content.
*
* The sidebar renders entirely from the product catalog: a Pinned section the
* user curates (favorites), then every product grouped by category. Each row
* opens the product (in-console route or external tab) and carries a pin
* toggle. Active state is an EXACT match so e.g. `stores` never lights up while
* a sibling is open. Adding a product to the catalog makes it appear here with
* no shell edits.
* The sidebar renders from the product catalog: fixed Overview/Docs links, a Pinned
* section the user curates (favorites), then every product grouped by category. A
* filter box narrows the whole sidebar so any product is one keystroke away, and
* each catalog row carries a pin toggle. The brand "H" mark COLLAPSES/expands the
* sidebar (icon-only ↔ full), persisted to the account so it follows the user.
* Active state is an EXACT match so e.g. `stores` never lights up while a sibling
* is open. Adding a product to the catalog makes it appear here with no shell edits.
*/
import type { ReactNode } from 'react'
import { useMemo, useState, type ComponentType, type ReactNode } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import { Button, ScrollView, Text, XStack, YStack } from '@hanzo/gui'
import { LogOut, Star, Lock, ExternalLink, LayoutGrid } from '@hanzogui/lucide-icons-2'
import { Button, Input, ScrollView, Text, XStack, YStack } from '@hanzo/gui'
import {
BookOpen,
CircleHelp,
ExternalLink,
House,
LayoutGrid,
Lock,
LogOut,
PanelLeft,
Search,
SlidersHorizontal,
Star,
X,
} from '@hanzogui/lucide-icons-2'
import { branding } from '~/config'
import { config } from '~/config'
import { catalogByCategory, findEntry, type CatalogEntry } from '~/lib/products/registry'
import { entryMatches } from '~/lib/products/search'
import { openProduct } from '~/lib/products/open'
import { useFavorites } from '~/lib/products/favorites'
import { usePreferences } from '~/lib/products/preferences'
import { useSession } from '~/lib/auth/session'
import { CommandSearchBox } from '~/components/CommandPalette'
import { useAppLauncher } from '~/components/AppLauncher'
import { ThemeToggle } from '~/components/ui/ThemeToggle'
import { Breadcrumbs } from '~/components/ui/Breadcrumbs'
import { BrandLogo } from '~/components/ui/BrandLogo'
import { OrgSwitcher } from '~/components/OrgSwitcher'
const EXPANDED_W = 264
const COLLAPSED_W = 64
/** A fixed (non-catalog) sidebar link: Overview, Docs. */
function FixedRow({
icon: Icon,
label,
active,
external,
collapsed,
onPress,
}: {
icon: ComponentType<{ size?: number }>
label: string
active?: boolean
external?: boolean
collapsed: boolean
onPress: () => void
}) {
return (
<Button
onPress={onPress}
bg={active ? '$color5' : 'transparent'}
justify={collapsed ? 'center' : 'flex-start'}
icon={<Icon size={18} />}
iconAfter={!collapsed && external ? <ExternalLink size={12} opacity={0.4} /> : undefined}
size="$3"
aria-label={label}
>
{collapsed ? undefined : label}
</Button>
)
}
function NavRow({
entry,
active,
pinned,
collapsed,
onOpen,
onToggle,
}: {
entry: CatalogEntry
active: boolean
pinned: boolean
collapsed: boolean
onOpen: () => void
onToggle: () => void
}) {
const Icon = entry.icon
if (collapsed) {
return (
<Button
onPress={onOpen}
bg={active ? '$color5' : 'transparent'}
justify="center"
icon={<Icon size={18} />}
size="$3"
aria-label={entry.label}
/>
)
}
const hint = entry.admin ? (
<Lock size={12} opacity={0.45} />
) : entry.kind === 'external' ? (
@@ -53,10 +124,10 @@ function NavRow({
>
{entry.label}
</Button>
{(entry.status === 'soon' || entry.status === 'waitlist') && (
<YStack px="$1.5" py={1} rounded="$10" bg={entry.status === 'waitlist' ? '$color4' : '$color4'}>
{entry.status === 'soon' && (
<YStack px="$1.5" py={1} rounded="$10" bg="$color4">
<Text fontSize={9} fontWeight="800" letterSpacing={0.5}>
{entry.status === 'waitlist' ? 'WAITLIST' : 'SOON'}
SOON
</Text>
</YStack>
)}
@@ -77,52 +148,146 @@ export function DashboardShell({ children }: { children: ReactNode }) {
const router = useRouter()
const { account, signOut } = useSession()
const { pinned, toggle, isPinned } = useFavorites()
const { get, set } = usePreferences()
const launcher = useAppLauncher()
const [filter, setFilter] = useState('')
const collapsed = get<boolean>('sidebarCollapsed', false)
const toggleCollapsed = () => set('sidebarCollapsed', !collapsed)
const push = (path: string) => router.push(path)
const isActive = (id: string) => pathname === `/${id}` || pathname.startsWith(`/${id}/`)
const openDocs = () => {
if (typeof window !== 'undefined') window.open(config.docsUrl, '_blank', 'noopener')
}
// When collapsed the filter box is hidden, so it never silently hides icons.
const q = (collapsed ? '' : filter).trim().toLowerCase()
const filtering = q.length > 0
const pinnedEntries = pinned
.map((id) => findEntry(id))
.filter((e): e is CatalogEntry => Boolean(e))
const groups = catalogByCategory()
const groups = useMemo(
() =>
catalogByCategory()
.map((g) => ({ category: g.category, entries: g.entries.filter((e) => entryMatches(e, q)) }))
.filter((g) => g.entries.length > 0),
[q],
)
return (
<XStack flex={1} minH="100vh" bg="$background">
<YStack
width={264}
width={collapsed ? COLLAPSED_W : EXPANDED_W}
p="$3"
gap="$2"
borderRightWidth={1}
borderColor="$borderColor"
bg="$color1"
>
<Button
onPress={() => push('/')}
bg={pathname === '/' ? '$color5' : 'transparent'}
justify="flex-start"
icon={<LayoutGrid size={18} />}
size="$3"
mb="$1"
>
<Text fontWeight="800" fontSize="$5">
{branding.name}
</Text>
</Button>
{/* Header: the H mark toggles collapse; the grid opens the app launcher. */}
{collapsed ? (
<YStack items="center" gap="$2" mb="$1">
<Button
size="$3"
chromeless
onPress={toggleCollapsed}
icon={<BrandLogo size={22} wordmark={false} />}
aria-label="Expand sidebar"
/>
<Button
size="$3"
chromeless
icon={<LayoutGrid size={18} />}
onPress={launcher.open}
aria-label="All apps"
/>
</YStack>
) : (
<XStack items="center" gap="$1" mb="$1">
<Button
flex={1}
onPress={toggleCollapsed}
bg="transparent"
justify="flex-start"
iconAfter={<PanelLeft size={15} opacity={0.4} />}
size="$3"
aria-label="Collapse sidebar"
>
<BrandLogo size={22} />
</Button>
<Button
size="$3"
chromeless
icon={<LayoutGrid size={18} />}
onPress={launcher.open}
aria-label="All apps"
/>
</XStack>
)}
{/* Filter box — narrows the whole sidebar to find any product fast. */}
{!collapsed ? (
<XStack
items="center"
gap="$2"
px="$2.5"
height={34}
rounded="$3"
borderWidth={1}
borderColor="$borderColor"
bg="$color2"
>
<Search size={14} opacity={0.6} />
<Input
flex={1}
unstyled
value={filter}
onChangeText={setFilter}
placeholder="Filter products…"
fontSize="$3"
color="$color12"
autoCapitalize="none"
autoCorrect={false}
/>
{filter ? (
<Button size="$1" chromeless icon={<X size={13} />} onPress={() => setFilter('')} aria-label="Clear filter" />
) : null}
</XStack>
) : null}
<ScrollView flex={1}>
<YStack gap="$3">
{pinnedEntries.length > 0 ? (
{/* Fixed links — hidden while filtering so results stay focused. */}
{!filtering ? (
<YStack gap="$1">
<Text px="$2" fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
Pinned
</Text>
<FixedRow
icon={House}
label="Overview"
active={pathname === '/'}
collapsed={collapsed}
onPress={() => push('/')}
/>
<FixedRow icon={BookOpen} label="Docs" external collapsed={collapsed} onPress={openDocs} />
</YStack>
) : null}
{!filtering && pinnedEntries.length > 0 ? (
<YStack gap="$1">
{!collapsed ? (
<Text px="$2" fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
Pinned
</Text>
) : null}
{pinnedEntries.map((entry) => (
<NavRow
key={`pin-${entry.id}`}
entry={entry}
active={entry.kind === 'module' && isActive(entry.id)}
pinned
collapsed={collapsed}
onOpen={() => openProduct(entry, push)}
onToggle={() => toggle(entry.id)}
/>
@@ -132,21 +297,30 @@ export function DashboardShell({ children }: { children: ReactNode }) {
{groups.map((group) => (
<YStack key={group.category} gap="$1">
<Text px="$2" fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
{group.category}
</Text>
{!collapsed ? (
<Text px="$2" fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
{group.category}
</Text>
) : null}
{group.entries.map((entry) => (
<NavRow
key={entry.id}
entry={entry}
active={entry.kind === 'module' && isActive(entry.id)}
pinned={isPinned(entry.id)}
collapsed={collapsed}
onOpen={() => openProduct(entry, push)}
onToggle={() => toggle(entry.id)}
/>
))}
</YStack>
))}
{filtering && groups.length === 0 ? (
<Text px="$2" py="$3" fontSize="$2" color="$color10">
No products match {filter.trim()}.
</Text>
) : null}
</YStack>
</ScrollView>
</YStack>
@@ -156,21 +330,53 @@ export function DashboardShell({ children }: { children: ReactNode }) {
height={56}
px="$4"
items="center"
justify="flex-end"
gap="$3"
borderBottomWidth={1}
borderColor="$borderColor"
>
{account ? (
<Text color="$color11" fontSize="$3">
{account.displayName || account.name}
</Text>
) : null}
<Button size="$2" chromeless icon={<LogOut size={16} />} onPress={() => void signOut()}>
Sign out
<CommandSearchBox />
<Button
size="$3"
icon={<LayoutGrid size={18} />}
onPress={launcher.open}
borderWidth={1}
borderColor="$borderColor"
>
Apps
</Button>
<XStack flex={1} />
<XStack items="center" gap="$2">
<ThemeToggle />
<Button
size="$2"
chromeless
icon={<CircleHelp size={16} />}
onPress={openDocs}
aria-label="Documentation"
/>
<OrgSwitcher />
{account ? (
<Button
size="$2"
chromeless
icon={<SlidersHorizontal size={15} />}
onPress={() => push('/settings')}
>
{account.displayName || account.name}
</Button>
) : null}
<Button size="$2" chromeless icon={<LogOut size={16} />} onPress={() => void signOut()}>
Sign out
</Button>
</XStack>
</XStack>
{pathname !== '/' ? (
<XStack px="$4" py="$2.5" borderBottomWidth={1} borderColor="$borderColor">
<Breadcrumbs />
</XStack>
) : null}
<ScrollView flex={1}>
<YStack flex={1} p="$4" gap="$4">
{children}
+110
View File
@@ -0,0 +1,110 @@
'use client'
/**
* Org gate — the console operates inside the user's CUSTOMER organization.
*
* Orthogonal to {@link AuthGate} (which only answers "is there a session?"): this
* answers "is the session's org usable on this customer console?". Two rules:
*
* 1. The brand's OWN org (`config.iamOrgName` — e.g. `hanzo`) is the internal,
* admin-protected tenant. A user who resolves to it is staff, not a customer,
* so on a CUSTOMER host we never run the console as that org — we send them to
* the brand's admin host (`admin.<adminDomain>`). The SAME image serves the
* admin host, where the internal org belongs; so the block is scoped to
* non-admin hosts (otherwise the redirect would loop). (HIP: console2 is for
* customers in their OWN orgs; staff live on the admin host.)
* 2. A session with no org can't scope anything — we surface an honest "no
* organization" state rather than render an unscoped console.
*
* Switching BETWEEN customer orgs (multi-membership) is the shell's `OrgSwitcher`
* job; this gate only refuses the two unusable cases.
*/
import { useEffect, type ReactNode } from 'react'
import { Button, Card, Text, YStack } from '@hanzo/gui'
import { config } from '~/config'
import { getBrand } from '~/lib/branding/brands'
import { useSession } from '~/lib/auth/session'
import { currentOrg, setCurrentOrg } from '~/lib/org-scope'
/** The admin host (`admin.<domain>`) is the staff surface — the internal org is
* welcome there, so the block (rule 1) applies only OFF this host. */
function onAdminHost(): boolean {
return typeof window !== 'undefined' && window.location.hostname.startsWith('admin.')
}
function GateCard({ title, body, children }: { title: string; body: string; children?: ReactNode }) {
return (
<YStack flex={1} minH="100vh" items="center" justify="center" p="$4">
<Card p="$5" gap="$4" width={420} borderWidth={1} borderColor="$borderColor" bg="$color1">
<YStack gap="$2">
<Text fontSize="$7" fontWeight="800">
{title}
</Text>
<Text fontSize="$3" color="$color11">
{body}
</Text>
</YStack>
{children}
</Card>
</YStack>
)
}
export function OrgGate({ children }: { children: ReactNode }) {
const { account, signOut } = useSession()
// AuthGate guarantees an account by the time this renders; guard anyway.
const owner = account?.owner ?? ''
// A customer's console acts in THEIR org. The org-scope module defaults to the
// brand org (designed for the cross-org admin), so on a customer host seed the
// signed-in user's own org as the active scope the FIRST time — i.e. only when
// no explicit switch is in effect yet (current scope is still the brand org).
// This makes the OrgSwitcher chip + the `X-Org-Id` client stamp reflect the
// real tenant, and never clobbers an admin's deliberate org switch.
useEffect(() => {
if (owner && owner !== config.iamOrgName && !onAdminHost() && currentOrg() === config.iamOrgName) {
setCurrentOrg(owner)
}
}, [owner])
// Rule 1 — internal brand org → brand admin console (customer hosts only; the
// admin host serves this same image and is where the internal org belongs, so
// blocking there would bounce the operator back into a redirect loop).
if (owner && owner === config.iamOrgName && !onAdminHost()) {
const brand = getBrand()
const adminUrl = `https://admin.${brand.adminDomain}`
return (
<GateCard
title={`${brand.brandName} staff`}
body={`The ${brand.brandName} Cloud console is for customer organizations. Staff administration for the ${brand.brandName} organization lives in the admin console.`}
>
<YStack gap="$2.5">
<Button size="$4" onPress={() => window.location.assign(adminUrl)}>
Go to admin.{brand.adminDomain}
</Button>
<Button size="$3" chromeless onPress={() => void signOut()}>
Sign out
</Button>
</YStack>
</GateCard>
)
}
// Rule 2 — a session with no organization can't scope the console.
if (!owner) {
return (
<GateCard
title="No organization"
body="Your account isn't part of an organization yet. An organization owner needs to invite you, or you can create one to get started."
>
<Button size="$3" chromeless onPress={() => void signOut()}>
Sign out
</Button>
</GateCard>
)
}
return <>{children}</>
}
+126
View File
@@ -0,0 +1,126 @@
'use client'
/**
* Org switcher — shows the organization the console is scoped to, and lets an
* operator who can see more than one switch + filter between them.
*
* The org LIST comes from IAM (`get-organizations`, via the gated `/admin/iam`
* proxy): a global admin (z@hanzo.ai) sees every org; a brand admin sees their
* own. Selecting one re-scopes the console IN PLACE — `setCurrentOrg` persists
* the choice and a reload refetches every module under the new `X-Org-Id`
* (`currentOrg()`); the IAM/KMS proxies authorize a global admin for any org and
* pin a brand admin to their own, so the re-scope is always safe. Brand identity
* (the host's wordmark/logo) is unchanged — only the data scope moves. We never
* fabricate orgs: if IAM lists one, this is just a labelled chip.
*/
import { useEffect, useMemo, useState } from 'react'
import { Button, Input, Popover, Text, XStack, YStack } from '@hanzo/gui'
import { Building2, Check, ChevronsUpDown, Search } from '@hanzogui/lucide-icons-2'
import { currentOrg, switchOrg, filterOrgs } from '~/lib/org-scope'
import { IamAdminApi, type Organization } from '~/lib/api'
const titleCase = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
export function OrgSwitcher() {
const currentId = currentOrg()
const [orgs, setOrgs] = useState<Organization[]>([])
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
useEffect(() => {
let live = true
IamAdminApi.organizations()
.then((p) => {
if (live) setOrgs(p.rows ?? [])
})
.catch(() => {
// IAM org listing not available to this account — single-org display.
if (live) setOrgs([])
})
return () => {
live = false
}
}, [])
const currentName = orgs.find((o) => o.name === currentId)?.displayName || titleCase(currentId)
const filtered = useMemo(() => filterOrgs(orgs, query), [orgs, query])
// A single visible org is just a label — there is nothing to switch to.
if (orgs.length <= 1) {
return (
<XStack items="center" gap="$2" px="$2.5" height={32} rounded="$3" borderWidth={1} borderColor="$borderColor">
<Building2 size={14} opacity={0.7} />
<Text fontSize="$2" color="$color11" numberOfLines={1}>
{currentName}
</Text>
</XStack>
)
}
const select = (org: Organization) => {
setOpen(false)
switchOrg(org.name)
}
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={288} bg="$color2" borderColor="$borderColor">
<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 · {orgs.length}
</Text>
<YStack gap="$0.5" maxH={320} 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>
</YStack>
</Popover.Content>
</Popover>
)
}
+146 -32
View File
@@ -1,24 +1,33 @@
'use client'
/**
* Sign-in card — adapted from the @hanzo/gui `sign-in-form` recipe.
* Sign-in 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/signin` exchange the redirect flow uses.
*
* 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'
/** Monochrome Google "G" — the canonical mark, filled with the current text
* color so it stays black/white with the rest of the console chrome. */
/** 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 +39,7 @@ function GoogleMark({ size = 18 }: { size?: number }) {
)
}
export function SignInForm() {
const { signIn, signInWith } = useSession()
function CardShell({ children }: { 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">
@@ -46,28 +54,134 @@ export function SignInForm() {
</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>
</YStack>
)
}
export function SignInForm() {
const { completeSignIn, signInWith } = useSession()
const router = useRouter()
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)
async function submit() {
if (busy || !email || !password) return
setBusy(true)
setError(null)
try {
const res = await loginWithPassword(email.trim(), password)
if (res.kind === 'code') {
await completeSignIn(res.code, loginState())
router.replace('/')
} else if (res.kind === 'mfa') {
setMfa(true)
} else {
setError(res.message)
setBusy(false)
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Sign-in failed.')
setBusy(false)
}
}
// 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>
<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>
)
}
return (
<CardShell>
<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="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 ? 'Signing in…' : 'Sign in'}
</PrimaryButton>
<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>
)
}
+19 -46
View File
@@ -10,8 +10,8 @@
*/
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { RefreshCw, ExternalLink, TriangleAlert } from '@hanzogui/lucide-icons-2'
import { Button, Text, XStack } from '@hanzo/gui'
import { RefreshCw, ExternalLink } from '@hanzogui/lucide-icons-2'
import { ApiError } from '~/lib/api'
import {
@@ -23,8 +23,18 @@ import {
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 { ErrorState, asApiError, 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 '—'
@@ -43,41 +53,6 @@ const dim = (v?: string | number | null) =>
</Text>
)
/** Map an ApiError to an honest, specific explanation — never a generic crash. */
function honestError(err: ApiError): { title: string; body: string } {
if (err.status === 404)
return {
title: 'Not available on this deployment',
body: '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.',
}
if (err.status === 401 || err.status === 403 || /sign ?in|login|unauthorized/i.test(err.message))
return {
title: 'Admin access required',
body: 'This view requires an admin session. Access is enforced server-side by IAM — sign in with an account that has the right role.',
}
return { title: 'Could not load', body: err.message }
}
function ErrorCard({ err, onRetry }: { err: ApiError; onRetry: () => void }) {
const { title, body } = honestError(err)
return (
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$2" maxWidth={620}>
<XStack gap="$2" items="center">
<TriangleAlert size={16} />
<Text fontSize="$4" fontWeight="700">
{title}
</Text>
</XStack>
<Text fontSize="$3" color="$color11">
{body}
</Text>
<Button size="$2" self="flex-start" onPress={onRetry}>
Retry
</Button>
</Card>
)
}
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. */
@@ -98,12 +73,7 @@ function AdminListView<T>({
setState({ phase: 'loading' })
fetcher()
.then((p) => setState({ phase: 'ready', rows: p.rows ?? [] }))
.catch((e) =>
setState({
phase: 'error',
err: e instanceof ApiError ? e : new ApiError(e instanceof Error ? e.message : String(e)),
}),
)
.catch((e) => setState({ phase: 'error', err: asApiError(e) }))
}, [fetcher])
useEffect(() => {
@@ -118,7 +88,7 @@ function AdminListView<T>({
</Button>
</XStack>
{state.phase === 'error' ? (
<ErrorCard err={state.err} onRetry={run} />
<ErrorState err={state.err} onRetry={run} copy={IAM_COPY} />
) : (
<DataTable
columns={columns}
@@ -213,7 +183,10 @@ const IAM_TABS = [
export function IamModule({ params }: { params: Record<string, string> }) {
const router = useRouter()
const tab = params.tab ?? ''
const org = config.iamOrgName
// 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 userFetcher = useCallback(() => IamAdminApi.users(org), [org])
@@ -255,7 +228,7 @@ export function IamModule({ params }: { params: Record<string, string> }) {
/** Audit — the identity & access event log (`/v1/iam/get-records`). */
export function AuditModule(_props: { params: Record<string, string> }) {
const org = config.iamOrgName
const org = currentOrg()
const fetcher = useCallback(() => IamAdminApi.records(org), [org])
return (
<>
+130
View File
@@ -0,0 +1,130 @@
'use client'
/**
* Agents — autonomous agent definitions and their runs, registered and executed
* by the platform.
*
* Reads the agent registry from the PaaS via the same-origin `/paas` proxy
* (`GET /v1/agents`), which injects the service token server-side. When the agent
* 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 Agent = {
id: string
name?: string
model?: string
status?: string
runs?: number
updatedAt?: string
}
export function AgentsModule(_props: { params: Record<string, string> }) {
const [rows, setRows] = useState<Agent[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<PlatformError | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await restGet<{ agents?: Agent[] }>(paas('agents'))
setRows(r.agents ?? [])
setLoadError(null)
} catch (e) {
setLoadError(interpretPlatformError(e))
setRows([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
const columns: Column<Agent>[] = [
{
key: 'name',
header: 'Name',
render: (a) => (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{a.name || a.id}
</Text>
),
},
{
key: 'model',
header: 'Model',
width: 180,
render: (a) => (
<Text fontSize="$3" color="$color11">
{a.model || '—'}
</Text>
),
},
{
key: 'runs',
header: 'Runs',
width: 90,
render: (a) => (
<Text fontSize="$3" color="$color11">
{a.runs ?? '—'}
</Text>
),
},
{
key: 'status',
header: 'Status',
width: 120,
render: (a) => <StatusTag status={a.status ?? 'unknown'} />,
},
{
key: 'updatedAt',
header: 'Updated',
width: 190,
render: (a) => (
<Text fontSize="$3" color="$color11">
{a.updatedAt ? new Date(a.updatedAt).toLocaleString() : '—'}
</Text>
),
},
]
return (
<>
<PageHeader
title="Agents"
subtitle="Autonomous agent definitions and runs."
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 agents yet."
/>
)}
</>
)
}
+130
View File
@@ -0,0 +1,130 @@
'use client'
/**
* Alerts — alert rules (conditions, severities, recent firings) evaluated by the
* platform.
*
* Reads the alert rules from the PaaS via the same-origin `/paas` proxy
* (`GET /v1/alerts`), which injects the service token server-side. When the
* alerting 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 Alert = {
id: string
name?: string
severity?: string
status?: string
condition?: string
lastFired?: string
}
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<{ alerts?: Alert[] }>(paas('alerts'))
setRows(r.alerts ?? [])
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,87 @@
'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 { Button, Text } from '@hanzo/gui'
import { RefreshCw } from '@hanzogui/lucide-icons-2'
import { O11yApi, type AnnotationQueue, 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
export function AnnotationQueuesModule(_props: { params: Record<string, string> }) {
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) => <Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>{q.name}</Text> },
{ 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> },
]
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}
empty="No annotation queues yet. Create a queue to organize human review and scoring."
/>
<Pager meta={meta} onPage={setPage} />
</>
)}
</>
)
}
+256
View File
@@ -0,0 +1,256 @@
'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'
/** 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) })
} 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>
</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 />
</>
)
}
@@ -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."
/>
)}
</>
)
}
+134
View File
@@ -0,0 +1,134 @@
'use client'
/**
* Authorization — fine-grained access-control policies (allow/deny effects over
* resources and principals) evaluated by the platform's policy engine.
*
* Reads the policy set from the PaaS via the same-origin `/paas` proxy
* (`GET /v1/authz/policies`), which injects the service token server-side. When
* the authorization 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 Policy = {
id: string
name?: string
effect?: string
resource?: string
principals?: string[]
updatedAt?: string
}
export function AuthzModule(_props: { params: Record<string, string> }) {
const [rows, setRows] = useState<Policy[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<PlatformError | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await restGet<{ policies?: Policy[] }>(paas('authz/policies'))
setRows(r.policies ?? [])
setLoadError(null)
} catch (e) {
setLoadError(interpretPlatformError(e))
setRows([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
const columns: Column<Policy>[] = [
{
key: 'name',
header: 'Name',
render: (p) => (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{p.name || p.id}
</Text>
),
},
{
key: 'effect',
header: 'Effect',
width: 110,
render: (p) => (
<Text fontSize="$3" color="$color11">
{p.effect || '—'}
</Text>
),
},
{
key: 'resource',
header: 'Resource',
width: 200,
render: (p) => (
<Text fontSize="$3" color="$color11" numberOfLines={1}>
{p.resource || '—'}
</Text>
),
},
{
key: 'principals',
header: 'Principals',
width: 110,
render: (p) => (
<Text fontSize="$3" color="$color11">
{p.principals?.length ?? 0}
</Text>
),
},
{
key: 'updatedAt',
header: 'Updated',
width: 190,
render: (p) => (
<Text fontSize="$3" color="$color11">
{p.updatedAt ? new Date(p.updatedAt).toLocaleString() : '—'}
</Text>
),
},
]
return (
<>
<PageHeader
title="Authorization"
subtitle="Fine-grained access-control policies."
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={(p) => p.id}
empty="No authorization policies yet."
/>
)}
</>
)
}
+10 -2
View File
@@ -41,8 +41,16 @@ export function BotModule() {
const h = await BotApi.health()
setStatus(h.status || (h.ok ? 'ok' : 'unknown'))
} catch (e) {
setStatus('error')
setErr(e instanceof ApiError ? e.message : 'Failed to reach the bot gateway')
// 404 = the bot gateway is not routed on this console's /v1 host (it runs
// behind the unified gateway at hanzo.bot / api.hanzo.ai). That is an
// honest "not routed here" state, not a failure — the deep-links still work.
if (e instanceof ApiError && e.status === 404) {
setStatus('not routed')
setErr('The bot gateway is not routed on this host. It runs at hanzo.bot — open it below.')
} else {
setStatus('error')
setErr(e instanceof ApiError ? e.message : 'Failed to reach the bot gateway')
}
} finally {
setLoading(false)
}
+138
View File
@@ -0,0 +1,138 @@
'use client'
/**
* Builds — image and binary builds and their status.
*
* Reads the build list from the PaaS via the same-origin `/paas` proxy
* (`GET /v1/builds`), which injects the service token server-side. When the
* builds service isn't provisioned the load fails and the honest
* not-configured / unavailable card renders instead of an empty grid.
*/
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 Build = {
id: string
repo?: string
commit?: string
status?: string
startedAt?: string
duration?: string
}
export function BuildsModule(_props: { params: Record<string, string> }) {
const [rows, setRows] = useState<Build[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<PlatformError | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await restGet<{ builds?: Build[] }>(paas('builds'))
setRows(r.builds ?? [])
setLoadError(null)
} catch (e) {
setLoadError(interpretPlatformError(e))
setRows([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
const columns: Column<Build>[] = [
{
key: 'id',
header: 'ID',
render: (b) => (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{b.id}
</Text>
),
},
{
key: 'repo',
header: 'Repo',
width: 200,
render: (b) => (
<Text fontSize="$3" color="$color11" numberOfLines={1}>
{b.repo || '—'}
</Text>
),
},
{
key: 'commit',
header: 'Commit',
width: 120,
render: (b) => (
<Text fontSize="$3" color="$color11" numberOfLines={1}>
{b.commit || '—'}
</Text>
),
},
{
key: 'status',
header: 'Status',
width: 120,
render: (b) => <StatusTag status={b.status ?? 'unknown'} />,
},
{
key: 'startedAt',
header: 'Started',
width: 190,
render: (b) => (
<Text fontSize="$3" color="$color11">
{b.startedAt ? new Date(b.startedAt).toLocaleString() : '—'}
</Text>
),
},
{
key: 'duration',
header: 'Duration',
width: 110,
render: (b) => (
<Text fontSize="$3" color="$color11">
{b.duration || '—'}
</Text>
),
},
]
return (
<>
<PageHeader
title="Builds"
subtitle="Image and binary builds and their status."
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={(b) => b.id}
empty="No builds yet."
/>
)}
</>
)
}
+17 -8
View File
@@ -1,26 +1,35 @@
'use client'
/**
* Chat admin — session list + read-only chat view, native on @hanzo/gui.
* Chat — an interactive assistant by default, with session history alongside.
*
* Logic ported from ChatListPage.js + ChatPage.js + backend/ChatBackend.js (and
* MessageBackend.js for the thread): list chats (`get-chats`), open one to read
* its message thread (`get-messages?owner&chat`), delete (`delete-chat`). UI
* rebuilt clean on GUI primitives (no antd).
*
* Routing: `/chat` lists; `/chat/<name>` views one session.
* `/chat` opens the live conversation (`ChatConversation` → real completions via
* the keyless `/ai` proxy). The "History" toggle lists prior saved sessions
* (`ChatListView`), and `/chat/<name>` deep-links a read-only thread
* (`ChatView`). One module, three composable views; nothing fabricated.
*/
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { ChatConversation } from './chat/ChatConversation'
import { ChatListView } from './chat/ChatListView'
import { ChatView } from './chat/ChatView'
export function ChatModule({ params }: { params: Record<string, string> }) {
const router = useRouter()
const name = params.name
const [view, setView] = useState<'chat' | 'history'>('chat')
if (name) {
return <ChatView name={decodeURIComponent(name)} onDone={() => router.push('/chat')} />
}
return <ChatListView onOpen={(c) => router.push(`/chat/${encodeURIComponent(c.name)}`)} />
if (view === 'history') {
return (
<ChatListView
onOpen={(c) => router.push(`/chat/${encodeURIComponent(c.name)}`)}
onBack={() => setView('chat')}
/>
)
}
return <ChatConversation onShowHistory={() => setView('history')} />
}
+60 -160
View File
@@ -1,38 +1,34 @@
'use client'
/**
* Clusters admin — the one control-plane surface for *where* workloads run.
* Clusters admin — the control-plane surface for your DEDICATED Hanzo K8S.
*
* Two targets, one list:
* - Provision a fresh DOKS cluster (region / node size / count) — the platform
* creates node pools and reconciles your apps + managed products into it.
* - Attach an existing cluster (name + kubeconfig) the operator should adopt.
* The platform can provision an org its OWN DigitalOcean Kubernetes cluster,
* install the hanzo-operator + per-tenant baseline, and make it the org's deploy
* target (the DO token is read from KMS server-side; the kubeconfig is never
* stored or returned). Workloads otherwise run on the shared Hanzo Cloud — which
* is the default target, not a row here, so an org with no dedicated cluster sees
* an honest empty list.
*
* Talks to the Hanzo PaaS (config.platformUrl) via PlatformApi. Tenancy is
* server-side (X-Org-Id from the session). Built on the shared GUI primitives +
* DataTable/PageHeader/Field, matching every other module.
* Talks to the Hanzo PaaS via the same-origin `/paas` proxy (`GET|POST
* /v1/org/{org}/cluster`). Tenancy is the brand org (`config.iamOrgName`). Built
* on the shared GUI primitives, matching every other module.
*/
import { useCallback, useEffect, useState } from 'react'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { Plus, Trash, RefreshCw, Link2 } from '@hanzogui/lucide-icons-2'
import { Plus, RefreshCw } from '@hanzogui/lucide-icons-2'
import {
ApiError,
PlatformApi,
DOKS_REGIONS,
DOKS_NODE_SIZES,
type Cluster,
} from '~/lib/api'
import { ApiError, PlatformApi, DOKS_REGIONS, DOKS_NODE_SIZES, type Cluster } from '~/lib/api'
import { config } from '~/config'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { StatusTag } from '~/components/ui/StatusTag'
import { FieldRow, FieldText, FieldTextArea, FieldSelect } from '~/components/ui/Field'
import { slugError } from '~/lib/slug'
import { FieldRow, FieldSelect } from '~/components/ui/Field'
import { interpretPlatformError, PlatformStateCard, type PlatformError } from './platform/state'
const KindTag = ({ kind }: { kind?: string }) => (
<Text fontSize="$1" px="$2" py="$1" rounded="$2" bg="$color3" color="$color11">
{kind === 'shared' ? 'Shared' : kind === 'byo' ? 'BYO' : kind || ''}
{kind === 'shared' ? 'Shared' : kind === 'byo' ? 'BYO' : kind || 'Dedicated'}
</Text>
)
@@ -43,29 +39,24 @@ const clampCount = (v: string): number => {
}
export function ClustersModule(_props: { params: Record<string, string> }) {
const org = config.iamOrgName
const [rows, setRows] = useState<Cluster[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// List-load failure (separate from action errors) — drives the honest
// not-configured / backend-unavailable card.
const [loadError, setLoadError] = useState<PlatformError | null>(null)
const [error, setError] = useState<string | null>(null)
// Provision form.
const [pName, setPName] = useState('')
const [pRegion, setPRegion] = useState<string>(DOKS_REGIONS[0])
// Provision form (the platform generates the cluster name).
const [pRegion, setPRegion] = useState<string>(DOKS_REGIONS[2]) // sfo3
const [pNodeSize, setPNodeSize] = useState<string>(DOKS_NODE_SIZES[1])
const [pCount, setPCount] = useState(3)
const [provisioning, setProvisioning] = useState(false)
// Attach form.
const [aName, setAName] = useState('')
const [aKubeconfig, setAKubeconfig] = useState('')
const [attaching, setAttaching] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
const data = await PlatformApi.listClusters()
const data = await PlatformApi.listClusters(org)
setRows(data ?? [])
setLoadError(null)
} catch (e) {
@@ -73,31 +64,21 @@ export function ClustersModule(_props: { params: Record<string, string> }) {
} finally {
setLoading(false)
}
}, [])
}, [org])
useEffect(() => {
void load()
}, [load])
const pNameErr = pName ? slugError(pName) : null
const aNameErr = aName ? slugError(aName) : null
const onProvision = async () => {
const err = slugError(pName)
if (err) {
setError(err)
return
}
setProvisioning(true)
setError(null)
try {
await PlatformApi.provisionCluster({
name: pName,
await PlatformApi.provisionCluster(org, {
region: pRegion,
nodeSize: pNodeSize,
nodeCount: pCount,
})
setPName('')
await load()
} catch (e) {
setError(e instanceof ApiError ? e.message : 'Failed to provision cluster')
@@ -106,49 +87,11 @@ export function ClustersModule(_props: { params: Record<string, string> }) {
}
}
const onAttach = async () => {
const err = slugError(aName)
if (err) {
setError(err)
return
}
if (!aKubeconfig.trim()) {
setError('Paste the cluster kubeconfig to attach.')
return
}
setAttaching(true)
setError(null)
try {
await PlatformApi.attachCluster({ name: aName, kubeconfig: aKubeconfig })
setAName('')
setAKubeconfig('')
await load()
} catch (e) {
setError(e instanceof ApiError ? e.message : 'Failed to attach cluster')
} finally {
setAttaching(false)
}
}
const onDelete = async (c: Cluster) => {
if (
typeof window !== 'undefined' &&
!window.confirm(`Remove cluster "${c.name}"? Workloads on it will be detached.`)
)
return
try {
await PlatformApi.removeCluster(c.name)
setRows((rs) => rs.filter((x) => x.name !== c.name))
} catch (e) {
setError(e instanceof ApiError ? e.message : `Failed to remove "${c.name}"`)
}
}
const columns: Column<Cluster>[] = [
{ key: 'name', header: 'Name', render: (c) => <Text fontSize="$3">{c.name}</Text> },
{ key: 'status', header: 'Status', width: 130, render: (c) => <StatusTag status={c.status} /> },
{ key: 'kind', header: 'Kind', width: 90, render: (c) => <KindTag kind={c.kind} /> },
{ key: 'region', header: 'Region', width: 90 },
{ key: 'name', header: 'Name', render: (c) => <Text fontSize="$3" fontWeight="600">{c.name}</Text> },
{ key: 'status', header: 'Status', width: 130, render: (c) => <StatusTag status={c.phase || c.status} /> },
{ key: 'kind', header: 'Kind', width: 110, render: (c) => <KindTag kind={c.kind} /> },
{ key: 'region', header: 'Region', width: 100, render: (c) => <Text fontSize="$3" color="$color11">{c.region ?? '—'}</Text> },
{
key: 'nodes',
header: 'Nodes',
@@ -159,23 +102,13 @@ export function ClustersModule(_props: { params: Record<string, string> }) {
</Text>
),
},
{
key: 'action',
header: '',
width: 90,
render: (c) => (
<XStack gap="$2" justify="flex-end" flex={1}>
<Button size="$2" icon={<Trash size={14} />} onPress={() => void onDelete(c)} />
</XStack>
),
},
]
return (
<>
<PageHeader
title="Clusters"
subtitle="Shared Hanzo Cloud or your own DigitalOcean Kubernetes."
subtitle="Your dedicated Hanzo K8S (DigitalOcean Kubernetes). Workloads otherwise run on shared Hanzo Cloud."
actions={
<Button icon={<RefreshCw size={16} />} onPress={() => void load()}>
Refresh
@@ -195,74 +128,41 @@ export function ClustersModule(_props: { params: Record<string, string> }) {
rows={rows}
loading={loading}
rowKey={(c) => c.id || c.name}
empty="No clusters yet. Provision a DOKS cluster or attach an existing one below."
empty="No dedicated clusters yet. Provision one below, or keep running on shared Hanzo Cloud."
/>
<XStack gap="$4" flexWrap="wrap">
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor" flex={1} minW={320}>
<Text fontSize="$5" fontWeight="700">
Provision DOKS cluster
</Text>
<FieldRow label="Name">
<YStack gap="$1.5" flex={1}>
<FieldText value={pName} onChange={setPName} placeholder="prod-cluster" />
<Text fontSize="$2" color={pNameErr ? '$color12' : '$color10'}>
{pNameErr ?? 'Lowercase letters, numbers and hyphens. 240 chars.'}
</Text>
</YStack>
</FieldRow>
<FieldRow label="Region">
<FieldSelect value={pRegion} options={[...DOKS_REGIONS]} onChange={setPRegion} />
</FieldRow>
<FieldRow label="Node size">
<FieldSelect value={pNodeSize} options={[...DOKS_NODE_SIZES]} onChange={setPNodeSize} />
</FieldRow>
<FieldRow label="Node count">
<FieldText
value={String(pCount)}
onChange={(v) => setPCount(clampCount(v))}
placeholder="3"
/>
</FieldRow>
<XStack>
<Button
theme="light"
icon={<Plus size={16} />}
disabled={provisioning || !pName || !!pNameErr}
onPress={() => void onProvision()}
>
{provisioning ? 'Provisioning…' : 'Provision'}
</Button>
</XStack>
</Card>
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor" flex={1} minW={320}>
<Text fontSize="$5" fontWeight="700">
Attach existing cluster
</Text>
<FieldRow label="Name">
<YStack gap="$1.5" flex={1}>
<FieldText value={aName} onChange={setAName} placeholder="my-cluster" />
<Text fontSize="$2" color={aNameErr ? '$color12' : '$color10'}>
{aNameErr ?? 'A label for this cluster in the console.'}
</Text>
</YStack>
</FieldRow>
<FieldRow label="Kubeconfig">
<FieldTextArea value={aKubeconfig} onChange={setAKubeconfig} rows={8} />
</FieldRow>
<XStack>
<Button
theme="light"
icon={<Link2 size={16} />}
disabled={attaching || !aName || !!aNameErr || !aKubeconfig.trim()}
onPress={() => void onAttach()}
>
{attaching ? 'Attaching…' : 'Attach'}
</Button>
</XStack>
</Card>
</XStack>
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor" maxWidth={460}>
<Text fontSize="$5" fontWeight="700">
Provision dedicated cluster
</Text>
<FieldRow label="Region">
<FieldSelect value={pRegion} options={[...DOKS_REGIONS]} onChange={setPRegion} />
</FieldRow>
<FieldRow label="Node size">
<FieldSelect value={pNodeSize} options={[...DOKS_NODE_SIZES]} onChange={setPNodeSize} />
</FieldRow>
<FieldRow label="Node count">
<FieldSelect
value={String(pCount)}
options={['1', '2', '3', '4', '5', '6', '8', '10']}
onChange={(v) => setPCount(clampCount(v))}
/>
</FieldRow>
<XStack>
<Button
theme="light"
icon={<Plus size={16} />}
disabled={provisioning}
onPress={() => void onProvision()}
>
{provisioning ? 'Provisioning…' : 'Provision'}
</Button>
</XStack>
<Text fontSize="$2" color="$color10">
Spins up a DigitalOcean Kubernetes cluster, installs the Hanzo operator, and makes it
your deploy target. Billed by DigitalOcean.
</Text>
</Card>
</>
)}
</>
+1 -1
View File
@@ -48,7 +48,7 @@ export function ComingSoon() {
borderWidth={1}
borderColor="$borderColor"
iconAfter={<ArrowUpRight size={15} />}
onPress={() => window.open(`${config.cloudUrl}/docs`, '_blank', 'noopener')}
onPress={() => window.open(config.docsUrl, '_blank', 'noopener')}
>
API docs
</Button>
@@ -1,80 +0,0 @@
'use client'
/**
* Coming-soon / waitlist placeholder for a cloud product whose full admin module
* isn't built yet. Every Hanzo cloud product is registered in the product menu —
* the ones without a finished console surface render this, so we can track
* enablement status (soon vs waitlist) for the whole catalog in one place.
*
* `comingSoon()` is a factory: it captures a product's label/repo/blurb/status and
* returns a route component (the registry's routes take `ComponentType`, so a
* per-product closure is the clean way to parameterize without a shell edit).
*/
import { Button, Text, XStack, YStack } from '@hanzo/gui'
/** Enablement state for a product module, surfaced in the nav + landing. */
export type ProductStatus = 'enabled' | 'soon' | 'waitlist'
export function comingSoon(opts: {
/** Product display name, e.g. 'Hanzo Vector'. */
label: string
/** Source repo, e.g. 'hanzoai/vector'. */
repo: string
/** One-paragraph positioning. */
blurb: string
/** 'soon' = built, not yet enabled here; 'waitlist' = gauging demand. */
status: Exclude<ProductStatus, 'enabled'>
}) {
return function ComingSoonModule() {
const isWaitlist = opts.status === 'waitlist'
const repoUrl = `https://github.com/${opts.repo}`
return (
<YStack p="$6" gap="$4" maxW={720}>
<XStack items="center" gap="$3">
<Text fontWeight="800" fontSize="$8">
{opts.label}
</Text>
<YStack
px="$2.5"
py="$1"
rounded="$10"
bg={isWaitlist ? '$color4' : '$color4'}
>
<Text fontSize="$1" fontWeight="800" letterSpacing={1}>
{isWaitlist ? 'WAITLIST' : 'COMING SOON'}
</Text>
</YStack>
</XStack>
<Text color="$color11" fontSize="$5">
{opts.blurb}
</Text>
<Text color="$color10" fontSize="$3">
source · github.com/{opts.repo}
</Text>
<XStack gap="$3" pt="$2">
<Button
theme="light"
onPress={() => {
if (typeof window !== 'undefined') window.open(repoUrl, '_blank', 'noopener')
}}
>
View repo
</Button>
{isWaitlist && (
<Button
onPress={() => {
if (typeof window !== 'undefined')
window.open('https://hanzo.ai/cloud', '_blank', 'noopener')
}}
>
Join the waitlist
</Button>
)}
</XStack>
</YStack>
)
}
}
@@ -0,0 +1,140 @@
'use client'
/**
* Containers — running container workloads scheduled by the platform.
*
* Reads the container inventory from the PaaS via the same-origin `/paas` proxy
* (`GET /v1/containers`), which injects the service token server-side. When the
* container 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 Container = {
id: string
name?: string
image?: string
status?: string
cpu?: string
memory?: string
node?: string
}
export function ContainersModule(_props: { params: Record<string, string> }) {
const [rows, setRows] = useState<Container[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<PlatformError | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await restGet<{ containers?: Container[] }>(paas('containers'))
setRows(r.containers ?? [])
setLoadError(null)
} catch (e) {
setLoadError(interpretPlatformError(e))
setRows([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
const columns: Column<Container>[] = [
{
key: 'name',
header: 'Name',
render: (c) => (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{c.name || c.id}
</Text>
),
},
{
key: 'image',
header: 'Image',
width: 220,
render: (c) => (
<Text fontSize="$3" color="$color11" numberOfLines={1}>
{c.image || '—'}
</Text>
),
},
{
key: 'node',
header: 'Node',
width: 140,
render: (c) => (
<Text fontSize="$3" color="$color11">
{c.node || '—'}
</Text>
),
},
{
key: 'cpu',
header: 'CPU',
width: 90,
render: (c) => (
<Text fontSize="$3" color="$color11">
{c.cpu || '—'}
</Text>
),
},
{
key: 'memory',
header: 'Memory',
width: 100,
render: (c) => (
<Text fontSize="$3" color="$color11">
{c.memory || '—'}
</Text>
),
},
{
key: 'status',
header: 'Status',
width: 120,
render: (c) => <StatusTag status={c.status ?? 'unknown'} />,
},
]
return (
<>
<PageHeader
title="Containers"
subtitle="Running container workloads."
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={(c) => c.id}
empty="No containers running yet."
/>
)}
</>
)
}
+215
View File
@@ -0,0 +1,215 @@
'use client'
/**
* Datasets — curate evaluation datasets (ported from the old console's datasets
* feature). Wired to the REAL cloud `/v1/evals/*` facade, which proxies the
* console's public dataset API:
* - POST /v1/evals/datasets create a dataset
* - POST /v1/evals/dataset-items add an item (input + expected output)
*
* The gateway does not mount a dataset LIST route yet, so the list area attempts
* the forward-compatible GET and renders an honest "not available here yet" card
* on 404/405 — it lights up automatically once the read route lands. Create and
* add always work; nothing is fabricated.
*/
import { useCallback, useEffect, useState } from 'react'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { Plus, RefreshCw, Database } from '@hanzogui/lucide-icons-2'
import { EvalsApi, type EvalDataset } from '~/lib/api'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { FieldRow, FieldText, FieldTextArea } from '~/components/ui/Field'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
type ListState =
| { phase: 'loading' }
| { phase: 'error'; error: BackendState }
| { phase: 'ready'; rows: EvalDataset[] }
const fmtTime = (s?: string) => {
if (!s) return '—'
const d = new Date(s)
return Number.isNaN(d.getTime()) ? s : d.toLocaleString()
}
/** Plain text stays a string; valid JSON is sent as JSON; empty is omitted. */
const parseMaybeJson = (s: string): unknown => {
const t = s.trim()
if (!t) return undefined
try {
return JSON.parse(t)
} catch {
return t
}
}
const columns: Column<EvalDataset>[] = [
{ key: 'name', header: 'Name', render: (d) => (
<Text fontSize="$3" fontWeight="600" numberOfLines={1}>{d.name}</Text>
) },
{ key: 'description', header: 'Description', render: (d) => (
<Text fontSize="$3" color={d.description ? '$color11' : '$color10'} numberOfLines={1}>
{d.description || '—'}
</Text>
) },
{ key: 'createdAt', header: 'Created', width: 190, render: (d) => (
<Text fontSize="$3" color="$color10">{fmtTime(d.createdAt)}</Text>
) },
]
export function DatasetsModule(_props: { params: Record<string, string> }) {
const [list, setList] = useState<ListState>({ phase: 'loading' })
// Create dataset
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [creating, setCreating] = useState(false)
const [createMsg, setCreateMsg] = useState<{ tone: 'ok' | 'err'; text: string } | null>(null)
// Add item
const [itemDataset, setItemDataset] = useState('')
const [input, setInput] = useState('')
const [expected, setExpected] = useState('')
const [adding, setAdding] = useState(false)
const [addMsg, setAddMsg] = useState<{ tone: 'ok' | 'err'; text: string } | null>(null)
const loadList = useCallback(() => {
setList({ phase: 'loading' })
EvalsApi.listDatasets()
.then((res) => {
const rows = Array.isArray(res) ? res : (res?.data ?? [])
setList({ phase: 'ready', rows })
})
.catch((e) => setList({ phase: 'error', error: classifyBackend(e) }))
}, [])
useEffect(() => {
loadList()
}, [loadList])
const createDataset = async () => {
if (!name.trim()) {
setCreateMsg({ tone: 'err', text: 'Name is required.' })
return
}
setCreating(true)
setCreateMsg(null)
try {
const d = await EvalsApi.createDataset({ name: name.trim(), description: description.trim() || undefined })
setCreateMsg({ tone: 'ok', text: `Created dataset "${d?.name ?? name.trim()}".` })
setItemDataset((cur) => cur || name.trim())
setName('')
setDescription('')
loadList()
} catch (e) {
setCreateMsg({ tone: 'err', text: e instanceof Error ? e.message : String(e) })
} finally {
setCreating(false)
}
}
const addItem = async () => {
if (!itemDataset.trim()) {
setAddMsg({ tone: 'err', text: 'Dataset name is required.' })
return
}
setAdding(true)
setAddMsg(null)
try {
await EvalsApi.createDatasetItem({
datasetName: itemDataset.trim(),
input: parseMaybeJson(input),
expectedOutput: parseMaybeJson(expected),
})
setAddMsg({ tone: 'ok', text: `Added item to "${itemDataset.trim()}".` })
setInput('')
setExpected('')
} catch (e) {
setAddMsg({ tone: 'err', text: e instanceof Error ? e.message : String(e) })
} finally {
setAdding(false)
}
}
const Status = ({ m }: { m: { tone: 'ok' | 'err'; text: string } | null }) =>
m ? (
<Text fontSize="$2" color={m.tone === 'ok' ? '$color11' : '$color12'}>
{m.text}
</Text>
) : null
return (
<>
<PageHeader
title="Datasets"
subtitle="Curate evaluation datasets and items."
actions={
<Button size="$2" icon={<RefreshCw size={15} />} onPress={loadList}>
Refresh
</Button>
}
/>
{/* ── List (forward-compatible; honest when the read route is absent) ── */}
{list.phase === 'error' ? (
<BackendStateCard state={list.error} onRetry={loadList} hint="endpoint · GET /v1/evals/datasets" />
) : (
<DataTable
columns={columns}
rows={list.phase === 'ready' ? list.rows : []}
loading={list.phase === 'loading'}
rowKey={(d) => d.id ?? d.name}
empty="No datasets returned. Create one below, then add items."
/>
)}
{/* ── Create + add ───────────────────────────────────────────────── */}
<XStack gap="$4" flexWrap="wrap" items="flex-start">
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor" flex={1} minW={340}>
<XStack gap="$2" items="center">
<Database size={16} />
<Text fontSize="$4" fontWeight="700">New dataset</Text>
</XStack>
<FieldRow label="Name">
<FieldText value={name} onChange={setName} placeholder="my-eval-set" />
</FieldRow>
<FieldRow label="Description">
<FieldText value={description} onChange={setDescription} />
</FieldRow>
<XStack gap="$3" items="center">
<Button self="flex-start" icon={<Plus size={15} />} disabled={creating} onPress={() => void createDataset()}>
{creating ? 'Creating…' : 'Create'}
</Button>
<Status m={createMsg} />
</XStack>
</Card>
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor" flex={1} minW={340}>
<XStack gap="$2" items="center">
<Plus size={16} />
<Text fontSize="$4" fontWeight="700">Add item</Text>
</XStack>
<FieldRow label="Dataset">
<FieldText value={itemDataset} onChange={setItemDataset} placeholder="dataset name" />
</FieldRow>
<FieldRow label="Input">
<FieldTextArea value={input} onChange={setInput} rows={3} />
</FieldRow>
<FieldRow label="Expected output">
<FieldTextArea value={expected} onChange={setExpected} rows={3} />
</FieldRow>
<XStack gap="$3" items="center">
<Button self="flex-start" icon={<Plus size={15} />} disabled={adding} onPress={() => void addItem()}>
{adding ? 'Adding…' : 'Add item'}
</Button>
<Status m={addMsg} />
</XStack>
<Text fontSize="$2" color="$color10">
Input and expected output accept plain text or JSON.
</Text>
</Card>
</XStack>
</>
)
}
+129
View File
@@ -0,0 +1,129 @@
'use client'
/**
* Edge — globally-distributed edge compute nodes run by the platform.
*
* Reads the edge-node inventory from the PaaS via the same-origin `/paas` proxy
* (`GET /v1/edge/nodes`), which injects the service token server-side. When the
* edge 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 EdgeNode = {
id: string
name?: string
region?: string
status?: string
requests?: string
latency?: string
}
export function EdgeModule(_props: { params: Record<string, string> }) {
const [rows, setRows] = useState<EdgeNode[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<PlatformError | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await restGet<{ nodes?: EdgeNode[] }>(paas('edge/nodes'))
setRows(r.nodes ?? [])
setLoadError(null)
} catch (e) {
setLoadError(interpretPlatformError(e))
setRows([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
const columns: Column<EdgeNode>[] = [
{
key: 'name',
header: 'Name',
render: (n) => (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{n.name || n.id}
</Text>
),
},
{
key: 'region',
header: 'Region',
width: 140,
render: (n) => (
<Text fontSize="$3" color="$color11">
{n.region || '—'}
</Text>
),
},
{
key: 'requests',
header: 'Requests',
width: 120,
render: (n) => (
<Text fontSize="$3" color="$color11">
{n.requests || '—'}
</Text>
),
},
{
key: 'latency',
header: 'Latency',
width: 110,
render: (n) => (
<Text fontSize="$3" color="$color11">
{n.latency || '—'}
</Text>
),
},
{
key: 'status',
header: 'Status',
width: 120,
render: (n) => <StatusTag status={n.status ?? 'unknown'} />,
},
]
return (
<>
<PageHeader
title="Edge"
subtitle="Globally-distributed edge compute nodes."
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={(n) => n.id}
empty="No edge nodes yet."
/>
)}
</>
)
}
@@ -0,0 +1,128 @@
'use client'
/**
* Environments — deploy targets (production, staging, development).
*
* Reads the environment list from the PaaS via the same-origin `/paas` proxy
* (`GET /v1/environments`), which injects the service token server-side. When the
* environments service isn't provisioned the load fails and the honest
* not-configured / unavailable card renders instead of an empty grid.
*/
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 Environment = {
id: string
name?: string
type?: string
status?: string
services?: string[]
updatedAt?: string
}
export function EnvironmentsModule(_props: { params: Record<string, string> }) {
const [rows, setRows] = useState<Environment[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<PlatformError | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await restGet<{ environments?: Environment[] }>(paas('environments'))
setRows(r.environments ?? [])
setLoadError(null)
} catch (e) {
setLoadError(interpretPlatformError(e))
setRows([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
const columns: Column<Environment>[] = [
{
key: 'name',
header: 'Name',
render: (e) => (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{e.name || e.id}
</Text>
),
},
{
key: 'type',
header: 'Type',
width: 130,
render: (e) => (
<Text fontSize="$3" color="$color11">
{e.type || '—'}
</Text>
),
},
{
key: 'status',
header: 'Status',
width: 120,
render: (e) => <StatusTag status={e.status ?? 'unknown'} />,
},
{
key: 'services',
header: 'Services',
width: 110,
render: (e) => (
<Text fontSize="$3" color="$color11">
{e.services?.length ?? 0}
</Text>
),
},
{
key: 'updatedAt',
header: 'Updated',
width: 190,
render: (e) => (
<Text fontSize="$3" color="$color11">
{e.updatedAt ? new Date(e.updatedAt).toLocaleString() : '—'}
</Text>
),
},
]
return (
<>
<PageHeader
title="Environments"
subtitle="Deploy targets — production, staging, and development."
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={(e) => e.id}
empty="No environments yet."
/>
)}
</>
)
}
+49
View File
@@ -0,0 +1,49 @@
'use client'
/**
* Evals — evaluate model outputs with scored runs (ported from the old console's
* evals feature). Two REAL surfaces over the cloud `/v1/evals/*` facade:
* - Run : POST /v1/evals/runs — run a dataset against a model + LLM judge.
* - Scores : GET /v1/evals/scores — the resulting scores.
* Routing: `/evals` opens Run; `/evals/scores` opens Scores. Both render honest
* states (loading / backend-unavailable / empty) — never fabricated results.
*/
import { useRouter } from 'next/navigation'
import { Button, XStack } from '@hanzo/gui'
import { PageHeader } from '~/components/ui/PageHeader'
import { EvalRunView } from './evals/EvalRunView'
import { EvalScoresView } from './evals/EvalScoresView'
const TABS = [
{ id: 'run', label: 'Run', path: '/evals' },
{ id: 'scores', label: 'Scores', path: '/evals/scores' },
] as const
export function EvalsModule({ params }: { params: Record<string, string> }) {
const router = useRouter()
const tab = params.tab === 'scores' ? 'scores' : 'run'
return (
<>
<PageHeader title="Evals" subtitle="Evaluate model and agent outputs with scored runs." />
<XStack gap="$1" flexWrap="wrap">
{TABS.map((t) => (
<Button
key={t.id}
size="$2"
bg={t.id === tab ? '$color5' : 'transparent'}
borderWidth={1}
borderColor="$borderColor"
onPress={() => router.push(t.path)}
>
{t.label}
</Button>
))}
</XStack>
{tab === 'scores' ? <EvalScoresView /> : <EvalRunView />}
</>
)
}
@@ -0,0 +1,130 @@
'use client'
/**
* Fine-tuning — custom model training runs (base model, progress, status)
* orchestrated by the platform.
*
* Reads the fine-tuning jobs from the PaaS via the same-origin `/paas` proxy
* (`GET /v1/finetuning/jobs`), which injects the service token server-side. When
* the fine-tuning 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 FinetuningJob = {
id: string
name?: string
baseModel?: string
status?: string
progress?: number
createdAt?: string
}
export function FinetuningModule(_props: { params: Record<string, string> }) {
const [rows, setRows] = useState<FinetuningJob[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<PlatformError | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await restGet<{ jobs?: FinetuningJob[] }>(paas('finetuning/jobs'))
setRows(r.jobs ?? [])
setLoadError(null)
} catch (e) {
setLoadError(interpretPlatformError(e))
setRows([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
const columns: Column<FinetuningJob>[] = [
{
key: 'name',
header: 'Name',
render: (j) => (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{j.name || j.id}
</Text>
),
},
{
key: 'baseModel',
header: 'Base model',
width: 180,
render: (j) => (
<Text fontSize="$3" color="$color11">
{j.baseModel || '—'}
</Text>
),
},
{
key: 'progress',
header: 'Progress',
width: 110,
render: (j) => (
<Text fontSize="$3" color="$color11">
{j.progress ?? '—'}
</Text>
),
},
{
key: 'status',
header: 'Status',
width: 120,
render: (j) => <StatusTag status={j.status ?? 'unknown'} />,
},
{
key: 'createdAt',
header: 'Created',
width: 190,
render: (j) => (
<Text fontSize="$3" color="$color11">
{j.createdAt ? new Date(j.createdAt).toLocaleString() : '—'}
</Text>
),
},
]
return (
<>
<PageHeader
title="Fine-tuning"
subtitle="Custom model training runs."
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={(j) => j.id}
empty="No fine-tuning jobs yet."
/>
)}
</>
)
}
+129
View File
@@ -0,0 +1,129 @@
'use client'
/**
* Functions — serverless compute (FaaS) deployed and invoked by the platform.
*
* Reads the function inventory from the PaaS via the same-origin `/paas` proxy
* (`GET /v1/functions`), which injects the service token server-side. When the
* functions 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 Func = {
id: string
name?: string
runtime?: string
status?: string
invocations?: string
lastInvoked?: string
}
export function FunctionsModule(_props: { params: Record<string, string> }) {
const [rows, setRows] = useState<Func[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<PlatformError | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await restGet<{ functions?: Func[] }>(paas('functions'))
setRows(r.functions ?? [])
setLoadError(null)
} catch (e) {
setLoadError(interpretPlatformError(e))
setRows([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
const columns: Column<Func>[] = [
{
key: 'name',
header: 'Name',
render: (f) => (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{f.name || f.id}
</Text>
),
},
{
key: 'runtime',
header: 'Runtime',
width: 140,
render: (f) => (
<Text fontSize="$3" color="$color11">
{f.runtime || '—'}
</Text>
),
},
{
key: 'invocations',
header: 'Invocations',
width: 120,
render: (f) => (
<Text fontSize="$3" color="$color11">
{f.invocations || '—'}
</Text>
),
},
{
key: 'status',
header: 'Status',
width: 120,
render: (f) => <StatusTag status={f.status ?? 'unknown'} />,
},
{
key: 'lastInvoked',
header: 'Last invoked',
width: 190,
render: (f) => (
<Text fontSize="$3" color="$color11">
{f.lastInvoked ? new Date(f.lastInvoked).toLocaleString() : '—'}
</Text>
),
},
]
return (
<>
<PageHeader
title="Functions"
subtitle="Serverless compute (FaaS)."
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={(f) => f.id}
empty="No functions deployed yet."
/>
)}
</>
)
}
+140
View File
@@ -0,0 +1,140 @@
'use client'
/**
* GPUs — accelerator inventory and utilization tracked by the platform.
*
* Reads the GPU inventory from the PaaS via the same-origin `/paas` proxy
* (`GET /v1/gpus`), which injects the service token server-side. When the
* GPU 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 Gpu = {
id: string
name?: string
model?: string
status?: string
utilization?: string
memory?: string
node?: string
}
export function GpusModule(_props: { params: Record<string, string> }) {
const [rows, setRows] = useState<Gpu[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<PlatformError | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await restGet<{ gpus?: Gpu[] }>(paas('gpus'))
setRows(r.gpus ?? [])
setLoadError(null)
} catch (e) {
setLoadError(interpretPlatformError(e))
setRows([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
const columns: Column<Gpu>[] = [
{
key: 'name',
header: 'Name',
render: (g) => (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{g.name || g.id}
</Text>
),
},
{
key: 'model',
header: 'Model',
width: 160,
render: (g) => (
<Text fontSize="$3" color="$color11">
{g.model || '—'}
</Text>
),
},
{
key: 'node',
header: 'Node',
width: 140,
render: (g) => (
<Text fontSize="$3" color="$color11">
{g.node || '—'}
</Text>
),
},
{
key: 'utilization',
header: 'Utilization',
width: 120,
render: (g) => (
<Text fontSize="$3" color="$color11">
{g.utilization || '—'}
</Text>
),
},
{
key: 'memory',
header: 'Memory',
width: 110,
render: (g) => (
<Text fontSize="$3" color="$color11">
{g.memory || '—'}
</Text>
),
},
{
key: 'status',
header: 'Status',
width: 120,
render: (g) => <StatusTag status={g.status ?? 'unknown'} />,
},
]
return (
<>
<PageHeader
title="GPUs"
subtitle="Accelerator inventory and utilization."
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={(g) => g.id}
empty="No GPUs yet."
/>
)}
</>
)
}
+130
View File
@@ -0,0 +1,130 @@
'use client'
/**
* HSM — hardware security modules that back key custody (PKCS#11 partitions,
* cloud-KMS HSM backends) for the platform's signing and encryption keys.
*
* Reads the configured HSM backends from the PaaS via the same-origin `/paas`
* proxy (`GET /v1/hsm`), which injects the service token server-side. When the
* HSM 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 Hsm = {
id: string
name?: string
type?: string
status?: string
keys?: number
region?: string
}
export function HsmModule(_props: { params: Record<string, string> }) {
const [rows, setRows] = useState<Hsm[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<PlatformError | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await restGet<{ hsms?: Hsm[] }>(paas('hsm'))
setRows(r.hsms ?? [])
setLoadError(null)
} catch (e) {
setLoadError(interpretPlatformError(e))
setRows([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
const columns: Column<Hsm>[] = [
{
key: 'name',
header: 'Name',
render: (h) => (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{h.name || h.id}
</Text>
),
},
{
key: 'type',
header: 'Type',
width: 150,
render: (h) => (
<Text fontSize="$3" color="$color11">
{h.type || '—'}
</Text>
),
},
{
key: 'region',
header: 'Region',
width: 130,
render: (h) => (
<Text fontSize="$3" color="$color11">
{h.region || '—'}
</Text>
),
},
{
key: 'keys',
header: 'Keys',
width: 90,
render: (h) => (
<Text fontSize="$3" color="$color11">
{h.keys ?? 0}
</Text>
),
},
{
key: 'status',
header: 'Status',
width: 120,
render: (h) => <StatusTag status={h.status ?? 'unknown'} />,
},
]
return (
<>
<PageHeader
title="HSM"
subtitle="Hardware security modules — key custody backends."
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={(h) => h.id}
empty="No HSM backends configured yet."
/>
)}
</>
)
}
+141
View File
@@ -0,0 +1,141 @@
'use client'
/**
* Indexer — chain indexers tracking block and event indexing status per network
* (current height, lag behind chain head, health) reported by the platform.
*
* Reads the indexer registry from the PaaS via the same-origin `/paas` proxy
* (`GET /v1/indexers`), which injects the service token server-side. When the
* indexer 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 Indexer = {
id: string
chain?: string
network?: string
height?: string
lag?: string
status?: string
updatedAt?: string
}
export function IndexerModule(_props: { params: Record<string, string> }) {
const [rows, setRows] = useState<Indexer[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<PlatformError | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await restGet<{ indexers?: Indexer[] }>(paas('indexers'))
setRows(r.indexers ?? [])
setLoadError(null)
} catch (e) {
setLoadError(interpretPlatformError(e))
setRows([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
const columns: Column<Indexer>[] = [
{
key: 'chain',
header: 'Chain',
render: (i) => (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{i.chain || i.id}
</Text>
),
},
{
key: 'network',
header: 'Network',
width: 160,
render: (i) => (
<Text fontSize="$3" color="$color11">
{i.network || '—'}
</Text>
),
},
{
key: 'height',
header: 'Height',
width: 120,
render: (i) => (
<Text fontSize="$3" color="$color11">
{i.height || '—'}
</Text>
),
},
{
key: 'lag',
header: 'Lag',
width: 100,
render: (i) => (
<Text fontSize="$3" color="$color11">
{i.lag || '—'}
</Text>
),
},
{
key: 'status',
header: 'Status',
width: 120,
render: (i) => <StatusTag status={i.status ?? 'unknown'} />,
},
{
key: 'updatedAt',
header: 'Updated',
width: 190,
render: (i) => (
<Text fontSize="$3" color="$color11">
{i.updatedAt ? new Date(i.updatedAt).toLocaleString() : '—'}
</Text>
),
},
]
return (
<>
<PageHeader
title="Indexer"
subtitle="Chain indexers — block and event indexing status per network."
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={(i) => i.id}
empty="No indexers running yet."
/>
)}
</>
)
}
+130
View File
@@ -0,0 +1,130 @@
'use client'
/**
* Inference — deployed model-serving endpoints (replicas, request volume, rollout
* status) served by the platform.
*
* Reads the endpoint inventory from the PaaS via the same-origin `/paas` proxy
* (`GET /v1/inference/endpoints`), which injects the service token server-side.
* When the inference 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 InferenceEndpoint = {
id: string
name?: string
model?: string
status?: string
replicas?: number
requests?: number
}
export function InferenceModule(_props: { params: Record<string, string> }) {
const [rows, setRows] = useState<InferenceEndpoint[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<PlatformError | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await restGet<{ endpoints?: InferenceEndpoint[] }>(paas('inference/endpoints'))
setRows(r.endpoints ?? [])
setLoadError(null)
} catch (e) {
setLoadError(interpretPlatformError(e))
setRows([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
const columns: Column<InferenceEndpoint>[] = [
{
key: 'name',
header: 'Name',
render: (e) => (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{e.name || e.id}
</Text>
),
},
{
key: 'model',
header: 'Model',
width: 180,
render: (e) => (
<Text fontSize="$3" color="$color11">
{e.model || '—'}
</Text>
),
},
{
key: 'replicas',
header: 'Replicas',
width: 100,
render: (e) => (
<Text fontSize="$3" color="$color11">
{e.replicas ?? '—'}
</Text>
),
},
{
key: 'requests',
header: 'Requests',
width: 120,
render: (e) => (
<Text fontSize="$3" color="$color11">
{e.requests ?? '—'}
</Text>
),
},
{
key: 'status',
header: 'Status',
width: 120,
render: (e) => <StatusTag status={e.status ?? 'unknown'} />,
},
]
return (
<>
<PageHeader
title="Inference"
subtitle="Deployed model-serving endpoints."
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={(e) => e.id}
empty="No inference endpoints yet."
/>
)}
</>
)
}
+129
View File
@@ -0,0 +1,129 @@
'use client'
/**
* Jobs — scheduled and one-off batch workloads run by the platform.
*
* Reads the job inventory from the PaaS via the same-origin `/paas` proxy
* (`GET /v1/jobs`), which injects the service token server-side. When the
* jobs 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 Job = {
id: string
name?: string
type?: string
status?: string
schedule?: string
lastRun?: string
}
export function JobsModule(_props: { params: Record<string, string> }) {
const [rows, setRows] = useState<Job[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<PlatformError | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await restGet<{ jobs?: Job[] }>(paas('jobs'))
setRows(r.jobs ?? [])
setLoadError(null)
} catch (e) {
setLoadError(interpretPlatformError(e))
setRows([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
const columns: Column<Job>[] = [
{
key: 'name',
header: 'Name',
render: (j) => (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{j.name || j.id}
</Text>
),
},
{
key: 'type',
header: 'Type',
width: 120,
render: (j) => (
<Text fontSize="$3" color="$color11">
{j.type || '—'}
</Text>
),
},
{
key: 'schedule',
header: 'Schedule',
width: 140,
render: (j) => (
<Text fontSize="$3" color="$color11">
{j.schedule || '—'}
</Text>
),
},
{
key: 'status',
header: 'Status',
width: 120,
render: (j) => <StatusTag status={j.status ?? 'unknown'} />,
},
{
key: 'lastRun',
header: 'Last run',
width: 190,
render: (j) => (
<Text fontSize="$3" color="$color11">
{j.lastRun ? new Date(j.lastRun).toLocaleString() : '—'}
</Text>
),
},
]
return (
<>
<PageHeader
title="Jobs"
subtitle="Scheduled and one-off batch workloads."
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={(j) => j.id}
empty="No jobs yet."
/>
)}
</>
)
}
+74 -83
View File
@@ -1,72 +1,66 @@
'use client'
/**
* Secrets (KMS) — Hanzo KMS is zero-knowledge: secret names AND values are
* encrypted on the MPC nodes, and the HTTP surface is get-one / put / rotate /
* delete under token (ZAP) auth — there is deliberately NO "list every secret
* value" endpoint, and a browser session cookie is not KMS credentials. So this
* module does NOT fabricate a secret table. It states the model plainly, probes
* the real `/v1/kms` surface to report whether it's reachable from this console,
* and deep-links to the KMS console for actual secret management.
* Secrets (KMS) — a names-only inventory over the server-gated `/admin/kms` proxy.
*
* Hanzo KMS is zero-knowledge: there is deliberately NO "list every secret value"
* endpoint, and a browser session cookie is not KMS authorization. So this module
* lists secret METADATA only (path / name / env / version) via `KmsAdminApi.list`
* the proxy enforces the brand-admin gate and forwards as the user, scoped to
* the active org. Values are NEVER fetched or rendered here. States are honest:
* loading, operator-access-required (403), listing-unavailable (404 — kmsd without
* the metadata-list endpoint), error, and empty.
*/
import { useCallback, useEffect, useState } from 'react'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { ExternalLink, RefreshCw, ShieldCheck, TriangleAlert, Check } from '@hanzogui/lucide-icons-2'
import { Button, Card, Text, XStack } from '@hanzo/gui'
import { ExternalLink, RefreshCw, ShieldCheck } from '@hanzogui/lucide-icons-2'
import { ApiError } from '~/lib/api'
import { restGet, v1Url } from '~/lib/api/client'
import { ApiError, KmsAdminApi, type KmsSecretMeta } from '~/lib/api'
import { currentOrg, isScopedAway } from '~/lib/org-scope'
import { config } from '~/config'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { ErrorState, asApiError, isForbidden, OperatorAccessRequired, type HonestCopy } from '~/components/ui/States'
const KMS_CONSOLE = 'https://kms.hanzo.ai'
type Probe =
| { phase: 'checking' }
| { phase: 'reachable' }
| { phase: 'elevated' }
| { phase: 'unavailable' }
| { phase: 'error'; message: string }
function describe(p: Probe): { tone: 'ok' | 'warn'; title: string; body: string } {
switch (p.phase) {
case 'checking':
return { tone: 'warn', title: 'Checking…', body: `Probing ${config.brandName} KMS.` }
case 'reachable':
return { tone: 'ok', title: 'KMS reachable', body: 'The KMS surface responded to this console session.' }
case 'elevated':
return {
tone: 'warn',
title: 'Elevated credentials required',
body: 'KMS responded but a console session cookie is not KMS authorization. Secret access uses token / ZAP (mnemonic-derived) credentials — manage secrets in the KMS console.',
}
case 'unavailable':
return {
tone: 'warn',
title: 'Not routed on this host',
body: 'The /v1/kms surface is not proxied on this host yet. It appears once the deployment routes /v1/kms to Hanzo KMS.',
}
case 'error':
return { tone: 'warn', title: 'Could not reach KMS', body: p.message }
}
/** KMS-specific guidance for the honest 404 / unauthorized states. */
const KMS_COPY: HonestCopy = {
notFound:
'Secret listing requires Hanzo KMS with the metadata-list endpoint (v0.159.4+). The inventory appears automatically once kmsd is upgraded — only names are listed, never values.',
unauthorized:
'This view requires an operator session. Access is enforced server-side by the admin gate — sign in with an @hanzo.ai admin account.',
}
const fmtDate = (v?: string): string => {
if (!v) return '—'
const d = new Date(v)
return Number.isNaN(d.getTime()) ? v : d.toLocaleString()
}
const columns: Column<KmsSecretMeta>[] = [
{ key: 'name', header: 'Name', render: (s) => <Text fontSize="$3" fontWeight="600" numberOfLines={1}>{s.name}</Text> },
{ key: 'path', header: 'Path', render: (s) => <Text fontSize="$3" color="$color11" numberOfLines={1}>{s.path || '—'}</Text> },
{ key: 'env', header: 'Env', width: 130, render: (s) => <Text fontSize="$3" color="$color11">{s.env || 'default'}</Text> },
{ key: 'version', header: 'Version', width: 100, render: (s) => <Text fontSize="$3" color="$color11">{s.version || '—'}</Text> },
{ key: 'updatedTime', header: 'Updated', width: 200, render: (s) => <Text fontSize="$3" color="$color10">{fmtDate(s.updatedTime)}</Text> },
]
type LoadState =
| { phase: 'loading' }
| { phase: 'error'; err: ApiError }
| { phase: 'ready'; rows: KmsSecretMeta[] }
export function KmsModule(_props: { params: Record<string, string> }) {
const [probe, setProbe] = useState<Probe>({ phase: 'checking' })
const [state, setState] = useState<LoadState>({ phase: 'loading' })
const org = currentOrg()
const run = useCallback(() => {
setProbe({ phase: 'checking' })
// Hit the canonical org-scoped secrets path. There is no list endpoint, so
// any 2xx is unexpected-but-fine; 401/403 means "reachable, needs token";
// 404 means "not routed here". Either way we never render secret values.
restGet(v1Url(`kms/orgs/${encodeURIComponent(config.iamOrgName)}/secrets/`))
.then(() => setProbe({ phase: 'reachable' }))
.catch((e) => {
const status = e instanceof ApiError ? e.status : 0
if (status === 401 || status === 403) setProbe({ phase: 'elevated' })
else if (status === 404) setProbe({ phase: 'unavailable' })
else setProbe({ phase: 'error', message: e instanceof Error ? e.message : String(e) })
})
}, [])
setState({ phase: 'loading' })
KmsAdminApi.list({ org })
.then((rows) => setState({ phase: 'ready', rows }))
.catch((e) => setState({ phase: 'error', err: asApiError(e) }))
}, [org])
useEffect(() => {
run()
@@ -76,17 +70,15 @@ export function KmsModule(_props: { params: Record<string, string> }) {
if (typeof window !== 'undefined') window.open(KMS_CONSOLE, '_blank', 'noopener')
}
const d = describe(probe)
return (
<>
<PageHeader
title="Secrets"
subtitle="Encryption keys and secrets, managed by Hanzo KMS."
subtitle={`Secret inventory (names only) for ${org}${isScopedAway() ? '' : ' — your organization'}. Values are never listed; manage them in the KMS console.`}
actions={
<XStack gap="$2">
<Button size="$2" icon={<RefreshCw size={15} />} onPress={run}>
Recheck
Refresh
</Button>
<Button icon={<ExternalLink size={15} />} onPress={open}>
KMS console
@@ -95,40 +87,39 @@ export function KmsModule(_props: { params: Record<string, string> }) {
}
/>
<Card p="$4" gap="$2.5" borderWidth={1} borderColor="$borderColor" maxWidth={680}>
<Card p="$3.5" gap="$2" borderWidth={1} borderColor="$borderColor" maxWidth={820}>
<XStack gap="$2" items="center">
<ShieldCheck size={20} />
<Text fontSize="$5" fontWeight="700">
<ShieldCheck size={18} />
<Text fontSize="$4" fontWeight="700">
Zero-knowledge by design
</Text>
</XStack>
<Text fontSize="$3" color="$color11">
KMS encrypts both secret names and values on the MPC nodes; nothing is stored or returned
in plaintext, and there is no endpoint that lists secret values. The console therefore does
not display a secret table create, read, rotate, and delete secrets in the KMS console,
where each value is revealed only on explicit, audited access.
KMS encrypts secret names and values on the MPC nodes. This inventory lists names and
versions only each value is revealed solely on explicit, audited access in the KMS
console. The console never fetches or stores a secret value.
</Text>
<XStack>
<Button self="flex-start" theme="light" iconAfter={<ExternalLink size={15} />} onPress={open}>
Open KMS console
</Button>
</XStack>
</Card>
<Card p="$4" gap="$2" borderWidth={1} borderColor={d.tone === 'ok' ? '$color7' : '$borderColor'} maxWidth={680}>
<XStack gap="$2" items="center">
{d.tone === 'ok' ? <Check size={16} /> : <TriangleAlert size={16} />}
<Text fontSize="$4" fontWeight="700">
{d.title}
</Text>
</XStack>
<Text fontSize="$3" color="$color11">
{d.body}
</Text>
<Text fontSize="$2" color="$color10">
endpoint · /v1/kms/orgs/{config.iamOrgName}/secrets
</Text>
</Card>
{state.phase === 'error' ? (
isForbidden(state.err) ? (
<OperatorAccessRequired />
) : (
<ErrorState err={state.err} onRetry={run} copy={KMS_COPY} />
)
) : (
<DataTable
columns={columns}
rows={state.phase === 'ready' ? state.rows : []}
loading={state.phase === 'loading'}
rowKey={(s) => `${s.path}/${s.name}/${s.env}`}
empty={`No secrets in ${org}.`}
/>
)}
<Text fontSize="$2" color="$color10">
endpoint · /v1/kms/orgs/{org}/secrets · {config.brandName}
</Text>
</>
)
}
+100 -178
View File
@@ -1,193 +1,132 @@
'use client'
/**
* Kubernetes — browse workloads and operator-managed custom resources for one
* cluster, through the Hanzo PaaS control plane (via the same-origin `/paas`
* proxy, org-scoped by path). Pick a cluster, then tab across Deployments / Pods
* / Services / Ingresses / Events / Custom Resources. Every state is honest:
* loading, not-configured (501), backend-not-available (404), error, and empty
* are all real — no fabricated rows, no fake charts.
* Kubernetes — browse the operator-managed workloads running on each cluster,
* through the Hanzo PaaS control plane (same-origin `/paas` proxy → `GET /v1/apps`,
* the apps inventory). Pick a cluster, see its services with declared/running tag
* and health. The platform exposes the inventory (a workload IS a `Service` CR +
* its Deployment), not a raw kube-apiserver passthrough, so this shows exactly
* what the control plane reports — never fabricated pods or fake charts. Every
* state is honest: loading, not-configured (proxy 501 / token rejected), upstream
* error, and empty are all real.
*
* Reuses the SAME `PlatformApi.apps` source as the Status module (one way to read
* the platform); it just scopes the view to a chosen cluster.
*/
import { useCallback, useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { RefreshCw, Network } from '@hanzogui/lucide-icons-2'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { RefreshCw } from '@hanzogui/lucide-icons-2'
import {
PlatformApi,
KubernetesApi,
K8S_RESOURCES,
type Cluster,
type K8sResource,
type K8sObject,
} from '~/lib/api'
import { config } from '~/config'
import { PlatformApi, clustersFromApps, type PlatformApp } from '~/lib/api'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { StatusTag } from '~/components/ui/StatusTag'
import { FieldSelect } from '~/components/ui/Field'
import { interpretPlatformError, PlatformStateCard, type PlatformError } from './platform/state'
const DEFAULT_KIND: K8sResource = 'deployments'
const isKind = (v: string): v is K8sResource => K8S_RESOURCES.some((r) => r.id === v)
const fmtAge = (o: K8sObject): string => {
if (o.age) return String(o.age)
if (!o.createdAt) return '—'
const d = new Date(o.createdAt)
return Number.isNaN(d.getTime()) ? String(o.createdAt) : d.toLocaleString()
}
const txt = (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>
)
const nameCell = (o: K8sObject) => (
<Text fontSize="$3" fontWeight="600" numberOfLines={1}>
{o.name ?? o.object ?? '—'}
</Text>
)
/** Defensive, per-kind columns — render what the platform reports, nothing more. */
function columnsFor(kind: K8sResource): Column<K8sObject>[] {
const ns: Column<K8sObject> = { key: 'namespace', header: 'Namespace', width: 160, render: (o) => txt(o.namespace) }
const age: Column<K8sObject> = { key: 'age', header: 'Age', width: 180, render: (o) => txt(fmtAge(o)) }
const status: Column<K8sObject> = {
key: 'status',
header: 'Status',
width: 130,
render: (o) => <StatusTag status={o.status ?? o.phase} />,
}
switch (kind) {
case 'pods':
return [{ key: 'name', header: 'Name', render: nameCell }, ns, status, { key: 'ready', header: 'Ready', width: 90, render: (o) => txt(o.ready) }, age]
case 'services':
return [{ key: 'name', header: 'Name', render: nameCell }, ns, { key: 'type', header: 'Type', width: 130, render: (o) => txt(o.type) }, age]
case 'ingresses':
return [{ key: 'name', header: 'Name', render: nameCell }, ns, { key: 'hosts', header: 'Hosts', render: (o) => txt(o.hosts?.join(', ') ?? o.host) }, age]
case 'events':
return [
{ key: 'type', header: 'Type', width: 110, render: (o) => txt(o.type) },
{ key: 'reason', header: 'Reason', width: 160, render: (o) => txt(o.reason) },
{ key: 'object', header: 'Object', render: (o) => txt(o.object ?? o.name) },
{ key: 'message', header: 'Message', render: (o) => txt(o.message) },
age,
]
case 'crs':
return [{ key: 'name', header: 'Name', render: nameCell }, ns, { key: 'kind', header: 'Kind', width: 180, render: (o) => txt(o.kind) }, age]
case 'deployments':
default:
return [{ key: 'name', header: 'Name', render: nameCell }, ns, { key: 'ready', header: 'Ready', width: 90, render: (o) => txt(o.ready) }, status, age]
}
}
const rowKey = (o: K8sObject): string =>
[o.namespace ?? '-', o.kind ?? '', o.name ?? o.object ?? '', o.reason ?? '', o.message ?? ''].join('|')
type ResourceState =
type State =
| { phase: 'loading' }
| { phase: 'error'; error: PlatformError }
| { phase: 'ready'; rows: K8sObject[] }
| { phase: 'ready'; apps: PlatformApp[] }
/** The workload table for one (cluster, kind) — honest states then DataTable. */
function ResourcePanel({ org, cluster, kind }: { org: string; cluster: string; kind: K8sResource }) {
const [state, setState] = useState<ResourceState>({ phase: 'loading' })
const columns: Column<PlatformApp>[] = [
{
key: 'app',
header: 'Workload',
render: (a) => (
<Text fontSize="$3" fontWeight="600" numberOfLines={1}>
{a.app}
</Text>
),
},
{
key: 'namespace',
header: 'Namespace',
width: 150,
render: (a) => (
<Text fontSize="$3" color="$color11" numberOfLines={1}>
{a.namespace ?? '—'}
</Text>
),
},
{ key: 'env', header: 'Env', width: 80, render: (a) => <Text fontSize="$3" color="$color11">{a.env}</Text> },
{
key: 'declaredTag',
header: 'Declared',
width: 140,
render: (a) => (
<Text fontSize="$2" color="$color11" numberOfLines={1}>
{a.declaredTag ?? '—'}
</Text>
),
},
{
key: 'runningTag',
header: 'Running',
width: 140,
render: (a) => (
<Text fontSize="$2" color="$color11" numberOfLines={1}>
{a.runningTag ?? '—'}
</Text>
),
},
{ key: 'health', header: 'Health', width: 110, render: (a) => <StatusTag status={a.health} /> },
]
export function KubernetesModule(_props: { params: Record<string, string> }) {
const [state, setState] = useState<State>({ phase: 'loading' })
const [cluster, setCluster] = useState<string | null>(null)
const load = useCallback(() => {
setState({ phase: 'loading' })
KubernetesApi.listResource(org, cluster, kind)
.then((rows) => setState({ phase: 'ready', rows }))
PlatformApi.apps()
.then((apps) => {
setState({ phase: 'ready', apps })
setCluster((cur) => cur ?? clustersFromApps(apps)[0] ?? null)
})
.catch((e) => setState({ phase: 'error', error: interpretPlatformError(e) }))
}, [org, cluster, kind])
}, [])
useEffect(() => {
load()
}, [load])
if (state.phase === 'error') return <PlatformStateCard error={state.error} onRetry={load} />
return (
<DataTable
columns={columnsFor(kind)}
rows={state.phase === 'ready' ? state.rows : []}
loading={state.phase === 'loading'}
rowKey={rowKey}
empty={`No ${kind} in this cluster.`}
/>
const clusters = useMemo(
() => (state.phase === 'ready' ? clustersFromApps(state.apps) : []),
[state],
)
const active = cluster ?? clusters[0] ?? ''
const rows = useMemo(
() => (state.phase === 'ready' ? state.apps.filter((a) => a.cluster === active) : []),
[state, active],
)
}
type ClustersState =
| { phase: 'loading' }
| { phase: 'error'; error: PlatformError }
| { phase: 'ready'; clusters: Cluster[] }
const clusterId = (c: Cluster): string => c.id ?? c.name
export function KubernetesModule({ params }: { params: Record<string, string> }) {
const router = useRouter()
const org = config.iamOrgName
const kind: K8sResource = params.tab && isKind(params.tab) ? params.tab : DEFAULT_KIND
const [clusters, setClusters] = useState<ClustersState>({ phase: 'loading' })
const [selected, setSelected] = useState<Cluster | null>(null)
const loadClusters = useCallback(() => {
setClusters({ phase: 'loading' })
PlatformApi.listClusters()
.then((list) => {
const arr = list ?? []
setClusters({ phase: 'ready', clusters: arr })
setSelected((cur) => cur ?? arr[0] ?? null)
})
.catch((e) => setClusters({ phase: 'error', error: interpretPlatformError(e) }))
}, [])
useEffect(() => {
loadClusters()
}, [loadClusters])
return (
<>
<PageHeader
title="Kubernetes"
subtitle="Workloads and operator custom resources across your clusters."
subtitle="Operator-managed workloads across your clusters."
actions={
<Button icon={<RefreshCw size={16} />} onPress={loadClusters}>
<Button icon={<RefreshCw size={16} />} onPress={load}>
Refresh
</Button>
}
/>
{clusters.phase === 'loading' ? (
{state.phase === 'loading' ? (
<XStack p="$4" gap="$2" items="center">
<Spinner />
<Text color="$color11">Loading clusters</Text>
<Text color="$color11">Loading workloads</Text>
</XStack>
) : clusters.phase === 'error' ? (
<PlatformStateCard error={clusters.error} onRetry={loadClusters} />
) : clusters.clusters.length === 0 ? (
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$2" maxWidth={620}>
<XStack gap="$2" items="center">
<Network size={18} />
<Text fontSize="$5" fontWeight="700">
No clusters yet
</Text>
</XStack>
<Text fontSize="$3" color="$color11">
Provision a DOKS cluster or attach an existing one, then browse its workloads here.
</Text>
<Button size="$2" self="flex-start" onPress={() => router.push('/clusters')}>
Go to Clusters
</Button>
</Card>
) : state.phase === 'error' ? (
<PlatformStateCard error={state.error} onRetry={load} />
) : clusters.length === 0 ? (
<DataTable
columns={columns}
rows={[]}
rowKey={(a) => a.id}
empty="No workloads observed yet — the control plane has not reported any apps."
/>
) : (
<>
<XStack gap="$3" items="center" flexWrap="wrap">
@@ -195,35 +134,18 @@ export function KubernetesModule({ params }: { params: Record<string, string> })
Cluster
</Text>
<YStack minW={240}>
<FieldSelect
value={selected ? selected.name : clusters.clusters[0].name}
options={clusters.clusters.map((c) => c.name)}
onChange={(name) =>
setSelected(clusters.clusters.find((c) => c.name === name) ?? clusters.clusters[0])
}
/>
<FieldSelect value={active} options={clusters} onChange={setCluster} />
</YStack>
<Text fontSize="$2" color="$color10">
{rows.length} workload{rows.length === 1 ? '' : 's'}
</Text>
</XStack>
<XStack gap="$1" flexWrap="wrap">
{K8S_RESOURCES.map((r) => (
<Button
key={r.id}
size="$2"
bg={r.id === kind ? '$color5' : 'transparent'}
borderWidth={1}
borderColor="$borderColor"
onPress={() => router.push(r.id === DEFAULT_KIND ? '/kubernetes' : `/kubernetes/${r.id}`)}
>
{r.label}
</Button>
))}
</XStack>
<ResourcePanel
org={org}
cluster={clusterId(selected ?? clusters.clusters[0])}
kind={kind}
<DataTable
columns={columns}
rows={rows}
rowKey={(a) => a.id}
empty="No workloads in this cluster."
/>
</>
)}
@@ -0,0 +1,130 @@
'use client'
/**
* Load Balancers — L4/L7 traffic distribution across backend targets (the
* platform's managed LBs, with their virtual IPs and health-checked pools).
*
* Reads the load-balancer inventory from the PaaS via the same-origin `/paas`
* proxy (`GET /v1/load-balancers`), which injects the service token server-side.
* When the load-balancer 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 LoadBalancer = {
id: string
name?: string
type?: string
targets?: number
ip?: string
status?: string
}
export function LoadBalancerModule(_props: { params: Record<string, string> }) {
const [rows, setRows] = useState<LoadBalancer[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<PlatformError | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await restGet<{ loadBalancers?: LoadBalancer[] }>(paas('load-balancers'))
setRows(r.loadBalancers ?? [])
setLoadError(null)
} catch (e) {
setLoadError(interpretPlatformError(e))
setRows([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
const columns: Column<LoadBalancer>[] = [
{
key: 'name',
header: 'Name',
render: (lb) => (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{lb.name || lb.id}
</Text>
),
},
{
key: 'type',
header: 'Type',
width: 110,
render: (lb) => (
<Text fontSize="$3" color="$color11">
{lb.type || '—'}
</Text>
),
},
{
key: 'ip',
header: 'IP',
width: 150,
render: (lb) => (
<Text fontSize="$3" color="$color11">
{lb.ip || '—'}
</Text>
),
},
{
key: 'targets',
header: 'Targets',
width: 100,
render: (lb) => (
<Text fontSize="$3" color="$color11">
{lb.targets ?? 0}
</Text>
),
},
{
key: 'status',
header: 'Status',
width: 120,
render: (lb) => <StatusTag status={lb.status ?? 'unknown'} />,
},
]
return (
<>
<PageHeader
title="Load Balancers"
subtitle="L4/L7 traffic distribution."
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={(lb) => lb.id}
empty="No load balancers yet."
/>
)}
</>
)
}
+122
View File
@@ -0,0 +1,122 @@
'use client'
/**
* Logs — structured application and platform log lines (time, level, service,
* message) collected by the platform.
*
* Reads the log stream from the PaaS via the same-origin `/paas` proxy
* (`GET /v1/logs`), which injects the service token server-side. When the log
* 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 { interpretPlatformError, PlatformStateCard, type PlatformError } from './platform/state'
const paas = (path: string) => `/paas/${path.replace(/^\/+/, '')}`
type LogEntry = {
id: string
timestamp?: string
level?: string
service?: string
message?: string
}
export function LogsModule(_props: { params: Record<string, string> }) {
const [rows, setRows] = useState<LogEntry[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<PlatformError | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await restGet<{ logs?: LogEntry[] }>(paas('logs'))
setRows(r.logs ?? [])
setLoadError(null)
} catch (e) {
setLoadError(interpretPlatformError(e))
setRows([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
const columns: Column<LogEntry>[] = [
{
key: 'timestamp',
header: 'Time',
width: 190,
render: (l) => (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{l.timestamp ? new Date(l.timestamp).toLocaleString() : '—'}
</Text>
),
},
{
key: 'level',
header: 'Level',
width: 90,
render: (l) => (
<Text fontSize="$3" color="$color11">
{l.level || '—'}
</Text>
),
},
{
key: 'service',
header: 'Service',
width: 150,
render: (l) => (
<Text fontSize="$3" color="$color11">
{l.service || '—'}
</Text>
),
},
{
key: 'message',
header: 'Message',
render: (l) => (
<Text fontSize="$3" color="$color11" numberOfLines={1}>
{l.message || '—'}
</Text>
),
},
]
return (
<>
<PageHeader
title="Logs"
subtitle="Structured application and platform logs."
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={(r) => r.id}
empty="No logs to show."
/>
)}
</>
)
}
+140
View File
@@ -0,0 +1,140 @@
'use client'
/**
* Machines — Linux VMs (the Docker-Desktop replacement) run by the platform.
*
* Reads the machine inventory from the PaaS via the same-origin `/paas` proxy
* (`GET /v1/machines`), which injects the service token server-side. When the
* machines 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 Machine = {
id: string
name?: string
status?: string
os?: string
cpu?: string
memory?: string
ip?: string
}
export function MachinesModule(_props: { params: Record<string, string> }) {
const [rows, setRows] = useState<Machine[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<PlatformError | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await restGet<{ machines?: Machine[] }>(paas('machines'))
setRows(r.machines ?? [])
setLoadError(null)
} catch (e) {
setLoadError(interpretPlatformError(e))
setRows([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
const columns: Column<Machine>[] = [
{
key: 'name',
header: 'Name',
render: (m) => (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{m.name || m.id}
</Text>
),
},
{
key: 'os',
header: 'OS',
width: 120,
render: (m) => (
<Text fontSize="$3" color="$color11">
{m.os || '—'}
</Text>
),
},
{
key: 'cpu',
header: 'CPU',
width: 90,
render: (m) => (
<Text fontSize="$3" color="$color11">
{m.cpu || '—'}
</Text>
),
},
{
key: 'memory',
header: 'Memory',
width: 100,
render: (m) => (
<Text fontSize="$3" color="$color11">
{m.memory || '—'}
</Text>
),
},
{
key: 'ip',
header: 'IP',
width: 150,
render: (m) => (
<Text fontSize="$3" color="$color11">
{m.ip || '—'}
</Text>
),
},
{
key: 'status',
header: 'Status',
width: 120,
render: (m) => <StatusTag status={m.status ?? 'unknown'} />,
},
]
return (
<>
<PageHeader
title="Machines"
subtitle="Linux VMs — the Docker-Desktop replacement."
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={(m) => m.id}
empty="No machines yet."
/>
)}
</>
)
}
+386
View File
@@ -0,0 +1,386 @@
'use client'
/**
* Memory — the user's personal memory, unified into the console.
*
* List + search (kind filter + text query) at `/memory`; one memory's detail +
* edit at `/memory/<id>` (the `:id` route param selects the view, like
* ProvidersModule). Reads/writes the per-user `/v1/memory` backend through
* MemoryApi; tenancy is server-side, so the browser sends cookie credentials
* only. Until that Go backend deploys the routes 404 and we render an honest
* "initializing" state — never fabricated memories. Mutations report through the
* shared toast.
*/
import { useCallback, useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Card, Input, Text, XStack, YStack } from '@hanzo/gui'
import { Plus, Trash, Search, ArrowLeft, Save, RefreshCw } from '@hanzogui/lucide-icons-2'
import { MemoryApi, MEMORY_KINDS, type Memory, type MemoryKind } from '~/lib/api'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { FieldRow, FieldSelect, FieldTextArea } from '~/components/ui/Field'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { useToast } from '~/components/ui/Toast'
/** Per-kind tone, so every memory list reads the same. */
const KIND_TONE: Record<MemoryKind, { bg: '$color5' | '$color4' | '$color3'; fg: '$color12' | '$color11' }> = {
user: { bg: '$color5', fg: '$color12' },
feedback: { bg: '$color4', fg: '$color12' },
project: { bg: '$color5', fg: '$color12' },
reference: { bg: '$color3', fg: '$color11' },
fact: { bg: '$color4', fg: '$color12' },
}
function KindBadge({ kind }: { kind: MemoryKind }) {
const tone = KIND_TONE[kind] ?? { bg: '$color3' as const, fg: '$color11' as const }
return (
<Text fontSize="$1" px="$2" py="$1" rounded="$2" bg={tone.bg} color={tone.fg}>
{kind}
</Text>
)
}
const fmtDate = (v?: string): string => {
if (!v) return ''
const d = new Date(v)
return Number.isNaN(d.getTime()) ? v : d.toLocaleString()
}
/** The honest "backend not ready" card, with memory-specific guidance. */
function NotReady({ state, onRetry }: { state: BackendState; onRetry: () => void }) {
return (
<BackendStateCard
state={state}
onRetry={onRetry}
hint="The per-user memory backend (/v1/memory) is being deployed. Your memories — what you've told the assistant to remember — appear here automatically once it is live."
/>
)
}
// ── List + search + add ───────────────────────────────────────────────────
const KIND_OPTIONS = ['all', ...MEMORY_KINDS] as const
function MemoryListView({ onOpen }: { onOpen: (m: Memory) => void }) {
const toast = useToast()
const [rows, setRows] = useState<Memory[]>([])
const [loading, setLoading] = useState(true)
const [state, setState] = useState<BackendState | null>(null)
const [query, setQuery] = useState('')
const [kind, setKind] = useState<(typeof KIND_OPTIONS)[number]>('all')
// Add form.
const [draftKind, setDraftKind] = useState<MemoryKind>('user')
const [draftContent, setDraftContent] = useState('')
const [saving, setSaving] = useState(false)
const load = useCallback(async () => {
setLoading(true)
const k = kind === 'all' ? undefined : kind
try {
const data = query.trim() ? await MemoryApi.search(query.trim(), k) : await MemoryApi.list(k)
setRows(data)
setState(null)
} catch (e) {
setState(classifyBackend(e))
} finally {
setLoading(false)
}
}, [query, kind])
useEffect(() => {
void load()
}, [load])
const onCreate = async () => {
const content = draftContent.trim()
if (!content) return
setSaving(true)
try {
await MemoryApi.remember({ kind: draftKind, content })
toast.success('Remembered', 'Your memory was stored.')
setDraftContent('')
await load()
} catch (e) {
const s = classifyBackend(e)
toast.error('Could not save memory', s.message)
} finally {
setSaving(false)
}
}
const onDelete = async (m: Memory) => {
if (typeof window !== 'undefined' && !window.confirm('Delete this memory?')) return
try {
await MemoryApi.remove(m.id)
setRows((rs) => rs.filter((r) => r.id !== m.id))
toast.success('Deleted')
} catch (e) {
toast.error('Could not delete', classifyBackend(e).message)
}
}
const columns: Column<Memory>[] = [
{
key: 'content',
header: 'Memory',
render: (m) => (
<Text fontSize="$3" color="$color12" numberOfLines={2} onPress={() => onOpen(m)} cursor="pointer">
{m.content}
</Text>
),
},
{ key: 'kind', header: 'Kind', width: 120, render: (m) => <KindBadge kind={m.kind} /> },
{
key: 'createdAt',
header: 'Created',
width: 190,
render: (m) => (
<Text fontSize="$3" color="$color11">
{fmtDate(m.createdAt)}
</Text>
),
},
{
key: 'action',
header: '',
width: 110,
render: (m) => (
<XStack gap="$2" justify="flex-end" flex={1}>
<Button size="$2" onPress={() => onOpen(m)}>
Open
</Button>
<Button size="$2" icon={<Trash size={14} />} onPress={() => void onDelete(m)} />
</XStack>
),
},
]
return (
<>
<PageHeader
title="Memory"
subtitle="Everything you've asked the assistant to remember — searchable, in one place."
actions={
<Button icon={<RefreshCw size={16} />} onPress={() => void load()}>
Refresh
</Button>
}
/>
{state ? (
<NotReady state={state} onRetry={() => void load()} />
) : (
<>
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor">
<Text fontSize="$5" fontWeight="700">
Remember something
</Text>
<FieldRow label="Kind">
<FieldSelect
value={draftKind}
options={[...MEMORY_KINDS]}
onChange={(v) => setDraftKind(v as MemoryKind)}
/>
</FieldRow>
<FieldRow label="Content">
<FieldTextArea value={draftContent} onChange={setDraftContent} rows={3} />
</FieldRow>
<XStack>
<Button
theme="light"
icon={<Plus size={16} />}
disabled={saving || !draftContent.trim()}
onPress={() => void onCreate()}
>
{saving ? 'Saving…' : 'Remember'}
</Button>
</XStack>
</Card>
<XStack gap="$2" items="center" flexWrap="wrap">
<XStack flex={1} minW={240} items="center" gap="$2">
<Input
flex={1}
value={query}
onChangeText={setQuery}
placeholder="Search memories…"
autoCapitalize="none"
onSubmitEditing={() => void load()}
/>
<Button icon={<Search size={16} />} onPress={() => void load()}>
Search
</Button>
</XStack>
<XStack width={220}>
<FieldSelect
value={kind}
options={[...KIND_OPTIONS]}
onChange={(v) => setKind(v as (typeof KIND_OPTIONS)[number])}
/>
</XStack>
</XStack>
<DataTable
columns={columns}
rows={rows}
loading={loading}
rowKey={(m) => m.id}
empty={query.trim() ? 'No memories match your search.' : 'No memories yet. Add one above.'}
/>
</>
)}
</>
)
}
// ── Detail + edit ─────────────────────────────────────────────────────────
function MemoryDetailView({ id, onBack }: { id: string; onBack: () => void }) {
const toast = useToast()
const [memory, setMemory] = useState<Memory | null>(null)
const [loading, setLoading] = useState(true)
const [state, setState] = useState<BackendState | null>(null)
const [notFound, setNotFound] = useState(false)
const [kind, setKind] = useState<MemoryKind>('user')
const [content, setContent] = useState('')
const [saving, setSaving] = useState(false)
// No GET-by-id route in the contract; the per-user list is small, so resolve
// the one memory from it. Honest "not found" when it isn't there.
const load = useCallback(async () => {
setLoading(true)
try {
const all = await MemoryApi.list()
const found = all.find((m) => m.id === id) ?? null
setMemory(found)
setNotFound(!found)
if (found) {
setKind(found.kind)
setContent(found.content)
}
setState(null)
} catch (e) {
setState(classifyBackend(e))
} finally {
setLoading(false)
}
}, [id])
useEffect(() => {
void load()
}, [load])
const onSave = async () => {
if (!content.trim()) return
setSaving(true)
try {
await MemoryApi.update({ id, kind, content: content.trim() })
toast.success('Saved')
await load()
} catch (e) {
toast.error('Could not save', classifyBackend(e).message)
} finally {
setSaving(false)
}
}
const onDelete = async () => {
if (typeof window !== 'undefined' && !window.confirm('Delete this memory?')) return
try {
await MemoryApi.remove(id)
toast.success('Deleted')
onBack()
} catch (e) {
toast.error('Could not delete', classifyBackend(e).message)
}
}
return (
<>
<PageHeader
title="Memory"
subtitle="View and edit one memory."
actions={
<Button icon={<ArrowLeft size={16} />} onPress={onBack}>
Back
</Button>
}
/>
{state ? (
<NotReady state={state} onRetry={() => void load()} />
) : loading ? (
<Text color="$color11">Loading</Text>
) : notFound ? (
<Card p="$4" borderWidth={1} borderColor="$borderColor">
<Text color="$color11">This memory no longer exists.</Text>
</Card>
) : memory ? (
<>
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor">
<FieldRow label="Kind">
<FieldSelect value={kind} options={[...MEMORY_KINDS]} onChange={(v) => setKind(v as MemoryKind)} />
</FieldRow>
<FieldRow label="Content">
<FieldTextArea value={content} onChange={setContent} rows={6} />
</FieldRow>
{memory.createdAt ? (
<FieldRow label="Created">
<Text fontSize="$3" color="$color11" pt="$2">
{fmtDate(memory.createdAt)}
</Text>
</FieldRow>
) : null}
<XStack gap="$2">
<Button
theme="light"
icon={<Save size={16} />}
disabled={saving || !content.trim()}
onPress={() => void onSave()}
>
{saving ? 'Saving…' : 'Save'}
</Button>
<Button theme="red" icon={<Trash size={16} />} onPress={() => void onDelete()}>
Delete
</Button>
</XStack>
</Card>
{memory.metadata && Object.keys(memory.metadata).length > 0 ? (
<Card p="$4" gap="$2" borderWidth={1} borderColor="$borderColor">
<Text fontSize="$5" fontWeight="700">
Metadata
</Text>
<Text
fontSize="$2"
color="$color11"
px="$3"
py="$2"
bg="$color2"
rounded="$3"
borderWidth={1}
borderColor="$borderColor"
>
{JSON.stringify(memory.metadata, null, 2)}
</Text>
</Card>
) : null}
</>
) : null}
</>
)
}
export function MemoryModule({ params }: { params: Record<string, string> }) {
const router = useRouter()
const id = params.id
if (id) {
return <MemoryDetailView id={decodeURIComponent(id)} onBack={() => router.push('/memory')} />
}
return <MemoryListView onOpen={(m) => router.push(`/memory/${encodeURIComponent(m.id)}`)} />
}
@@ -0,0 +1,172 @@
'use client'
/**
* Model catalog — the available models served by the gateway, native on @hanzo/gui.
*
* Ported from hanzoai/console features/cloud-models (ModelsTable + ProviderFilter):
* the provider filter, the premium badge, and the $/Mtok price columns. Data is
* the REAL `/v1/models` list (`CloudModelApi`) with a best-effort pricing overlay.
* Read-only — models come from providers + routes (their own modules). Loading,
* error (404 not routed / 401 / 503), and empty are honest states; prices that
* the pricing API doesn't return read "—" rather than being fabricated.
*/
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button, Text, XStack } from '@hanzo/gui'
import { RefreshCw } from '@hanzogui/lucide-icons-2'
import { ApiError, CloudModelApi, providerLabels, type CatalogModel } from '~/lib/api'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { ErrorState, asApiError } from '~/components/ui/States'
/** Format a $/million-tokens price the same way the source ModelsTable did. */
function formatPrice(perMillion?: number): string {
if (perMillion == null) return '—'
if (perMillion === 0) return 'Free'
if (perMillion < 0.01) return `$${perMillion.toFixed(4)}`
if (perMillion < 1) return `$${perMillion.toFixed(3)}`
return `$${perMillion.toFixed(2)}`
}
const Badge = ({ on, label }: { on: boolean; label: string }) => (
<Text
fontSize="$1"
px="$2"
py="$1"
rounded="$2"
bg={on ? '$color5' : '$color3'}
color={on ? '$color12' : '$color11'}
>
{label}
</Text>
)
type LoadState =
| { phase: 'loading' }
| { phase: 'error'; err: ApiError }
| { phase: 'ready'; models: CatalogModel[] }
const columns: Column<CatalogModel>[] = [
{
key: 'id',
header: 'Model',
render: (m) => (
<Text fontSize="$3" color="$color12" numberOfLines={1}>
{m.id}
</Text>
),
},
{
key: 'owned_by',
header: 'Provider',
width: 180,
render: (m) => <Text fontSize="$3">{providerLabels[m.owned_by] ?? m.owned_by}</Text>,
},
{
key: 'premium',
header: 'Tier',
width: 110,
render: (m) => <Badge on={!!m.premium} label={m.premium ? 'Premium' : 'Standard'} />,
},
{
key: 'input',
header: 'Input $/Mtok',
width: 130,
render: (m) => <Text fontSize="$3" color="$color11">{formatPrice(m.pricing?.inputPerMillion)}</Text>,
},
{
key: 'output',
header: 'Output $/Mtok',
width: 130,
render: (m) => <Text fontSize="$3" color="$color11">{formatPrice(m.pricing?.outputPerMillion)}</Text>,
},
]
export function ModelCatalogModule(_props: { params: Record<string, string> }) {
const [state, setState] = useState<LoadState>({ phase: 'loading' })
const [provider, setProvider] = useState<string | null>(null)
const run = useCallback(() => {
setState({ phase: 'loading' })
CloudModelApi.list()
.then((models) => setState({ phase: 'ready', models }))
.catch((e) => setState({ phase: 'error', err: asApiError(e) }))
}, [])
useEffect(() => {
run()
}, [run])
const models = state.phase === 'ready' ? state.models : []
const providers = useMemo(
() => Array.from(new Set(models.map((m) => m.owned_by))).sort(),
[models],
)
const rows = useMemo(
() => (provider ? models.filter((m) => m.owned_by === provider) : models),
[models, provider],
)
return (
<>
<PageHeader
title="Model Catalog"
subtitle="Available models across providers, with pricing. Routing is configured in Models; credentials in Providers."
actions={
<Button size="$2" icon={<RefreshCw size={15} />} onPress={run}>
Refresh
</Button>
}
/>
{state.phase === 'error' ? (
<ErrorState
err={state.err}
onRetry={run}
copy={{
notFound:
'The model catalog (/v1/models) is not routed on this host yet. It appears automatically once the deployment proxies /v1/models through the gateway.',
}}
/>
) : (
<>
{providers.length > 0 ? (
<XStack gap="$1" flexWrap="wrap">
<Button
size="$2"
bg={provider === null ? '$color5' : 'transparent'}
borderWidth={1}
borderColor="$borderColor"
onPress={() => setProvider(null)}
>
All
</Button>
{providers.map((p) => (
<Button
key={p}
size="$2"
bg={provider === p ? '$color5' : 'transparent'}
borderWidth={1}
borderColor="$borderColor"
onPress={() => setProvider(p)}
>
{providerLabels[p] ?? p}
</Button>
))}
</XStack>
) : null}
<DataTable
columns={columns}
rows={rows}
loading={state.phase === 'loading'}
rowKey={(m) => m.id}
empty="No models available on this deployment yet."
/>
</>
)}
</>
)
}
+80
View File
@@ -0,0 +1,80 @@
'use client'
/**
* Model picker — the ONE way to choose a gateway model across the console
* (Playground, Evals run). Loads the real catalog from GET /v1/models; renders a
* select when it's available and a free-text id field otherwise, so a model can
* always be chosen even when the catalog can't be read. Honest: it never invents
* model names, and surfaces a retry when the catalog is unreachable.
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { Button, Text, XStack, YStack } from '@hanzo/gui'
import { RefreshCw } from '@hanzogui/lucide-icons-2'
import { PlaygroundApi } from '~/lib/api'
import { FieldSelect, FieldText } from '~/components/ui/Field'
import { classifyBackend, type BackendState } from '~/components/ui/BackendState'
type State =
| { phase: 'loading' }
| { phase: 'error'; error: BackendState }
| { phase: 'ready'; models: string[] }
export function ModelPicker({
value,
onChange,
placeholder = 'model id, e.g. zen-omni',
autoselect = false,
}: {
value: string
onChange: (v: string) => void
placeholder?: string
/** When set, pick the first catalog model if `value` is still empty on load. */
autoselect?: boolean
}) {
const [state, setState] = useState<State>({ phase: 'loading' })
// Refs so `load` can autoselect without depending on `value`/`onChange`
// (which would re-create it on every keystroke and re-fetch the catalog).
const valueRef = useRef(value)
valueRef.current = value
const onChangeRef = useRef(onChange)
onChangeRef.current = onChange
const load = useCallback(() => {
setState({ phase: 'loading' })
PlaygroundApi.listModels()
.then((models) => {
setState({ phase: 'ready', models })
if (autoselect && !valueRef.current && models[0]) onChangeRef.current(models[0])
})
.catch((e) => setState({ phase: 'error', error: classifyBackend(e) }))
}, [autoselect])
useEffect(() => {
load()
}, [load])
return (
<YStack gap="$1.5">
{state.phase === 'ready' && state.models.length > 0 ? (
<FieldSelect value={value} options={state.models} onChange={onChange} />
) : (
<FieldText value={value} onChange={onChange} placeholder={placeholder} />
)}
{state.phase === 'loading' ? (
<Text fontSize="$2" color="$color10">
Loading model catalog
</Text>
) : state.phase === 'error' ? (
<XStack gap="$2" items="center">
<Text fontSize="$2" color="$color10">
Model list unavailable enter a model id.
</Text>
<Button size="$1" chromeless icon={<RefreshCw size={12} />} onPress={load}>
Retry
</Button>
</XStack>
) : null}
</YStack>
)
}
+65 -16
View File
@@ -1,39 +1,88 @@
'use client'
/**
* Models admin — model-route list + create/edit, native on @hanzo/gui.
* Models — the AI model surface, catalog-first.
*
* Logic ported from ModelRouteListPage.js + ModelRouteEditPage.js +
* backend/ModelRouteBackend.js: the route field set (provider/upstream/fallbacks/
* pricing/flags), the new-route template, and the add/update/delete calls. UI
* rebuilt clean on GUI primitives (no antd).
* The #1 "I don't see any models" trap was two near-identical nav items: "Models"
* (route POLICY, empty until you add routes) and "Model Catalog" (the live, ~49
* Zen models). The obvious click landed on the empty one. Fixed by MERGING: Models
* opens the live Catalog by default, with route policy as a secondary "Routing" tab
* — one product, the real models on the obvious click. The separate "Model Catalog"
* nav entry is retired (its surface IS this default tab; one way, no duplicate).
*
* Routing: `/models` lists; `/models/new` creates; `/models/<modelName>` edits.
* Routing:
* /models → Catalog (default — the live model list + pricing)
* /models/routing → Routing (model-route list)
* /models/routing/new → create a route
* /models/routing/<name> → edit a route
*/
import { useRouter } from 'next/navigation'
import { Button, XStack, YStack } from '@hanzo/gui'
import { Library, Waypoints } from '@hanzogui/lucide-icons-2'
import type { ComponentType } from 'react'
import { ModelCatalogModule } from './ModelCatalogModule'
import { ModelRouteListView } from './models/ModelRouteListView'
import { ModelRouteEditView } from './models/ModelRouteEditView'
type Tab = 'catalog' | 'routing'
function TabButton({
active,
label,
Icon,
onPress,
}: {
active: boolean
label: string
Icon: ComponentType<{ size?: number }>
onPress: () => void
}) {
return (
<Button
size="$2"
icon={<Icon size={15} />}
bg={active ? '$color5' : 'transparent'}
borderWidth={1}
borderColor="$borderColor"
onPress={onPress}
>
{label}
</Button>
)
}
export function ModelsModule({ params }: { params: Record<string, string> }) {
const router = useRouter()
const name = params.name
if (name === 'new') {
return <ModelRouteEditView modelName={null} onDone={() => router.push('/models')} />
}
if (name) {
// /models/routing/<name|new> — edit or create a route.
if (name !== undefined) {
return (
<ModelRouteEditView
modelName={decodeURIComponent(name)}
onDone={() => router.push('/models')}
modelName={name === 'new' ? null : decodeURIComponent(name)}
onDone={() => router.push('/models/routing')}
/>
)
}
const tab: Tab = params.tab === 'routing' ? 'routing' : 'catalog'
return (
<ModelRouteListView
onOpen={(r) => router.push(`/models/${encodeURIComponent(r.modelName ?? '')}`)}
onNew={() => router.push('/models/new')}
/>
<YStack gap="$3">
<XStack gap="$1.5">
<TabButton active={tab === 'catalog'} label="Catalog" Icon={Library} onPress={() => router.push('/models')} />
<TabButton active={tab === 'routing'} label="Routing" Icon={Waypoints} onPress={() => router.push('/models/routing')} />
</XStack>
{tab === 'catalog' ? (
<ModelCatalogModule params={params} />
) : (
<ModelRouteListView
onOpen={(r) => router.push(`/models/routing/${encodeURIComponent(r.modelName ?? '')}`)}
onNew={() => router.push('/models/routing/new')}
/>
)}
</YStack>
)
}
@@ -1,156 +0,0 @@
'use client'
/**
* Observability — traces, evals, and prompts for AI workloads (HIP-0106). This
* is the LARGEST old-console surface; porting it natively is staged, so this is
* the honest first step: it probes the REAL `/v1/o11y` runtime to report status,
* deep-links to the full observability surface for traces/evals/prompts today,
* and truthfully marks the in-console native browser as coming. No fabricated
* charts, no fake spans — only the real runtime status and real links.
*/
import { useCallback, useEffect, useState } from 'react'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { Activity, ExternalLink, RefreshCw, Check, TriangleAlert } from '@hanzogui/lucide-icons-2'
import { ApiError } from '~/lib/api'
import { restGet, v1Url } from '~/lib/api/client'
import { config } from '~/config'
import { PageHeader } from '~/components/ui/PageHeader'
const OBSERVABILITY_URL = 'https://insights.hanzo.ai'
type Status = 'checking' | 'online' | 'not-initialized' | 'unavailable' | 'access' | 'error'
function classify(e: unknown): Status {
const s = e instanceof ApiError ? e.status : 0
if (s === 503) return 'not-initialized'
if (s === 404) return 'unavailable'
if (s === 401 || s === 403) return 'access'
return 'error'
}
function describe(status: Status, message: string): { tone: 'ok' | 'warn'; title: string; body: string } {
switch (status) {
case 'checking':
return { tone: 'warn', title: 'Checking…', body: 'Probing the observability runtime.' }
case 'online':
return { tone: 'ok', title: 'Runtime online', body: 'The o11y runtime is serving on this host.' }
case 'not-initialized':
return {
tone: 'warn',
title: 'Runtime not initialized',
body: 'The /v1/o11y routes are mounted but the observability runtime (telemetry stores, query service) is not initialized on this deployment yet. Use the observability surface below until it is.',
}
case 'unavailable':
return { tone: 'warn', title: 'Not routed on this host', body: 'The /v1/o11y surface is not proxied here yet.' }
case 'access':
return { tone: 'warn', title: 'Access required', body: 'Sign in with an account that can read observability data.' }
case 'error':
return { tone: 'warn', title: 'Could not reach observability', body: message }
}
}
/** A deep-link tile for a sub-surface that lives in the observability product. */
function SurfaceTile({ title, body }: { title: string; body: string }) {
return (
<Card p="$4" gap="$2" borderWidth={1} borderColor="$borderColor" flex={1} minW={220}>
<Text fontSize="$5" fontWeight="700">
{title}
</Text>
<Text fontSize="$3" color="$color11" minH={40}>
{body}
</Text>
<XStack>
<Button
self="flex-start"
size="$2"
iconAfter={<ExternalLink size={14} />}
onPress={() => {
if (typeof window !== 'undefined') window.open(OBSERVABILITY_URL, '_blank', 'noopener')
}}
>
Open
</Button>
</XStack>
</Card>
)
}
export function ObservabilityModule(_props: { params: Record<string, string> }) {
const [status, setStatus] = useState<Status>('checking')
const [message, setMessage] = useState('')
const run = useCallback(() => {
setStatus('checking')
setMessage('')
restGet(v1Url('o11y/traces'))
.then(() => setStatus('online'))
.catch((e) => {
setStatus(classify(e))
setMessage(e instanceof Error ? e.message : String(e))
})
}, [])
useEffect(() => {
run()
}, [run])
const open = () => {
if (typeof window !== 'undefined') window.open(OBSERVABILITY_URL, '_blank', 'noopener')
}
const d = describe(status, message)
return (
<>
<PageHeader
title="Observability"
subtitle="Traces, evals, and prompts for your AI workloads."
actions={
<XStack gap="$2">
<Button size="$2" icon={<RefreshCw size={15} />} onPress={run}>
Recheck
</Button>
<Button icon={<ExternalLink size={15} />} onPress={open}>
Observability
</Button>
</XStack>
}
/>
<Card p="$4" gap="$2" borderWidth={1} borderColor={d.tone === 'ok' ? '$color7' : '$borderColor'} maxWidth={680}>
<XStack gap="$2" items="center">
{d.tone === 'ok' ? <Check size={16} /> : <TriangleAlert size={16} />}
<Text fontSize="$4" fontWeight="700">
{d.title}
</Text>
</XStack>
<Text fontSize="$3" color="$color11">
{d.body}
</Text>
<Text fontSize="$2" color="$color10">
endpoint · /v1/o11y · {config.brandName}
</Text>
</Card>
<XStack gap="$3" flexWrap="wrap">
<SurfaceTile title="Traces" body="Request and agent traces with spans and latency." />
<SurfaceTile title="Evals" body="Evaluation runs and scores for model outputs." />
<SurfaceTile title="Prompts" body="Prompt versions, diffs, and usage." />
</XStack>
<Card p="$4" gap="$2" borderWidth={1} borderColor="$borderColor" bg="$color2" maxWidth={680}>
<XStack gap="$2" items="center">
<Activity size={18} />
<Text fontSize="$4" fontWeight="700">
Native console browser coming
</Text>
</XStack>
<Text fontSize="$3" color="$color11">
In-console traces, evals, and prompts (built directly on /v1/o11y, HIP-0106) are on the
way. Until then, the full observability surface above has everything. This page never shows
placeholder telemetry.
</Text>
</Card>
</>
)
}
@@ -0,0 +1,136 @@
'use client'
/**
* Observations — the spans/generations/events inside traces. The flat,
* cross-trace view of every observation (the leaves a trace is built from),
* paged from the cloud o11y backend (`GET /v1/o11y/observations`). Honest
* runtime-not-initialized state when o11y isn't provisioned for the deployment.
*/
import { useCallback, useEffect, useState } from 'react'
import { Button, Text } from '@hanzo/gui'
import { RefreshCw } from '@hanzogui/lucide-icons-2'
import { O11yApi, type Observation, type O11yPageMeta } from '~/lib/api'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { StatusTag } from '~/components/ui/StatusTag'
import { RuntimeNotice } from './observability/RuntimeNotice'
import { Pager } from './observability/Pager'
import { fmtDate } from './observability/format'
const PAGE_LIMIT = 50
export function ObservationsModule(_props: { params: Record<string, string> }) {
const [rows, setRows] = useState<Observation[]>([])
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.observations({ 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<Observation>[] = [
{
key: 'name',
header: 'Name',
render: (o) => (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{o.name || o.id}
</Text>
),
},
{
key: 'type',
header: 'Type',
width: 130,
render: (o) => (
<Text fontSize="$3" color="$color11">
{o.type || '—'}
</Text>
),
},
{
key: 'model',
header: 'Model',
width: 170,
render: (o) => (
<Text fontSize="$3" color="$color11" numberOfLines={1}>
{o.model || '—'}
</Text>
),
},
{
key: 'usage',
header: 'Tokens',
width: 110,
render: (o) => (
<Text fontSize="$3" color="$color11">
{o.usage?.total ?? '—'}
</Text>
),
},
{
key: 'level',
header: 'Level',
width: 110,
render: (o) => <StatusTag status={o.level ?? 'default'} />,
},
{
key: 'startTime',
header: 'Start',
width: 190,
render: (o) => (
<Text fontSize="$3" color="$color11">
{fmtDate(o.startTime)}
</Text>
),
},
]
return (
<>
<PageHeader
title="Observations"
subtitle="Spans, generations, and events — the observations inside your traces."
actions={
<Button icon={<RefreshCw size={16} />} onPress={() => void load(page)}>
Refresh
</Button>
}
/>
{error ? (
<RuntimeNotice surface="observations" error={error} />
) : (
<>
<DataTable
columns={columns}
rows={rows}
loading={loading}
rowKey={(o) => o.id}
empty="No observations yet. Instrument your app to emit traces and their observations appear here."
/>
<Pager meta={meta} onPage={setPage} />
</>
)}
</>
)
}
+141
View File
@@ -0,0 +1,141 @@
'use client'
/**
* Oracles — price and data oracles feeding on-chain contracts (push/pull feeds,
* aggregated values, source provenance) tracked by the platform.
*
* Reads the oracle registry from the PaaS via the same-origin `/paas` proxy
* (`GET /v1/oracles`), which injects the service token server-side. When the
* oracle 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 Oracle = {
id: string
name?: string
feed?: string
value?: string
source?: string
status?: string
updatedAt?: string
}
export function OraclesModule(_props: { params: Record<string, string> }) {
const [rows, setRows] = useState<Oracle[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<PlatformError | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await restGet<{ oracles?: Oracle[] }>(paas('oracles'))
setRows(r.oracles ?? [])
setLoadError(null)
} catch (e) {
setLoadError(interpretPlatformError(e))
setRows([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
const columns: Column<Oracle>[] = [
{
key: 'name',
header: 'Name',
render: (o) => (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{o.name || o.id}
</Text>
),
},
{
key: 'feed',
header: 'Feed',
width: 160,
render: (o) => (
<Text fontSize="$3" color="$color11">
{o.feed || '—'}
</Text>
),
},
{
key: 'value',
header: 'Value',
width: 140,
render: (o) => (
<Text fontSize="$3" color="$color11">
{o.value || '—'}
</Text>
),
},
{
key: 'source',
header: 'Source',
width: 180,
render: (o) => (
<Text fontSize="$3" color="$color11">
{o.source || '—'}
</Text>
),
},
{
key: 'status',
header: 'Status',
width: 120,
render: (o) => <StatusTag status={o.status ?? 'unknown'} />,
},
{
key: 'updatedAt',
header: 'Updated',
width: 190,
render: (o) => (
<Text fontSize="$3" color="$color11">
{o.updatedAt ? new Date(o.updatedAt).toLocaleString() : '—'}
</Text>
),
},
]
return (
<>
<PageHeader
title="Oracles"
subtitle="Price and data oracles feeding on-chain contracts."
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={(o) => o.id}
empty="No oracles configured yet. Data feeds appear here."
/>
)}
</>
)
}
+128
View File
@@ -0,0 +1,128 @@
'use client'
/**
* Pipelines — CI pipelines (build, test, deploy) tracked per repository.
*
* Reads the pipeline list from the PaaS via the same-origin `/paas` proxy
* (`GET /v1/pipelines`), which injects the service token server-side. When the
* pipelines service isn't provisioned the load fails and the honest
* not-configured / unavailable card renders instead of an empty grid.
*/
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 Pipeline = {
id: string
name?: string
repo?: string
status?: string
lastRun?: string
duration?: string
}
export function PipelinesModule(_props: { params: Record<string, string> }) {
const [rows, setRows] = useState<Pipeline[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<PlatformError | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await restGet<{ pipelines?: Pipeline[] }>(paas('pipelines'))
setRows(r.pipelines ?? [])
setLoadError(null)
} catch (e) {
setLoadError(interpretPlatformError(e))
setRows([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
const columns: Column<Pipeline>[] = [
{
key: 'name',
header: 'Name',
render: (p) => (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{p.name || p.id}
</Text>
),
},
{
key: 'repo',
header: 'Repo',
width: 200,
render: (p) => (
<Text fontSize="$3" color="$color11" numberOfLines={1}>
{p.repo || '—'}
</Text>
),
},
{
key: 'status',
header: 'Status',
width: 120,
render: (p) => <StatusTag status={p.status ?? 'unknown'} />,
},
{
key: 'lastRun',
header: 'Last run',
width: 190,
render: (p) => (
<Text fontSize="$3" color="$color11">
{p.lastRun ? new Date(p.lastRun).toLocaleString() : '—'}
</Text>
),
},
{
key: 'duration',
header: 'Duration',
width: 110,
render: (p) => (
<Text fontSize="$3" color="$color11">
{p.duration || '—'}
</Text>
),
},
]
return (
<>
<PageHeader
title="Pipelines"
subtitle="CI pipelines — build, test, and deploy per repository."
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={(p) => p.id}
empty="No pipelines configured yet."
/>
)}
</>
)
}
-231
View File
@@ -1,231 +0,0 @@
'use client'
/**
* Deploy — PaaS embedded natively in the console (Job 3). Wired to the REAL
* platform.hanzo.ai control plane through console2's own `/paas` proxy (service
* token stays server-side). It shows real apps/deploys across clusters with
* declared vs running tag + drift, and a real health-gated redeploy. NOT an
* iframe, NOT a stub — and it never fabricates rows: loading, not-configured
* (501), error, and empty are all honest states.
*
* The six Deploy sub-pages (Projects · Environments · Builds · Registry ·
* Releases · Pipelines) are tabs over the same real app inventory, each framing
* the slice that matters for that lens.
*/
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Card, ScrollView, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { RefreshCw, Rocket, TriangleAlert } from '@hanzogui/lucide-icons-2'
import { paas, appTags, PaasError, type PaasApp } from '~/lib/paas/client'
import { PageHeader } from '~/components/ui/PageHeader'
const SECTIONS = [
{ id: '', label: 'Projects' },
{ id: 'environments', label: 'Environments' },
{ id: 'builds', label: 'Builds' },
{ id: 'registry', label: 'Registry' },
{ id: 'releases', label: 'Releases' },
{ id: 'pipelines', label: 'Pipelines' },
] as const
type State =
| { kind: 'loading' }
| { kind: 'not-configured'; message: string }
| { kind: 'error'; message: string }
| { kind: 'ready'; apps: PaasApp[] }
function appKey(a: PaasApp): string {
return a.id ?? a.name ?? a.service ?? Math.random().toString(36).slice(2)
}
function appName(a: PaasApp): string {
return a.name ?? a.service ?? a.id ?? 'unknown'
}
export function PlatformModule({ params }: { params: Record<string, string> }) {
const router = useRouter()
const section = params.section ?? ''
const [state, setState] = useState<State>({ kind: 'loading' })
const [busy, setBusy] = useState<string | null>(null)
const load = useCallback(async () => {
setState({ kind: 'loading' })
try {
const apps = await paas.listApps()
setState({ kind: 'ready', apps })
} catch (e) {
if (e instanceof PaasError && e.status === 501) {
setState({ kind: 'not-configured', message: e.message })
} else {
setState({ kind: 'error', message: e instanceof Error ? e.message : String(e) })
}
}
}, [])
useEffect(() => {
void load()
}, [load])
const onRedeploy = useCallback(
async (a: PaasApp) => {
const id = a.id ?? a.name
if (!id) return
setBusy(id)
try {
await paas.redeploy(id)
await load()
} catch {
/* surfaced on next load; keep the row */
} finally {
setBusy(null)
}
},
[load],
)
return (
<>
<PageHeader
title="Deploy"
subtitle="Projects, environments, builds, and releases — on the Hanzo PaaS control plane."
/>
{/* Section tabs */}
<XStack gap="$1" flexWrap="wrap">
{SECTIONS.map((s) => {
const active = s.id === section
return (
<Button
key={s.id || 'projects'}
size="$2"
bg={active ? '$color5' : 'transparent'}
borderWidth={1}
borderColor="$borderColor"
onPress={() => router.push(s.id ? `/deploy/${s.id}` : '/deploy')}
>
{s.label}
</Button>
)
})}
<Button size="$2" chromeless icon={<RefreshCw size={15} />} onPress={() => void load()}>
Refresh
</Button>
</XStack>
{state.kind === 'loading' ? (
<XStack p="$4" gap="$2" items="center">
<Spinner />
<Text color="$color11">Loading apps from platform.hanzo.ai</Text>
</XStack>
) : state.kind === 'not-configured' ? (
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$2" maxWidth={620}>
<Text fontSize="$5" fontWeight="700">
PaaS control plane not yet connected
</Text>
<Text fontSize="$3" color="$color11">
This console is wired to platform.hanzo.ai, but the server-side service token
(PAAS_SERVICE_TOKEN, from KMS) is not configured on this deployment yet. Once it is,
real projects and deploys appear here. No placeholder data is shown.
</Text>
</Card>
) : state.kind === 'error' ? (
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$2" maxWidth={620}>
<XStack gap="$2" items="center">
<TriangleAlert size={16} />
<Text fontSize="$4" fontWeight="700">
Could not reach the PaaS control plane
</Text>
</XStack>
<Text fontSize="$3" color="$color11">
{state.message}
</Text>
<Button size="$2" self="flex-start" onPress={() => void load()}>
Retry
</Button>
</Card>
) : state.apps.length === 0 ? (
<Card borderWidth={1} borderColor="$borderColor" p="$4" maxWidth={620}>
<Text fontSize="$4" fontWeight="700">
No apps yet
</Text>
<Text fontSize="$3" color="$color11">
The control plane returned no apps for your account. Deploy a project to see it here.
</Text>
</Card>
) : (
<AppTable apps={state.apps} section={section} busy={busy} onRedeploy={onRedeploy} />
)}
</>
)
}
function AppTable({
apps,
section,
busy,
onRedeploy,
}: {
apps: PaasApp[]
section: string
busy: string | null
onRedeploy: (a: PaasApp) => void
}) {
const showLatest = section === 'builds' || section === 'releases'
const sorted = useMemo(
() => [...apps].sort((a, b) => appName(a).localeCompare(appName(b))),
[apps],
)
return (
<YStack borderWidth={1} borderColor="$borderColor" rounded="$4" overflow="hidden">
<XStack bg="$color2" px="$3" py="$2" gap="$3">
<Text flex={2} fontSize="$2" fontWeight="700" color="$color11">App</Text>
<Text flex={1} fontSize="$2" fontWeight="700" color="$color11">Cluster</Text>
<Text flex={1} fontSize="$2" fontWeight="700" color="$color11">Declared</Text>
<Text flex={1} fontSize="$2" fontWeight="700" color="$color11">Running</Text>
{showLatest ? <Text flex={1} fontSize="$2" fontWeight="700" color="$color11">Latest</Text> : null}
<Text width={120} fontSize="$2" fontWeight="700" color="$color11">Status</Text>
<Text width={110} fontSize="$2" fontWeight="700" color="$color11" />
</XStack>
<ScrollView maxH={560}>
{sorted.map((a) => {
const t = appTags(a)
const id = a.id ?? a.name ?? ''
return (
<XStack
key={appKey(a)}
px="$3"
py="$2"
gap="$3"
items="center"
borderTopWidth={1}
borderColor="$borderColor"
>
<Text flex={2} fontSize="$3" fontWeight="600">{appName(a)}</Text>
<Text flex={1} fontSize="$2" color="$color11">{a.cluster ?? a.namespace ?? '—'}</Text>
<Text flex={1} fontSize="$2">{t.declared}</Text>
<XStack flex={1} gap="$1" items="center">
<Text fontSize="$2">{t.running}</Text>
{t.drift ? <TriangleAlert size={12} opacity={0.7} /> : null}
</XStack>
{showLatest ? <Text flex={1} fontSize="$2">{t.latest}</Text> : null}
<Text width={120} fontSize="$2" color="$color11">
{a.status ?? a.health ?? (t.drift ? 'Drift' : 'In sync')}
</Text>
<Button
width={110}
size="$2"
borderWidth={1}
borderColor="$borderColor"
icon={<Rocket size={13} />}
disabled={busy === id}
onPress={() => onRedeploy(a)}
>
{busy === id ? '…' : 'Redeploy'}
</Button>
</XStack>
)
})}
</ScrollView>
</YStack>
)
}
@@ -0,0 +1,209 @@
'use client'
/**
* Playground — try models and prompts interactively against the LIVE gateway.
*
* Ported from the old console's playground (features/playground): compose a
* system prompt + a message thread, set sampling params, run, read the output.
* The old console proxied to its own LLM connections; here the REAL runtime is
* the cloud gateway — GET /v1/models for the catalog, POST /v1/chat/completions
* to run (PlaygroundApi). No fabricated output: the model list, the completion,
* and the token usage are all real, and every failure renders an honest state.
*/
import { useState } from 'react'
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { Play, Plus, Trash } from '@hanzogui/lucide-icons-2'
import { PlaygroundApi, type ChatCompletion, type ChatMessage } from '~/lib/api'
import { PageHeader } from '~/components/ui/PageHeader'
import { FieldRow, FieldText, FieldTextArea, FieldSelect, FieldSlider } from '~/components/ui/Field'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { ModelPicker } from './ModelPicker'
const ROLES = ['user', 'assistant', 'system'] as const
/** Render multi-line model output preserving line breaks (no whitespace tricks). */
function OutputText({ content }: { content: string }) {
const lines = content.split('\n')
return (
<YStack>
{lines.map((line, i) => (
<Text key={i} fontSize="$3" color="$color12">
{line === '' ? ' ' : line}
</Text>
))}
</YStack>
)
}
export function PlaygroundModule(_props: { params: Record<string, string> }) {
const [model, setModel] = useState('')
const [system, setSystem] = useState('')
const [messages, setMessages] = useState<ChatMessage[]>([{ role: 'user', content: '' }])
const [temperature, setTemperature] = useState(0.7)
const [topP, setTopP] = useState(1)
const [maxTokens, setMaxTokens] = useState('1024')
const [running, setRunning] = useState(false)
const [result, setResult] = useState<ChatCompletion | null>(null)
const [runError, setRunError] = useState<BackendState | null>(null)
const setMessage = (i: number, patch: Partial<ChatMessage>) =>
setMessages((ms) => ms.map((m, j) => (j === i ? { ...m, ...patch } : m)))
const addMessage = () =>
setMessages((ms) => [...ms, { role: ms[ms.length - 1]?.role === 'user' ? 'assistant' : 'user', content: '' }])
const removeMessage = (i: number) => setMessages((ms) => ms.filter((_, j) => j !== i))
const run = async () => {
const thread: ChatMessage[] = []
if (system.trim()) thread.push({ role: 'system', content: system })
for (const m of messages) if (m.content.trim()) thread.push(m)
if (!model.trim()) {
setRunError({ kind: 'error', message: 'Choose or enter a model id first.' })
return
}
if (thread.length === 0) {
setRunError({ kind: 'error', message: 'Add at least one non-empty message.' })
return
}
setRunning(true)
setRunError(null)
setResult(null)
try {
const r = await PlaygroundApi.chat({
model: model.trim(),
messages: thread,
temperature,
top_p: topP,
max_tokens: Number(maxTokens) || undefined,
})
if (r.error?.message) {
setRunError({ kind: 'error', message: r.error.message })
} else {
setResult(r)
}
} catch (e) {
setRunError(classifyBackend(e))
} finally {
setRunning(false)
}
}
const content = result?.choices?.[0]?.message?.content ?? ''
const usage = result?.usage
return (
<>
<PageHeader
title="Playground"
subtitle="Try models and prompts against the live gateway."
actions={
<Button icon={<Play size={16} />} disabled={running} onPress={() => void run()}>
{running ? 'Running…' : 'Run'}
</Button>
}
/>
<XStack gap="$4" flexWrap="wrap" items="flex-start">
{/* ── Left: prompt + params ─────────────────────────────────────── */}
<YStack gap="$3" flex={1} minW={360}>
<Card p="$4" gap="$3.5" borderWidth={1} borderColor="$borderColor">
<FieldRow label="Model">
<ModelPicker value={model} onChange={setModel} autoselect />
</FieldRow>
<FieldRow label="System prompt">
<FieldTextArea value={system} onChange={setSystem} rows={4} />
</FieldRow>
<FieldRow label="Temperature">
<FieldSlider value={temperature} min={0} max={2} step={0.01} onChange={setTemperature} />
</FieldRow>
<FieldRow label="Top P">
<FieldSlider value={topP} min={0} max={1} step={0.01} onChange={setTopP} />
</FieldRow>
<FieldRow label="Max tokens">
<FieldText value={maxTokens} onChange={setMaxTokens} placeholder="1024" />
</FieldRow>
</Card>
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor">
<XStack justify="space-between" items="center">
<Text fontSize="$4" fontWeight="700">
Messages
</Text>
<Button size="$2" icon={<Plus size={14} />} onPress={addMessage}>
Add
</Button>
</XStack>
{messages.map((m, i) => (
<YStack key={i} gap="$2" borderTopWidth={i === 0 ? 0 : 1} borderColor="$borderColor" pt={i === 0 ? 0 : '$3'}>
<XStack gap="$2" items="center" justify="space-between">
<YStack width={160}>
<FieldSelect
value={m.role}
options={[...ROLES]}
onChange={(v) => setMessage(i, { role: v as ChatMessage['role'] })}
/>
</YStack>
{messages.length > 1 ? (
<Button size="$2" chromeless icon={<Trash size={14} />} onPress={() => removeMessage(i)} />
) : null}
</XStack>
<FieldTextArea value={m.content} onChange={(v) => setMessage(i, { content: v })} rows={3} />
</YStack>
))}
</Card>
</YStack>
{/* ── Right: output ─────────────────────────────────────────────── */}
<YStack gap="$3" flex={1} minW={360}>
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor" minH={240}>
<XStack justify="space-between" items="center">
<Text fontSize="$4" fontWeight="700">
Output
</Text>
{result?.model ? (
<Text fontSize="$2" color="$color10">
{result.model}
</Text>
) : null}
</XStack>
{running ? (
<XStack p="$4" gap="$2" items="center">
<Spinner color="$color11" />
<Text color="$color11">Running</Text>
</XStack>
) : runError ? (
<BackendStateCard state={runError} onRetry={() => void run()} />
) : result ? (
<YStack gap="$3">
<OutputText content={content} />
{usage ? (
<XStack gap="$3" flexWrap="wrap" borderTopWidth={1} borderColor="$borderColor" pt="$2">
<Text fontSize="$2" color="$color10">
prompt {usage.prompt_tokens ?? '—'}
</Text>
<Text fontSize="$2" color="$color10">
completion {usage.completion_tokens ?? '—'}
</Text>
<Text fontSize="$2" color="$color10">
total {usage.total_tokens ?? '—'}
</Text>
</XStack>
) : null}
</YStack>
) : (
<Text fontSize="$3" color="$color10">
Run to see the model output. Nothing is shown until a real completion returns.
</Text>
)}
</Card>
</YStack>
</XStack>
</>
)
}
@@ -80,9 +80,8 @@ export function ProductInterstitial({ id }: { id: string }) {
}
const Icon = entry.icon
const enabled = entry.status === 'enabled'
const external = entry.kind === 'external'
const primaryLabel = enabled ? 'Open' : external ? `Open ${entry.label}` : 'Get started'
const primaryLabel = external ? `Open ${entry.label}` : 'Open'
return (
<>
@@ -106,6 +105,7 @@ export function ProductInterstitial({ id }: { id: string }) {
</Text>
<Text fontSize="$2" color="$color10">
{entry.category}
{entry.gcp ? ` · ${entry.gcp}` : ''}
{entry.admin ? ' · admin' : ''}
</Text>
</YStack>
@@ -134,7 +134,7 @@ export function ProductInterstitial({ id }: { id: string }) {
title="Docs & guides"
body={`Quickstarts, how-tos, and API reference for ${entry.label}.`}
cta="Read the docs"
onPress={() => open(DOCS_URL)}
onPress={() => open(entry.docs ?? DOCS_URL)}
/>
{entry.repo ? (
<LinkCard
+147
View File
@@ -0,0 +1,147 @@
'use client'
/**
* Prompts — versioned prompt management (ported from the old console's prompts
* feature). The cloud gateway does not mount a `/v1/prompts` route yet (prompts
* live in the console eval engine, reached today through the observability
* surface and the prompt API), so this probes the forward-compatible endpoint:
* - On success it renders the REAL prompt list (name, version, type, labels).
* - On 404/405 it renders an honest card pointing at where prompts are managed
* today, and lights up automatically once the read route lands.
* No prompts are ever fabricated.
*/
import { useCallback, useEffect, useState } from 'react'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { RefreshCw, ExternalLink, FileText } from '@hanzogui/lucide-icons-2'
import { restGet, v1Url } from '~/lib/api/client'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
const PROMPTS_SURFACE = 'https://insights.hanzo.ai'
const PROMPTS_DOCS = 'https://docs.hanzo.ai/prompts'
/** Prompt metadata (Langfuse prompt-meta shape; only surfaced fields typed). */
type PromptMeta = {
name: string
versions?: number[]
type?: string
labels?: string[]
tags?: string[]
lastUpdatedAt?: string
}
type State =
| { phase: 'loading' }
| { phase: 'error'; error: BackendState }
| { phase: 'ready'; rows: PromptMeta[] }
const fmtTime = (s?: string) => {
if (!s) return '—'
const d = new Date(s)
return Number.isNaN(d.getTime()) ? s : d.toLocaleString()
}
const list = (xs?: string[]) => (xs && xs.length ? xs.join(', ') : '—')
const columns: Column<PromptMeta>[] = [
{ key: 'name', header: 'Name', render: (p) => (
<Text fontSize="$3" fontWeight="600" numberOfLines={1}>{p.name}</Text>
) },
{ key: 'versions', header: 'Versions', width: 100, render: (p) => (
<Text fontSize="$3" color="$color11">{p.versions?.length ?? '—'}</Text>
) },
{ key: 'type', header: 'Type', width: 90, render: (p) => (
<Text fontSize="$3" color="$color11">{p.type || '—'}</Text>
) },
{ key: 'labels', header: 'Labels', render: (p) => (
<Text fontSize="$3" color={p.labels?.length ? '$color11' : '$color10'} numberOfLines={1}>
{list(p.labels)}
</Text>
) },
{ key: 'lastUpdatedAt', header: 'Updated', width: 190, render: (p) => (
<Text fontSize="$3" color="$color10">{fmtTime(p.lastUpdatedAt)}</Text>
) },
]
const openSurface = () => {
if (typeof window !== 'undefined') window.open(PROMPTS_SURFACE, '_blank', 'noopener')
}
export function PromptsModule(_props: { params: Record<string, string> }) {
const [state, setState] = useState<State>({ phase: 'loading' })
const load = useCallback(() => {
setState({ phase: 'loading' })
restGet<{ data?: PromptMeta[] } | PromptMeta[]>(v1Url('prompts'))
.then((res) => {
const rows = Array.isArray(res) ? res : (res?.data ?? [])
setState({ phase: 'ready', rows })
})
.catch((e) => setState({ phase: 'error', error: classifyBackend(e) }))
}, [])
useEffect(() => {
load()
}, [load])
return (
<>
<PageHeader
title="Prompts"
subtitle="Versioned prompts with labels and history."
actions={
<XStack gap="$2">
<Button size="$2" icon={<RefreshCw size={15} />} onPress={load}>
Recheck
</Button>
<Button icon={<ExternalLink size={15} />} onPress={openSurface}>
Open surface
</Button>
</XStack>
}
/>
{state.phase === 'error' ? (
<YStack gap="$3">
<BackendStateCard state={state.error} onRetry={load} hint="endpoint · GET /v1/prompts" />
<Card p="$4" gap="$2" borderWidth={1} borderColor="$borderColor" bg="$color2" maxWidth={640}>
<XStack gap="$2" items="center">
<FileText size={18} />
<Text fontSize="$4" fontWeight="700">Where prompts live today</Text>
</XStack>
<Text fontSize="$3" color="$color11">
Prompt versions, labels, and history are managed in the observability surface and the
prompt API until the in-console browser is wired to /v1/prompts. This page never shows
placeholder prompts.
</Text>
<XStack gap="$2">
<Button size="$2" iconAfter={<ExternalLink size={14} />} onPress={openSurface}>
Observability
</Button>
<Button
size="$2"
chromeless
iconAfter={<ExternalLink size={14} />}
onPress={() => {
if (typeof window !== 'undefined') window.open(PROMPTS_DOCS, '_blank', 'noopener')
}}
>
Docs
</Button>
</XStack>
</Card>
</YStack>
) : (
<DataTable
columns={columns}
rows={state.phase === 'ready' ? state.rows : []}
loading={state.phase === 'loading'}
rowKey={(p) => p.name}
empty="No prompts yet."
/>
)}
</>
)
}
+128
View File
@@ -0,0 +1,128 @@
'use client'
/**
* Releases — versioned deploys recorded per environment.
*
* Reads the release history from the PaaS via the same-origin `/paas` proxy
* (`GET /v1/releases`), which injects the service token server-side. When the
* releases service isn't provisioned the load fails and the honest
* not-configured / unavailable card renders instead of an empty grid.
*/
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 Release = {
id: string
name?: string
version?: string
environment?: string
status?: string
releasedAt?: string
}
export function ReleasesModule(_props: { params: Record<string, string> }) {
const [rows, setRows] = useState<Release[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<PlatformError | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await restGet<{ releases?: Release[] }>(paas('releases'))
setRows(r.releases ?? [])
setLoadError(null)
} catch (e) {
setLoadError(interpretPlatformError(e))
setRows([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
const columns: Column<Release>[] = [
{
key: 'name',
header: 'Name',
render: (r) => (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{r.name || r.id}
</Text>
),
},
{
key: 'version',
header: 'Version',
width: 120,
render: (r) => (
<Text fontSize="$3" color="$color11">
{r.version || '—'}
</Text>
),
},
{
key: 'environment',
header: 'Environment',
width: 150,
render: (r) => (
<Text fontSize="$3" color="$color11">
{r.environment || '—'}
</Text>
),
},
{
key: 'status',
header: 'Status',
width: 120,
render: (r) => <StatusTag status={r.status ?? 'unknown'} />,
},
{
key: 'releasedAt',
header: 'Released',
width: 190,
render: (r) => (
<Text fontSize="$3" color="$color11">
{r.releasedAt ? new Date(r.releasedAt).toLocaleString() : '—'}
</Text>
),
},
]
return (
<>
<PageHeader
title="Releases"
subtitle="Versioned deploys per environment."
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={(r) => r.id}
empty="No releases yet."
/>
)}
</>
)
}
+15 -3
View File
@@ -42,6 +42,7 @@ import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { FieldRow, FieldText } from '~/components/ui/Field'
import { StatusTag } from '~/components/ui/StatusTag'
import { useToast } from '~/components/ui/Toast'
import { slugError } from '~/lib/slug'
const fmtDate = (v?: string): string => {
@@ -141,6 +142,7 @@ function ResourceDetailScreen({
onBack: () => void
}) {
const { kind, productLabel, connectionHint } = opts
const toast = useToast()
const [resource, setResource] = useState<Resource | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -167,9 +169,12 @@ function ResourceDetailScreen({
return
try {
await ProvisioningApi.remove(kind, name)
toast.success(`Deleted ${name}`)
onBack()
} catch (e) {
setError(e instanceof ApiError ? e.message : `Failed to delete "${name}"`)
const msg = e instanceof ApiError ? e.message : `Failed to delete "${name}"`
setError(msg)
toast.error(`Could not delete ${name}`, msg)
}
}
@@ -253,6 +258,7 @@ function ResourceListScreen({
onOpen: (r: Resource) => void
}) {
const { kind, productLabel, connectionHint } = opts
const toast = useToast()
const [rows, setRows] = useState<Resource[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -291,10 +297,13 @@ function ResourceListScreen({
try {
const res = await ProvisioningApi.create(kind, name)
setCreated(res)
toast.success(`Created ${res.name}`, 'Save the credentials shown — they appear only once.')
setName('')
await load()
} catch (e) {
setError(e instanceof ApiError ? e.message : `Failed to create ${productLabel}`)
const msg = e instanceof ApiError ? e.message : `Failed to create ${productLabel}`
setError(msg)
toast.error(`Could not create ${productLabel}`, msg)
} finally {
setCreating(false)
}
@@ -306,8 +315,11 @@ function ResourceListScreen({
try {
await ProvisioningApi.remove(kind, r.name)
setRows((rs) => rs.filter((x) => x.name !== r.name))
toast.success(`Deleted ${r.name}`)
} catch (e) {
setError(e instanceof ApiError ? e.message : `Failed to delete "${r.name}"`)
const msg = e instanceof ApiError ? e.message : `Failed to delete "${r.name}"`
setError(msg)
toast.error(`Could not delete ${r.name}`, msg)
}
}
@@ -0,0 +1,103 @@
'use client'
/**
* Score Configs — list of score definitions (HIP-0106), native on @hanzo/gui.
*
* A score config defines a score's data type and its valid range (numeric) or
* categories (categorical/boolean). Reads the REAL `/v1/o11y/score-configs`
* surface; when the runtime is not initialized (503) or unrouted (404) it shows
* an honest RuntimeNotice — never fabricated configs. Read-only here; configs are
* authored where scores are recorded.
*/
import { useCallback, useEffect, useState } from 'react'
import { Button, Text } from '@hanzo/gui'
import { RefreshCw } from '@hanzogui/lucide-icons-2'
import { O11yApi, type O11yPageMeta, type ScoreConfig } 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 { Badge } from './observability/parts'
import { fmtDate } from './observability/format'
const PAGE_LIMIT = 50
/** A one-line summary of a config's valid range or categories. */
const rangeOf = (c: ScoreConfig): string => {
if (c.categories && c.categories.length > 0) {
const labels = c.categories.map((x) => x.label).join(', ')
return `${c.categories.length} categories · ${labels}`
}
const hasMin = c.minValue !== undefined && c.minValue !== null
const hasMax = c.maxValue !== undefined && c.maxValue !== null
if (hasMin && hasMax) return `${c.minValue} ${c.maxValue}`
if (hasMin) return `${c.minValue}`
if (hasMax) return `${c.maxValue}`
return '—'
}
export function ScoreConfigsModule(_props: { params: Record<string, string> }) {
const [rows, setRows] = useState<ScoreConfig[]>([])
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.scoreConfigs({ 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<ScoreConfig>[] = [
{ key: 'name', header: 'Name', render: (c) => <Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>{c.name}</Text> },
{ key: 'dataType', header: 'Type', width: 120, render: (c) => <Badge label={c.dataType} /> },
{ key: 'range', header: 'Range / Categories', render: (c) => <Text fontSize="$3" color="$color11" numberOfLines={1}>{rangeOf(c)}</Text> },
{ key: 'isArchived', header: 'Status', width: 110, render: (c) => (c.isArchived ? <Badge label="archived" /> : <Text fontSize="$3" color="$color11">active</Text>) },
{ key: 'createdAt', header: 'Created', width: 190, render: (c) => <Text fontSize="$3" color="$color11">{fmtDate(c.createdAt)}</Text> },
]
return (
<>
<PageHeader
title="Score Configs"
subtitle="Score definitions — data type and valid range or categories."
actions={
<Button icon={<RefreshCw size={16} />} onPress={() => void load(page)}>
Refresh
</Button>
}
/>
{error ? (
<RuntimeNotice surface="score-configs" error={error} />
) : (
<>
<DataTable
columns={columns}
rows={rows}
loading={loading}
rowKey={(c) => c.id}
empty="No score configs yet. Define a score config to standardize how scores are recorded."
/>
<Pager meta={meta} onPage={setPage} />
</>
)}
</>
)
}
+106
View File
@@ -0,0 +1,106 @@
'use client'
/**
* Scores — list for evaluation scores (HIP-0106), native on @hanzo/gui.
*
* List at `/scores`. Reads the REAL `/v1/o11y/scores` surface; when the runtime
* is not initialized (503) or unrouted (404) it shows an honest RuntimeNotice —
* never fabricated scores or charts. A score links to its trace.
*/
import { useCallback, useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Text, XStack } from '@hanzo/gui'
import { ChevronRight, RefreshCw } from '@hanzogui/lucide-icons-2'
import { O11yApi, type O11yPageMeta, type Score } 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 { Badge } from './observability/parts'
import { fmtDate, scoreValue } from './observability/format'
const PAGE_LIMIT = 50
export function ScoresModule(_props: { params: Record<string, string> }) {
const router = useRouter()
const [rows, setRows] = useState<Score[]>([])
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.scores({ 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<Score>[] = [
{ key: 'name', header: 'Name', render: (s) => <Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>{s.name}</Text> },
{ key: 'value', header: 'Value', width: 160, render: (s) => <Text fontSize="$3" color="$color11" numberOfLines={1}>{scoreValue(s)}</Text> },
{ key: 'dataType', header: 'Type', width: 120, render: (s) => <Badge label={s.dataType} /> },
{ key: 'source', header: 'Source', width: 120, render: (s) => <Badge label={s.source} /> },
{ key: 'timestamp', header: 'Timestamp', width: 190, render: (s) => <Text fontSize="$3" color="$color11">{fmtDate(s.timestamp)}</Text> },
{
key: 'action',
header: '',
width: 120,
render: (s) =>
s.traceId ? (
<XStack justify="flex-end" flex={1}>
<Button
size="$2"
iconAfter={<ChevronRight size={14} />}
onPress={() => router.push(`/o11y/${encodeURIComponent(s.traceId as string)}`)}
>
Trace
</Button>
</XStack>
) : null,
},
]
return (
<>
<PageHeader
title="Scores"
subtitle="Evaluation scores from feedback, model graders, and manual review."
actions={
<Button icon={<RefreshCw size={16} />} onPress={() => void load(page)}>
Refresh
</Button>
}
/>
{error ? (
<RuntimeNotice surface="scores" error={error} />
) : (
<>
<DataTable
columns={columns}
rows={rows}
loading={loading}
rowKey={(s) => s.id}
empty="No scores yet. Scores appear here once evaluations or feedback are recorded."
/>
<Pager meta={meta} onPage={setPage} />
</>
)}
</>
)
}
@@ -0,0 +1,130 @@
'use client'
/**
* Service Mesh — inter-service routing, mutual-TLS, and traffic policy for the
* platform's workloads (the sidecar/mesh data plane).
*
* Reads the mesh service registry from the PaaS via the same-origin `/paas` proxy
* (`GET /v1/mesh/services`), which injects the service token server-side. When the
* mesh 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 MeshService = {
id: string
service?: string
namespace?: string
mtls?: string
requests?: number
status?: string
}
export function ServiceMeshModule(_props: { params: Record<string, string> }) {
const [rows, setRows] = useState<MeshService[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<PlatformError | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await restGet<{ services?: MeshService[] }>(paas('mesh/services'))
setRows(r.services ?? [])
setLoadError(null)
} catch (e) {
setLoadError(interpretPlatformError(e))
setRows([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
const columns: Column<MeshService>[] = [
{
key: 'service',
header: 'Service',
render: (m) => (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{m.service || m.id}
</Text>
),
},
{
key: 'namespace',
header: 'Namespace',
width: 160,
render: (m) => (
<Text fontSize="$3" color="$color11">
{m.namespace || '—'}
</Text>
),
},
{
key: 'mtls',
header: 'mTLS',
width: 90,
render: (m) => (
<Text fontSize="$3" color="$color11">
{m.mtls || '—'}
</Text>
),
},
{
key: 'requests',
header: 'Requests',
width: 120,
render: (m) => (
<Text fontSize="$3" color="$color11">
{m.requests ?? '—'}
</Text>
),
},
{
key: 'status',
header: 'Status',
width: 120,
render: (m) => <StatusTag status={m.status ?? 'unknown'} />,
},
]
return (
<>
<PageHeader
title="Service Mesh"
subtitle="Inter-service routing, mTLS, and traffic."
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={(m) => m.id}
empty="No mesh services yet."
/>
)}
</>
)
}
+221
View File
@@ -0,0 +1,221 @@
'use client'
/**
* Sessions — list + detail for grouped traces (HIP-0106), native on @hanzo/gui.
*
* List at `/sessions`, one session at `/sessions/<id>` (selected by the `id`
* route param). Reads the REAL `/v1/o11y/sessions` surface; when the runtime is
* not initialized (503) or unrouted (404) it shows an honest RuntimeNotice —
* never fabricated sessions or traces.
*/
import { useCallback, useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { ArrowLeft, ChevronRight, RefreshCw } from '@hanzogui/lucide-icons-2'
import {
O11yApi,
type O11yPageMeta,
type Session,
type SessionDetail,
type Trace,
} 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 { DetailRow } from './observability/parts'
import { fmtCost, fmtDate, fmtLatency } from './observability/format'
const PAGE_LIMIT = 50
/** List + pager surface — the index view. */
function SessionListView({ onOpen }: { onOpen: (s: Session) => void }) {
const [rows, setRows] = useState<Session[]>([])
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.sessions({ 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<Session>[] = [
{
key: 'id',
header: 'Session',
render: (s) => (
<Button chromeless px="$0" onPress={() => onOpen(s)}>
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{s.id}
</Text>
</Button>
),
},
{ key: 'createdAt', header: 'Created', width: 220, render: (s) => <Text fontSize="$3" color="$color11">{fmtDate(s.createdAt)}</Text> },
{ key: 'environment', header: 'Environment', width: 160, render: (s) => <Text fontSize="$3" color="$color11">{s.environment || 'default'}</Text> },
{
key: 'action',
header: '',
width: 120,
render: (s) => (
<XStack justify="flex-end" flex={1}>
<Button size="$2" iconAfter={<ChevronRight size={14} />} onPress={() => onOpen(s)}>
Open
</Button>
</XStack>
),
},
]
return (
<>
<PageHeader
title="Sessions"
subtitle="Traces grouped into multi-turn sessions."
actions={
<Button icon={<RefreshCw size={16} />} onPress={() => void load(page)}>
Refresh
</Button>
}
/>
{error ? (
<RuntimeNotice surface="sessions" error={error} />
) : (
<>
<DataTable
columns={columns}
rows={rows}
loading={loading}
rowKey={(s) => s.id}
empty="No sessions yet. Sessions appear here once your traces carry a session id."
/>
<Pager meta={meta} onPage={setPage} />
</>
)}
</>
)
}
/** Detail surface — overview plus the session's traces. */
function SessionDetailView({ id, onBack, onOpenTrace }: { id: string; onBack: () => void; onOpenTrace: (t: Trace) => void }) {
const [session, setSession] = useState<SessionDetail | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<unknown>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const data = await O11yApi.session(id)
setSession(data)
setError(null)
} catch (e) {
setError(e)
} finally {
setLoading(false)
}
}, [id])
useEffect(() => {
void load()
}, [load])
const traceColumns: Column<Trace>[] = [
{
key: 'name',
header: 'Name',
render: (t) => (
<Button chromeless px="$0" onPress={() => onOpenTrace(t)}>
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{t.name || t.id}
</Text>
</Button>
),
},
{ key: 'timestamp', header: 'Timestamp', width: 190, render: (t) => <Text fontSize="$3" color="$color11">{fmtDate(t.timestamp)}</Text> },
{ key: 'latency', header: 'Latency', width: 100, render: (t) => <Text fontSize="$3" color="$color11">{fmtLatency(t.latency)}</Text> },
{ key: 'totalCost', header: 'Cost', width: 100, render: (t) => <Text fontSize="$3" color="$color11">{fmtCost(t.totalCost)}</Text> },
]
return (
<>
<PageHeader
title={id}
subtitle="Session detail"
actions={
<XStack gap="$2">
<Button icon={<ArrowLeft size={16} />} onPress={onBack}>
Back
</Button>
<Button icon={<RefreshCw size={16} />} onPress={() => void load()}>
Refresh
</Button>
</XStack>
}
/>
{error ? (
<RuntimeNotice surface="sessions" error={error} />
) : loading && !session ? (
<Text color="$color11">Loading</Text>
) : session ? (
<>
<Card p="$4" gap="$1" borderWidth={1} borderColor="$borderColor">
<Text fontSize="$5" fontWeight="700" mb="$2">
Overview
</Text>
<DetailRow label="ID" value={session.id} />
<DetailRow label="Created" value={fmtDate(session.createdAt)} />
<DetailRow label="Environment" value={session.environment || 'default'} />
<DetailRow label="Traces" value={String(session.traces?.length ?? 0)} />
</Card>
<YStack gap="$2">
<Text fontSize="$5" fontWeight="700">
Traces
</Text>
<DataTable
columns={traceColumns}
rows={session.traces ?? []}
rowKey={(t) => t.id}
empty="No traces in this session."
/>
</YStack>
</>
) : null}
</>
)
}
export function SessionsModule({ params }: { params: Record<string, string> }) {
const router = useRouter()
const id = params.id
if (id) {
return (
<SessionDetailView
id={decodeURIComponent(id)}
onBack={() => router.push('/sessions')}
onOpenTrace={(t) => router.push(`/o11y/${encodeURIComponent(t.id)}`)}
/>
)
}
return <SessionListView onOpen={(s) => router.push(`/sessions/${encodeURIComponent(s.id)}`)} />
}
+266
View File
@@ -0,0 +1,266 @@
'use client'
/**
* Settings — account, organization, API keys, and branding in one tabbed module.
*
* Consolidates hanzoai/console's org/project/account settings pages onto the
* surfaces console2 actually has: the General tab reads the REAL signed-in
* account (`get-account`) and organization (`/v1/iam/get-organization`); Members
* reads the REAL `/v1/iam/get-users`; API Keys embeds the shared `ApiKeysView`;
* Branding shows the REAL per-host runtime config the app resolved. Identity
* mutations (rename, invite, roles) are owned by IAM — this deep-links there
* rather than re-implementing them. Every read has honest loading/404/401/503
* states; nothing is fabricated.
*
* The tab is the route param (`/settings`, `/settings/api-keys`, …), mirroring
* the IamModule param branch.
*/
import { useCallback, useEffect, useState, type ReactNode } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { ExternalLink } from '@hanzogui/lucide-icons-2'
import { ApiError, IamAdminApi, type Organization, type IamUser } from '~/lib/api'
import { config } from '~/config'
import { useSession } from '~/lib/auth/session'
import { PageHeader } from '~/components/ui/PageHeader'
import { FieldRow } from '~/components/ui/Field'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { ErrorState, asApiError, type HonestCopy } from '~/components/ui/States'
import { ApiKeysView } from './ApiKeysModule'
const IAM_COPY: HonestCopy = {
notFound:
'IAM (/v1/iam) is not routed on this host yet. It appears automatically once the deployment proxies /v1/iam to Hanzo IAM.',
unauthorized:
'This requires an authorized session, enforced server-side by IAM. Sign in with an account that has access.',
}
const fmtDate = (v?: string): string => {
if (!v) return '—'
const d = new Date(v)
return Number.isNaN(d.getTime()) ? v : d.toLocaleString()
}
/** A labeled read-only value row; renders a dim em-dash when empty. */
function InfoRow({ label, value }: { label: string; value?: string | number | boolean | null }) {
const text =
value === undefined || value === null || value === '' ? '—' : String(value)
const empty = text === '—'
return (
<FieldRow label={label}>
<Text fontSize="$3" color={empty ? '$color10' : '$color12'} pt="$2">
{text}
</Text>
</FieldRow>
)
}
// ── Generic async load ────────────────────────────────────────────────────────
type Async<T> = { phase: 'loading' } | { phase: 'error'; err: ApiError } | { phase: 'ready'; data: T }
/** Run a memoized fetcher into an honest async state, with a reload. */
function useAsync<T>(fn: () => Promise<T>): { state: Async<T>; reload: () => void } {
const [state, setState] = useState<Async<T>>({ phase: 'loading' })
const reload = useCallback(() => {
setState({ phase: 'loading' })
fn()
.then((data) => setState({ phase: 'ready', data }))
.catch((e) => setState({ phase: 'error', err: asApiError(e) }))
}, [fn])
useEffect(() => {
reload()
}, [reload])
return { state, reload }
}
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<YStack gap="$2">
<Text fontSize="$5" fontWeight="700">
{title}
</Text>
{children}
</YStack>
)
}
function ManageInIam() {
return (
<Button
size="$2"
icon={<ExternalLink size={15} />}
onPress={() => {
if (typeof window !== 'undefined') window.open(config.iamUrl, '_blank', 'noopener')
}}
>
IAM console
</Button>
)
}
// ── Tabs ──────────────────────────────────────────────────────────────────────
function GeneralTab() {
const { account } = useSession()
const orgName = config.iamOrgName
const fetchOrg = useCallback(() => IamAdminApi.organization(orgName), [orgName])
const { state, reload } = useAsync<Organization>(fetchOrg)
return (
<YStack gap="$5">
<Section title="Account">
<Card p="$4" gap="$3.5" borderWidth={1} borderColor="$borderColor" maxWidth={720}>
<InfoRow label="Email" value={account?.email} />
<InfoRow label="Display name" value={account?.displayName} />
<InfoRow label="Username" value={account?.name} />
<InfoRow label="Organization" value={account?.organization} />
<InfoRow label="Admin" value={account?.isAdmin ? 'yes' : 'no'} />
</Card>
</Section>
<Section title="Organization">
{state.phase === 'error' ? (
<ErrorState err={state.err} onRetry={reload} copy={IAM_COPY} />
) : state.phase === 'loading' ? (
<XStack p="$6" justify="center">
<Spinner size="large" color="$color11" />
</XStack>
) : (
<Card p="$4" gap="$3.5" borderWidth={1} borderColor="$borderColor" maxWidth={720}>
<InfoRow label="Name" value={state.data.name} />
<InfoRow label="Display name" value={state.data.displayName} />
<InfoRow label="Website" value={state.data.websiteUrl} />
<InfoRow label="Created" value={fmtDate(state.data.createdTime)} />
</Card>
)}
</Section>
<Text fontSize="$3" color="$color10">
Rename the organization, invite members, and manage roles in the IAM
console identity is owned by Hanzo IAM.
</Text>
</YStack>
)
}
const memberColumns: Column<IamUser>[] = [
{
key: 'name',
header: 'Name',
render: (u) => (
<Text fontSize="$3" fontWeight="600" numberOfLines={1}>
{u.displayName || u.name}
</Text>
),
},
{ key: 'email', header: 'Email', render: (u) => <Text fontSize="$3" color="$color11" numberOfLines={1}>{u.email || '—'}</Text> },
{
key: 'role',
header: 'Role',
width: 120,
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>
),
},
]
function MembersTab() {
const orgName = config.iamOrgName
const fetchUsers = useCallback(() => IamAdminApi.users(orgName), [orgName])
const { state, reload } = useAsync(fetchUsers)
if (state.phase === 'error') {
return <ErrorState err={state.err} onRetry={reload} copy={IAM_COPY} />
}
return (
<DataTable
columns={memberColumns}
rows={state.phase === 'ready' ? state.data.rows : []}
loading={state.phase === 'loading'}
rowKey={(u) => `${u.owner}/${u.name}`}
empty="No members in this organization."
/>
)
}
function BrandingTab() {
return (
<YStack gap="$3">
<Text fontSize="$3" color="$color10">
The runtime branding resolved for this host. One console image serves
every brand; these values come from the request hostname.
</Text>
<Card p="$4" gap="$3.5" borderWidth={1} borderColor="$borderColor" maxWidth={720}>
<InfoRow label="Brand" value={config.brand} />
<InfoRow label="Name" value={config.brandName} />
<InfoRow label="IAM issuer" value={config.iamUrl} />
<InfoRow label="IAM organization" value={config.iamOrgName} />
<InfoRow label="IAM application" value={config.iamAppName} />
<InfoRow label="Cloud API" value={config.cloudUrl} />
<InfoRow label="Platform" value={config.platformUrl} />
<InfoRow label="Billing" value={config.billingUrl} />
</Card>
</YStack>
)
}
// ── Module ──────────────────────────────────────────────────────────────────────
const TABS = [
{ id: '', label: 'General' },
{ id: 'api-keys', label: 'API Keys' },
{ id: 'members', label: 'Members' },
{ id: 'branding', label: 'Branding' },
] as const
export function SettingsModule({ params }: { params: Record<string, string> }) {
const router = useRouter()
const tab = params.tab ?? ''
const body =
tab === 'api-keys' ? (
<ApiKeysView />
) : tab === 'members' ? (
<MembersTab />
) : tab === 'branding' ? (
<BrandingTab />
) : (
<GeneralTab />
)
return (
<>
<PageHeader
title="Settings"
subtitle="Account, organization, API keys, and branding."
actions={<ManageInIam />}
/>
<XStack gap="$1" flexWrap="wrap">
{TABS.map((t) => (
<Button
key={t.id || 'general'}
size="$2"
bg={t.id === tab ? '$color5' : 'transparent'}
borderWidth={1}
borderColor="$borderColor"
onPress={() => router.push(t.id ? `/settings/${t.id}` : '/settings')}
>
{t.label}
</Button>
))}
</XStack>
{body}
</>
)
}
@@ -0,0 +1,140 @@
'use client'
/**
* Settlement — batch settlement of on-chain transfers and payouts (amount,
* counterparty, status) tracked by the platform.
*
* Reads the settlement ledger from the PaaS via the same-origin `/paas` proxy
* (`GET /v1/settlement`), which injects the service token server-side. When the
* settlement 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 Settlement = {
id: string
type?: string
amount?: string
counterparty?: string
status?: string
createdAt?: string
}
export function SettlementModule(_props: { params: Record<string, string> }) {
const [rows, setRows] = useState<Settlement[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<PlatformError | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await restGet<{ settlements?: Settlement[] }>(paas('settlement'))
setRows(r.settlements ?? [])
setLoadError(null)
} catch (e) {
setLoadError(interpretPlatformError(e))
setRows([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
const columns: Column<Settlement>[] = [
{
key: 'id',
header: 'ID',
render: (s) => (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{s.id}
</Text>
),
},
{
key: 'type',
header: 'Type',
width: 140,
render: (s) => (
<Text fontSize="$3" color="$color11">
{s.type || '—'}
</Text>
),
},
{
key: 'amount',
header: 'Amount',
width: 140,
render: (s) => (
<Text fontSize="$3" color="$color11">
{s.amount || '—'}
</Text>
),
},
{
key: 'counterparty',
header: 'Counterparty',
width: 200,
render: (s) => (
<Text fontSize="$3" color="$color11" numberOfLines={1}>
{s.counterparty || '—'}
</Text>
),
},
{
key: 'status',
header: 'Status',
width: 120,
render: (s) => <StatusTag status={s.status ?? 'unknown'} />,
},
{
key: 'createdAt',
header: 'Created',
width: 190,
render: (s) => (
<Text fontSize="$3" color="$color11">
{s.createdAt ? new Date(s.createdAt).toLocaleString() : '—'}
</Text>
),
},
]
return (
<>
<PageHeader
title="Settlement"
subtitle="Batch settlement of on-chain transfers and payouts."
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={(s) => s.id}
empty="No settlement runs yet."
/>
)}
</>
)
}
+156
View File
@@ -0,0 +1,156 @@
'use client'
/**
* Status — the health of every Hanzo service, from REAL data only.
*
* Source: the Hanzo PaaS control plane (platform.hanzo.ai) through the
* same-origin `/paas` proxy, `GET /v1/apps` — the apps inventory the control
* plane already computes (declared/running tag + drift + health per service, per
* cluster). A row IS a running operator-managed service; its `health`
* (green/yellow/red) is the real verdict. Nothing is fabricated — there are no
* fake green dots, only what the cluster actually reports. Every other condition
* is honest: loading, not-configured (proxy 501 / token rejected), upstream
* error, and empty are all real.
*
* Composition over a new client: it reuses the SAME `PlatformApi.apps` the
* Kubernetes (workloads) module reads, and the SAME honest-state card — one way
* to read the platform.
*/
import { useCallback, useEffect, useState } from 'react'
import { Button, Card, Spinner, Text, XStack } from '@hanzo/gui'
import { RefreshCw } from '@hanzogui/lucide-icons-2'
import { PlatformApi, type PlatformApp } from '~/lib/api'
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 State =
| { phase: 'loading' }
| { phase: 'error'; error: PlatformError }
| { phase: 'ready'; rows: PlatformApp[] }
const isUp = (a: PlatformApp): boolean => a.health === 'green'
export function StatusModule(_props: { params: Record<string, string> }) {
const [state, setState] = useState<State>({ phase: 'loading' })
const load = useCallback(() => {
setState({ phase: 'loading' })
PlatformApi.apps()
.then((rows) => setState({ phase: 'ready', rows }))
.catch((e) => setState({ phase: 'error', error: interpretPlatformError(e) }))
}, [])
useEffect(() => {
load()
}, [load])
const columns: Column<PlatformApp>[] = [
{
key: 'app',
header: 'Service',
render: (r) => (
<Text fontSize="$3" fontWeight="600" numberOfLines={1}>
{r.app}
</Text>
),
},
{
key: 'cluster',
header: 'Cluster',
width: 150,
render: (r) => (
<Text fontSize="$3" color="$color11" numberOfLines={1}>
{r.cluster}
</Text>
),
},
{
key: 'namespace',
header: 'Namespace',
width: 140,
render: (r) => (
<Text fontSize="$3" color="$color11" numberOfLines={1}>
{r.namespace ?? '—'}
</Text>
),
},
{
key: 'env',
header: 'Env',
width: 80,
render: (r) => (
<Text fontSize="$3" color="$color11">
{r.env}
</Text>
),
},
{
key: 'runningTag',
header: 'Running',
width: 150,
render: (r) => (
<Text fontSize="$2" color="$color11" numberOfLines={1}>
{r.runningTag ?? '—'}
</Text>
),
},
{ key: 'health', header: 'Health', width: 110, render: (r) => <StatusTag status={r.health} /> },
]
return (
<>
<PageHeader
title="Status"
subtitle="Live health of every Hanzo service across your clusters."
actions={
<Button icon={<RefreshCw size={16} />} onPress={load}>
Refresh
</Button>
}
/>
{state.phase === 'loading' ? (
<XStack p="$4" gap="$2" items="center">
<Spinner />
<Text color="$color11">Probing services</Text>
</XStack>
) : state.phase === 'error' ? (
<PlatformStateCard error={state.error} onRetry={load} />
) : (
<>
{state.rows.length > 0 ? (
<XStack gap="$3" flexWrap="wrap">
<Stat label="Services" value={state.rows.length} />
<Stat label="Healthy" value={state.rows.filter(isUp).length} />
<Stat label="Clusters" value={new Set(state.rows.map((r) => r.cluster)).size} />
</XStack>
) : null}
<DataTable
columns={columns}
rows={state.rows}
rowKey={(r) => r.id}
empty="No services observed yet — the control plane has not reported any apps."
/>
</>
)}
</>
)
}
/** A small headline number (services / healthy / clusters). */
function Stat({ label, value }: { label: string; value: number }) {
return (
<Card p="$3" gap="$1" borderWidth={1} borderColor="$borderColor" minW={120}>
<Text fontSize="$2" color="$color10">
{label}
</Text>
<Text fontSize="$7" fontWeight="800">
{value}
</Text>
</Card>
)
}
+493
View File
@@ -0,0 +1,493 @@
'use client'
/**
* Tasks — the user's durable workflows and schedules, unified into the console.
*
* Index (`/tasks`): pick a namespace, then browse its workflow executions (one
* durable "task" each) or its schedules (recurring tasks), with a live cluster
* strip. Detail (`/tasks/<ns>/<workflowId>`): one workflow's overview + durable
* history. Reads the REAL `/v1/tasks` engine (hanzoai/tasks) through TasksApi;
* every call is org-scoped server-side, so the browser sends cookie credentials
* only. Honest states render when a route is gated/absent — never fabricated.
*/
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { ArrowLeft, RefreshCw, ChevronRight } from '@hanzogui/lucide-icons-2'
import {
TasksApi,
type ClusterStatus,
type Namespace,
type Schedule,
type WorkflowExecution,
type HistoryEvent,
type WorkflowDetail,
} from '~/lib/api'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { FieldSelect } from '~/components/ui/Field'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
const fmtDate = (v?: string): string => {
if (!v) return ''
const d = new Date(v)
return Number.isNaN(d.getTime()) ? v : d.toLocaleString()
}
// ── workflow status → label + tone ─────────────────────────────────────────
type Tone = 'green' | 'yellow' | 'red' | 'neutral'
const TONE_BG = { green: '$color5', yellow: '$color4', red: '$color4', neutral: '$color3' } as const
const TONE_FG = { green: '$color12', yellow: '$color12', red: '$color12', neutral: '$color11' } as const
/** Map a `WORKFLOW_EXECUTION_STATUS_*` string to a readable label + tone. */
function wfStatus(raw: string): { label: string; tone: Tone } {
const s = (raw || '').replace(/^WORKFLOW_EXECUTION_STATUS_/, '').toUpperCase()
switch (s) {
case 'RUNNING':
return { label: 'Running', tone: 'green' }
case 'COMPLETED':
return { label: 'Completed', tone: 'green' }
case 'FAILED':
return { label: 'Failed', tone: 'red' }
case 'TIMED_OUT':
return { label: 'Timed out', tone: 'red' }
case 'TERMINATED':
return { label: 'Terminated', tone: 'red' }
case 'CANCELED':
return { label: 'Canceled', tone: 'neutral' }
case 'CONTINUED_AS_NEW':
return { label: 'Continued', tone: 'neutral' }
default:
return { label: s ? s.replace(/_/g, ' ').toLowerCase() : 'unknown', tone: 'neutral' }
}
}
function StatusBadge({ status }: { status: string }) {
const { label, tone } = wfStatus(status)
return (
<Text fontSize="$1" px="$2" py="$1" rounded="$2" bg={TONE_BG[tone]} color={TONE_FG[tone]}>
{label}
</Text>
)
}
function StateBadge({ on, onLabel, offLabel }: { on: boolean; onLabel: string; offLabel: string }) {
return (
<Text
fontSize="$1"
px="$2"
py="$1"
rounded="$2"
bg={on ? '$color4' : '$color5'}
color="$color12"
>
{on ? onLabel : offLabel}
</Text>
)
}
const nsName = (n: Namespace): string => n.namespaceInfo?.name ?? ''
/** The thin live cluster strip (best-effort; hidden when unavailable). */
function ClusterStrip({ cluster, health }: { cluster: ClusterStatus | null; health: string | null }) {
if (!cluster && !health) return null
const parts: string[] = []
if (cluster?.replicator) parts.push(`engine ${cluster.replicator}`)
if (typeof cluster?.openShards === 'number' && typeof cluster?.shardCount === 'number') {
parts.push(`shards ${cluster.openShards}/${cluster.shardCount}`)
}
if (health) parts.push(health === 'ok' ? 'healthy' : health)
if (parts.length === 0) return null
return (
<Text fontSize="$2" color="$color10">
{parts.join(' · ')}
</Text>
)
}
// ── index: namespace + tabs ─────────────────────────────────────────────────
type Tab = 'workflows' | 'schedules'
function TasksIndex({ onOpen }: { onOpen: (ns: string, wf: WorkflowExecution) => void }) {
const [namespaces, setNamespaces] = useState<Namespace[]>([])
const [ns, setNs] = useState<string>('')
const [tab, setTab] = useState<Tab>('workflows')
const [workflows, setWorkflows] = useState<WorkflowExecution[]>([])
const [schedules, setSchedules] = useState<Schedule[]>([])
const [loading, setLoading] = useState(true)
const [state, setState] = useState<BackendState | null>(null)
const [cluster, setCluster] = useState<ClusterStatus | null>(null)
const [health, setHealth] = useState<string | null>(null)
// Namespaces first — the org tenant set. Default to "default", else the first.
const loadNamespaces = useCallback(async () => {
setLoading(true)
try {
const rows = await TasksApi.namespaces()
setNamespaces(rows)
const names = rows.map(nsName).filter(Boolean)
setNs((cur) => cur || (names.includes('default') ? 'default' : names[0] ?? ''))
setState(null)
} catch (e) {
setState(classifyBackend(e))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void loadNamespaces()
// Cluster strip is best-effort; failures never block the page.
TasksApi.cluster().then(setCluster).catch(() => setCluster(null))
TasksApi.health()
.then((h) => setHealth(h.status))
.catch(() => setHealth(null))
}, [loadNamespaces])
const loadTab = useCallback(async () => {
if (!ns) return
setLoading(true)
try {
if (tab === 'workflows') setWorkflows(await TasksApi.workflows(ns))
else setSchedules(await TasksApi.schedules(ns))
setState(null)
} catch (e) {
setState(classifyBackend(e))
} finally {
setLoading(false)
}
}, [ns, tab])
useEffect(() => {
void loadTab()
}, [loadTab])
const nsOptions = useMemo(() => namespaces.map(nsName).filter(Boolean), [namespaces])
const workflowColumns: Column<WorkflowExecution>[] = [
{
key: 'workflowId',
header: 'Workflow',
render: (w) => (
<Text fontSize="$3" color="$color12" numberOfLines={1} onPress={() => onOpen(ns, w)} cursor="pointer">
{w.execution?.workflowId}
</Text>
),
},
{
key: 'type',
header: 'Type',
width: 200,
render: (w) => (
<Text fontSize="$3" color="$color11" numberOfLines={1}>
{w.type?.name}
</Text>
),
},
{ key: 'status', header: 'Status', width: 130, render: (w) => <StatusBadge status={w.status} /> },
{
key: 'taskQueue',
header: 'Task queue',
width: 160,
render: (w) => (
<Text fontSize="$3" color="$color11" numberOfLines={1}>
{w.taskQueue || '—'}
</Text>
),
},
{
key: 'startTime',
header: 'Started',
width: 190,
render: (w) => (
<Text fontSize="$3" color="$color11">
{fmtDate(w.startTime)}
</Text>
),
},
{
key: 'action',
header: '',
width: 110,
render: (w) => (
<XStack justify="flex-end" flex={1}>
<Button size="$2" iconAfter={<ChevronRight size={14} />} onPress={() => onOpen(ns, w)}>
Open
</Button>
</XStack>
),
},
]
const scheduleColumns: Column<Schedule>[] = [
{ key: 'scheduleId', header: 'Schedule', render: (s) => (
<Text fontSize="$3" color="$color12" numberOfLines={1}>{s.scheduleId}</Text>
) },
{
key: 'workflowType',
header: 'Workflow',
width: 200,
render: (s) => (
<Text fontSize="$3" color="$color11" numberOfLines={1}>
{s.action?.workflowType?.name || '—'}
</Text>
),
},
{
key: 'paused',
header: 'State',
width: 110,
render: (s) => <StateBadge on={!!s.state?.paused} onLabel="Paused" offLabel="Active" />,
},
{
key: 'next',
header: 'Next run',
width: 190,
render: (s) => (
<Text fontSize="$3" color="$color11">
{fmtDate(s.info?.nextActionTime) || '—'}
</Text>
),
},
{
key: 'count',
header: 'Runs',
width: 80,
render: (s) => (
<Text fontSize="$3" color="$color11">
{s.info?.actionCount ?? 0}
</Text>
),
},
]
const TabButton = ({ id, label }: { id: Tab; label: string }) => (
<Button size="$2" theme={tab === id ? 'light' : undefined} onPress={() => setTab(id)}>
{label}
</Button>
)
return (
<>
<PageHeader
title="Tasks"
subtitle="Your durable workflows and schedules — every running and finished task, in one place."
actions={
<Button icon={<RefreshCw size={16} />} onPress={() => void loadTab()}>
Refresh
</Button>
}
/>
<ClusterStrip cluster={cluster} health={health} />
{state ? (
<BackendStateCard
state={state}
onRetry={() => {
void loadNamespaces()
void loadTab()
}}
hint="The Tasks engine (/v1/tasks) is reached through the gateway with your session. Workflows and schedules appear here once the route is live for your org."
/>
) : nsOptions.length === 0 && !loading ? (
<Card p="$4" borderWidth={1} borderColor="$borderColor">
<Text color="$color11">
No task namespaces yet for your organization. Workflows appear here once a worker registers
one.
</Text>
</Card>
) : (
<>
<XStack gap="$3" items="center" flexWrap="wrap">
<XStack width={260} items="center" gap="$2">
<Text fontSize="$2" color="$color11">
Namespace
</Text>
<YStack flex={1}>
<FieldSelect value={ns} options={nsOptions} onChange={setNs} />
</YStack>
</XStack>
<XStack gap="$2">
<TabButton id="workflows" label="Workflows" />
<TabButton id="schedules" label="Schedules" />
</XStack>
</XStack>
{tab === 'workflows' ? (
<DataTable
columns={workflowColumns}
rows={workflows}
loading={loading}
rowKey={(w) => `${w.execution?.workflowId}/${w.execution?.runId}`}
empty="No workflows in this namespace yet."
/>
) : (
<DataTable
columns={scheduleColumns}
rows={schedules}
loading={loading}
rowKey={(s) => s.scheduleId}
empty="No schedules in this namespace yet."
/>
)}
</>
)}
</>
)
}
// ── detail: one workflow ────────────────────────────────────────────────────
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<XStack justify="space-between" items="center" py="$2" borderBottomWidth={1} borderColor="$borderColor">
<Text fontSize="$3" color="$color11" fontWeight="600">
{label}
</Text>
<Text fontSize="$3" color="$color12" numberOfLines={1}>
{value}
</Text>
</XStack>
)
}
function WorkflowDetailView({ ns, wid, onBack }: { ns: string; wid: string; onBack: () => void }) {
const [detail, setDetail] = useState<WorkflowDetail | null>(null)
const [events, setEvents] = useState<HistoryEvent[]>([])
const [loading, setLoading] = useState(true)
const [state, setState] = useState<BackendState | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const d = await TasksApi.workflow(ns, wid)
setDetail(d)
const runId = d.workflowExecutionInfo?.execution?.runId
setEvents(await TasksApi.history(ns, wid, runId))
setState(null)
} catch (e) {
setState(classifyBackend(e))
} finally {
setLoading(false)
}
}, [ns, wid])
useEffect(() => {
void load()
}, [load])
const info = detail?.workflowExecutionInfo
const eventColumns: Column<HistoryEvent>[] = [
{
key: 'eventId',
header: 'ID',
width: 70,
render: (e) => (
<Text fontSize="$3" color="$color11">
{e.eventId ?? ''}
</Text>
),
},
{
key: 'eventType',
header: 'Type',
render: (e) => (
<Text fontSize="$3" color="$color12" numberOfLines={1}>
{e.eventType ?? ''}
</Text>
),
},
{
key: 'eventTime',
header: 'Time',
width: 210,
render: (e) => (
<Text fontSize="$3" color="$color11">
{fmtDate(e.eventTime)}
</Text>
),
},
]
return (
<>
<PageHeader
title={wid}
subtitle={info?.type?.name ? `${info.type.name} · ${ns}` : ns}
actions={
<XStack gap="$2">
<Button icon={<ArrowLeft size={16} />} onPress={onBack}>
Back
</Button>
<Button icon={<RefreshCw size={16} />} onPress={() => void load()}>
Refresh
</Button>
</XStack>
}
/>
{state ? (
<BackendStateCard state={state} onRetry={() => void load()} />
) : loading && !detail ? (
<Text color="$color11">Loading</Text>
) : info ? (
<>
<Card p="$4" gap="$1" borderWidth={1} borderColor="$borderColor">
<Text fontSize="$5" fontWeight="700" mb="$2">
Overview
</Text>
<DetailRow label="Status" value={<StatusBadge status={info.status} />} />
<DetailRow label="Type" value={info.type?.name || '—'} />
<DetailRow label="Task queue" value={info.taskQueue || detail?.executionConfig?.taskQueue?.name || '—'} />
<DetailRow label="Run ID" value={info.execution?.runId || '—'} />
{info.startTime ? <DetailRow label="Started" value={fmtDate(info.startTime)} /> : null}
{info.closeTime ? <DetailRow label="Closed" value={fmtDate(info.closeTime)} /> : null}
{typeof info.historyLength === 'number' ? (
<DetailRow label="History length" value={info.historyLength} />
) : null}
</Card>
<YStack gap="$2">
<Text fontSize="$5" fontWeight="700">
History
</Text>
<DataTable
columns={eventColumns}
rows={events}
loading={loading}
rowKey={(e) => String(e.eventId ?? '')}
empty="No history events."
/>
</YStack>
</>
) : null}
</>
)
}
export function TasksModule({ params }: { params: Record<string, string> }) {
const router = useRouter()
const { ns, wid } = params
if (ns && wid) {
return (
<WorkflowDetailView
ns={decodeURIComponent(ns)}
wid={decodeURIComponent(wid)}
onBack={() => router.push('/tasks')}
/>
)
}
return (
<TasksIndex
onOpen={(namespace, wf) =>
router.push(`/tasks/${encodeURIComponent(namespace)}/${encodeURIComponent(wf.execution.workflowId)}`)
}
/>
)
}
+145
View File
@@ -0,0 +1,145 @@
'use client'
/**
* Tokens — issued tokens with supply, holder counts, and contract addresses
* across chains, tracked by the platform.
*
* Reads the token registry from the PaaS via the same-origin `/paas` proxy
* (`GET /v1/tokens`), which injects the service token server-side. When the
* token 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 Token = {
id: string
symbol?: string
name?: string
supply?: string
holders?: string
contract?: string
chain?: string
}
export function TokensModule(_props: { params: Record<string, string> }) {
const [rows, setRows] = useState<Token[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<PlatformError | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await restGet<{ tokens?: Token[] }>(paas('tokens'))
setRows(r.tokens ?? [])
setLoadError(null)
} catch (e) {
setLoadError(interpretPlatformError(e))
setRows([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
const columns: Column<Token>[] = [
{
key: 'symbol',
header: 'Symbol',
render: (t) => (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{t.symbol || t.id}
</Text>
),
},
{
key: 'name',
header: 'Name',
width: 180,
render: (t) => (
<Text fontSize="$3" color="$color11">
{t.name || '—'}
</Text>
),
},
{
key: 'supply',
header: 'Supply',
width: 140,
render: (t) => (
<Text fontSize="$3" color="$color11">
{t.supply || '—'}
</Text>
),
},
{
key: 'holders',
header: 'Holders',
width: 110,
render: (t) => (
<Text fontSize="$3" color="$color11">
{t.holders || '—'}
</Text>
),
},
{
key: 'chain',
header: 'Chain',
width: 120,
render: (t) => (
<Text fontSize="$3" color="$color11">
{t.chain || '—'}
</Text>
),
},
{
key: 'contract',
header: 'Contract',
width: 220,
render: (t) => (
<Text fontSize="$3" color="$color11" numberOfLines={1}>
{t.contract || '—'}
</Text>
),
},
]
return (
<>
<PageHeader
title="Tokens"
subtitle="Issued tokens — supply, holders, and contract addresses."
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={(t) => t.id}
empty="No tokens issued yet."
/>
)}
</>
)
}
+263
View File
@@ -0,0 +1,263 @@
'use client'
/**
* Traces — list + detail for LLM/agent traces (HIP-0106), native on @hanzo/gui.
*
* List at `/o11y`, one trace at `/o11y/<id>` (selected by the `id` route param,
* mirroring ProvidersModule/ResourceModule). Reads the REAL `/v1/o11y/traces`
* surface; when the runtime is not initialized (503) or unrouted (404) it shows
* an honest RuntimeNotice — never fabricated spans, costs, or charts.
*/
import { useCallback, useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { ArrowLeft, ChevronRight, RefreshCw } from '@hanzogui/lucide-icons-2'
import {
ApiError,
O11yApi,
type Observation,
type O11yPageMeta,
type Score,
type Trace,
type TraceDetail,
} 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 { Badge, DetailRow, JsonCard, Tags } from './observability/parts'
import { SpanTree } from './observability/SpanTree'
import { elapsed, fmtCost, fmtDate, fmtLatency, scoreValue } from './observability/format'
const PAGE_LIMIT = 50
const traceLabel = (t: Pick<Trace, 'id' | 'name'>): string => t.name || t.id
/** List + pager surface — the index view. */
function TraceListView({ onOpen }: { onOpen: (t: Trace) => void }) {
const [rows, setRows] = useState<Trace[]>([])
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.traces({ 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<Trace>[] = [
{
key: 'name',
header: 'Name',
render: (t) => (
<Button chromeless px="$0" onPress={() => onOpen(t)}>
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{traceLabel(t)}
</Text>
</Button>
),
},
{ key: 'timestamp', header: 'Timestamp', width: 190, render: (t) => <Text fontSize="$3" color="$color11">{fmtDate(t.timestamp)}</Text> },
{ key: 'userId', header: 'User', width: 140, render: (t) => <Text fontSize="$3" color="$color11" numberOfLines={1}>{t.userId || '—'}</Text> },
{ key: 'latency', header: 'Latency', width: 100, render: (t) => <Text fontSize="$3" color="$color11">{fmtLatency(t.latency)}</Text> },
{ key: 'totalCost', header: 'Cost', width: 100, render: (t) => <Text fontSize="$3" color="$color11">{fmtCost(t.totalCost)}</Text> },
{
key: 'action',
header: '',
width: 120,
render: (t) => (
<XStack justify="flex-end" flex={1}>
<Button size="$2" iconAfter={<ChevronRight size={14} />} onPress={() => onOpen(t)}>
Open
</Button>
</XStack>
),
},
]
return (
<>
<PageHeader
title="Traces"
subtitle="End-to-end LLM and agent traces."
actions={
<Button icon={<RefreshCw size={16} />} onPress={() => void load(page)}>
Refresh
</Button>
}
/>
{error ? (
<RuntimeNotice surface="traces" error={error} />
) : (
<>
<DataTable
columns={columns}
rows={rows}
loading={loading}
rowKey={(t) => t.id}
empty="No traces yet. Traces appear here once your workloads send them to the observability runtime."
/>
<Pager meta={meta} onPage={setPage} />
</>
)}
</>
)
}
/** Detail surface — overview, input/output, observations, and scores. */
function TraceDetailView({ id, onBack }: { id: string; onBack: () => void }) {
const [trace, setTrace] = useState<TraceDetail | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<unknown>(null)
const [obsView, setObsView] = useState<'tree' | 'table'>('tree')
const load = useCallback(async () => {
setLoading(true)
try {
const data = await O11yApi.trace(id)
setTrace(data)
setError(null)
} catch (e) {
setError(e)
} finally {
setLoading(false)
}
}, [id])
useEffect(() => {
void load()
}, [load])
const obsColumns: Column<Observation>[] = [
{ key: 'name', header: 'Name', render: (o) => <Text fontSize="$3" color="$color12" numberOfLines={1}>{o.name || o.id}</Text> },
{ key: 'type', header: 'Type', width: 130, render: (o) => <Badge label={o.type} /> },
{ key: 'model', header: 'Model', width: 180, render: (o) => <Text fontSize="$3" color="$color11" numberOfLines={1}>{o.model || '—'}</Text> },
{ key: 'latency', header: 'Latency', width: 100, render: (o) => <Text fontSize="$3" color="$color11">{fmtLatency(elapsed(o.startTime, o.endTime))}</Text> },
{ key: 'level', header: 'Level', width: 100, render: (o) => <Text fontSize="$3" color="$color11">{o.level}</Text> },
]
const scoreColumns: Column<Score>[] = [
{ key: 'name', header: 'Name', render: (s) => <Text fontSize="$3" color="$color12" numberOfLines={1}>{s.name}</Text> },
{ key: 'value', header: 'Value', width: 160, render: (s) => <Text fontSize="$3" color="$color11" numberOfLines={1}>{scoreValue(s)}</Text> },
{ key: 'dataType', header: 'Type', width: 130, render: (s) => <Badge label={s.dataType} /> },
{ key: 'source', header: 'Source', width: 130, render: (s) => <Badge label={s.source} /> },
]
return (
<>
<PageHeader
title={trace ? traceLabel(trace) : id}
subtitle="Trace detail"
actions={
<XStack gap="$2">
<Button icon={<ArrowLeft size={16} />} onPress={onBack}>
Back
</Button>
<Button icon={<RefreshCw size={16} />} onPress={() => void load()}>
Refresh
</Button>
</XStack>
}
/>
{error ? (
<RuntimeNotice surface="traces" error={error} />
) : loading && !trace ? (
<Text color="$color11">Loading</Text>
) : trace ? (
<>
<Card p="$4" gap="$1" borderWidth={1} borderColor="$borderColor">
<Text fontSize="$5" fontWeight="700" mb="$2">
Overview
</Text>
<DetailRow label="ID" value={trace.id} />
<DetailRow label="Timestamp" value={fmtDate(trace.timestamp)} />
<DetailRow label="User" value={trace.userId || '—'} />
<DetailRow label="Session" value={trace.sessionId || '—'} />
<DetailRow label="Environment" value={trace.environment || 'default'} />
<DetailRow label="Latency" value={fmtLatency(trace.latency)} />
<DetailRow label="Cost" value={fmtCost(trace.totalCost)} />
{trace.release ? <DetailRow label="Release" value={trace.release} /> : null}
{trace.version ? <DetailRow label="Version" value={trace.version} /> : null}
<DetailRow label="Tags" value={<Tags tags={trace.tags} />} />
</Card>
<JsonCard title="Input" value={trace.input} />
<JsonCard title="Output" value={trace.output} />
<JsonCard title="Metadata" value={trace.metadata} />
<YStack gap="$2">
<XStack items="center" justify="space-between" gap="$3">
<Text fontSize="$5" fontWeight="700">
Observations
</Text>
<XStack gap="$1">
{(['tree', 'table'] as const).map((v) => (
<Button
key={v}
size="$2"
bg={v === obsView ? '$color5' : 'transparent'}
borderWidth={1}
borderColor="$borderColor"
onPress={() => setObsView(v)}
>
{v === 'tree' ? 'Tree' : 'Table'}
</Button>
))}
</XStack>
</XStack>
{obsView === 'tree' ? (
<SpanTree observations={trace.observations ?? []} />
) : (
<DataTable
columns={obsColumns}
rows={trace.observations ?? []}
rowKey={(o) => o.id}
empty="No observations on this trace."
/>
)}
</YStack>
<YStack gap="$2">
<Text fontSize="$5" fontWeight="700">
Scores
</Text>
<DataTable
columns={scoreColumns}
rows={trace.scores ?? []}
rowKey={(s) => s.id}
empty="No scores on this trace."
/>
</YStack>
</>
) : null}
</>
)
}
export function TracesModule({ params }: { params: Record<string, string> }) {
const router = useRouter()
const id = params.id
if (id) {
return <TraceDetailView id={decodeURIComponent(id)} onBack={() => router.push('/o11y')} />
}
return <TraceListView onOpen={(t) => router.push(`/o11y/${encodeURIComponent(t.id)}`)} />
}
+130
View File
@@ -0,0 +1,130 @@
'use client'
/**
* Users — the per-user analytics rollup the o11y backend aggregates from traces
* carrying a `userId` (trace count, cost, tokens, last seen). Paged from
* `GET /v1/o11y/users`. Honest runtime-not-initialized state when o11y isn't
* provisioned for the deployment.
*/
import { useCallback, useEffect, useState } from 'react'
import { Button, Text } from '@hanzo/gui'
import { RefreshCw } from '@hanzogui/lucide-icons-2'
import { O11yApi, type O11yUser, 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
const usd = (n?: number) => (typeof n === 'number' ? `$${n.toFixed(4)}` : '—')
export function UsersModule(_props: { params: Record<string, string> }) {
const [rows, setRows] = useState<O11yUser[]>([])
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.users({ 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<O11yUser>[] = [
{
key: 'userId',
header: 'User',
render: (u) => (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{u.userId}
</Text>
),
},
{
key: 'traceCount',
header: 'Traces',
width: 110,
render: (u) => (
<Text fontSize="$3" color="$color11">
{u.traceCount ?? '—'}
</Text>
),
},
{
key: 'totalTokens',
header: 'Tokens',
width: 120,
render: (u) => (
<Text fontSize="$3" color="$color11">
{u.totalTokens ?? '—'}
</Text>
),
},
{
key: 'totalCost',
header: 'Cost',
width: 120,
render: (u) => (
<Text fontSize="$3" color="$color11">
{usd(u.totalCost)}
</Text>
),
},
{
key: 'lastSeen',
header: 'Last seen',
width: 190,
render: (u) => (
<Text fontSize="$3" color="$color11">
{u.lastSeen ? fmtDate(u.lastSeen) : '—'}
</Text>
),
},
]
return (
<>
<PageHeader
title="Users"
subtitle="Per-user analytics — trace volume, token usage, and cost by userId."
actions={
<Button icon={<RefreshCw size={16} />} onPress={() => void load(page)}>
Refresh
</Button>
}
/>
{error ? (
<RuntimeNotice surface="users" error={error} />
) : (
<>
<DataTable
columns={columns}
rows={rows}
loading={loading}
rowKey={(u) => u.userId}
empty="No users yet. Tag traces with a userId to see per-user analytics here."
/>
<Pager meta={meta} onPage={setPage} />
</>
)}
</>
)
}
+130
View File
@@ -0,0 +1,130 @@
'use client'
/**
* VPC — virtual private clouds providing network isolation (an org's private
* address space, carved into subnets across regions).
*
* Reads the VPC inventory from the PaaS via the same-origin `/paas` proxy
* (`GET /v1/vpcs`), which injects the service token server-side. When the VPC
* 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 Vpc = {
id: string
name?: string
cidr?: string
region?: string
subnets?: string[]
status?: string
}
export function VpcModule(_props: { params: Record<string, string> }) {
const [rows, setRows] = useState<Vpc[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<PlatformError | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await restGet<{ vpcs?: Vpc[] }>(paas('vpcs'))
setRows(r.vpcs ?? [])
setLoadError(null)
} catch (e) {
setLoadError(interpretPlatformError(e))
setRows([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
const columns: Column<Vpc>[] = [
{
key: 'name',
header: 'Name',
render: (v) => (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{v.name || v.id}
</Text>
),
},
{
key: 'cidr',
header: 'CIDR',
width: 150,
render: (v) => (
<Text fontSize="$3" color="$color11">
{v.cidr || '—'}
</Text>
),
},
{
key: 'region',
header: 'Region',
width: 130,
render: (v) => (
<Text fontSize="$3" color="$color11">
{v.region || '—'}
</Text>
),
},
{
key: 'subnets',
header: 'Subnets',
width: 100,
render: (v) => (
<Text fontSize="$3" color="$color11">
{v.subnets?.length ?? 0}
</Text>
),
},
{
key: 'status',
header: 'Status',
width: 120,
render: (v) => <StatusTag status={v.status ?? 'unknown'} />,
},
]
return (
<>
<PageHeader
title="VPC"
subtitle="Virtual private clouds — network isolation."
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={(v) => v.id}
empty="No VPCs yet."
/>
)}
</>
)
}
+11 -5
View File
@@ -73,11 +73,12 @@ export function WalletModule(_props: { params: Record<string, string> }) {
const value = await WalletApi.cloudBalance(uid)
setCloud({ state: 'ok', value })
} catch (e) {
if (e instanceof ApiError && (e.status === 401 || e.status === 403)) {
setCloud({ state: 'noauth' })
} else {
setCloud({ state: 'error', error: e instanceof Error ? e.message : 'Failed to load balance' })
}
const code = e instanceof ApiError ? e.status : 0
// 404 = the credit-balance endpoint is not routed on this host yet (billing
// ships separately) — honest "not available", not a scary error.
if (code === 401 || code === 403) setCloud({ state: 'noauth' })
else if (code === 404) setCloud({ state: 'unconfigured' })
else setCloud({ state: 'error', error: e instanceof Error ? e.message : 'Failed to load balance' })
}
}, [])
@@ -200,6 +201,11 @@ export function WalletModule(_props: { params: Record<string, string> }) {
<Text fontSize="$3" color="$color11">
Sign in to view your cloud credit balance.
</Text>
) : cloud.state === 'unconfigured' ? (
<Text fontSize="$3" color="$color11">
Cloud credit balance isn&apos;t available on this deployment yet. Manage billing in
Hanzo Billing.
</Text>
) : cloud.state === 'error' ? (
<Text fontSize="$3" color="$color11">
Balance unavailable: {cloud.error}
@@ -0,0 +1,194 @@
'use client'
/**
* Interactive chat — a real round-trip conversation with the model gateway.
*
* This is the working Chat surface (the prior module was a read-only history
* viewer). It composes the ONE AI binding (`AiApi.chat` → the keyless `/ai`
* proxy → /v1/chat/completions) with a turn-by-turn thread: append the user
* turn, send the full history, append the assistant reply. Nothing is faked —
* every reply is a real completion, and a failure renders an honest state
* (including the 402 "add credits" billing gate), never fabricated text.
*/
import { useEffect, useState } from 'react'
import { Button, Card, ScrollView, Spinner, Text, TextArea, XStack, YStack } from '@hanzo/gui'
import { Send, Sparkles, Plus, History } from '@hanzogui/lucide-icons-2'
import { AiApi, PlaygroundApi, type ChatMessage } from '~/lib/api'
import { PageHeader } from '~/components/ui/PageHeader'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
function Bubble({ role, content }: ChatMessage) {
const isUser = role === 'user'
return (
<XStack justify={isUser ? 'flex-end' : 'flex-start'}>
<Card
p="$3"
maxW="82%"
bg={isUser ? '$color5' : '$color2'}
borderWidth={1}
borderColor="$borderColor"
rounded="$4"
>
<YStack gap="$1">
<Text fontSize="$1" color="$color10" fontWeight="700">
{isUser ? 'You' : 'Assistant'}
</Text>
{content.split('\n').map((line, i) => (
<Text key={i} fontSize="$3" color="$color12">
{line === '' ? ' ' : line}
</Text>
))}
</YStack>
</Card>
</XStack>
)
}
export function ChatConversation({ onShowHistory }: { onShowHistory: () => void }) {
const [model, setModel] = useState('')
const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState('')
const [sending, setSending] = useState(false)
const [error, setError] = useState<BackendState | null>(null)
// Default to a Zen model once the catalog loads (Hanzo-first), else the first.
useEffect(() => {
let live = true
PlaygroundApi.listModels()
.then((ids) => {
if (live && ids.length) setModel((m) => m || (ids.find((x) => /zen/i.test(x)) ?? ids[0]))
})
.catch(() => {
/* model stays auto-resolved server-side */
})
return () => {
live = false
}
}, [])
useEffect(() => {
if (typeof document !== 'undefined') {
document.getElementById('chat-bottom')?.scrollIntoView({ behavior: 'smooth', block: 'end' })
}
}, [messages, sending])
const send = async () => {
const q = input.trim()
if (!q || sending) return
const history = messages
setMessages((m) => [...m, { role: 'user', content: q }])
setInput('')
setSending(true)
setError(null)
try {
const reply = await AiApi.chat({
question: q,
history,
model: model || undefined,
temperature: 0.7,
})
setMessages((m) => [...m, { role: 'assistant', content: reply || '(empty response)' }])
} catch (e) {
setError(classifyBackend(e))
} finally {
setSending(false)
}
}
return (
<YStack flex={1} gap="$3" minH={480}>
<PageHeader
title="Chat"
subtitle={`Talk to ${model || 'Zen'} and other models — real completions through the gateway.`}
actions={
<XStack gap="$2">
<Button size="$2" icon={<History size={15} />} onPress={onShowHistory}>
History
</Button>
<Button
size="$2"
icon={<Plus size={15} />}
disabled={messages.length === 0 && !error}
onPress={() => {
setMessages([])
setError(null)
}}
>
New chat
</Button>
</XStack>
}
/>
<Card flex={1} borderWidth={1} borderColor="$borderColor" p="$3" gap="$3" minH={320}>
<ScrollView flex={1}>
{messages.length === 0 && !error ? (
<YStack flex={1} items="center" justify="center" p="$6" gap="$2">
<Sparkles size={26} opacity={0.6} />
<Text fontSize="$5" fontWeight="700" color="$color12">
Start a conversation
</Text>
<Text fontSize="$3" color="$color10" maxW={420} style={{ textAlign: 'center' }}>
Ask anything. Responses come from the live model gateway, billed to your organization.
</Text>
</YStack>
) : (
<YStack gap="$3">
{messages.map((m, i) => (
<Bubble key={i} role={m.role} content={m.content} />
))}
{sending ? (
<XStack gap="$2" items="center" p="$2">
<Spinner color="$color11" />
<Text color="$color11" fontSize="$3">
Thinking
</Text>
</XStack>
) : null}
{error ? <BackendStateCard state={error} onRetry={() => void send()} /> : null}
{/* scroll sentinel (id, not ref — avoids tamagui element typing) */}
<YStack id="chat-bottom" height={1} />
</YStack>
)}
</ScrollView>
</Card>
<Card borderWidth={1} borderColor="$borderColor" p="$2.5">
<XStack gap="$2" items="flex-end">
<YStack flex={1}>
<TextArea
value={input}
onChangeText={setInput}
placeholder="Message the assistant… (Enter to send, Shift+Enter for a new line)"
numberOfLines={2}
disabled={sending}
onKeyPress={(e) => {
const ev = e as unknown as {
key?: string
shiftKey?: boolean
preventDefault?: () => void
nativeEvent?: { key?: string; shiftKey?: boolean }
}
const key = ev.key ?? ev.nativeEvent?.key
const shift = ev.shiftKey ?? ev.nativeEvent?.shiftKey
if (key === 'Enter' && !shift) {
ev.preventDefault?.()
void send()
}
}}
/>
</YStack>
<Button
bg="$color5"
icon={<Send size={16} />}
disabled={sending || !input.trim()}
onPress={() => void send()}
>
Send
</Button>
</XStack>
</Card>
</YStack>
)
}
+13 -3
View File
@@ -2,14 +2,14 @@
import { useCallback, useEffect, useState } from 'react'
import { Button, Text, XStack } from '@hanzo/gui'
import { Trash } from '@hanzogui/lucide-icons-2'
import { Trash, ArrowLeft } from '@hanzogui/lucide-icons-2'
import { ApiError, ChatApi, type Chat } from '~/lib/api'
import { useSession } from '~/lib/auth/session'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
export function ChatListView({ onOpen }: { onOpen: (c: Chat) => void }) {
export function ChatListView({ onOpen, onBack }: { onOpen: (c: Chat) => void; onBack?: () => void }) {
const { account } = useSession()
const user = account?.name ?? 'admin'
@@ -75,7 +75,17 @@ export function ChatListView({ onOpen }: { onOpen: (c: Chat) => void }) {
return (
<>
<PageHeader title="Chat" subtitle="Chat sessions and history." />
<PageHeader
title="Chat history"
subtitle="Your saved chat sessions."
actions={
onBack ? (
<Button size="$2" icon={<ArrowLeft size={15} />} onPress={onBack}>
Back to chat
</Button>
) : undefined
}
/>
{error ? <Text color="$color12">{error}</Text> : null}
<DataTable
columns={columns}
@@ -0,0 +1,152 @@
'use client'
/**
* Run an eval — POST /v1/evals/runs. This is REAL orchestration: the cloud
* evals facade runs each dataset item through the model gateway, records a trace
* + dataset-run-item in the console, then scores it with an LLM-as-judge. The
* response is a true per-item summary (avg score, scored count, per-item output
* and errors), rendered as-is. Nothing is fabricated — a run that scores nothing
* is shown as a real failure, not a fake success.
*/
import { useState } from 'react'
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { Play } from '@hanzogui/lucide-icons-2'
import { EvalsApi, type EvalRunSummary, type EvalItemResult } from '~/lib/api'
import { FieldRow, FieldText, FieldTextArea } from '~/components/ui/Field'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { ModelPicker } from '../ModelPicker'
const itemColumns: Column<EvalItemResult>[] = [
{ key: 'itemId', header: 'Item', width: 150, render: (r) => (
<Text fontSize="$3" numberOfLines={1}>{r.itemId}</Text>
) },
{ key: 'score', header: 'Score', width: 90, render: (r) => (
<Text fontSize="$3" color={r.error ? '$color10' : '$color12'}>
{r.error ? '—' : r.score.toFixed(2)}
</Text>
) },
{ key: 'output', header: 'Output / Error', render: (r) => (
<Text fontSize="$3" color={r.error ? '$color12' : '$color11'} numberOfLines={2}>
{r.error ? r.error : r.output || '—'}
</Text>
) },
]
export function EvalRunView() {
const [dataset, setDataset] = useState('')
const [model, setModel] = useState('')
const [runName, setRunName] = useState('')
const [limit, setLimit] = useState('20')
const [judgeModel, setJudgeModel] = useState('')
const [criteria, setCriteria] = useState('')
const [running, setRunning] = useState(false)
const [summary, setSummary] = useState<EvalRunSummary | null>(null)
const [error, setError] = useState<BackendState | null>(null)
const run = async () => {
if (!dataset.trim() || !model.trim()) {
setError({ kind: 'error', message: 'Dataset and model are required.' })
return
}
setRunning(true)
setError(null)
setSummary(null)
try {
const s = await EvalsApi.run({
dataset: dataset.trim(),
model: model.trim(),
runName: runName.trim() || undefined,
limit: Number(limit) || undefined,
judge:
judgeModel.trim() || criteria.trim()
? { model: judgeModel.trim() || undefined, criteria: criteria.trim() || undefined }
: undefined,
})
setSummary(s)
} catch (e) {
setError(classifyBackend(e))
} finally {
setRunning(false)
}
}
return (
<XStack gap="$4" flexWrap="wrap" items="flex-start">
{/* ── Left: run config ────────────────────────────────────────────── */}
<YStack gap="$3" flex={1} minW={360}>
<Card p="$4" gap="$3.5" borderWidth={1} borderColor="$borderColor">
<FieldRow label="Dataset">
<FieldText value={dataset} onChange={setDataset} placeholder="dataset name" />
</FieldRow>
<FieldRow label="Model under test">
<ModelPicker value={model} onChange={setModel} autoselect />
</FieldRow>
<FieldRow label="Run name">
<FieldText value={runName} onChange={setRunName} placeholder="auto if blank" />
</FieldRow>
<FieldRow label="Item limit">
<FieldText value={limit} onChange={setLimit} placeholder="20" />
</FieldRow>
<FieldRow label="Judge model">
<ModelPicker value={judgeModel} onChange={setJudgeModel} placeholder="defaults to model under test" />
</FieldRow>
<FieldRow label="Judge criteria">
<FieldTextArea value={criteria} onChange={setCriteria} rows={3} />
</FieldRow>
<XStack>
<Button self="flex-start" icon={<Play size={16} />} disabled={running} onPress={() => void run()}>
{running ? 'Running…' : 'Run eval'}
</Button>
</XStack>
</Card>
</YStack>
{/* ── Right: results ──────────────────────────────────────────────── */}
<YStack gap="$3" flex={1} minW={360}>
{running ? (
<Card p="$4" borderWidth={1} borderColor="$borderColor">
<XStack gap="$2" items="center">
<Spinner color="$color11" />
<Text color="$color11">Running each item through the model and judge</Text>
</XStack>
</Card>
) : error ? (
<BackendStateCard state={error} onRetry={() => void run()} hint="endpoint · POST /v1/evals/runs" />
) : summary ? (
<>
<Card p="$4" gap="$2" borderWidth={1} borderColor="$borderColor">
<Text fontSize="$5" fontWeight="800">
{summary.avgScore.toFixed(3)}
<Text fontSize="$3" color="$color11"> avg score</Text>
</Text>
<XStack gap="$3" flexWrap="wrap">
<Text fontSize="$2" color="$color10">run · {summary.runName}</Text>
<Text fontSize="$2" color="$color10">model · {summary.model}</Text>
<Text fontSize="$2" color="$color10">judge · {summary.judgeModel}</Text>
<Text fontSize="$2" color="$color10">
scored {summary.scored}/{summary.items}
</Text>
</XStack>
</Card>
<DataTable
columns={itemColumns}
rows={summary.results ?? []}
rowKey={(r) => r.itemId}
empty="No item results returned."
/>
</>
) : (
<Card p="$4" borderWidth={1} borderColor="$borderColor">
<Text fontSize="$3" color="$color10">
Configure a run and press Run eval. Results real per-item scores from the model and
judge appear here. Nothing is shown until a run returns.
</Text>
</Card>
)}
</YStack>
</XStack>
)
}
@@ -0,0 +1,103 @@
'use client'
/**
* Eval scores — the REAL score list from GET /v1/evals/scores (the cloud evals
* facade proxies the console's public scores API verbatim). Numeric scores show
* their value; categorical scores show their string value. Every state is honest:
* loading, the backend-state card on failure, and a true empty state — no
* fabricated scores.
*/
import { useCallback, useEffect, useState } from 'react'
import { Button, Text, XStack } from '@hanzo/gui'
import { RefreshCw } from '@hanzogui/lucide-icons-2'
import { EvalsApi, type EvalScore } from '~/lib/api'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
type State =
| { phase: 'loading' }
| { phase: 'error'; error: BackendState }
| { phase: 'ready'; rows: EvalScore[]; total: number }
const fmtTime = (s?: string) => {
if (!s) return '—'
const d = new Date(s)
return Number.isNaN(d.getTime()) ? s : d.toLocaleString()
}
const scoreValue = (r: EvalScore): string => {
if (typeof r.value === 'number') return r.value.toFixed(4).replace(/\.?0+$/, '')
if (r.stringValue) return r.stringValue
return '—'
}
const muted = (v?: string) => (
<Text fontSize="$3" color={v ? '$color11' : '$color10'} numberOfLines={1}>
{v || '—'}
</Text>
)
const columns: Column<EvalScore>[] = [
{ key: 'name', header: 'Name', render: (r) => (
<Text fontSize="$3" fontWeight="600" numberOfLines={1}>{r.name || '—'}</Text>
) },
{ key: 'value', header: 'Value', width: 120, render: (r) => (
<Text fontSize="$3" color="$color12">{scoreValue(r)}</Text>
) },
{ key: 'dataType', header: 'Type', width: 110, render: (r) => muted(r.dataType) },
{ key: 'source', header: 'Source', width: 110, render: (r) => muted(r.source) },
{ key: 'traceId', header: 'Trace', width: 150, render: (r) => muted(r.traceId) },
{ key: 'timestamp', header: 'Time', width: 190, render: (r) => muted(fmtTime(r.timestamp ?? r.createdAt)) },
]
export function EvalScoresView() {
const [state, setState] = useState<State>({ phase: 'loading' })
const load = useCallback(() => {
setState({ phase: 'loading' })
EvalsApi.listScores({ limit: 50 })
.then((page) =>
setState({
phase: 'ready',
rows: page.data ?? [],
total: page.meta?.totalItems ?? page.data?.length ?? 0,
}),
)
.catch((e) => setState({ phase: 'error', error: classifyBackend(e) }))
}, [])
useEffect(() => {
load()
}, [load])
if (state.phase === 'error') {
return (
<BackendStateCard
state={state.error}
onRetry={load}
hint="endpoint · GET /v1/evals/scores"
/>
)
}
return (
<>
<XStack justify="space-between" items="center">
<Text fontSize="$3" color="$color11">
{state.phase === 'ready' ? `${state.total} score${state.total === 1 ? '' : 's'}` : 'Loading…'}
</Text>
<Button size="$2" icon={<RefreshCw size={14} />} onPress={load}>
Refresh
</Button>
</XStack>
<DataTable
columns={columns}
rows={state.phase === 'ready' ? state.rows : []}
loading={state.phase === 'loading'}
rowKey={(r) => r.id}
empty="No scores yet. Run an eval to produce scores."
/>
</>
)
}
+157
View File
@@ -0,0 +1,157 @@
'use client'
/**
* IAM application create + edit. `name === 'new'` is the create form; any other
* name loads the application (`get-application`) and edits its display name +
* description. Read-only fields (clientId) are shown for reference. Delete is
* available in edit mode. Mirrors the ProviderEditView/ApplicationEditView shape.
*/
import { useEffect, useState } from 'react'
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { Trash } from '@hanzogui/lucide-icons-2'
import { ApiError, IamAdminApi, type IamApplication } from '~/lib/api'
import { FieldRow, FieldText } from '~/components/ui/Field'
import { PageHeader } from '~/components/ui/PageHeader'
import { useToast } from '~/components/ui/Toast'
import { newApplication } from './logic'
export function AppEditView({ org, name, onDone }: { org: string; name: string; onDone: () => void }) {
const toast = useToast()
const creating = name === 'new'
const [app, setApp] = useState<IamApplication | null>(creating ? newApplication(org) : null)
const [loading, setLoading] = useState(!creating)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (creating) return
let live = true
setLoading(true)
IamAdminApi.application(`admin/${name}`)
.then((a) => {
if (live) {
setApp(a)
setError(null)
}
})
.catch((e: unknown) => {
if (live) setError(e instanceof ApiError ? e.message : 'Failed to load application')
})
.finally(() => {
if (live) setLoading(false)
})
return () => {
live = false
}
}, [name, creating])
if (loading) {
return (
<XStack p="$6" justify="center">
<Spinner size="large" color="$color11" />
</XStack>
)
}
if (error && !app) {
return (
<YStack gap="$3">
<Text color="$color12">{error}</Text>
<Button self="flex-start" onPress={onDone}>
Back
</Button>
</YStack>
)
}
if (!app) return null
const a = app
const set = (patch: Partial<IamApplication>) => setApp({ ...a, ...patch })
const save = async () => {
setSaving(true)
setError(null)
try {
if (creating) {
if (!a.name.trim()) {
setError('An application name is required.')
setSaving(false)
return
}
await IamAdminApi.addApplication(a)
toast.success(`Created ${a.name}`)
} else {
await IamAdminApi.updateApplication(`admin/${name}`, a)
toast.success(`Saved ${a.name}`)
}
onDone()
} catch (e) {
const msg = e instanceof ApiError ? e.message : 'Failed to save application'
setError(msg)
toast.error(creating ? 'Could not create application' : 'Could not save application', msg)
} finally {
setSaving(false)
}
}
const remove = async () => {
if (typeof window !== 'undefined' && !window.confirm(`Delete application "${a.name}"?`)) return
setSaving(true)
setError(null)
try {
await IamAdminApi.deleteApplication(a)
toast.success(`Deleted ${a.name}`)
onDone()
} catch (e) {
const msg = e instanceof ApiError ? e.message : 'Failed to delete application'
setError(msg)
toast.error('Could not delete application', msg)
} finally {
setSaving(false)
}
}
return (
<>
<PageHeader
title={creating ? 'New application' : 'Edit application'}
subtitle={creating ? org : a.name}
actions={
<XStack gap="$2">
<Button onPress={onDone}>Back</Button>
<Button theme="light" disabled={saving} onPress={() => void save()}>
{creating ? 'Create' : 'Save'}
</Button>
{!creating ? (
<Button theme="red" icon={<Trash size={15} />} disabled={saving} onPress={() => void remove()}>
Delete
</Button>
) : null}
</XStack>
}
/>
{error ? <Text color="$color12">{error}</Text> : null}
<Card p="$4" gap="$3.5" borderWidth={1} borderColor="$borderColor" maxWidth={680}>
<FieldRow label="Name">
<FieldText value={a.name} onChange={(v) => set({ name: v })} disabled={!creating} placeholder="my-app" />
</FieldRow>
<FieldRow label="Display name">
<FieldText value={a.displayName ?? ''} onChange={(v) => set({ displayName: v })} />
</FieldRow>
<FieldRow label="Organization">
<FieldText value={a.organization ?? org} onChange={(v) => set({ organization: v })} />
</FieldRow>
<FieldRow label="Description">
<FieldText value={a.description ?? ''} onChange={(v) => set({ description: v })} />
</FieldRow>
{!creating && a.clientId ? (
<FieldRow label="Client ID">
<FieldText value={a.clientId} onChange={() => undefined} disabled />
</FieldRow>
) : null}
</Card>
</>
)
}
+93
View File
@@ -0,0 +1,93 @@
'use client'
/**
* IAM organization detail = its MEMBERSHIPS — the org's users with role/isAdmin
* and 2FA, read via `get-users?owner=<org>` through the gated proxy (a non-global
* admin may only open their own org). Organization create/delete is intentionally
* NOT here: the admin proxy allow-lists user/application/provider mutations only,
* so org lifecycle stays in the full IAM console (the header deep-link) where it
* is a global-admin operation.
*/
import { useCallback, useEffect, useState } from 'react'
import { Button, Card, Text, XStack } from '@hanzo/gui'
import { RefreshCw } from '@hanzogui/lucide-icons-2'
import { ApiError, IamAdminApi, type IamUser } from '~/lib/api'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { PageHeader } from '~/components/ui/PageHeader'
import { mfaLabel, roleLabel } from './logic'
const RoleTag = ({ u }: { u: IamUser }) => (
<Text
fontSize="$2"
px="$2"
py="$1"
rounded="$2"
bg={u.isAdmin ? '$color5' : '$color3'}
color={u.isAdmin ? '$color12' : '$color11'}
>
{roleLabel(u)}
</Text>
)
const memberColumns: Column<IamUser>[] = [
{ key: 'name', header: 'Name', render: (u) => <Text fontSize="$3" fontWeight="600">{u.name}</Text> },
{ key: 'email', header: 'Email', render: (u) => <Text fontSize="$3" color="$color11" numberOfLines={1}>{u.email || '—'}</Text> },
{ key: 'role', header: 'Role', width: 110, render: (u) => <RoleTag u={u} /> },
{ key: 'mfa', header: '2FA', width: 90, render: (u) => <Text fontSize="$3" color="$color11">{mfaLabel(u)}</Text> },
]
type LoadState = { phase: 'loading' } | { phase: 'error'; err: ApiError } | { phase: 'ready'; rows: IamUser[] }
export function OrgView({ name, onDone }: { name: string; onDone: () => void }) {
const [state, setState] = useState<LoadState>({ phase: 'loading' })
const load = useCallback(() => {
setState({ phase: 'loading' })
IamAdminApi.users(name)
.then((p) => setState({ phase: 'ready', rows: p.rows ?? [] }))
.catch((e) => setState({ phase: 'error', err: e instanceof ApiError ? e : new ApiError(String(e)) }))
}, [name])
useEffect(() => {
load()
}, [load])
return (
<>
<PageHeader
title={name}
subtitle="Organization memberships"
actions={
<XStack gap="$2">
<Button icon={<RefreshCw size={15} />} onPress={load}>
Refresh
</Button>
<Button onPress={onDone}>Back</Button>
</XStack>
}
/>
{state.phase === 'error' ? (
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$2" maxWidth={620}>
<Text fontSize="$4" fontWeight="700">
Could not load memberships
</Text>
<Text fontSize="$3" color="$color11">
{state.err.message}
</Text>
<Button size="$2" self="flex-start" onPress={load}>
Retry
</Button>
</Card>
) : (
<DataTable
columns={memberColumns}
rows={state.phase === 'ready' ? state.rows : []}
loading={state.phase === 'loading'}
rowKey={(u) => `${u.owner}/${u.name}`}
empty="No members in this organization."
/>
)}
</>
)
}
@@ -0,0 +1,171 @@
'use client'
/**
* IAM user create + edit. `name === 'new'` is the create form (name / display
* name / email / password); any other name loads the user (`get-user`, incl. the
* authoritative MFA fields) and edits role (isAdmin), enabled state (isForbidden),
* and display name. Mirrors the ProviderEditView/ApplicationEditView shape; every
* mutation reports through the shared toast, every result honest.
*/
import { useEffect, useState } from 'react'
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { Trash } from '@hanzogui/lucide-icons-2'
import { ApiError, IamAdminApi, type IamUser } from '~/lib/api'
import { FieldRow, FieldText, FieldSwitch } from '~/components/ui/Field'
import { PageHeader } from '~/components/ui/PageHeader'
import { useToast } from '~/components/ui/Toast'
import { newUser, mfaLabel } from './logic'
export function UserEditView({ org, name, onDone }: { org: string; name: string; onDone: () => void }) {
const toast = useToast()
const creating = name === 'new'
const [user, setUser] = useState<IamUser | null>(creating ? newUser(org) : null)
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(!creating)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (creating) return
let live = true
setLoading(true)
IamAdminApi.getUser(`${org}/${name}`)
.then((u) => {
if (live) {
setUser(u)
setError(null)
}
})
.catch((e: unknown) => {
if (live) setError(e instanceof ApiError ? e.message : 'Failed to load user')
})
.finally(() => {
if (live) setLoading(false)
})
return () => {
live = false
}
}, [org, name, creating])
if (loading) {
return (
<XStack p="$6" justify="center">
<Spinner size="large" color="$color11" />
</XStack>
)
}
if (error && !user) {
return (
<YStack gap="$3">
<Text color="$color12">{error}</Text>
<Button self="flex-start" onPress={onDone}>
Back
</Button>
</YStack>
)
}
if (!user) return null
const u = user
const set = (patch: Partial<IamUser>) => setUser({ ...u, ...patch })
const save = async () => {
setSaving(true)
setError(null)
try {
if (creating) {
if (!u.name.trim()) {
setError('A username is required.')
setSaving(false)
return
}
await IamAdminApi.addUser({ ...u, ...(password ? { password } : {}) })
toast.success(`Created ${u.name}`)
} else {
await IamAdminApi.updateUser(`${org}/${name}`, u)
toast.success(`Saved ${u.name}`)
}
onDone()
} catch (e) {
const msg = e instanceof ApiError ? e.message : 'Failed to save user'
setError(msg)
toast.error(creating ? 'Could not create user' : 'Could not save user', msg)
} finally {
setSaving(false)
}
}
const remove = async () => {
if (typeof window !== 'undefined' && !window.confirm(`Delete user "${u.name}"?`)) return
setSaving(true)
setError(null)
try {
await IamAdminApi.deleteUser(u)
toast.success(`Deleted ${u.name}`)
onDone()
} catch (e) {
const msg = e instanceof ApiError ? e.message : 'Failed to delete user'
setError(msg)
toast.error('Could not delete user', msg)
} finally {
setSaving(false)
}
}
return (
<>
<PageHeader
title={creating ? 'New user' : 'Edit user'}
subtitle={creating ? org : `${org}/${name}`}
actions={
<XStack gap="$2">
<Button onPress={onDone}>Back</Button>
<Button theme="light" disabled={saving} onPress={() => void save()}>
{creating ? 'Create' : 'Save'}
</Button>
{!creating ? (
<Button theme="red" icon={<Trash size={15} />} disabled={saving} onPress={() => void remove()}>
Delete
</Button>
) : null}
</XStack>
}
/>
{error ? <Text color="$color12">{error}</Text> : null}
<Card p="$4" gap="$3.5" borderWidth={1} borderColor="$borderColor" maxWidth={680}>
<FieldRow label="Username">
<FieldText value={u.name} onChange={(v) => set({ name: v })} disabled={!creating} placeholder="jane" />
</FieldRow>
<FieldRow label="Display name">
<FieldText value={u.displayName ?? ''} onChange={(v) => set({ displayName: v })} />
</FieldRow>
<FieldRow label="Email">
<FieldText value={u.email ?? ''} onChange={(v) => set({ email: v })} placeholder="jane@example.com" />
</FieldRow>
{creating ? (
<FieldRow label="Password">
<FieldText value={password} onChange={setPassword} secure placeholder="Set an initial password" />
</FieldRow>
) : null}
<FieldRow label="Admin role">
<FieldSwitch checked={!!u.isAdmin} onChange={(v) => set({ isAdmin: v })} />
</FieldRow>
{!creating ? (
<>
<FieldRow label="Enabled">
<FieldSwitch checked={!u.isForbidden} onChange={(v) => set({ isForbidden: !v })} />
</FieldRow>
<FieldRow label="2FA">
<Text fontSize="$3" color="$color11" pt="$2">
{mfaLabel(u)}
</Text>
</FieldRow>
</>
) : null}
</Card>
</>
)
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Pure IAM admin helpers — new-record templates + display mappings. No I/O, no
* React, so the views stay declarative and the labels stay in one place.
*/
import type { IamUser, IamApplication } from '~/lib/api'
/** A blank org-scoped user (password is collected separately in the create form). */
export function newUser(owner: string): IamUser {
return { owner, name: '', displayName: '', email: '', type: 'normal-user', isAdmin: false }
}
/** A blank application — casdoor apps are owned by the built-in `admin`, scoped to the org. */
export function newApplication(owner: string): IamApplication {
return { owner: 'admin', name: '', displayName: '', organization: owner, description: '' }
}
/** Human-readable 2FA channel — "TOTP" / "SMS" / "Email" / "none". */
export function mfaLabel(u: Pick<IamUser, 'preferredMfaType' | 'mfaPhoneEnabled' | 'mfaEmailEnabled'>): string {
const t = (u.preferredMfaType || '').toLowerCase()
if (t === 'app' || t === 'totp') return 'TOTP'
if (t === 'sms') return 'SMS'
if (t === 'email') return 'Email'
if (u.mfaPhoneEnabled) return 'SMS'
if (u.mfaEmailEnabled) return 'Email'
return 'none'
}
/** Role label for the Users/Memberships table. */
export const roleLabel = (u: Pick<IamUser, 'isAdmin' | 'type'>): string =>
u.isAdmin ? 'admin' : u.type || 'member'
@@ -0,0 +1,41 @@
'use client'
/**
* Pagination footer for an observability list — reads the `{ page, limit,
* totalItems, totalPages }` meta the list endpoints return. Renders nothing when
* there is no data; shows the row range always and prev/next when paginated.
*/
import { Button, Text, XStack } from '@hanzo/gui'
import { ChevronLeft, ChevronRight } from '@hanzogui/lucide-icons-2'
import type { O11yPageMeta } from '~/lib/api'
export function Pager({ meta, onPage }: { meta: O11yPageMeta | null; onPage: (page: number) => void }) {
if (!meta || meta.totalItems === 0) return null
const { page, limit, totalItems, totalPages } = meta
const from = (page - 1) * limit + 1
const to = Math.min(page * limit, totalItems)
return (
<XStack items="center" justify="space-between" gap="$3">
<Text fontSize="$2" color="$color11">
{from}{to} of {totalItems}
</Text>
<XStack gap="$2" items="center">
<Button size="$2" icon={<ChevronLeft size={14} />} disabled={page <= 1} onPress={() => onPage(page - 1)}>
Prev
</Button>
<Text fontSize="$2" color="$color11">
Page {page} / {Math.max(totalPages, 1)}
</Text>
<Button
size="$2"
iconAfter={<ChevronRight size={14} />}
disabled={page >= totalPages}
onPress={() => onPage(page + 1)}
>
Next
</Button>
</XStack>
</XStack>
)
}
@@ -0,0 +1,61 @@
'use client'
/**
* The ONE honest state for an observability surface whose fetch failed.
*
* The `/v1/o11y` runtime returns 503 until its telemetry stores + query service
* are initialized (and 404 where the surface is unrouted, 401/403 on access). We
* never fabricate traces, sessions, scores, or charts — this card explains the
* real reason and names the endpoint, so an empty observability area always reads
* truthfully.
*/
import { Card, Text, XStack } from '@hanzo/gui'
import { TriangleAlert } from '@hanzogui/lucide-icons-2'
import { ApiError } from '~/lib/api'
import { config } from '~/config'
export type RuntimeStatus = 'not-initialized' | 'unavailable' | 'access' | 'error'
/** Map a failed o11y fetch to an honest runtime status. */
export function classifyRuntime(e: unknown): RuntimeStatus {
const s = e instanceof ApiError ? e.status : 0
if (s === 503) return 'not-initialized'
if (s === 404) return 'unavailable'
if (s === 401 || s === 403) return 'access'
return 'error'
}
const TITLE: Record<RuntimeStatus, string> = {
'not-initialized': 'Observability runtime initializing',
unavailable: 'Not routed on this host',
access: 'Access required',
error: 'Could not reach observability',
}
export function RuntimeNotice({ surface, error }: { surface: string; error: unknown }) {
const status = classifyRuntime(error)
const message = error instanceof Error ? error.message : String(error)
const body: Record<RuntimeStatus, string> = {
'not-initialized': `The /v1/o11y routes are mounted, but the observability runtime (telemetry stores, query service) is not initialized on this deployment yet, so ${surface} cannot be read. This page shows live ${surface} the moment the runtime is online — it never shows placeholder data.`,
unavailable: `The /v1/o11y/${surface} surface is not proxied on this host yet.`,
access: `Sign in with an account that can read observability ${surface}.`,
error: message,
}
return (
<Card p="$4" gap="$2" borderWidth={1} borderColor="$borderColor" maxWidth={680}>
<XStack gap="$2" items="center">
<TriangleAlert size={16} />
<Text fontSize="$4" fontWeight="700">
{TITLE[status]}
</Text>
</XStack>
<Text fontSize="$3" color="$color11">
{body[status]}
</Text>
<Text fontSize="$2" color="$color10">
endpoint · /v1/o11y/{surface} · {config.brandName}
</Text>
</Card>
)
}
@@ -0,0 +1,193 @@
'use client'
/**
* Span tree — the nested waterfall view of a trace's observations.
*
* The flat observations table loses the call structure; this renders the REAL
* parent/child hierarchy carried on each observation (`parentObservationId`) as
* an indented, collapsible tree, with a proportional timeline bar per span so the
* latency waterfall reads at a glance. Built only on GUI Stacks (no graph/chart
* dependency) — positions are computed from the observations' own start/end
* times, so nothing is fabricated. Orphans and any parent cycles are surfaced as
* top-level rows rather than dropped, so every observation appears exactly once.
*/
import { useMemo, useState } from 'react'
import { Button, Text, XStack, YStack } from '@hanzo/gui'
import { ChevronDown, ChevronRight } from '@hanzogui/lucide-icons-2'
import type { Observation } from '~/lib/api'
import { Badge } from './parts'
import { elapsed, fmtLatency } from './format'
type SpanNode = { obs: Observation; depth: number; children: SpanNode[] }
const startMs = (o: Observation): number => new Date(o.startTime).getTime()
const endMs = (o: Observation): number => (o.endTime ? new Date(o.endTime).getTime() : startMs(o))
/**
* Build the observation forest from `parentObservationId`. A parent ref pointing
* outside this trace's set is treated as a root; orphans/cycles are appended as
* roots after the reachable tree so nothing is dropped or loops forever.
*/
function buildForest(observations: Observation[]): SpanNode[] {
const ids = new Set(observations.map((o) => o.id))
const childrenOf = new Map<string | null, Observation[]>()
for (const o of observations) {
const key = o.parentObservationId && ids.has(o.parentObservationId) ? o.parentObservationId : null
const arr = childrenOf.get(key)
if (arr) arr.push(o)
else childrenOf.set(key, [o])
}
const byStart = (a: Observation, b: Observation): number => startMs(a) - startMs(b)
const seen = new Set<string>()
const build = (o: Observation, depth: number): SpanNode => {
seen.add(o.id)
const kids = (childrenOf.get(o.id) ?? []).filter((c) => !seen.has(c.id)).sort(byStart)
return { obs: o, depth, children: kids.map((c) => build(c, depth + 1)) }
}
const roots = (childrenOf.get(null) ?? []).sort(byStart).map((o) => build(o, 0))
for (const o of observations.filter((x) => !seen.has(x.id)).sort(byStart)) roots.push(build(o, 0))
return roots
}
/** Inclusive [start, end] window across all observations (ms). */
function window(observations: Observation[]): { start: number; span: number } {
let start = Infinity
let end = -Infinity
for (const o of observations) {
const s = startMs(o)
const e = endMs(o)
if (Number.isFinite(s)) start = Math.min(start, s)
if (Number.isFinite(e)) end = Math.max(end, e)
}
if (!Number.isFinite(start)) return { start: 0, span: 0 }
return { start, span: Math.max(end - start, 0) }
}
const clamp = (n: number, lo: number, hi: number): number => Math.min(Math.max(n, lo), hi)
/** One proportional timeline bar for a span, positioned within the trace window. */
function Waterfall({ obs, win }: { obs: Observation; win: { start: number; span: number } }) {
const total = win.span || 1
const left = clamp(((startMs(obs) - win.start) / total) * 100, 0, 100)
const width = clamp(((endMs(obs) - startMs(obs)) / total) * 100, 0.75, 100 - left)
return (
<YStack flex={1} height={10} bg="$color3" rounded="$2" position="relative" overflow="hidden">
<YStack
position="absolute"
t={0}
b={0}
l={`${left}%`}
width={`${width}%`}
bg="$color9"
rounded="$2"
/>
</YStack>
)
}
function SpanRow({
node,
win,
collapsed,
onToggle,
}: {
node: SpanNode
win: { start: number; span: number }
collapsed: Set<string>
onToggle: (id: string) => void
}) {
const { obs, depth, children } = node
const hasChildren = children.length > 0
const isCollapsed = collapsed.has(obs.id)
return (
<>
<XStack
items="center"
gap="$2"
py="$1.5"
px="$2"
borderTopWidth={1}
borderColor="$borderColor"
hoverStyle={{ bg: '$color2' }}
>
<XStack width={340} items="center" gap="$1.5" pl={depth * 16}>
{hasChildren ? (
<Button
chromeless
circular
size="$1"
p="$0"
icon={isCollapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
onPress={() => onToggle(obs.id)}
/>
) : (
<YStack width={20} />
)}
<Badge label={obs.type} />
<Text fontSize="$3" color="$color12" numberOfLines={1}>
{obs.name || obs.id}
</Text>
{obs.model ? (
<Text fontSize="$2" color="$color10" numberOfLines={1}>
{obs.model}
</Text>
) : null}
</XStack>
<Waterfall obs={obs} win={win} />
<YStack width={84} items="flex-end">
<Text fontSize="$2" color="$color11">
{fmtLatency(elapsed(obs.startTime, obs.endTime))}
</Text>
</YStack>
</XStack>
{hasChildren && !isCollapsed
? children.map((c) => (
<SpanRow key={c.obs.id} node={c} win={win} collapsed={collapsed} onToggle={onToggle} />
))
: null}
</>
)
}
/** The full span tree for a trace's observations; empty state when there are none. */
export function SpanTree({ observations }: { observations: Observation[] }) {
const forest = useMemo(() => buildForest(observations), [observations])
const win = useMemo(() => window(observations), [observations])
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set())
const toggle = (id: string) =>
setCollapsed((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
if (observations.length === 0) {
return (
<YStack p="$6" items="center" borderWidth={1} borderColor="$borderColor" rounded="$4">
<Text color="$color11">No observations on this trace.</Text>
</YStack>
)
}
return (
<YStack borderWidth={1} borderColor="$borderColor" rounded="$4" overflow="hidden">
<XStack bg="$color2" py="$2.5" px="$2" gap="$2">
<Text width={340} fontSize="$2" fontWeight="700" color="$color11">
Span
</Text>
<Text flex={1} fontSize="$2" fontWeight="700" color="$color11">
Timeline · {fmtLatency(win.span / 1000)}
</Text>
<Text width={84} fontSize="$2" fontWeight="700" color="$color11" text="right">
Duration
</Text>
</XStack>
{forest.map((n) => (
<SpanRow key={n.obs.id} node={n} win={win} collapsed={collapsed} onToggle={toggle} />
))}
</YStack>
)
}
@@ -0,0 +1,58 @@
/**
* Display formatters for the observability surfaces — pure, display-only.
*
* One place for the trace/session/score value formatting so every list and
* detail reads the same. No data is fabricated here; missing values render as an
* em dash.
*/
import type { Score } from '~/lib/api'
/** A timestamp as a locale date-time; empty string for missing/invalid. */
export const fmtDate = (v?: string | null): string => {
if (!v) return ''
const d = new Date(v)
return Number.isNaN(d.getTime()) ? String(v) : d.toLocaleString()
}
/** Latency in seconds as "450ms" / "1.23s"; em dash for missing. */
export const fmtLatency = (seconds?: number | null): string => {
if (seconds === undefined || seconds === null) return '—'
if (seconds < 1) return `${Math.round(seconds * 1000)}ms`
return `${seconds.toFixed(2)}s`
}
/** Cost in USD as "$0.0123"; em dash for missing. */
export const fmtCost = (usd?: number | null): string => {
if (usd === undefined || usd === null) return '—'
if (usd === 0) return '$0'
return usd < 0.01 ? `$${usd.toFixed(5)}` : `$${usd.toFixed(4)}`
}
/** Elapsed seconds between two ISO timestamps; null if either is missing. */
export const elapsed = (start?: string | null, end?: string | null): number | null => {
if (!start || !end) return null
const ms = new Date(end).getTime() - new Date(start).getTime()
return Number.isFinite(ms) ? ms / 1000 : null
}
/** A score's display value — the number, or its string value for non-numeric. */
export const scoreValue = (s: Score): string => {
if (s.dataType === 'NUMERIC') return String(s.value)
return s.stringValue ?? String(s.value)
}
/** Stringify any JSON-ish value safely. */
const stringify = (v: unknown, space?: number): string => {
try {
return JSON.stringify(v, null, space)
} catch {
return String(v)
}
}
/** Pretty multi-line JSON for a detail panel; the raw string for strings. */
export const jsonBlock = (v: unknown): string => {
if (v === undefined || v === null) return ''
if (typeof v === 'string') return v
return stringify(v, 2)
}
@@ -0,0 +1,74 @@
'use client'
/**
* Shared detail primitives for the observability surfaces — one place for the
* label/value row, the tag/badge pills, and the JSON panel so traces, sessions,
* and scores render identically. Built only on GUI shorthand props.
*/
import type { ReactNode } from 'react'
import { Card, Text, XStack, YStack } from '@hanzo/gui'
import { jsonBlock } from './format'
/** A single label/value row in a detail overview. */
export function DetailRow({ label, value }: { label: string; value: ReactNode }) {
return (
<XStack justify="space-between" items="center" gap="$4" py="$2" borderBottomWidth={1} borderColor="$borderColor">
<Text fontSize="$3" color="$color11" fontWeight="600">
{label}
</Text>
<Text fontSize="$3" color="$color12" numberOfLines={1}>
{value}
</Text>
</XStack>
)
}
/** A small neutral pill — observation type, score source, etc. */
export function Badge({ label }: { label: string }) {
return (
<Text fontSize="$1" px="$2" py="$1" rounded="$2" bg="$color3" color="$color11">
{label}
</Text>
)
}
/** A right-aligned, wrapping list of tag pills; em dash when empty. */
export function Tags({ tags }: { tags?: string[] }) {
if (!tags || tags.length === 0) {
return (
<Text fontSize="$3" color="$color12">
</Text>
)
}
return (
<XStack gap="$1.5" flexWrap="wrap" justify="flex-end">
{tags.map((t) => (
<Badge key={t} label={t} />
))}
</XStack>
)
}
/**
* A JSON detail panel. Renders the pretty value in a monospace-ish block;
* react-native-web Text defaults to `white-space: pre-wrap`, so newlines and
* indentation are preserved on the web target. Renders nothing for empty values.
*/
export function JsonCard({ title, value }: { title: string; value: unknown }) {
const text = jsonBlock(value)
if (!text) return null
return (
<Card p="$4" gap="$2" borderWidth={1} borderColor="$borderColor">
<Text fontSize="$5" fontWeight="700">
{title}
</Text>
<YStack maxH={360} overflow="scroll" bg="$color2" rounded="$3" p="$3">
<Text fontSize="$2" color="$color11">
{text}
</Text>
</YStack>
</Card>
)
}
+4 -1
View File
@@ -20,7 +20,10 @@ export type PlatformError = { kind: PlatformErrorKind; message: string }
export function interpretPlatformError(e: unknown): PlatformError {
const status = e instanceof ApiError ? e.status : 0
const message = e instanceof Error ? e.message : String(e)
if (status === 501) return { kind: 'not-configured', message }
// 501 = proxy has no service token; 401/403 = the proxy HAS a token but the
// platform rejected it (mis-wired/expired token) — both mean "this console's
// PaaS credential is not configured correctly", never a user-auth problem.
if (status === 501 || status === 401 || status === 403) return { kind: 'not-configured', message }
if (status === 404) return { kind: 'unavailable', message }
return { kind: 'error', message }
}
@@ -3,7 +3,7 @@
import { useEffect, useState } from 'react'
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { ApiError, ProviderApi, type Provider } from '~/lib/zap'
import { ApiError, ProviderApi, type Provider } from '~/lib/api'
import {
FieldRow,
FieldText,
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from 'react'
import { Button, Text, XStack } from '@hanzo/gui'
import { Plus, Trash } from '@hanzogui/lucide-icons-2'
import { ApiError, ProviderApi, type Provider } from '~/lib/zap'
import { ApiError, ProviderApi, type Provider } from '~/lib/api'
import { useSession } from '~/lib/auth/session'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
+87
View File
@@ -0,0 +1,87 @@
'use client'
/**
* Honest backend states for `/v1`-backed modules (Playground, Evals, Datasets,
* Prompts) — ONE place that maps a thrown `ApiError` to a truthful explanation.
*
* These surfaces compose the model gateway and the console eval engine through
* the cloud `/v1` facade. A 503 means the runtime/keys aren't wired on this
* deployment yet, a 404/405 means the route isn't mounted here, 401/403 means
* the session can't read it. Nothing is fabricated — on failure the caller shows
* this card instead of placeholder rows/charts.
*/
import type { ReactNode } from 'react'
import { Button, Card, Text, XStack } from '@hanzo/gui'
import { TriangleAlert } from '@hanzogui/lucide-icons-2'
import { ApiError } from '~/lib/api'
export type BackendStateKind = 'not-initialized' | 'unavailable' | 'access' | 'billing' | 'error'
export type BackendState = { kind: BackendStateKind; message: string }
/** Classify a thrown `/v1` error into an honest kind + raw message. */
export function classifyBackend(e: unknown): BackendState {
const status = e instanceof ApiError ? e.status : 0
const message = e instanceof Error ? e.message : String(e)
if (status === 503) return { kind: 'not-initialized', message }
if (status === 404 || status === 405) return { kind: 'unavailable', message }
if (status === 402) return { kind: 'billing', message }
if (status === 401 || status === 403) return { kind: 'access', message }
return { kind: 'error', message }
}
const TITLES: Record<BackendStateKind, string> = {
'not-initialized': 'Backend not initialized',
unavailable: 'Not available on this deployment yet',
access: 'Access required',
billing: 'Add credits to continue',
error: 'Could not reach the backend',
}
const BODIES: Record<BackendStateKind, string> = {
'not-initialized':
'The /v1 route is mounted but its runtime (or the console API key it proxies to) is not configured on this deployment yet. Real data appears here once it is — no placeholder data is shown.',
unavailable:
'This endpoint is not mounted at the gateway on this host yet. The view lights up automatically once the route is live.',
access: 'Sign in with an account that can read this data.',
// Empty → the card shows the backend's own message (the honest "Insufficient
// balance. Please add credits…" from the gateway billing gate).
billing: '',
error: '',
}
/** A truthful state card for a `/v1` load failure, with an optional retry + hint. */
export function BackendStateCard({
state,
onRetry,
hint,
}: {
state: BackendState
onRetry?: () => void
hint?: ReactNode
}) {
return (
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$2" maxWidth={640}>
<XStack gap="$2" items="center">
<TriangleAlert size={16} />
<Text fontSize="$4" fontWeight="700">
{TITLES[state.kind]}
</Text>
</XStack>
<Text fontSize="$3" color="$color11">
{BODIES[state.kind] || state.message}
</Text>
{hint ? (
<Text fontSize="$2" color="$color10">
{hint}
</Text>
) : null}
{onRetry ? (
<Button size="$2" self="flex-start" onPress={onRetry}>
Retry
</Button>
) : null}
</Card>
)
}
+90
View File
@@ -0,0 +1,90 @@
'use client'
/**
* Brand logo for the console chrome — TWO orthogonal layers:
* 1. default: the host-derived BRAND mark (inline SVG, currentColor) + wordmark.
* White-label by hostname — a lux/zoo/pars host NEVER renders the Hanzo mark.
* 2. override: the selected org's own logo (IAM `organization.logo`) when set,
* resolved once per org per session (cached) and failing safe to the brand
* mark if IAM can't be read (e.g. a non-admin session 403s the gated proxy).
*/
import { useEffect, useState } from 'react'
import { Text, XStack } from '@hanzo/gui'
import { config } from '~/config'
import { useSession } from '~/lib/auth/session'
import { IamAdminApi } from '~/lib/api'
import { getBrand } from '~/lib/branding/brands'
/** Per-org logo cache for the session ('' = checked, none). */
const orgLogoCache = new Map<string, string>()
/** The host-derived brand mark — inline, build-time-trusted SVG (currentColor). */
function BrandMark({ size }: { size: number }) {
const brand = getBrand()
return (
<svg
width={size}
height={size}
viewBox={brand.logoViewBox}
role="img"
aria-label={brand.brandName}
fill="currentColor"
style={{ display: 'block', flexShrink: 0 }}
// logoContent is a hardcoded constant in src/lib/branding/brands.ts — never
// user input — so inlining it as SVG markup is safe.
dangerouslySetInnerHTML={{ __html: brand.logoContent }}
/>
)
}
export function BrandLogo({ size = 22, wordmark = true }: { size?: number; wordmark?: boolean }) {
const { account } = useSession()
const orgName = account?.organization || account?.owner || config.iamOrgName
const [logo, setLogo] = useState<string>(() => orgLogoCache.get(orgName) ?? '')
useEffect(() => {
if (orgLogoCache.has(orgName)) {
setLogo(orgLogoCache.get(orgName) ?? '')
return
}
let live = true
IamAdminApi.organization(orgName)
.then((o) => {
const l = typeof o.logo === 'string' ? o.logo : ''
orgLogoCache.set(orgName, l)
if (live) setLogo(l)
})
.catch(() => {
orgLogoCache.set(orgName, '')
if (live) setLogo('')
})
return () => {
live = false
}
}, [orgName])
const wordmarkText = `${getBrand().brandName.replace(' Cloud', '')} Console`
return (
<XStack items="center" gap="$2">
{logo ? (
// Arbitrary external org logo URL — raw <img> (next/image would need a
// configured remote allowlist for every tenant domain).
// eslint-disable-next-line @next/next/no-img-element
<img
src={logo}
alt="Organization logo"
style={{ height: size, width: 'auto', maxWidth: size * 5, objectFit: 'contain', display: 'block' }}
/>
) : (
<BrandMark size={size} />
)}
{wordmark ? (
<Text fontWeight="800" fontSize="$5" color="$color12">
{wordmarkText}
</Text>
) : null}
</XStack>
)
}
+75
View File
@@ -0,0 +1,75 @@
'use client'
/**
* Breadcrumbs — where you are, derived from the route + the catalog. The path
* `/vector/my-db` reads Home / Data / Vector / my-db: the product's category and
* label come from its catalog entry (so a new product gets correct crumbs for
* free), and trailing segments are the detail params. Links jump; the category
* and the current leaf are plain text. Nothing renders on the home route.
*/
import { usePathname, useRouter } from 'next/navigation'
import { Text, XStack } from '@hanzo/gui'
import { ChevronRight } from '@hanzogui/lucide-icons-2'
import { findEntry } from '~/lib/products/registry'
type Crumb = { label: string; href?: string }
function crumbsFor(pathname: string): Crumb[] {
const segs = pathname.split('/').filter(Boolean)
const crumbs: Crumb[] = [{ label: 'Home', href: '/' }]
if (segs.length === 0) return crumbs
if (segs[0] === 'discover') {
crumbs.push({ label: 'Discover' })
const e = segs[1] ? findEntry(segs[1]) : undefined
if (e) crumbs.push({ label: e.label })
else if (segs[1]) crumbs.push({ label: decodeURIComponent(segs[1]) })
return crumbs
}
const entry = findEntry(segs[0])
if (entry) {
crumbs.push({ label: entry.category })
crumbs.push({ label: entry.label, href: segs.length > 1 ? `/${entry.id}` : undefined })
for (let i = 1; i < segs.length; i++) crumbs.push({ label: decodeURIComponent(segs[i]) })
} else {
for (const s of segs) crumbs.push({ label: decodeURIComponent(s) })
}
return crumbs
}
export function Breadcrumbs() {
const pathname = usePathname()
const router = useRouter()
const crumbs = crumbsFor(pathname)
if (crumbs.length <= 1) return null
return (
<XStack items="center" gap="$1.5" flexWrap="wrap">
{crumbs.map((c, i) => {
const last = i === crumbs.length - 1
return (
<XStack key={`${c.label}-${i}`} items="center" gap="$1.5">
{i > 0 ? <ChevronRight size={13} opacity={0.4} /> : null}
{c.href && !last ? (
<Text
fontSize="$2"
color="$color11"
hoverStyle={{ color: '$color12' }}
cursor="pointer"
onPress={() => router.push(c.href!)}
>
{c.label}
</Text>
) : (
<Text fontSize="$2" color={last ? '$color12' : '$color10'} fontWeight={last ? '600' : '400'}>
{c.label}
</Text>
)}
</XStack>
)
})}
</XStack>
)
}
+28
View File
@@ -0,0 +1,28 @@
'use client'
/**
* The Hanzo "H" mark — the canonical brand glyph (same geometry as app/icon.svg).
*
* Rendered as inline SVG with `fill: currentColor`, so it inherits the
* surrounding text color and adapts to the dark/light theme with no per-theme
* asset. ONE source for the mark across the chrome (sidebar + header).
*/
export function HanzoMark({ size = 22, color = 'currentColor' }: { size?: number; color?: string }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 67 67"
role="img"
aria-label="Hanzo"
fill={color}
style={{ display: 'block', flexShrink: 0 }}
>
<path d="M22.21 67V44.6369H0V67H22.21Z" />
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" />
<path d="M22.21 0H0V22.3184H22.21V0Z" />
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" />
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" />
</svg>
)
}
+54 -16
View File
@@ -1,16 +1,25 @@
'use client'
/**
* Brand loader — the monochrome Hanzo ▼/H mark, centered and correctly sized.
* Brand loader — the resolved brand's mark.
*
* One loader for every full-screen "still loading" state (the auth gate, the
* OAuth callback). The mark renders at ~48px — a reasonably sized centered
* loader, not a viewport-filling spinner — filled with the theme's high-contrast
* `$color12` (white on dark, near-black on light).
* console2 is brand-aware (`config.brand` ∈ hanzo | lux | zoo | pars, from the
* hostname), so the mark follows the brand: each brand's OWN published,
* self-contained animated SVG — the logo IS that brand's "AI" you can play with
* (it animates in on load, flourishes on hover, squashes on press; pure CSS, no
* JS). A static block-H renders during SSR / first paint (no hydration mismatch),
* then upgrades to the brand's interactive animated mark on mount.
*/
import { Text, YStack, useTheme } from '@hanzo/gui'
import { useEffect, useState } from 'react'
/** The five-path Hanzo mark — identical geometry to `app/icon.svg`. */
import { Text, YStack, useTheme } from '@hanzo/gui'
import { getAnimatedSVG as hanzoAnimated } from '@hanzo/logo'
import { getAnimatedSVG as luxAnimated } from '@luxfi/logo'
import { getAnimatedSVG as zooAnimated } from '@zooai/logo'
import { config } from '~/config'
/** The five-path Hanzo mark — identical geometry to `app/icon.svg` (SSR fallback). */
const MARK_PATHS = [
'M22.21 67V44.6369H0V67H22.21Z',
'M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z',
@@ -19,28 +28,57 @@ const MARK_PATHS = [
'M66.7198 67V44.6369H44.5098V67H66.7198Z',
]
/** The monochrome Hanzo mark as an inline SVG, sized in px. */
export function HanzoMark({ size = 48 }: { size?: number }) {
/** Brand → its published animated mark (load → hover → press, pure CSS, no JS). */
const ANIMATED: Partial<Record<string, () => string>> = {
hanzo: hanzoAnimated,
lux: luxAnimated,
zoo: zooAnimated,
}
/**
* The resolved brand's mark, sized in px. Renders the static block-H on the
* server / first paint; upgrades to the brand's interactive animated SVG on mount.
*/
export function BrandMark({ size = 48 }: { size?: number }) {
const theme = useTheme()
const fill = theme.color12?.get() ?? '#ffffff'
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
const animate = mounted ? ANIMATED[config.brand] : undefined
if (!animate) {
return (
<svg width={size} height={size} viewBox="0 0 67 67" style={{ fill }} role="img" aria-label="Hanzo">
{MARK_PATHS.map((d) => (
<path key={d} d={d} />
))}
</svg>
)
}
// Size the self-contained animated SVG to the box (it has a viewBox, no w/h).
const svg = animate().replace('<svg ', '<svg width="100%" height="100%" ')
return (
<svg width={size} height={size} viewBox="0 0 67 67" style={{ fill }} role="img" aria-label="Hanzo">
{MARK_PATHS.map((d) => (
<path key={d} d={d} />
))}
</svg>
<div
style={{ width: size, height: size, display: 'inline-flex', cursor: 'pointer' }}
role="img"
aria-label={config.brandName}
dangerouslySetInnerHTML={{ __html: svg }}
/>
)
}
/** Back-compat alias — the mark is now brand-aware + animated. */
export const HanzoMark = BrandMark
/** Full-screen centered brand loader with an optional label. */
export function Loader({ label, size = 48 }: { label?: string; size?: number }) {
return (
<YStack flex={1} minH="100vh" items="center" justify="center" gap="$3">
<style>
{'@keyframes hz-pulse{0%,100%{opacity:.45}50%{opacity:1}}.hz-pulse{animation:hz-pulse 1.4s ease-in-out infinite;display:inline-flex}'}
{'@keyframes hz-pulse{0%,100%{opacity:.5}50%{opacity:1}}.hz-pulse{animation:hz-pulse 1.5s ease-in-out infinite;display:inline-flex}'}
</style>
<div className="hz-pulse">
<HanzoMark size={size} />
<BrandMark size={size} />
</div>
{label ? (
<Text fontSize="$3" color="$color11">
+108
View File
@@ -0,0 +1,108 @@
'use client'
/**
* Honest async states — the ONE way the console explains a failed/empty load.
*
* Every admin module that reads a real `/v1` endpoint can 404 (not routed on
* this deployment), 401/403 (access enforced server-side), or 503 (backend
* starting). `honestError` maps an `ApiError` to a specific, truthful message —
* never a generic crash and never fabricated data — and `ErrorState` renders it
* with a retry. Per-call copy overrides keep surface-specific guidance (e.g. the
* IAM admin API) without duplicating the structure.
*/
import { Button, Card, Text, XStack } from '@hanzo/gui'
import { TriangleAlert, Lock } from '@hanzogui/lucide-icons-2'
import { ApiError } from '~/lib/api'
import { useSession } from '~/lib/auth/session'
import { getBrand } from '~/lib/branding/brands'
/** Surface-specific overrides for the 404/unauthorized explanations. */
export type HonestCopy = { notFound?: string; unauthorized?: string }
/** Map an ApiError to an honest title + body. Defaults are generic and truthful. */
export function honestError(err: ApiError, copy: HonestCopy = {}): { title: string; body: string } {
if (err.status === 404)
return {
title: 'Not available on this deployment',
body:
copy.notFound ??
'This API is not routed on this host yet. It appears automatically once the deployment proxies it through the gateway.',
}
if (err.status === 503)
return {
title: 'Service unavailable',
body: 'The service is starting up or temporarily unavailable. Retry in a moment.',
}
if (err.status === 401 || err.status === 403 || /sign ?in|login|unauthorized/i.test(err.message))
return {
title: 'Access required',
body:
copy.unauthorized ??
'This view requires an authorized session, enforced server-side. Sign in with an account that has access.',
}
return { title: 'Could not load', body: err.message }
}
/** Coerce an unknown thrown value into an ApiError for honest rendering. */
export const asApiError = (e: unknown): ApiError =>
e instanceof ApiError ? e : new ApiError(e instanceof Error ? e.message : String(e))
/** True for the gate's `ApiError('forbidden', 403)` — the operator-access panel. */
export const isForbidden = (err: ApiError): boolean => err.status === 403
/**
* The operator-access-required panel — the honest UX on top of the authoritative
* server-side admin gate. Shown when the IAM/KMS gated proxies return 403: the
* caller is signed in but not authorized for THIS brand's admin console.
*/
export function OperatorAccessRequired() {
const { account } = useSession()
const brand = getBrand()
const who = account?.email || account?.name || 'This account'
return (
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$2" maxWidth={620}>
<XStack gap="$2" items="center">
<Lock size={16} />
<Text fontSize="$4" fontWeight="700">
Operator access required
</Text>
</XStack>
<Text fontSize="$3" color="$color11">
{who} is not authorized for the {brand.brandName} admin console. This console requires an
@{brand.adminDomain} account with an admin role.
</Text>
</Card>
)
}
/** The honest error card — title, explanation, and an optional retry. */
export function ErrorState({
err,
onRetry,
copy,
}: {
err: ApiError
onRetry?: () => void
copy?: HonestCopy
}) {
const { title, body } = honestError(err, copy)
return (
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$2" maxWidth={620}>
<XStack gap="$2" items="center">
<TriangleAlert size={16} />
<Text fontSize="$4" fontWeight="700">
{title}
</Text>
</XStack>
<Text fontSize="$3" color="$color11">
{body}
</Text>
{onRetry ? (
<Button size="$2" self="flex-start" onPress={onRetry}>
Retry
</Button>
) : null}
</Card>
)
}

Some files were not shown because too many files have changed in this diff Show More