Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd02bb0dc4 | ||
|
|
367c9235c1 | ||
|
|
3a3ac71fc9 | ||
|
|
d6d9aafc66 | ||
|
|
077a728693 | ||
|
|
4b690519c3 | ||
|
|
248101b84b | ||
|
|
bbffa64c2a | ||
|
|
74525b25dd | ||
|
|
20f56d5b9d | ||
|
|
ae34f15348 | ||
|
|
668cba2601 | ||
|
|
d1b6db0a14 | ||
|
|
1078bd980d | ||
|
|
e15c3f945c | ||
|
|
9c3ea47c3c | ||
|
|
31ab3c8a1d | ||
|
|
d946fc15d7 | ||
|
|
201648dfaf | ||
|
|
ea63e4e144 | ||
|
|
4603f234d0 | ||
|
|
312f44787d | ||
|
|
9a0badbdc6 | ||
|
|
3e4db1de3b | ||
|
|
2328b33558 | ||
|
|
afd5f82fea | ||
|
|
59f751e21c | ||
|
|
ab8bcd56fa | ||
|
|
589e7cc476 | ||
|
|
8e17b10312 | ||
|
|
8b8b820c24 | ||
|
|
b15381f922 | ||
|
|
b8f3fe36f8 | ||
|
|
1ac1a7a49c | ||
|
|
c1bb8ca61c | ||
|
|
e76773d065 | ||
|
|
5308dea0fa | ||
|
|
c56477b4d4 | ||
|
|
64695e9747 | ||
|
|
0bb83f9e20 | ||
|
|
b3d10cc5a7 | ||
|
|
785dc4f8bd | ||
|
|
e85c3ec141 | ||
|
|
e1d6480396 | ||
|
|
493654db0e | ||
|
|
3c33e3e459 | ||
|
|
8099715023 | ||
|
|
166a7176c6 | ||
|
|
3223c7112c | ||
|
|
4acc825a82 | ||
|
|
bba35bbd25 | ||
|
|
86abf5a066 | ||
|
|
a55edecee0 | ||
|
|
340d66c0a0 | ||
|
|
61aa5c8b8e | ||
|
|
a18f51798d | ||
|
|
9fd630bcde | ||
|
|
1d932b5119 | ||
|
|
c582dacbd6 | ||
|
|
89451b2973 | ||
|
|
fa8974b5db | ||
|
|
4a4fe7518d | ||
|
|
316081dd52 | ||
|
|
de5907fd12 | ||
|
|
89c2aab767 | ||
|
|
0430ec8222 | ||
|
|
e3a8f957ba | ||
|
|
b7320ac01e | ||
|
|
7d3fed557f | ||
|
|
9bc9ef785e | ||
|
|
04783533c1 | ||
|
|
f597098f76 | ||
|
|
e4493ec846 | ||
|
|
0a2979a6f1 | ||
|
|
152a044b03 | ||
|
|
2b3e5125fd | ||
|
|
4696584876 | ||
|
|
6ca7daed8e | ||
|
|
27805a336d | ||
|
|
301eea3569 | ||
|
|
3bc4a98d52 | ||
|
|
a0fa7026e6 | ||
|
|
5028329338 | ||
|
|
86bcfe6dce | ||
|
|
dfb4abddb9 | ||
|
|
172f604622 | ||
|
|
4b97d20922 | ||
|
|
4b5f28bb75 | ||
|
|
c4fb363efe | ||
|
|
c6e5c94f66 | ||
|
|
9f9f5b7083 | ||
|
|
1ea48d96f2 | ||
|
|
b475beb2d9 | ||
|
|
6649df2dee | ||
|
|
bf370dd986 | ||
|
|
f23df126e0 | ||
|
|
4fb384e5ca | ||
|
|
e9bd15578f | ||
|
|
f1bbaa8f02 | ||
|
|
104ba324c2 | ||
|
|
e93640365a | ||
|
|
6a92e279f8 | ||
|
|
4d29f67eaa | ||
|
|
2f7f7ce8cb | ||
|
|
11e8e64a81 | ||
|
|
f0ce43ac73 | ||
|
|
e608d02295 | ||
|
|
f2f9f3a2e4 | ||
|
|
a3af2102df | ||
|
|
ef14d2a120 | ||
|
|
964645890b | ||
|
|
8792c9d521 | ||
|
|
c48174c395 | ||
|
|
ae2728f622 | ||
|
|
eda304c0ca | ||
|
|
b4ef94c02c | ||
|
|
9e74a52323 | ||
|
|
1a9f96ea44 | ||
|
|
3a055a5938 | ||
|
|
da7d0f14db | ||
|
|
3edaaa2f53 | ||
|
|
c880dd98d4 | ||
|
|
7eecef3fcb | ||
|
|
cdfdad6561 | ||
|
|
b10bff252f | ||
|
|
6f7d18e9a9 | ||
|
|
5330bd72cb | ||
|
|
f5c7e74bc6 | ||
|
|
aec505578e | ||
|
|
5428e2207d | ||
|
|
ab39f938a4 | ||
|
|
7950a33e88 | ||
|
|
0f7c62d61f | ||
|
|
9264c8136f | ||
|
|
ff35fe92fd | ||
|
|
8d46368562 | ||
|
|
1bca918811 | ||
|
|
8d9f53596e | ||
|
|
e38d695966 | ||
|
|
207f2c6707 | ||
|
|
a43112c276 | ||
|
|
2df4ecfd08 | ||
|
|
6c59003ab9 | ||
|
|
64e252063a | ||
|
|
39a61da463 | ||
|
|
e6fb02c67e | ||
|
|
8c913b7864 | ||
|
|
fbf1226a1a | ||
|
|
fdb785c32b | ||
|
|
f70b82345d | ||
|
|
a2d482b662 | ||
|
|
40b75f0484 | ||
|
|
eca7e91fad | ||
|
|
df082c7042 | ||
|
|
5df6fe211f | ||
|
|
af0cabe1fc | ||
|
|
766ebee45e | ||
|
|
ac31fc6628 | ||
|
|
6f26240758 | ||
|
|
8badff6bd7 | ||
|
|
d0fa302242 | ||
|
|
7d34b33939 | ||
|
|
3265635e0f | ||
|
|
70b15baf1f | ||
|
|
7735a5082a | ||
|
|
2ceceb3f90 | ||
|
|
11c3c8d284 | ||
|
|
4b13dbf5cb | ||
|
|
f6dc1032b6 | ||
|
|
06e07799c0 | ||
|
|
baa78a757e | ||
|
|
0900d8c8c2 | ||
|
|
97e54c7854 | ||
|
|
11caebd961 | ||
|
|
faeec92455 | ||
|
|
a736e4443c | ||
|
|
e339b8f8ae | ||
|
|
de08a2b6b9 | ||
|
|
1eaab01b7e | ||
|
|
1c9818a69f | ||
|
|
e2d20b79b6 | ||
|
|
0ee8b88038 | ||
|
|
3465aefabe | ||
|
|
f090863ecd | ||
|
|
b8cfe98dbd | ||
|
|
03a7d96cac | ||
|
|
7929048d5b | ||
|
|
444a9ae41b | ||
|
|
9f0d1f5ad8 | ||
|
|
48e98d7df2 | ||
|
|
4a662481f7 | ||
|
|
74e9811e6e | ||
|
|
41bbc4b6e1 | ||
|
|
b526752c4c | ||
|
|
c1b06e2589 | ||
|
|
2a4e0bf8de | ||
|
|
f59d3e6a96 | ||
|
|
67ad7f5296 | ||
|
|
b64b133fa3 | ||
|
|
4ac6a4e0e2 | ||
|
|
103b8c1f91 | ||
|
|
c93c0f0a98 | ||
|
|
d4c5b88519 | ||
|
|
98632915fe | ||
|
|
7f19d72d28 | ||
|
|
1fcf9dce94 | ||
|
|
2515b596b7 | ||
|
|
2ad4de8410 | ||
|
|
88863caa79 | ||
|
|
86c312b7d4 | ||
|
|
becf552ac5 | ||
|
|
6894aac69d | ||
|
|
14219cf9b5 | ||
|
|
54447b5025 | ||
|
|
aca5c3cd41 | ||
|
|
1a9f041acc | ||
|
|
793cf731ab | ||
|
|
45df2ea8b9 | ||
|
|
1a6cc7888b | ||
|
|
179ae0de2e | ||
|
|
6e3360edaa | ||
|
|
4c859ac0f2 | ||
|
|
3e8a117f5b | ||
|
|
f179586d90 | ||
|
|
8da884864e | ||
|
|
71d2ee86c6 | ||
|
|
8d1059edd5 | ||
|
|
40096d8471 | ||
|
|
8be202f42a | ||
|
|
6c3fef23ee | ||
|
|
661469fe51 | ||
|
|
55be65b4f2 | ||
|
|
e635bb6627 | ||
|
|
4fdf69a766 | ||
|
|
8480853a7c | ||
|
|
ad762f30e7 | ||
|
|
f00b55894a | ||
|
|
269545eaa4 | ||
|
|
e48482db66 | ||
|
|
408ae15c25 | ||
|
|
9cb2ccfb25 | ||
|
|
c45b00c942 | ||
|
|
88b693b90d | ||
|
|
51c4d12dce | ||
|
|
6fe7078b06 | ||
|
|
3cdad07e32 | ||
|
|
8a492a2d18 | ||
|
|
fb6da37f9f | ||
|
|
41d618d1d8 | ||
|
|
4cb8cac44c | ||
|
|
5aee8715d7 | ||
|
|
aa7f45b29d | ||
|
|
65d32dda4c | ||
|
|
9ba70a4e2c | ||
|
|
5850560681 | ||
|
|
189b34971f | ||
|
|
e010864f26 | ||
|
|
e74efbab46 | ||
|
|
4884736c69 | ||
|
|
bb48d51d70 | ||
|
|
3d88cc75eb | ||
|
|
bc8945ff4e | ||
|
|
9627ebbbbc | ||
|
|
80147c328f | ||
|
|
29b3652711 | ||
|
|
891818443a | ||
|
|
532d8e692c | ||
|
|
e45833b5f2 | ||
|
|
dffa6cef34 | ||
|
|
04407e9cb5 | ||
|
|
9f0e37d8c3 | ||
|
|
bf18ef7a35 | ||
|
|
edda5a41b8 | ||
|
|
320885b073 | ||
|
|
d97b670580 | ||
|
|
b8ecd6c408 | ||
|
|
4086d54305 | ||
|
|
6831117b97 | ||
|
|
6ea1042d44 | ||
|
|
d9f090bb53 | ||
|
|
e1184390dc | ||
|
|
a42e158a07 | ||
|
|
6347fe0aeb | ||
|
|
f08abfe55a | ||
|
|
fbe58782d9 | ||
|
|
16ffaea878 | ||
|
|
a4f1a1c16f | ||
|
|
8ed0e27d73 | ||
|
|
9958dde439 | ||
|
|
e64e9cdbdc | ||
|
|
095773b2da | ||
|
|
926985e5ad | ||
|
|
24ace5354a | ||
|
|
412ee93a25 | ||
|
|
751111a0d7 | ||
|
|
8dab1688f7 | ||
|
|
7887485e95 | ||
|
|
3e1283365a | ||
|
|
8919c32d0f | ||
|
|
be95271bc4 | ||
|
|
f2752141ff | ||
|
|
b6311022f3 | ||
|
|
07f1182d52 | ||
|
|
bacd545b67 | ||
|
|
d95693b61a | ||
|
|
dce79e1309 |
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json",
|
||||
"changelog": ["@changesets/changelog-github", { "repo": "shadcn-ui/ui" }],
|
||||
"commit": false,
|
||||
"fixed": [],
|
||||
"linked": [],
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": ["v4", "tests"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
"@hanzo/analytics": patch
|
||||
---
|
||||
|
||||
Add `@hanzo/analytics` — the shared product-analytics capture client. Batched
|
||||
pageview/event/identify/group emit to Hanzo Cloud (`/v1/analytics` +
|
||||
`/v1/tracker`) with first-touch UTM/referrer/refCode attribution,
|
||||
beacon-on-unload, dual cookie/bearer auth, and the shared event + goal + cohort
|
||||
vocabulary (`EVENTS`, `GOALS`, `COHORTS`). Framework-agnostic core plus a
|
||||
`@hanzo/analytics/react` provider and hooks.
|
||||
@@ -1,12 +0,0 @@
|
||||
// ORIGINALLY FROM CLOUDFLARE WRANGLER:
|
||||
// https://github.com/cloudflare/wrangler2/blob/main/.github/changeset-version.js
|
||||
|
||||
import { execSync } from "child_process"
|
||||
|
||||
// This script is used by the `release.yml` workflow to update the version of the packages being released.
|
||||
// The standard step is only to run `changeset version` but this does not update the pnpm-lock.yaml file.
|
||||
// So we also run `pnpm install`, which does this update.
|
||||
// This is a workaround until this is handled automatically by `changeset version`.
|
||||
// See https://github.com/changesets/changesets/issues/421.
|
||||
execSync("npx changeset version", { stdio: "inherit" })
|
||||
execSync("pnpm install --lockfile-only", { stdio: "inherit" })
|
||||
@@ -1,96 +0,0 @@
|
||||
name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
capture_screenshots:
|
||||
description: 'Capture component screenshots (slow, optional)'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Build @hanzo/ui package
|
||||
run: |
|
||||
cd pkgs/ui && pnpm build
|
||||
|
||||
- name: Capture screenshots (optional)
|
||||
if: github.event.inputs.capture_screenshots == 'true'
|
||||
working-directory: ./app
|
||||
run: pnpm capture:registry
|
||||
timeout-minutes: 5
|
||||
|
||||
- name: Build documentation
|
||||
working-directory: ./app
|
||||
run: |
|
||||
pnpm build
|
||||
touch out/.nojekyll
|
||||
env:
|
||||
NODE_ENV: production
|
||||
GITHUB_ACTIONS: true
|
||||
NEXT_PUBLIC_APP_URL: https://ui.hanzo.ai
|
||||
SKIP_SCREENSHOTS: true
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: ./app/out
|
||||
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: hanzo-deploy-linux-amd64
|
||||
needs: build
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -1,185 +0,0 @@
|
||||
name: NPM Publish
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
package:
|
||||
description: 'Package to publish'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- ui
|
||||
- ui-mcp
|
||||
- auth
|
||||
- commerce
|
||||
- checkout
|
||||
- brand
|
||||
- react
|
||||
- all
|
||||
version_bump:
|
||||
description: 'Version bump type'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build packages
|
||||
run: |
|
||||
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "ui" ]; then
|
||||
cd pkgs/ui && pnpm build
|
||||
cd ../..
|
||||
fi
|
||||
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "ui-mcp" ]; then
|
||||
cd pkgs/ui-mcp && pnpm build
|
||||
cd ../..
|
||||
fi
|
||||
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "auth" ]; then
|
||||
cd pkgs/auth && pnpm build
|
||||
cd ../..
|
||||
fi
|
||||
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "commerce" ]; then
|
||||
cd pkgs/commerce && pnpm build
|
||||
cd ../..
|
||||
fi
|
||||
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "checkout" ]; then
|
||||
cd pkgs/checkout && pnpm build
|
||||
cd ../..
|
||||
fi
|
||||
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "brand" ]; then
|
||||
cd pkgs/brand && pnpm build
|
||||
cd ../..
|
||||
fi
|
||||
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "react" ]; then
|
||||
cd pkgs/react && pnpm build
|
||||
cd ../..
|
||||
fi
|
||||
|
||||
- name: Bump version and publish
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
run: |
|
||||
# Set up npm and pnpm auth
|
||||
npm config set //registry.npmjs.org/:_authToken $NODE_AUTH_TOKEN
|
||||
echo "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" >> ~/.npmrc
|
||||
|
||||
publish_package() {
|
||||
local pkg_dir=$1
|
||||
local pkg_name=$(cd $pkg_dir && node -p "require('./package.json').name")
|
||||
local pkg_version=$(cd $pkg_dir && node -p "require('./package.json').version")
|
||||
echo "=== Publishing $pkg_name@$pkg_version from $pkg_dir ==="
|
||||
|
||||
# Change to package directory
|
||||
pushd "$pkg_dir"
|
||||
|
||||
# Bump version manually using node to avoid any pnpm/npm hooks
|
||||
echo "Bumping version (${{ github.event.inputs.version_bump }})..."
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
||||
const [major, minor, patch] = pkg.version.split('.').map(Number);
|
||||
const bump = '${{ github.event.inputs.version_bump }}';
|
||||
if (bump === 'major') pkg.version = (major + 1) + '.0.0';
|
||||
else if (bump === 'minor') pkg.version = major + '.' + (minor + 1) + '.0';
|
||||
else pkg.version = major + '.' + minor + '.' + (patch + 1);
|
||||
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
|
||||
console.log('New version:', pkg.version);
|
||||
"
|
||||
|
||||
local new_version=$(node -p "require('./package.json').version")
|
||||
echo "Version is now: $new_version"
|
||||
|
||||
# Pack the tarball
|
||||
echo "Running pnpm pack..."
|
||||
pnpm pack --pack-gzip-level 9
|
||||
local tarball=$(ls -t *.tgz | head -1)
|
||||
echo "Created tarball: $tarball"
|
||||
|
||||
# Verify tarball contents
|
||||
echo "Tarball package.json:"
|
||||
tar -xzf "$tarball" -O package/package.json
|
||||
|
||||
# Copy to temp dir and publish
|
||||
local temp_dir="/tmp/publish-$$"
|
||||
mkdir -p "$temp_dir"
|
||||
cp "$tarball" "$temp_dir/"
|
||||
echo "Publishing from $temp_dir..."
|
||||
cd "$temp_dir"
|
||||
npm publish "$tarball" --access public
|
||||
cd -
|
||||
|
||||
# Cleanup
|
||||
rm -rf "$temp_dir"
|
||||
rm -f "$tarball"
|
||||
popd
|
||||
}
|
||||
|
||||
case "${{ github.event.inputs.package }}" in
|
||||
ui)
|
||||
publish_package "pkgs/ui"
|
||||
;;
|
||||
ui-mcp)
|
||||
publish_package "pkgs/ui-mcp"
|
||||
;;
|
||||
auth)
|
||||
publish_package "pkgs/auth"
|
||||
;;
|
||||
commerce)
|
||||
publish_package "pkgs/commerce"
|
||||
;;
|
||||
checkout)
|
||||
publish_package "pkgs/checkout"
|
||||
;;
|
||||
brand)
|
||||
publish_package "pkgs/brand"
|
||||
;;
|
||||
react)
|
||||
publish_package "pkgs/react"
|
||||
;;
|
||||
all)
|
||||
publish_package "pkgs/ui"
|
||||
publish_package "pkgs/ui-mcp"
|
||||
publish_package "pkgs/auth"
|
||||
publish_package "pkgs/commerce"
|
||||
publish_package "pkgs/checkout"
|
||||
publish_package "pkgs/brand"
|
||||
publish_package "pkgs/react"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commit-message: 'chore: bump ${{ github.event.inputs.package }} version to ${{ github.event.inputs.version_bump }}'
|
||||
title: 'chore: bump ${{ github.event.inputs.package }} version'
|
||||
body: |
|
||||
Automated version bump for ${{ github.event.inputs.package }} package(s).
|
||||
|
||||
Version bump type: ${{ github.event.inputs.version_bump }}
|
||||
branch: version-bump-${{ github.event.inputs.package }}-${{ github.run_number }}
|
||||
@@ -1,65 +0,0 @@
|
||||
# Adapted from create-t3-app.
|
||||
name: Write Beta Release comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Release - Beta"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
if: |
|
||||
github.repository_owner == 'shadcn-ui' &&
|
||||
${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
name: Write comment to the PR
|
||||
steps:
|
||||
- name: "Comment on PR"
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: context.payload.workflow_run.id,
|
||||
});
|
||||
|
||||
for (const artifact of allArtifacts.data.artifacts) {
|
||||
// Extract the PR number and package version from the artifact name
|
||||
const match = /^npm-package-shadcn@(.*?)-pr-(\d+)/.exec(artifact.name);
|
||||
|
||||
if (match) {
|
||||
require("fs").appendFileSync(
|
||||
process.env.GITHUB_ENV,
|
||||
`\nBETA_PACKAGE_VERSION=${match[1]}` +
|
||||
`\nWORKFLOW_RUN_PR=${match[2]}` +
|
||||
`\nWORKFLOW_RUN_ID=${context.payload.workflow_run.id}`
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
- name: "Comment on PR with Link"
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
with:
|
||||
number: ${{ env.WORKFLOW_RUN_PR }}
|
||||
message: |
|
||||
A new prerelease is available for testing:
|
||||
|
||||
```sh
|
||||
pnpm dlx shadcn@${{ env.BETA_PACKAGE_VERSION }}
|
||||
```
|
||||
|
||||
- name: "Remove the autorelease label once published"
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: '${{ env.WORKFLOW_RUN_PR }}',
|
||||
name: '🚀 autorelease',
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
# Adapted from create-t3-app.
|
||||
|
||||
name: Release - Beta
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
prerelease:
|
||||
if: |
|
||||
github.repository_owner == 'shadcn-ui' &&
|
||||
contains(github.event.pull_request.labels.*.name, '🚀 autorelease')
|
||||
name: Build & Publish a beta release to NPM
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
environment: Preview
|
||||
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Use PNPM
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.0.6
|
||||
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Update npm for OIDC support
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Install NPM Dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Modify package.json version
|
||||
run: node .github/version-script-beta.js
|
||||
|
||||
- name: Publish Beta to NPM
|
||||
run: pnpm pub:beta
|
||||
|
||||
- name: get-npm-version
|
||||
id: package-version
|
||||
uses: martinbeentjes/npm-get-version-action@main
|
||||
with:
|
||||
path: pkgs/shadcn
|
||||
|
||||
- name: Upload packaged artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: npm-package-shadcn@${{ steps.package-version.outputs.current-version }}-pr-${{ github.event.number }} # encode the PR number into the artifact name
|
||||
path: pkgs/shadcn/dist/index.js
|
||||
@@ -1,150 +0,0 @@
|
||||
name: Publish on Tag
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*' # Match @hanzo/ui version (e.g., v5.1.1)
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Tests
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Build packages
|
||||
run: |
|
||||
cd pkgs/ui && pnpm build && cd ../..
|
||||
cd pkgs/commerce && pnpm build && cd ../..
|
||||
cd pkgs/brand && pnpm build && cd ../..
|
||||
cd pkgs/react && pnpm build && cd ../..
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
cd pkgs/ui && pnpm test
|
||||
cd ../react && pnpm test
|
||||
cd ../..
|
||||
|
||||
publish:
|
||||
name: Publish to NPM
|
||||
needs: test
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Build all packages
|
||||
run: |
|
||||
cd pkgs/ui && pnpm build && cd ../..
|
||||
cd pkgs/commerce && pnpm build && cd ../..
|
||||
cd pkgs/brand && pnpm build && cd ../..
|
||||
cd pkgs/react && pnpm build && cd ../..
|
||||
|
||||
- name: Configure npm authentication
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
npm config set //registry.npmjs.org/:_authToken $NODE_AUTH_TOKEN
|
||||
npm whoami
|
||||
|
||||
- name: Check and publish packages
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
echo "Checking all packages for unpublished versions..."
|
||||
|
||||
PUBLISHED_COUNT=0
|
||||
SKIPPED_COUNT=0
|
||||
PUBLISHED_PACKAGES=""
|
||||
|
||||
for package in ui commerce brand react; do
|
||||
cd "pkgs/$package"
|
||||
|
||||
CURRENT_VERSION=$(node -p "require('./package.json').version")
|
||||
PACKAGE_NAME=$(node -p "require('./package.json').name")
|
||||
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "📦 Checking $PACKAGE_NAME@$CURRENT_VERSION"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
# Check if this version already exists on npm
|
||||
if npm view "$PACKAGE_NAME@$CURRENT_VERSION" version 2>/dev/null; then
|
||||
echo "⏭️ Already published - skipping"
|
||||
SKIPPED_COUNT=$((SKIPPED_COUNT + 1))
|
||||
else
|
||||
echo "🚀 Publishing to npm..."
|
||||
npm publish --access public
|
||||
echo "✅ Successfully published $PACKAGE_NAME@$CURRENT_VERSION"
|
||||
PUBLISHED_COUNT=$((PUBLISHED_COUNT + 1))
|
||||
PUBLISHED_PACKAGES="$PUBLISHED_PACKAGES\n- $PACKAGE_NAME@$CURRENT_VERSION"
|
||||
fi
|
||||
|
||||
cd ../..
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "📊 Publishing Summary"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "✅ Published: $PUBLISHED_COUNT package(s)"
|
||||
echo "⏭️ Skipped: $SKIPPED_COUNT package(s)"
|
||||
|
||||
if [ $PUBLISHED_COUNT -gt 0 ]; then
|
||||
echo ""
|
||||
echo -e "Published packages:$PUBLISHED_PACKAGES"
|
||||
fi
|
||||
|
||||
# Save for GitHub release notes
|
||||
echo "PUBLISHED_COUNT=$PUBLISHED_COUNT" >> $GITHUB_ENV
|
||||
echo "PUBLISHED_PACKAGES<<EOF" >> $GITHUB_ENV
|
||||
echo -e "$PUBLISHED_PACKAGES" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: ${{ env.PUBLISHED_COUNT > 0 }}
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
generate_release_notes: true
|
||||
body: |
|
||||
## 📦 NPM Packages Published
|
||||
|
||||
${{ env.PUBLISHED_PACKAGES }}
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Install latest versions
|
||||
npm install @hanzo/ui @hanzo/commerce @hanzo/brand @hanzo/react
|
||||
```
|
||||
files: |
|
||||
CHANGELOG.md
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -5,7 +5,6 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'pkgs/*/package.json'
|
||||
- 'pkgs/*/package.json'
|
||||
|
||||
jobs:
|
||||
detect-changes:
|
||||
@@ -21,7 +20,7 @@ jobs:
|
||||
id: changed
|
||||
run: |
|
||||
CHANGED=()
|
||||
for pkg_json in pkgs/*/package.json pkgs/*/package.json; do
|
||||
for pkg_json in pkgs/*/package.json; do
|
||||
[ ! -f "$pkg_json" ] && continue
|
||||
pkg_dir=$(dirname "$pkg_json")
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
# Adapted from create-t3-app.
|
||||
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
if: ${{ github.repository_owner == 'shadcn-ui' }}
|
||||
name: Create a PR for release workflow
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Use PNPM
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.0.6
|
||||
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Update npm for OIDC support
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Install NPM Dependencies
|
||||
run: pnpm install
|
||||
|
||||
# - name: Check for errors
|
||||
# run: pnpm check
|
||||
|
||||
- name: Build the package
|
||||
run: pnpm shadcn:build
|
||||
|
||||
- name: Create Version PR or Publish to NPM
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
commit: "chore(release): version packages"
|
||||
title: "chore(release): version packages"
|
||||
version: node .github/changeset-version.js
|
||||
publish: npx changeset publish
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_ENV: "production"
|
||||
@@ -5,7 +5,6 @@ node_modules
|
||||
**/node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
/pnpm-lock.yaml
|
||||
|
||||
# testing
|
||||
coverage
|
||||
|
||||
+3
-1
@@ -12,7 +12,9 @@ This repository is a monorepo.
|
||||
|
||||
- We use [pnpm](https://pnpm.io) and [`workspaces`](https://pnpm.io/workspaces) for development.
|
||||
- We use [Turborepo](https://turbo.build/repo) as our build system.
|
||||
- We use [changesets](https://github.com/changesets/changesets) for managing releases.
|
||||
- Releases are semver-driven: bump a package's `version` in its `package.json` and
|
||||
merge to `main`. CI (`.github/workflows/publish.yml`) detects the version change
|
||||
and publishes that package to npm. One step, one source of truth.
|
||||
|
||||
## Structure
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# Hanzo Design System — Canonical Tokens
|
||||
|
||||
`@hanzo/ui` is the single source of truth for the shared Hanzo product look across
|
||||
the **Tailwind** apps (hanzo.chat, hanzo.app, hanzo console, commerce, hanzo-desktop).
|
||||
Change a value here; apps converge on it. This file is that source of truth for the
|
||||
three things that must read as **one product**: typography, the sidebar/panel system,
|
||||
and the dark-black palette.
|
||||
|
||||
> One library: **`@hanzo/ui@8`** (`pkg/ui`, on **`@hanzo/gui`**) IS the component
|
||||
> library — the cross-platform product/record layer every surface consumes.
|
||||
> **`@hanzo/ui-shadcn`** (`pkgs/ui`) is the legacy shadcn/Tailwind/Radix kit, kept
|
||||
> only for existing v5 consumers (pin `@hanzo/ui-shadcn@^5`; no new adoptions).
|
||||
> This file stays the source of truth for the *token values* (fonts, dark palette,
|
||||
> the sidebar glyph) both render.
|
||||
|
||||
---
|
||||
|
||||
## 1. Typography — Basel Grotesk + Geist Mono
|
||||
|
||||
| Role | Family | Notes |
|
||||
|------|--------|-------|
|
||||
| UI / body / display / heading (`sans`) | **Basel Grotesk** | Self-hosted. Book = weight **400**, Medium = weight **500**. |
|
||||
| code / data / mono (`mono`) | **Geist Mono** | `next/font/google` (`Geist_Mono`) or the geist CDN. |
|
||||
| Arabic / Hebrew (`--font-ar` / `--font-he`) | unchanged | i18n only — keep. |
|
||||
|
||||
**Dropped as defaults:** Geist Sans, DM Sans, Figtree, Inter, PT Sans, Roboto Mono.
|
||||
|
||||
Basel is a **licensed, non-Google** face — **self-host** the woff2/woff, do NOT use
|
||||
`next/font/google` for it. Canonical files (mirror lux.exchange):
|
||||
`Basel-Grotesk-Book.woff2/.woff` (400), `Basel-Grotesk-Medium.woff2/.woff` (500).
|
||||
|
||||
`@font-face` (weights 400/500, `font-display: swap`, `font-style: normal`):
|
||||
|
||||
```css
|
||||
@font-face {
|
||||
font-family: 'Basel';
|
||||
font-style: normal;
|
||||
font-weight: 400; /* Book; 500 = Medium */
|
||||
font-display: swap;
|
||||
src: url('.../Basel-Grotesk-Book.woff2') format('woff2'),
|
||||
url('.../Basel-Grotesk-Book.woff') format('woff');
|
||||
}
|
||||
```
|
||||
|
||||
Per-app adoption (converge the value, keep each app's own mechanism):
|
||||
- **@hanzo/ui / Next apps** → `next/font/local` for Basel (`--font-basel-sans`) +
|
||||
`next/font/google` `Geist_Mono` (`--font-geist-mono`). See `app/lib/fonts.ts`;
|
||||
tailwind `sans → var(--font-basel-sans)`, `mono → var(--font-geist-mono)`.
|
||||
- **Vite + Tailwind apps** (chat, launcher, desktop) → self-host Basel `@font-face`
|
||||
+ geist-mono CDN import; tailwind `fontFamily.sans = ['Basel', …]`,
|
||||
`mono = ['Geist Mono', …]`.
|
||||
- **Tamagui (console)** → Basel `@font-face` in globals + override the Tamagui
|
||||
`body`/`heading` font `family` to Basel; Geist Mono for `code`/`pre`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Sidebar toggle icon — lucide `PanelLeft`
|
||||
|
||||
One glyph everywhere: lucide **`PanelLeft`** (the shadcn `SidebarTrigger` default).
|
||||
Where a directional open/close affordance is wanted, use the pair
|
||||
**`PanelLeftClose`** (expanded) / **`PanelLeft`** (collapsed). Never a hamburger,
|
||||
a magnifier, a directional arrow, or a bespoke panel SVG for the sidebar toggle.
|
||||
|
||||
- Icon: stroke-2, ~16–20px, `currentColor`.
|
||||
- Button: ghost/outline, square (`size-7`/`h-6 w-6`), subtle hover.
|
||||
|
||||
---
|
||||
|
||||
## 3. Sidebar + panels
|
||||
|
||||
| Spec | Value |
|
||||
|------|-------|
|
||||
| Sidebar width (expanded) | **16rem / 256px** (`SIDEBAR_WIDTH`) |
|
||||
| Sidebar width (collapsed / icon rail) | **3rem / 48px** (apps vary 48–70px) |
|
||||
| Sidebar / panel surface | resting **#0a0a0a** over the true-black page |
|
||||
| Border / separation | `border-border` — subtle white-alpha ~10% in dark (`border-r` / `border-l`) |
|
||||
| Item hover | subtle `white/5` |
|
||||
| Item active | monochrome `white/10` — **no colored accent** (the house style is monochrome) |
|
||||
| Right panel rail | `border-l border-border`, same surface, collapsible |
|
||||
|
||||
Prefer the `@hanzo/ui` `Sidebar` primitive (`pkgs/ui/primitives/sidebar.tsx`,
|
||||
`SidebarTrigger` → `PanelLeft`) where the app can consume it; otherwise match these
|
||||
classes/tokens.
|
||||
|
||||
---
|
||||
|
||||
## 4. Dark-black palette (true-black OLED)
|
||||
|
||||
The house dark theme is a **true-black** canvas (matches hanzo.ai marketing +
|
||||
hanzo.chat OLED), with a shallow surface-depth ladder for cards/panels and quiet
|
||||
hairline borders — never harsh pure-white on pure-black.
|
||||
|
||||
| Token | Value | Use |
|
||||
|-------|-------|-----|
|
||||
| Page background | **#000000** (`oklch(0 0 0)`) | body / canvas |
|
||||
| Surface / sidebar / panel (resting) | **#0a0a0a** | sidebars, panels, cards |
|
||||
| Press | **#050505** | pressed surface |
|
||||
| Elevated / hover | **#171717** | hover, raised card |
|
||||
| Border / divider | **rgba(255,255,255,0.10)** (≈ `#171717` opaque on black) | hairlines |
|
||||
| Foreground (primary text) | near-white **#ededf1** (`oklch(0.985)`) — not pure `#fff` | body text |
|
||||
| Muted / secondary text | `white/70` (≈ `#a1a1aa`) | secondary |
|
||||
|
||||
Keep each app's theme-token engine (CSS vars / Tailwind tokens / Tamagui `$color*`);
|
||||
converge the **values/usage** to the table above, don't rip the engine.
|
||||
|
||||
Reference: `pkgs/ui/style/hanzo-default-colors.css` (`.dark` / `.hanzo-ui-dark-theme`).
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# ui.hanzo.ai — the @hanzo/ui docs + component registry, served by the house
|
||||
# static server (ghcr.io/hanzoai/static, a Go binary), same as every other Hanzo
|
||||
# static site. Built on our own runners, never on a laptop and never by a
|
||||
# third-party builder.
|
||||
#
|
||||
# The app is a Next.js static export: `pnpm build` in app/ writes app/out, which
|
||||
# is the entire site — docs, the registry JSON the CLI reads, and the static
|
||||
# /api/registry/*.json index.
|
||||
FROM node:22 AS builder
|
||||
WORKDIR /src
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Publishable ingest key (pk_…), baked in at build because a static export has no
|
||||
# server to read config at runtime. Write-only and HMAC-verified to one org, so it
|
||||
# is safe in a public bundle; the deployment that builds decides which org the
|
||||
# site reports as. Absent, the client stays inert.
|
||||
ARG NEXT_PUBLIC_HANZO_INGEST_KEY=""
|
||||
ENV NEXT_PUBLIC_HANZO_INGEST_KEY=$NEXT_PUBLIC_HANZO_INGEST_KEY
|
||||
# 300+ prerendered pages; the default heap is not enough.
|
||||
ENV NODE_OPTIONS=--max-old-space-size=8192
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
COPY . .
|
||||
|
||||
# The lockfile is committed, so the build resolves exactly what was reviewed.
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Workspace packages the app imports must be built first: their package.json
|
||||
# exports point at dist/.
|
||||
RUN cd pkgs/ui && pnpm build
|
||||
RUN cd pkgs/event && pnpm build
|
||||
|
||||
# Builds the component registry, then the site (app/package.json build script).
|
||||
RUN cd app && pnpm build
|
||||
|
||||
# 0.5.1 serves a directory's index.html in place. On 0.4.1 every page 301'd to
|
||||
# an explicit /index.html, which leaks that filename into the address bar and
|
||||
# into the URLs Next builds for its route prefetches.
|
||||
FROM ghcr.io/hanzoai/static:0.5.2-amd64
|
||||
COPY --from=builder /src/app/out /public
|
||||
EXPOSE 3000
|
||||
# No -spa: the export writes a real index.html per route (trailingSlash), so a
|
||||
# missing path must 404 rather than silently render the home page.
|
||||
ENTRYPOINT ["/static", "-port", "3000", "-root", "/public"]
|
||||
@@ -1,10 +1,71 @@
|
||||
# Hanzo UI - LLM Context
|
||||
# @hanzo/ui — LLM context
|
||||
|
||||
## Overview
|
||||
**What this is.** The React component library for AI applications: a shadcn/ui
|
||||
fork with 161+ components, 24+ blocks, two themes, multi-framework output, and a
|
||||
single typed import surface. Published as `@hanzo/ui` (v8) on npm. Docs at
|
||||
https://ui.hanzo.ai. Dev port: 3003.
|
||||
|
||||
React component library (shadcn/ui fork). 161 components, 24+ blocks, two themes, multi-framework. Published as `@hanzo/ui` on npm.
|
||||
**Canonical role.** This is the canonical impl repo for Hanzo's web UI kit —
|
||||
frontend components, not an SDK. It sits alongside the two SDK lines (full cloud
|
||||
SDK generated from OpenAPI in `hanzo-<lang>/sdk` + wrapper in `hanzoai/<lang>-sdk`;
|
||||
AI/agents lib `hanzo` in `hanzoai/python-sdk` flagship, `@hanzo/ai` in `hanzo-js/ai`).
|
||||
`@hanzo/event` (telemetry, `POST /v1/event`) lives here in `pkgs/event`. DRY: one
|
||||
impl, one place — link out, never duplicate.
|
||||
|
||||
**Docs**: https://ui.hanzo.ai | **Dev port**: 3003
|
||||
**Brand rules (hard).**
|
||||
- Never call Hanzo an "LLM gateway" or position it against LiteLLM — it is a full
|
||||
AI SDK / AI cloud, not a proxy. Purge that framing on sight.
|
||||
- Paths are `/v1/…` only — never an `/api/` prefix.
|
||||
- Zen models are our own family — never name upstream models.
|
||||
- Voice: "Hanzo — the Open AI Cloud." Developer-first, crisp, no emoji-spam.
|
||||
|
||||
**Install / run.**
|
||||
```bash
|
||||
pnpm add @hanzo/ui # consume
|
||||
# dev:
|
||||
pnpm install && pnpm build:registry && pnpm dev # registry MUST build before app
|
||||
```
|
||||
|
||||
**Key entry points.** `pkg/ui/` (core lib + v8 subpaths: /product /data /canvas
|
||||
/wallet /network /billing /dashboard /usage /gitops) · `app/registry/{default,new-york}/`
|
||||
(component SOURCE OF TRUTH) · `pkgs/*` (auto-published `@hanzo/*` packages) ·
|
||||
`packages/shadcn/` (CLI) · `app/content/docs/` (MDX docs). Publish = bump a
|
||||
package `version` + merge to main (`.github/workflows/publish.yml`).
|
||||
|
||||
**Spec / more context.** Canonical SDK + docs model: `~/work/hanzo/SDK-ARCHITECTURE.md`.
|
||||
Detailed engineering notes (build order, import surface, telemetry, upstream sync,
|
||||
gotchas) follow below.
|
||||
|
||||
---
|
||||
|
||||
## v8 — the one import surface (`@hanzo/ui@8`, `pkg/ui`)
|
||||
|
||||
`@hanzo/ui@8` (`pkg/ui`) is the cross-platform product/record layer on `@hanzo/gui`.
|
||||
Every recent component kit is reachable from this single package as a thin subpath
|
||||
that re-exports its home package (code lives once; each home is an OPTIONAL peer,
|
||||
pulled only when the subpath is used):
|
||||
|
||||
| Subpath | Home | Components |
|
||||
|---|---|---|
|
||||
| `@hanzo/ui` · `/product` | (this) | charts, metrics, PageHeader, StatusTag, EmptyState, ComboBox, SlideOver, Toast, Reorder, Field |
|
||||
| `@hanzo/ui/data` | `@hanzo/data` | RecordsView, DataTable, typed field editors |
|
||||
| `@hanzo/ui/canvas` | `@hanzo/canvas` | ProjectCanvas, ServiceNode, DeployTimeline, EnvSwitcher, ServiceDetailDrawer, ServiceStatusBadge |
|
||||
| `@hanzo/ui/wallet` | `@hanzo/ui-shadcn/wallet` | WalletMenu, injectedEvmAdapter (EIP-1193), walletAvailable, ensureEvmNetwork |
|
||||
| `@hanzo/ui/network` | `@hanzo/ui-shadcn/network` | NetworkSwitcher, useNetwork, configureNetworks, HANZO_NETWORKS |
|
||||
| `@hanzo/ui/billing` | `@hanzo/ui-shadcn/billing` | CreditModal |
|
||||
| `@hanzo/ui/dashboard` | `@hanzo/dashboard` | landing + deploy-pipeline + overview kit |
|
||||
| `@hanzo/ui/usage` | `@hanzo/usage` | UsageMeter, UsageProviderCard, UsageDashboard |
|
||||
| `@hanzo/ui/gitops` | `@hanzo/gitops` | GitopsAppList, tree, diff, sync/rollback, HealthBadge |
|
||||
|
||||
The **8 newest kits** = canvas, wallet, network, billing, dashboard, usage, gitops,
|
||||
data. Add one by mirroring `src/gitops.ts` (a one-line `export *`) + a `./name`
|
||||
export + an optional peer/devDep. NOTE: `pkg/*` is a pnpm workspace member (for
|
||||
`workspace:*` dev links), but `pkg/ui` publishes via the maintainer flow, not
|
||||
`publish.yml` (which auto-publishes only `pkgs/*` on a version bump — see
|
||||
PUBLISH_GUIDE.md). The shared shell lives here too: `AppHeader` + `BrandMark`
|
||||
(@hanzo/logo) + `OrgSwitcher` + `orgScope` (the console org-scope contract,
|
||||
hoisted per #36). Lux surfaces use `@luxfi/web3`
|
||||
for wallet/login; `@hanzo/ui/wallet`+`/network` are the Hanzo-branded equivalents.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
@@ -58,14 +119,46 @@ pnpm lint # Lint all workspaces
|
||||
pnpm typecheck # Type checking
|
||||
pnpm test # Unit tests
|
||||
pnpm test:e2e # Playwright E2E
|
||||
pnpm changeset # Create changeset for publishing
|
||||
```
|
||||
|
||||
## Publishing
|
||||
|
||||
One way: bump a package's `version` in its `package.json` and merge to `main`.
|
||||
`.github/workflows/publish.yml` detects the changed `@hanzo/*` package and
|
||||
publishes it to npm (needs the repo `NPM_TOKEN` secret). No changesets, no
|
||||
version-PR bot — the semver bump is the trigger.
|
||||
|
||||
## Telemetry — `@hanzo/event` is the ONE client (`pkgs/event`)
|
||||
|
||||
`@hanzo/event` is the single canonical telemetry client for every Hanzo surface.
|
||||
It emits **one** kind of thing — an `Event` — to **one** door: `POST /v1/event`
|
||||
with the batched `{ batch: [Event, …] }` wire, `-> { accepted, dropped }`.
|
||||
Pageview, event, identify, group, AND errors are all events on that one stream;
|
||||
Cloud resolves the tenant server-side (session / publishable `pk_` key) and fans
|
||||
the stream to the read lenses (analytics = web, insights = product, sentry =
|
||||
errors). The client **never** sends the org. Entries: `.` (framework-agnostic:
|
||||
`createAnalytics`, `EVENTS`, `GOALS`, attribution helpers) and `./react`
|
||||
(`AnalyticsProvider`, `useAnalytics`, `usePageview`, `ErrorBoundary`). Auto error
|
||||
capture (window.onerror / unhandledrejection / React boundary) makes it the
|
||||
drop-in error-tracking replacement. SSR-safe, fail-soft, beacon-on-unload.
|
||||
|
||||
Build is a tsup dual bundle: **CJS → `.cjs`, ESM → `.mjs`** (required under
|
||||
`"type": "module"` — a CJS `.js` is parsed as ESM and crashes `require()` with
|
||||
"exports is not defined"). Each `exports` condition carries its own types.
|
||||
|
||||
### One way — supersessions (no divergent telemetry client)
|
||||
|
||||
| Package | Status | Note |
|
||||
|---|---|---|
|
||||
| `@hanzo/event` | **canonical** | `pkgs/event`, posts `/v1/event` only |
|
||||
| `@hanzo/capture` (npm) | **deprecated → `@hanzo/event`** | the old name of this package; `@hanzo/event` is a superset |
|
||||
| `pkgs/capture` (`@hanzo/analytics@0.1.0` dup) | **deleted** | stale in-repo duplicate, removed |
|
||||
|
||||
## Three-Layer Architecture
|
||||
|
||||
1. **Components** (`registry/{style}/ui/`) -- Single primitives (Button, Card, Dialog). CLI-installable.
|
||||
2. **Examples** (`registry/{style}/example/`) -- Usage demos for docs via `<ComponentPreview />`.
|
||||
3. **Blocks** (`registry/{style}/blocks/`) -- Full-page sections (Dashboard, Login). NOT CLI-installable, docs only.
|
||||
1. **Components** (`registry/{style}/ui/`) — single primitives (Button, Card, Dialog). CLI-installable.
|
||||
2. **Examples** (`registry/{style}/example/`) — usage demos for docs via `<ComponentPreview />`.
|
||||
3. **Blocks** (`registry/{style}/blocks/`) — full-page sections (Dashboard, Login). NOT CLI-installable, docs only.
|
||||
|
||||
## Import Path Transformation
|
||||
|
||||
@@ -94,37 +187,25 @@ React 19, Next.js 15.3+, Tailwind CSS 4 (OKLCH colors), Radix UI, Turborepo + pn
|
||||
|
||||
## Upstream Sync
|
||||
|
||||
Remote `shadcn` points to `/Users/z/work/shadcn/ui` (local clone of shadcn-ui/ui).
|
||||
hanzoai/ui is NOT a GitHub fork -- no shared object store, so large merges can fail on push.
|
||||
|
||||
Last sync: 2026-03-24 (shadcn@4.1.0, commit 8bec9c123)
|
||||
Remote `shadcn` points to a local clone of shadcn-ui/ui.
|
||||
hanzoai/ui is NOT a GitHub fork — no shared object store, so large merges can fail on push.
|
||||
Strategy: file-level checkout from shadcn/main for specific directories (not git merge).
|
||||
- Take theirs: packages/shadcn/, packages/tests/, apps/, templates/, scripts/, skills/
|
||||
- Keep ours: app/, pkg/, demo/, docs/, template/next/, pnpm-workspace.yaml, package.json
|
||||
- Remove: deprecated/ (upstream deleted it)
|
||||
- Regenerate: pnpm-lock.yaml after sync
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Page Builder** (`/builder`): Drag-drop block assembly with @dnd-kit, export to TSX
|
||||
- **Page Builder** (`/builder`): drag-drop block assembly with @dnd-kit, export to TSX
|
||||
- **White-Label**: Zoo/Lux forks via `brands/{BRAND}.brand.ts`
|
||||
- **External Registries**: 35+ sources in `app/registries.json`, install via `npx @hanzo/ui add @aceternity/spotlight`
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Registry index is `Index[style][name]`, NOT `Index[name]` -- caused silent block render failures
|
||||
- Shiki `getHighlighter` incompatible with static export -- replaced with basic pre/code
|
||||
- Registry index is `Index[style][name]`, NOT `Index[name]` — caused silent block render failures
|
||||
- Shiki `getHighlighter` incompatible with static export — replaced with basic pre/code
|
||||
- Some blocks (login-01, login-02, sidebar-02) have Server Component issues with event handlers
|
||||
- Zod validation removed from `_getAllBlocks()`/`_getBlockCode()` -- we control generation
|
||||
- Firebase split to optional `@hanzo/auth-firebase` package (Jan 2025)
|
||||
- `@hanzo/auth` v2.6.0 uses pluggable provider registry: `registerAuthProvider('firebase', FirebaseAuthService)`
|
||||
|
||||
## Component Stats
|
||||
|
||||
- 161 total files, ~127 implemented, ~34 stubs
|
||||
- Unique: 9 3D components, 12 AI components, 13 animation components, 15 nav variants
|
||||
- 3x more components than upstream shadcn/ui (161 vs 58)
|
||||
- shadcn CLI: v4.1.0 with font transformers, chart color picker, scaffold from github
|
||||
- `@hanzo/auth` v2.6.0 uses a pluggable provider registry: `registerAuthProvider('firebase', FirebaseAuthService)`
|
||||
|
||||
## Rules
|
||||
|
||||
|
||||
+24
-159
@@ -1,174 +1,39 @@
|
||||
# NPM Publishing Guide - React 19 Packages
|
||||
# Publishing
|
||||
|
||||
## Current Package Versions
|
||||
Two lanes, one trigger each. No changesets, no version-PR bot.
|
||||
|
||||
All packages updated to support **React 19.2.0**:
|
||||
## 1. `pkgs/*` — auto-publish on version bump (`publish.yml`)
|
||||
|
||||
- `@hanzo/ui` - v5.1.1
|
||||
- `@hanzo/auth` - Latest
|
||||
- `@hanzo/commerce` - Latest
|
||||
- `@hanzo/brand` - Latest
|
||||
- `@hanzo/react` - v1.0.0
|
||||
Bump a package's `version` in `pkgs/<name>/package.json` and merge to `main`.
|
||||
`.github/workflows/publish.yml` detects the changed public `@hanzo/*` package,
|
||||
builds it, and publishes to npm (repo secret `NPM_TOKEN`). Patch bumps only
|
||||
(`x.y.z` → `x.y.z+1`).
|
||||
|
||||
## Publishing Methods
|
||||
## 2. `pkg/ui` — `@hanzo/ui@8`, the v8 lane (maintainer flow)
|
||||
|
||||
### 1. Automatic Publishing (Tag-based)
|
||||
|
||||
When you push a git tag starting with `v` (typically matching @hanzo/ui version), the workflow automatically checks all packages and publishes any with new versions:
|
||||
`pkg/ui` (with `pkg/data`) is the modern cross-platform library on `@hanzo/gui`.
|
||||
It publishes from the package directory (`prepack` builds the `types/`):
|
||||
|
||||
```bash
|
||||
# Tag with @hanzo/ui version (workflow checks all packages)
|
||||
git tag v5.1.1
|
||||
git push origin v5.1.1
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
1. Tests run (pkg/ui and pkg/react)
|
||||
2. All 5 packages build
|
||||
3. **Automatic version detection:**
|
||||
- Checks each package's current version in package.json
|
||||
- Queries npm to see if that version already exists
|
||||
- Only publishes packages with new versions not on npm
|
||||
4. GitHub release created (only if packages were published)
|
||||
|
||||
**Example workflow output:**
|
||||
```
|
||||
📦 Checking @hanzo/ui@5.1.1
|
||||
⏭️ Already published - skipping
|
||||
|
||||
📦 Checking @hanzo/auth@2.5.5
|
||||
🚀 Publishing to npm...
|
||||
✅ Successfully published @hanzo/auth@2.5.5
|
||||
|
||||
📊 Publishing Summary
|
||||
✅ Published: 1 package(s)
|
||||
⏭️ Skipped: 4 package(s)
|
||||
```
|
||||
|
||||
This approach means you:
|
||||
- Only need to tag once (with @hanzo/ui version)
|
||||
- Don't need to track which packages need publishing
|
||||
- Can bump any package version and it auto-publishes on next tag
|
||||
- Similar to python-sdk monorepo publishing
|
||||
|
||||
**Workflow:** `.github/workflows/publish-on-tag.yml`
|
||||
|
||||
### 2. Manual Publishing (Workflow Dispatch)
|
||||
|
||||
Use GitHub Actions UI to manually publish specific packages:
|
||||
|
||||
1. Go to **Actions** → **NPM Publish**
|
||||
2. Click **Run workflow**
|
||||
3. Select package: `ui`, `auth`, `commerce`, `brand`, `react`, or `all`
|
||||
4. Select version bump: `patch`, `minor`, or `major`
|
||||
5. Click **Run workflow**
|
||||
|
||||
**What happens:**
|
||||
- Selected package(s) build
|
||||
- Version bumped automatically
|
||||
- Package(s) published to npm
|
||||
- PR created with version bump
|
||||
|
||||
**Workflow:** `.github/workflows/npm-publish.yml`
|
||||
|
||||
### 3. Local Publishing (Manual)
|
||||
|
||||
For quick patches or testing:
|
||||
|
||||
```bash
|
||||
# Build and test
|
||||
cd pkg/ui
|
||||
pnpm build
|
||||
pnpm test
|
||||
|
||||
# Bump version
|
||||
npm version patch # or minor/major
|
||||
|
||||
# Publish
|
||||
pnpm typecheck && pnpm test && pnpm build
|
||||
# bump "version" in package.json (patch), commit to main, then:
|
||||
npm publish --access public
|
||||
```
|
||||
|
||||
`@hanzo/ui-shadcn` (`pkgs/ui`) is the legacy v5 kit — existing consumers pin
|
||||
`@hanzo/ui-shadcn@^5`; it rides lane 1 like any other `pkgs/*` package.
|
||||
|
||||
> The old tag-driven flow (`publish-on-tag.yml`, `npm-publish.yml`, the
|
||||
> `pkg/commerce|brand|react` paths) is gone — do not tag to publish here.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### NPM Authentication Token
|
||||
- `NPM_TOKEN` repo secret (lane 1) / npm auth as a maintainer (lane 2)
|
||||
- Every package carries `"publishConfig": { "access": "public" }`
|
||||
|
||||
The GitHub secret `NPM_AUTH_TOKEN` must be set:
|
||||
## Checklist
|
||||
|
||||
1. Generate token at https://www.npmjs.com/settings/tokens
|
||||
2. Add to GitHub: Settings → Secrets → Actions → `NPM_AUTH_TOKEN`
|
||||
|
||||
### Package Publish Configuration
|
||||
|
||||
All packages already configured with:
|
||||
```json
|
||||
{
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## React 19 Compatibility
|
||||
|
||||
### Key Updates Made
|
||||
|
||||
1. **Type Declarations**: Added `hanzo-ui.d.ts` files for React 19 compatibility
|
||||
2. **Peer Dependencies**: Force React 19.2.0 via pnpm overrides
|
||||
3. **Test Environment**: Switched to happy-dom for better React 19 support
|
||||
4. **Build Configuration**: All packages build successfully with React 19
|
||||
|
||||
### Test Status
|
||||
|
||||
- **pkg/ui**: 206/207 tests passing (99.5%)
|
||||
- **pkg/react**: 10/10 tests passing (100%)
|
||||
|
||||
## Publishing Checklist
|
||||
|
||||
Before publishing:
|
||||
|
||||
- [ ] All packages build successfully: `pnpm build`
|
||||
- [ ] Tests pass: `pnpm test`
|
||||
- [ ] Types check: `cd app && pnpm typecheck`
|
||||
- [ ] Lint passes: `cd app && pnpm lint`
|
||||
- [ ] Update CHANGELOG.md
|
||||
- [ ] Update version in package.json (if manual)
|
||||
- [ ] Commit changes
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Build Failures
|
||||
|
||||
```bash
|
||||
# Clean install
|
||||
rm -rf node_modules pnpm-lock.yaml
|
||||
pnpm install
|
||||
|
||||
# Rebuild
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### Test Failures
|
||||
|
||||
```bash
|
||||
# Run specific package tests
|
||||
cd pkg/ui && pnpm test
|
||||
cd pkg/react && pnpm test
|
||||
```
|
||||
|
||||
### Publish Failures
|
||||
|
||||
- Check NPM_AUTH_TOKEN is valid
|
||||
- Ensure version is unique (not already published)
|
||||
- Verify package builds: `cd pkg/<name> && pnpm build`
|
||||
|
||||
## Package URLs
|
||||
|
||||
- npm: https://www.npmjs.com/org/hanzo
|
||||
- GitHub: https://github.com/hanzoai/ui
|
||||
- Docs: https://ui.hanzo.ai
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-10-05
|
||||
**React Version:** 19.2.0
|
||||
- [ ] `pnpm typecheck` + `pnpm test` green in the package
|
||||
- [ ] Patch version bump (check the last published patch first)
|
||||
- [ ] Commit to `main`; lane 1 publishes on merge, lane 2 via `npm publish`
|
||||
|
||||
@@ -1,37 +1,42 @@
|
||||
<p align="center"><img src=".github/hero.svg" alt="ui" width="880"></p>
|
||||
<p align="center"><img src=".github/hero.svg" alt="@hanzo/ui" width="880"></p>
|
||||
|
||||
# @hanzo/ui
|
||||
|
||||
Accessible and customizable components for React, Vue, Svelte, and React Native. **Built on shadcn/ui with multi-framework support, 3D components, AI components, and advanced features.**
|
||||
**The React component library for AI applications.** Accessible, customizable primitives for React, Vue, Svelte, and React Native — built on shadcn/ui, extended with AI, 3D, animation, and commerce components, and a single typed import surface.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/package/@hanzo/ui"><img src="https://img.shields.io/npm/v/@hanzo/ui?color=black&label=%40hanzo%2Fui" alt="npm"></a>
|
||||
<a href="./LICENSE.md"><img src="https://img.shields.io/badge/license-MIT-black" alt="MIT"></a>
|
||||
<a href="https://ui.hanzo.ai"><img src="https://img.shields.io/badge/docs-ui.hanzo.ai-black" alt="docs"></a>
|
||||
</p>
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- **161+ Components** - 3x more than shadcn/ui
|
||||
- **Multi-Framework** - React, Vue, Svelte, React Native
|
||||
- **Two Themes** - Default & New York variants
|
||||
- **AI Components** - Chat, assistants, playground
|
||||
- **3D Components** - Interactive 3D elements
|
||||
- **Animations** - Advanced motion components
|
||||
- **Page Builder** - Visual drag-drop interface
|
||||
- **White-Label** - Fork and rebrand easily
|
||||
- **Blocks** - 24+ production-ready templates
|
||||
- **Accessible** - Built with Radix UI primitives
|
||||
- **Customizable** - Tailwind CSS powered
|
||||
- **TypeScript** - Fully typed
|
||||
- **161+ components** — 3x the surface of upstream shadcn/ui
|
||||
- **Multi-framework** — React, Vue, Svelte, React Native
|
||||
- **Two themes** — Default & New York variants
|
||||
- **AI components** — chat, assistants, agent UI, playground
|
||||
- **3D components** — interactive 3D elements
|
||||
- **Animations** — advanced motion components
|
||||
- **Page builder** — visual drag-and-drop assembly, export to TSX
|
||||
- **Blocks** — 24+ production-ready full-page templates
|
||||
- **White-label** — fork and rebrand by domain (Zoo, Lux, …)
|
||||
- **Accessible** — built on Radix UI primitives
|
||||
- **Customizable** — Tailwind CSS 4 (OKLCH), fully typed TypeScript
|
||||
|
||||
## Quick Start
|
||||
## Quick start
|
||||
|
||||
### Installation
|
||||
### Install
|
||||
|
||||
```bash
|
||||
npm install @hanzo/ui
|
||||
# or
|
||||
pnpm add @hanzo/ui
|
||||
# or
|
||||
npm install @hanzo/ui
|
||||
```
|
||||
|
||||
### Usage
|
||||
### Use
|
||||
|
||||
```tsx
|
||||
import { Button, Card, Input } from '@hanzo/ui'
|
||||
@@ -53,23 +58,62 @@ export function App() {
|
||||
}
|
||||
```
|
||||
|
||||
## Documentation
|
||||
## One import surface (v8)
|
||||
|
||||
Visit **[ui.hanzo.ai](https://ui.hanzo.ai)** for full docs.
|
||||
`@hanzo/ui@8` is the single entry point for the whole kit. Each capability is a
|
||||
thin subpath that re-exports its home package — code lives once, and each home is
|
||||
an optional peer, pulled only when you use its subpath.
|
||||
|
||||
| Import | What you get |
|
||||
|---|---|
|
||||
| `@hanzo/ui` · `/product` | charts, metrics, PageHeader, StatusTag, EmptyState, ComboBox, SlideOver, Toast |
|
||||
| `@hanzo/ui/data` | RecordsView, DataTable, typed field editors |
|
||||
| `@hanzo/ui/canvas` | ProjectCanvas, ServiceNode, DeployTimeline, EnvSwitcher |
|
||||
| `@hanzo/ui/wallet` | WalletMenu, EIP-1193 adapter, network helpers |
|
||||
| `@hanzo/ui/network` | NetworkSwitcher, useNetwork, configureNetworks |
|
||||
| `@hanzo/ui/billing` | CreditModal |
|
||||
| `@hanzo/ui/dashboard` | landing + deploy-pipeline + overview kit |
|
||||
| `@hanzo/ui/usage` | UsageMeter, UsageProviderCard, UsageDashboard |
|
||||
| `@hanzo/ui/gitops` | GitopsAppList, tree, diff, sync/rollback, HealthBadge |
|
||||
|
||||
Also available as granular imports:
|
||||
|
||||
```ts
|
||||
import { Button, Card } from '@hanzo/ui/components'
|
||||
import * as Dialog from '@hanzo/ui/primitives/dialog'
|
||||
import { cn } from '@hanzo/ui/lib/utils'
|
||||
```
|
||||
|
||||
## CLI
|
||||
|
||||
Add components straight into your project — the CLI copies source you own:
|
||||
|
||||
```bash
|
||||
npx @hanzo/ui add button
|
||||
npx @hanzo/ui add card dialog
|
||||
```
|
||||
|
||||
Install from 35+ external registries too:
|
||||
|
||||
```bash
|
||||
npx @hanzo/ui add @aceternity/spotlight
|
||||
```
|
||||
|
||||
## Packages
|
||||
|
||||
- `@hanzo/ui` - Main UI library (161 components)
|
||||
- `@hanzo/auth` - Authentication components
|
||||
- `@hanzo/commerce` - E-commerce components
|
||||
- `@hanzo/brand` - Branding system
|
||||
The workspace publishes a family of scoped packages under `@hanzo/*`:
|
||||
|
||||
| Package | Purpose |
|
||||
|---|---|
|
||||
| `@hanzo/ui` | Core library + the v8 import surface (161+ components) |
|
||||
| `@hanzo/react` | React primitives |
|
||||
| `@hanzo/data` | Records, data tables, typed field editors |
|
||||
| `@hanzo/canvas` | Service/deploy canvas components |
|
||||
| `@hanzo/dashboard` | Dashboard + deploy-pipeline kit |
|
||||
| `@hanzo/commerce` · `@hanzo/checkout` · `@hanzo/shop` | Commerce components |
|
||||
| `@hanzo/agent-ui` | AI agent UI components |
|
||||
| `@hanzo/brand` · `@hanzo/tokens` | Branding system & design tokens |
|
||||
| `@hanzo/event` | Telemetry client (`POST /v1/event`) |
|
||||
|
||||
## Development
|
||||
|
||||
@@ -77,17 +121,39 @@ npx @hanzo/ui add card dialog
|
||||
git clone https://github.com/hanzoai/ui.git
|
||||
cd ui
|
||||
pnpm install
|
||||
pnpm dev
|
||||
|
||||
pnpm build:registry # generate the component registry FIRST
|
||||
pnpm dev # docs site + registry (http://localhost:3003)
|
||||
```
|
||||
|
||||
> The registry generates the JSON the CLI reads, so `build:registry` must run
|
||||
> before `build`. Keep the Default and New York themes in sync when adding
|
||||
> components. Use pnpm — not npm or yarn.
|
||||
|
||||
```bash
|
||||
pnpm build # build the docs app
|
||||
pnpm lint # lint all workspaces
|
||||
pnpm typecheck # type check
|
||||
pnpm test # unit tests
|
||||
pnpm test:e2e # Playwright E2E
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
Full docs, live previews, and the component catalog: **[ui.hanzo.ai](https://ui.hanzo.ai)**.
|
||||
|
||||
## Contributing
|
||||
|
||||
Please read the [contributing guide](/CONTRIBUTING.md).
|
||||
See the [contributing guide](/CONTRIBUTING.md).
|
||||
|
||||
## License
|
||||
|
||||
MIT - See [LICENSE.md](./LICENSE.md) for details.
|
||||
MIT — see [LICENSE.md](./LICENSE.md).
|
||||
|
||||
---
|
||||
|
||||
Built by [Hanzo](https://hanzo.ai)
|
||||
## Hanzo — the Open AI Cloud
|
||||
|
||||
Open source · every language · on-chain settlement. [hanzo.ai](https://hanzo.ai) · [docs.hanzo.ai](https://docs.hanzo.ai)
|
||||
|
||||
**SDKs in every language** — [Python](https://github.com/hanzoai/python-sdk) (flagship) · [TypeScript](https://github.com/hanzo-js/sdk) · [Go](https://github.com/hanzo-go/sdk) · [Rust](https://github.com/hanzo-rs/sdk) · [C++](https://github.com/hanzo-cpp/sdk) · [Swift](https://github.com/hanzo-swift/sdk) · [Kotlin](https://github.com/hanzo-kt/sdk) · [umbrella](https://github.com/hanzoai/sdk)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { ConnectButton } from "@rainbow-me/rainbowkit"
|
||||
import { formatEther } from "viem"
|
||||
import {
|
||||
useAccount,
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
useWriteContract,
|
||||
} from "wagmi"
|
||||
|
||||
import { ConnectWallet } from "@/components/connect-wallet"
|
||||
import {
|
||||
AI_TOKEN_ABI,
|
||||
CONTRACT_ADDRESSES,
|
||||
@@ -208,7 +208,7 @@ export function IdentityForm() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ConnectButton />
|
||||
<ConnectWallet />
|
||||
|
||||
{isConnected && aiBalance !== undefined && (
|
||||
<div className="mt-4">
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
"use client"
|
||||
|
||||
import { Analytics as VercelAnalytics } from "@vercel/analytics/react"
|
||||
import { useEffect, useRef } from "react"
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
import { analytics } from "@/lib/analytics"
|
||||
|
||||
export function Analytics() {
|
||||
return <VercelAnalytics />
|
||||
const pathname = usePathname()
|
||||
const started = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!started.current) {
|
||||
started.current = true
|
||||
analytics.init()
|
||||
}
|
||||
analytics.pageview(pathname ?? undefined)
|
||||
}, [pathname])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client"
|
||||
|
||||
import { useAccount, useConnect, useDisconnect } from "wagmi"
|
||||
|
||||
import { Button } from "@/registry/default/ui/button"
|
||||
|
||||
const short = (address: string) => `${address.slice(0, 6)}…${address.slice(-4)}`
|
||||
|
||||
/**
|
||||
* Connect the wallet the browser already has, over EIP-1193. There is no
|
||||
* third-party modal and no bridge service in the page — the extension is the
|
||||
* only party involved.
|
||||
*/
|
||||
export function ConnectWallet() {
|
||||
const { address, isConnected } = useAccount()
|
||||
const { connect, connectors, isPending } = useConnect()
|
||||
const { disconnect } = useDisconnect()
|
||||
|
||||
if (isConnected && address) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-sm">{short(address)}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => disconnect()}>
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const injected = connectors[0]
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() => injected && connect({ connector: injected })}
|
||||
disabled={!injected || isPending}
|
||||
>
|
||||
{isPending ? "Connecting…" : "Connect Wallet"}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +1,18 @@
|
||||
"use client"
|
||||
|
||||
import "@rainbow-me/rainbowkit/styles.css"
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react"
|
||||
import { RainbowKitProvider } from "@rainbow-me/rainbowkit"
|
||||
import { type ReactNode } from "react"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { WagmiProvider, type Config } from "wagmi"
|
||||
import { WagmiProvider } from "wagmi"
|
||||
|
||||
import { getConfig } from "@/lib/wagmi"
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
const config = getConfig()
|
||||
|
||||
export function Web3Provider({ children }: { children: ReactNode }) {
|
||||
const [config, setConfig] = useState<Config | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Dynamically import wagmi config only on client side
|
||||
import("@/lib/wagmi").then((mod) => {
|
||||
setConfig(mod.getConfig())
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Don't render until config is loaded
|
||||
if (!config) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<WagmiProvider config={config}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RainbowKitProvider modalSize="compact">{children}</RainbowKitProvider>
|
||||
</QueryClientProvider>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</WagmiProvider>
|
||||
)
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,18 @@
|
||||
import { createAnalytics } from "@hanzo/event"
|
||||
|
||||
// One client for the whole site. Pageviews (components/analytics.tsx) and
|
||||
// product events (lib/events.ts) share it, so there is a single place that
|
||||
// knows where telemetry goes — call sites only name what happened. Host
|
||||
// defaults to the one edge (api.hanzo.ai), which is what a static site wants.
|
||||
//
|
||||
// /v1/event is authed, and deliberately does not trust the request Host — a Host
|
||||
// header is spoofable, so a static page proves which org it belongs to by
|
||||
// carrying a publishable key. `pk_` keys are write-only and HMAC-verified with no
|
||||
// database hop, which is what makes them safe to ship inside a public bundle (the
|
||||
// same reason a Sentry DSN is public). The build supplies it, so each deployment
|
||||
// reports as the org that deployed it; without one the client stays inert rather
|
||||
// than posting events that would only be rejected.
|
||||
export const analytics = createAnalytics({
|
||||
product: "site",
|
||||
ingestKey: process.env.NEXT_PUBLIC_HANZO_INGEST_KEY,
|
||||
})
|
||||
+28
-7
@@ -165,22 +165,42 @@ export const AI_TOKEN_ABI = [
|
||||
// Contract addresses by chain ID
|
||||
export const CONTRACT_ADDRESSES: Record<
|
||||
number,
|
||||
{ registry: `0x${string}`; token: `0x${string}` }
|
||||
{
|
||||
registry: `0x${string}`;
|
||||
token: `0x${string}`;
|
||||
chainConfig?: `0x${string}`;
|
||||
husd?: `0x${string}`;
|
||||
faucet?: `0x${string}`;
|
||||
}
|
||||
> = {
|
||||
// Local Testnet (31337)
|
||||
31337: {
|
||||
registry: "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0",
|
||||
token: "0x5FbDB2315678afecb367f032d93F642f64180aa3",
|
||||
},
|
||||
// Hanzo Mainnet (36963)
|
||||
// Hanzo Mainnet (36963) — sovereign L1, deployed 2026-06
|
||||
36963: {
|
||||
registry: "0x0000000000000000000000000000000000000000", // Deploy and update
|
||||
token: "0x0000000000000000000000000000000000000000", // Deploy and update
|
||||
registry: "0xf3df584A4a996b5D215E740B2240886d42C7307a",
|
||||
token: "0x799586e3637E68250449e840F22F8a1a01d6E934", // AIToken (AI)
|
||||
chainConfig: "0x25C806e07bA1c7B5c3495a8C57E6b8fd346092E1",
|
||||
husd: "0xe9e32EF8aaECB68794Da3E1E9191b0a64CeC2c83", // HUSD (LUSD)
|
||||
faucet: "0xd27d8049A575A63b54aAbbC4C41dBa5963cedF56",
|
||||
},
|
||||
// Hanzo Testnet (36962)
|
||||
// Hanzo Testnet (36962) — sovereign L1, deployed 2026-06
|
||||
36962: {
|
||||
registry: "0x0000000000000000000000000000000000000000", // Deploy and update
|
||||
token: "0x0000000000000000000000000000000000000000", // Deploy and update
|
||||
registry: "0x6EA9D7C669DAC51830219ff5d4391872a25AB147",
|
||||
token: "0x9Adf4583DDB3aFF5fA08a6788fc203e9d9908F4F", // AIToken (AI)
|
||||
chainConfig: "0x6162A52F71a1C8F0F1F86FE8D17d6DDedaEdaC3c",
|
||||
husd: "0xc57b7eCE2Ce2E74ef3Bc08Cfd5f5Fb41B6Ad4D66", // HUSD (LUSD)
|
||||
faucet: "0x88810C4F376aF0018641e98Fcc06f0b7Ba529937",
|
||||
},
|
||||
// Hanzo Devnet (36964) — sovereign L1, deployed 2026-06
|
||||
36964: {
|
||||
registry: "0xDeA8179dEc51eA55E03fbe257d51c6d0f5908E3F",
|
||||
token: "0x486809dD1bac9A17f18a1a640cdEf014C7DD809a", // AIToken (AI)
|
||||
chainConfig: "0xf911b6e4952781949Db84B478582Ca05817fECB4",
|
||||
husd: "0xBf92c933774daDF112159Be4b29e6BDc3ffAa2B1", // HUSD (LUSD)
|
||||
faucet: "0x0B2B0BF9f423C03151480e1bB63caCd4cB3B6343",
|
||||
},
|
||||
// Lux Mainnet (96369)
|
||||
96369: {
|
||||
@@ -208,6 +228,7 @@ export const NETWORK_NAMES: Record<number, string> = {
|
||||
31337: "localhost",
|
||||
36963: "hanzo",
|
||||
36962: "hanzo-testnet",
|
||||
36964: "hanzo-devnet",
|
||||
96369: "lux",
|
||||
96368: "lux-testnet",
|
||||
200200: "zoo",
|
||||
|
||||
+3
-7
@@ -1,6 +1,7 @@
|
||||
import va from "@vercel/analytics"
|
||||
import { z } from "zod"
|
||||
|
||||
import { analytics } from "@/lib/analytics"
|
||||
|
||||
const eventSchema = z.object({
|
||||
name: z.enum([
|
||||
"copy_npm_command",
|
||||
@@ -27,11 +28,6 @@ export type Event = z.infer<typeof eventSchema>
|
||||
export function trackEvent(input: Event): void {
|
||||
const event = eventSchema.parse(input)
|
||||
if (event) {
|
||||
va.track(
|
||||
event.name,
|
||||
event.properties as
|
||||
| Record<string, string | number | boolean | null>
|
||||
| undefined
|
||||
)
|
||||
analytics.capture(event.name, event.properties)
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -1,6 +1,17 @@
|
||||
import { GeistMono } from "geist/font/mono"
|
||||
import { GeistSans } from "geist/font/sans"
|
||||
import localFont from "next/font/local"
|
||||
|
||||
export const fontSans = GeistSans
|
||||
// Canonical Hanzo typography (single source of truth; see DESIGN.md):
|
||||
// - UI / body / display / heading -> Basel Grotesk (self-hosted, Book 400 + Medium 500)
|
||||
// - code / data / mono -> Geist Mono
|
||||
// Basel replaces Geist Sans / DM Sans / Figtree / Inter as the default sans.
|
||||
export const fontSans = localFont({
|
||||
src: [
|
||||
{ path: "../fonts/Basel-Grotesk-Book.woff2", weight: "400", style: "normal" },
|
||||
{ path: "../fonts/Basel-Grotesk-Medium.woff2", weight: "500", style: "normal" },
|
||||
],
|
||||
variable: "--font-basel-sans",
|
||||
display: "swap",
|
||||
})
|
||||
|
||||
export const fontMono = GeistMono
|
||||
|
||||
@@ -3,10 +3,9 @@ import { promises as fs } from "fs"
|
||||
import path from "path"
|
||||
import type { Highlighter } from "shiki"
|
||||
|
||||
// Enable syntax highlighting in development
|
||||
// For static exports, we can use client-side highlighting instead
|
||||
const highlightCodeEnabled =
|
||||
process.env.NODE_ENV === "development" || !process.env.GITHUB_ACTIONS
|
||||
// Highlight on the server while developing. A production build is a static
|
||||
// export, which highlights on the client instead — so the build stays cheap.
|
||||
const highlightCodeEnabled = process.env.NODE_ENV === "development"
|
||||
|
||||
// Singleton highlighter instance to prevent memory leaks
|
||||
let highlighterInstance: Highlighter | null = null
|
||||
|
||||
+24
-25
@@ -1,7 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { getDefaultConfig } from "@rainbow-me/rainbowkit"
|
||||
import { defineChain } from "viem"
|
||||
import { createConfig, http } from "wagmi"
|
||||
import { injected } from "wagmi/connectors"
|
||||
|
||||
// Localhost (Anvil)
|
||||
export const localhost = defineChain({
|
||||
@@ -155,28 +156,30 @@ export const zooTestnet = defineChain({
|
||||
testnet: true,
|
||||
})
|
||||
|
||||
let _config: ReturnType<typeof getDefaultConfig> | undefined
|
||||
const chains = [
|
||||
localhost,
|
||||
hanzoMainnet,
|
||||
hanzoTestnet,
|
||||
luxMainnet,
|
||||
luxTestnet,
|
||||
zooMainnet,
|
||||
zooTestnet,
|
||||
] as const
|
||||
|
||||
export const getConfig = () => {
|
||||
if (typeof window === "undefined") {
|
||||
// Return a minimal config for SSR that won't be used
|
||||
return {} as ReturnType<typeof getDefaultConfig>
|
||||
}
|
||||
type Config = ReturnType<typeof createConfig>
|
||||
|
||||
let _config: Config | undefined
|
||||
|
||||
export const getConfig = (): Config => {
|
||||
if (!_config) {
|
||||
_config = getDefaultConfig({
|
||||
appName: "Hanzo Identity",
|
||||
projectId:
|
||||
process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || "YOUR_PROJECT_ID",
|
||||
chains: [
|
||||
localhost,
|
||||
hanzoMainnet,
|
||||
hanzoTestnet,
|
||||
luxMainnet,
|
||||
luxTestnet,
|
||||
zooMainnet,
|
||||
zooTestnet,
|
||||
],
|
||||
_config = createConfig({
|
||||
chains,
|
||||
// The browser wallet speaks EIP-1193 directly. No third-party bridge, no
|
||||
// vendor project id, and nothing phoning home from a docs page.
|
||||
connectors: [injected()],
|
||||
transports: Object.fromEntries(
|
||||
chains.map((chain) => [chain.id, http()])
|
||||
) as Record<(typeof chains)[number]["id"], ReturnType<typeof http>>,
|
||||
ssr: true,
|
||||
})
|
||||
}
|
||||
@@ -184,8 +187,4 @@ export const getConfig = () => {
|
||||
return _config
|
||||
}
|
||||
|
||||
// For backward compatibility
|
||||
export const config =
|
||||
typeof window !== "undefined"
|
||||
? getConfig()
|
||||
: ({} as ReturnType<typeof getDefaultConfig>)
|
||||
export const config = getConfig()
|
||||
|
||||
+4
-6
@@ -5,15 +5,13 @@ const nextConfig = {
|
||||
// Transpile packages that might have issues with pnpm symlinks
|
||||
transpilePackages: ["chrono-node", "@hanzo/ui"],
|
||||
|
||||
// Enable static export for GitHub Pages / Cloudflare Pages deployment (but not for E2E tests)
|
||||
output: (process.env.GITHUB_ACTIONS || process.env.CF_PAGES) && !process.env.E2E_TEST ? "export" : undefined,
|
||||
// The docs site is a static export, wherever it is built. It is served by
|
||||
// hanzoai/static; `next dev` is unaffected by this.
|
||||
output: "export",
|
||||
|
||||
// Use trailing slashes for GitHub Pages compatibility
|
||||
// Directory-style URLs, so a static server resolves /docs/button/index.html.
|
||||
trailingSlash: true,
|
||||
|
||||
// Base path for GitHub Pages (when deployed to github.io subdirectory)
|
||||
basePath: process.env.GITHUB_PAGES ? "/react-sdk" : "",
|
||||
|
||||
// Asset prefix for proper loading on custom domain
|
||||
assetPrefix: process.env.NEXT_PUBLIC_APP_URL || "",
|
||||
|
||||
|
||||
+1
-2
@@ -31,6 +31,7 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@faker-js/faker": "^10.4.0",
|
||||
"@hanzo/event": "^0.3.1",
|
||||
"@hanzo/docs-core": "16.5.3",
|
||||
"@hanzo/docs-docgen": "3.0.7",
|
||||
"@hanzo/docs-mdx": "14.3.0",
|
||||
@@ -70,7 +71,6 @@
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@rainbow-me/rainbowkit": "^2.2.10",
|
||||
"@rc-component/color-picker": "^3.1.1",
|
||||
"@tabler/icons-react": "^3.40.0",
|
||||
"@tanstack/react-query": "^5.94.5",
|
||||
@@ -79,7 +79,6 @@
|
||||
"@tiptap/react": "^3.20.4",
|
||||
"@tiptap/starter-kit": "^3.20.4",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vercel/analytics": "^2.0.1",
|
||||
"@vercel/og": "^0.11.1",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"chrono-node": "^2.9.0",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,14 +0,0 @@
|
||||
import { NextApiRequest, NextApiResponse } from "next"
|
||||
|
||||
import components from "./components.json"
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).end()
|
||||
}
|
||||
|
||||
return res.status(200).json(components)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
ui.hanzo.ai
|
||||
@@ -23,7 +23,7 @@ const config = {
|
||||
},
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ["var(--font-geist-sans)", ...fontFamily.sans],
|
||||
sans: ["var(--font-basel-sans)", ...fontFamily.sans],
|
||||
mono: ["var(--font-geist-mono)", ...fontFamily.mono],
|
||||
},
|
||||
colors: {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
e2e-shots
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,78 @@
|
||||
# Hanzo CD — the dedicated deploy dashboard (cd.hanzo.ai)
|
||||
|
||||
A focused, mobile-first CD dashboard served at **cd.hanzo.ai**. It replaces the
|
||||
ArgoCD React fork (`~/work/hanzo/deploy/ui`, webpack/`argo-cd-ui`) with the shared
|
||||
Hanzo component packages over the native cloud CD plane. Its whole job: **see every
|
||||
service deployed as an operator App CR — stats · sync · health · resource tree ·
|
||||
logs · sync/rollback.**
|
||||
|
||||
## Stack + why
|
||||
|
||||
- **Vite + React 19 static SPA** → `dist/` (a static bundle, no runtime server).
|
||||
- **`@hanzo/gitops`** (workspace) is the ArgoCD-replacement UI — a clean-room,
|
||||
framework-free port (plain React + scoped CSS, no Tamagui/Tailwind). We mount
|
||||
`GitopsAppList` (the fleet), and compose `GitopsSyncPanel` + `GitopsAppTree` +
|
||||
`GitopsNodeInfo` + `GitopsRollbackDialog` for the app detail (lazy per-node
|
||||
`/resource` + `/logs` fetch).
|
||||
- **`@hanzo/canvas/pure`** (workspace) for the pure helpers.
|
||||
- The workspace packages are resolved by **Vite path alias** (`vite.config.ts`),
|
||||
so the app builds off `@hanzo/gitops/dist` + `@hanzo/canvas/src` **without a full
|
||||
monorepo install** (the shared pnpm store lacks the registry deps of the heavier
|
||||
packages). Run `pnpm --filter @hanzo/gitops build` once if its `dist` is stale.
|
||||
|
||||
## Backend + auth (the contract this preserves)
|
||||
|
||||
- **API = the cloud binary at `/v1/deploy`** (SuperAdmin-gated, clean paths —
|
||||
`applications`, `:name/tree`, `:name/resource/:ref`, `:name/logs`, `sync`,
|
||||
`rollback`). The cd.hanzo.ai ingress peels `/v1/deploy/*` off to `cloud:8000`;
|
||||
the SPA calls it **same-origin** with `credentials: 'include'`.
|
||||
- **Auth = the `admin-console` PKCE gate** (`public/login.html`, ported verbatim
|
||||
from the ArgoCD fork — self-contained, proven): `hanzo.id` → sets the non-httpOnly
|
||||
`hanzo_iam_token` cookie the cloud binary validates (`SanitizeIdentity` →
|
||||
`c.IsAdmin()`). The SPA reads that cookie for its gate; a missing/expired session
|
||||
(or an API 401/403) lands on the sign-in screen — never a fabricated row.
|
||||
- `src/lib/adapt.ts` maps the `/v1/deploy` DTOs INTO the shared `@hanzo/gitops`
|
||||
view-models (reusing the package's own `foldHealth`/`foldSync`). Note: the cloud
|
||||
wire uses the hyphenated `out-of-sync`, which `foldSync` (whitespace-only strip)
|
||||
reads as Unknown — the adapter strips `[-_]` before folding.
|
||||
|
||||
## Build + serve
|
||||
|
||||
```bash
|
||||
pnpm --filter cd build # tsc -b && vite build → dist/ (index.html + login.html + CNAME + assets)
|
||||
pnpm --filter cd preview # serve dist locally
|
||||
pnpm --filter cd dev # vite dev (proxies /v1 → CD_API, default https://cd.hanzo.ai)
|
||||
```
|
||||
|
||||
Serving: the built `dist/` is a static bundle published to **`s3://cdn/cd`** — the
|
||||
existing static plane cd.hanzo.ai already serves (ingress `staticFiles` +
|
||||
`spaMode`, zero pods; see `universe/infra/k8s/operator/crs/static-sites.yaml`). No
|
||||
ingress or backend change: publish the bundle, retire the old ArgoCD `ui/` + the
|
||||
`deploy/dashboard.go` embed.
|
||||
|
||||
## Verify
|
||||
|
||||
- `tsc --noEmit -p tsconfig.app.json` — clean.
|
||||
- `npx vitest run src/lib/adapt.test.ts` — the `/v1/deploy` → `@hanzo/gitops`
|
||||
mapping (incl. the `out-of-sync` fold fix, `parentRefs` tree linking, clean-semver
|
||||
rollback).
|
||||
- `npx playwright test` — builds + serves + proves the fleet renders, a row opens
|
||||
the ArgoCD-grade detail (sync panel + resource topology), and **no horizontal body
|
||||
scroll at 390px**. Screenshots in `e2e-shots/`.
|
||||
|
||||
## Follow-ons (registry-install / routing gated — flagged, not fabricated)
|
||||
|
||||
- **`@hanzo/canvas` fleet MAP** (the Railway board) needs `@hanzo/gui` (Tamagui),
|
||||
which is an external npm dep not in the offline store here — so the map is a
|
||||
registry-install follow-on. It already ships green in the console
|
||||
(`hanzoai/console` `feat/cd-canvas-map`); lifting it in is an import once
|
||||
`@hanzo/gui` installs.
|
||||
- **`@hanzo/ui-shadcn` shared shell** (`HanzoHeader` + org/project switcher + ⌘K)
|
||||
needs a full monorepo install (its Tailwind deps aren't in the offline store).
|
||||
This app ships a lean self-contained topbar (Geist, small mark, env scope) in the
|
||||
interim; swap to the shared shell once installed. The **org/project↔IAM switcher**
|
||||
additionally needs `/v1/iam` routed on cd.hanzo.ai (today the SuperAdmin cookie
|
||||
sees the whole fleet; env is the scope control).
|
||||
- **Rollback targets** come from the app's `revisions` (forward-compat — the plane
|
||||
doesn't expose prior tags yet; empty → the dialog's honest "no history"). Cloud
|
||||
re-validates the clean semver.
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* e2e: Hanzo CD — mocked-network render + RESPONSIVE proof.
|
||||
*
|
||||
* Serves the built static SPA; mocks the cloud CD plane (`/v1/deploy/*`) with
|
||||
* real-shaped `clients/deploy` rows + a session cookie so the app renders the
|
||||
* fleet. Proves: the fleet renders on the shared @hanzo/gitops `GitopsAppList`, a
|
||||
* row opens the ArgoCD-grade detail (sync panel + resource topology), and — the
|
||||
* CTO requirement — the body never scrolls horizontally at a 390px viewport.
|
||||
* Screenshots at desktop (1440) and mobile (390).
|
||||
*/
|
||||
import { test, expect, type Route, type Page, type BrowserContext } from "@playwright/test"
|
||||
import { mkdirSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
const SHOTS = join(process.cwd(), "e2e-shots")
|
||||
|
||||
const FLEET = {
|
||||
applications: [
|
||||
{ name: "cloud", namespace: "hanzo", env: "main", repository: "ghcr.io/hanzoai/cloud", version: "v1.800.1", runningVersion: "v1.800.1", health: "healthy", sync: "synced", phase: "Running" },
|
||||
{ name: "iam", namespace: "hanzo", env: "main", repository: "ghcr.io/hanzoai/iam", version: "v1.4.11", runningVersion: "v1.4.10", health: "progressing", healthMessage: "rolling update (1/2)", sync: "out-of-sync", phase: "Running", revisions: ["v1.4.10", "v1.4.9"] },
|
||||
{ name: "gateway", namespace: "hanzo", env: "main", repository: "ghcr.io/hanzoai/gateway", version: "v2.16.4", runningVersion: "v2.16.4", health: "healthy", sync: "synced", phase: "Running" },
|
||||
{ name: "o11y", namespace: "hanzo", env: "test", repository: "ghcr.io/hanzoai/o11y", version: "v1.5.12", runningVersion: "v1.5.10", health: "degraded", healthMessage: "CrashLoopBackOff", sync: "out-of-sync", phase: "Degraded" },
|
||||
],
|
||||
summary: { total: 4, healthy: 2, degraded: 1, outOfSync: 2 },
|
||||
}
|
||||
const TREE = {
|
||||
application: FLEET.applications[1],
|
||||
nodes: [
|
||||
{ group: "hanzo.ai", version: "v1", kind: "App", namespace: "hanzo", name: "iam", ref: "hanzo.ai:App:hanzo:iam", uid: "u1", health: "progressing", parentRefs: [] },
|
||||
{ group: "apps", version: "v1", kind: "Deployment", namespace: "hanzo", name: "iam", ref: "apps:Deployment:hanzo:iam", uid: "u2", health: "progressing", parentRefs: [{ ref: "hanzo.ai:App:hanzo:iam" }] },
|
||||
{ group: "apps", version: "v1", kind: "ReplicaSet", namespace: "hanzo", name: "iam-6d8f", ref: "apps:ReplicaSet:hanzo:iam-6d8f", uid: "u3", health: "healthy", parentRefs: [{ ref: "apps:Deployment:hanzo:iam" }] },
|
||||
{ group: "", version: "v1", kind: "Pod", namespace: "hanzo", name: "iam-6d8f-abc", ref: ":Pod:hanzo:iam-6d8f-abc", uid: "u4", health: "healthy", parentRefs: [{ ref: "apps:ReplicaSet:hanzo:iam-6d8f" }] },
|
||||
],
|
||||
}
|
||||
const RESOURCE = { ref: "apps:Deployment:hanzo:iam", health: "healthy", liveManifest: { apiVersion: "apps/v1", kind: "Deployment", metadata: { name: "iam" }, spec: { replicas: 2 } }, desiredSource: "last-applied", diff: { modified: false } }
|
||||
const LOGS = { application: "hanzo/iam", pod: "iam-6d8f-abc", logs: "listening on :8080\nready to serve\n" }
|
||||
|
||||
async function mock(route: Route) {
|
||||
const p = new URL(route.request().url()).pathname
|
||||
const json = (b: unknown) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(b) })
|
||||
if (p === "/v1/deploy/applications") return json(FLEET)
|
||||
if (/^\/v1\/deploy\/[^/]+\/tree$/.test(p)) return json(TREE)
|
||||
if (/^\/v1\/deploy\/[^/]+\/resource\//.test(p)) return json(RESOURCE)
|
||||
if (/^\/v1\/deploy\/[^/]+\/logs$/.test(p)) return json(LOGS)
|
||||
if (p.startsWith("/v1/deploy/")) return json({ ok: true })
|
||||
return route.continue()
|
||||
}
|
||||
|
||||
async function open(ctx: BrowserContext): Promise<Page> {
|
||||
// The PKCE login sets `hanzo_iam_token`; seed it so the SPA renders the dashboard.
|
||||
await ctx.addCookies([{ name: "hanzo_iam_token", value: "e.y.j", url: "http://localhost:4173" }])
|
||||
const page = await ctx.newPage()
|
||||
await page.route("**/*", mock)
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" })
|
||||
await page.locator("text=Fleet").first().waitFor({ timeout: 15_000 })
|
||||
return page
|
||||
}
|
||||
|
||||
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
|
||||
|
||||
test("renders the fleet, opens a row → ArgoCD-grade detail (desktop)", async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await open(ctx)
|
||||
|
||||
await expect(page.locator("text=Applications").first()).toBeVisible()
|
||||
await expect(page.locator("tr", { hasText: "iam" }).first()).toBeVisible()
|
||||
// sync fold: the hyphenated cloud verdict renders OutOfSync (not Unknown).
|
||||
await expect(page.locator("text=OutOfSync").first()).toBeVisible()
|
||||
await page.screenshot({ path: join(SHOTS, "cd-fleet-desktop.png"), fullPage: true })
|
||||
|
||||
// Tap the app's NAME cell (always visible; the row overflows on narrow tables).
|
||||
await page.locator(".hz-gitops-row", { hasText: "iam" }).first().locator("td").first().click()
|
||||
await expect(page.getByText("← Fleet").first()).toBeVisible({ timeout: 10_000 }) // detail-only breadcrumb
|
||||
await expect(page.locator(".hz-gitops-tree-world").first()).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.locator("text=Deployment").first()).toBeVisible()
|
||||
await page.screenshot({ path: join(SHOTS, "cd-detail-desktop.png"), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test("reflows with no horizontal body scroll at a narrow (mobile) viewport", async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
|
||||
const page = await open(ctx)
|
||||
|
||||
await expect(page.locator("tr", { hasText: "iam" }).first()).toBeVisible()
|
||||
const overflow = await page.evaluate(() => ({ sw: document.documentElement.scrollWidth, cw: document.documentElement.clientWidth }))
|
||||
expect(overflow.sw, "no horizontal body scroll at 390px").toBeLessThanOrEqual(overflow.cw + 1)
|
||||
await page.screenshot({ path: join(SHOTS, "cd-fleet-mobile.png"), fullPage: true })
|
||||
|
||||
// Tap-to-open must work on mobile: tap the name cell → the ArgoCD-grade detail.
|
||||
await page.locator(".hz-gitops-row", { hasText: "iam" }).first().locator("td").first().click()
|
||||
await expect(page.getByText("← Fleet").first()).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.locator(".hz-gitops-tree-world").first()).toBeVisible({ timeout: 10_000 })
|
||||
const overflow2 = await page.evaluate(() => ({ sw: document.documentElement.scrollWidth, cw: document.documentElement.clientWidth }))
|
||||
expect(overflow2.sw, "no horizontal body scroll on the detail at 390px").toBeLessThanOrEqual(overflow2.cw + 1)
|
||||
await page.screenshot({ path: join(SHOTS, "cd-detail-mobile.png"), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>Hanzo CD</title>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/svg+xml"
|
||||
href="data:image/svg+xml,%3Csvg viewBox='0 0 64 64' xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='64' height='64' rx='8' fill='%23000'/%3E%3Cg transform='translate(8,8) scale(0.716)'%3E%3Cpath d='M22.21 67V44.6369H0V67H22.21Z' fill='%23fff'/%3E%3Cpath d='M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z' fill='%23fff'/%3E%3Cpath d='M22.21 0H0V22.3184H22.21V0Z' fill='%23fff'/%3E%3Cpath d='M66.7198 0H44.5098V22.3184H66.7198V0Z' fill='%23fff'/%3E%3Cpath d='M66.7198 67V44.6369H44.5098V67H66.7198Z' fill='%23fff'/%3E%3C/g%3E%3C/svg%3E"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "cd",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Hanzo CD — the dedicated deploy dashboard served at cd.hanzo.ai (a @hanzo/gitops + @hanzo/canvas SPA over /v1/deploy). Replaces the ArgoCD UI fork.",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.app.json",
|
||||
"preview": "vite preview --port 4173"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/canvas": "workspace:*",
|
||||
"@hanzo/gitops": "workspace:*",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { defineConfig, devices } from "@playwright/test"
|
||||
|
||||
/**
|
||||
* Hanzo CD render + responsive proof. Builds the static SPA and serves it (vite
|
||||
* preview), then the spec mocks `/v1/deploy/*` + a session cookie and asserts the
|
||||
* fleet renders, a row opens the ArgoCD-grade detail (sync panel + resource tree),
|
||||
* and the body never scrolls horizontally at 390px. Screenshots per width.
|
||||
*
|
||||
* pnpm --filter cd exec playwright test # builds + serves + runs
|
||||
* BASE_URL=https://cd.hanzo.ai pnpm … test # against a live deploy
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
timeout: 60_000,
|
||||
retries: 1,
|
||||
workers: 1,
|
||||
reporter: "list",
|
||||
use: { baseURL: process.env.BASE_URL ?? "http://localhost:4173", headless: true },
|
||||
webServer: process.env.BASE_URL
|
||||
? undefined
|
||||
: {
|
||||
command: "npx vite build && npx vite preview --port 4173 --strictPort",
|
||||
url: "http://localhost:4173",
|
||||
reuseExistingServer: true,
|
||||
timeout: 120_000,
|
||||
},
|
||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
cd.hanzo.ai
|
||||
@@ -0,0 +1,143 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Hanzo CD — Sign in</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg viewBox='0 0 64 64' xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='64' height='64' rx='8' fill='%23000'/%3E%3Cg transform='translate(8,8) scale(0.716)'%3E%3Cpath d='M22.21 67V44.6369H0V67H22.21Z' fill='%23fff'/%3E%3Cpath d='M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z' fill='%23fff'/%3E%3Cpath d='M22.21 0H0V22.3184H22.21V0Z' fill='%23fff'/%3E%3Cpath d='M66.7198 0H44.5098V22.3184H66.7198V0Z' fill='%23fff'/%3E%3Cpath d='M66.7198 67V44.6369H44.5098V67H66.7198Z' fill='%23fff'/%3E%3C/g%3E%3C/svg%3E">
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
* { box-sizing: border-box; }
|
||||
html,body { height:100%; margin:0; }
|
||||
body {
|
||||
font-family: "Geist", "Geist Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
background:#0a0a0a; color:#e7e7e7;
|
||||
display:grid; grid-template-columns: 1.1fr 1fr; min-height:100vh;
|
||||
}
|
||||
@media (max-width: 820px){ body{ grid-template-columns:1fr; } .pitch{ display:none; } }
|
||||
.pitch { padding:64px 56px; display:flex; flex-direction:column; justify-content:center; gap:22px;
|
||||
border-right:1px solid #1c1c1c; }
|
||||
.brand { display:flex; align-items:center; gap:12px; font-size:22px; font-weight:700; letter-spacing:-.02em; }
|
||||
.brand .mark { width:26px; height:26px; display:block; }
|
||||
h1 { font-size:40px; line-height:1.08; margin:6px 0 0; letter-spacing:-.03em; font-weight:800; }
|
||||
.sub { color:#9a9a9a; font-size:16px; max-width:38ch; line-height:1.5; }
|
||||
ul.feat { list-style:none; padding:0; margin:8px 0 0; display:grid; gap:12px; }
|
||||
ul.feat li { display:flex; gap:12px; align-items:flex-start; color:#cfcfcf; font-size:14.5px; }
|
||||
ul.feat li b { color:#fff; font-weight:600; }
|
||||
ul.feat .dot { width:6px; height:6px; border-radius:50%; background:#fff; margin-top:7px; flex:none; }
|
||||
.auth { display:flex; align-items:center; justify-content:center; padding:40px; }
|
||||
.card { width:100%; max-width:360px; text-align:center; }
|
||||
.card .mark2 { width:52px; height:52px; display:block; margin:0 auto 18px; }
|
||||
.card h2 { font-size:22px; margin:0 0 6px; font-weight:700; }
|
||||
.card p.hint { color:#8f8f8f; font-size:14px; margin:0 0 26px; }
|
||||
button#login {
|
||||
width:100%; padding:14px 18px; font-size:15px; font-weight:600; cursor:pointer;
|
||||
background:#fff; color:#000; border:0; border-radius:10px; display:flex; align-items:center;
|
||||
justify-content:center; gap:10px; transition:opacity .15s;
|
||||
}
|
||||
button#login:hover { opacity:.88; }
|
||||
button#login:disabled { opacity:.5; cursor:default; }
|
||||
.status { margin-top:16px; font-size:13.5px; color:#9a9a9a; min-height:20px; }
|
||||
.status.err { color:#ff7a7a; }
|
||||
.foot { margin-top:28px; font-size:12px; color:#5c5c5c; }
|
||||
a { color:#bdbdbd; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<section class="pitch">
|
||||
<div class="brand"><svg class="mark" viewBox="0 0 67 67" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><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> Hanzo CD</div>
|
||||
<h1>Continuous delivery for your whole fleet.</h1>
|
||||
<p class="sub">GitOps for every Hanzo App. Declarative, versioned, and auto-synced from git to your clusters — one control plane across hanzo, lux, zoo & pars.</p>
|
||||
<ul class="feat">
|
||||
<li><span class="dot"></span><span><b>Live fleet view</b> — health & sync status for every App across all namespaces.</span></li>
|
||||
<li><span class="dot"></span><span><b>Git-to-cluster</b> — desired state lives in <code>infra/k8s/operator/crs</code>, reconciled continuously.</span></li>
|
||||
<li><span class="dot"></span><span><b>Sync & rollback</b> — one click to reconcile or roll back any application.</span></li>
|
||||
<li><span class="dot"></span><span><b>Resource trees & logs</b> — drill into any workload the operator manages.</span></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="auth">
|
||||
<div class="card">
|
||||
<svg class="mark2" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="64" height="64" rx="14" fill="#fff"/><g transform="translate(8,8) scale(0.716)"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#000"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#000"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#000"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#000"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#000"/></g></svg>
|
||||
<h2>Hanzo CD</h2>
|
||||
<p class="hint">Sign in with your Hanzo account to manage the fleet.</p>
|
||||
<button id="login">
|
||||
<svg width="17" height="17" viewBox="0 0 67 67" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="currentColor"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="currentColor"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="currentColor"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="currentColor"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="currentColor"/></svg>
|
||||
Sign in with Hanzo
|
||||
</button>
|
||||
<div class="status" id="status"></div>
|
||||
<div class="foot">Powered by <a href="https://hanzo.id" rel="noreferrer">Hanzo IAM</a> · SuperAdmin access</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var IAM = 'https://hanzo.id';
|
||||
var CLIENT = 'admin-console'; // the IAM app whose org is `admin`
|
||||
var REDIRECT = location.origin + '/login.html'; // must be registered on admin-console
|
||||
var COOKIE = 'hanzo_iam_token'; // the cookie hanzoai/cloud validates
|
||||
|
||||
var statusEl = document.getElementById('status');
|
||||
var btn = document.getElementById('login');
|
||||
function setStatus(m, err){ statusEl.textContent = m || ''; statusEl.className = 'status' + (err ? ' err' : ''); }
|
||||
|
||||
function b64url(bytes){
|
||||
var s = btoa(String.fromCharCode.apply(null, new Uint8Array(bytes)));
|
||||
return s.replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
|
||||
}
|
||||
function randB64(n){ return b64url(crypto.getRandomValues(new Uint8Array(n))); }
|
||||
async function sha256b64(str){
|
||||
var d = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));
|
||||
return b64url(d);
|
||||
}
|
||||
|
||||
async function startLogin(){
|
||||
btn.disabled = true; setStatus('Redirecting to Hanzo…');
|
||||
var verifier = randB64(48);
|
||||
var state = randB64(16);
|
||||
sessionStorage.setItem('cd_pkce_verifier', verifier);
|
||||
sessionStorage.setItem('cd_oauth_state', state);
|
||||
var challenge = await sha256b64(verifier);
|
||||
var u = new URL(IAM + '/login/oauth/authorize');
|
||||
u.search = new URLSearchParams({
|
||||
client_id: CLIENT, response_type: 'code', redirect_uri: REDIRECT,
|
||||
scope: 'openid profile email', state: state,
|
||||
code_challenge: challenge, code_challenge_method: 'S256'
|
||||
}).toString();
|
||||
location.href = u.toString();
|
||||
}
|
||||
|
||||
async function finishLogin(code, state){
|
||||
btn.disabled = true; setStatus('Signing in…');
|
||||
if (state !== sessionStorage.getItem('cd_oauth_state')) { setStatus('Sign-in state mismatch — please try again.', true); btn.disabled = false; return; }
|
||||
var verifier = sessionStorage.getItem('cd_pkce_verifier');
|
||||
if (!verifier) { setStatus('Sign-in session lost — please try again.', true); btn.disabled = false; return; }
|
||||
try {
|
||||
var r = await fetch(IAM + '/v1/iam/oauth/access_token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code', client_id: CLIENT, code: code,
|
||||
redirect_uri: REDIRECT, code_verifier: verifier
|
||||
})
|
||||
});
|
||||
var j = await r.json();
|
||||
if (!j.access_token) { setStatus('Sign-in failed: ' + (j.error_description || j.error || 'no token'), true); btn.disabled = false; return; }
|
||||
// set the session cookie cloud reads (non-httpOnly by design so the page can set it; still Secure)
|
||||
document.cookie = COOKIE + '=' + j.access_token + '; path=/; secure; samesite=lax; max-age=86400';
|
||||
sessionStorage.removeItem('cd_pkce_verifier'); sessionStorage.removeItem('cd_oauth_state');
|
||||
setStatus('Signed in. Loading the dashboard…');
|
||||
location.replace('/');
|
||||
} catch (e) {
|
||||
setStatus('Sign-in error: ' + (e && e.message ? e.message : e), true); btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
var q = new URLSearchParams(location.search);
|
||||
if (q.get('error')) { setStatus('Hanzo returned: ' + (q.get('error_description') || q.get('error')), true); }
|
||||
else if (q.get('code')) { finishLogin(q.get('code'), q.get('state')); }
|
||||
btn.onclick = startLogin;
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Hanzo CD — the dedicated deploy dashboard (cd.hanzo.ai). A static SPA over the
|
||||
* cloud CD plane (`/v1/deploy`): the fleet of operator App CRs with health, sync,
|
||||
* resource topology, logs, and sync/rollback, built on the shared @hanzo/gitops
|
||||
* components. Auth is the first-party `hanzo_iam_token` cookie (set by the PKCE
|
||||
* login); a missing/expired session lands on the sign-in screen — never a fake row.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
|
||||
import { DeployApi, hasSession, isAuthError } from "./lib/deploy"
|
||||
import type { DeployApp } from "./lib/adapt"
|
||||
import { Topbar, type EnvOption } from "./shell/Topbar"
|
||||
import { FleetView } from "./views/FleetView"
|
||||
import { AppView } from "./views/AppView"
|
||||
|
||||
const POLL_MS = 20_000
|
||||
|
||||
type Phase = "signin" | "loading" | "error" | "ready"
|
||||
|
||||
const parseHash = (): string => {
|
||||
const m = /^#\/app\/(.+)$/.exec(location.hash)
|
||||
return m ? decodeURIComponent(m[1]) : ""
|
||||
}
|
||||
|
||||
function SignIn() {
|
||||
return (
|
||||
<div style={{ minHeight: "100vh", display: "grid", placeItems: "center", padding: 24 }}>
|
||||
<div style={{ textAlign: "center", maxWidth: 360 }}>
|
||||
<svg width={52} height={52} viewBox="0 0 64 64" style={{ marginBottom: 18 }} xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<rect width="64" height="64" rx="14" fill="#fff" />
|
||||
<g transform="translate(8,8) scale(0.716)">
|
||||
<path d="M22.21 67V44.6369H0V67H22.21Z" fill="#000" />
|
||||
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#000" />
|
||||
<path d="M22.21 0H0V22.3184H22.21V0Z" fill="#000" />
|
||||
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#000" />
|
||||
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#000" />
|
||||
</g>
|
||||
</svg>
|
||||
<h2 style={{ fontSize: 22, margin: "0 0 6px", fontWeight: 700 }}>Hanzo CD</h2>
|
||||
<p className="cd-muted" style={{ margin: "0 0 26px", fontSize: 14 }}>
|
||||
Sign in with your Hanzo account to manage the fleet.
|
||||
</p>
|
||||
<button type="button" className="cd-btn cd-btn--primary" style={{ width: "100%", justifyContent: "center", padding: "13px 18px" }} onClick={() => (location.href = "/login.html")}>
|
||||
Sign in with Hanzo
|
||||
</button>
|
||||
<div className="cd-muted" style={{ marginTop: 28, fontSize: 12 }}>
|
||||
Powered by Hanzo IAM · SuperAdmin access
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [phase, setPhase] = useState<Phase>(hasSession() ? "loading" : "signin")
|
||||
const [apps, setApps] = useState<DeployApp[]>([])
|
||||
const [errMsg, setErrMsg] = useState("")
|
||||
const [env, setEnv] = useState("")
|
||||
const [selected, setSelected] = useState<string>(parseHash())
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [toast, setToast] = useState<{ msg: string; err?: boolean } | null>(null)
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!hasSession()) {
|
||||
setPhase("signin")
|
||||
return
|
||||
}
|
||||
setRefreshing(true)
|
||||
DeployApi.applications()
|
||||
.then((rows) => {
|
||||
setApps(rows)
|
||||
setPhase("ready")
|
||||
})
|
||||
.catch((e) => {
|
||||
if (isAuthError(e)) setPhase("signin")
|
||||
else {
|
||||
setPhase("error")
|
||||
setErrMsg(e instanceof Error ? e.message : "failed to load")
|
||||
}
|
||||
})
|
||||
.finally(() => setRefreshing(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
const t = setInterval(load, POLL_MS)
|
||||
const onHash = () => setSelected(parseHash())
|
||||
window.addEventListener("hashchange", onHash)
|
||||
return () => {
|
||||
clearInterval(t)
|
||||
window.removeEventListener("hashchange", onHash)
|
||||
}
|
||||
}, [load])
|
||||
|
||||
const notify = useCallback((msg: string, err?: boolean) => {
|
||||
setToast({ msg, err })
|
||||
setTimeout(() => setToast(null), 4000)
|
||||
}, [])
|
||||
|
||||
const envs: EnvOption[] = useMemo(() => {
|
||||
const counts = new Map<string, number>()
|
||||
for (const a of apps) if (a.env) counts.set(a.env, (counts.get(a.env) ?? 0) + 1)
|
||||
return Array.from(counts, ([id, count]) => ({ id, label: id, count })).sort((x, y) => x.id.localeCompare(y.id))
|
||||
}, [apps])
|
||||
|
||||
const visibleApps = useMemo(() => (env ? apps.filter((a) => a.env === env) : apps), [apps, env])
|
||||
const selectedApp = selected ? apps.find((a) => a.name === selected) ?? null : null
|
||||
|
||||
const openApp = (name: string) => {
|
||||
location.hash = `#/app/${encodeURIComponent(name)}`
|
||||
}
|
||||
const backToFleet = () => {
|
||||
location.hash = ""
|
||||
}
|
||||
const signOut = () => {
|
||||
document.cookie = "hanzo_iam_token=; path=/; max-age=0"
|
||||
location.href = "/login.html"
|
||||
}
|
||||
|
||||
if (phase === "signin") return <SignIn />
|
||||
|
||||
return (
|
||||
<div className="cd-app">
|
||||
<Topbar envs={envs} env={env} onEnv={setEnv} onRefresh={load} refreshing={refreshing} onSignOut={signOut} />
|
||||
<main className="cd-main">
|
||||
{phase === "loading" ? (
|
||||
<div className="cd-muted" style={{ padding: 40 }}>
|
||||
Loading the fleet…
|
||||
</div>
|
||||
) : phase === "error" ? (
|
||||
<div style={{ padding: 24, border: "1px solid var(--cd-border-strong)", borderRadius: 10, background: "var(--cd-surface)" }}>
|
||||
<div style={{ fontWeight: 700, marginBottom: 6 }}>Could not reach the deploy plane</div>
|
||||
<div className="cd-muted" style={{ fontSize: 14, marginBottom: 14 }}>
|
||||
{errMsg || "The CD read (GET /v1/deploy/applications) failed."}
|
||||
</div>
|
||||
<button type="button" className="cd-btn" onClick={load}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : selectedApp ? (
|
||||
<AppView app={selectedApp} onBack={backToFleet} onChanged={load} notify={notify} />
|
||||
) : (
|
||||
<FleetView apps={visibleApps} onOpen={openApp} />
|
||||
)}
|
||||
</main>
|
||||
|
||||
{toast ? (
|
||||
<div
|
||||
role="status"
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 18,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
zIndex: 1000,
|
||||
maxWidth: "calc(100vw - 32px)",
|
||||
padding: "11px 16px",
|
||||
borderRadius: 10,
|
||||
background: toast.err ? "#3d1418" : "var(--cd-surface-2)",
|
||||
border: `1px solid ${toast.err ? "#E96D76" : "var(--cd-border-strong)"}`,
|
||||
color: "var(--cd-fg-strong)",
|
||||
fontSize: 13.5,
|
||||
boxShadow: "0 8px 30px rgba(0,0,0,0.4)",
|
||||
}}
|
||||
>
|
||||
{toast.msg}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* RED adversarial suite — feeds hostile / malformed /v1/deploy JSON to the adapter
|
||||
* and the shared @hanzo/gitops folds, asserting the view degrades honestly and
|
||||
* NEVER (a) crashes, (b) wedges the tree builder into a hang, or (c) mislabels a
|
||||
* bad state as Healthy/Synced. Written by Red; not part of Blue's suite.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { buildResourceGraph, foldHealth, foldSync } from "@hanzo/gitops"
|
||||
|
||||
import {
|
||||
manifestText,
|
||||
normalizeDeployApp,
|
||||
parseApplications,
|
||||
toGitopsApp,
|
||||
toLogLines,
|
||||
toManagedResource,
|
||||
toResourceTree,
|
||||
toRollbackHistory,
|
||||
} from "./adapt"
|
||||
|
||||
// A wall-clock guard: proves a call TERMINATES (no infinite loop) fast.
|
||||
function within<T>(ms: number, fn: () => T): T {
|
||||
const t0 = Date.now()
|
||||
const out = fn()
|
||||
const dt = Date.now() - t0
|
||||
expect(dt).toBeLessThan(ms)
|
||||
return out
|
||||
}
|
||||
|
||||
// ── A. Robustness: hostile top-level inputs never throw ──────────────────────
|
||||
|
||||
describe("RED robustness — malformed payloads never crash", () => {
|
||||
const junk: unknown[] = [null, undefined, 42, "str", true, [], {}, { applications: null }, { apps: 7 }, NaN]
|
||||
|
||||
it("parseApplications tolerates any junk → array, never throws", () => {
|
||||
for (const j of junk) expect(Array.isArray(parseApplications(j))).toBe(true)
|
||||
})
|
||||
it("normalizeDeployApp of junk → safe defaults", () => {
|
||||
for (const j of junk) {
|
||||
const a = normalizeDeployApp(j)
|
||||
expect(typeof a.name).toBe("string")
|
||||
expect(a.namespace).toBe("hanzo") // default
|
||||
expect(Array.isArray(a.endpoints)).toBe(true)
|
||||
}
|
||||
})
|
||||
it("toResourceTree / toManagedResource / toLogLines tolerate junk", () => {
|
||||
for (const j of junk) {
|
||||
expect(Array.isArray(toResourceTree(j).nodes)).toBe(true)
|
||||
expect(() => toManagedResource(j)).not.toThrow()
|
||||
expect(Array.isArray(toLogLines(j))).toBe(true)
|
||||
}
|
||||
})
|
||||
it("wrong-typed fields (health object, numeric name) degrade to empty, dropped", () => {
|
||||
const rows = parseApplications({ applications: [{ name: 123, health: { evil: 1 }, sync: ["x"] }, { name: "ok" }] })
|
||||
expect(rows.map((r) => r.name)).toEqual(["ok"]) // numeric-name row dropped
|
||||
const g = toGitopsApp(normalizeDeployApp({ name: "z", health: { nested: true } }))
|
||||
expect(g.health).toBe("Unknown") // object health → '' → Unknown, NOT guessed up
|
||||
})
|
||||
})
|
||||
|
||||
// ── B. CYCLE SAFETY — the headline #3 case ───────────────────────────────────
|
||||
// Adversarial parentRefs must not wedge buildResourceGraph into an infinite loop.
|
||||
|
||||
const treeJSON = (nodes: object[]) => toResourceTree({ nodes })
|
||||
const node = (ref: string, kind: string, parents: string[] = []) => ({
|
||||
ref,
|
||||
kind,
|
||||
name: ref,
|
||||
namespace: "demo",
|
||||
parentRefs: parents.map((p) => ({ ref: p })),
|
||||
})
|
||||
|
||||
describe("RED cycle safety — buildResourceGraph terminates on hostile parentRefs", () => {
|
||||
it("self-referencing parent (A→A) terminates; node survives as a root", () => {
|
||||
const g = within(1000, () => buildResourceGraph(treeJSON([node("A", "Pod", ["A"])])))
|
||||
expect(g.nodes.map((n) => n.id)).toContain("A")
|
||||
expect(g.nodes.find((n) => n.id === "A")!.depth).toBe(0)
|
||||
expect(g.edges).toHaveLength(0) // self-edge dropped
|
||||
})
|
||||
|
||||
// ── HIGH-1 (FIXED): buildResourceGraph used to THROW whenever a node's parent
|
||||
// appeared LATER in the array — `children` was seeded lazily per-node, so
|
||||
// `children.get(pid)!.push(id)` dereferenced undefined. That is EVERY cycle AND
|
||||
// any tree listing a child before its parent (K8s lists are not topologically
|
||||
// sorted), and with no error boundary the whole SPA white-screened. `children`
|
||||
// is now pre-seeded for every id, so order and cycles are both safe. These are
|
||||
// the regression tests: each MUST build a graph, never throw. ───────────────
|
||||
it("BENIGN acyclic tree that lists a child before its parent builds", () => {
|
||||
// Deployment listed before the Application that owns it — a totally normal,
|
||||
// non-adversarial ordering a backend may emit.
|
||||
const g = within(1000, () =>
|
||||
buildResourceGraph(treeJSON([node("dep", "Deployment", ["app"]), node("app", "Application")])),
|
||||
)
|
||||
expect(g.nodes.map((n) => n.id).sort()).toEqual(["app", "dep"])
|
||||
expect(g.nodes.find((n) => n.id === "app")!.depth).toBe(0)
|
||||
expect(g.nodes.find((n) => n.id === "dep")!.depth).toBe(1)
|
||||
})
|
||||
|
||||
it("2-cycle with NO external root (A↔B) does not crash", () => {
|
||||
const g = within(1000, () =>
|
||||
buildResourceGraph(treeJSON([node("A", "Pod", ["B"]), node("B", "Pod", ["A"])])),
|
||||
)
|
||||
expect(Array.isArray(g.nodes)).toBe(true) // unreachable-from-a-root nodes may drop; must not throw
|
||||
})
|
||||
|
||||
it("2-cycle WITH an external root (R→A, A↔B) renders R, A, B", () => {
|
||||
const g = within(1000, () =>
|
||||
buildResourceGraph(treeJSON([node("R", "App"), node("A", "Pod", ["R", "B"]), node("B", "Pod", ["A"])])),
|
||||
)
|
||||
expect(g.nodes.map((n) => n.id).sort()).toEqual(["A", "B", "R"])
|
||||
})
|
||||
|
||||
it("3-cycle (A→B→C→A) with a root renders 4 nodes", () => {
|
||||
const g = within(1000, () =>
|
||||
buildResourceGraph(
|
||||
treeJSON([node("R", "App"), node("A", "Pod", ["R", "C"]), node("B", "Pod", ["A"]), node("C", "Pod", ["B"])]),
|
||||
),
|
||||
)
|
||||
expect(g.nodes.map((n) => n.id).sort()).toEqual(["A", "B", "C", "R"])
|
||||
})
|
||||
|
||||
it("dangling parent (points at a non-existent uid) → node is a root, no crash", () => {
|
||||
const g = within(1000, () => buildResourceGraph(treeJSON([node("A", "Pod", ["ghost-does-not-exist"])])))
|
||||
expect(g.nodes.find((n) => n.id === "A")!.depth).toBe(0)
|
||||
})
|
||||
|
||||
it("large deep chain (2000 nodes) terminates quickly", () => {
|
||||
const chain = Array.from({ length: 2000 }, (_, i) => node(`n${i}`, "Pod", i ? [`n${i - 1}`] : []))
|
||||
const g = within(6000, () => buildResourceGraph(treeJSON(chain)))
|
||||
expect(g.nodes.length).toBe(2000)
|
||||
})
|
||||
|
||||
it("every node self-parents (all self-cycles) → all render as roots, terminates", () => {
|
||||
const many = Array.from({ length: 500 }, (_, i) => node(`s${i}`, "Pod", [`s${i}`]))
|
||||
const g = within(4000, () => buildResourceGraph(treeJSON(many)))
|
||||
expect(g.nodes.length).toBe(500)
|
||||
})
|
||||
})
|
||||
|
||||
// ── C. MISLABEL — the fold's one dangerous direction: bad → Healthy/Synced ────
|
||||
|
||||
describe("RED mislabel (FIXED) — a BAD state is never up-guessed to a GOOD one", () => {
|
||||
it("health words that MEAN broken never fold to Healthy", () => {
|
||||
// The positive substring up-guess is gone: unrecognized → honest Unknown.
|
||||
expect(foldHealth("Broken")).toBe("Unknown") // contains 'ok'
|
||||
expect(foldHealth("revoked")).toBe("Unknown") // cert/token revoked
|
||||
expect(foldHealth("invoked")).toBe("Unknown")
|
||||
expect(foldHealth("NotReady")).toBe("Unknown") // contains 'ready'
|
||||
expect(foldHealth("unavailable")).toBe("Unknown") // contains 'available'
|
||||
expect(foldHealth("MinimumReplicasUnavailable")).toBe("Unknown")
|
||||
// The canonical vocabulary cloud actually emits still folds exactly:
|
||||
expect(foldHealth("healthy")).toBe("Healthy")
|
||||
expect(foldHealth("")).toBe("Unknown")
|
||||
expect(foldHealth("degraded")).toBe("Degraded")
|
||||
expect(foldHealth("CrashLoopBackOff")).toBe("Degraded") // bad-substring — safe direction
|
||||
})
|
||||
|
||||
it("sync words that MEAN not-synced never fold to Synced", () => {
|
||||
expect(foldSync("notsynced")).toBe("Unknown") // means NOT synced
|
||||
expect(foldSync("unsynced")).toBe("Unknown")
|
||||
expect(foldSync("NotSynced")).toBe("Unknown")
|
||||
// Canonical + separator-normalized cases still fold exactly:
|
||||
expect(foldSync("synced")).toBe("Synced")
|
||||
expect(foldSync("out-of-sync")).toBe("OutOfSync") // hyphens normalized at the source
|
||||
expect(foldSync("out-of-sync".replace(/[-_]/g, ""))).toBe("OutOfSync")
|
||||
expect(foldSync("")).toBe("Unknown")
|
||||
})
|
||||
|
||||
it("SAFE: a Degraded app is never shown Healthy through the adapter", () => {
|
||||
const g = toGitopsApp(normalizeDeployApp({ name: "x", health: "degraded" }))
|
||||
expect(g.health).toBe("Degraded")
|
||||
})
|
||||
})
|
||||
|
||||
// ── D. Honest sync derivation (no fabricated Synced) ─────────────────────────
|
||||
|
||||
describe("RED honest sync — derived state does not fabricate Synced", () => {
|
||||
it("missing sync + version drift → OutOfSync", () => {
|
||||
expect(toGitopsApp(normalizeDeployApp({ name: "a", version: "v2", runningVersion: "v1" })).sync).toBe("OutOfSync")
|
||||
})
|
||||
it("missing sync + equal version → Synced (declared==running proxy)", () => {
|
||||
expect(toGitopsApp(normalizeDeployApp({ name: "a", version: "v1", runningVersion: "v1" })).sync).toBe("Synced")
|
||||
})
|
||||
it("missing sync + BOTH versions empty → Synced (''==''), a benign over-report", () => {
|
||||
// documents: an app the plane reports with no versions folds to Synced, not Unknown.
|
||||
expect(toGitopsApp(normalizeDeployApp({ name: "a" })).sync).toBe("Synced")
|
||||
})
|
||||
})
|
||||
|
||||
// ── E. Manifest handling — object/string/circular ────────────────────────────
|
||||
|
||||
describe("RED manifest — object/string/circular never throw", () => {
|
||||
it("circular object manifest → '' (no throw)", () => {
|
||||
const circular: Record<string, unknown> = { kind: "Deployment" }
|
||||
circular.self = circular
|
||||
expect(manifestText(circular)).toBe("")
|
||||
const m = toManagedResource({ ref: "r", liveManifest: circular })
|
||||
expect(m.liveState).toBe("")
|
||||
})
|
||||
it("object manifest → pretty JSON; string manifest → verbatim; null → ''", () => {
|
||||
expect(toManagedResource({ ref: "r", liveManifest: { a: 1 } }).liveState).toContain('"a": 1')
|
||||
expect(toManagedResource({ ref: "r", liveManifest: "raw yaml here" }).liveState).toBe("raw yaml here")
|
||||
expect(toManagedResource({ ref: "r", liveManifest: null }).liveState).toBe("")
|
||||
})
|
||||
it("a manifest string carrying HTML is passed through as TEXT (React escapes on render)", () => {
|
||||
const evil = "<img src=x onerror=alert(document.cookie)>"
|
||||
// The adapter must NOT interpret it; it stays a plain string (rendered via {text}).
|
||||
expect(toManagedResource({ ref: "r", liveManifest: evil }).liveState).toBe(evil)
|
||||
})
|
||||
})
|
||||
|
||||
// ── F. Logs — array gap + unbounded blob (no client cap) ─────────────────────
|
||||
|
||||
describe("RED logs (FIXED) — fidelity + bound", () => {
|
||||
it("logs delivered as an ARRAY yield lines (blob AND array shapes)", () => {
|
||||
expect(toLogLines({ pod: "p", logs: ["line-1", "line-2"] })).toEqual([
|
||||
{ content: "line-1", podName: "p" },
|
||||
{ content: "line-2", podName: "p" },
|
||||
])
|
||||
// the blob shape still works
|
||||
expect(toLogLines({ pod: "p", logs: "a\nb" }).map((l) => l.content)).toEqual(["a", "b"])
|
||||
})
|
||||
it("a huge blob is line-capped client-side (keeps the most recent lines)", () => {
|
||||
const blob = Array.from({ length: 50_000 }, (_, i) => `l${i}`).join("\n")
|
||||
const out = toLogLines({ pod: "p", logs: blob })
|
||||
expect(out.length).toBe(2000) // MAX_LOG_LINES
|
||||
expect(out[out.length - 1].content).toBe("l49999") // tail kept, not head
|
||||
})
|
||||
it("a single enormous line is clamped (no one-megabyte text node)", () => {
|
||||
const huge = "x".repeat(50_000)
|
||||
const out = toLogLines({ pod: "p", logs: huge })
|
||||
expect(out[0].content.length).toBeLessThanOrEqual(4001) // MAX_LOG_LINE + ellipsis
|
||||
})
|
||||
})
|
||||
|
||||
// ── G. Rollback semver filter — injection + hygiene ──────────────────────────
|
||||
|
||||
describe("RED rollback — only clean semver, current excluded, no injection", () => {
|
||||
it("drops non-semver + injection-shaped tags, excludes current, newest-first, dedupes", () => {
|
||||
const h = toRollbackHistory("v1.800.1", [
|
||||
"v1.800.0",
|
||||
"latest",
|
||||
"main",
|
||||
"deadbeef",
|
||||
"v1.800.1", // == current → excluded
|
||||
"v1.799.15",
|
||||
"v1.799.15", // dup
|
||||
"v1.2.3; rm -rf /", // injection shape → not semver → dropped
|
||||
"'; DROP TABLE apps;--",
|
||||
])
|
||||
expect(h.map((r) => r.revision)).toEqual(["v1.800.0", "v1.799.15"])
|
||||
expect(h[0].id).toBeGreaterThan(h[1].id)
|
||||
})
|
||||
it("pre-release semver IS offered as a rollback target (documents the accepted grammar)", () => {
|
||||
const h = toRollbackHistory("v2.0.0", ["v1.9.9-rc.1", "v1.9.9"])
|
||||
expect(h.map((r) => r.revision)).toContain("v1.9.9-rc.1")
|
||||
})
|
||||
it("empty history → []", () => {
|
||||
expect(toRollbackHistory("v1.0.0", [])).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* The /v1/deploy → @hanzo/gitops adapter — pins the console CD app against cloud's
|
||||
* REAL clients/deploy shapes (the hyphenated sync verdict, object manifests,
|
||||
* parentRefs tree edges, the logs blob, clean-semver rollback).
|
||||
*/
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import {
|
||||
normalizeDeployApp,
|
||||
parseApplications,
|
||||
toGitopsApp,
|
||||
toLogLines,
|
||||
toManagedResource,
|
||||
toResourceTree,
|
||||
toRollbackHistory,
|
||||
} from "./adapt"
|
||||
|
||||
describe("toGitopsApp", () => {
|
||||
it("folds the hyphenated cloud sync verdict (the foldSync hyphen bug)", () => {
|
||||
const a = parseApplications({
|
||||
applications: [
|
||||
{ name: "iam", namespace: "hanzo", env: "main", repository: "ghcr.io/hanzoai/iam", version: "v1.4.11", runningVersion: "v1.4.10", health: "progressing", healthMessage: "rolling", sync: "out-of-sync" },
|
||||
],
|
||||
})[0]
|
||||
const g = toGitopsApp(a)
|
||||
expect(g.sync).toBe("OutOfSync") // 'out-of-sync' must NOT fall through to Unknown
|
||||
expect(g.health).toBe("Progressing")
|
||||
expect(g.revision).toBe("v1.4.11")
|
||||
expect(g.source).toEqual({ repoURL: "ghcr.io/hanzoai/iam" })
|
||||
expect(g.message).toBe("rolling")
|
||||
expect(g.project).toBe("main")
|
||||
})
|
||||
it("derives sync from declared-vs-running when the plane omits it", () => {
|
||||
const [drift, synced] = parseApplications([
|
||||
{ name: "a", repository: "r", version: "v2", runningVersion: "v1" },
|
||||
{ name: "b", repository: "r", version: "v1", runningVersion: "v1" },
|
||||
])
|
||||
expect(toGitopsApp(drift).sync).toBe("OutOfSync")
|
||||
expect(toGitopsApp(synced).sync).toBe("Synced")
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseApplications", () => {
|
||||
it("tolerates a bare array and drops nameless rows", () => {
|
||||
expect(parseApplications([{ name: "cloud" }, { name: "" }]).map((a) => a.name)).toEqual(["cloud"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("toResourceTree", () => {
|
||||
it("maps parentRefs[].ref into linking AppTreeNodes (uid == ref token)", () => {
|
||||
const tree = toResourceTree({
|
||||
nodes: [
|
||||
{ group: "hanzo.ai", version: "v1", kind: "App", namespace: "hanzo", name: "iam", ref: "hanzo.ai:App:hanzo:iam", parentRefs: [] },
|
||||
{ group: "apps", version: "v1", kind: "Deployment", namespace: "hanzo", name: "iam", ref: "apps:Deployment:hanzo:iam", health: "progressing", parentRefs: [{ ref: "hanzo.ai:App:hanzo:iam" }] },
|
||||
],
|
||||
})
|
||||
expect(tree.nodes).toHaveLength(2)
|
||||
const dep = tree.nodes.find((n) => n.kind === "Deployment")!
|
||||
expect(dep.uid).toBe("apps:Deployment:hanzo:iam")
|
||||
expect(dep.parentRefs?.[0].uid).toBe("hanzo.ai:App:hanzo:iam") // matches the App node's uid → the tree links
|
||||
expect(dep.health?.status).toBe("Progressing")
|
||||
})
|
||||
})
|
||||
|
||||
describe("toManagedResource", () => {
|
||||
it("stringifies object manifests + reads the nested desired manifest", () => {
|
||||
const m = toManagedResource({
|
||||
ref: { group: "apps", version: "v1", kind: "Deployment", namespace: "hanzo", name: "iam", ref: "apps:Deployment:hanzo:iam" },
|
||||
liveManifest: { kind: "Deployment", spec: { replicas: 1 } },
|
||||
diff: { modified: true, desiredManifest: { kind: "Deployment", spec: { replicas: 2 } } },
|
||||
})
|
||||
expect(m.uid).toBe("apps:Deployment:hanzo:iam")
|
||||
expect(m.liveState).toContain('"replicas": 1')
|
||||
expect(m.targetState).toContain('"replicas": 2')
|
||||
})
|
||||
})
|
||||
|
||||
describe("toLogLines", () => {
|
||||
it("splits the /v1/deploy logs blob and tags the pod", () => {
|
||||
const lines = toLogLines({ pod: "iam-abc", logs: "first\nsecond\n" })
|
||||
expect(lines).toHaveLength(2)
|
||||
expect(lines[0]).toEqual({ content: "first", podName: "iam-abc" })
|
||||
})
|
||||
it("honest empty when no logs", () => {
|
||||
expect(toLogLines({ pod: "", logs: "" })).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("toRollbackHistory", () => {
|
||||
it("offers only clean-semver releases, current excluded, newest first", () => {
|
||||
const h = toRollbackHistory("v1.800.1", ["v1.800.0", "latest", "v1.799.15", "v1.800.1", "main"])
|
||||
expect(h.map((r) => r.revision)).toEqual(["v1.800.0", "v1.799.15"])
|
||||
expect(h[0].id).toBeGreaterThan(h[1].id) // monotonic ids for the dialog
|
||||
})
|
||||
})
|
||||
|
||||
// ── the ACTUAL argoproj shape /v1/deploy/applications serves in production ────
|
||||
// Captured live (cloud v1.801.109). The first cut read only flat keys, so the
|
||||
// fleet bound to zero rows against real data; this pins the real contract.
|
||||
describe("live argoproj-shaped application", () => {
|
||||
const LIVE = {
|
||||
apiVersion: "argoproj.io/v1alpha1",
|
||||
kind: "Application",
|
||||
metadata: {
|
||||
name: "admin-guard",
|
||||
namespace: "hanzo",
|
||||
uid: "257fcb88-974d-46fc-8ac2-6f8b86a1f15f",
|
||||
creationTimestamp: "2026-07-15T05:31:10Z",
|
||||
labels: { "hanzo.ai/env": "main", "hanzo.ai/instance": "admin-guard" },
|
||||
},
|
||||
spec: {
|
||||
source: { repoURL: "https://git.hanzo.ai/hanzoai/universe", path: "infra/k8s/operator/crs", targetRevision: "main" },
|
||||
destination: { server: "https://kubernetes.default.svc", namespace: "hanzo" },
|
||||
project: "default",
|
||||
},
|
||||
status: {
|
||||
sync: { status: "Synced", revision: "v0.1.4" },
|
||||
health: { status: "Healthy", message: "Running: available" },
|
||||
resources: [],
|
||||
summary: { images: ["ghcr.io/hanzoai/admin-guard:v0.1.4"] },
|
||||
},
|
||||
}
|
||||
|
||||
it("binds name/env/health/sync/revision/repository from the nested shape", () => {
|
||||
const a = normalizeDeployApp(LIVE)
|
||||
expect(a.name).toBe("admin-guard")
|
||||
expect(a.namespace).toBe("hanzo")
|
||||
expect(a.env).toBe("main")
|
||||
expect(a.health).toBe("Healthy")
|
||||
expect(a.sync).toBe("Synced")
|
||||
expect(a.version).toBe("v0.1.4")
|
||||
expect(a.repository).toBe("ghcr.io/hanzoai/admin-guard")
|
||||
expect(a.runningVersion).toBe("v0.1.4")
|
||||
})
|
||||
|
||||
it("folds to a Healthy/Synced gitops app (the fleet row renders)", () => {
|
||||
const g = toGitopsApp(normalizeDeployApp(LIVE))
|
||||
expect(g.name).toBe("admin-guard")
|
||||
expect(g.health).toBe("Healthy")
|
||||
expect(g.sync).toBe("Synced")
|
||||
})
|
||||
|
||||
it("still reads the flat native shape (both wires supported)", () => {
|
||||
const a = normalizeDeployApp({ name: "x", health: "degraded", sync: "out-of-sync", version: "v1.2.3" })
|
||||
expect(a.name).toBe("x")
|
||||
expect(a.health).toBe("degraded")
|
||||
expect(toGitopsApp(a).sync).toBe("OutOfSync")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* The CD data adapter — maps cloud's `/v1/deploy` DTOs INTO the shared view-models
|
||||
* of `@hanzo/gitops` (GitopsApplication / ResourceTree / ManagedResource / LogLine
|
||||
* / RevisionHistory). Reuses each package's OWN pure folds (foldHealth/foldSync) so
|
||||
* status vocabulary is one way. No React, no I/O — unit-testable.
|
||||
*
|
||||
* Contract (cloud clients/deploy):
|
||||
* applications: [{name,namespace,env,role,repository,version,runningVersion,
|
||||
* health,healthMessage,sync,phase,endpoints}]
|
||||
* {name}/tree: {application, nodes:[{group,version,kind,namespace,name,ref,uid,
|
||||
* createdAt,health,healthMessage,sync,version,parentRefs:[{…,ref}]}]}
|
||||
* {name}/resource/{ref}: {ref, health, healthMessage, liveManifest:{…},
|
||||
* desiredSource, diff:{modified,desiredManifest:{…}}}
|
||||
* {name}/logs: {application, pod, container, logs:"…", note?}
|
||||
*/
|
||||
import {
|
||||
foldHealth,
|
||||
foldSync,
|
||||
type AppTreeNode,
|
||||
type GitopsApplication,
|
||||
type LogLine,
|
||||
type ManagedResource,
|
||||
type ResourceRef,
|
||||
type ResourceTree,
|
||||
type RevisionHistory,
|
||||
} from "@hanzo/gitops"
|
||||
|
||||
// ── optional-safe helpers (snake_case + camelCase tolerant) ──────────────────
|
||||
|
||||
const str = (v: unknown): string => (typeof v === "string" ? v : "")
|
||||
const arr = (v: unknown): unknown[] => (Array.isArray(v) ? v : [])
|
||||
const rec = (v: unknown): Record<string, unknown> =>
|
||||
v && typeof v === "object" ? (v as Record<string, unknown>) : {}
|
||||
const pick = (r: Record<string, unknown>, ...keys: string[]): unknown => {
|
||||
for (const k of keys) if (r[k] !== undefined && r[k] !== null) return r[k]
|
||||
return undefined
|
||||
}
|
||||
const strList = (v: unknown): string[] => arr(v).map(str).filter(Boolean)
|
||||
const epochMs = (v: unknown): number | undefined => {
|
||||
const s = str(v)
|
||||
if (!s) return undefined
|
||||
const t = Date.parse(s)
|
||||
return Number.isNaN(t) ? undefined : t
|
||||
}
|
||||
|
||||
// The cloud wire uses hyphenated verdicts (`out-of-sync`); @hanzo/gitops's folds
|
||||
// strip whitespace but not hyphens, so normalize `[-_]` out before folding.
|
||||
const fHealth = (raw: string) => foldHealth(raw.replace(/[-_]/g, ""))
|
||||
const fSync = (raw: string) => foldSync(raw.replace(/[-_]/g, ""))
|
||||
|
||||
/** A manifest object → pretty JSON text; a string verbatim; else ''. */
|
||||
export function manifestText(v: unknown): string {
|
||||
if (typeof v === "string") return v
|
||||
if (v && typeof v === "object") {
|
||||
try {
|
||||
return JSON.stringify(v, null, 2)
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/** repo basename of an image repository: `ghcr.io/hanzoai/iam` → `iam`. */
|
||||
export function repoBaseName(repository: string): string {
|
||||
const s = (repository || "").trim().replace(/:.*/, "")
|
||||
return (s.split("/").filter(Boolean).pop() ?? "").toLowerCase()
|
||||
}
|
||||
|
||||
// ── Application ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface DeployApp {
|
||||
name: string
|
||||
namespace: string
|
||||
env: string
|
||||
role: string
|
||||
repository: string
|
||||
version: string
|
||||
runningVersion: string
|
||||
health: string
|
||||
healthMessage: string
|
||||
sync: string
|
||||
phase: string
|
||||
endpoints: string[]
|
||||
/** Prior release tags the plane records (forward-compat rollback source); [] today. */
|
||||
revisions: string[]
|
||||
}
|
||||
|
||||
/** Normalize a raw /v1/deploy application row to a stable internal shape. */
|
||||
/**
|
||||
* Normalize one application from EITHER wire shape:
|
||||
*
|
||||
* - argoproj (what `/v1/deploy/applications` actually serves): the projection
|
||||
* nests everything — `metadata.{name,namespace,labels}`, `spec.source.*`,
|
||||
* `spec.project`, `status.{sync,health}.status`, `status.summary.images[]`.
|
||||
* - flat native: `{name,namespace,env,repository,version,health,sync,…}`.
|
||||
*
|
||||
* Both are read here so the app binds regardless of which the plane returns —
|
||||
* flat keys win when present, then the nested argo fields fill in.
|
||||
*/
|
||||
export function normalizeDeployApp(raw: unknown): DeployApp {
|
||||
const r = rec(raw)
|
||||
const meta = rec(pick(r, "metadata"))
|
||||
const spec = rec(pick(r, "spec"))
|
||||
const status = rec(pick(r, "status"))
|
||||
const source = rec(pick(spec, "source"))
|
||||
const dest = rec(pick(spec, "destination"))
|
||||
const sync = rec(pick(status, "sync"))
|
||||
const health = rec(pick(status, "health"))
|
||||
const labels = rec(pick(meta, "labels"))
|
||||
const summary = rec(pick(status, "summary"))
|
||||
// `status.summary.images: ["ghcr.io/hanzoai/x:v1.2.3"]` → repository + tag.
|
||||
const image = strList(pick(summary, "images"))[0] ?? ""
|
||||
const cut = image.lastIndexOf(":")
|
||||
const imageRepo = cut > 0 ? image.slice(0, cut) : image
|
||||
const imageTag = cut > 0 ? image.slice(cut + 1) : ""
|
||||
|
||||
return {
|
||||
name: str(pick(r, "name")) || str(pick(meta, "name")),
|
||||
namespace:
|
||||
str(pick(r, "namespace", "ns")) || str(pick(meta, "namespace")) || str(pick(dest, "namespace")) || "hanzo",
|
||||
env: str(pick(r, "env", "environment")) || str(pick(labels, "hanzo.ai/env")) || str(pick(source, "targetRevision")),
|
||||
role: str(pick(r, "role")),
|
||||
// Prefer the deployed image repository; fall back to the manifest repo URL.
|
||||
repository: str(pick(r, "repository", "repo")) || imageRepo || str(pick(source, "repoURL")),
|
||||
version: str(pick(r, "version", "tag")) || str(pick(sync, "revision")) || imageTag,
|
||||
runningVersion: str(pick(r, "runningVersion", "running_version")) || imageTag,
|
||||
health: str(pick(r, "health")) || str(pick(health, "status")),
|
||||
healthMessage:
|
||||
str(pick(r, "healthMessage", "health_message", "message")) || str(pick(health, "message")),
|
||||
sync: str(pick(r, "sync", "syncStatus", "sync_status")) || str(pick(sync, "status")),
|
||||
phase: str(pick(r, "phase")) || str(pick(health, "status")),
|
||||
endpoints: strList(pick(r, "endpoints", "urls")),
|
||||
revisions: strList(pick(r, "revisions", "history", "tags")),
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse the applications list payload (array or {applications|apps|items}). */
|
||||
export function parseApplications(data: unknown): DeployApp[] {
|
||||
const rows = Array.isArray(data) ? data : arr(pick(rec(data), "applications", "apps", "items", "services"))
|
||||
return rows.map(normalizeDeployApp).filter((a) => a.name)
|
||||
}
|
||||
|
||||
/** Fold a normalized app into the @hanzo/gitops application view-model. */
|
||||
export function toGitopsApp(a: DeployApp): GitopsApplication {
|
||||
return {
|
||||
name: a.name,
|
||||
namespace: a.namespace,
|
||||
project: a.env || undefined,
|
||||
health: fHealth(a.health),
|
||||
// desired (declared version) vs live (runningVersion): equal ⇒ Synced.
|
||||
sync: a.sync ? fSync(a.sync) : fSync(a.version === a.runningVersion ? "synced" : "outofsync"),
|
||||
revision: a.version || undefined,
|
||||
source: a.repository ? { repoURL: a.repository } : undefined,
|
||||
message: a.healthMessage || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// ── tree → ResourceTree (AppTreeNode with linking parentRefs) ────────────────
|
||||
|
||||
/** Map a /v1/deploy ResourceRef DTO (carrying its `ref` token) to a linking ref.
|
||||
* The `ref` token is the stable id shared by a node and its children's
|
||||
* parentRefs, so it drives resourceId() — the tree links on it. */
|
||||
function toRef(raw: unknown): ResourceRef {
|
||||
const r = rec(raw)
|
||||
const token = str(pick(r, "ref"))
|
||||
return {
|
||||
uid: token || str(pick(r, "uid")),
|
||||
group: str(pick(r, "group")),
|
||||
version: str(pick(r, "version")),
|
||||
kind: str(pick(r, "kind")),
|
||||
namespace: str(pick(r, "namespace", "ns")),
|
||||
name: str(pick(r, "name")),
|
||||
}
|
||||
}
|
||||
|
||||
function toTreeNode(raw: unknown): AppTreeNode {
|
||||
const r = rec(raw)
|
||||
const self = toRef(r)
|
||||
const version = str(pick(r, "version"))
|
||||
return {
|
||||
...self,
|
||||
parentRefs: arr(pick(r, "parentRefs", "parent_refs")).map(toRef),
|
||||
health: { status: fHealth(str(pick(r, "health"))), message: str(pick(r, "healthMessage")) || undefined },
|
||||
sync: pick(r, "sync") ? fSync(str(pick(r, "sync"))) : undefined,
|
||||
images: version ? [version] : [],
|
||||
createdAt: epochMs(pick(r, "createdAt", "created_at", "creationTimestamp")),
|
||||
}
|
||||
}
|
||||
|
||||
export function toResourceTree(raw: unknown): ResourceTree {
|
||||
const r = rec(raw)
|
||||
const nodes = arr(pick(r, "nodes", "resources"))
|
||||
.map(toTreeNode)
|
||||
.filter((n) => n.name && n.kind)
|
||||
// Defense-in-depth: never render a Secret node (its live manifest carries
|
||||
// base64 data). Cloud already excludes Secrets from the tree; drop any that
|
||||
// slip through so the client never surfaces one.
|
||||
.filter((n) => n.kind !== "Secret")
|
||||
return { nodes }
|
||||
}
|
||||
|
||||
// ── resource → ManagedResource (live + desired for the node drawer/diff) ─────
|
||||
|
||||
export function toManagedResource(raw: unknown): ManagedResource {
|
||||
const r = rec(raw)
|
||||
const refField = pick(r, "ref")
|
||||
const self: ResourceRef =
|
||||
typeof refField === "string"
|
||||
? { uid: refField, group: "", version: "", kind: "", namespace: "", name: "" }
|
||||
: toRef(refField)
|
||||
const diffObj = rec(pick(r, "diff"))
|
||||
const desired = pick(r, "desiredManifest", "desired") ?? pick(diffObj, "desiredManifest", "desired")
|
||||
return {
|
||||
...self,
|
||||
liveState: manifestText(pick(r, "liveManifest", "live_manifest", "live", "manifest")),
|
||||
targetState: manifestText(desired),
|
||||
}
|
||||
}
|
||||
|
||||
// ── logs (blob OR array) → LogLine[], bounded ────────────────────────────────
|
||||
|
||||
// Client-side caps so one giant log payload can't bloat the DOM even if the
|
||||
// server's `?tail` is absent or ignored: keep the last MAX_LOG_LINES, and clamp
|
||||
// any single line (a newline-free megabyte otherwise renders as one huge node).
|
||||
const MAX_LOG_LINES = 2000
|
||||
const MAX_LOG_LINE = 4000
|
||||
|
||||
export function toLogLines(raw: unknown): LogLine[] {
|
||||
const r = rec(raw)
|
||||
const pod = str(pick(r, "pod")) || undefined
|
||||
// Tolerate both shapes: a newline blob (`logs:"…"`) and a line array
|
||||
// (`logs:[…]` / `lines:[…]`), which the blob-only path silently dropped to [].
|
||||
const listed = arr(pick(r, "logs", "lines", "log", "output"))
|
||||
const lines = listed.length
|
||||
? listed.map(str)
|
||||
: str(pick(r, "logs", "log", "output")).split("\n")
|
||||
const kept = lines.filter((l) => l.length > 0).slice(-MAX_LOG_LINES)
|
||||
return kept.map((l) => ({ content: l.length > MAX_LOG_LINE ? l.slice(0, MAX_LOG_LINE) + "…" : l, podName: pod }))
|
||||
}
|
||||
|
||||
// ── git tags → RevisionHistory[] (rollback targets; cloud takes a clean semver) ─
|
||||
|
||||
const SEMVER = /^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/
|
||||
export const isReleaseTag = (t: string): boolean => SEMVER.test(t.trim())
|
||||
|
||||
/** Build rollback history from an app's real git tags (clean semver, newest first,
|
||||
* current excluded). `revision` carries the tag cloud's rollback endpoint accepts. */
|
||||
export function toRollbackHistory(currentTag: string, tags: string[]): RevisionHistory[] {
|
||||
const seen = new Set<string>([currentTag.trim()])
|
||||
const clean: string[] = []
|
||||
for (const raw of tags) {
|
||||
const t = raw.trim()
|
||||
if (!t || seen.has(t) || !isReleaseTag(t)) continue
|
||||
seen.add(t)
|
||||
clean.push(t)
|
||||
}
|
||||
clean.sort(compareSemverDesc)
|
||||
return clean.map((revision, i) => ({ id: clean.length - i, revision }))
|
||||
}
|
||||
|
||||
export function compareSemverDesc(a: string, b: string): number {
|
||||
const pa = semverParts(a)
|
||||
const pb = semverParts(b)
|
||||
for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pb[i] - pa[i]
|
||||
return b.localeCompare(a)
|
||||
}
|
||||
function semverParts(tag: string): [number, number, number] {
|
||||
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(tag.trim())
|
||||
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : [-1, -1, -1]
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* The typed client for cloud's native CD plane (`/v1/deploy`). Same-origin: the
|
||||
* cd.hanzo.ai ingress peels `/v1/deploy/*` off to the cloud binary, so the SPA
|
||||
* calls it with `credentials: 'include'` and the first-party `hanzo_iam_token`
|
||||
* cookie (set by the PKCE login) rides — cloud validates it (SuperAdmin gate). A
|
||||
* 401/403 surfaces as an `ApiError` so the app can send the user back to sign in.
|
||||
*
|
||||
* Responses are mapped INTO the shared `@hanzo/gitops` view-models by `./adapt`.
|
||||
*/
|
||||
import type { LogLine, ManagedResource, ResourceTree } from "@hanzo/gitops"
|
||||
|
||||
import { parseApplications, toLogLines, toManagedResource, toResourceTree, type DeployApp } from "./adapt"
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
constructor(message: string, status: number) {
|
||||
super(message)
|
||||
this.name = "ApiError"
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the error means "not signed in / not authorized" (→ sign-in screen). */
|
||||
export const isAuthError = (e: unknown): boolean => e instanceof ApiError && (e.status === 401 || e.status === 403)
|
||||
|
||||
const url = (path: string): string => `/v1/deploy/${path.replace(/^\/+/, "")}`
|
||||
|
||||
async function request<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(url(path), {
|
||||
method,
|
||||
credentials: "include",
|
||||
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
} catch (e) {
|
||||
throw new ApiError(e instanceof Error ? e.message : "network error", 0)
|
||||
}
|
||||
if (!res.ok) {
|
||||
let msg = `${res.status} ${res.statusText}`
|
||||
try {
|
||||
const j = (await res.json()) as { error?: string; message?: string }
|
||||
msg = j.error || j.message || msg
|
||||
} catch {
|
||||
/* non-JSON body */
|
||||
}
|
||||
throw new ApiError(msg, res.status)
|
||||
}
|
||||
if (res.status === 204) return undefined as T
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
export const DeployApi = {
|
||||
/** The fleet: every operator App CR (`GET /v1/deploy/applications`). */
|
||||
applications: async (): Promise<DeployApp[]> => parseApplications(await request<unknown>("GET", "applications")),
|
||||
|
||||
/**
|
||||
* One application's owned-resource tree
|
||||
* (`GET /v1/deploy/applications/:name/resource-tree`).
|
||||
*
|
||||
* The wired route lives under `applications/` — the shorter `:name/tree` form
|
||||
* belongs to an unregistered handler, so it 404s against the live plane.
|
||||
*/
|
||||
tree: async (name: string): Promise<ResourceTree> =>
|
||||
toResourceTree(await request<unknown>("GET", `applications/${encodeURIComponent(name)}/resource-tree`)),
|
||||
|
||||
/** One tree node's live manifest + desired-vs-live (`GET /v1/deploy/:name/resource/:ref`). */
|
||||
resource: async (name: string, ref: string): Promise<ManagedResource> =>
|
||||
toManagedResource(await request<unknown>("GET", `${encodeURIComponent(name)}/resource/${encodeURIComponent(ref)}`)),
|
||||
|
||||
/** The newest pod's logs (`GET /v1/deploy/:name/logs`). */
|
||||
logs: async (name: string, tail = 300): Promise<LogLine[]> =>
|
||||
toLogLines(await request<unknown>("GET", `${encodeURIComponent(name)}/logs?tail=${tail}`)),
|
||||
|
||||
/** Pin the CR image to a prior clean-semver release (`POST /v1/deploy/:name/rollback`). */
|
||||
rollback: async (name: string, tag: string): Promise<void> => {
|
||||
await request<unknown>("POST", `${encodeURIComponent(name)}/rollback`, { tag })
|
||||
},
|
||||
|
||||
/** Request an operator reconcile now (`POST /v1/deploy/:name/sync`). */
|
||||
sync: async (name: string): Promise<void> => {
|
||||
await request<unknown>("POST", `${encodeURIComponent(name)}/sync`, {})
|
||||
},
|
||||
}
|
||||
|
||||
/** Read the `hanzo_iam_token` cookie the PKCE login sets (non-httpOnly by design). */
|
||||
export function hasSession(): boolean {
|
||||
return document.cookie.split(";").some((c) => c.trim().startsWith("hanzo_iam_token="))
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
|
||||
import { App } from "./App"
|
||||
import { ErrorBoundary } from "./shell/ErrorBoundary"
|
||||
import "./styles.css"
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react"
|
||||
|
||||
/**
|
||||
* Top-level boundary so a single component render throw can never white-screen
|
||||
* the whole dashboard. A caught error shows a recover panel (back to the fleet /
|
||||
* reload) instead of an unmounted root. Deliberately dependency-free.
|
||||
*/
|
||||
export class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
|
||||
state = { error: null as Error | null }
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
// Surface to the console for the operator; never log identity/token.
|
||||
console.error("[cd] render error", error, info.componentStack)
|
||||
}
|
||||
|
||||
private reset = () => {
|
||||
this.setState({ error: null })
|
||||
if (location.hash && location.hash !== "#/") location.hash = "#/"
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.error) return this.props.children
|
||||
return (
|
||||
<div style={{ maxWidth: 520, margin: "12vh auto", padding: "0 24px", textAlign: "center", fontFamily: "'Geist', ui-sans-serif, system-ui, sans-serif" }}>
|
||||
<h1 style={{ fontSize: 20, fontWeight: 700, margin: "0 0 8px" }}>Something went wrong</h1>
|
||||
<p style={{ color: "#8a8a8a", fontSize: 14, margin: "0 0 20px" }}>
|
||||
A view failed to render. Your fleet is unaffected — this is only the dashboard.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 10, justifyContent: "center" }}>
|
||||
<button onClick={this.reset} style={btn(true)}>← Back to fleet</button>
|
||||
<button onClick={() => location.reload()} style={btn(false)}>Reload</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const btn = (primary: boolean): React.CSSProperties => ({
|
||||
padding: "9px 16px",
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
borderRadius: 8,
|
||||
border: primary ? "0" : "1px solid #2a2a2a",
|
||||
background: primary ? "#fff" : "transparent",
|
||||
color: primary ? "#000" : "inherit",
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* The Hanzo CD topbar — the shared shell chrome for the dedicated dashboard: the
|
||||
* small Hanzo mark + wordmark (top-left), the environment scope switcher (the CD
|
||||
* "project" dimension — main/test/dev, the operator namespaces), a refresh, and
|
||||
* sign-out. Lean + self-contained (dark, Geist) so the static SPA has no heavy
|
||||
* shell dependency; the org/project↔IAM switcher lights up when /v1/iam is routed
|
||||
* on cd.hanzo.ai (today the SuperAdmin cookie sees the whole fleet).
|
||||
*/
|
||||
|
||||
const MARK = (size: number, fill: string) => (
|
||||
<svg width={size} height={size} viewBox="0 0 67 67" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M22.21 67V44.6369H0V67H22.21Z" fill={fill} />
|
||||
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill={fill} />
|
||||
<path d="M22.21 0H0V22.3184H22.21V0Z" fill={fill} />
|
||||
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill={fill} />
|
||||
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill={fill} />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export interface EnvOption {
|
||||
id: string
|
||||
label: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export function Topbar({
|
||||
envs,
|
||||
env,
|
||||
onEnv,
|
||||
onRefresh,
|
||||
refreshing,
|
||||
onSignOut,
|
||||
}: {
|
||||
envs: EnvOption[]
|
||||
env: string
|
||||
onEnv: (id: string) => void
|
||||
onRefresh: () => void
|
||||
refreshing?: boolean
|
||||
onSignOut: () => void
|
||||
}) {
|
||||
return (
|
||||
<header
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "10px 16px",
|
||||
borderBottom: "1px solid var(--cd-border)",
|
||||
background: "var(--cd-surface)",
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 20,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<a href="/" style={{ display: "flex", alignItems: "center", gap: 9, textDecoration: "none", color: "var(--cd-fg-strong)" }}>
|
||||
<span style={{ display: "inline-flex" }}>{MARK(18, "#fff")}</span>
|
||||
<span style={{ fontWeight: 800, letterSpacing: "-0.02em", fontSize: 15 }}>Hanzo CD</span>
|
||||
</a>
|
||||
|
||||
{envs.length > 1 ? (
|
||||
<div style={{ display: "inline-flex", gap: 2, background: "var(--cd-surface-2)", borderRadius: 8, padding: 2, border: "1px solid var(--cd-border)" }}>
|
||||
{[{ id: "", label: "All", count: envs.reduce((n, e) => n + e.count, 0) }, ...envs].map((o) => {
|
||||
const on = o.id === env
|
||||
return (
|
||||
<button
|
||||
key={o.id || "all"}
|
||||
type="button"
|
||||
onClick={() => onEnv(o.id)}
|
||||
className="cd-mono"
|
||||
style={{
|
||||
border: 0,
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
padding: "5px 10px",
|
||||
borderRadius: 6,
|
||||
background: on ? "var(--cd-border-strong)" : "transparent",
|
||||
color: on ? "var(--cd-fg-strong)" : "var(--cd-fg-muted)",
|
||||
}}
|
||||
>
|
||||
{o.label}
|
||||
<span style={{ opacity: 0.6, marginLeft: 5 }}>{o.count}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<span style={{ flex: 1 }} />
|
||||
|
||||
<button type="button" className="cd-btn" onClick={onRefresh} disabled={refreshing}>
|
||||
{refreshing ? "Refreshing…" : "Refresh"}
|
||||
</button>
|
||||
<button type="button" className="cd-btn" onClick={onSignOut}>
|
||||
Sign out
|
||||
</button>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export { MARK }
|
||||
@@ -0,0 +1,90 @@
|
||||
/* Hanzo CD — app chrome. The @hanzo/gitops components bring their OWN scoped CSS
|
||||
(GitopsStyles + THEME_VARS); this is only the shell + page shell, dark-first,
|
||||
Geist. Mobile-first: nothing scrolls the body horizontally. */
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--cd-bg: #0a0a0a;
|
||||
--cd-surface: #0d1117;
|
||||
--cd-surface-2: #161b22;
|
||||
--cd-border: #21262d;
|
||||
--cd-border-strong: #30363d;
|
||||
--cd-fg: #e7e7e7;
|
||||
--cd-fg-muted: #8b949e;
|
||||
--cd-fg-strong: #f0f6fc;
|
||||
--cd-accent: #2f81f7;
|
||||
--cd-sans: "Geist", "Geist Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
--cd-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
html {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
body {
|
||||
font-family: var(--cd-sans);
|
||||
background: var(--cd-bg);
|
||||
color: var(--cd-fg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.cd-app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
}
|
||||
.cd-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 20px;
|
||||
max-width: 1400px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.cd-main {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.cd-btn {
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--cd-border-strong);
|
||||
background: var(--cd-surface-2);
|
||||
color: var(--cd-fg);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 36px;
|
||||
}
|
||||
.cd-btn:hover {
|
||||
border-color: var(--cd-fg-muted);
|
||||
}
|
||||
.cd-btn--primary {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
border-color: #fff;
|
||||
}
|
||||
.cd-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.cd-muted {
|
||||
color: var(--cd-fg-muted);
|
||||
}
|
||||
.cd-mono {
|
||||
font-family: var(--cd-mono);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* The application detail — the ArgoCD-grade drill-in, composed from the shared
|
||||
* @hanzo/gitops components: `GitopsSyncPanel` (health/sync/revision + Sync /
|
||||
* Refresh / History), `GitopsAppTree` (the owned-resource topology), `GitopsNodeInfo`
|
||||
* (a node's manifest / desired-vs-live diff / events / logs), and `GitopsRollbackDialog`.
|
||||
*
|
||||
* Composed (not the fixed `GitopsAppDetails`) so a node's manifest + logs are
|
||||
* fetched LAZILY on selection (the plane's `/resource` + `/logs` reads) rather than
|
||||
* pre-fetched for the whole tree. Every effect is a real `/v1/deploy` call; honest
|
||||
* loading/empty/error states, never fabricated.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import {
|
||||
GitopsAppTree,
|
||||
GitopsNodeInfo,
|
||||
GitopsRollbackDialog,
|
||||
GitopsSyncPanel,
|
||||
type AppTreeNode,
|
||||
type LogLine,
|
||||
type ManagedResource,
|
||||
type ResourceTree,
|
||||
type SyncOptions,
|
||||
} from "@hanzo/gitops"
|
||||
|
||||
import { DeployApi } from "../lib/deploy"
|
||||
import { toGitopsApp, toRollbackHistory, type DeployApp } from "../lib/adapt"
|
||||
|
||||
export function AppView({
|
||||
app,
|
||||
onBack,
|
||||
onChanged,
|
||||
notify,
|
||||
}: {
|
||||
app: DeployApp
|
||||
onBack: () => void
|
||||
onChanged: () => void
|
||||
notify: (msg: string, err?: boolean) => void
|
||||
}) {
|
||||
const gitopsApp = toGitopsApp(app)
|
||||
const [tree, setTree] = useState<ResourceTree | null | undefined>(undefined)
|
||||
const [treeErr, setTreeErr] = useState<string>("")
|
||||
const [logs, setLogs] = useState<LogLine[]>([])
|
||||
const [selected, setSelected] = useState<AppTreeNode | null>(null)
|
||||
const [resource, setResource] = useState<ManagedResource | undefined>(undefined)
|
||||
const [rollbackOpen, setRollbackOpen] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const history = toRollbackHistory(app.version, app.revisions)
|
||||
|
||||
const load = useCallback(() => {
|
||||
let live = true
|
||||
setTree(undefined)
|
||||
setTreeErr("")
|
||||
setSelected(null)
|
||||
DeployApi.tree(app.name)
|
||||
.then((t) => live && setTree(t))
|
||||
.catch((e) => {
|
||||
if (live) {
|
||||
setTree(null)
|
||||
setTreeErr(e instanceof Error ? e.message : "failed to load")
|
||||
}
|
||||
})
|
||||
DeployApi.logs(app.name)
|
||||
.then((l) => live && setLogs(l))
|
||||
.catch(() => live && setLogs([]))
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [app.name])
|
||||
useEffect(() => load(), [load])
|
||||
|
||||
const onSelect = useCallback(
|
||||
(node: AppTreeNode | null) => {
|
||||
setSelected(node)
|
||||
setResource(undefined)
|
||||
if (!node) return
|
||||
DeployApi.resource(app.name, node.uid ?? "")
|
||||
.then(setResource)
|
||||
.catch(() => setResource(undefined))
|
||||
},
|
||||
[app.name],
|
||||
)
|
||||
|
||||
const onSync = useCallback(
|
||||
async (_opts: SyncOptions) => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await DeployApi.sync(app.name)
|
||||
notify(`Sync requested for ${app.name}`)
|
||||
onChanged()
|
||||
load()
|
||||
} catch (e) {
|
||||
notify(`Sync failed: ${e instanceof Error ? e.message : e}`, true)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
},
|
||||
[app.name, notify, onChanged, load],
|
||||
)
|
||||
|
||||
const onRollback = useCallback(
|
||||
async (revision: string) => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await DeployApi.rollback(app.name, revision)
|
||||
notify(`Rolled back ${app.name} → ${revision}`)
|
||||
onChanged()
|
||||
load()
|
||||
} catch (e) {
|
||||
notify(`Rollback failed: ${e instanceof Error ? e.message : e}`, true)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
},
|
||||
[app.name, notify, onChanged, load],
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
||||
<button type="button" className="cd-btn" onClick={onBack}>
|
||||
← Fleet
|
||||
</button>
|
||||
<span className="cd-muted">/</span>
|
||||
<span className="cd-mono" style={{ color: "var(--cd-fg-strong)", fontWeight: 700 }}>
|
||||
{app.name}
|
||||
</span>
|
||||
{app.env ? (
|
||||
<span className="cd-mono cd-muted" style={{ fontSize: 12 }}>
|
||||
· {app.env}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<GitopsSyncPanel
|
||||
app={gitopsApp}
|
||||
theme="dark"
|
||||
busy={busy}
|
||||
onSync={onSync}
|
||||
onRefresh={load}
|
||||
onRollback={() => setRollbackOpen(true)}
|
||||
/>
|
||||
|
||||
<div style={{ display: "flex", gap: 12, flexWrap: "wrap", alignItems: "flex-start" }}>
|
||||
{tree === undefined ? (
|
||||
<div className="cd-muted" style={{ padding: 24 }}>
|
||||
Loading topology…
|
||||
</div>
|
||||
) : tree === null ? (
|
||||
<div className="cd-muted" style={{ padding: 24 }}>
|
||||
Could not load the resource tree{treeErr ? `: ${treeErr}` : ""}.
|
||||
</div>
|
||||
) : tree.nodes.length === 0 ? (
|
||||
<div className="cd-muted" style={{ padding: 24 }}>
|
||||
No owned resources reported.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<GitopsAppTree
|
||||
tree={tree}
|
||||
theme="dark"
|
||||
onSelect={onSelect}
|
||||
selectedId={selected?.uid ?? null}
|
||||
height={400}
|
||||
style={{ flex: 1, minWidth: 280 }}
|
||||
/>
|
||||
{selected ? (
|
||||
<GitopsNodeInfo
|
||||
node={selected}
|
||||
theme="dark"
|
||||
resource={resource}
|
||||
logs={logs}
|
||||
onClose={() => onSelect(null)}
|
||||
style={{ width: "min(440px, 100%)", height: 400, flexShrink: 0 }}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<GitopsRollbackDialog
|
||||
open={rollbackOpen}
|
||||
history={history}
|
||||
theme="dark"
|
||||
busy={busy}
|
||||
onClose={() => setRollbackOpen(false)}
|
||||
onRollback={(entry) => {
|
||||
setRollbackOpen(false)
|
||||
void onRollback(entry.revision)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* The fleet view — every operator App CR as a row, health + sync + revision +
|
||||
* source, with the shared @hanzo/gitops `GitopsAppList` (search, health/sync
|
||||
* filters, sortable). A compact KPI band up top gives the at-a-glance fleet stats.
|
||||
* Data-prop-driven: the app fetches `/v1/deploy/applications` and hands the folded
|
||||
* rows in; nothing is fabricated.
|
||||
*/
|
||||
import { useMemo } from "react"
|
||||
import { GitopsAppList } from "@hanzo/gitops"
|
||||
|
||||
import { toGitopsApp, type DeployApp } from "../lib/adapt"
|
||||
|
||||
function Stat({ label, value, tone }: { label: string; value: number; tone?: string }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 104,
|
||||
border: "1px solid var(--cd-border-strong)",
|
||||
borderRadius: 10,
|
||||
padding: "12px 14px",
|
||||
background: "var(--cd-surface)",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 26, fontWeight: 800, color: tone && value > 0 ? tone : "var(--cd-fg-strong)" }}>{value}</div>
|
||||
<div className="cd-muted" style={{ fontSize: 13, marginTop: 2 }}>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FleetView({ apps, onOpen }: { apps: DeployApp[]; onOpen: (name: string) => void }) {
|
||||
const gitopsApps = useMemo(() => apps.map(toGitopsApp), [apps])
|
||||
|
||||
const kpi = useMemo(() => {
|
||||
const s = { total: 0, healthy: 0, progressing: 0, degraded: 0, outOfSync: 0 }
|
||||
for (const a of gitopsApps) {
|
||||
s.total++
|
||||
if (a.health === "Healthy") s.healthy++
|
||||
else if (a.health === "Progressing") s.progressing++
|
||||
else if (a.health === "Degraded" || a.health === "Missing") s.degraded++
|
||||
if (a.sync === "OutOfSync") s.outOfSync++
|
||||
}
|
||||
return s
|
||||
}, [gitopsApps])
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 800, margin: "0 0 4px", letterSpacing: "-0.02em" }}>Fleet</h1>
|
||||
<p className="cd-muted" style={{ margin: 0, fontSize: 14, maxWidth: "60ch" }}>
|
||||
Every Hanzo App reconciled by the operator — declarative, versioned, auto-synced from git to your clusters. Open
|
||||
one for its resource tree, logs, and sync/rollback.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
|
||||
<Stat label="Applications" value={kpi.total} />
|
||||
<Stat label="Healthy" value={kpi.healthy} tone="#18BE94" />
|
||||
<Stat label="Progressing" value={kpi.progressing} tone="#0DADEA" />
|
||||
<Stat label="Degraded" value={kpi.degraded} tone="#E96D76" />
|
||||
<Stat label="Out of sync" value={kpi.outOfSync} tone="#f4c030" />
|
||||
</div>
|
||||
|
||||
<GitopsAppList
|
||||
applications={gitopsApps}
|
||||
theme="dark"
|
||||
view="table"
|
||||
onOpen={(a) => onOpen(a.name)}
|
||||
emptyLabel="No applications reported by the deploy plane yet."
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@hanzo/gitops": ["../../pkgs/gitops/dist/index.d.ts"],
|
||||
"@hanzo/canvas/pure": ["../../pkgs/canvas/src/pure.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import { fileURLToPath, URL } from "node:url"
|
||||
|
||||
import react from "@vitejs/plugin-react"
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
/**
|
||||
* Hanzo CD — a static SPA served at cd.hanzo.ai from the shared static plane
|
||||
* (s3://cdn/cd, ingress staticFiles/spaMode). `/v1/deploy/*` is peeled off to the
|
||||
* cloud binary by the ingress, so in prod the SPA calls same-origin `/v1/deploy`
|
||||
* with the `hanzo_iam_token` cookie (cloud validates → SuperAdmin gate).
|
||||
*
|
||||
* The @hanzo/gitops (built dist) + @hanzo/canvas/pure (pure TS source) workspace
|
||||
* packages are resolved by path alias so the app builds without a full monorepo
|
||||
* install (the shared store lacks the registry deps of the heavier packages). A
|
||||
* dev proxy forwards `/v1` to CD_API (default the live cluster) for local dev.
|
||||
*/
|
||||
const r = (p: string) => fileURLToPath(new URL(p, import.meta.url))
|
||||
const CD_API = process.env.CD_API ?? "https://cd.hanzo.ai"
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
dedupe: ["react", "react-dom"],
|
||||
alias: {
|
||||
"@hanzo/gitops": r("../../pkgs/gitops/dist/index.mjs"),
|
||||
"@hanzo/canvas/pure": r("../../pkgs/canvas/src/pure.ts"),
|
||||
},
|
||||
},
|
||||
// Plain CSS only — override the monorepo-root PostCSS/Tailwind config discovery
|
||||
// (this app has no Tailwind; the @hanzo/gitops components ship their own scoped CSS).
|
||||
css: { postcss: { plugins: [] } },
|
||||
build: { outDir: "dist", target: "es2022", sourcemap: false },
|
||||
server: {
|
||||
port: 4200,
|
||||
proxy: { "/v1": { target: CD_API, changeOrigin: true, secure: false } },
|
||||
},
|
||||
// The e2e/ specs are Playwright's (they use test.beforeAll); vitest must not
|
||||
// glob them, or a bare `vitest run` reports a red suite for a passing app.
|
||||
test: { exclude: ["e2e/**", "node_modules/**", "dist/**"] },
|
||||
})
|
||||
@@ -51,7 +51,6 @@
|
||||
"format:check": "turbo run format:check",
|
||||
"sync:templates": "./scripts/sync-templates.sh \"templates/*\"",
|
||||
"check": "bash scripts/check-no-brand-pkg.sh && turbo lint typecheck format:check",
|
||||
"release": "changeset version",
|
||||
"pub:beta": "cd pkgs/hanzo && pnpm pub:beta",
|
||||
"pub:release": "cd pkgs/hanzo && pnpm pub:release",
|
||||
"test:dev": "turbo run test --filter=!hanzo-ui --force",
|
||||
@@ -71,8 +70,6 @@
|
||||
"packageManager": "pnpm@9.0.6",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.29.7",
|
||||
"@changesets/changelog-github": "^0.7.0",
|
||||
"@changesets/cli": "^2.31.0",
|
||||
"@commitlint/cli": "^21.0.2",
|
||||
"@commitlint/config-conventional": "^21.0.2",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Build artifacts — declarations are emitted by `pnpm run build` at pack time.
|
||||
types/
|
||||
*.tsbuildinfo
|
||||
# Standalone install (this package is not in the root pnpm workspace).
|
||||
node_modules/
|
||||
pnpm-lock.yaml
|
||||
+2
-3
@@ -1,8 +1,7 @@
|
||||
# Keep the published tarball lean — ship src, not tests or dev type-check config.
|
||||
# Keep the published tarball lean — ship src + compiled types, not tests or dev config.
|
||||
**/*.test.ts
|
||||
**/*.test.tsx
|
||||
tsconfig.json
|
||||
tsconfig.build.json
|
||||
vitest.config.ts
|
||||
gui.config.ts
|
||||
gui.d.ts
|
||||
node_modules
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
// The @hanzo/gui v5 config this package is authored against. `createGui` with the
|
||||
// shared `@hanzogui/config/v5` default enables the shorthand style props
|
||||
// (bg/px/py/items/justify/rounded/…) the components use; `Conf` feeds the type
|
||||
// augmentation in `gui.d.ts` so `tsc --noEmit` type-checks the shorthands exactly
|
||||
// as the consuming app (console) does. Not shipped — dev/type-check only.
|
||||
import { defaultConfig } from '@hanzogui/config/v5'
|
||||
import { createGui } from '@hanzo/gui'
|
||||
|
||||
export const config = createGui(defaultConfig)
|
||||
|
||||
export default config
|
||||
|
||||
export type Conf = typeof config
|
||||
Vendored
-15
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* Registers our Gui config with the type system so shorthand style props
|
||||
* (tokens, themes, bg/px/py/items/justify etc.) type-check exactly as they do in
|
||||
* the consuming app. GuiCustomConfig is declared in @hanzogui/web and flows
|
||||
* through @hanzo/gui. Dev/type-check only — never shipped.
|
||||
*/
|
||||
import type { Conf } from './gui.config'
|
||||
|
||||
declare module '@hanzogui/web' {
|
||||
interface GuiCustomConfig extends Conf {}
|
||||
}
|
||||
|
||||
declare module '@hanzogui/core' {
|
||||
interface GuiCustomConfig extends Conf {}
|
||||
}
|
||||
+13
-7
@@ -1,38 +1,42 @@
|
||||
{
|
||||
"name": "@hanzo/data",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"type": "module",
|
||||
"description": "Hanzo Data — cross-platform, metadata-driven data-app components (typed fields, record table, kanban board, record detail, saved views) on @hanzo/gui. The universal object/field/record/view layer that powers any Base-backed CRM, CMS, or commerce app on web, native (iOS), and desktop. Airtable/Twenty-class polish, clean-room.",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"types": "./types/index.d.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./table/logic": {
|
||||
"types": "./src/table/logic.ts",
|
||||
"types": "./types/table/logic.d.ts",
|
||||
"default": "./src/table/logic.ts"
|
||||
},
|
||||
"./board/logic": {
|
||||
"types": "./src/board/logic.ts",
|
||||
"types": "./types/board/logic.d.ts",
|
||||
"default": "./src/board/logic.ts"
|
||||
},
|
||||
"./view/logic": {
|
||||
"types": "./src/view/logic.ts",
|
||||
"types": "./types/view/logic.d.ts",
|
||||
"default": "./src/view/logic.ts"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"module": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"types": "./types/index.d.ts",
|
||||
"sideEffects": [
|
||||
"**/registerDefaults.ts",
|
||||
"**/index.ts"
|
||||
],
|
||||
"files": [
|
||||
"src"
|
||||
"src",
|
||||
"types"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"clean": "rm -rf types",
|
||||
"prepack": "pnpm run build",
|
||||
"tc": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
@@ -45,7 +49,9 @@
|
||||
"devDependencies": {
|
||||
"@hanzo/gui": "7.3.0",
|
||||
"@hanzogui/config": "7.3.0",
|
||||
"@hanzogui/core": "7.3.0",
|
||||
"@hanzogui/lucide-icons-2": "7.3.0",
|
||||
"@hanzogui/web": "7.3.0",
|
||||
"@types/react": "^19.1.10",
|
||||
"react": "^19.0.0",
|
||||
"typescript": "^5.7.2",
|
||||
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Registers this package's @hanzo/gui config with the type system so the
|
||||
* shorthand style props (bg / px / py / items / justify / gap / rounded …) that
|
||||
* the components author against resolve to their concrete types DURING THIS
|
||||
* PACKAGE'S OWN COMPILATION.
|
||||
*
|
||||
* Because the resolution happens here, the emitted `.d.ts` (see
|
||||
* `tsconfig.build.json`) bakes in the concrete prop types — a consumer type-checks
|
||||
* against the compiled declarations and never has to re-declare this augmentation
|
||||
* (nor descend into our `.tsx` internals). Ambient + type-only; never emitted.
|
||||
*
|
||||
* `GuiCustomConfig` is the extension point declared by @hanzogui/web and re-declared
|
||||
* by @hanzogui/core; `InferGuiConfig<typeof defaultConfig>` (the return type of
|
||||
* `createGui(defaultConfig)`) is the exact config the components are written for.
|
||||
*/
|
||||
import type { createGui } from '@hanzo/gui'
|
||||
import type { defaultConfig } from '@hanzogui/config/v5'
|
||||
|
||||
type Conf = ReturnType<typeof createGui<typeof defaultConfig>>
|
||||
|
||||
declare module '@hanzogui/web' {
|
||||
interface GuiCustomConfig extends Conf {}
|
||||
}
|
||||
|
||||
declare module '@hanzogui/core' {
|
||||
interface GuiCustomConfig extends Conf {}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "./types",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "types", "**/*.test.ts", "**/*.test.tsx"]
|
||||
}
|
||||
@@ -14,6 +14,6 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["react"]
|
||||
},
|
||||
"include": ["gui.config.ts", "gui.d.ts", "src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist", "types"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Build artifacts — declarations are emitted by `pnpm run build` at pack time.
|
||||
types/
|
||||
*.tsbuildinfo
|
||||
# Standalone install (this package is not in the root pnpm workspace).
|
||||
node_modules/
|
||||
pnpm-lock.yaml
|
||||
+2
-3
@@ -1,8 +1,7 @@
|
||||
# Keep the published tarball lean — ship src, not tests or dev type-check config.
|
||||
# Keep the published tarball lean — ship src + compiled types, not tests or dev config.
|
||||
**/*.test.ts
|
||||
**/*.test.tsx
|
||||
tsconfig.json
|
||||
tsconfig.build.json
|
||||
vitest.config.ts
|
||||
gui.config.ts
|
||||
gui.d.ts
|
||||
node_modules
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
// The @hanzo/gui v5 config this package is authored against. `createGui` with the
|
||||
// shared `@hanzogui/config/v5` default enables the shorthand style props
|
||||
// (bg/px/py/items/justify/rounded/…) the components use; `Conf` feeds the type
|
||||
// augmentation in `gui.d.ts` so `tsc --noEmit` type-checks the shorthands exactly
|
||||
// as the consuming app (console) does. Not shipped — dev/type-check only.
|
||||
import { defaultConfig } from '@hanzogui/config/v5'
|
||||
import { createGui } from '@hanzo/gui'
|
||||
|
||||
export const config = createGui(defaultConfig)
|
||||
|
||||
export default config
|
||||
|
||||
export type Conf = typeof config
|
||||
Vendored
-15
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* Registers our Gui config with the type system so shorthand style props
|
||||
* (tokens, themes, bg/px/py/items/justify etc.) type-check exactly as they do in
|
||||
* the consuming app. GuiCustomConfig is declared in @hanzogui/web and flows
|
||||
* through @hanzo/gui. Dev/type-check only — never shipped.
|
||||
*/
|
||||
import type { Conf } from './gui.config'
|
||||
|
||||
declare module '@hanzogui/web' {
|
||||
interface GuiCustomConfig extends Conf {}
|
||||
}
|
||||
|
||||
declare module '@hanzogui/core' {
|
||||
interface GuiCustomConfig extends Conf {}
|
||||
}
|
||||
+110
-18
@@ -1,63 +1,155 @@
|
||||
{
|
||||
"name": "@hanzo/ui",
|
||||
"version": "8.0.0",
|
||||
"version": "8.0.8",
|
||||
"type": "module",
|
||||
"description": "Hanzo UI — the one cross-platform component library on @hanzo/gui. The product/app layer (charts, metrics, page headers, status tags, rich empty states, combobox, slide-over, toasts, drag-reorder, labeled field rows, provider/product marks) + the metadata-driven record layer (@hanzo/data: RecordsView, DataTable, board, typed field editors) + the calm dark-first tokens and motion. Presentational, host-agnostic (data/effects injected), clean-room. Web + native (iOS) + desktop.",
|
||||
"description": "Hanzo UI \u2014 the one cross-platform component library on @hanzo/gui. The product/app layer (charts, metrics, page headers, status tags, rich empty states, combobox, slide-over, toasts, drag-reorder, labeled field rows, provider/product marks) + the metadata-driven record layer (@hanzo/data: RecordsView, DataTable, board, typed field editors) + the calm dark-first tokens and motion. Presentational, host-agnostic (data/effects injected), clean-room. Web + native (iOS) + desktop.",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./product": {
|
||||
"types": "./src/product/index.ts",
|
||||
"default": "./src/product/index.ts"
|
||||
"types": "./dist/product/index.d.ts",
|
||||
"import": "./dist/product/index.js",
|
||||
"require": "./dist/product/index.cjs",
|
||||
"default": "./dist/product/index.js"
|
||||
},
|
||||
"./data": {
|
||||
"types": "./src/data.ts",
|
||||
"default": "./src/data.ts"
|
||||
"types": "./dist/data.d.ts",
|
||||
"import": "./dist/data.js",
|
||||
"require": "./dist/data.cjs",
|
||||
"default": "./dist/data.js"
|
||||
},
|
||||
"./primitives/bases/data": {
|
||||
"types": "./src/primitives/bases/data/index.ts",
|
||||
"default": "./src/primitives/bases/data/index.ts"
|
||||
"types": "./dist/primitives/bases/data/index.d.ts",
|
||||
"import": "./dist/primitives/bases/data/index.js",
|
||||
"require": "./dist/primitives/bases/data/index.cjs",
|
||||
"default": "./dist/primitives/bases/data/index.js"
|
||||
},
|
||||
"./styles/motion.css": "./src/styles/hanzo-motion.css",
|
||||
"./gitops": {
|
||||
"types": "./dist/gitops.d.ts",
|
||||
"import": "./dist/gitops.js",
|
||||
"require": "./dist/gitops.cjs",
|
||||
"default": "./dist/gitops.js"
|
||||
},
|
||||
"./canvas": {
|
||||
"types": "./dist/canvas.d.ts",
|
||||
"import": "./dist/canvas.js",
|
||||
"require": "./dist/canvas.cjs",
|
||||
"default": "./dist/canvas.js"
|
||||
},
|
||||
"./wallet": {
|
||||
"types": "./dist/wallet.d.ts",
|
||||
"import": "./dist/wallet.js",
|
||||
"require": "./dist/wallet.cjs",
|
||||
"default": "./dist/wallet.js"
|
||||
},
|
||||
"./network": {
|
||||
"types": "./dist/network.d.ts",
|
||||
"import": "./dist/network.js",
|
||||
"require": "./dist/network.cjs",
|
||||
"default": "./dist/network.js"
|
||||
},
|
||||
"./billing": {
|
||||
"types": "./dist/billing.d.ts",
|
||||
"import": "./dist/billing.js",
|
||||
"require": "./dist/billing.cjs",
|
||||
"default": "./dist/billing.js"
|
||||
},
|
||||
"./dashboard": {
|
||||
"types": "./dist/dashboard.d.ts",
|
||||
"import": "./dist/dashboard.js",
|
||||
"require": "./dist/dashboard.cjs",
|
||||
"default": "./dist/dashboard.js"
|
||||
},
|
||||
"./usage": {
|
||||
"types": "./dist/usage.d.ts",
|
||||
"import": "./dist/usage.js",
|
||||
"require": "./dist/usage.cjs",
|
||||
"default": "./dist/usage.js"
|
||||
},
|
||||
"./styles/motion.css": "./dist/styles/hanzo-motion.css",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"module": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"files": [
|
||||
"src"
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup && tsc -p tsconfig.build.json && mkdir -p dist/styles && cp src/styles/hanzo-motion.css dist/styles/hanzo-motion.css && node scripts/add-use-client.mjs",
|
||||
"clean": "rm -rf dist types",
|
||||
"prepack": "pnpm run build",
|
||||
"tc": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@hanzo/canvas": ">=0.1.0",
|
||||
"@hanzo/dashboard": ">=0.1.0",
|
||||
"@hanzo/data": ">=1.2.0",
|
||||
"@hanzo/gitops": ">=0.1.0",
|
||||
"@hanzo/gui": ">=7.2.2",
|
||||
"@hanzo/ui-shadcn": ">=5.7.0",
|
||||
"@hanzo/usage": ">=0.1.0",
|
||||
"@hanzogui/next-theme": ">=7.3.0",
|
||||
"react": ">=19"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@hanzo/canvas": {
|
||||
"optional": true
|
||||
},
|
||||
"@hanzo/dashboard": {
|
||||
"optional": true
|
||||
},
|
||||
"@hanzo/gitops": {
|
||||
"optional": true
|
||||
},
|
||||
"@hanzo/ui-shadcn": {
|
||||
"optional": true
|
||||
},
|
||||
"@hanzo/usage": {
|
||||
"optional": true
|
||||
},
|
||||
"@hanzogui/next-theme": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hanzo/data": "1.2.0",
|
||||
"@hanzo/canvas": "workspace:*",
|
||||
"@hanzo/dashboard": "workspace:*",
|
||||
"@hanzo/data": "1.2.1",
|
||||
"@hanzo/gitops": "workspace:*",
|
||||
"@hanzo/gui": "7.3.0",
|
||||
"@hanzo/ui-shadcn": "workspace:*",
|
||||
"@hanzo/usage": "^0.1.6",
|
||||
"@hanzogui/config": "7.3.0",
|
||||
"@hanzogui/core": "7.3.0",
|
||||
"@hanzogui/lucide-icons-2": "7.3.0",
|
||||
"@hanzogui/next-theme": "7.3.0",
|
||||
"@hanzogui/web": "7.3.0",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^2.1.9"
|
||||
"vitest": "^4.1.8",
|
||||
"react-native-web": "^0.21.2",
|
||||
"tsup": "^8.5.1"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"author": "Hanzo AI <dev@hanzo.ai>"
|
||||
"author": "Hanzo AI <dev@hanzo.ai>",
|
||||
"dependencies": {
|
||||
"@hanzo/logo": "^1.0.13"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Prepend `'use client';` to every compiled JS/CJS file in dist.
|
||||
//
|
||||
// The whole library is client-side @hanzo/gui (Tamagui) UI that uses React hooks, so
|
||||
// every module is a client module. tsup's `banner` is unreliable with code splitting
|
||||
// (it misses chunks), and Next's flight-client loader needs the directive to be the
|
||||
// FIRST statement — so we stamp it deterministically here, post-build.
|
||||
import { readdirSync, readFileSync, writeFileSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const DIST = new URL('../dist/', import.meta.url).pathname
|
||||
const DIRECTIVE = "'use client';\n"
|
||||
|
||||
function walk(dir) {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name)
|
||||
if (statSync(p).isDirectory()) {
|
||||
walk(p)
|
||||
} else if (/\.(c?js|mjs)$/.test(name)) {
|
||||
const src = readFileSync(p, 'utf8')
|
||||
if (!/^['"]use client['"]/.test(src)) writeFileSync(p, DIRECTIVE + src)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(DIST)
|
||||
console.log("stamped 'use client' across dist")
|
||||
@@ -0,0 +1,9 @@
|
||||
// @hanzo/ui/billing — the shared billing surface, re-exported from its home
|
||||
// module @hanzo/ui-shadcn/billing. `CreditModal` presents trial + prepaid credit
|
||||
// buckets with an injected top-up handler (no payment logic in the component).
|
||||
// Thin subpath so every console shows the same credit UI:
|
||||
//
|
||||
// import { CreditModal } from '@hanzo/ui/billing'
|
||||
//
|
||||
// Optional peer — only pulled when the subpath is used.
|
||||
export * from '@hanzo/ui-shadcn/billing'
|
||||
@@ -0,0 +1,11 @@
|
||||
// @hanzo/ui/canvas — the PaaS project canvas, re-exported from its home package
|
||||
// @hanzo/canvas (a Railway-grade pannable/zoomable board of service nodes with
|
||||
// live status, metric sparklines, deploy timelines, an environment switcher and
|
||||
// a service detail drawer). Kept as a thin subpath so a console can
|
||||
//
|
||||
// import { ProjectCanvas, ServiceNode, ServiceStatusBadge, DeployTimeline,
|
||||
// EnvSwitcher, ServiceDetailDrawer } from '@hanzo/ui/canvas'
|
||||
//
|
||||
// while the code lives once in @hanzo/canvas. Optional peer — only pulled when
|
||||
// the subpath is used.
|
||||
export * from '@hanzo/canvas'
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Deterministic layered graph layout — pure, no React, no DOM, unit-tested. This
|
||||
* is the dependency-free equivalent of the GitOps engine UI's dagre `rankdir:LR`
|
||||
* pass: resources flow left→right by ownership depth, each tier a clean column.
|
||||
*
|
||||
* Three folds:
|
||||
* 1. columns — longest-path layering (Kahn topological order). A root (no
|
||||
* incoming edge) sits in column 0; every other node one column right of its
|
||||
* deepest parent. Cycles degrade gracefully (a child keeps its best column),
|
||||
* so a malformed graph can never wedge the layout.
|
||||
* 2. order — within a column, nodes are ordered by the barycentre of their
|
||||
* parents' rows (then name), so children cluster under their owner and edge
|
||||
* crossings stay low — dagre's crossing-reduction, cheaply.
|
||||
* 3. place — nodes stack top-to-bottom at their own height (pods are taller
|
||||
* than controllers) with a fixed gap, and each column is vertically centred
|
||||
* on a shared axis so tiers read as balanced columns.
|
||||
*
|
||||
* The same graph always yields byte-identical positions.
|
||||
*/
|
||||
|
||||
export interface LayoutNode {
|
||||
id: string
|
||||
/** Rendered height in px (pods are taller than controllers). */
|
||||
h: number
|
||||
}
|
||||
|
||||
export interface LayoutEdge {
|
||||
source: string
|
||||
target: string
|
||||
}
|
||||
|
||||
export interface LayoutOptions {
|
||||
/** Uniform node width in px (x is placed on a fixed column pitch). */
|
||||
nodeWidth?: number
|
||||
/** Horizontal gap between a column's right edge and the next column. */
|
||||
columnGap?: number
|
||||
/** Vertical gap between stacked nodes in a column. */
|
||||
rowGap?: number
|
||||
}
|
||||
|
||||
export interface PositionedNode {
|
||||
id: string
|
||||
/** Top-left corner, px. */
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
col: number
|
||||
}
|
||||
|
||||
export interface GraphLayout {
|
||||
nodes: PositionedNode[]
|
||||
/** Content bounds — the transformed layer's intrinsic size. */
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
const DEFAULTS = { nodeWidth: 260, columnGap: 68, rowGap: 22 } as const
|
||||
|
||||
/**
|
||||
* Assign each node a column via longest-path layering over the edges. Processes
|
||||
* nodes in Kahn topological order; an edge into an unknown/cyclic parent is
|
||||
* skipped so a cycle never wedges the pass.
|
||||
*/
|
||||
function assignColumns(ids: string[], edges: LayoutEdge[]): Map<string, number> {
|
||||
const present = new Set(ids)
|
||||
const outgoing = new Map<string, string[]>()
|
||||
const indeg = new Map<string, number>()
|
||||
for (const id of ids) {
|
||||
outgoing.set(id, [])
|
||||
indeg.set(id, 0)
|
||||
}
|
||||
for (const e of edges) {
|
||||
if (!present.has(e.source) || !present.has(e.target) || e.source === e.target) continue
|
||||
outgoing.get(e.source)!.push(e.target)
|
||||
indeg.set(e.target, (indeg.get(e.target) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const column = new Map<string, number>()
|
||||
for (const id of ids) column.set(id, 0)
|
||||
|
||||
const remaining = new Map(indeg)
|
||||
const queue = ids.filter((id) => (remaining.get(id) ?? 0) === 0).sort()
|
||||
const seen = new Set<string>()
|
||||
while (queue.length) {
|
||||
const u = queue.shift()!
|
||||
if (seen.has(u)) continue
|
||||
seen.add(u)
|
||||
const cu = column.get(u) ?? 0
|
||||
for (const v of outgoing.get(u) ?? []) {
|
||||
if (cu + 1 > (column.get(v) ?? 0)) column.set(v, cu + 1)
|
||||
const left = (remaining.get(v) ?? 0) - 1
|
||||
remaining.set(v, left)
|
||||
if (left <= 0 && !seen.has(v)) queue.push(v)
|
||||
}
|
||||
queue.sort()
|
||||
}
|
||||
return column
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay a resource graph out into centred LR columns. Returns one positioned node
|
||||
* per input node (input order preserved in the result array) plus content bounds.
|
||||
*/
|
||||
export function layoutTree(
|
||||
nodes: LayoutNode[],
|
||||
edges: LayoutEdge[],
|
||||
opts: LayoutOptions = {}
|
||||
): GraphLayout {
|
||||
const nodeWidth = opts.nodeWidth ?? DEFAULTS.nodeWidth
|
||||
const columnGap = opts.columnGap ?? DEFAULTS.columnGap
|
||||
const rowGap = opts.rowGap ?? DEFAULTS.rowGap
|
||||
|
||||
const ids = nodes.map((n) => n.id)
|
||||
const heightOf = new Map(nodes.map((n) => [n.id, n.h]))
|
||||
const column = assignColumns(ids, edges)
|
||||
|
||||
// Parents per node (for the barycentre ordering).
|
||||
const parents = new Map<string, string[]>()
|
||||
for (const id of ids) parents.set(id, [])
|
||||
const present = new Set(ids)
|
||||
for (const e of edges) {
|
||||
if (!present.has(e.source) || !present.has(e.target) || e.source === e.target) continue
|
||||
parents.get(e.target)!.push(e.source)
|
||||
}
|
||||
|
||||
// Group ids by column, preserving input order as the stable tie-break.
|
||||
const inputOrder = new Map(ids.map((id, i) => [id, i]))
|
||||
const byColumn = new Map<number, string[]>()
|
||||
for (const id of ids) {
|
||||
const c = column.get(id) ?? 0
|
||||
const list = byColumn.get(c)
|
||||
if (list) list.push(id)
|
||||
else byColumn.set(c, [id])
|
||||
}
|
||||
|
||||
// Order each column left→right by the barycentre of parents' rows in the
|
||||
// previous column, so children sit under their owner.
|
||||
const rowOrder = new Map<string, number>()
|
||||
const sortedColumns = Array.from(byColumn.keys()).sort((a, b) => a - b)
|
||||
for (const c of sortedColumns) {
|
||||
const list = byColumn.get(c)!
|
||||
list.sort((a, b) => {
|
||||
const key = (id: string): number => {
|
||||
const ps = parents.get(id) ?? []
|
||||
const rows = ps.map((p) => rowOrder.get(p)).filter((r): r is number => r != null)
|
||||
return rows.length ? rows.reduce((s, r) => s + r, 0) / rows.length : Number.POSITIVE_INFINITY
|
||||
}
|
||||
const ka = key(a)
|
||||
const kb = key(b)
|
||||
if (ka !== kb) return ka - kb
|
||||
return (inputOrder.get(a) ?? 0) - (inputOrder.get(b) ?? 0)
|
||||
})
|
||||
list.forEach((id, i) => rowOrder.set(id, i))
|
||||
}
|
||||
|
||||
// Column heights → the shared vertical axis (tallest column centred).
|
||||
const columnHeight = new Map<number, number>()
|
||||
for (const [c, list] of byColumn) {
|
||||
const total = list.reduce((s, id) => s + (heightOf.get(id) ?? 0), 0) + Math.max(0, list.length - 1) * rowGap
|
||||
columnHeight.set(c, total)
|
||||
}
|
||||
const axis = Math.max(0, ...Array.from(columnHeight.values())) / 2
|
||||
|
||||
const pos = new Map<string, { x: number; y: number }>()
|
||||
for (const c of sortedColumns) {
|
||||
const list = byColumn.get(c)!
|
||||
const x = c * (nodeWidth + columnGap)
|
||||
let y = axis - (columnHeight.get(c) ?? 0) / 2
|
||||
for (const id of list) {
|
||||
pos.set(id, { x, y })
|
||||
y += (heightOf.get(id) ?? 0) + rowGap
|
||||
}
|
||||
}
|
||||
|
||||
const positioned: PositionedNode[] = nodes.map((n) => {
|
||||
const p = pos.get(n.id) ?? { x: 0, y: 0 }
|
||||
return { id: n.id, x: p.x, y: p.y, w: nodeWidth, h: n.h, col: column.get(n.id) ?? 0 }
|
||||
})
|
||||
|
||||
const width = positioned.reduce((m, n) => Math.max(m, n.x + n.w), 0)
|
||||
const height = positioned.reduce((m, n) => Math.max(m, n.y + n.h), 0)
|
||||
return { nodes: positioned, width, height }
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* CD status → colour, tone, label, priority — the ONE semantic mapping, pure and
|
||||
* unit-tested. Everything visual reads from here so a health/sync verdict looks
|
||||
* identical in a pill, a tree node's accent, a mini-bar, and the minimap.
|
||||
*
|
||||
* The hues keep the GitOps meaning (green = healthy/synced, amber = out-of-sync /
|
||||
* missing, red = degraded, blue = progressing, purple = suspended, grey =
|
||||
* unknown) but are re-tuned for a dark-first surface so they stay legible on
|
||||
* near-black without the washed-out look of the light-theme originals.
|
||||
*/
|
||||
import type { HealthStatus, OperationPhase, PodPhase, SyncStatus } from './types'
|
||||
|
||||
/** A coarse tone bucket, shared with the rest of @hanzo/ui's tag vocabulary. */
|
||||
export type StatusTone = 'green' | 'yellow' | 'red' | 'blue' | 'purple' | 'neutral'
|
||||
|
||||
/** The concrete swatch a status renders in: dot/text hue + a soft chip fill. */
|
||||
export interface StatusColor {
|
||||
/** The saturated hue — the dot, the accent bar, the pill text. */
|
||||
hue: string
|
||||
tone: StatusTone
|
||||
}
|
||||
|
||||
// Dark-tuned semantic hues. Bright enough to read as the pill's own text on a
|
||||
// near-black surface; the soft chip fill is derived from these at low alpha.
|
||||
const HUES: Record<StatusTone, string> = {
|
||||
green: '#22c55e',
|
||||
yellow: '#f5b544',
|
||||
red: '#f4636e',
|
||||
blue: '#38a0f5',
|
||||
purple: '#a684f5',
|
||||
neutral: '#8b96a5',
|
||||
}
|
||||
|
||||
const HEALTH_TONE: Record<HealthStatus, StatusTone> = {
|
||||
Healthy: 'green',
|
||||
Progressing: 'blue',
|
||||
Degraded: 'red',
|
||||
Suspended: 'purple',
|
||||
Missing: 'yellow',
|
||||
Unknown: 'neutral',
|
||||
}
|
||||
|
||||
const SYNC_TONE: Record<SyncStatus, StatusTone> = {
|
||||
Synced: 'green',
|
||||
OutOfSync: 'yellow',
|
||||
Unknown: 'neutral',
|
||||
}
|
||||
|
||||
const OPERATION_TONE: Record<OperationPhase, StatusTone> = {
|
||||
Succeeded: 'green',
|
||||
Running: 'blue',
|
||||
Pending: 'blue',
|
||||
Terminating: 'red',
|
||||
Failed: 'red',
|
||||
Error: 'red',
|
||||
}
|
||||
|
||||
const POD_TONE: Record<PodPhase, StatusTone> = {
|
||||
Running: 'green',
|
||||
Succeeded: 'green',
|
||||
Pending: 'blue',
|
||||
Failed: 'red',
|
||||
Unknown: 'neutral',
|
||||
}
|
||||
|
||||
/**
|
||||
* Worst-first ordering (lower = worse), so a rollup surfaces the most broken
|
||||
* verdict and a sort puts problems on top — matching the GitOps engine's own
|
||||
* health precedence.
|
||||
*/
|
||||
export const HEALTH_PRIORITY: Record<HealthStatus, number> = {
|
||||
Missing: 0,
|
||||
Degraded: 1,
|
||||
Unknown: 2,
|
||||
Progressing: 3,
|
||||
Suspended: 4,
|
||||
Healthy: 5,
|
||||
}
|
||||
|
||||
export const SYNC_PRIORITY: Record<SyncStatus, number> = {
|
||||
OutOfSync: 0,
|
||||
Unknown: 1,
|
||||
Synced: 2,
|
||||
}
|
||||
|
||||
const HEALTH_SET = new Set<string>(Object.keys(HEALTH_TONE))
|
||||
const SYNC_SET = new Set<string>(Object.keys(SYNC_TONE))
|
||||
|
||||
/** Coerce a free-form backend string into a known health code (fails to Unknown). */
|
||||
export function normalizeHealth(s?: string): HealthStatus {
|
||||
return s && HEALTH_SET.has(s) ? (s as HealthStatus) : 'Unknown'
|
||||
}
|
||||
|
||||
/** Coerce a free-form backend string into a known sync code (fails to Unknown). */
|
||||
export function normalizeSync(s?: string): SyncStatus {
|
||||
return s && SYNC_SET.has(s) ? (s as SyncStatus) : 'Unknown'
|
||||
}
|
||||
|
||||
export function healthColor(status: HealthStatus): StatusColor {
|
||||
const tone = HEALTH_TONE[status] ?? 'neutral'
|
||||
return { hue: HUES[tone], tone }
|
||||
}
|
||||
|
||||
export function syncColor(status: SyncStatus): StatusColor {
|
||||
const tone = SYNC_TONE[status] ?? 'neutral'
|
||||
return { hue: HUES[tone], tone }
|
||||
}
|
||||
|
||||
export function operationColor(phase: OperationPhase): StatusColor {
|
||||
const tone = OPERATION_TONE[phase] ?? 'neutral'
|
||||
return { hue: HUES[tone], tone }
|
||||
}
|
||||
|
||||
export function podColor(phase: PodPhase): StatusColor {
|
||||
const tone = POD_TONE[phase] ?? 'neutral'
|
||||
return { hue: HUES[tone], tone }
|
||||
}
|
||||
|
||||
/** The saturated hue for a tone (for callers that already have a tone). */
|
||||
export function toneHue(tone: StatusTone): string {
|
||||
return HUES[tone]
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll a set of resource healths up to the single verdict that best describes
|
||||
* the whole — the worst one present. Empty ⇒ `Unknown` (honest, never `Healthy`).
|
||||
*/
|
||||
export function worstHealth(statuses: HealthStatus[]): HealthStatus {
|
||||
if (statuses.length === 0) return 'Unknown'
|
||||
return statuses.reduce((worst, s) =>
|
||||
HEALTH_PRIORITY[s] < HEALTH_PRIORITY[worst] ? s : worst
|
||||
)
|
||||
}
|
||||
|
||||
/** Roll a set of sync verdicts up to one — OutOfSync wins, then Unknown. */
|
||||
export function worstSync(statuses: SyncStatus[]): SyncStatus {
|
||||
if (statuses.length === 0) return 'Unknown'
|
||||
return statuses.reduce((worst, s) =>
|
||||
SYNC_PRIORITY[s] < SYNC_PRIORITY[worst] ? s : worst
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `#rgb`/`#rrggbb` hue into an `rgba()` at the given alpha — for the
|
||||
* soft chip fills and translucent accents. Non-hex input passes through (so a
|
||||
* caller may hand a hue that's already `rgba(...)`).
|
||||
*/
|
||||
export function withAlpha(hex: string, alpha: number): string {
|
||||
const m = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.exec(hex)
|
||||
if (!m) return hex
|
||||
let h = m[1]
|
||||
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]
|
||||
const r = parseInt(h.slice(0, 2), 16)
|
||||
const g = parseInt(h.slice(2, 4), 16)
|
||||
const b = parseInt(h.slice(4, 6), 16)
|
||||
const a = Math.max(0, Math.min(1, alpha))
|
||||
return `rgba(${r}, ${g}, ${b}, ${a})`
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Compact relative time — pure and deterministic given `now`. Future or invalid
|
||||
* timestamps degrade honestly (`—` / `now`), never a fabricated duration. Shared
|
||||
* by the tile's last-synced, the drawer's created/age, and the history rows.
|
||||
*/
|
||||
export function relativeTime(
|
||||
epochMs: number | undefined | null,
|
||||
now: number = Date.now()
|
||||
): string {
|
||||
if (epochMs == null || !Number.isFinite(epochMs) || epochMs <= 0) return '—'
|
||||
const diff = now - epochMs
|
||||
if (diff < 0) return 'now'
|
||||
const s = Math.floor(diff / 1000)
|
||||
if (s < 5) return 'now'
|
||||
if (s < 60) return `${s}s ago`
|
||||
const m = Math.floor(s / 60)
|
||||
if (m < 60) return `${m}m ago`
|
||||
const h = Math.floor(m / 60)
|
||||
if (h < 24) return `${h}h ago`
|
||||
const d = Math.floor(h / 24)
|
||||
if (d < 7) return `${d}d ago`
|
||||
const w = Math.floor(d / 7)
|
||||
if (w < 5) return `${w}w ago`
|
||||
const mo = Math.floor(d / 30)
|
||||
if (mo < 12) return `${mo}mo ago`
|
||||
const y = Math.floor(d / 365)
|
||||
return `${y}y ago`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an ISO-8601 (or any `Date`-parseable) timestamp to epoch **ms**, or
|
||||
* `undefined` when absent/unparseable — so the contract's string timestamps
|
||||
* (`createdAt`, event times) flow into `relativeTime` without throwing.
|
||||
*/
|
||||
export function toEpochMs(v: string | number | undefined | null): number | undefined {
|
||||
if (v == null) return undefined
|
||||
if (typeof v === 'number') {
|
||||
if (!Number.isFinite(v) || v <= 0) return undefined
|
||||
// Hanzo `/v1` rows may report epoch seconds; scale sub-2001 values to ms.
|
||||
return v < 1e12 ? Math.round(v * 1000) : Math.round(v)
|
||||
}
|
||||
const t = Date.parse(v)
|
||||
return Number.isFinite(t) && t > 0 ? t : undefined
|
||||
}
|
||||
|
||||
/** Relative time for a string/number timestamp (parses, then formats). */
|
||||
export function relativeTimeOf(
|
||||
v: string | number | undefined | null,
|
||||
now: number = Date.now()
|
||||
): string {
|
||||
return relativeTime(toEpochMs(v), now)
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* @hanzo/ui/cd — the Hanzo CD (continuous-delivery) data contract.
|
||||
*
|
||||
* This is the FE↔BE handshake for the `/v1/gitops` API. Every component in this
|
||||
* module is presentational and data-prop-driven: a host console fetches these
|
||||
* rows (via the injected `GitopsClient`) and hands them to the views. The shapes
|
||||
* live in ONE place so the pure folds (status → tone, tree → graph, diff → hunks)
|
||||
* are unit-tested in isolation and the visual components never reach into a data
|
||||
* layer.
|
||||
*
|
||||
* The vocabulary mirrors a GitOps engine's model (health/sync verdicts, the
|
||||
* owner-reference resource graph, operation phases) so a backend that projects a
|
||||
* cluster's live state maps onto it directly, but the names are engine-neutral.
|
||||
*
|
||||
* Attribution: the information design of these views is a derivative port of the
|
||||
* Argo CD web UI (Apache-2.0). See ./NOTICE.
|
||||
*/
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Status vocabularies — small, glanceable, closed unions.
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A resource/application health verdict. `Unknown` is honest — a missing signal
|
||||
* is never guessed up to `Healthy`.
|
||||
*/
|
||||
export type HealthStatus =
|
||||
| 'Healthy'
|
||||
| 'Progressing'
|
||||
| 'Degraded'
|
||||
| 'Suspended'
|
||||
| 'Missing'
|
||||
| 'Unknown'
|
||||
|
||||
/** Whether live state matches the desired (Git) state. */
|
||||
export type SyncStatus = 'Synced' | 'OutOfSync' | 'Unknown'
|
||||
|
||||
/** The phase of an in-flight sync/rollback operation. */
|
||||
export type OperationPhase =
|
||||
| 'Running'
|
||||
| 'Terminating'
|
||||
| 'Failed'
|
||||
| 'Error'
|
||||
| 'Succeeded'
|
||||
| 'Pending'
|
||||
|
||||
/** A pod's lifecycle phase (drives the pod-status dot). */
|
||||
export type PodPhase = 'Pending' | 'Running' | 'Succeeded' | 'Failed' | 'Unknown'
|
||||
|
||||
/** Per-resource result code within a sync operation. */
|
||||
export type SyncResultCode = 'Synced' | 'Pruned' | 'SyncFailed' | 'Unknown'
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Resource graph — the owner-reference tree of live cluster objects.
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A group/version/kind/namespace/name coordinate for a cluster object. */
|
||||
export interface ResourceRef {
|
||||
/** Stable unique id (k8s uid, or a synthesized `group/kind/ns/name`). */
|
||||
uid: string
|
||||
group: string
|
||||
version: string
|
||||
kind: string
|
||||
namespace: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/** A labelled datum shown as a chip on a node / in the summary (e.g. `Revision: 3`). */
|
||||
export interface InfoItem {
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/** Ingress/service surface of a networked resource (Service, Ingress). */
|
||||
export interface ResourceNetworking {
|
||||
/** Objects this resource routes to (Service → Pods, Ingress → Service). */
|
||||
targetRefs?: ResourceRef[]
|
||||
labels?: Record<string, string>
|
||||
/** Externally reachable URLs (a domain's live site). */
|
||||
externalURLs?: string[]
|
||||
ingress?: { hostname?: string; ip?: string }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* One node of the live resource graph. Ownership is expressed by `parentRefs`
|
||||
* (an object may have several owners); the graph is the transitive closure of
|
||||
* those edges rooted at the application. Pure data — no React, no back-refs.
|
||||
*/
|
||||
export interface ResourceNode extends ResourceRef {
|
||||
/** The object(s) that own this one — the edges of the graph. */
|
||||
parentRefs?: ResourceRef[]
|
||||
/** Health verdict for this object, when the engine computes one. */
|
||||
health?: { status: HealthStatus; message?: string }
|
||||
/** Sync verdict for this object (managed objects only). */
|
||||
status?: SyncStatus
|
||||
/** Glanceable facts shown as chips (revision, replicas, image tag, …). */
|
||||
info?: InfoItem[]
|
||||
networkingInfo?: ResourceNetworking
|
||||
images?: string[]
|
||||
createdAt?: string
|
||||
/** True for hook/sync-operation objects; `resourceVersion` for freshness. */
|
||||
hook?: boolean
|
||||
resourceVersion?: string
|
||||
/** Present on Pod nodes — drives pod-specific rendering. */
|
||||
podInfo?: PodInfo
|
||||
}
|
||||
|
||||
/** Extra facts a Pod node carries (containers, phase, restarts, node/host). */
|
||||
export interface PodInfo {
|
||||
phase: PodPhase
|
||||
/** Reason string when the phase alone is ambiguous (CrashLoopBackOff, …). */
|
||||
reason?: string
|
||||
ready?: string // e.g. "2/3"
|
||||
restarts?: number
|
||||
node?: string
|
||||
age?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The full resource graph for an application: the managed nodes plus any
|
||||
* orphaned objects (live in the app's namespace but not owned by it).
|
||||
*/
|
||||
export interface ResourceTree {
|
||||
nodes: ResourceNode[]
|
||||
orphanedNodes?: ResourceNode[]
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Application — the deployable unit the list shows and the detail drills into.
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Where an application's desired state comes from (a Git repo path @ revision). */
|
||||
export interface AppSource {
|
||||
repoURL: string
|
||||
path?: string
|
||||
targetRevision?: string
|
||||
chart?: string
|
||||
}
|
||||
|
||||
/** The destination cluster + namespace an application deploys to. */
|
||||
export interface AppDestination {
|
||||
server?: string
|
||||
name?: string
|
||||
namespace: string
|
||||
}
|
||||
|
||||
/** Revision metadata resolved from the source repo (for the status panel). */
|
||||
export interface RevisionMetadata {
|
||||
revision: string
|
||||
author?: string
|
||||
date?: string
|
||||
message?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A summary of one application — the shape the list/tiles render. Enough to draw
|
||||
* a row without fetching the tree.
|
||||
*/
|
||||
export interface AppSummary {
|
||||
name: string
|
||||
namespace?: string
|
||||
project?: string
|
||||
health: HealthStatus
|
||||
sync: SyncStatus
|
||||
/** Short revision (sha/tag) currently reconciled. */
|
||||
revision?: string
|
||||
source?: AppSource
|
||||
destination?: AppDestination
|
||||
/** Epoch ms of the last successful sync (rendered relative). */
|
||||
lastSyncedAt?: number
|
||||
/** Phase of an in-flight operation, when one is running. */
|
||||
operationPhase?: OperationPhase
|
||||
/** Rollup counts of managed resources by health, for the tile mini-bar. */
|
||||
resourceCounts?: Partial<Record<HealthStatus, number>>
|
||||
labels?: Record<string, string>
|
||||
}
|
||||
|
||||
/** A single sync/deploy in the history (for the rollback list). */
|
||||
export interface DeployRevision {
|
||||
/** Monotonic history id — the handle passed to `rollback`. */
|
||||
id: number
|
||||
revision: string
|
||||
deployedAt?: number
|
||||
source?: AppSource
|
||||
metadata?: RevisionMetadata
|
||||
}
|
||||
|
||||
/** The result of (or progress of) the most recent sync/rollback operation. */
|
||||
export interface OperationState {
|
||||
phase: OperationPhase
|
||||
message?: string
|
||||
startedAt?: number
|
||||
finishedAt?: number
|
||||
/** The revision the operation targeted. */
|
||||
revision?: string
|
||||
/** Per-resource results (kind/name → code + message). */
|
||||
syncResult?: {
|
||||
ref: ResourceRef
|
||||
result: SyncResultCode
|
||||
message?: string
|
||||
hookPhase?: string
|
||||
}[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The full application detail — everything the status panel + controls need
|
||||
* beyond the tree. Returned by `getApp`.
|
||||
*/
|
||||
export interface AppDetail extends AppSummary {
|
||||
/** Resolved metadata for the reconciled revision. */
|
||||
revisionMetadata?: RevisionMetadata
|
||||
/** Deploy/rollout history, newest last (matches engine ordering). */
|
||||
history?: DeployRevision[]
|
||||
/** In-flight or last-completed operation. */
|
||||
operationState?: OperationState
|
||||
/** Free-form conditions/warnings surfaced by the engine. */
|
||||
conditions?: { type: string; message: string; lastTransitionTime?: string }[]
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Node detail — manifest + events + logs for the drawer.
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A k8s-style event row shown in the node drawer's Events tab. */
|
||||
export interface ResourceEvent {
|
||||
reason: string
|
||||
message: string
|
||||
type?: 'Normal' | 'Warning'
|
||||
count?: number
|
||||
firstSeen?: string
|
||||
lastSeen?: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
/** One log line (optionally tagged with its container + a parsed timestamp). */
|
||||
export interface LogEntry {
|
||||
content: string
|
||||
timestamp?: string
|
||||
container?: string
|
||||
/** True for stderr — rendered in the warn tone. */
|
||||
err?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the node drawer needs for a single resource: the live manifest
|
||||
* (YAML), recent events, a recent-logs snapshot (pods), and the desired/live
|
||||
* pair for the inline diff. Returned by `getResource`.
|
||||
*/
|
||||
export interface ResourceDetail {
|
||||
ref: ResourceRef
|
||||
health?: { status: HealthStatus; message?: string }
|
||||
syncStatus?: SyncStatus
|
||||
/** Live manifest as YAML (or JSON) text. */
|
||||
liveManifest?: string
|
||||
/** Desired manifest as YAML text — enables the drawer's inline diff. */
|
||||
desiredManifest?: string
|
||||
events?: ResourceEvent[]
|
||||
/** Recent log lines for a pod/container (a snapshot; streaming is optional). */
|
||||
logs?: LogEntry[]
|
||||
/** Container names, when the resource is a Pod (drives the log selector). */
|
||||
containers?: string[]
|
||||
info?: InfoItem[]
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Diff — desired vs live for the whole application.
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** One resource's desired/live manifest pair for the application diff view. */
|
||||
export interface ResourceDiff {
|
||||
ref: ResourceRef
|
||||
/** Sync verdict for this resource (drives the section pill). */
|
||||
status?: SyncStatus
|
||||
desired?: string
|
||||
live?: string
|
||||
/** True when the resource is unmanaged/hook (rendered muted). */
|
||||
hook?: boolean
|
||||
}
|
||||
|
||||
/** The full application diff payload. Returned by `getDiff`. */
|
||||
export interface AppDiff {
|
||||
items: ResourceDiff[]
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Mutations — sync + rollback request bodies.
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Body for `POST /v1/gitops/{name}/sync`. */
|
||||
export interface SyncRequest {
|
||||
/** Target revision; omit to sync to the source's configured revision. */
|
||||
revision?: string
|
||||
/** Prune resources that no longer exist in the desired state. */
|
||||
prune?: boolean
|
||||
/** Server-side dry-run (compute the plan without applying). */
|
||||
dryRun?: boolean
|
||||
/** Restrict the sync to specific resources (default: all). */
|
||||
resources?: ResourceRef[]
|
||||
}
|
||||
|
||||
/** Body for `POST /v1/gitops/{name}/rollback`. */
|
||||
export interface RollbackRequest {
|
||||
/** The history id (from `DeployRevision.id`) to roll back to. */
|
||||
id: number
|
||||
prune?: boolean
|
||||
dryRun?: boolean
|
||||
}
|
||||
|
||||
/** The response to a sync/rollback — the operation the engine kicked off. */
|
||||
export interface OperationResponse {
|
||||
operationState: OperationState
|
||||
}
|
||||
|
||||
/** The apps-list response: `GET /v1/gitops`. */
|
||||
export interface AppListResponse {
|
||||
items: AppSummary[]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// @hanzo/ui/dashboard — the console dashboard kit (landing + deploy pipeline +
|
||||
// composable overview primitives + charts/motion layer), re-exported from its
|
||||
// home package @hanzo/dashboard. Thin subpath:
|
||||
//
|
||||
// import { ... } from '@hanzo/ui/dashboard'
|
||||
//
|
||||
// Optional peer — only pulled when the subpath is used.
|
||||
export * from '@hanzo/dashboard'
|
||||
@@ -0,0 +1,5 @@
|
||||
// @hanzo/ui/gitops — the CD/GitOps surface, re-exported from its home package
|
||||
// @hanzo/gitops (an Apache-2.0 clean-room port of the Argo CD UI). Kept as a thin
|
||||
// subpath so a console can `import { GitopsAppList, GitopsAppTree, GitopsNodeInfo,
|
||||
// HealthBadge, ... } from '@hanzo/ui/gitops'` while the code lives in one place.
|
||||
export * from '@hanzo/gitops'
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Registers this package's @hanzo/gui config with the type system so the
|
||||
* shorthand style props (bg / px / py / items / justify / gap / rounded …) that
|
||||
* the components author against resolve to their concrete types DURING THIS
|
||||
* PACKAGE'S OWN COMPILATION.
|
||||
*
|
||||
* Because the resolution happens here, the emitted `.d.ts` (see
|
||||
* `tsconfig.build.json`) bakes in the concrete prop types — a consumer type-checks
|
||||
* against the compiled declarations and never has to re-declare this augmentation
|
||||
* (nor descend into our `.tsx` internals). Ambient + type-only; never emitted.
|
||||
*
|
||||
* `GuiCustomConfig` is the extension point declared by @hanzogui/web and re-declared
|
||||
* by @hanzogui/core; `InferGuiConfig<typeof defaultConfig>` (the return type of
|
||||
* `createGui(defaultConfig)`) is the exact config the components are written for.
|
||||
*/
|
||||
import type { createGui } from '@hanzo/gui'
|
||||
import type { defaultConfig } from '@hanzogui/config/v5'
|
||||
|
||||
type Conf = ReturnType<typeof createGui<typeof defaultConfig>>
|
||||
|
||||
declare module '@hanzogui/web' {
|
||||
interface GuiCustomConfig extends Conf {}
|
||||
}
|
||||
|
||||
declare module '@hanzogui/core' {
|
||||
interface GuiCustomConfig extends Conf {}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// @hanzo/ui/network — the ONE network selector, re-exported from its home module
|
||||
// @hanzo/ui-shadcn/network. `NetworkSwitcher` renders the selected environment
|
||||
// and a menu of configured networks + a custom-endpoint form, backed by the
|
||||
// shared selected-network store; `configureNetworks` swaps the network set so a
|
||||
// downstream brand can white-label it. Thin subpath:
|
||||
//
|
||||
// import { NetworkSwitcher, useNetwork, configureNetworks, HANZO_NETWORKS,
|
||||
// type Network } from '@hanzo/ui/network'
|
||||
//
|
||||
// Optional peer — only pulled when the subpath is used.
|
||||
export * from '@hanzo/ui-shadcn/network'
|
||||
@@ -0,0 +1,183 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AppHeader — the shared shell header every Hanzo surface renders: the brand
|
||||
* mark top-left (from @hanzo/logo, white-label via `wordmark`/`brand`), an
|
||||
* org/project slot (usually `<OrgSwitcher/>`), an app switcher listing the
|
||||
* Hanzo surfaces (modeled on the console's AppLauncher), and an identity menu
|
||||
* (Profile · Theme · Team settings · Billing · Sign out).
|
||||
*
|
||||
* Host-agnostic: every action is an injected handler; a row renders only when
|
||||
* its handler is provided (honest — no dead menu items). Mobile-first: fluid
|
||||
* flex + truncation, the wordmark collapse is the brand motion itself.
|
||||
*/
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { Button, Popover, Separator, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { AppWindow, Bot, CreditCard, Grip, LayoutGrid, LogOut, MessageCircle, Settings2, Sparkles, UserRound, Users } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { BrandMark } from './BrandMark'
|
||||
import { ThemeToggle } from './ThemeToggle'
|
||||
import { otherSurfaces, type Surface, type SurfaceId } from './surfaces.data'
|
||||
|
||||
/** The per-surface glyph — keyed by `Surface.id`, so each surface reads distinctly. */
|
||||
const SURFACE_ICON = {
|
||||
ai: Sparkles,
|
||||
console: LayoutGrid,
|
||||
app: AppWindow,
|
||||
chat: MessageCircle,
|
||||
bot: Bot,
|
||||
team: Users,
|
||||
billing: CreditCard,
|
||||
} as const satisfies Record<SurfaceId, unknown>
|
||||
|
||||
const openHref = (href: string) => {
|
||||
if (typeof window !== 'undefined') window.open(href, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
function MenuRow({ icon, label, onPress }: { icon: ReactNode; label: string; onPress: () => void }) {
|
||||
return (
|
||||
<XStack onPress={onPress} cursor="pointer" items="center" gap="$2.5" px="$2" py="$2" rounded="$3" hoverStyle={{ bg: '$color5' }}>
|
||||
{icon}
|
||||
<Text fontSize="$2" color="$color12">
|
||||
{label}
|
||||
</Text>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
export type AppHeaderProps = {
|
||||
/** Brand slot — defaults to the animated `<BrandMark/>`. */
|
||||
brand?: ReactNode
|
||||
/** White-label wordmark for the default brand mark. */
|
||||
wordmark?: string
|
||||
/** Brand press (usually "go home"). */
|
||||
onBrand?: () => void
|
||||
/** Org/project slot — usually `<OrgSwitcher/>` (+ a project switcher). */
|
||||
org?: ReactNode
|
||||
/** Free slot between the org slot and the right cluster. */
|
||||
children?: ReactNode
|
||||
/** The surface this header renders on — omitted from the switcher (no self-link). */
|
||||
current?: SurfaceId
|
||||
/** App-switcher surfaces; defaults to every surface but `current`. [] hides it. */
|
||||
surfaces?: Surface[]
|
||||
/** Open a surface — default `window.open` (new tab). */
|
||||
open?: (surface: Surface) => void
|
||||
/** Identity-menu trigger label (the signed-in user's name/email). */
|
||||
user?: string
|
||||
/** Replace the whole identity menu content. */
|
||||
menu?: ReactNode
|
||||
/** Theme row content — default `<ThemeToggle/>`; null hides the row. */
|
||||
theme?: ReactNode
|
||||
onProfile?: () => void
|
||||
onTeam?: () => void
|
||||
onBilling?: () => void
|
||||
onSignOut?: () => void
|
||||
}
|
||||
|
||||
export function AppHeader({
|
||||
brand,
|
||||
wordmark = 'Hanzo',
|
||||
onBrand,
|
||||
org,
|
||||
children,
|
||||
current,
|
||||
surfaces,
|
||||
open,
|
||||
user,
|
||||
menu,
|
||||
theme,
|
||||
onProfile,
|
||||
onTeam,
|
||||
onBilling,
|
||||
onSignOut,
|
||||
}: AppHeaderProps) {
|
||||
const [appsOpen, setAppsOpen] = useState(false)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const items = surfaces ?? otherSurfaces(current)
|
||||
const launch = (s: Surface) => {
|
||||
setAppsOpen(false)
|
||||
if (open) open(s)
|
||||
else openHref(s.href)
|
||||
}
|
||||
|
||||
return (
|
||||
<XStack items="center" gap="$2" px="$3" height={52} borderBottomWidth={1} borderColor="$borderColor" bg="$background">
|
||||
<XStack items="center" cursor={onBrand ? 'pointer' : undefined} onPress={onBrand}>
|
||||
{brand ?? <BrandMark wordmark={wordmark} />}
|
||||
</XStack>
|
||||
|
||||
{org ? (
|
||||
<XStack items="center" minW={0}>
|
||||
{org}
|
||||
</XStack>
|
||||
) : null}
|
||||
|
||||
<XStack flex={1} items="center" minW={0}>
|
||||
{children}
|
||||
</XStack>
|
||||
|
||||
{items.length > 0 ? (
|
||||
<Popover open={appsOpen} onOpenChange={setAppsOpen} placement="bottom-end">
|
||||
<Popover.Trigger asChild>
|
||||
<Button size="$2" chromeless icon={<Grip size={16} />} aria-label="Apps" />
|
||||
</Popover.Trigger>
|
||||
<Popover.Content bordered elevate p="$2" width={230} bg="$color2" borderColor="$borderColor">
|
||||
<YStack gap="$1">
|
||||
{items.map((s) => {
|
||||
const Icon = SURFACE_ICON[s.id]
|
||||
return (
|
||||
<XStack key={s.id} onPress={() => launch(s)} cursor="pointer" items="center" gap="$2.5" px="$2" py="$2" rounded="$3" hoverStyle={{ bg: '$color5' }}>
|
||||
<Icon size={16} />
|
||||
<Text flex={1} fontSize="$2" fontWeight="600" color="$color12">
|
||||
{s.label}
|
||||
</Text>
|
||||
{s.hint ? (
|
||||
<Text fontSize="$1" color="$color10">
|
||||
{s.hint}
|
||||
</Text>
|
||||
) : null}
|
||||
</XStack>
|
||||
)
|
||||
})}
|
||||
</YStack>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
) : null}
|
||||
|
||||
<Popover open={menuOpen} onOpenChange={setMenuOpen} placement="bottom-end">
|
||||
<Popover.Trigger asChild>
|
||||
<Button size="$2" chromeless icon={<UserRound size={16} />} aria-label="Account">
|
||||
{user ? (
|
||||
<Text fontSize="$2" color="$color12" numberOfLines={1} maxW={140}>
|
||||
{user}
|
||||
</Text>
|
||||
) : null}
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content bordered elevate p="$2" width={220} bg="$color2" borderColor="$borderColor">
|
||||
{menu ?? (
|
||||
<YStack gap="$1">
|
||||
{onProfile ? <MenuRow icon={<UserRound size={15} />} label="Profile" onPress={() => (setMenuOpen(false), onProfile())} /> : null}
|
||||
{theme !== null ? (
|
||||
<XStack items="center" gap="$2.5" px="$2" py="$1" rounded="$3">
|
||||
<Text flex={1} fontSize="$2" color="$color12">
|
||||
Theme
|
||||
</Text>
|
||||
{theme ?? <ThemeToggle />}
|
||||
</XStack>
|
||||
) : null}
|
||||
{onTeam ? <MenuRow icon={<Settings2 size={15} />} label="Team settings" onPress={() => (setMenuOpen(false), onTeam())} /> : null}
|
||||
{onBilling ? <MenuRow icon={<CreditCard size={15} />} label="Billing" onPress={() => (setMenuOpen(false), onBilling())} /> : null}
|
||||
{onSignOut ? (
|
||||
<>
|
||||
<Separator borderColor="$borderColor" my="$1" />
|
||||
<MenuRow icon={<LogOut size={15} />} label="Sign out" onPress={() => (setMenuOpen(false), onSignOut())} />
|
||||
</>
|
||||
) : null}
|
||||
</YStack>
|
||||
)}
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* BrandMark — the canonical brand lockup for an app header, from @hanzo/logo
|
||||
* (the ONE home of the mark geometry + motion). Animated by default: intro
|
||||
* flip + idle breathing + a wordmark that slides in, holds, then collapses
|
||||
* (returns on hover). Pure CSS, reduced-motion-safe; the mark inherits
|
||||
* `currentColor` so it themes for free. White-label via `wordmark`.
|
||||
*/
|
||||
import { HanzoLogo } from '@hanzo/logo/react'
|
||||
|
||||
import { HanzoMark } from './HanzoMark'
|
||||
|
||||
export function BrandMark({
|
||||
size = 20,
|
||||
wordmark = 'Hanzo',
|
||||
animated = true,
|
||||
}: {
|
||||
size?: number
|
||||
/** Wordmark text (white-label): "Hanzo", "Lux", "Zoo", … */
|
||||
wordmark?: string
|
||||
/** false = the static mark alone (no motion shell, no wordmark). */
|
||||
animated?: boolean
|
||||
}) {
|
||||
if (!animated) return <HanzoMark size={size} />
|
||||
return <HanzoLogo animated size={size} wordmark={wordmark} />
|
||||
}
|
||||
@@ -1,22 +1,23 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* ComboBox — a typeable select: a text input the user can type any value into,
|
||||
* PLUS a popover of LIVE options (filtered by what's typed) they can pick from.
|
||||
* The value is always exactly the input text, so a custom id is inherently
|
||||
* supported — selecting an option just fills the input. This is the ONE way the
|
||||
* console offers "pick from a live list OR type your own" (the model field, the
|
||||
* tool field). Prop-driven + self-contained (options/loading/error injected by the
|
||||
* caller), so it is orthogonal to the data source and lifts into `@hanzo/ui`.
|
||||
* ComboBox — a typeable select: a text input the user can type any value into, PLUS a
|
||||
* menu of LIVE options (filtered by what's typed) they can pick from. The value is
|
||||
* always exactly the input text, so a custom id is inherently supported. Prop-driven +
|
||||
* self-contained (options/loading/error injected by the caller).
|
||||
*
|
||||
* Idiom: the same @hanzo/gui Popover shell as OrgSwitcher/SelectMenu (bordered,
|
||||
* elevate, `$color2`). Options render in a portal above the DetailPane SlideOver.
|
||||
* Uses the ONE shared menu spec (MenuItemView) + FloatingMenu (gui Portal) so options look
|
||||
* identical to every other menu and render correctly through the portal under a nested
|
||||
* `<Theme>` on gui-native hosts. The input row is the anchor (excluded from dismiss) and
|
||||
* the panel does NOT steal focus, so typing keeps filtering.
|
||||
*/
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Button, Input, Popover, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Check, ChevronDown, RefreshCw } from '@hanzogui/lucide-icons-2'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { Button, Input, Spinner, Text, XStack } from '@hanzo/gui'
|
||||
import { ChevronDown, RefreshCw } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { filterOptions, type ComboOption } from './combobox/filter'
|
||||
import { MenuItemView } from './menu/items'
|
||||
import { FloatingMenu } from './menu/FloatingMenu'
|
||||
|
||||
export type { ComboOption } from './combobox/filter'
|
||||
|
||||
@@ -49,6 +50,9 @@ export function ComboBox({
|
||||
minWidth?: number
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const rowRef = useRef<HTMLElement | null>(null)
|
||||
const anchorRect = useCallback(() => rowRef.current?.getBoundingClientRect() ?? null, [])
|
||||
const anchorEl = useCallback(() => rowRef.current, [])
|
||||
const filtered = useMemo(() => filterOptions(options, value), [options, value])
|
||||
|
||||
const pick = (v: string) => {
|
||||
@@ -57,12 +61,12 @@ export function ComboBox({
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen} placement="bottom-start" allowFlip>
|
||||
<XStack items="center" gap="$2" minW={minWidth}>
|
||||
<>
|
||||
<XStack ref={rowRef as never} items="center" gap="$2" minW={minWidth}>
|
||||
<Input
|
||||
flex={1}
|
||||
value={value}
|
||||
onChangeText={(v) => {
|
||||
onChangeText={(v: string) => {
|
||||
onChange(v)
|
||||
if (!open) setOpen(true)
|
||||
}}
|
||||
@@ -71,77 +75,57 @@ export function ComboBox({
|
||||
placeholder={placeholder}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<Popover.Trigger asChild>
|
||||
<Button
|
||||
size="$2"
|
||||
chromeless
|
||||
disabled={disabled}
|
||||
icon={<ChevronDown size={16} opacity={0.7} />}
|
||||
onPress={() => setOpen((o) => !o)}
|
||||
aria-label="Show options"
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<Button
|
||||
size="$2"
|
||||
chromeless
|
||||
disabled={disabled}
|
||||
icon={<ChevronDown size={16} opacity={0.7} />}
|
||||
onPress={() => setOpen((o) => !o)}
|
||||
aria-label="Show options"
|
||||
/>
|
||||
</XStack>
|
||||
|
||||
<Popover.Content bordered elevate p="$1.5" minW={minWidth} bg="$color2" borderColor="$borderColor">
|
||||
<YStack gap="$0.5" minW={minWidth} maxH={300} overflow="scroll">
|
||||
{loading ? (
|
||||
<XStack items="center" gap="$2" px="$2.5" py="$2">
|
||||
<Spinner size="small" color="$color11" />
|
||||
<Text fontSize="$2" color="$color10">
|
||||
Loading options…
|
||||
</Text>
|
||||
</XStack>
|
||||
) : error ? (
|
||||
<XStack items="center" gap="$2" px="$2.5" py="$2">
|
||||
<Text fontSize="$2" color="$color10" flex={1} numberOfLines={2}>
|
||||
{error}
|
||||
</Text>
|
||||
{onRetry ? (
|
||||
<Button size="$1" chromeless icon={<RefreshCw size={12} />} onPress={onRetry} aria-label="Retry" />
|
||||
) : null}
|
||||
</XStack>
|
||||
) : filtered.length === 0 ? (
|
||||
<Text fontSize="$2" color="$color10" px="$2.5" py="$2">
|
||||
{emptyText}
|
||||
<FloatingMenu
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
anchorRect={anchorRect}
|
||||
anchorEl={anchorEl}
|
||||
autoFocus={false}
|
||||
minWidth={minWidth}
|
||||
maxHeight={300}
|
||||
>
|
||||
{loading ? (
|
||||
<XStack items="center" gap="$2" px="$2" py="$2">
|
||||
<Spinner size="small" color="$color11" />
|
||||
<Text fontSize="$2" color="$color10">
|
||||
Loading options…
|
||||
</Text>
|
||||
) : (
|
||||
filtered.map((o) => (
|
||||
<Row key={o.value} option={o} active={o.value === value} onPress={() => pick(o.value)} />
|
||||
))
|
||||
)}
|
||||
</YStack>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ option, active, onPress }: { option: ComboOption; active: boolean; onPress: () => void }) {
|
||||
return (
|
||||
<XStack
|
||||
items="center"
|
||||
gap="$2"
|
||||
px="$2.5"
|
||||
py="$1.5"
|
||||
rounded="$3"
|
||||
cursor="pointer"
|
||||
hoverStyle={{ bg: '$color4' }}
|
||||
bg={active ? '$color4' : 'transparent'}
|
||||
onPress={onPress}
|
||||
>
|
||||
<XStack width={14} items="center" justify="center">
|
||||
{active ? <Check size={13} /> : null}
|
||||
</XStack>
|
||||
<YStack flex={1} minW={0}>
|
||||
<Text fontSize="$2" color="$color12" numberOfLines={1}>
|
||||
{option.label ?? option.value}
|
||||
</Text>
|
||||
{option.hint ? (
|
||||
<Text fontSize="$1" color="$color10" numberOfLines={1}>
|
||||
{option.hint}
|
||||
</XStack>
|
||||
) : error ? (
|
||||
<XStack items="center" gap="$2" px="$2" py="$2">
|
||||
<Text fontSize="$2" color="$color10" flex={1} numberOfLines={2}>
|
||||
{error}
|
||||
</Text>
|
||||
{onRetry ? (
|
||||
<Button size="$1" chromeless icon={<RefreshCw size={12} />} onPress={onRetry} aria-label="Retry" />
|
||||
) : null}
|
||||
</XStack>
|
||||
) : filtered.length === 0 ? (
|
||||
<Text fontSize="$2" color="$color10" px="$2" py="$2">
|
||||
{emptyText}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
</XStack>
|
||||
) : (
|
||||
filtered.map((o) => (
|
||||
<MenuItemView
|
||||
key={o.value}
|
||||
label={o.label ?? o.value}
|
||||
description={o.hint}
|
||||
selected={o.value === value}
|
||||
onSelect={() => pick(o.value)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</FloatingMenu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* The Hanzo "H" mark — the canonical brand glyph (same geometry as app/icon.svg).
|
||||
* The Hanzo "H" mark — the canonical 7-path shaded glyph: five body blocks +
|
||||
* two shade slivers (geometry canon: @hanzo/logo `MARK_PATHS`).
|
||||
*
|
||||
* 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).
|
||||
* Rendered as inline SVG with `fill: currentColor` (shade at reduced opacity),
|
||||
* 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.
|
||||
*/
|
||||
export function HanzoMark({ size = 22, color = 'currentColor' }: { size?: number; color?: string }) {
|
||||
return (
|
||||
@@ -19,9 +20,11 @@ export function HanzoMark({ size = 22, color = 'currentColor' }: { size?: number
|
||||
style={{ display: 'block', flexShrink: 0 }}
|
||||
>
|
||||
<path d="M22.21 67V44.6369H0V67H22.21Z" />
|
||||
<path opacity={0.55} d="M0 44.6369L22.21 46.8285V44.6369H0Z" />
|
||||
<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 opacity={0.55} d="M66.6753 22.3185L44.5098 20.0822V22.3185H66.6753Z" />
|
||||
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* OrgSwitcher — the Vercel-style org/team switcher, hoisted from the console
|
||||
* (hanzoai/ui#36) onto @hanzo/gui primitives. Shows the org the surface is
|
||||
* scoped to, switches between the orgs the caller can see, and creates one.
|
||||
*
|
||||
* Host-agnostic: the LIST is a lazy, paged loader the host injects (IAM
|
||||
* `get-organizations` behind its own proxy) — one page at a time, the search
|
||||
* term pushed to the server, more on demand (scroll + "Load more"), so it
|
||||
* scales to thousands of orgs. No loader → the current org alone, synthesized
|
||||
* from the scope (never fabricated). Selecting re-scopes IN PLACE via
|
||||
* `scope.switchOrg` (persist + reload → every module refetches under the new
|
||||
* `X-Org-Id`). Create posts through the injected hook, then scopes into the
|
||||
* new org.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Button, Input, Popover, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Check, ChevronsUpDown, LayoutGrid, Plus, Search } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { filterOrgs, type Org, type OrgScope } from './scope'
|
||||
|
||||
const titleCase = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
|
||||
const initialsOf = (o: Org) =>
|
||||
(o.displayName || o.name)
|
||||
.split(/\s+/)
|
||||
.map((w) => w[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase()
|
||||
|
||||
/** An org's avatar — its logo when set, else a monogram tile. */
|
||||
function OrgAvatar({ org, size = 22 }: { org: Org; size?: number }) {
|
||||
if (org.logo) {
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
return <img src={org.logo} alt="" style={{ height: size, width: size, objectFit: 'contain', display: 'block', borderRadius: 6 }} />
|
||||
}
|
||||
return (
|
||||
<YStack width={size} height={size} rounded="$3" bg="$color4" items="center" justify="center">
|
||||
<Text fontSize="$1" fontWeight="800" color="$color12">
|
||||
{initialsOf(org)}
|
||||
</Text>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
export type OrgSwitcherProps = {
|
||||
/** The active-org contract (see `orgScope`). */
|
||||
scope: OrgScope
|
||||
/**
|
||||
* Lazy paged org list: 0-based page + server-pushed search → one page of
|
||||
* rows. A full page (=== pageSize) implies another. Omit when the caller
|
||||
* can only see its own org.
|
||||
*/
|
||||
orgs?: (page: number, query: string) => Promise<Org[]>
|
||||
/** Rows per page the loader returns. Default 20. */
|
||||
pageSize?: number
|
||||
/** Create-org hook → the created org's id; omit to hide the affordance. */
|
||||
create?: (name: string) => Promise<string>
|
||||
/** Show the "All organizations" de-scope row (`scope.leaveOrg`). */
|
||||
picker?: boolean
|
||||
}
|
||||
|
||||
export function OrgSwitcher({ scope, orgs, pageSize = 20, create, picker = false }: OrgSwitcherProps) {
|
||||
const currentId = scope.currentOrg()
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [debounced, setDebounced] = useState('')
|
||||
const [rows, setRows] = useState<Org[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const pageRef = useRef(0)
|
||||
const reqRef = useRef(0) // race token — a newer request supersedes older ones
|
||||
|
||||
// Debounce the search so type-as-you-search re-hits the server at most ~4×/s.
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebounced(query.trim()), 250)
|
||||
return () => clearTimeout(id)
|
||||
}, [query])
|
||||
|
||||
const fetchPage = useCallback(
|
||||
async (page: number, q: string, append: boolean) => {
|
||||
if (!orgs) return
|
||||
const token = ++reqRef.current
|
||||
append ? setLoadingMore(true) : setLoading(true)
|
||||
try {
|
||||
const incoming = await orgs(page, q)
|
||||
if (token !== reqRef.current) return
|
||||
pageRef.current = page
|
||||
setRows((prev) => {
|
||||
const merged = append ? [...prev] : []
|
||||
const seen = new Set(merged.map((o) => o.name))
|
||||
for (const o of incoming) if (!seen.has(o.name)) merged.push(o)
|
||||
return merged
|
||||
})
|
||||
setHasMore(incoming.length >= pageSize)
|
||||
} catch {
|
||||
// Honest empty / keep what's shown — never a fabricated list.
|
||||
if (token !== reqRef.current) return
|
||||
if (!append) setRows([])
|
||||
setHasMore(false)
|
||||
} finally {
|
||||
if (token === reqRef.current) {
|
||||
setLoading(false)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
[orgs, pageSize],
|
||||
)
|
||||
|
||||
// (Re)load page 0 when the popover opens or the debounced query changes.
|
||||
useEffect(() => {
|
||||
if (!open || !orgs) return
|
||||
void fetchPage(0, debounced, false)
|
||||
}, [open, debounced, fetchPage, orgs])
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (loading || loadingMore || !hasMore) return
|
||||
void fetchPage(pageRef.current + 1, debounced, true)
|
||||
}, [loading, loadingMore, hasMore, debounced, fetchPage])
|
||||
|
||||
// Rows to render — the loaded list (client-filtered too), else the current
|
||||
// org synthesized from the scope.
|
||||
const visible: Org[] = useMemo(() => {
|
||||
if (orgs) return filterOrgs(rows, query)
|
||||
return [{ name: currentId, displayName: titleCase(currentId) }]
|
||||
}, [orgs, rows, query, currentId])
|
||||
|
||||
const current: Org = useMemo(
|
||||
() => rows.find((o) => o.name === currentId) ?? { name: currentId, displayName: titleCase(currentId) },
|
||||
[rows, currentId],
|
||||
)
|
||||
|
||||
const select = useCallback(
|
||||
(org: string) => {
|
||||
setOpen(false)
|
||||
scope.switchOrg(org)
|
||||
},
|
||||
[scope],
|
||||
)
|
||||
|
||||
const submitCreate = async () => {
|
||||
const name = newName.trim()
|
||||
if (!name || !create) return
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
const org = await create(name)
|
||||
scope.switchOrg(org)
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Could not create the organization.')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen} placement="bottom-start">
|
||||
<Popover.Trigger asChild>
|
||||
<Button size="$2" chromeless icon={<OrgAvatar org={current} size={18} />} iconAfter={<ChevronsUpDown size={13} />}>
|
||||
{current.displayName || titleCase(current.name)}
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content bordered elevate p="$2" width={300} bg="$color2" borderColor="$borderColor">
|
||||
{creating ? (
|
||||
<YStack gap="$2">
|
||||
<Text fontSize="$2" color="$color12" fontWeight="700">
|
||||
Create organization
|
||||
</Text>
|
||||
<Input
|
||||
size="$3"
|
||||
placeholder="Organization name"
|
||||
value={newName}
|
||||
onChangeText={setNewName}
|
||||
autoCapitalize="words"
|
||||
onSubmitEditing={() => void submitCreate()}
|
||||
/>
|
||||
{err ? (
|
||||
<Text fontSize="$1" color="$red10">
|
||||
{err}
|
||||
</Text>
|
||||
) : null}
|
||||
<XStack gap="$2" justify="flex-end">
|
||||
<Button size="$2" chromeless onPress={() => setCreating(false)} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="$2"
|
||||
onPress={() => void submitCreate()}
|
||||
disabled={busy || !newName.trim()}
|
||||
icon={busy ? <Spinner size="small" /> : <Plus size={14} />}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</XStack>
|
||||
</YStack>
|
||||
) : (
|
||||
<YStack gap="$1">
|
||||
{orgs ? (
|
||||
<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="Find organization…"
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</XStack>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
style={{ maxHeight: 300, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget
|
||||
if (el.scrollHeight - el.scrollTop - el.clientHeight < 48) loadMore()
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<XStack items="center" gap="$2" px="$2" py="$3">
|
||||
<Spinner size="small" color="$color11" />
|
||||
<Text fontSize="$2" color="$color10">
|
||||
Loading organizations…
|
||||
</Text>
|
||||
</XStack>
|
||||
) : visible.length === 0 ? (
|
||||
<Text px="$2" py="$2" fontSize="$2" color="$color10">
|
||||
{query.trim() ? `No organizations match “${query.trim()}”.` : 'No organizations yet.'}
|
||||
</Text>
|
||||
) : (
|
||||
visible.map((org) => (
|
||||
<XStack
|
||||
key={org.name}
|
||||
onPress={() => select(org.name)}
|
||||
cursor="pointer"
|
||||
items="center"
|
||||
gap="$2.5"
|
||||
px="$2"
|
||||
py="$2"
|
||||
rounded="$3"
|
||||
bg={org.name === currentId ? '$color4' : 'transparent'}
|
||||
hoverStyle={{ bg: '$color5' }}
|
||||
>
|
||||
<OrgAvatar org={org} />
|
||||
<Text flex={1} fontSize="$2" color="$color12" numberOfLines={1}>
|
||||
{org.displayName || org.name}
|
||||
</Text>
|
||||
{org.name === currentId ? <Check size={15} /> : null}
|
||||
</XStack>
|
||||
))
|
||||
)}
|
||||
|
||||
{loadingMore ? (
|
||||
<XStack items="center" gap="$2" px="$2" py="$2">
|
||||
<Spinner size="small" color="$color11" />
|
||||
<Text fontSize="$1" color="$color10">
|
||||
Loading more…
|
||||
</Text>
|
||||
</XStack>
|
||||
) : hasMore ? (
|
||||
<XStack onPress={loadMore} cursor="pointer" items="center" justify="center" px="$2" py="$1.5" rounded="$3" hoverStyle={{ bg: '$color4' }}>
|
||||
<Text fontSize="$1" color="$color11" fontWeight="600">
|
||||
Load more
|
||||
</Text>
|
||||
</XStack>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{create ? (
|
||||
<XStack
|
||||
onPress={() => {
|
||||
setCreating(true)
|
||||
setErr(null)
|
||||
}}
|
||||
cursor="pointer"
|
||||
items="center"
|
||||
gap="$2.5"
|
||||
px="$2"
|
||||
py="$2"
|
||||
mt="$1"
|
||||
rounded="$3"
|
||||
borderTopWidth={1}
|
||||
borderColor="$borderColor"
|
||||
hoverStyle={{ bg: '$color5' }}
|
||||
>
|
||||
<YStack width={22} height={22} rounded="$3" bg="$color3" items="center" justify="center">
|
||||
<Plus size={14} />
|
||||
</YStack>
|
||||
<Text fontSize="$2" color="$color12">
|
||||
Create organization
|
||||
</Text>
|
||||
</XStack>
|
||||
) : null}
|
||||
|
||||
{picker ? (
|
||||
<XStack
|
||||
onPress={() => {
|
||||
setOpen(false)
|
||||
scope.leaveOrg()
|
||||
}}
|
||||
cursor="pointer"
|
||||
items="center"
|
||||
gap="$2.5"
|
||||
px="$2"
|
||||
py="$2"
|
||||
rounded="$3"
|
||||
hoverStyle={{ bg: '$color5' }}
|
||||
>
|
||||
<YStack width={22} height={22} rounded="$3" bg="$color3" items="center" justify="center">
|
||||
<LayoutGrid size={14} />
|
||||
</YStack>
|
||||
<Text fontSize="$2" color="$color12">
|
||||
All organizations
|
||||
</Text>
|
||||
</XStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
)}
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* SelectMenu — a compact, reusable dropdown select on @hanzo/gui Popover (the
|
||||
* console's proven dropdown idiom, same as OrgSwitcher/ScopeSwitcher). Renders a
|
||||
* labelled trigger ("All types", or the chosen option) and a popover list with a
|
||||
* check on the active option. Prop-driven + self-contained so it lifts into
|
||||
* `@hanzo/ui`. `value === null` is the "all"/unfiltered state.
|
||||
* SelectMenu — a compact, reusable dropdown select. Renders a labelled trigger ("All
|
||||
* types", or the chosen option) and a menu list with a check on the active option.
|
||||
* Prop-driven + self-contained. `value === null` is the "all"/unfiltered state.
|
||||
*
|
||||
* A thin DropdownMenu (the ONE menu mechanism) so it is pixel-identical to every other
|
||||
* menu and rides the same portal-theme-safe Portal path — no gui Popover, no Sheet
|
||||
* re-root, works on gui-native hosts.
|
||||
*/
|
||||
import type { ReactElement } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Button, Popover, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { ChevronDown, Check } from '@hanzogui/lucide-icons-2'
|
||||
import { Button, Text } from '@hanzo/gui'
|
||||
import { ChevronDown } from '@hanzogui/lucide-icons-2'
|
||||
import { DropdownMenu } from './menu/DropdownMenu'
|
||||
import type { MenuItemSpec } from './menu/items'
|
||||
|
||||
export type SelectOption<T extends string> = { key: T; label: string }
|
||||
|
||||
@@ -31,18 +34,18 @@ export function SelectMenu<T extends string>({
|
||||
icon?: ReactElement
|
||||
minWidth?: number
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const active = value === null ? null : options.find((o) => o.key === value) ?? null
|
||||
const triggerLabel = active ? active.label : allLabel
|
||||
|
||||
const pick = (v: T | null) => {
|
||||
onChange(v)
|
||||
setOpen(false)
|
||||
}
|
||||
const items: MenuItemSpec[] = [
|
||||
{ key: '__all__', label: allLabel, selected: value === null, onSelect: () => onChange(null) },
|
||||
...options.map((o) => ({ key: o.key, label: o.label, selected: value === o.key, onSelect: () => onChange(o.key) })),
|
||||
]
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen} placement="bottom-start">
|
||||
<Popover.Trigger asChild>
|
||||
<DropdownMenu
|
||||
minWidth={Math.max(minWidth, 160)}
|
||||
trigger={
|
||||
<Button
|
||||
size="$2"
|
||||
minW={minWidth}
|
||||
@@ -57,38 +60,8 @@ export function SelectMenu<T extends string>({
|
||||
{triggerLabel}
|
||||
</Text>
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content bordered elevate p="$1.5" minW={minWidth} bg="$color2" borderColor="$borderColor">
|
||||
<YStack gap="$0.5" minW={minWidth} maxH={320} overflow="scroll">
|
||||
<Row label={allLabel} active={value === null} onPress={() => pick(null)} />
|
||||
{options.map((o) => (
|
||||
<Row key={o.key} label={o.label} active={value === o.key} onPress={() => pick(o.key)} />
|
||||
))}
|
||||
</YStack>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
|
||||
return (
|
||||
<XStack
|
||||
items="center"
|
||||
gap="$2"
|
||||
px="$2.5"
|
||||
py="$1.5"
|
||||
rounded="$3"
|
||||
cursor="pointer"
|
||||
hoverStyle={{ bg: '$color4' }}
|
||||
bg={active ? '$color4' : 'transparent'}
|
||||
onPress={onPress}
|
||||
>
|
||||
<XStack width={14} items="center" justify="center">
|
||||
{active ? <Check size={13} /> : null}
|
||||
</XStack>
|
||||
<Text fontSize="$2" color="$color12" flex={1} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</XStack>
|
||||
}
|
||||
items={items}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,25 +1,135 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Theme toggle — flips the console between dark and light via next-theme. The
|
||||
* provider already wires `NextThemeProvider` → `GuiProvider`, so setting the
|
||||
* theme here re-themes the whole tree. Shows the action's target icon (a sun in
|
||||
* dark mode, a moon in light mode).
|
||||
* ThemeToggle — flips between dark and light. Framework-agnostic by default so
|
||||
* Vite / Tauri / Express hosts can use it with NO Next dependency:
|
||||
*
|
||||
* • Controlled: <ThemeToggle theme={theme} onToggle={setTheme} /> (host owns theme)
|
||||
* • Uncontrolled (agnostic): <ThemeToggle onThemeChange={fn} /> (toggles the DOM
|
||||
* `.dark` class + persists to localStorage)
|
||||
* • Uncontrolled (no props): <ThemeToggle /> (console/Next —
|
||||
* falls back to the OPTIONAL @hanzogui/next-theme path;
|
||||
* if it cannot load, degrades to the agnostic DOM toggle)
|
||||
*
|
||||
* Shows the action's target icon (a sun in dark mode, a moon in light mode).
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { Component, Suspense, lazy, useState } from 'react'
|
||||
import { Button } from '@hanzo/gui'
|
||||
import { useThemeSetting } from '@hanzogui/next-theme'
|
||||
import { Moon, Sun } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { current, resolvedTheme, set } = useThemeSetting()
|
||||
const isDark = (resolvedTheme ?? current ?? 'dark') !== 'light'
|
||||
export type ThemeMode = 'light' | 'dark'
|
||||
|
||||
export type ThemeToggleProps = {
|
||||
/** Controlled theme. Pair with `onToggle`/`onThemeChange` to fully control it. */
|
||||
theme?: ThemeMode
|
||||
/** Seed for uncontrolled mode (default: read from the DOM `.dark` class, else 'dark'). */
|
||||
defaultTheme?: ThemeMode
|
||||
/** Called with the next theme on toggle (alias of `onThemeChange`). */
|
||||
onToggle?: (next: ThemeMode) => void
|
||||
/** Called with the next theme on toggle. */
|
||||
onThemeChange?: (next: ThemeMode) => void
|
||||
/** @hanzo/gui Button size token (default "$2"). */
|
||||
size?: string
|
||||
/** aria-label override. */
|
||||
label?: string
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'theme'
|
||||
|
||||
function readDomTheme(): ThemeMode | undefined {
|
||||
if (typeof document === 'undefined') return undefined
|
||||
const el = document.documentElement
|
||||
if (el.classList.contains('dark')) return 'dark'
|
||||
if (el.classList.contains('light')) return 'light'
|
||||
try {
|
||||
const ls = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (ls === 'light' || ls === 'dark') return ls
|
||||
} catch {
|
||||
/* localStorage may be unavailable */
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function applyDomTheme(next: ThemeMode): void {
|
||||
if (typeof document === 'undefined') return
|
||||
const el = document.documentElement
|
||||
el.classList.toggle('dark', next === 'dark')
|
||||
el.classList.toggle('light', next === 'light')
|
||||
el.style.colorScheme = next
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, next)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The framework-agnostic toggle button. Controlled when `theme` is provided; otherwise
|
||||
* self-managed via the DOM `.dark` class. No framework dependency — safe on any host.
|
||||
*/
|
||||
export function AgnosticThemeToggle({
|
||||
theme,
|
||||
defaultTheme,
|
||||
onToggle,
|
||||
onThemeChange,
|
||||
size = '$2',
|
||||
label,
|
||||
}: ThemeToggleProps) {
|
||||
const controlled = theme !== undefined
|
||||
const [internal, setInternal] = useState<ThemeMode>(() => theme ?? defaultTheme ?? readDomTheme() ?? 'dark')
|
||||
const current: ThemeMode = controlled ? (theme as ThemeMode) : internal
|
||||
const isDark = current === 'dark'
|
||||
|
||||
const toggle = () => {
|
||||
const next: ThemeMode = isDark ? 'light' : 'dark'
|
||||
if (!controlled) {
|
||||
setInternal(next)
|
||||
applyDomTheme(next)
|
||||
}
|
||||
onToggle?.(next)
|
||||
onThemeChange?.(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="$2"
|
||||
size={size as never}
|
||||
chromeless
|
||||
icon={isDark ? <Sun size={16} /> : <Moon size={16} />}
|
||||
onPress={() => set(isDark ? 'light' : 'dark')}
|
||||
aria-label={isDark ? 'Switch to light theme' : 'Switch to dark theme'}
|
||||
onPress={toggle}
|
||||
aria-label={label ?? (isDark ? 'Switch to light theme' : 'Switch to dark theme')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// @hanzogui/next-theme is an OPTIONAL dependency — loaded only on the uncontrolled,
|
||||
// no-props path (console/Next). Lazily code-split so non-Next hosts never need it at
|
||||
// runtime; if it fails to load, the boundary below degrades to the agnostic toggle.
|
||||
const NextThemeToggle = lazy(() => import('./ThemeToggleNext'))
|
||||
|
||||
class NextThemeBoundary extends Component<{ fallback: ReactNode; children: ReactNode }, { failed: boolean }> {
|
||||
state = { failed: false }
|
||||
static getDerivedStateFromError() {
|
||||
return { failed: true }
|
||||
}
|
||||
render() {
|
||||
return this.state.failed ? this.props.fallback : this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
export function ThemeToggle(props: ThemeToggleProps) {
|
||||
const injected =
|
||||
props.theme !== undefined || props.onToggle !== undefined || props.onThemeChange !== undefined
|
||||
if (injected) return <AgnosticThemeToggle {...props} />
|
||||
|
||||
// No props → keep the existing console/Next behaviour via the optional next-theme
|
||||
// path, degrading to the agnostic DOM toggle if it is not installed.
|
||||
const fallback = <AgnosticThemeToggle {...props} />
|
||||
return (
|
||||
<NextThemeBoundary fallback={fallback}>
|
||||
<Suspense fallback={fallback}>
|
||||
<NextThemeToggle size={props.size} label={props.label} />
|
||||
</Suspense>
|
||||
</NextThemeBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* ThemeToggleNext — the @hanzogui/next-theme binding (console / Next). This is the ONLY
|
||||
* module that imports @hanzogui/next-theme, so it stays an OPTIONAL dependency: the base
|
||||
* ThemeToggle lazy-loads it (default export) and non-Next hosts never pull it in unless
|
||||
* they import this component directly. UI is delegated to the framework-agnostic button
|
||||
* in controlled mode, so every toggle looks identical.
|
||||
*/
|
||||
import { useThemeSetting } from '@hanzogui/next-theme'
|
||||
import { AgnosticThemeToggle, type ThemeToggleProps } from './ThemeToggle'
|
||||
|
||||
function ThemeToggleNext({ size, label }: Pick<ThemeToggleProps, 'size' | 'label'>) {
|
||||
const { current, resolvedTheme, set } = useThemeSetting()
|
||||
const isDark = (resolvedTheme ?? current ?? 'dark') !== 'light'
|
||||
return (
|
||||
<AgnosticThemeToggle
|
||||
theme={isDark ? 'dark' : 'light'}
|
||||
onToggle={(next) => set(next)}
|
||||
size={size}
|
||||
label={label}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default ThemeToggleNext
|
||||
export { ThemeToggleNext }
|
||||
@@ -38,6 +38,20 @@ export { Donut as DonutRing, type DonutSegment } from './Donut'
|
||||
export { ComboBox } from './ComboBox'
|
||||
export * from './combobox/filter'
|
||||
|
||||
// The shared shell — brand mark, org scope + switcher, app header (the
|
||||
// console's org-scope contract + switcher hoisted here; hanzoai/ui#36).
|
||||
// `surfaces.data` is the ONE canonical cross-surface list every launcher consumes.
|
||||
export * from './surfaces.data'
|
||||
export * from './AppHeader'
|
||||
export * from './BrandMark'
|
||||
export * from './OrgSwitcher'
|
||||
export * from './scope'
|
||||
|
||||
// Menu — the ONE menu system: portal-theme-safe DropdownMenu (click) + ContextMenu
|
||||
// (right-click), both rendering the SAME item spec (MenuPanel/MenuItemView) so every
|
||||
// menu across the fleet is pixel-identical. SelectMenu/ComboBox share the same spec.
|
||||
export * from './menu'
|
||||
|
||||
// The rest — every exported name below is unique across the layer.
|
||||
export * from './DataTable'
|
||||
export * from './EmptyState'
|
||||
@@ -53,5 +67,10 @@ export * from './SelectMenu'
|
||||
export * from './SlideOver'
|
||||
export * from './StatusTag'
|
||||
export * from './ThemeToggle'
|
||||
export { ThemeToggleNext } from './ThemeToggleNext'
|
||||
export * from './Toast'
|
||||
export * from './color'
|
||||
|
||||
// Usage — the canonical AI-usage surface (totals, chart, spend-by-model, activity)
|
||||
// now lives in ONE home: `@hanzo/usage/panel` (<UsagePanel>). The former meter/card/
|
||||
// dashboard kit here was orphaned (zero consumers) and folded into it.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* ContextMenu — a right-click menu that renders the SAME shared item spec as DropdownMenu,
|
||||
* so every menu in the fleet is identical. Built on FloatingMenu (gui Portal), anchored to
|
||||
* the cursor point. Web/desktop right-click (`onContextMenu`); on native — which has no
|
||||
* right-click — the wrapped child renders untouched.
|
||||
*
|
||||
* <ContextMenu items={[{ key:'copy', label:'Copy', icon:<Copy size={16}/>, onSelect:copy }]}>
|
||||
* <YStack>right-click me</YStack>
|
||||
* </ContextMenu>
|
||||
*/
|
||||
import type { ReactElement, MouseEvent } from 'react'
|
||||
import { cloneElement, useCallback, useState } from 'react'
|
||||
import { renderMenuItems, type MenuItemSpec } from './items'
|
||||
import { FloatingMenu } from './FloatingMenu'
|
||||
|
||||
export type ContextMenuProps = {
|
||||
/** The right-clickable target. Its `onContextMenu` is composed, not replaced. */
|
||||
children: ReactElement
|
||||
items: MenuItemSpec[]
|
||||
disabled?: boolean
|
||||
minWidth?: number
|
||||
maxHeight?: number
|
||||
}
|
||||
|
||||
export function ContextMenu({ children, items, disabled, minWidth = 200, maxHeight }: ContextMenuProps) {
|
||||
const [state, setState] = useState<{ open: boolean; x: number; y: number }>({ open: false, x: 0, y: 0 })
|
||||
const close = useCallback(() => setState((s) => (s.open ? { ...s, open: false } : s)), [])
|
||||
// A zero-size rect at the cursor; FloatingMenu opens the panel at that point (gap 0).
|
||||
const anchorRect = useCallback(
|
||||
() => ({ left: state.x, top: state.y, right: state.x, bottom: state.y, width: 0, height: 0 }),
|
||||
[state.x, state.y],
|
||||
)
|
||||
|
||||
const childOnContextMenu = (children.props as { onContextMenu?: (e: MouseEvent) => void }).onContextMenu
|
||||
const target = cloneElement(children, {
|
||||
onContextMenu: (e: MouseEvent) => {
|
||||
childOnContextMenu?.(e)
|
||||
if (disabled) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setState({ open: true, x: e.clientX, y: e.clientY })
|
||||
},
|
||||
} as Partial<typeof children.props>)
|
||||
|
||||
return (
|
||||
<>
|
||||
{target}
|
||||
<FloatingMenu open={state.open} onClose={close} anchorRect={anchorRect} gap={0} minWidth={minWidth} maxHeight={maxHeight}>
|
||||
{renderMenuItems(items, close)}
|
||||
</FloatingMenu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* DropdownMenu — a portal-theme-safe click menu. Declarative `items` rendered through the
|
||||
* ONE shared menu-item spec, so it is pixel-identical to ContextMenu/SelectMenu/ComboBox.
|
||||
* Built on FloatingMenu (gui Portal), NOT the gui Popover — so it mounts without the
|
||||
* Sheet re-root that loses theme context on gui-native hosts (react-native-web-lite).
|
||||
*
|
||||
* <DropdownMenu
|
||||
* trigger={<Button>Actions</Button>}
|
||||
* items={[
|
||||
* { key: 'rename', label: 'Rename', icon: <Pencil size={16} />, onSelect: rename },
|
||||
* { type: 'separator' },
|
||||
* { key: 'del', label: 'Delete', destructive: true, onSelect: del },
|
||||
* ]}
|
||||
* />
|
||||
*/
|
||||
import type { ReactElement } from 'react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { XStack, useControllableState } from '@hanzo/gui'
|
||||
import { renderMenuItems, type MenuItemSpec } from './items'
|
||||
import { FloatingMenu } from './FloatingMenu'
|
||||
|
||||
export type DropdownMenuProps = {
|
||||
/** The clickable element. Wrapped so any element opens the menu on press. */
|
||||
trigger: ReactElement
|
||||
items: MenuItemSpec[]
|
||||
/** Controlled open state. Omit for uncontrolled. */
|
||||
open?: boolean
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
minWidth?: number
|
||||
maxHeight?: number
|
||||
}
|
||||
|
||||
export function DropdownMenu({
|
||||
trigger,
|
||||
items,
|
||||
open,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
minWidth = 200,
|
||||
maxHeight,
|
||||
}: DropdownMenuProps) {
|
||||
const [isOpen, setOpen] = useControllableState({
|
||||
prop: open,
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
})
|
||||
const anchorRef = useRef<HTMLElement | null>(null)
|
||||
const anchorRect = useCallback(() => anchorRef.current?.getBoundingClientRect() ?? null, [])
|
||||
const anchorEl = useCallback(() => anchorRef.current, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<XStack ref={anchorRef as never} self="flex-start" cursor="pointer" onPress={() => setOpen(!isOpen)}>
|
||||
{trigger}
|
||||
</XStack>
|
||||
<FloatingMenu
|
||||
open={isOpen}
|
||||
onClose={() => setOpen(false)}
|
||||
anchorRect={anchorRect}
|
||||
anchorEl={anchorEl}
|
||||
minWidth={minWidth}
|
||||
maxHeight={maxHeight}
|
||||
>
|
||||
{renderMenuItems(items, () => setOpen(false))}
|
||||
</FloatingMenu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* FloatingMenu — the ONE floating-panel mechanism for every menu. Renders the shared
|
||||
* MenuPanel through the @hanzo/gui Portal, positioned at an anchor (a trigger rect or a
|
||||
* cursor point), theme-forwarded via PortalTheme, edge-flipped, dismiss-wired and
|
||||
* keyboard-navigable.
|
||||
*
|
||||
* Why Portal, not gui Popover: gui's Popover pulls in a SheetController that re-roots the
|
||||
* trigger subtree and reads the theme from React context — on gui-native hosts
|
||||
* (react-native-web-lite, theme in context, no CSS-class fallback) that throws
|
||||
* "Missing theme" AT MOUNT. The raw Portal does not re-root, so DropdownMenu, ContextMenu,
|
||||
* SelectMenu and ComboBox all ride this ONE working path.
|
||||
*/
|
||||
import type { ReactNode, KeyboardEvent as ReactKeyboardEvent } from 'react'
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { Portal } from '@hanzo/gui'
|
||||
import { MenuPanel } from './items'
|
||||
import { PortalTheme, useThemeName } from './portal-theme'
|
||||
import { menuKeyDown } from './roving'
|
||||
|
||||
const EDGE = 8
|
||||
|
||||
export type RectLike = { left: number; top: number; right: number; bottom: number; width: number; height: number }
|
||||
|
||||
export function FloatingMenu({
|
||||
open,
|
||||
onClose,
|
||||
anchorRect,
|
||||
anchorEl,
|
||||
gap = 4,
|
||||
minWidth = 200,
|
||||
maxHeight,
|
||||
autoFocus = true,
|
||||
children,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
/** Anchor in viewport coords — a trigger's rect, or a zero-size rect at a cursor point. */
|
||||
anchorRect: () => RectLike | null | undefined
|
||||
/** Element excluded from outside-dismiss (the trigger / input row). */
|
||||
anchorEl?: () => HTMLElement | null | undefined
|
||||
/** Space between the anchor's bottom and the panel (0 for a cursor menu). */
|
||||
gap?: number
|
||||
minWidth?: number
|
||||
maxHeight?: number
|
||||
/** Focus the panel on open (for roving keys). ComboBox keeps focus in its input. */
|
||||
autoFocus?: boolean
|
||||
children: ReactNode
|
||||
}) {
|
||||
const themeName = useThemeName()
|
||||
const panelRef = useRef<HTMLElement | null>(null)
|
||||
const [pos, setPos] = useState<{ left: number; top: number } | null>(null)
|
||||
|
||||
const setPanel = useCallback((n: HTMLElement | null) => {
|
||||
panelRef.current = n
|
||||
}, [])
|
||||
|
||||
const place = useCallback(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
const a = anchorRect()
|
||||
if (!a) return
|
||||
const node = panelRef.current
|
||||
const w = node?.offsetWidth || minWidth
|
||||
const h = node?.offsetHeight || 0
|
||||
let left = a.left
|
||||
let top = a.bottom + gap
|
||||
if (left + w > window.innerWidth - EDGE) left = Math.max(EDGE, window.innerWidth - w - EDGE)
|
||||
if (h && top + h > window.innerHeight - EDGE) {
|
||||
const above = a.top - gap - h
|
||||
top = above >= EDGE ? above : Math.max(EDGE, window.innerHeight - h - EDGE)
|
||||
}
|
||||
setPos({ left, top })
|
||||
}, [anchorRect, gap, minWidth])
|
||||
|
||||
// Measure + position before paint; focus the panel for keyboard nav.
|
||||
useLayoutEffect(() => {
|
||||
if (!open) {
|
||||
setPos(null)
|
||||
return
|
||||
}
|
||||
place()
|
||||
if (autoFocus) panelRef.current?.focus?.()
|
||||
}, [open, place, autoFocus])
|
||||
|
||||
// Dismiss on outside pointer (excluding the anchor), Escape, scroll, resize, blur.
|
||||
useEffect(() => {
|
||||
if (!open || typeof document === 'undefined') return
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
const t = e.target as Node
|
||||
if (panelRef.current?.contains(t)) return
|
||||
const anchor = anchorEl?.()
|
||||
if (anchor && anchor.contains(t)) return
|
||||
onClose()
|
||||
}
|
||||
const onKey = (e: globalThis.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('pointerdown', onPointerDown, true)
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
window.addEventListener('scroll', onClose, true)
|
||||
window.addEventListener('resize', onClose)
|
||||
window.addEventListener('blur', onClose)
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown, true)
|
||||
document.removeEventListener('keydown', onKey, true)
|
||||
window.removeEventListener('scroll', onClose, true)
|
||||
window.removeEventListener('resize', onClose)
|
||||
window.removeEventListener('blur', onClose)
|
||||
}
|
||||
}, [open, onClose, anchorEl])
|
||||
|
||||
if (!open) return null
|
||||
return (
|
||||
<Portal>
|
||||
<PortalTheme name={themeName}>
|
||||
<MenuPanel
|
||||
panelRef={setPanel}
|
||||
minWidth={minWidth}
|
||||
maxHeight={maxHeight}
|
||||
tabIndex={-1}
|
||||
onKeyDown={(e: ReactKeyboardEvent) => menuKeyDown(e, onClose)}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: pos?.left ?? 0,
|
||||
top: pos?.top ?? 0,
|
||||
zIndex: 100000,
|
||||
visibility: pos ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MenuPanel>
|
||||
</PortalTheme>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// @hanzo/ui/product menu — ONE menu system. Portal-theme-safe DropdownMenu (click)
|
||||
// and ContextMenu (right-click) both render the SAME item spec, so every menu across
|
||||
// the fleet is pixel-identical. See items.tsx for the shared spec.
|
||||
|
||||
export { DropdownMenu, type DropdownMenuProps } from './DropdownMenu'
|
||||
export { ContextMenu, type ContextMenuProps } from './ContextMenu'
|
||||
export { FloatingMenu, type RectLike } from './FloatingMenu'
|
||||
export {
|
||||
MenuPanel,
|
||||
MenuItemView,
|
||||
MenuSeparatorView,
|
||||
MenuLabelView,
|
||||
renderMenuItems,
|
||||
type MenuItemSpec,
|
||||
} from './items'
|
||||
export { PortalTheme } from './portal-theme'
|
||||
@@ -0,0 +1,230 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* The ONE menu-item spec — every menu across the fleet renders through these
|
||||
* presentational primitives, so DropdownMenu, ContextMenu, SelectMenu and ComboBox
|
||||
* are pixel-identical. Geometry is literal px on a strict 8-grid (never random);
|
||||
* colour is theme-adaptive tokens ($color2/$color11/$color12/$borderColor) so light
|
||||
* and dark both work, with the single purple accent supplied by the brand CSS vars.
|
||||
*
|
||||
* Style props use the @hanzo/gui config shorthands (bg/items/justify/px/py/p/mx/my/
|
||||
* minW/maxH/rounded/select/shrink) — the config omits the longhand aliases.
|
||||
*
|
||||
* Panel — bg $color2 (#111 dark), hairline border, radius 12, inner pad 4, subtle
|
||||
* border+ambient elevation.
|
||||
* Item — height 30 (28–32 band), px 8, gap 8, radius 7; icon in a fixed 16px slot
|
||||
* on the left; label 13px $color12; right affordance (shortcut / check /
|
||||
* chevron) right-aligned. States: hover/active/focus → accent-soft, focus
|
||||
* visible; selected → check + purple; disabled → muted, no hover.
|
||||
* Separator — 1px hairline, 4px vertical margin.
|
||||
* Label — 11px uppercase muted section header, px 8.
|
||||
*/
|
||||
import type { ReactNode, KeyboardEvent } from 'react'
|
||||
import { Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Check, ChevronRight } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
// ── Geometry — literal px, 8-grid ──────────────────────────────────────────────
|
||||
const ITEM_MIN_HEIGHT = 30
|
||||
const ITEM_RADIUS = 7
|
||||
const ITEM_PX = 8
|
||||
const ITEM_GAP = 8
|
||||
const ICON_SLOT = 16
|
||||
export const PANEL_RADIUS = 12
|
||||
export const PANEL_PAD = 4
|
||||
const PANEL_GAP = 2
|
||||
const SEP_MARGIN = 4
|
||||
const FONT_LABEL = 13
|
||||
const FONT_MUTED = 11
|
||||
const FONT_SHORTCUT = 12
|
||||
|
||||
// ── Colour — brand purple accent via CSS vars (fallbacks keep it correct even when
|
||||
// @hanzo/brand is not loaded, e.g. a bare Vite/Tauri host); everything else uses
|
||||
// theme-adaptive Tamagui tokens. ──────────────────────────────────────────────
|
||||
const ACCENT = 'var(--hanzo-accent, #8b5cf6)'
|
||||
const ACCENT_SOFT = 'var(--hanzo-accent-soft, rgba(139,92,246,0.16))'
|
||||
const DANGER = 'var(--hanzo-danger, #ef4444)'
|
||||
|
||||
// ── Declarative item model ──────────────────────────────────────────────────────
|
||||
export type MenuItemSpec =
|
||||
| {
|
||||
type?: 'item'
|
||||
/** Stable key. */
|
||||
key: string
|
||||
label: string
|
||||
/** Leading Lucide icon (16px, unfilled) in the fixed left slot. */
|
||||
icon?: ReactNode
|
||||
/** Second muted line under the label (e.g. an id or hint). */
|
||||
description?: string
|
||||
/** Right-aligned shortcut / hint text (e.g. "⌘K"). */
|
||||
shortcut?: string
|
||||
/** Renders a right check + purple tint. */
|
||||
selected?: boolean
|
||||
disabled?: boolean
|
||||
/** Danger styling (red label/icon). */
|
||||
destructive?: boolean
|
||||
/** Renders a right chevron (submenu / drill-in affordance). */
|
||||
hasSubmenu?: boolean
|
||||
onSelect: () => void
|
||||
/** Keep the menu open after selecting (default: close). */
|
||||
closeOnSelect?: boolean
|
||||
}
|
||||
| { type: 'separator'; key?: string }
|
||||
| { type: 'label'; key?: string; label: string }
|
||||
|
||||
// ── Surface ───────────────────────────────────────────────────────────────────
|
||||
export function MenuPanel({
|
||||
children,
|
||||
minWidth = 200,
|
||||
maxHeight = 360,
|
||||
onKeyDown,
|
||||
panelRef,
|
||||
...rest
|
||||
}: {
|
||||
children: ReactNode
|
||||
minWidth?: number
|
||||
maxHeight?: number
|
||||
onKeyDown?: (e: KeyboardEvent) => void
|
||||
/** Web DOM node of the panel (for measuring / edge-flip). */
|
||||
panelRef?: (node: HTMLElement | null) => void
|
||||
[key: string]: unknown
|
||||
}) {
|
||||
return (
|
||||
<YStack
|
||||
// Web DOM ref; Gui forwards it to the underlying node. Cast (not @ts-expect-error)
|
||||
// so it is env-agnostic — the ref type is strict under the pkg build, loose here.
|
||||
ref={panelRef as never}
|
||||
role="menu"
|
||||
bg="$color2"
|
||||
borderColor="$borderColor"
|
||||
borderWidth={1}
|
||||
rounded={PANEL_RADIUS}
|
||||
p={PANEL_PAD}
|
||||
gap={PANEL_GAP}
|
||||
minW={minWidth}
|
||||
maxH={maxHeight}
|
||||
overflow="scroll"
|
||||
// Subtle border + ambient elevation, minimal shadow.
|
||||
shadowColor="rgba(0,0,0,0.45)"
|
||||
shadowRadius={20}
|
||||
shadowOffset={{ width: 0, height: 10 }}
|
||||
onKeyDown={onKeyDown as never}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Item — the ONE row ──────────────────────────────────────────────────────────
|
||||
export function MenuItemView({
|
||||
icon,
|
||||
label,
|
||||
description,
|
||||
shortcut,
|
||||
selected = false,
|
||||
disabled = false,
|
||||
destructive = false,
|
||||
hasSubmenu = false,
|
||||
onSelect,
|
||||
}: Omit<Extract<MenuItemSpec, { type?: 'item' }>, 'key' | 'type' | 'closeOnSelect'>) {
|
||||
const press = () => {
|
||||
if (!disabled) onSelect()
|
||||
}
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (disabled) return
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onSelect()
|
||||
}
|
||||
}
|
||||
const labelColor = destructive ? DANGER : '$color12'
|
||||
return (
|
||||
<XStack
|
||||
role="menuitem"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-disabled={disabled || undefined}
|
||||
items="center"
|
||||
gap={ITEM_GAP}
|
||||
px={ITEM_PX}
|
||||
minH={ITEM_MIN_HEIGHT}
|
||||
rounded={ITEM_RADIUS}
|
||||
cursor={disabled ? 'default' : 'pointer'}
|
||||
opacity={disabled ? 0.4 : 1}
|
||||
select="none"
|
||||
style={{ outline: 'none' }}
|
||||
hoverStyle={disabled ? {} : { bg: ACCENT_SOFT as never }}
|
||||
pressStyle={disabled ? {} : { bg: ACCENT_SOFT as never }}
|
||||
focusStyle={disabled ? {} : { bg: ACCENT_SOFT as never }}
|
||||
onPress={press}
|
||||
onKeyDown={onKeyDown as never}
|
||||
>
|
||||
<XStack width={ICON_SLOT} height={ICON_SLOT} items="center" justify="center" shrink={0}>
|
||||
{icon ? (
|
||||
<XStack items="center" justify="center" opacity={destructive ? 1 : 0.9} style={destructive ? { color: DANGER } : undefined}>
|
||||
{icon}
|
||||
</XStack>
|
||||
) : null}
|
||||
</XStack>
|
||||
|
||||
<YStack flex={1} minW={0}>
|
||||
<Text fontSize={FONT_LABEL} lineHeight={18} color={labelColor as never} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
{description ? (
|
||||
<Text fontSize={FONT_MUTED} lineHeight={14} color="$color11" numberOfLines={1}>
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
|
||||
{shortcut ? (
|
||||
<Text fontSize={FONT_SHORTCUT} color="$color11" shrink={0}>
|
||||
{shortcut}
|
||||
</Text>
|
||||
) : null}
|
||||
{selected ? <Check size={14} color={ACCENT} /> : null}
|
||||
{hasSubmenu ? <ChevronRight size={14} color="var(--hanzo-muted, currentColor)" opacity={0.6} /> : null}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Separator ───────────────────────────────────────────────────────────────────
|
||||
export function MenuSeparatorView() {
|
||||
return <YStack height={1} bg="$borderColor" my={SEP_MARGIN} mx={SEP_MARGIN} role="separator" />
|
||||
}
|
||||
|
||||
// ── Section label ────────────────────────────────────────────────────────────────
|
||||
export function MenuLabelView({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Text
|
||||
fontSize={FONT_MUTED}
|
||||
color="$color11"
|
||||
px={ITEM_PX}
|
||||
py={SEP_MARGIN}
|
||||
textTransform="uppercase"
|
||||
letterSpacing={0.4}
|
||||
select="none"
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The single render path shared by every menu ──────────────────────────────────
|
||||
export function renderMenuItems(items: MenuItemSpec[], close?: () => void): ReactNode {
|
||||
return items.map((it, i) => {
|
||||
if (it.type === 'separator') return <MenuSeparatorView key={it.key ?? `sep-${i}`} />
|
||||
if (it.type === 'label') return <MenuLabelView key={it.key ?? `lbl-${i}`}>{it.label}</MenuLabelView>
|
||||
const { key, onSelect, closeOnSelect = true, ...rest } = it
|
||||
return (
|
||||
<MenuItemView
|
||||
key={key}
|
||||
{...rest}
|
||||
onSelect={() => {
|
||||
onSelect()
|
||||
if (closeOnSelect) close?.()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* PortalTheme — the ONE fix for "themed content escapes its theme through a portal".
|
||||
*
|
||||
* @hanzo/gui's `Popover.Content` and `Portal` render their children in a SEPARATE
|
||||
* React subtree (a Gorhom-style portal host mounted at the app root), NOT via
|
||||
* `ReactDOM.createPortal`. React context therefore does NOT flow from the trigger's
|
||||
* location to the host. Under a nested `<Theme name="dark">` the portaled content
|
||||
* lands OUTSIDE that theme context and Tamagui throws "Missing theme" (or renders
|
||||
* unthemed).
|
||||
*
|
||||
* The fix is two-part and must stay two-part:
|
||||
* 1. At the TRIGGER site (theme context still available) capture the resolved
|
||||
* theme name with `useThemeName()`.
|
||||
* 2. INSIDE the portaled content re-apply it: `<PortalTheme name={captured}>`.
|
||||
*
|
||||
* Every portal-backed menu in this package (DropdownMenu, ContextMenu, SelectMenu,
|
||||
* ComboBox) uses this so a menu renders identically whether it is opened under the
|
||||
* root theme or under any nested `<Theme>`, light or dark.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { Theme, useThemeName } from '@hanzo/gui'
|
||||
|
||||
/** Capture the current resolved theme name at the trigger site. Re-exported so the
|
||||
* capture + re-apply pair reads from one module. */
|
||||
export { useThemeName }
|
||||
|
||||
export function PortalTheme({ name, children }: { name: string; children: ReactNode }) {
|
||||
// `name` is the resolved theme name (e.g. "dark", or a compound "dark_purple").
|
||||
// Tamagui's `Theme` accepts the resolved name and reconstructs the context.
|
||||
return <Theme name={name as never}>{children}</Theme>
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Roving keyboard focus for a menu panel — ArrowUp/Down move focus between enabled
|
||||
* `[role="menuitem"]` children, Home/End jump to ends, Escape closes. Web/desktop
|
||||
* only (guards on `document`); native menus have no pointer-keyboard nav. Shared by
|
||||
* DropdownMenu and ContextMenu so navigation is identical.
|
||||
*/
|
||||
import type { KeyboardEvent } from 'react'
|
||||
|
||||
export function menuKeyDown(e: KeyboardEvent, onClose?: () => void): void {
|
||||
if (e.key === 'Escape') {
|
||||
onClose?.()
|
||||
return
|
||||
}
|
||||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Home' && e.key !== 'End') return
|
||||
if (typeof document === 'undefined') return
|
||||
|
||||
const panel = e.currentTarget as HTMLElement
|
||||
const items = Array.from(
|
||||
panel.querySelectorAll<HTMLElement>('[role="menuitem"]:not([aria-disabled="true"])'),
|
||||
)
|
||||
if (items.length === 0) return
|
||||
e.preventDefault()
|
||||
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
const current = active ? items.indexOf(active) : -1
|
||||
let next: number
|
||||
if (e.key === 'Home') next = 0
|
||||
else if (e.key === 'End') next = items.length - 1
|
||||
else if (e.key === 'ArrowDown') next = current < 0 ? 0 : (current + 1) % items.length
|
||||
else next = current <= 0 ? items.length - 1 : current - 1
|
||||
items[next]?.focus()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user