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 12917066769d545eca2c15b9a80df7b1d48b13fe
Author: Wu Sheng <[email protected]>
AuthorDate: Mon May 18 16:28:24 2026 +0800

    review fixes: rbac coverage, layer-template schema drift, container 
writability, lint runnability, polling + limits
    
    rbac route-policy
    - Add three missing entries: POST /api/ebpf/network/tasks
      (profile:enable), GET /api/ebpf/network/topology (profile:read), and
      POST /api/layer/:key/logs/facets (logs:read). Without them, any
      authenticated user could create a network-profile task / hit the log
      facet aggregator without the intended verb.
    - Replace the stale POST /api/ebpf/network/topology entry with the
      GET form actually registered at ebpf.ts:367.
    - Upgrade the missing-policy fallback from `warn + auth-only` to `throw
      at route-registration time` for any /api/* URL. The previous
      fail-safe was silently letting forgotten endpoints become reachable
      by any logged-in viewer — exactly how the three above slipped in. CI
      and first boot now refuse to start when a new /api/* route lands
      without a ROUTE_POLICY entry.
    
    layer-template admin save schema
    - The Zod schema at http/config/layer-template.ts was silently
      stripping six top-level fields the loader's LayerTemplate interface
      knows about (visibility, group, top-level topology /
      endpointDependency / traces / log) — admin saves were destructively
      rewriting layer JSONs. Also added the canonical `header` field
      alongside the legacy `metrics` alias.
    - The `.strict()` on `components` was hard-rejecting `networkProfiling`
      and `pprofProfiling` (both used in general.json) — saving GENERAL
      via the admin UI 400'd.
    - Switch the outer template and `components` to `.passthrough()` so a
      new loader-side field doesn't silently strip on save going forward.
      Inner objects that are well-defined (header, naming, slots) stay
      strict-ish via explicit enumeration + passthrough on the leaf.
    
    docker writability
    - COPY --chown=horizon:horizon for bundled_templates/ so the admin
      Layer-Templates and Overview-Templates editors can write per-key
      JSONs without EACCES. Previously root-owned, USER horizon couldn't
      save.
    - Declare /data as VOLUME, chown to horizon, point HORIZON_AUDIT_FILE
      / HORIZON_SETUP_FILE / HORIZON_ALARMS_FILE / HORIZON_WIRE_LOG_FILE
      env vars at /data/*. config/schema.ts seeds the four state-file
      defaults from those env vars so an operator who runs the published
      image with a minimal horizon.yaml gets writes routed to a durable
      mount without any path overrides. An explicit value in horizon.yaml
      always wins.
    
    fs.watch lifecycle
    - Move the bundled-template fs.watch out of module-import scope into
      startLayerTemplateWatcher(), called once from server.ts. Skipped
      under NODE_ENV=test so vitest workers don't each spawn an fd and
      EMFILE on low-ulimit CI. stopLayerTemplateWatcher() exposed for
      graceful shutdown.
    
    lint runnability
    - BFF had a lint script but no eslint dep at all — the script could
      never run. Add eslint, @eslint/js, typescript-eslint as devDeps;
      introduce flat eslint.config.mjs.
    - UI had eslint 9 installed but no eslint.config.* — ESLint 9 dropped
      legacy autodiscovery, so `eslint .` errored out. Add the flat
      config.
    - Split `lint` (read-only check, suitable for CI gating) from
      `lint:fix` (mutating) on both packages. Previous scripts ran --fix
      by default — silent rewrite-on-check.
    - Fix the two pre-existing lint findings the new gate caught
      (prefer-const in dashboard.ts + serviceName.ts; useless-escape in
      overview/loader.ts).
    - Scope-disable vue/no-parsing-error for LayerServiceMapView — the
      literal NUL-byte cluster-key sentinel is intentional and lives in a
      <script> string, never in the rendered DOM.
    
    polling + limits
    - Drop the duplicate `useAutoRefreshSubscribe(() => q.refetch())` from
      useOapInfo and useAdminFeatures. Both already set `refetchInterval`,
      and the global ticker subscription was firing on a second
      unsynchronized clock — these are infrastructure health probes, the
      topbar's manual-refresh button shouldn't re-run them.
    - Replace the hardcoded `limit: 10000` in the async + pprof task list
      endpoints with clampTaskListLimit(): default 500, max 5000, accepts
      ?limit= override. Large fleets with years of recorded tasks no
      longer page-fault the BFF on initial load.
    
    verified: bff type-check + 73/73 tests + lint clean; ui type-check +
    69/69 tests + lint clean; license-eye 0 invalid.
---
 Dockerfile                                 |  29 +++-
 apps/bff/eslint.config.mjs                 |  41 +++++
 apps/bff/package.json                      |   8 +-
 apps/bff/src/config/schema.ts              |  26 +++-
 apps/bff/src/http/config/layer-template.ts | 232 +++++++++++++++++++----------
 apps/bff/src/http/query/async-profile.ts   |  23 ++-
 apps/bff/src/http/query/dashboard.ts       |   2 +-
 apps/bff/src/logic/layers/loader.ts        |  47 ++++--
 apps/bff/src/logic/overview/loader.ts      |   2 +-
 apps/bff/src/rbac/route-policy.ts          |  18 ++-
 apps/bff/src/server.ts                     |   5 +
 apps/ui/eslint.config.mjs                  | Bin 0 -> 2037 bytes
 apps/ui/package.json                       |   3 +-
 apps/ui/src/shell/useAdminFeatures.ts      |   3 -
 apps/ui/src/shell/useOapInfo.ts            |   4 -
 apps/ui/src/utils/serviceName.ts           |   2 +-
 pnpm-lock.yaml                             |  15 ++
 17 files changed, 335 insertions(+), 125 deletions(-)

diff --git a/Dockerfile b/Dockerfile
index 538f7af..056fd6e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -54,20 +54,37 @@ 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
-# The bundled layer + overview JSONs must sit one level up from the
-# compiled server (`/app/dist/server.js`). The loader resolves them via
-# `path.join(__dirname, '..', 'bundled_templates', ...)` — keeping this
-# layout in sync with the source tree means no path remapping at boot.
-COPY --from=builder /workspace/apps/bff/src/bundled_templates 
./bundled_templates
 COPY --from=builder /workspace/apps/ui/dist ./static
 COPY --from=builder /workspace/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
+
+# `/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).
+RUN mkdir -p /data && chown horizon:horizon /data
+VOLUME ["/data"]
+
 ENV NODE_ENV=production \
     HORIZON_STATIC_DIR=/app/static \
-    HORIZON_CONFIG=/app/horizon.yaml
+    HORIZON_CONFIG=/app/horizon.yaml \
+    HORIZON_AUDIT_FILE=/data/horizon-audit.jsonl \
+    HORIZON_SETUP_FILE=/data/horizon-setup.json \
+    HORIZON_ALARMS_FILE=/data/horizon-alarms.json \
+    HORIZON_WIRE_LOG_FILE=/data/horizon-wire.jsonl
 
 USER horizon
 EXPOSE 8081
diff --git a/apps/bff/eslint.config.mjs b/apps/bff/eslint.config.mjs
new file mode 100644
index 0000000..42a8779
--- /dev/null
+++ b/apps/bff/eslint.config.mjs
@@ -0,0 +1,41 @@
+/*
+ * 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.
+ */
+
+// Flat config for ESLint 9. BFF-only: pure TypeScript, no Vue/JSX.
+import js from '@eslint/js';
+import tseslint from 'typescript-eslint';
+
+export default tseslint.config(
+  {
+    ignores: ['dist/**', 'node_modules/**', 'coverage/**', '*.cjs'],
+  },
+  js.configs.recommended,
+  ...tseslint.configs.recommended,
+  {
+    rules: {
+      // The codebase is `strict: true` already (tsc enforces); a few
+      // ergonomics rules off so the lint job stays advisory rather than
+      // mass-rewriting code on first run.
+      '@typescript-eslint/no-explicit-any': 'warn',
+      '@typescript-eslint/no-unused-vars': [
+        'warn',
+        { argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
+      ],
+      '@typescript-eslint/no-empty-object-type': 'off',
+    },
+  },
+);
diff --git a/apps/bff/package.json b/apps/bff/package.json
index ef89f2b..82e66bc 100644
--- a/apps/bff/package.json
+++ b/apps/bff/package.json
@@ -9,7 +9,8 @@
     "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",
-    "lint": "eslint . --ext .ts,.cjs,.mjs --fix --ignore-path 
../../.gitignore",
+    "lint": "eslint .",
+    "lint:fix": "eslint . --fix",
     "test:unit": "vitest run --root src/",
     "test:unit:watch": "vitest --root src/"
   },
@@ -27,10 +28,15 @@
     "zod": "^3.23.8"
   },
   "devDependencies": {
+    "@eslint/js": "^9.14.0",
     "@types/node": "^22.9.0",
+    "@typescript-eslint/eslint-plugin": "^8.16.0",
+    "@typescript-eslint/parser": "^8.16.0",
     "esbuild": "^0.24.0",
+    "eslint": "^9.14.0",
     "tsx": "^4.19.2",
     "typescript": "~5.6.3",
+    "typescript-eslint": "^8.16.0",
     "vitest": "^2.1.4"
   }
 }
diff --git a/apps/bff/src/config/schema.ts b/apps/bff/src/config/schema.ts
index f18e4e2..ecb3aed 100644
--- a/apps/bff/src/config/schema.ts
+++ b/apps/bff/src/config/schema.ts
@@ -234,38 +234,48 @@ const sessionSchema = z
   .strict()
   .default({ ttlMinutes: 60, cookieName: 'horizon_sid', cookieSecure: false });
 
+// Env-var-overridable defaults for the four state-file paths. The
+// Docker image sets `HORIZON_*_FILE=/data/...` so an operator running
+// the published image without a custom `horizon.yaml` (or with one
+// that omits these blocks) gets writes routed to the writable `/data`
+// volume instead of `/app` (which is root-owned and EACCESes).
+const auditDefault = process.env.HORIZON_AUDIT_FILE ?? './horizon-audit.jsonl';
+const setupDefault = process.env.HORIZON_SETUP_FILE ?? './horizon-setup.json';
+const alarmsDefault = process.env.HORIZON_ALARMS_FILE ?? 
'./horizon-alarms.json';
+const wireLogDefault = process.env.HORIZON_WIRE_LOG_FILE ?? 
'./horizon-wire.jsonl';
+
 const auditSchema = z
   .object({
-    file: z.string().default('./horizon-audit.jsonl'),
+    file: z.string().default(auditDefault),
   })
   .strict()
-  .default({ file: './horizon-audit.jsonl' });
+  .default({ file: auditDefault });
 
 const setupSchema = z
   .object({
-    file: z.string().default('./horizon-setup.json'),
+    file: z.string().default(setupDefault),
   })
   .strict()
-  .default({ file: './horizon-setup.json' });
+  .default({ file: setupDefault });
 
 const alarmsSchema = z
   .object({
-    file: z.string().default('./horizon-alarms.json'),
+    file: z.string().default(alarmsDefault),
   })
   .strict()
-  .default({ file: './horizon-alarms.json' });
+  .default({ file: alarmsDefault });
 
 const debugLogSchema = z
   .object({
     enabled: z.boolean().default(false),
-    file: z.string().default('./horizon-wire.jsonl'),
+    file: z.string().default(wireLogDefault),
     maxBodyChars: z.number().int().nonnegative().default(8192),
     redactAuthHeaders: z.boolean().default(true),
   })
   .strict()
   .default({
     enabled: false,
-    file: './horizon-wire.jsonl',
+    file: wireLogDefault,
     maxBodyChars: 8192,
     redactAuthHeaders: true,
   });
diff --git a/apps/bff/src/http/config/layer-template.ts 
b/apps/bff/src/http/config/layer-template.ts
index 4c46afe..c173157 100644
--- a/apps/bff/src/http/config/layer-template.ts
+++ b/apps/bff/src/http/config/layer-template.ts
@@ -46,86 +46,160 @@ export interface LayerTemplateConfigDeps {
   sessions: SessionStore;
 }
 
-const adminTemplateSchema = z.object({
-  key: z.string().regex(/^[A-Z][A-Z0-9_]*$/),
-  alias: z.string().optional(),
-  color: z.string().optional(),
-  documentLink: z.string().optional(),
-  slots: z
-    .object({
-      services: z.string().optional(),
-      instances: z.string().optional(),
-      endpoints: z.string().optional(),
-      endpointDependency: z.string().optional(),
-    })
-    .strict(),
-  components: z
-    .object({
-      service: z.boolean().optional(),
-      instances: z.boolean().optional(),
-      endpoints: z.boolean().optional(),
-      endpointDependency: z.boolean().optional(),
-      topology: z.boolean().optional(),
-      traces: z.boolean().optional(),
-      logs: z.boolean().optional(),
-      traceProfiling: z.boolean().optional(),
-      ebpfProfiling: z.boolean().optional(),
-      asyncProfiling: z.boolean().optional(),
-    })
-    .strict(),
-  metrics: z
-    .object({
-      orderBy: z.string().optional(),
-      columns: z
-        .array(
-          z.object({
-            metric: z.string().min(1),
-            label: z.string(),
-            unit: z.string().optional(),
-            mqe: z.string().optional(),
-            aggregation: z.enum(['sum', 'avg']).optional(),
-            scale: z.number().finite().optional(),
-            precision: z.number().int().min(0).max(6).optional(),
-          }),
-        )
-        .max(5)
-        .optional(),
-    })
-    .strict(),
-  overview: z
-    .object({
-      throughput: z.string().optional(),
-      spark: z.string().optional(),
-    })
-    .strict()
-    .optional(),
-  dashboards: z
-    .object({
-      service: z.array(widgetSchema).max(40).optional(),
-      instance: z.array(widgetSchema).max(40).optional(),
-      endpoint: z.array(widgetSchema).max(40).optional(),
-      dependency: z.array(widgetSchema).max(40).optional(),
-      topology: z.array(widgetSchema).max(40).optional(),
-      trace: z.array(widgetSchema).max(40).optional(),
-      logs: z.array(widgetSchema).max(40).optional(),
-      traceProfiling: z.array(widgetSchema).max(40).optional(),
-      ebpfProfiling: z.array(widgetSchema).max(40).optional(),
-      asyncProfiling: z.array(widgetSchema).max(40).optional(),
-    })
-    .strict()
-    .optional(),
-  widgets: z.array(widgetSchema).max(40).optional(),
-  naming: z
-    .object({
-      pattern: z.string().min(1),
-      flags: z.string().optional(),
-      displayGroup: z.string().optional(),
-      valueGroup: z.string().optional(),
-      alias: z.string().min(1),
-    })
-    .strict()
-    .optional(),
+// One LayerMetricColumn / OverviewMetric / TopologyMetricDef row. The
+// columns family across header / overview / topology share the same
+// MQE-plus-presentation shape; we extract a base + extend per row type.
+const metricColumnSchema = z
+  .object({
+    id: z.string().optional(),
+    metric: z.string().min(1).optional(),
+    label: z.string(),
+    tip: z.string().optional(),
+    unit: z.string().optional(),
+    mqe: z.string().optional(),
+    aggregation: z.enum(['sum', 'avg']).optional(),
+    scale: z.number().finite().optional(),
+    precision: z.number().int().min(0).max(6).optional(),
+  })
+  .passthrough();
+
+// Header config (legacy field name `metrics`, canonical `header`).
+const headerSchema = z
+  .object({
+    orderBy: z.string().optional(),
+    columns: z.array(metricColumnSchema).max(8).optional(),
+  })
+  .passthrough();
+
+// Overview-tile config — `groups` is the canonical shape; the legacy
+// fields (`metrics`, `throughput`, `spark`) are preserved so older
+// bundled JSONs round-trip cleanly.
+const overviewGroupSchema = z
+  .object({
+    title: z.string(),
+    size: z.enum(['auto', 'square']),
+    metrics: z.array(metricColumnSchema).max(20),
+  })
+  .passthrough();
+const overviewSchema = z
+  .object({
+    groups: z.array(overviewGroupSchema).max(10).optional(),
+    metrics: z.array(metricColumnSchema).max(20).optional(),
+    throughput: z.string().optional(),
+    spark: z.string().optional(),
+  })
+  .passthrough();
+
+// Topology + endpoint-dependency: node + edge metric defs. The role
+// field is a string union per TopologyMetricDef; we accept any string
+// here so future roles don't break saves.
+const topologyMetricSchema = metricColumnSchema.extend({
+  role: z.string().optional(),
 });
+const topologyConfigSchema = z
+  .object({
+    nodeMetrics: z.array(topologyMetricSchema).max(20).optional(),
+    linkServerMetrics: z.array(topologyMetricSchema).max(20).optional(),
+    linkClientMetrics: z.array(topologyMetricSchema).max(20).optional(),
+  })
+  .passthrough();
+const endpointDependencyConfigSchema = z
+  .object({
+    nodeMetrics: z.array(topologyMetricSchema).max(20).optional(),
+    linkMetrics: z.array(topologyMetricSchema).max(20).optional(),
+  })
+  .passthrough();
+
+const tracesConfigSchema = z
+  .object({
+    source: z.enum(['native', 'zipkin', 'both']).optional(),
+  })
+  .passthrough();
+
+const logConfigSchema = z
+  .object({
+    scope: z.enum(['service', 'instance', 'endpoint']).optional(),
+    defaultTags: z
+      .array(z.object({ key: z.string().min(1), value: z.string() 
}).passthrough())
+      .max(20)
+      .optional(),
+  })
+  .passthrough();
+
+// `.passthrough()` on the outer template AND on `components` keeps the
+// schema from silently dropping fields the loader interface knows about
+// (visibility, group, topology, endpointDependency, traces, log) or
+// from hard-rejecting newer component flags (networkProfiling,
+// pprofProfiling) that bundled JSONs use today. Adding a new flag in
+// `LayerComponentFlags` no longer requires a schema bump to ship.
+const adminTemplateSchema = z
+  .object({
+    key: z.string().regex(/^[A-Z][A-Z0-9_]*$/),
+    alias: z.string().optional(),
+    group: z.string().optional(),
+    visibility: z.enum(['public', 'operate']).optional(),
+    color: z.string().optional(),
+    documentLink: z.string().optional(),
+    slots: z
+      .object({
+        services: z.string().optional(),
+        instances: z.string().optional(),
+        endpoints: z.string().optional(),
+        endpointDependency: z.string().optional(),
+      })
+      .passthrough(),
+    components: z
+      .object({
+        service: z.boolean().optional(),
+        instances: z.boolean().optional(),
+        endpoints: z.boolean().optional(),
+        endpointDependency: z.boolean().optional(),
+        topology: z.boolean().optional(),
+        traces: z.boolean().optional(),
+        logs: z.boolean().optional(),
+        traceProfiling: z.boolean().optional(),
+        ebpfProfiling: z.boolean().optional(),
+        asyncProfiling: z.boolean().optional(),
+        networkProfiling: z.boolean().optional(),
+        pprofProfiling: z.boolean().optional(),
+      })
+      .passthrough(),
+    // Accept both `header` (canonical) and `metrics` (legacy alias).
+    header: headerSchema.optional(),
+    metrics: headerSchema.optional(),
+    overview: overviewSchema.optional(),
+    dashboards: z
+      .object({
+        service: z.array(widgetSchema).max(40).optional(),
+        instance: z.array(widgetSchema).max(40).optional(),
+        endpoint: z.array(widgetSchema).max(40).optional(),
+        dependency: z.array(widgetSchema).max(40).optional(),
+        topology: z.array(widgetSchema).max(40).optional(),
+        trace: z.array(widgetSchema).max(40).optional(),
+        logs: z.array(widgetSchema).max(40).optional(),
+        traceProfiling: z.array(widgetSchema).max(40).optional(),
+        ebpfProfiling: z.array(widgetSchema).max(40).optional(),
+        asyncProfiling: z.array(widgetSchema).max(40).optional(),
+      })
+      .passthrough()
+      .optional(),
+    widgets: z.array(widgetSchema).max(40).optional(),
+    topology: topologyConfigSchema.optional(),
+    endpointDependency: endpointDependencyConfigSchema.optional(),
+    traces: tracesConfigSchema.optional(),
+    log: logConfigSchema.optional(),
+    naming: z
+      .object({
+        pattern: z.string().min(1),
+        flags: z.string().optional(),
+        displayGroup: z.string().optional(),
+        valueGroup: z.string().optional(),
+        alias: z.string().min(1),
+      })
+      .passthrough()
+      .optional(),
+  })
+  .passthrough();
 
 export function registerLayerTemplateRoutes(
   app: FastifyInstance,
diff --git a/apps/bff/src/http/query/async-profile.ts 
b/apps/bff/src/http/query/async-profile.ts
index a950dce..652ca61 100644
--- a/apps/bff/src/http/query/async-profile.ts
+++ b/apps/bff/src/http/query/async-profile.ts
@@ -59,6 +59,19 @@ export interface AsyncProfileRouteDeps {
   fetch?: FetchLike;
 }
 
+/** Bound the per-service task-list page. Default 500 is enough for the
+ *  Profiling tab's "recent tasks" rail; large fleets can opt up to 5000
+ *  via `?limit=`. The OAP query carries no built-in cap, so an unbounded
+ *  default would page-fault the BFF on services with years of history. */
+const DEFAULT_TASK_LIST_LIMIT = 500;
+const MAX_TASK_LIST_LIMIT = 5000;
+function clampTaskListLimit(raw: string | undefined): number {
+  if (!raw) return DEFAULT_TASK_LIST_LIMIT;
+  const n = Number(raw);
+  if (!Number.isFinite(n) || n <= 0) return DEFAULT_TASK_LIST_LIMIT;
+  return Math.min(Math.floor(n), MAX_TASK_LIST_LIMIT);
+}
+
 // ── Async profiler queries ──────────────────────────────────────────
 
 const LIST_SERVICES_FOR_RESOLVE = /* GraphQL */ `
@@ -202,17 +215,18 @@ export function registerAsyncProfileRoutes(
     { preHandler: auth },
     async (req: FastifyRequest, reply: FastifyReply) => {
       const params = req.params as { key: string };
-      const q = req.query as { service?: string };
+      const q = req.query as { service?: string; limit?: string };
       const serviceArg = (q.service ?? '').trim();
       const payload: AsyncProfilingTaskListResponse = { tasks: [], reachable: 
true };
       if (!serviceArg) return reply.send(payload);
       const opts = buildOapOpts(deps.config.current, deps.fetch);
+      const limit = clampTaskListLimit(q.limit);
       try {
         const serviceId = await resolveServiceId(opts, params.key, serviceArg);
         if (!serviceId) return reply.send(payload);
         const data = await graphqlPost<{
           asyncTaskList: { errorReason?: string; tasks: 
AsyncProfilingTaskListResponse['tasks'] };
-        }>(opts, GET_ASYNC_TASK_LIST, { request: { serviceId, limit: 10000 } 
});
+        }>(opts, GET_ASYNC_TASK_LIST, { request: { serviceId, limit } });
         payload.tasks = data.asyncTaskList?.tasks ?? [];
         payload.errorReason = data.asyncTaskList?.errorReason;
         return reply.send(payload);
@@ -291,17 +305,18 @@ export function registerAsyncProfileRoutes(
     { preHandler: auth },
     async (req: FastifyRequest, reply: FastifyReply) => {
       const params = req.params as { key: string };
-      const q = req.query as { service?: string };
+      const q = req.query as { service?: string; limit?: string };
       const serviceArg = (q.service ?? '').trim();
       const payload: PprofTaskListResponse = { tasks: [], reachable: true };
       if (!serviceArg) return reply.send(payload);
       const opts = buildOapOpts(deps.config.current, deps.fetch);
+      const limit = clampTaskListLimit(q.limit);
       try {
         const serviceId = await resolveServiceId(opts, params.key, serviceArg);
         if (!serviceId) return reply.send(payload);
         const data = await graphqlPost<{
           pprofTaskList: { errorReason?: string; tasks: 
PprofTaskListResponse['tasks'] };
-        }>(opts, GET_PPROF_TASK_LIST, { request: { serviceId, limit: 10000 } 
});
+        }>(opts, GET_PPROF_TASK_LIST, { request: { serviceId, limit } });
         payload.tasks = data.pprofTaskList?.tasks ?? [];
         payload.errorReason = data.pprofTaskList?.errorReason;
         return reply.send(payload);
diff --git a/apps/bff/src/http/query/dashboard.ts 
b/apps/bff/src/http/query/dashboard.ts
index 2752e9d..67ad53f 100644
--- a/apps/bff/src/http/query/dashboard.ts
+++ b/apps/bff/src/http/query/dashboard.ts
@@ -489,7 +489,7 @@ export function registerDashboardQueryRoute(app: 
FastifyInstance, deps: Dashboar
           })),
         );
       }
-      let data: Record<string, MqeResultShape> = {};
+      const data: Record<string, MqeResultShape> = {};
       try {
         const chunkResults = await Promise.all(
           widgetChunks.map(async (chunk) => {
diff --git a/apps/bff/src/logic/layers/loader.ts 
b/apps/bff/src/logic/layers/loader.ts
index b95990e..55641a3 100644
--- a/apps/bff/src/logic/layers/loader.ts
+++ b/apps/bff/src/logic/layers/loader.ts
@@ -599,19 +599,40 @@ export function reloadLayerTemplates(): void {
  * Errors are swallowed — a missing config dir is an existing
  * problem the loader surfaces elsewhere; failing to watch shouldn't
  * crash the BFF.
+ *
+ * MUST be called explicitly from `server.ts` rather than running at
+ * import. Each test file that imports this module would otherwise
+ * spawn its own fd, and large test suites EMFILE on low-ulimit
+ * machines. Production calls `startLayerTemplateWatcher()` once during
+ * server boot; tests skip it entirely.
  */
 let watchTimer: NodeJS.Timeout | null = null;
-try {
-  fsWatch(CONFIG_DIR, (_event, filename) => {
-    if (!filename || !filename.endsWith('.json')) return;
-    if (watchTimer) clearTimeout(watchTimer);
-    watchTimer = setTimeout(() => {
-      cache = null;
-      watchTimer = null;
-    }, 50);
-  });
-} catch {
-  // Best-effort. If fs.watch isn't supported on this filesystem
-  // (e.g. some network FS), the operator can still reload through
-  // the admin save endpoint.
+let watcher: ReturnType<typeof fsWatch> | null = null;
+export function startLayerTemplateWatcher(): void {
+  if (watcher) return;
+  try {
+    watcher = fsWatch(CONFIG_DIR, (_event, filename) => {
+      if (!filename || !filename.endsWith('.json')) return;
+      if (watchTimer) clearTimeout(watchTimer);
+      watchTimer = setTimeout(() => {
+        cache = null;
+        watchTimer = null;
+      }, 50);
+    });
+  } catch {
+    // Best-effort. If fs.watch isn't supported on this filesystem
+    // (e.g. some network FS), the operator can still reload through
+    // the admin save endpoint.
+  }
+}
+/** Tear down the watcher — for graceful shutdown + test cleanup. */
+export function stopLayerTemplateWatcher(): void {
+  if (watchTimer) {
+    clearTimeout(watchTimer);
+    watchTimer = null;
+  }
+  if (watcher) {
+    watcher.close();
+    watcher = null;
+  }
 }
diff --git a/apps/bff/src/logic/overview/loader.ts 
b/apps/bff/src/logic/overview/loader.ts
index 73c4b20..2ba0cd8 100644
--- a/apps/bff/src/logic/overview/loader.ts
+++ b/apps/bff/src/logic/overview/loader.ts
@@ -252,7 +252,7 @@ export function createOverviewDashboard(dash: 
OverviewDashboard): void {
    * exactly. Sanitise to guard against accidental path traversal —
    * loader id-equality is what really binds the file to the
    * dashboard, but the operator's also editing on disk later. */
-  const safe = dash.id.replace(/[^A-Za-z0-9_\-]/g, '_');
+  const safe = dash.id.replace(/[^A-Za-z0-9_-]/g, '_');
   const file = path.join(CONFIG_DIR, `${safe}.json`);
   fs.writeFileSync(file, JSON.stringify(dash, null, 2), 'utf8');
   invalidateOverviewCache();
diff --git a/apps/bff/src/rbac/route-policy.ts 
b/apps/bff/src/rbac/route-policy.ts
index 7025699..83f3a9d 100644
--- a/apps/bff/src/rbac/route-policy.ts
+++ b/apps/bff/src/rbac/route-policy.ts
@@ -91,6 +91,7 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = {
 
   // ── Logs (read) ──────────────────────────────────────────────────
   'POST /api/layer/:key/logs':                     'logs:read',
+  'POST /api/layer/:key/logs/facets':              'logs:read',
   'GET /api/log-tags/keys':                        'logs:read',
   'GET /api/log-tags/values':                      'logs:read',
 
@@ -131,7 +132,8 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = {
   'GET /api/layer/:key/ebpf/network/tasks':        'profile:read',
   'POST /api/layer/:key/ebpf/network/tasks':       'profile:enable',
   'GET /api/ebpf/network/tasks':                   'profile:read',
-  'POST /api/ebpf/network/topology':               'profile:read',
+  'POST /api/ebpf/network/tasks':                  'profile:enable',
+  'GET /api/ebpf/network/topology':                'profile:read',
   'POST /api/ebpf/network/tasks/:taskId/keep-alive': 'profile:enable',
 
   // ── Config — alarm-page setup, layer setup, overview, dashboards ─
@@ -224,11 +226,21 @@ export function makeRouteAuthHook(deps: AuthDeps) {
       chosenKey = key;
     }
     if (chosen === null) {
-      // Don't gate health/metrics endpoints registered late in startup.
+      // Don't gate Fastify's catch-all SPA fallback or any non-API route.
       if (route.url === '*' || route.url.startsWith('/metrics')) return;
+      // For `/api/*` routes a missing policy entry is a hard error —
+      // silently defaulting to auth-only is how unintended write
+      // endpoints (create-task, log-facets, etc.) end up reachable by
+      // any logged-in viewer. Fail loudly at registration time so the
+      // gap surfaces in CI / first boot, not at exploit time.
+      if (route.url.startsWith('/api/')) {
+        const msg = `rbac: route ${String(methods)} ${route.url} has no entry 
in ROUTE_POLICY; add one in apps/bff/src/rbac/route-policy.ts`;
+        logger.error({ method: methods, url: route.url }, msg);
+        throw new Error(msg);
+      }
       logger.warn(
         { method: methods, url: route.url },
-        'rbac: route has no policy entry; defaulting to auth-only',
+        'rbac: non-api route has no policy entry; defaulting to auth-only',
       );
       chosen = 'auth';
     }
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index abd9bfd..915f21d 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -49,6 +49,7 @@ import { registerAsyncProfileRoutes } from 
'./http/query/async-profile.js';
 // Config (CRUD for templates / settings)
 import { registerDashboardConfigRoute } from './http/config/dashboard.js';
 import { registerLayerTemplateRoutes } from './http/config/layer-template.js';
+import { startLayerTemplateWatcher } from './logic/layers/loader.js';
 import { registerAlarmsConfigRoutes } from './http/config/alarms.js';
 import { registerSetupRoutes } from './http/config/setup.js';
 import { registerOverviewRoutes } from './http/config/overview.js';
@@ -158,6 +159,10 @@ registerAsyncProfileRoutes(app, { config: source, sessions 
});
 // ── Config ─────────────────────────────────────────────────────────
 registerDashboardConfigRoute(app, { config: source, sessions });
 registerLayerTemplateRoutes(app, { config: source, sessions });
+// Spawn the bundled-template fs.watch once per process. Skipped in
+// tests (each test file imports the loader; a watcher per import
+// EMFILEs CI under low ulimits). Production calls this exactly once.
+if (process.env.NODE_ENV !== 'test') startLayerTemplateWatcher();
 registerAlarmsConfigRoutes(app, { config: source, sessions, audit, store: 
alarmsStore, serviceLayer });
 registerSetupRoutes(app, { config: source, sessions, audit, store: setupStore 
});
 registerOverviewRoutes(app, { config: source, sessions });
diff --git a/apps/ui/eslint.config.mjs b/apps/ui/eslint.config.mjs
new file mode 100644
index 0000000..8dda499
Binary files /dev/null and b/apps/ui/eslint.config.mjs differ
diff --git a/apps/ui/package.json b/apps/ui/package.json
index a0a32b6..36c659f 100644
--- a/apps/ui/package.json
+++ b/apps/ui/package.json
@@ -9,7 +9,8 @@
     "build-only": "vite build",
     "preview": "vite preview",
     "type-check": "vue-tsc --noEmit",
-    "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix 
--ignore-path ../../.gitignore",
+    "lint": "eslint .",
+    "lint:fix": "eslint . --fix",
     "test:unit": "vitest run --environment jsdom --root src/",
     "test:unit:watch": "vitest --environment jsdom --root src/"
   },
diff --git a/apps/ui/src/shell/useAdminFeatures.ts 
b/apps/ui/src/shell/useAdminFeatures.ts
index 8034a67..a3cefa2 100644
--- a/apps/ui/src/shell/useAdminFeatures.ts
+++ b/apps/ui/src/shell/useAdminFeatures.ts
@@ -19,7 +19,6 @@ import { computed } from 'vue';
 import { useQuery } from '@tanstack/vue-query';
 import type { PreflightResult } from '@skywalking-horizon-ui/api-client';
 import { bffClient } from '@/api/client';
-import { useAutoRefreshSubscribe } from '../controls/useAutoRefreshSubscribe';
 
 /**
  * Admin-port preflight — interrogates OAP's `/debugging/config/dump`
@@ -45,8 +44,6 @@ export function useAdminFeatures() {
     refetchOnWindowFocus: true,
   });
 
-  useAutoRefreshSubscribe(() => q.refetch());
-
   const result = computed<PreflightResult | null>(() => q.data.value ?? null);
   const adminReachable = computed<boolean>(() => result.value?.adminReachable 
?? false);
   const adminUrl = computed<string | undefined>(() => result.value?.adminUrl);
diff --git a/apps/ui/src/shell/useOapInfo.ts b/apps/ui/src/shell/useOapInfo.ts
index 0861319..cc2ffa1 100644
--- a/apps/ui/src/shell/useOapInfo.ts
+++ b/apps/ui/src/shell/useOapInfo.ts
@@ -17,7 +17,6 @@
 
 import { computed } from 'vue';
 import { useQuery } from '@tanstack/vue-query';
-import { useAutoRefreshSubscribe } from '../controls/useAutoRefreshSubscribe';
 import {
   parseOapTimezoneMinutes,
   type OapCapabilities,
@@ -98,9 +97,6 @@ export function useOapInfo() {
     return 'ok';
   });
 
-  useAutoRefreshSubscribe(() => q.refetch());
-
-
   return {
     isLoading: q.isLoading,
     info,
diff --git a/apps/ui/src/utils/serviceName.ts b/apps/ui/src/utils/serviceName.ts
index c41b125..3b195e7 100644
--- a/apps/ui/src/utils/serviceName.ts
+++ b/apps/ui/src/utils/serviceName.ts
@@ -115,7 +115,7 @@ export function resolveServiceIdentity(
   // cluster rule captures `mesh-svr::reviews` as the service, and we
   // then split off `mesh-svr` here).
   let legacyGroup: string | null = null;
-  let workingName = r;
+  const workingName = r;
 
   // Cluster rule first.
   const re = compileRule(rule);
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 662277e..0b39909 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -48,18 +48,33 @@ importers:
         specifier: ^3.23.8
         version: 3.25.76
     devDependencies:
+      '@eslint/js':
+        specifier: ^9.14.0
+        version: 9.39.4
       '@types/node':
         specifier: ^22.9.0
         version: 22.19.19
+      '@typescript-eslint/eslint-plugin':
+        specifier: ^8.16.0
+        version: 
8.59.3(@typescript-eslint/[email protected]([email protected])([email protected]))([email protected])([email protected])
+      '@typescript-eslint/parser':
+        specifier: ^8.16.0
+        version: 8.59.3([email protected])([email protected])
       esbuild:
         specifier: ^0.24.0
         version: 0.24.2
+      eslint:
+        specifier: ^9.14.0
+        version: 9.39.4
       tsx:
         specifier: ^4.19.2
         version: 4.21.0
       typescript:
         specifier: ~5.6.3
         version: 5.6.3
+      typescript-eslint:
+        specifier: ^8.16.0
+        version: 8.59.3([email protected])([email protected])
       vitest:
         specifier: ^2.1.4
         version: 2.1.9(@types/[email protected])([email protected])([email protected])


Reply via email to