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])
