Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba571999a2 | ||
|
|
594314d351 | ||
|
|
c709b1f96a |
+50
-47
@@ -1,48 +1,51 @@
|
||||
# console2 — Hanzo Cloud Console (Next.js 15 + @hanzo/gui). MIT OR Apache-2.0.
|
||||
# NEXT_PUBLIC_* are inlined at build time (browser config), so they are build args.
|
||||
FROM public.ecr.aws/docker/library/node:24-alpine AS build
|
||||
WORKDIR /app
|
||||
# Exact commit for a deterministic Next build id (next.config.mjs generateBuildId).
|
||||
# The alpine image has no git binary, so CI passes the SHA as a build arg -> ENV,
|
||||
# baked into .next/BUILD_ID so every replica of this image shares ONE build id.
|
||||
ARG SOURCE_COMMIT=""
|
||||
ENV SOURCE_COMMIT=$SOURCE_COMMIT
|
||||
# Copy ALL source FIRST, then install — order matters under Kaniko --single-snapshot:
|
||||
# a `COPY` that FOLLOWS the install in the same stage drops that RUN's freshly
|
||||
# created node_modules (the 'next not found' cause — the install's own `test -f next`
|
||||
# passed, then `COPY . .` wiped node_modules before the build RUN). Putting COPY
|
||||
# before install means node_modules is created by the LAST RUNs and nothing clobbers
|
||||
# it. (Layer-cache for deps is moot here — the on-cluster build runs --cache=false.)
|
||||
COPY . .
|
||||
# public/ may be empty (git doesn't track empty dirs) — ensure it exists for the runner COPY.
|
||||
RUN mkdir -p public
|
||||
# corepack installs the exact pnpm from package.json's `packageManager`, so the
|
||||
# builder and a laptop resolve identically. --frozen-lockfile is the whole reason
|
||||
# this repo is on pnpm: the old `npm install` here could not be `npm ci`, because
|
||||
# @hanzo/gui's react-native tree resolves its platform/optional packages differently
|
||||
# across npm versions and a lockfile written by one npm failed under another. pnpm
|
||||
# records every platform in the lockfile, so the build installs exactly what is
|
||||
# committed and fails loudly instead of quietly resolving something else.
|
||||
RUN corepack enable && pnpm install --frozen-lockfile
|
||||
# ONE brand-agnostic image: brand (IAM org/issuer/app + wordmark) is resolved at
|
||||
# RUNTIME from the request hostname (src/config/index.ts), and /v1 is same-origin
|
||||
# per host. Baking NEXT_PUBLIC_* here would inline a single brand and break that.
|
||||
# Next 15 + @hanzo/gui (large RN dep tree) overflows Node's default heap → OOMKill
|
||||
# (exit 137); cap the heap generously (chat uses 4096).
|
||||
ENV NEXT_TELEMETRY_DISABLED=1 NODE_OPTIONS=--max-old-space-size=6144
|
||||
RUN pnpm build
|
||||
# hanzoai/console — the console image. It serves itself.
|
||||
#
|
||||
# The console is a static SPA export; this puts hanzoai/static in front of it. That
|
||||
# itself: hanzoai/static in front of the bundle. It exists so a console change
|
||||
# can reach production without a cloud release.
|
||||
#
|
||||
# Today console.hanzo.ai is answered by the cloud binary, which go:embeds the
|
||||
# bundle (webui/console.go `//go:embed all:dist`). That couples a frontend change
|
||||
# to a backend release: the bundle must be published, its tag pinned in cloud's
|
||||
# Dockerfile, and a whole cloud image rebuilt and rolled out. The pin commit that
|
||||
# preceded this one says what that costs — "four changes that could not reach
|
||||
# production".
|
||||
#
|
||||
# Nothing about the request path changes when this serves instead. The embedded
|
||||
# console is already a static export talking to the SAME origin's /v1, and cloud's
|
||||
# catch-all only ever answered paths that no API route claimed (its apiPrefixes
|
||||
# list is exactly "/v1/", "/api/", "/zap", "/healthz", "/readyz"). So the split is
|
||||
# the one the ingress already expresses for admin.lux.cloud: /v1 + /zap to cloud,
|
||||
# everything else here. Same bytes, same origin, same cookie — one fewer release
|
||||
# in the way.
|
||||
#
|
||||
# -spa, not a 404 page: every unknown path IS a client-side route for an app shell
|
||||
# (/models, /billing/budgets, a deep link someone pasted). The marketing site takes
|
||||
# the opposite setting for the opposite reason — there a miss is a mistake.
|
||||
|
||||
FROM public.ecr.aws/docker/library/node:24-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=4000
|
||||
RUN addgroup -S app && adduser -S app -G app
|
||||
COPY --from=build /app/.next ./.next
|
||||
COPY --from=build /app/public ./public
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/package.json ./package.json
|
||||
COPY --from=build /app/next.config.mjs ./next.config.mjs
|
||||
# next.config.mjs imports this at load time (build AND standalone runtime); copy it or the server ERR_MODULE_NOT_FOUND-crashes on boot.
|
||||
COPY --from=build /app/src/config/build-id.mjs ./src/config/build-id.mjs
|
||||
USER app
|
||||
EXPOSE 4000
|
||||
CMD ["node", "node_modules/next/dist/bin/next", "start", "-p", "4000"]
|
||||
FROM public.ecr.aws/docker/library/node:24-alpine AS build
|
||||
RUN apk add --no-cache git
|
||||
WORKDIR /console
|
||||
# Heap headroom so the full @hanzo/gui static export never OOMs into a stub; telemetry off.
|
||||
ENV NEXT_TELEMETRY_DISABLED=1 NODE_OPTIONS=--max-old-space-size=8192
|
||||
# The console.hanzo.ai analytics property (public per-site id, not a KMS secret) —
|
||||
# the same default Dockerfile.embed bakes, so a bundle served from here reports
|
||||
# identically to one served from inside cloud.
|
||||
ARG NEXT_PUBLIC_ANALYTICS_WEBSITE_ID=7dce54ee-41f6-4751-96bf-fe005067c7c7
|
||||
ENV NEXT_PUBLIC_ANALYTICS_WEBSITE_ID=$NEXT_PUBLIC_ANALYTICS_WEBSITE_ID
|
||||
COPY . .
|
||||
RUN corepack enable && pnpm install --frozen-lockfile
|
||||
# FAIL-HARD: the export MUST emit a real bundle, never a placeholder shell. An
|
||||
# empty index.html would serve a blank page on every route with a 200, which is
|
||||
# indistinguishable from a working deploy until someone opens it.
|
||||
RUN pnpm build:embed && [ -s out/index.html ] && [ -d out/_next ] \
|
||||
&& echo ">> servable REAL console bundle: $(wc -c < out/index.html)-byte index.html, $(du -sh out/_next | cut -f1) _next/"
|
||||
|
||||
# hanzoai/static, digest-pinned: a base image is pinned by digest so the bytes
|
||||
# cannot change under a rebuild. (The console's OWN release is named by semver in
|
||||
# the values file — that is the version a human reads.)
|
||||
FROM ghcr.io/hanzoai/static@sha256:346ad30dc7f762c508b4467c2801b3d7e9ec201ec9b257bc7a38b60d59cecc05
|
||||
COPY --from=build /console/out/ /srv/
|
||||
EXPOSE 3000
|
||||
ENTRYPOINT ["/static"]
|
||||
CMD ["-root=/srv", "-spa", "-port=3000"]
|
||||
|
||||
@@ -1,34 +1,42 @@
|
||||
# Canonical CI config for hanzoai/console — read by the hanzoai/ci reusable
|
||||
# (.hanzo/workflows/cicd.yml) and platform.hanzo.ai.
|
||||
# (.hanzo/workflows/cicd.yml) and platform.hanzo.ai. hanzoai/ci pushes to `repo:`
|
||||
# (GHCR) and server-side-mirrors to registry.hanzo.ai automatically.
|
||||
#
|
||||
# Publishes the console STATIC EMBED artifact (SPA static export at /dist) as a
|
||||
# versioned immutable image. hanzoai/cloud consumes it via `FROM ... AS console`
|
||||
# + `COPY --from=console /dist/`, so it never rebuilds npm+Next on a cloud release.
|
||||
# hanzoai/ci pushes to `repo:` (GHCR) and server-side-mirrors to registry.hanzo.ai
|
||||
# automatically.
|
||||
# TWO artifacts, one bundle. The console is a static SPA export; what differs is
|
||||
# only who serves it:
|
||||
#
|
||||
# BOTH console images are declared here — one config, read by whichever runner
|
||||
# executes it. The Next.js SERVER image (admin.hanzo.ai, operator CR
|
||||
# universe:crs/console.yaml) was built by .github/workflows/build-image.yml until
|
||||
# that file was neutralized on 2026-07-24 in favour of a native pipeline that
|
||||
# could not run: hanzoai/console had the forge Actions unit DISABLED
|
||||
# (`has_actions: false`, zero runs), so nothing built it — v8.5.23 and 8.5.24
|
||||
# shipped no image, and the CR still pins the last one built, v8.5.22. Declaring
|
||||
# both images here puts them on the ONE pipeline, wherever it executes.
|
||||
# console-embed the bundle alone at /dist. hanzoai/cloud does
|
||||
# `COPY --from=console /dist/` so a cloud release never rebuilds
|
||||
# npm+Next. Needed only while cloud go:embeds the console.
|
||||
# console the bundle behind hanzoai/static, serving itself. This is how
|
||||
# a console change ships WITHOUT a cloud release: move image.tag
|
||||
# in a universe values file and cd rolls it.
|
||||
#
|
||||
# Tag shape changes with the builder, deliberately: the shared builder publishes
|
||||
# the immutable `sha-<sha7>-amd64` per main push (plus the bare semver on a v*
|
||||
# tag), not the `:v<X.Y.Z>` receipt the old bespoke workflow minted. Pin the CR to
|
||||
# the sha tag — that is what hanzoai/cloud does, and an immutable digest-shaped
|
||||
# tag cannot be re-pushed to different bytes the way `:v8.4.118` once was.
|
||||
# The Next.js SERVER image that used to be the second entry is gone. It was
|
||||
# already doing nothing a file server could not — every host it served sent /v1
|
||||
# and /zap to cloud-api at the ingress, so its BFF was never reached — and its own
|
||||
# auth routes stopped mattering when identity became a client-held IAM token.
|
||||
#
|
||||
# TAGS: the shared builder publishes `sha-<sha7>-amd64` on every main push AND the
|
||||
# bare semver on a cut v* tag. PIN THE SEMVER — it says which console RELEASE a
|
||||
# deployment carries, which a sha cannot. The discipline that keeps that honest is
|
||||
# that a cut tag is never re-pointed (`:v8.4.118` once was): cut the next patch
|
||||
# instead.
|
||||
images:
|
||||
- name: console-embed
|
||||
context: .
|
||||
dockerfile: Dockerfile.embed
|
||||
repo: ghcr.io/hanzoai/console-embed
|
||||
# The brand-agnostic Next.js server image: brand resolves at RUNTIME from the
|
||||
# request hostname, so no NEXT_PUBLIC_* may be baked (baking pins the image to
|
||||
# one brand). SOURCE_COMMIT is the only build arg it ever took.
|
||||
# The console. It is static — that is not a variant, it is what the console IS,
|
||||
# so the image is `console` and there is no adjective in the name. Dockerfile
|
||||
# builds the SPA export and puts hanzoai/static in front of it.
|
||||
#
|
||||
# This REPLACES the Next.js server image that used to be published here. It was
|
||||
# already doing nothing a file server could not: every host it serves
|
||||
# (admin.lux.cloud, admin.lux.network, admin.zoo.cloud) sends /v1 and /zap to
|
||||
# cloud-api at the ingress, so the server's BFF at /v1/* was never reached on any
|
||||
# of them. Its own auth routes went the same way when identity became a
|
||||
# client-held IAM token. One console, one image.
|
||||
- name: console
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
|
||||
@@ -59,8 +59,8 @@ const CHANNELS: Channel[] = [
|
||||
icon: MessageCircle,
|
||||
title: 'Join Our Discord',
|
||||
body: 'Chat live with developers and other users from our community.',
|
||||
href: 'https://discord.gg/hanzo',
|
||||
cta: 'discord.gg/hanzo',
|
||||
href: 'https://discord.gg/CJCyAsm9Vr',
|
||||
cta: 'discord.gg/CJCyAsm9Vr',
|
||||
},
|
||||
{
|
||||
icon: Linkedin,
|
||||
|
||||
@@ -107,7 +107,7 @@ function NeedHelpCard() {
|
||||
return (
|
||||
<SectionCard title="Need help?" p="$3">
|
||||
<YStack gap="$0.5">
|
||||
<ActionRow icon={<MessageSquare size={15} />} label="Join Discord" onPress={() => openExternal('https://discord.gg/hanzo')} />
|
||||
<ActionRow icon={<MessageSquare size={15} />} label="Join Discord" onPress={() => openExternal('https://discord.gg/CJCyAsm9Vr')} />
|
||||
<ActionRow icon={<LifeBuoy size={15} />} label="Contact Support" onPress={() => openExternal(`mailto:support@${apex}`)} />
|
||||
</YStack>
|
||||
</SectionCard>
|
||||
|
||||
@@ -54,7 +54,7 @@ export function ResourceRail({ config, onViewCode }: { config: ProductLandingCon
|
||||
|
||||
<LandingCard title="Need help?" p="$3">
|
||||
<YStack gap="$0.5">
|
||||
<ActionRow icon={<MessageSquare size={15} />} label="Community" sub="Join the Discord" onPress={() => openExternal('https://discord.gg/hanzo')} />
|
||||
<ActionRow icon={<MessageSquare size={15} />} label="Community" sub="Join the Discord" onPress={() => openExternal('https://discord.gg/CJCyAsm9Vr')} />
|
||||
<ActionRow icon={<LifeBuoy size={15} />} label="Contact Support" sub={`support@${apex}`} onPress={() => openExternal(supportMailto(docs))} />
|
||||
</YStack>
|
||||
</LandingCard>
|
||||
|
||||
@@ -12,7 +12,7 @@ export function ModelSelect({
|
||||
ids,
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder = 'model id, e.g. zen-omni',
|
||||
placeholder = 'model id, e.g. zen5-mini',
|
||||
}: {
|
||||
value: string
|
||||
ids: string[]
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { EXAMPLES } from './examples'
|
||||
|
||||
describe('EXAMPLES', () => {
|
||||
it('has unique ids and fills every field', () => {
|
||||
const ids = EXAMPLES.map((e) => e.id)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
for (const e of EXAMPLES) {
|
||||
expect(e.label.trim()).not.toBe('')
|
||||
expect(e.system.trim()).not.toBe('')
|
||||
expect(e.user.trim()).not.toBe('')
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* The defect this guards: all six examples once suggested `zen-omni` or `zen-coder`,
|
||||
* and the gateway serves neither. Applying an example falls back to the selected
|
||||
* model when the suggestion is absent, so nothing threw and nothing was logged — the
|
||||
* card just advertised a model that could never be the one that ran.
|
||||
*
|
||||
* The test is on the SHAPE, not on a list of ids, because a hardcoded catalog would
|
||||
* rot the same way the suggestions did. Zen's naming splits cleanly: `zen5…` are the
|
||||
* text models; `zen-<noun>` names a modality (embedding, image, video, rerank, voice,
|
||||
* vl, guard) and cannot hold a chat turn. A chat example must suggest a text model.
|
||||
*/
|
||||
it('suggests only Zen TEXT models — never a modality SKU or a retired id', () => {
|
||||
for (const e of EXAMPLES) {
|
||||
expect(e.model, `${e.id} suggests "${e.model}"`).toMatch(/^zen\d/)
|
||||
}
|
||||
})
|
||||
|
||||
it('never suggests the ids that were wrong', () => {
|
||||
const retired = new Set(['zen-omni', 'zen-coder'])
|
||||
for (const e of EXAMPLES) expect(retired.has(e.model)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -3,8 +3,15 @@
|
||||
*
|
||||
* Each is a clearly-labelled STARTER (not fabricated history): picking one fills
|
||||
* the System + User message and, when the suggested model is in the live catalog,
|
||||
* selects it. Pure data; no network. The `model` is a suggestion shown as a chip,
|
||||
* exactly like the mockup ("Explain quantum computing · zen-omni").
|
||||
* selects it. Pure data; no network. The `model` is a suggestion shown as a chip.
|
||||
*
|
||||
* EVERY MODEL HERE MUST BE ONE THE GATEWAY SERVES. All six once named `zen-omni` or
|
||||
* `zen-coder`, and the catalog carries neither — zen's text models are `zen5…`, while
|
||||
* `zen-<noun>` names a modality (embedding, image, video, rerank, voice, vl). Applying
|
||||
* an example falls back to the currently-selected model when the suggestion is absent
|
||||
* (`models.byId.has(...)`), so nothing broke and nothing was logged: the card simply
|
||||
* advertised a model that would never be the one that ran. `examples.test.ts` bites on
|
||||
* the shape now, so a modality SKU or a retired id cannot come back.
|
||||
*/
|
||||
export type Example = {
|
||||
id: string
|
||||
@@ -20,42 +27,42 @@ export const EXAMPLES: Example[] = [
|
||||
{
|
||||
id: 'quantum',
|
||||
label: 'Explain quantum computing',
|
||||
model: 'zen-omni',
|
||||
model: 'zen5-mini',
|
||||
system: 'You are a patient teacher. Explain clearly for a curious beginner.',
|
||||
user: 'Explain quantum computing in simple terms, with one everyday analogy.',
|
||||
},
|
||||
{
|
||||
id: 'debounce',
|
||||
label: 'Write a debounce function',
|
||||
model: 'zen-coder',
|
||||
model: 'zen5-coder',
|
||||
system: 'You are an expert TypeScript engineer. Return only the code, no prose.',
|
||||
user: 'Write a typed debounce<T> function with a cancel() method.',
|
||||
},
|
||||
{
|
||||
id: 'summarize',
|
||||
label: 'Summarize a paragraph',
|
||||
model: 'zen-omni',
|
||||
model: 'zen5-mini',
|
||||
system: 'Summarize the user text in exactly three bullet points.',
|
||||
user: 'Hanzo Cloud is a unified AI gateway exposing hundreds of models behind one OpenAI-compatible API, with built-in retrieval, billing and per-org keys, so orgs switch models without changing code.',
|
||||
},
|
||||
{
|
||||
id: 'json',
|
||||
label: 'Extract structured JSON',
|
||||
model: 'zen-omni',
|
||||
model: 'zen5-mini',
|
||||
system: 'Respond with a single minified JSON object and nothing else.',
|
||||
user: 'Extract name, role and company as JSON from: "Aoi Tanaka, the CTO at Hanzo, presented today."',
|
||||
},
|
||||
{
|
||||
id: 'reasoning',
|
||||
label: 'Step-by-step reasoning',
|
||||
model: 'zen-omni',
|
||||
model: 'zen5',
|
||||
system: 'Think step by step, then give the final answer on its own line.',
|
||||
user: 'A bat and a ball cost $1.10 in total. The bat costs $1.00 more than the ball. How much does the ball cost?',
|
||||
},
|
||||
{
|
||||
id: 'sql',
|
||||
label: 'Write a SQL query',
|
||||
model: 'zen-coder',
|
||||
model: 'zen5-coder',
|
||||
system: 'You are a senior data engineer. Return only the SQL.',
|
||||
user: 'Given users(id, created_at) and orders(id, user_id, total), write SQL for the top 5 users by total spend in 2026.',
|
||||
},
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ function consented(): boolean {
|
||||
|
||||
// ── No publishable ingest key is passed, and that is DELIBERATE ──────────────
|
||||
//
|
||||
// Do not "fix" this by adding a build arg or by reading NEXT_PUBLIC_EVENT_INGEST_KEY
|
||||
// Do not "fix" this by adding a build arg or by reading NEXT_PUBLIC_PUBLISHABLE_KEY
|
||||
// here.
|
||||
//
|
||||
// A `pk-` resolves to exactly ONE org (cloud stamps the tenant from the key), and
|
||||
|
||||
Reference in New Issue
Block a user