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 <[email protected]>
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 [email protected] --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 [email protected] --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);
+}


Reply via email to