Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98a7088be7 | ||
|
|
28c186b508 | ||
|
|
bba56046e4 | ||
|
|
948a627c72 | ||
|
|
bdda312f5e | ||
|
|
4cfcd1f779 | ||
|
|
b3fc76186f | ||
|
|
fecaf7d49d | ||
|
|
1d9c2a9225 | ||
|
|
7a3b13d55e | ||
|
|
bc7f92665e | ||
|
|
509e511b6e | ||
|
|
ab9f8751b7 | ||
|
|
5ea5cde253 | ||
|
|
f28d79673a | ||
|
|
40a6236aa3 | ||
|
|
97c385f5ca | ||
|
|
7cf8015c07 | ||
|
|
5874ec5acc | ||
|
|
01844deb23 | ||
|
|
f7a90ffe65 | ||
|
|
7f7a9341da | ||
|
|
13978c0ca9 | ||
|
|
d564d3c488 | ||
|
|
bd933e5984 | ||
|
|
c0da836191 | ||
|
|
abb45c9481 | ||
|
|
a09e9483c8 | ||
|
|
5a61a9571c | ||
|
|
7b9a02ab9a | ||
|
|
03d80356bf | ||
|
|
35e0ea4c30 | ||
|
|
cf44eff091 | ||
|
|
e3c4d52bb4 | ||
|
|
da83b67e89 | ||
|
|
a13de116e8 | ||
|
|
725beb1d98 | ||
|
|
21c1016292 | ||
|
|
bc76e6fc76 | ||
|
|
24f9b14e69 | ||
|
|
ac0ecfcb84 | ||
|
|
e547011c96 | ||
|
|
164f073b50 | ||
|
|
6df405adcd | ||
|
|
f50dbdd9a6 | ||
|
|
b4274bda4a | ||
|
|
c9adc16cad | ||
|
|
f30cdbbee5 | ||
|
|
a307d64b07 | ||
|
|
c32bb30154 | ||
|
|
c27a62d271 |
@@ -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 |
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) => (
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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).' },
|
||||
|
||||
Generated
+1533
-81
File diff suppressed because it is too large
Load Diff
+12
-7
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}</>
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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')} />
|
||||
}
|
||||
|
||||
@@ -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. 2–40 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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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."
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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)}`)} />
|
||||
}
|
||||
@@ -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."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)}`)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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)}`)} />
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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'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>
|
||||
)
|
||||
}
|
||||
@@ -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."
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user