You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/05/18 13:08:09 UTC

(skywalking-horizon-ui) branch main updated (6d84d7b -> 1fd9659)

This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a change to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git


    from 6d84d7b  auth: boot-and-warn when no users / no LDAP configured; surface state on login page
     new 961513b  build: real packages/* builds + self-contained dist + copy-in image (no compile in image)
     new 1fd9659  logging: treat local `node dist/server.js` as production (quiet); only `pnpm dev` is dev

The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 pnpm-workspace.yaml => .dockerignore       |  13 ++-
 .github/workflows/publish-image.yaml       |  25 +++++-
 Dockerfile                                 |  82 +++++++-----------
 README.md                                  |  39 +++++++--
 apps/bff/package.json                      |   5 +-
 apps/bff/src/logger.ts                     |  24 +++++-
 apps/bff/src/logic/layers/loader.ts        |   5 ++
 apps/bff/src/logic/overview/loader.ts      |   1 +
 apps/bff/src/rbac/route-policy.ts          |  12 +--
 apps/bff/src/server.ts                     |  19 +++--
 docs/setup/container-image.md              |  56 ++++++++++++-
 package.json                               |   4 +-
 packages/api-client/package.json           |  14 +++-
 packages/api-client/tsconfig.build.json    |  16 ++++
 packages/design-tokens/package.json        |  14 +++-
 packages/design-tokens/tsconfig.build.json |  16 ++++
 packages/templates/package.json            |  13 ++-
 packages/templates/tsconfig.build.json     |  16 ++++
 scripts/package.mjs                        | 130 +++++++++++++++++++++++++++++
 19 files changed, 414 insertions(+), 90 deletions(-)
 copy pnpm-workspace.yaml => .dockerignore (71%)
 create mode 100644 packages/api-client/tsconfig.build.json
 create mode 100644 packages/design-tokens/tsconfig.build.json
 create mode 100644 packages/templates/tsconfig.build.json
 create mode 100644 scripts/package.mjs


(skywalking-horizon-ui) 02/02: logging: treat local `node dist/server.js` as production (quiet); only `pnpm dev` is dev

Posted by wu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit 1fd965986fc82b66a790cdc912a28e7c58c784b2
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Mon May 18 21:07:57 2026 +0800

    logging: treat local `node dist/server.js` as production (quiet); only `pnpm dev` is dev
    
    The previous logger default flipped on `NODE_ENV !== 'production'`, so
    `node dist/server.js` without an explicit env var fell into dev mode
    (verbose pretty logs). The published binary is a production target —
    it should be quiet by default and emit JSON for log aggregators.
    
    Inverted: dev mode requires `NODE_ENV=development` explicitly.
    Everything else (the local binary, the Docker image, CI) is production.
    
    - logger.ts: `isDev = NODE_ENV === 'development'`. Dev → debug+pretty,
      prod → error+JSON. Doc-comment spells out where each mode kicks in.
    - apps/bff dev script sets `NODE_ENV=development` explicitly so the
      contributor workflow (`pnpm --filter bff dev`) keeps the verbose +
      per-request access logs that make iteration fast.
    - container-image.md updated to reflect: any non-development entry
      point lands in the production log shape.
    - README Development section rewritten — replaced stale `npm install`
      recipe with the real pnpm-based loop, added the binary + image
      paths, and documented the two log modes + the LOG_LEVEL knob for
      triage.
    
    verified: tests 77/77, lint clean, license-eye clean.
---
 README.md                     | 39 +++++++++++++++++++++++++++++++--------
 apps/bff/package.json         |  2 +-
 apps/bff/src/logger.ts        | 19 ++++++++++++++-----
 docs/setup/container-image.md |  4 ++--
 4 files changed, 48 insertions(+), 16 deletions(-)

diff --git a/README.md b/README.md
index 65fb970..b46184c 100644
--- a/README.md
+++ b/README.md
@@ -88,18 +88,41 @@ Horizon UI is built against the SkyWalking OAP GraphQL query-protocol (same sche
 
 ## Development
 
-Requires Node.js 20+.
+Requires Node.js 20+ and pnpm 10+ (pinned via Corepack in `package.json`).
 
 ```bash
-npm install
-npm run dev          # vite dev server (proxies /graphql to http://127.0.0.1:12800)
-npm run build        # production build
-npm run type-check
-npm run lint
-npm run test:unit
-npm run license:check
+pnpm install                     # one-time / after lockfile changes; auto-builds workspace packages via `prepare`
+
+# Dev loop (hot-reload, verbose pretty logs)
+pnpm --filter bff dev            # BFF on :8081 with NODE_ENV=development → debug level + per-request access logs
+pnpm --filter ui dev             # Vite dev server on :9091, proxies /api to the BFF
+
+# Static checks / tests
+pnpm -r type-check
+pnpm -r lint                     # read-only; `pnpm -r lint:fix` to mutate
+pnpm -r test:unit
+pnpm license:check               # CI gate via skywalking-eyes
+
+# Self-contained "binary mode" — produces ./dist/ that boots with `node dist/server.js`
+pnpm package
+HORIZON_CONFIG=./horizon.yaml HORIZON_STATIC_DIR=./dist/static node dist/server.js
+
+# Container — Docker just copies in the pre-built dist (no compile in image)
+docker build -t horizon-ui:local .
+docker run --rm -p 8081:8081 -v "$PWD/horizon.yaml:/app/horizon.yaml:ro" horizon-ui:local
 ```
 
+### Logging
+
+The BFF uses [pino](https://github.com/pinojs/pino). Two modes, picked from `NODE_ENV`:
+
+| Mode | When | Format | Default level |
+|---|---|---|---|
+| **Dev** | `pnpm --filter bff dev` (sets `NODE_ENV=development`) | Pretty, colorized, via `pino-pretty` | `debug` + per-request access logs |
+| **Prod** | Everything else — `node dist/server.js`, Docker container, CI | One JSON object per line on stdout | `error` — quiet by default |
+
+Adjust with `LOG_LEVEL`: `info` opens per-request access logs in prod, `debug` adds lifecycle chatter, `trace` is everything. Genuine request errors stay logged at `error` under any default that includes `error`. See [docs/setup/container-image.md → Logging](docs/setup/container-image.md#logging) for the three orthogonal channels (app logs, audit log, wire-debug log) and example `jq` recipes.
+
 ## License
 
 Apache 2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
diff --git a/apps/bff/package.json b/apps/bff/package.json
index 9e50ba6..b7b50d4 100644
--- a/apps/bff/package.json
+++ b/apps/bff/package.json
@@ -8,7 +8,7 @@
     "dist"
   ],
   "scripts": {
-    "dev": "tsx watch src/server.ts",
+    "dev": "NODE_ENV=development tsx watch src/server.ts",
     "build": "esbuild src/server.ts --bundle --platform=node --format=esm --target=node20 --outfile=dist/server.js --packages=external",
     "cli:hash": "tsx src/cli/hash.ts",
     "type-check": "tsc --noEmit",
diff --git a/apps/bff/src/logger.ts b/apps/bff/src/logger.ts
index bbeb0db..576d6b9 100644
--- a/apps/bff/src/logger.ts
+++ b/apps/bff/src/logger.ts
@@ -17,14 +17,23 @@
 
 import pino, { type LoggerOptions } from 'pino';
 
-const isDev = process.env.NODE_ENV !== 'production';
+// Production unless explicitly opted into dev. Matters because the
+// "production target" includes both `node dist/server.js` (local
+// binary) and the Docker image — both should be quiet by default and
+// emit machine-readable JSON for log aggregators. Only `pnpm dev` /
+// `tsx watch` flips into dev mode (its `dev` script sets
+// NODE_ENV=development).
+const isDev = process.env.NODE_ENV === 'development';
 
 /**
  * Default log level:
- *   - dev: `debug` (verbose, helpful while iterating)
- *   - prod: `error` (quiet by default — Fastify's per-request `info`
- *     access logs are suppressed; only warnings, errors, and fatals
- *     reach stdout)
+ *   - dev (`NODE_ENV=development`, e.g. `pnpm --filter bff dev`):
+ *     `debug` — verbose lifecycle + per-request access logs, pretty-
+ *     printed via `pino-pretty` for human reading.
+ *   - prod (anything else, incl. local `node dist/server.js` and the
+ *     Docker image): `error` — quiet by default. Fastify's per-request
+ *     `info` access logs are suppressed; only warnings, errors, and
+ *     fatals reach stdout as JSON.
  *
  * Operators turn it up explicitly when triaging: `LOG_LEVEL=info` for
  * access logs, `LOG_LEVEL=debug` for the lifecycle chatter, `trace`
diff --git a/docs/setup/container-image.md b/docs/setup/container-image.md
index 2ce6ccd..3d31312 100644
--- a/docs/setup/container-image.md
+++ b/docs/setup/container-image.md
@@ -224,8 +224,8 @@ The BFF uses [pino](https://github.com/pinojs/pino) and writes **structured JSON
 
 | Mode | How to enter | Output |
 |---|---|---|
-| Production | The image sets `NODE_ENV=production`. Local: `NODE_ENV=production node dist/server.js`. | One JSON object per line on stdout. **Default level `error`** — quiet by default; only warnings, errors, and fatals reach stdout. Fields: `level`, `time`, `pid`, `hostname`, plus per-event keys (`reqId`, `req`, `res`, `responseTime`, `msg`, …). |
-| Development | The local binary defaults to dev (`NODE_ENV` unset). | Pretty-printed, colorized, with timestamps via `pino-pretty`. Default level `debug`. Same fields, human-readable. |
+| Production | The image sets `NODE_ENV=production`. Anything that isn't explicitly `NODE_ENV=development` is treated as production — including the local binary `node dist/server.js`. | One JSON object per line on stdout. **Default level `error`** — quiet by default; only warnings, errors, and fatals reach stdout. Fields: `level`, `time`, `pid`, `hostname`, plus per-event keys (`reqId`, `req`, `res`, `responseTime`, `msg`, …). |
+| Development | `pnpm --filter bff dev` (the `dev` script sets `NODE_ENV=development` explicitly). | Pretty-printed, colorized, with timestamps via `pino-pretty`. **Default level `debug`** — full lifecycle chatter + per-request access logs. Human-readable. |
 
 Adjust the floor with `LOG_LEVEL` when triaging:
 


(skywalking-horizon-ui) 01/02: build: real packages/* builds + self-contained dist + copy-in image (no compile in image)

Posted by wu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit 961513b58bc40bab15c3c8b8db10e18bc3549f71
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Mon May 18 21:06:31 2026 +0800

    build: real packages/* builds + self-contained dist + copy-in image (no compile in image)
    
    Fixes the regression where the published image crashed on first import
    with `ERR_UNKNOWN_FILE_EXTENSION: Unknown file extension ".ts"`: the
    internal workspace packages had no build step, their `main` fields
    pointed at src/*.ts, and the BFF's esbuild used `--packages=external`
    so dist/server.js fell straight through to Node's resolver on the
    uncompiled TypeScript source. Closes the path end-to-end and adds a
    single-command artifact target.
    
    workspace packages (api-client / design-tokens / templates)
    - Each gets a `tsconfig.build.json` (NodeNext / declaration / source
      maps / out to dist).
    - `build` script = `tsc -p tsconfig.build.json`. `prepare` script
      fires on `pnpm install` so a fresh dev tree builds packages
      automatically.
    - `main` / `types` / `exports` point at `dist/`. `files: ["dist"]` so
      `pnpm deploy` ships only the compiled output.
    - design-tokens keeps `./tokens.css` exporting from `src/` (CSS isn't
      compiled).
    
    self-contained ./dist/ (the local "binary mode")
    - New `scripts/package.mjs` produces a flat `./dist/` at the repo root:
      server.js + node_modules (with the .pnpm symlink store intact) +
      bundled_templates + static + horizon.example.yaml + package.json.
    - `pnpm package` runs it. Each invocation rm -rfs ./dist first — every
      rebuild is fully clean.
    - The deploy output is `renameSync`d into place to preserve pnpm's
      in-tree relative symlinks (cpSync default would keep them as
      symlinks pointing into the deleted scratch; dereference would
      explode the tree 5-10x).
    - BFF loaders gained a `__dirname/bundled_templates` probe so the flat
      sibling layout (server.js next to bundled_templates) resolves
      without any env-var override. Existing dev + docker probes kept.
    - Verified: `node dist/server.js` boots, serves SPA + API, audits to
      /tmp paths, OAP demo reachable.
    
    docker image (copy-in only — no compile, no pnpm install)
    - Single-stage Dockerfile. Just `COPY dist/* /app/`. The runtime image
      doesn't see Node toolchain, devDeps, pnpm, or any source.
    - `.dockerignore` is allowlist-style: ignore everything, unignore only
      `dist/` and `Dockerfile`. Tiny build context, no accidental source
      leaks if a future Dockerfile change tries to reach back.
    - Image now 178 MB (was significantly larger with the multi-stage
      builder). Built and run-tested locally end-to-end.
    - bundled_templates owned by `horizon` user so admin Layer-Templates +
      Overview-Templates saves don't EACCES (carries forward the earlier
      Issue-3 fix).
    - /data writable volume + HORIZON_*_FILE env-var defaults (carried
      forward from the earlier review-fixes commit).
    
    ci
    - publish-image.yaml installs Node + pnpm on the runner, runs
      `pnpm install && pnpm package`, then `docker buildx` consumes the
      pre-built dist/. Image build itself needs zero network.
    - Switched from multi-arch (linux/amd64+arm64) to single-arch (amd64):
      argon2 ships native bindings, and cross-arch packaging requires a
      per-arch matrix that each builds its own dist/ + platform-tagged
      image. Noted as a follow-up in the workflow comment; arm64 demand
      has not surfaced yet.
    
    logging (binary + image, production-ready)
    - Logger default level: dev `debug`, **prod `error`** (per the
      product-ready directive). Operators bump to `info` for Fastify's
      per-request access logs, `debug` for lifecycle chatter, `trace` for
      full instrumentation. Genuine request errors stay logged at the
      error floor.
    - Logging story documented in docs/setup/container-image.md with a
      three-channel summary (app log / audit log / wire-debug log),
      example pino JSON output, docker logs + jq recipes, and per-aggregator
      ingestion notes (vector / fluent-bit / promtail / filebeat).
    
    side-fixes uncovered while making node dist/server.js boot
    - server.staticDir from horizon.yaml was being ignored — server.ts
      only read HORIZON_STATIC_DIR env var. Now reads env first, falls
      back to the YAML value. Schema and docs already documented this
      knob; the implementation was missing.
    - The route-policy fail-loud check (from the prior commit) tripped on
      Fastify's auto-registered HEAD-for-GET routes. Fix: HEAD inherits
      its GET sibling's policy (same data, same RBAC).
    - The same fail-loud check also 401'd every SPA static route
      (`/`, `/login`, `/assets/*` registered by @fastify/static). Fix:
      non-`/api/*` routes without an explicit policy entry are inherently
      public — the SPA bundle is harmless; the protected APIs it calls
      each have their own enforced policy entry. /api/* routes still
      hard-error on missing policy.
    
    verified: bff type-check + lint + 77/77 tests; ui type-check + lint;
    license-eye 0 invalid; pnpm package green; node dist/server.js boots
    and serves; docker build + docker run boot, default log level quiet,
    LOG_LEVEL=info opens access logs.
---
 .dockerignore                              |  25 ++++++
 .github/workflows/publish-image.yaml       |  25 +++++-
 Dockerfile                                 |  82 +++++++-----------
 apps/bff/package.json                      |   3 +
 apps/bff/src/logger.ts                     |  13 ++-
 apps/bff/src/logic/layers/loader.ts        |   5 ++
 apps/bff/src/logic/overview/loader.ts      |   1 +
 apps/bff/src/rbac/route-policy.ts          |  12 +--
 apps/bff/src/server.ts                     |  19 +++--
 docs/setup/container-image.md              |  56 ++++++++++++-
 package.json                               |   4 +-
 packages/api-client/package.json           |  14 +++-
 packages/api-client/tsconfig.build.json    |  16 ++++
 packages/design-tokens/package.json        |  14 +++-
 packages/design-tokens/tsconfig.build.json |  16 ++++
 packages/templates/package.json            |  13 ++-
 packages/templates/tsconfig.build.json     |  16 ++++
 scripts/package.mjs                        | 130 +++++++++++++++++++++++++++++
 18 files changed, 387 insertions(+), 77 deletions(-)

diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..b37888f
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,25 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Allowlist style: the image is a pure copy-in of `./dist/`, so the
+# Docker context never needs anything else. Excluding everything by
+# default keeps the context tiny, the build deterministic, and
+# eliminates any temptation to reach back into source from a future
+# Dockerfile change.
+
+*
+!dist
+!dist/**
+!Dockerfile
diff --git a/.github/workflows/publish-image.yaml b/.github/workflows/publish-image.yaml
index 043a143..0fe0a9a 100644
--- a/.github/workflows/publish-image.yaml
+++ b/.github/workflows/publish-image.yaml
@@ -58,6 +58,23 @@ jobs:
         with:
           persist-credentials: false
 
+      # The Dockerfile is a pure copy-in of `./dist/` (no compile or
+      # network inside `docker build`). Build the artifact on the
+      # runner first; the image just lays it out under /app.
+      - name: Set up Node
+        uses: actions/setup-node@v4
+        with:
+          node-version: 20
+
+      - name: Activate pnpm
+        run: corepack enable && corepack prepare pnpm@10.33.2 --activate
+
+      - name: Install workspace deps
+        run: pnpm install --frozen-lockfile
+
+      - name: Build self-contained ./dist/
+        run: pnpm package
+
       - name: Set up QEMU
         uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392
 
@@ -103,8 +120,14 @@ jobs:
       - name: Build + push
         run: |
           set -eu
+          # Single-platform (linux/amd64) for now. The `./dist/` is built
+          # on the GitHub-hosted runner (linux/amd64), and `argon2` ships
+          # native bindings — cross-arch packaging would need a per-arch
+          # matrix of build jobs that each produce their own dist/ +
+          # platform-tagged image, then a buildx imagetools merge into a
+          # manifest list. Easy follow-up when arm64 demand is real.
           docker buildx build \
-            --platform linux/amd64,linux/arm64 \
+            --platform linux/amd64 \
             --file Dockerfile \
             --label "org.opencontainers.image.source=https://github.com/${{ github.repository }}" \
             --label "org.opencontainers.image.revision=${{ github.sha }}" \
diff --git a/Dockerfile b/Dockerfile
index 056fd6e..c55f6d0 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -13,68 +13,48 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-# ---- builder ----------------------------------------------------------------
-# Builds the BFF bundle + the Vite SPA in a single workspace install. Final
-# image only carries the BFF dist + UI dist + the production dependency tree
-# (no source, no devDependencies, no pnpm store).
-FROM node:20-alpine AS builder
-WORKDIR /workspace
-
-# argon2 (password hashing) builds a native module; alpine needs python + a
-# C toolchain at build time. The deps are dropped from the runtime stage.
-RUN apk add --no-cache python3 make g++ libc6-compat
-# Activate the pnpm version pinned in the repo's root `package.json`.
-# We pin it here too so `corepack prepare` doesn't need package.json
-# on disk yet (it runs before the COPY of workspace manifests).
-RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
-
-COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
-COPY apps/bff/package.json apps/bff/
-COPY apps/ui/package.json apps/ui/
-COPY packages/api-client/package.json packages/api-client/
-COPY packages/design-tokens/package.json packages/design-tokens/
-COPY packages/templates/package.json packages/templates/
-RUN pnpm install --frozen-lockfile
-
-COPY . .
-RUN pnpm --filter @skywalking-horizon-ui/bff build \
- && pnpm --filter @skywalking-horizon-ui/ui  build
-
-# Deploy a focused production install for the BFF — pnpm copies only the
-# packages it actually needs (workspace deps + runtime npm deps). The
-# `--legacy` flag is mandatory under pnpm 10+ for non-injected
-# workspaces (the alternative is enabling `inject-workspace-packages`
-# everywhere, which we don't need for plain workspace deps).
-RUN pnpm deploy --legacy --filter @skywalking-horizon-ui/bff --prod /deploy/bff
+# Copy-in image. The image does NOT compile anything and does NOT run
+# `pnpm install` — it consumes the pre-built `./dist/` produced by
+# `pnpm package` at the repo root and lays it out under `/app/`. Build
+# the artifact first:
+#
+#     pnpm install            # one-time / on lockfile changes
+#     pnpm package            # produces ./dist/ (server.js + node_modules
+#                             # + bundled_templates + static + example yaml)
+#     docker build -t horizon-ui:local .
+#
+# Net effect: tiny image (no Node toolchain, no devDeps, no pnpm store,
+# no source), reproducible (the dist/ tarball is the contract), and
+# air-gap-friendly (image build needs zero network).
 
-# ---- runtime ---------------------------------------------------------------
 FROM node:20-alpine
 WORKDIR /app
 
 # Run as a non-root user — the BFF doesn't need any privileged access.
 RUN addgroup -S horizon && adduser -S -G horizon horizon
 
-# Read-only artifacts (code, deps, static assets, example config) — owned
-# by root, world-readable. The BFF never writes here.
-COPY --from=builder /deploy/bff/dist ./dist
-COPY --from=builder /deploy/bff/node_modules ./node_modules
-COPY --from=builder /deploy/bff/package.json ./package.json
-COPY --from=builder /workspace/apps/ui/dist ./static
-COPY --from=builder /workspace/horizon.example.yaml ./horizon.example.yaml
+# Pre-built artifact. Layout matches what `node server.js` expects:
+# server.js + bundled_templates + node_modules + static all siblings
+# under /app/. The bundled-template loader probes `__dirname/bundled_
+# templates` first (see apps/bff/src/logic/layers/loader.ts).
+#
+# Read-only artifacts owned by root:
+COPY dist/server.js              ./server.js
+COPY dist/package.json           ./package.json
+COPY dist/node_modules           ./node_modules
+COPY dist/static                 ./static
+COPY dist/horizon.example.yaml   ./horizon.example.yaml
 
 # `bundled_templates/` is writable: the admin Layer-Templates and
-# Overview-Templates editors `writeFileSync` into the per-key/per-id
-# JSON files here. Must be owned by the `horizon` user, otherwise admin
-# saves EACCES. The loader still resolves the directory via
-# `__dirname/../bundled_templates`, so the path layout stays in sync
-# with the source tree.
-COPY --from=builder --chown=horizon:horizon /workspace/apps/bff/src/bundled_templates ./bundled_templates
+# Overview-Templates editors `writeFileSync` into per-key / per-id JSON
+# files. Owned by the `horizon` user so saves don't EACCES.
+COPY --chown=horizon:horizon dist/bundled_templates  ./bundled_templates
 
 # `/data` is the writable state directory the BFF writes its runtime
 # files into (audit log, setup state, alarm state, wire debug log).
-# Operators can mount a PVC / named volume / host bind at /data and
-# the configured paths below land on durable storage. Without this
-# mount the writes go to the container's writable layer (ephemeral).
+# Operators mount a PVC / named volume / host bind here for durable
+# storage. Without a mount the writes go to the container's writable
+# layer (ephemeral).
 RUN mkdir -p /data && chown horizon:horizon /data
 VOLUME ["/data"]
 
@@ -88,4 +68,4 @@ ENV NODE_ENV=production \
 
 USER horizon
 EXPOSE 8081
-CMD ["node", "dist/server.js"]
+CMD ["node", "server.js"]
diff --git a/apps/bff/package.json b/apps/bff/package.json
index 82e66bc..9e50ba6 100644
--- a/apps/bff/package.json
+++ b/apps/bff/package.json
@@ -4,6 +4,9 @@
   "private": true,
   "type": "module",
   "main": "dist/server.js",
+  "files": [
+    "dist"
+  ],
   "scripts": {
     "dev": "tsx watch src/server.ts",
     "build": "esbuild src/server.ts --bundle --platform=node --format=esm --target=node20 --outfile=dist/server.js --packages=external",
diff --git a/apps/bff/src/logger.ts b/apps/bff/src/logger.ts
index 335beed..bbeb0db 100644
--- a/apps/bff/src/logger.ts
+++ b/apps/bff/src/logger.ts
@@ -19,8 +19,19 @@ import pino, { type LoggerOptions } from 'pino';
 
 const isDev = process.env.NODE_ENV !== 'production';
 
+/**
+ * Default log level:
+ *   - dev: `debug` (verbose, helpful while iterating)
+ *   - prod: `error` (quiet by default — Fastify's per-request `info`
+ *     access logs are suppressed; only warnings, errors, and fatals
+ *     reach stdout)
+ *
+ * Operators turn it up explicitly when triaging: `LOG_LEVEL=info` for
+ * access logs, `LOG_LEVEL=debug` for the lifecycle chatter, `trace`
+ * for everything pino-instrumented code emits.
+ */
 export const loggerOptions: LoggerOptions = {
-  level: process.env.LOG_LEVEL ?? (isDev ? 'debug' : 'info'),
+  level: process.env.LOG_LEVEL ?? (isDev ? 'debug' : 'error'),
   ...(isDev
     ? {
         transport: {
diff --git a/apps/bff/src/logic/layers/loader.ts b/apps/bff/src/logic/layers/loader.ts
index 55641a3..c7b7770 100644
--- a/apps/bff/src/logic/layers/loader.ts
+++ b/apps/bff/src/logic/layers/loader.ts
@@ -320,7 +320,12 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
  *   arbitrary working dir with the templates beside them. */
 function locateConfigDir(): string {
   const candidates = [
+    // Self-contained flat dist (`pnpm package` output: server.js +
+    // bundled_templates as siblings inside ./dist/).
+    join(__dirname, 'bundled_templates', 'layers'),
+    // Dev (tsx watch: apps/bff/src/logic/layers/loader.ts).
     join(__dirname, '..', '..', 'bundled_templates', 'layers'),
+    // Docker image (/app/dist/server.js → /app/bundled_templates).
     join(__dirname, '..', 'bundled_templates', 'layers'),
     join(process.cwd(), 'bundled_templates', 'layers'),
   ];
diff --git a/apps/bff/src/logic/overview/loader.ts b/apps/bff/src/logic/overview/loader.ts
index 2ba0cd8..faf984d 100644
--- a/apps/bff/src/logic/overview/loader.ts
+++ b/apps/bff/src/logic/overview/loader.ts
@@ -39,6 +39,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
  *  — see that file for the rationale. */
 function locateConfigDir(): string {
   const candidates = [
+    path.join(__dirname, 'bundled_templates', 'overviews'),
     path.join(__dirname, '..', '..', 'bundled_templates', 'overviews'),
     path.join(__dirname, '..', 'bundled_templates', 'overviews'),
     path.join(process.cwd(), 'bundled_templates', 'overviews'),
diff --git a/apps/bff/src/rbac/route-policy.ts b/apps/bff/src/rbac/route-policy.ts
index 685e473..1a859a2 100644
--- a/apps/bff/src/rbac/route-policy.ts
+++ b/apps/bff/src/rbac/route-policy.ts
@@ -243,11 +243,13 @@ export function makeRouteAuthHook(deps: AuthDeps) {
         logger.error({ method: methods, url: route.url }, msg);
         throw new Error(msg);
       }
-      logger.warn(
-        { method: methods, url: route.url },
-        'rbac: non-api route has no policy entry; defaulting to auth-only',
-      );
-      chosen = 'auth';
+      // Everything else (SPA static — `/`, `/login`, `/assets/*`, etc.,
+      // registered by @fastify/static) is inherently public: the served
+      // bundle is harmless, and the protected APIs it calls all have
+      // their own ROUTE_POLICY entries enforced above. Gating these
+      // routes on 'auth' breaks the login page (the browser can't load
+      // /login before the user has a session).
+      return;
     }
     if (chosen === 'public') return;
 
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index 915f21d..4101f0b 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -181,13 +181,18 @@ registerOverviewTemplatesAdminRoutes(app, { config: source, sessions });
 registerAuthStatusRoutes(app, { config: source, ldapHealth, sessions });
 registerAdminUsersRoute(app, { config: source, seenCache });
 
-// Serve the built SPA out of the BFF when HORIZON_STATIC_DIR points at a
-// directory (Docker image layout: /app/static contains the Vite dist).
-// Local dev keeps using the Vite dev-server on :9091 so this is a no-op
-// when the env var is absent.
-const staticDir = process.env.HORIZON_STATIC_DIR
-  ? resolvePath(process.env.HORIZON_STATIC_DIR)
-  : null;
+// Serve the built SPA out of the BFF when a static dir is configured.
+// Two paths to set it:
+//   - HORIZON_STATIC_DIR env var (Docker image layout: /app/static).
+//   - server.staticDir in horizon.yaml (local dev / operator-managed).
+// The env var wins when both are set so the image's default isn't
+// silently shadowed by a stale YAML value. Vite dev-server on :9091 is
+// still the right path during UI development; this branch is for the
+// "serve the built SPA from the BFF" workflow.
+const staticDir = (() => {
+  const raw = process.env.HORIZON_STATIC_DIR ?? source.current.server.staticDir;
+  return raw ? resolvePath(raw) : null;
+})();
 if (staticDir && existsSync(staticDir)) {
   await app.register(fastifyStatic, { root: staticDir, prefix: '/', wildcard: false });
   // SPA fallback — anything that isn't an `/api/*` request and didn't match
diff --git a/docs/setup/container-image.md b/docs/setup/container-image.md
index 053fea9..2ce6ccd 100644
--- a/docs/setup/container-image.md
+++ b/docs/setup/container-image.md
@@ -39,7 +39,8 @@ The runtime stage runs as the non-root user `horizon`. Two locations are owned b
 
 | Variable | Default in image | Purpose |
 |---|---|---|
-| `NODE_ENV` | `production` | Sets Node into production mode. |
+| `NODE_ENV` | `production` | Drives the logger format (JSON vs pretty) and Node optimizations. |
+| `LOG_LEVEL` | (unset → `error` in production, `debug` in dev) | Pino log level: `trace`, `debug`, `info`, `warn`, `error`, `fatal`. |
 | `HORIZON_CONFIG` | `/app/horizon.yaml` | Where the BFF looks for `horizon.yaml`. Override to mount elsewhere. |
 | `HORIZON_STATIC_DIR` | `/app/static` | Where the BFF serves UI assets from. |
 | `HORIZON_AUDIT_FILE` | `/data/horizon-audit.jsonl` | Default for `audit.file` when `horizon.yaml` doesn't override it. |
@@ -217,6 +218,59 @@ To persist admin-edited templates across container restarts / image updates:
 
 The mounted directory must be writable by the `horizon` user (UID/GID inside the container — check with `docker run --rm <image> id horizon`). Without persistence, admin edits behave as ephemeral overrides — useful for try-it-out, destructive for production.
 
+## Logging
+
+The BFF uses [pino](https://github.com/pinojs/pino) and writes **structured JSON** to **stdout** in production — visible via `docker logs <container>` and ready for any log aggregator (Fluent Bit, Vector, Promtail, Filebeat, Datadog) without extra parsers.
+
+| Mode | How to enter | Output |
+|---|---|---|
+| Production | The image sets `NODE_ENV=production`. Local: `NODE_ENV=production node dist/server.js`. | One JSON object per line on stdout. **Default level `error`** — quiet by default; only warnings, errors, and fatals reach stdout. Fields: `level`, `time`, `pid`, `hostname`, plus per-event keys (`reqId`, `req`, `res`, `responseTime`, `msg`, …). |
+| Development | The local binary defaults to dev (`NODE_ENV` unset). | Pretty-printed, colorized, with timestamps via `pino-pretty`. Default level `debug`. Same fields, human-readable. |
+
+Adjust the floor with `LOG_LEVEL` when triaging:
+
+```sh
+docker run -e LOG_LEVEL=info ...    # add per-request access logs + lifecycle
+docker run -e LOG_LEVEL=debug ...   # add the loader / capability-probe chatter
+docker run -e LOG_LEVEL=trace ...   # every pino-instrumented site
+docker run -e LOG_LEVEL=warn ...    # even quieter than the default
+NODE_ENV=production LOG_LEVEL=info node dist/server.js
+```
+
+### Per-request logging
+
+Fastify's request logger is on by default and emits one `incoming request` line + one `request completed` line per HTTP request, both tagged with a stable `reqId`. These are level-`info` (30) events — **suppressed under the production default `error`**. Bump to `LOG_LEVEL=info` to surface them; example pair under that level:
+
+```json
+{"level":30,"time":1779109372598,"pid":1,"hostname":"...","reqId":"req-1","req":{"method":"GET","url":"/api/auth/health","host":"127.0.0.1:8081","remoteAddress":"192.168.65.1","remotePort":60655},"msg":"incoming request"}
+{"level":30,"time":1779109372614,"pid":1,"hostname":"...","reqId":"req-1","res":{"statusCode":200},"responseTime":14.93,"msg":"request completed"}
+```
+
+Genuine request errors (5xx, request-handler exceptions) are still logged at `error` (50) — they reach stdout under any default that includes `error`.
+
+This is separate from the **audit log** (which records sensitive operations — login, rule edits, break-glass — to a JSONL file at `audit.file`; see [Access Control → Audit Log](../access-control/audit-log.md)) and the **wire-debug log** (which records OAP HTTP request/response payloads when `debugLog.enabled: true`; see [Setup → debugLog](debug-log.md)). Three orthogonal channels:
+
+| Channel | Where | What | Toggle |
+|---|---|---|---|
+| App logs | stdout (JSON in prod, pretty in dev) | Lifecycle + per-request | Always on. `LOG_LEVEL` adjusts. |
+| Audit log | `audit.file` (JSONL) | Logins, RBAC-gated mutations | Always on. Path = `audit.file`. |
+| Wire-debug | `debugLog.file` (JSONL) | Outbound OAP requests/responses | Off by default. `debugLog.enabled: true` opt-in. |
+
+### Aggregating from Docker
+
+```sh
+# Quick tail with severity color (jq):
+docker logs -f horizon-test | jq -c '. | "\(.time|todate) [\(.level)] \(.msg)"' -r
+
+# Just request failures:
+docker logs -f horizon-test | jq -c 'select(.res.statusCode >= 400)'
+
+# Just structured slowness:
+docker logs -f horizon-test | jq -c 'select(.responseTime != null and .responseTime > 200)'
+```
+
+For Kubernetes, the standard pipelines (fluent-bit `tail` plugin with `Parser json`, or vector `kubernetes_logs` → `parse_json`) ingest these lines directly. No app-side configuration required.
+
 ## Network
 
 - Container exposes **8081** by default. If you change `server.port`, publish the new port and update the readiness probe.
diff --git a/package.json b/package.json
index 5c1d29f..18f9a8e 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,9 @@
     "lint": "pnpm -r run lint",
     "test:unit": "pnpm -r run test:unit",
     "license:check": "license-eye -c .licenserc.yaml header check",
-    "license:fix": "license-eye -c .licenserc.yaml header fix"
+    "license:fix": "license-eye -c .licenserc.yaml header fix",
+    "package": "node scripts/package.mjs",
+    "start": "HORIZON_CONFIG=${HORIZON_CONFIG:-./horizon.yaml} node dist/server.js"
   },
   "devDependencies": {
     "typescript": "~5.6.3"
diff --git a/packages/api-client/package.json b/packages/api-client/package.json
index ab0caf3..9560fdc 100644
--- a/packages/api-client/package.json
+++ b/packages/api-client/package.json
@@ -3,15 +3,21 @@
   "version": "0.1.0",
   "private": true,
   "type": "module",
-  "main": "src/index.ts",
-  "types": "src/index.ts",
+  "main": "./dist/index.js",
+  "types": "./dist/index.d.ts",
   "exports": {
     ".": {
-      "types": "./src/index.ts",
-      "import": "./src/index.ts"
+      "types": "./dist/index.d.ts",
+      "import": "./dist/index.js"
     }
   },
+  "files": [
+    "dist"
+  ],
   "scripts": {
+    "build": "tsc -p tsconfig.build.json",
+    "clean": "rm -rf dist",
+    "prepare": "tsc -p tsconfig.build.json",
     "type-check": "tsc --noEmit"
   },
   "devDependencies": {
diff --git a/packages/api-client/tsconfig.build.json b/packages/api-client/tsconfig.build.json
new file mode 100644
index 0000000..e29ab80
--- /dev/null
+++ b/packages/api-client/tsconfig.build.json
@@ -0,0 +1,16 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "baseUrl": ".",
+    "rootDir": "src",
+    "outDir": "dist",
+    "noEmit": false,
+    "declaration": true,
+    "declarationMap": true,
+    "sourceMap": true,
+    "module": "NodeNext",
+    "moduleResolution": "NodeNext"
+  },
+  "include": ["src/**/*"],
+  "exclude": ["dist", "node_modules"]
+}
diff --git a/packages/design-tokens/package.json b/packages/design-tokens/package.json
index c765df8..d898d1b 100644
--- a/packages/design-tokens/package.json
+++ b/packages/design-tokens/package.json
@@ -3,15 +3,23 @@
   "version": "0.1.0",
   "private": true,
   "type": "module",
-  "main": "src/index.ts",
+  "main": "./dist/index.js",
+  "types": "./dist/index.d.ts",
   "exports": {
     ".": {
-      "types": "./src/index.ts",
-      "import": "./src/index.ts"
+      "types": "./dist/index.d.ts",
+      "import": "./dist/index.js"
     },
     "./tokens.css": "./src/tokens.css"
   },
+  "files": [
+    "dist",
+    "src/tokens.css"
+  ],
   "scripts": {
+    "build": "tsc -p tsconfig.build.json",
+    "clean": "rm -rf dist",
+    "prepare": "tsc -p tsconfig.build.json",
     "type-check": "tsc --noEmit"
   },
   "devDependencies": {
diff --git a/packages/design-tokens/tsconfig.build.json b/packages/design-tokens/tsconfig.build.json
new file mode 100644
index 0000000..dcd3866
--- /dev/null
+++ b/packages/design-tokens/tsconfig.build.json
@@ -0,0 +1,16 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "baseUrl": ".",
+    "rootDir": "src",
+    "outDir": "dist",
+    "noEmit": false,
+    "declaration": true,
+    "declarationMap": true,
+    "sourceMap": true,
+    "module": "NodeNext",
+    "moduleResolution": "NodeNext"
+  },
+  "include": ["src/**/*.ts"],
+  "exclude": ["dist", "node_modules"]
+}
diff --git a/packages/templates/package.json b/packages/templates/package.json
index a93630c..57f96dc 100644
--- a/packages/templates/package.json
+++ b/packages/templates/package.json
@@ -3,14 +3,21 @@
   "version": "0.1.0",
   "private": true,
   "type": "module",
-  "main": "src/index.ts",
+  "main": "./dist/index.js",
+  "types": "./dist/index.d.ts",
   "exports": {
     ".": {
-      "types": "./src/index.ts",
-      "import": "./src/index.ts"
+      "types": "./dist/index.d.ts",
+      "import": "./dist/index.js"
     }
   },
+  "files": [
+    "dist"
+  ],
   "scripts": {
+    "build": "tsc -p tsconfig.build.json",
+    "clean": "rm -rf dist",
+    "prepare": "tsc -p tsconfig.build.json",
     "type-check": "tsc --noEmit"
   },
   "devDependencies": {
diff --git a/packages/templates/tsconfig.build.json b/packages/templates/tsconfig.build.json
new file mode 100644
index 0000000..e29ab80
--- /dev/null
+++ b/packages/templates/tsconfig.build.json
@@ -0,0 +1,16 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "baseUrl": ".",
+    "rootDir": "src",
+    "outDir": "dist",
+    "noEmit": false,
+    "declaration": true,
+    "declarationMap": true,
+    "sourceMap": true,
+    "module": "NodeNext",
+    "moduleResolution": "NodeNext"
+  },
+  "include": ["src/**/*"],
+  "exclude": ["dist", "node_modules"]
+}
diff --git a/scripts/package.mjs b/scripts/package.mjs
new file mode 100644
index 0000000..8ec251b
--- /dev/null
+++ b/scripts/package.mjs
@@ -0,0 +1,130 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Produce a self-contained, network-free `./dist/` artifact at the repo
+ * root. After this script finishes, the operator can:
+ *
+ *     HORIZON_CONFIG=./horizon.yaml node dist/server.js
+ *
+ * — without `pnpm install`, without network access, without any other
+ * checkout state. The folder contains everything the BFF needs:
+ *
+ *     dist/
+ *       server.js              — esbuild-bundled BFF (single ESM file)
+ *       package.json           — Node ESM marker + module type
+ *       node_modules/          — production deps (npm + workspace dists)
+ *       bundled_templates/     — layer + overview JSON templates
+ *       static/                — built UI (Vite dist)
+ *       horizon.example.yaml   — copy-and-edit starting point
+ *
+ * The build:
+ *   1. Builds each workspace package under `packages/*` (tsc → dist/).
+ *   2. Builds the BFF (esbuild → apps/bff/dist/server.js).
+ *   3. Builds the UI (vite → apps/ui/dist/).
+ *   4. Runs `pnpm deploy --legacy --prod` to materialize a production
+ *      install tree under `./_deploy_tmp/` (with workspace deps already
+ *      resolved as the `dist/` we built in step 1).
+ *   5. Re-layouts the deploy output into the flat `./dist/` shape above
+ *      and cleans up `_deploy_tmp`.
+ *
+ * The BFF's `locateConfigDir()` probes (in the layers + overview
+ * loaders) include `__dirname/bundled_templates`, so the flat sibling
+ * layout resolves without any env-var override.
+ */
+
+import { execSync } from 'node:child_process';
+import { cpSync, existsSync, mkdirSync, renameSync, rmSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const root = resolve(__dirname, '..');
+const dist = resolve(root, 'dist');
+const tmp = resolve(root, '_deploy_tmp');
+
+function step(name) {
+  process.stdout.write(`\n▸ ${name}\n`);
+}
+function run(cmd) {
+  execSync(cmd, { cwd: root, stdio: 'inherit' });
+}
+
+step('Cleaning ./dist and ./_deploy_tmp');
+rmSync(dist, { recursive: true, force: true });
+rmSync(tmp, { recursive: true, force: true });
+
+step('Building workspace packages (api-client / design-tokens / templates)');
+run("pnpm -r --filter './packages/*' run build");
+
+step('Building BFF (esbuild bundle)');
+run('pnpm --filter @skywalking-horizon-ui/bff build');
+
+step('Building UI (vite production build)');
+run('pnpm --filter @skywalking-horizon-ui/ui build');
+
+step('Materializing production install tree (pnpm deploy)');
+// `--legacy` is required under pnpm 10+ for non-injected workspaces; see
+// the matching note in the Dockerfile. We deploy directly into ./dist
+// (renaming through an intermediate) rather than copying out, because
+// the produced node_modules contains pnpm-style symlinks into an
+// in-tree `.pnpm/` store — copying would either preserve broken
+// symlinks (cpSync default) or explode the size 5-10x (dereference).
+run(`pnpm deploy --legacy --filter @skywalking-horizon-ui/bff --prod ${tmp}`);
+
+step('Re-layouting into flat ./dist/');
+// Move the deploy output wholesale to ./dist. Internal `.pnpm/`
+// symlinks stay valid because they're all relative within the tree.
+renameSync(tmp, dist);
+// pnpm deploy preserved the BFF's original layout, so server.js is at
+// dist/dist/server.js. Lift it up one level and drop the empty wrapper.
+renameSync(resolve(dist, 'dist', 'server.js'), resolve(dist, 'server.js'));
+rmSync(resolve(dist, 'dist'), { recursive: true, force: true });
+// Copy the operator-facing assets in: writable bundled_templates (per-
+// key admin saves write here), built UI, copy-and-edit yaml.
+cpSync(
+  resolve(root, 'apps/bff/src/bundled_templates'),
+  resolve(dist, 'bundled_templates'),
+  { recursive: true },
+);
+cpSync(resolve(root, 'apps/ui/dist'), resolve(dist, 'static'), { recursive: true });
+cpSync(resolve(root, 'horizon.example.yaml'), resolve(dist, 'horizon.example.yaml'));
+
+step('Done');
+console.log(`
+Target binary: ${resolve(dist, 'server.js')}
+
+Boot it:
+
+    cp horizon.example.yaml horizon.yaml         # configure once
+    HORIZON_CONFIG=./horizon.yaml \\
+      HORIZON_STATIC_DIR=./dist/static \\
+      node dist/server.js
+
+Or from inside dist/ (cwd-relative fallback resolves bundled_templates):
+
+    cd dist
+    HORIZON_CONFIG=../horizon.yaml HORIZON_STATIC_DIR=./static node server.js
+
+The folder is fully self-contained — copy it anywhere, no \`pnpm install\`
+required, no network access at boot.
+`);
+
+if (!existsSync(resolve(dist, 'server.js'))) {
+  console.error('FATAL: dist/server.js missing after package');
+  process.exit(1);
+}