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


The following commit(s) were added to refs/heads/main by this push:
     new e24ec2a  bff: split http/ into query/ config/ admin/ sub-folders
e24ec2a is described below

commit e24ec2a9c13b8e841fc0b13698436545ea81e5c4
Author: Wu Sheng <[email protected]>
AuthorDate: Sat May 16 22:50:16 2026 +0800

    bff: split http/ into query/ config/ admin/ sub-folders
    
    Drop the flat http/ folder and group the 23 route files by functional
    role per CLAUDE.md's layering:
    
      http/query/     read-only OAP queries (menu, landing, log, trace,
                      zipkin, instance, endpoint, endpoint-dependency,
                      topology, profile, ebpf, async-profile, info,
                      preflight + the runtime POST /api/layer/:key/dashboard
                      + GET /api/alarms*)
      http/config/    CRUD for templates / settings (dashboard config,
                      layer-template admin, alarms config, setup, overview)
      http/admin/     operational admin (dsl/{catalog,rule,dump,oal},
                      cluster, live-debug, inspect)
    
    Three mixed-scope files were split:
    
      dashboard.ts (697 LOC) →
        query/dashboard.ts        POST /api/layer/:key/dashboard
        config/dashboard.ts       GET  /api/layer/:key/dashboard/config
        config/layer-template.ts  GET/POST /api/admin/layer-templates*
    
      alarms.ts (601 LOC) →
        query/alarms.ts           GET /api/alarms{,/traffic,/services}
        config/alarms.ts          GET/POST /api/alarms/config
        The shared ServiceLayerMap is now constructed in server.ts and
        passed to both files so a config save still invalidates the cache
        backing the list call.
    
      dsl.ts (482 LOC) →
        admin/dsl/_shared.ts      DslRouteDeps + verb/audit/parse helpers
        admin/dsl/catalog.ts      GET /api/catalog{,/list,/bundled}
        admin/dsl/rule.ts         /api/rule{,/inactivate,/delete}
        admin/dsl/dump.ts         GET /api/dump{,/:catalog}
        admin/dsl/oal.ts          /api/oal/{files,rules}{,/:name}
        admin/cluster.ts          GET /api/cluster/state (was lumped in dsl)
    
    server.ts is now organised into User / Query / Config / Admin bands
    mirroring the folder layout. tsc / esbuild bundle / license-eye all
    green. Pure restructure — no behavior changes.
---
 apps/bff/src/client/cluster.ts                     |   2 +-
 apps/bff/src/http/admin/cluster.ts                 |  54 +++
 apps/bff/src/http/admin/dsl/_shared.ts             | 172 ++++++++
 apps/bff/src/http/admin/dsl/catalog.ts             |  74 ++++
 apps/bff/src/http/admin/dsl/dump.ts                |  71 +++
 apps/bff/src/http/admin/dsl/oal.ts                 | 101 +++++
 apps/bff/src/http/admin/dsl/rule.ts                | 166 +++++++
 apps/bff/src/http/{ => admin}/inspect.ts           |  20 +-
 .../bff/src/http/{debug.ts => admin/live-debug.ts} |  14 +-
 apps/bff/src/http/config/alarms.ts                 |  91 ++++
 apps/bff/src/http/config/dashboard.ts              |  61 +++
 apps/bff/src/http/config/layer-template.ts         | 166 +++++++
 apps/bff/src/http/{ => config}/overview.ts         |   8 +-
 apps/bff/src/http/{ => config}/setup.ts            |  12 +-
 apps/bff/src/http/dsl.ts                           | 482 ---------------------
 apps/bff/src/http/{ => query}/alarms.ts            |  74 +---
 apps/bff/src/http/{ => query}/async-profile.ts     |   8 +-
 apps/bff/src/http/{ => query}/dashboard.ts         | 164 +------
 apps/bff/src/http/{ => query}/ebpf.ts              |   8 +-
 .../src/http/{ => query}/endpoint-dependency.ts    |  10 +-
 apps/bff/src/http/{ => query}/endpoint.ts          |   8 +-
 apps/bff/src/http/{ => query}/info.ts              |   8 +-
 apps/bff/src/http/{ => query}/instance.ts          |   8 +-
 apps/bff/src/http/{ => query}/landing.ts           |  10 +-
 apps/bff/src/http/{ => query}/log.ts               |   8 +-
 apps/bff/src/http/{ => query}/menu.ts              |  12 +-
 apps/bff/src/http/{ => query}/preflight.ts         |   8 +-
 apps/bff/src/http/{ => query}/profile.ts           |   8 +-
 apps/bff/src/http/{ => query}/topology.ts          |  10 +-
 apps/bff/src/http/{ => query}/trace-tag.ts         |   8 +-
 apps/bff/src/http/{ => query}/trace.ts             |  14 +-
 apps/bff/src/http/{ => query}/zipkin.ts            |   8 +-
 apps/bff/src/http/user.ts                          |   4 +-
 apps/bff/src/logic/alarms/service-layer-map.ts     |   6 +-
 apps/bff/src/logic/alarms/store.ts                 |   2 +-
 apps/bff/src/logic/inspect/exec.ts                 |   2 +-
 apps/bff/src/logic/preflight/preflight.ts          |   2 +-
 apps/bff/src/logic/setup/store.ts                  |   2 +-
 apps/bff/src/rbac/policy.ts                        |   4 +-
 apps/bff/src/server.ts                             | 100 +++--
 apps/bff/src/util/trace-protocol-cache.ts          |   2 +-
 apps/ui/src/views/auth/LoginView.vue               |  11 -
 42 files changed, 1155 insertions(+), 848 deletions(-)

diff --git a/apps/bff/src/client/cluster.ts b/apps/bff/src/client/cluster.ts
index 9257618..a0182b7 100644
--- a/apps/bff/src/client/cluster.ts
+++ b/apps/bff/src/client/cluster.ts
@@ -32,7 +32,7 @@ import type {
   LocalState,
   RuleStatus,
 } from '@skywalking-horizon-ui/api-client';
-import type { OapClients } from './clients.js';
+import type { OapClients } from './index.js';
 
 export interface NodeListResult {
   url: string;
diff --git a/apps/bff/src/http/admin/cluster.ts 
b/apps/bff/src/http/admin/cluster.ts
new file mode 100644
index 0000000..e90636d
--- /dev/null
+++ b/apps/bff/src/http/admin/cluster.ts
@@ -0,0 +1,54 @@
+/*
+ * 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.
+ */
+
+/**
+ * `GET /api/cluster/state` — fan-out `/runtime/rule/list` to every
+ * configured OAP admin URL and pivot the result into the per-rule ×
+ * per-node matrix the SPA renders. Gated on `cluster:read`. Not part
+ * of the DSL family proper — kept here under admin/ because it shares
+ * the same OAP-admin auth surface.
+ */
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import type { FetchLike } from '@skywalking-horizon-ui/api-client';
+import type { ConfigSource } from '../../config/loader.js';
+import type { SessionStore } from '../../user/sessions.js';
+import { requireAuth } from '../../user/middleware.js';
+import { ensureVerb, makeClients } from './dsl/_shared.js';
+import { fetchPerNode, pivotClusterState } from '../../client/cluster.js';
+import type { AuditLogger } from '../../audit/logger.js';
+
+export interface ClusterRouteDeps {
+  config: ConfigSource;
+  sessions: SessionStore;
+  audit: AuditLogger;
+  fetch?: FetchLike;
+}
+
+export function registerClusterRoutes(app: FastifyInstance, deps: 
ClusterRouteDeps): void {
+  const auth = requireAuth(deps);
+  const clients = makeClients(deps);
+  app.get(
+    '/api/cluster/state',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      if (!ensureVerb(req, reply, deps, 'cluster:read')) return;
+      const perNode = await fetchPerNode(clients());
+      return reply.send(pivotClusterState(perNode));
+    },
+  );
+}
diff --git a/apps/bff/src/http/admin/dsl/_shared.ts 
b/apps/bff/src/http/admin/dsl/_shared.ts
new file mode 100644
index 0000000..c3583b3
--- /dev/null
+++ b/apps/bff/src/http/admin/dsl/_shared.ts
@@ -0,0 +1,172 @@
+/*
+ * 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.
+ */
+
+/**
+ * Shared deps + parsers + audit/verb helpers for every DSL admin handler
+ * (catalog / rule / dump / oal). Lives next to the routes so each handler
+ * file stays small enough to read in one screen.
+ */
+
+import type { FastifyReply, FastifyRequest } from 'fastify';
+import {
+  RuntimeRuleApiError,
+  isCatalog,
+  type Catalog,
+  type DeleteMode,
+  type FetchLike,
+} from '@skywalking-horizon-ui/api-client';
+import type { ConfigSource } from '../../../config/loader.js';
+import type { AuditLogger } from '../../../audit/logger.js';
+import type { Session, SessionStore } from '../../../user/sessions.js';
+import { sessionHasVerb } from '../../../rbac/policy.js';
+import { buildOapClients, type OapClients } from '../../../client/index.js';
+
+export interface DslRouteDeps {
+  config: ConfigSource;
+  sessions: SessionStore;
+  audit: AuditLogger;
+  /** Test seam — replaces `globalThis.fetch` in every OAP call. */
+  fetch?: FetchLike;
+}
+
+export function makeClients(deps: DslRouteDeps): () => OapClients {
+  return () => buildOapClients(deps.config.current, { fetch: deps.fetch });
+}
+
+const TRUTHY = new Set(['true', '1', 'yes']);
+export function parseBoolean(v: string | undefined, fallback: boolean): 
boolean {
+  if (v === undefined) return fallback;
+  return TRUTHY.has(v.toLowerCase());
+}
+
+export function hasCatalogParam(q: unknown): boolean {
+  return typeof q === 'object' && q !== null && 'catalog' in q;
+}
+
+/** When `catalog` is missing → returns `undefined`. When present but
+ *  invalid → sends 400 and returns `undefined`; callers use
+ *  {@link hasCatalogParam} to disambiguate. */
+export function parseOptionalCatalog(q: unknown, reply: FastifyReply): Catalog 
| undefined {
+  const raw = (q as Record<string, string | undefined>).catalog;
+  if (raw === undefined || raw === '') return undefined;
+  if (!isCatalog(raw)) {
+    reply.code(400).send({ error: 'invalid_catalog', value: raw });
+    return undefined;
+  }
+  return raw;
+}
+
+export function parseRequiredCatalog(q: unknown, reply: FastifyReply): Catalog 
| null {
+  const raw = (q as Record<string, string | undefined>).catalog;
+  if (!raw) {
+    reply.code(400).send({ error: 'missing_catalog' });
+    return null;
+  }
+  if (!isCatalog(raw)) {
+    reply.code(400).send({ error: 'invalid_catalog', value: raw });
+    return null;
+  }
+  return raw;
+}
+
+export function parseDeleteMode(raw: string | undefined, reply: FastifyReply): 
DeleteMode | null {
+  if (raw === undefined || raw === '') return '';
+  if (raw === 'revertToBundled') return 'revertToBundled';
+  reply.code(400).send({ error: 'invalid_delete_mode', value: raw });
+  return null;
+}
+
+export function ensureVerb(
+  req: FastifyRequest,
+  reply: FastifyReply,
+  deps: DslRouteDeps,
+  verb: string,
+): boolean {
+  const session: Session | undefined = req.session;
+  if (!session) {
+    reply.code(401).send({ error: 'unauthenticated' });
+    return false;
+  }
+  if (!sessionHasVerb(deps.config.current, session.roles, verb)) {
+    reply.code(403).send({ error: 'permission_denied', verb });
+    return false;
+  }
+  return true;
+}
+
+export function passOapError(err: unknown, reply: FastifyReply): FastifyReply {
+  if (err instanceof RuntimeRuleApiError) {
+    return reply.code(err.status).send(err.body);
+  }
+  return reply.code(502).send({
+    error: 'oap_unreachable',
+    message: err instanceof Error ? err.message : String(err),
+  });
+}
+
+export function passOapErrorAudit(
+  err: unknown,
+  reply: FastifyReply,
+  deps: DslRouteDeps,
+  req: FastifyRequest,
+  action: string,
+  verb: string,
+  catalog: Catalog,
+  name: string,
+  details: Record<string, unknown> = {},
+): FastifyReply {
+  let outcome = 'oap_unreachable';
+  if (err instanceof RuntimeRuleApiError) {
+    const apiErr: RuntimeRuleApiError = err;
+    const body = apiErr.body;
+    outcome =
+      typeof body === 'object' && body !== null && 'applyStatus' in body
+        ? body.applyStatus
+        : `http_${apiErr.status}`;
+  }
+  deps.audit.record({
+    action,
+    verb,
+    actor: req.session?.username ?? null,
+    outcome,
+    details: { catalog, name, ...details },
+    fromIp: req.ip,
+    sessionId: req.session?.sid,
+  });
+  return passOapError(err, reply);
+}
+
+export function auditMutation(
+  deps: DslRouteDeps,
+  req: FastifyRequest,
+  action: string,
+  verb: string,
+  catalog: Catalog,
+  name: string,
+  outcome: string,
+  details: Record<string, unknown> = {},
+): void {
+  deps.audit.record({
+    action,
+    verb,
+    actor: req.session?.username ?? null,
+    outcome,
+    details: { catalog, name, ...details },
+    fromIp: req.ip,
+    sessionId: req.session?.sid,
+  });
+}
diff --git a/apps/bff/src/http/admin/dsl/catalog.ts 
b/apps/bff/src/http/admin/dsl/catalog.ts
new file mode 100644
index 0000000..9bda086
--- /dev/null
+++ b/apps/bff/src/http/admin/dsl/catalog.ts
@@ -0,0 +1,74 @@
+/*
+ * 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.
+ */
+
+/**
+ *   GET /api/catalog/list     — runtime + bundled rule list per catalog.
+ *   GET /api/catalog/bundled  — bundled-only list (with raw content opt).
+ *
+ * Both gated on `rule:read`; read-only — no audit.
+ */
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import { requireAuth } from '../../../user/middleware.js';
+import {
+  type DslRouteDeps,
+  ensureVerb,
+  hasCatalogParam,
+  makeClients,
+  parseBoolean,
+  parseOptionalCatalog,
+  parseRequiredCatalog,
+  passOapError,
+} from './_shared.js';
+
+export function registerDslCatalogRoutes(app: FastifyInstance, deps: 
DslRouteDeps): void {
+  const auth = requireAuth(deps);
+  const clients = makeClients(deps);
+
+  app.get(
+    '/api/catalog/list',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      if (!ensureVerb(req, reply, deps, 'rule:read')) return;
+      const catalog = parseOptionalCatalog(req.query, reply);
+      if (catalog === undefined && hasCatalogParam(req.query)) return;
+      try {
+        const env = await clients().primary().list(catalog);
+        return reply.send(env);
+      } catch (err) {
+        return passOapError(err, reply);
+      }
+    },
+  );
+
+  app.get(
+    '/api/catalog/bundled',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      if (!ensureVerb(req, reply, deps, 'rule:read')) return;
+      const catalog = parseRequiredCatalog(req.query, reply);
+      if (!catalog) return;
+      const withContent = parseBoolean((req.query as Record<string, 
string>).withContent, true);
+      try {
+        const list = await clients().primary().listBundled(catalog, 
withContent);
+        return reply.send(list);
+      } catch (err) {
+        return passOapError(err, reply);
+      }
+    },
+  );
+}
diff --git a/apps/bff/src/http/admin/dsl/dump.ts 
b/apps/bff/src/http/admin/dsl/dump.ts
new file mode 100644
index 0000000..6c54e9d
--- /dev/null
+++ b/apps/bff/src/http/admin/dsl/dump.ts
@@ -0,0 +1,71 @@
+/*
+ * 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.
+ */
+
+/**
+ *   GET /api/dump             — dump every catalog (zipped).
+ *   GET /api/dump/:catalog    — dump one catalog. Streams the upstream
+ *                                response straight through.
+ *
+ * Gated on `rule:read`; response is a passthrough of OAP's content-type
+ * and content-disposition so the browser's save-as dialog works.
+ */
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import { isCatalog, type Catalog } from '@skywalking-horizon-ui/api-client';
+import { requireAuth } from '../../../user/middleware.js';
+import {
+  type DslRouteDeps,
+  ensureVerb,
+  makeClients,
+  passOapError,
+} from './_shared.js';
+
+export function registerDslDumpRoutes(app: FastifyInstance, deps: 
DslRouteDeps): void {
+  const auth = requireAuth(deps);
+  const clients = makeClients(deps);
+
+  const dumpHandler = (catalog: Catalog | null) =>
+    async function (req: FastifyRequest, reply: FastifyReply) {
+      if (!ensureVerb(req, reply, deps, 'rule:read')) return;
+      try {
+        const upstream = await clients()
+          .primary()
+          .dump(catalog ?? undefined);
+        const ct = upstream.headers.get('content-type') ?? 
'application/octet-stream';
+        const cd = upstream.headers.get('content-disposition');
+        reply.header('content-type', ct);
+        if (cd) reply.header('content-disposition', cd);
+        return reply.send(upstream.body ?? '');
+      } catch (err) {
+        return passOapError(err, reply);
+      }
+    };
+
+  app.get('/api/dump', { preHandler: auth }, dumpHandler(null));
+
+  app.get(
+    '/api/dump/:catalog',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const params = req.params as { catalog: string };
+      if (!isCatalog(params.catalog)) {
+        return reply.code(400).send({ error: 'invalid_catalog', value: 
params.catalog });
+      }
+      return dumpHandler(params.catalog)(req, reply);
+    },
+  );
+}
diff --git a/apps/bff/src/http/admin/dsl/oal.ts 
b/apps/bff/src/http/admin/dsl/oal.ts
new file mode 100644
index 0000000..bc10c3d
--- /dev/null
+++ b/apps/bff/src/http/admin/dsl/oal.ts
@@ -0,0 +1,101 @@
+/*
+ * 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.
+ */
+
+/**
+ * OAL read-only browse (SWIP-13 §4.1). All gated on `rule:read`, no audit.
+ *   GET /api/oal/files          — { files: string[], count }
+ *   GET /api/oal/files/:name    — text/plain raw .oal content
+ *   GET /api/oal/rules          — per-dispatcher source listing
+ *   GET /api/oal/rules/:source  — single source detail with `status`
+ */
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import { requireAuth } from '../../../user/middleware.js';
+import {
+  type DslRouteDeps,
+  ensureVerb,
+  makeClients,
+  passOapError,
+} from './_shared.js';
+
+export function registerDslOalRoutes(app: FastifyInstance, deps: 
DslRouteDeps): void {
+  const auth = requireAuth(deps);
+  const clients = makeClients(deps);
+
+  app.get(
+    '/api/oal/files',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      if (!ensureVerb(req, reply, deps, 'rule:read')) return;
+      try {
+        const list = await clients().oal().listFiles();
+        return reply.send(list);
+      } catch (err) {
+        return passOapError(err, reply);
+      }
+    },
+  );
+
+  app.get(
+    '/api/oal/files/:name',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      if (!ensureVerb(req, reply, deps, 'rule:read')) return;
+      const params = req.params as { name: string };
+      if (!params.name) return reply.code(400).send({ error: 'missing_name' });
+      try {
+        const content = await clients().oal().getFileContent(params.name);
+        if (content === null) return reply.code(404).send({ error: 'not_found' 
});
+        reply.header('content-type', 'text/plain; charset=utf-8');
+        return reply.send(content);
+      } catch (err) {
+        return passOapError(err, reply);
+      }
+    },
+  );
+
+  app.get(
+    '/api/oal/rules',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      if (!ensureVerb(req, reply, deps, 'rule:read')) return;
+      try {
+        const sources = await clients().oal().listSources();
+        return reply.send(sources);
+      } catch (err) {
+        return passOapError(err, reply);
+      }
+    },
+  );
+
+  app.get(
+    '/api/oal/rules/:source',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      if (!ensureVerb(req, reply, deps, 'rule:read')) return;
+      const params = req.params as { source: string };
+      if (!params.source) return reply.code(400).send({ error: 
'missing_source' });
+      try {
+        const detail = await clients().oal().getSource(params.source);
+        if (detail === null) return reply.code(404).send({ error: 'not_found' 
});
+        return reply.send(detail);
+      } catch (err) {
+        return passOapError(err, reply);
+      }
+    },
+  );
+}
diff --git a/apps/bff/src/http/admin/dsl/rule.ts 
b/apps/bff/src/http/admin/dsl/rule.ts
new file mode 100644
index 0000000..99bc4df
--- /dev/null
+++ b/apps/bff/src/http/admin/dsl/rule.ts
@@ -0,0 +1,166 @@
+/*
+ * 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.
+ */
+
+/**
+ *   GET  /api/rule              — single rule fetch (`If-None-Match` aware).
+ *   POST /api/rule              — add or update (audited; structural
+ *                                  writes need `rule:write:structural`).
+ *   POST /api/rule/inactivate   — `rule:write`, audited.
+ *   POST /api/rule/delete       — `rule:delete`; `mode=revertToBundled`
+ *                                  is treated as a structural write.
+ */
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import { requireAuth } from '../../../user/middleware.js';
+import {
+  auditMutation,
+  type DslRouteDeps,
+  ensureVerb,
+  makeClients,
+  parseBoolean,
+  parseDeleteMode,
+  parseRequiredCatalog,
+  passOapError,
+  passOapErrorAudit,
+} from './_shared.js';
+
+export function registerDslRuleRoutes(app: FastifyInstance, deps: 
DslRouteDeps): void {
+  const auth = requireAuth(deps);
+  const clients = makeClients(deps);
+
+  app.get('/api/rule', { preHandler: auth }, async (req: FastifyRequest, 
reply: FastifyReply) => {
+    if (!ensureVerb(req, reply, deps, 'rule:read')) return;
+    const q = req.query as Record<string, string | undefined>;
+    const catalog = parseRequiredCatalog(q, reply);
+    if (!catalog) return;
+    if (!q.name) return reply.code(400).send({ error: 'missing_name' });
+    const source = q.source as 'runtime' | 'bundled' | undefined;
+    if (source !== undefined && source !== 'runtime' && source !== 'bundled') {
+      return reply.code(400).send({ error: 'invalid_source', value: source });
+    }
+    const ifNoneMatch = req.headers['if-none-match'] as string | undefined;
+    try {
+      const got = await clients()
+        .primary()
+        .get({
+          catalog,
+          name: q.name,
+          ...(source !== undefined ? { source } : {}),
+          ...(ifNoneMatch !== undefined ? { ifNoneMatch } : {}),
+        });
+      if ('notModified' in got) {
+        reply.header('etag', got.etag);
+        reply.header('x-sw-content-hash', got.contentHash);
+        reply.header('x-sw-status', got.status);
+        return reply.code(304).send();
+      }
+      reply.header('content-type', 'application/x-yaml; charset=utf-8');
+      reply.header('etag', got.etag);
+      reply.header('x-sw-content-hash', got.contentHash);
+      reply.header('x-sw-status', got.status);
+      reply.header('x-sw-source', got.source);
+      reply.header('x-sw-update-time', String(got.updateTime));
+      return reply.send(got.content);
+    } catch (err) {
+      return passOapError(err, reply);
+    }
+  });
+
+  app.post('/api/rule', { preHandler: auth }, async (req: FastifyRequest, 
reply: FastifyReply) => {
+    const q = req.query as Record<string, string | undefined>;
+    const catalog = parseRequiredCatalog(q, reply);
+    if (!catalog) return;
+    if (!q.name) return reply.code(400).send({ error: 'missing_name' });
+    if (typeof req.body !== 'string' || req.body.length === 0) {
+      return reply.code(400).send({ error: 'empty_body' });
+    }
+    const allowStorageChange = parseBoolean(q.allowStorageChange, false);
+    const force = parseBoolean(q.force, false);
+    const verb = allowStorageChange || force ? 'rule:write:structural' : 
'rule:write';
+    if (!ensureVerb(req, reply, deps, verb)) return;
+
+    try {
+      const result = await clients().primary().addOrUpdate({
+        catalog,
+        name: q.name,
+        body: req.body,
+        allowStorageChange,
+        force,
+      });
+      auditMutation(deps, req, 'addOrUpdate', verb, catalog, q.name, 
result.applyStatus, {
+        allowStorageChange,
+        force,
+      });
+      return reply.send(result);
+    } catch (err) {
+      return passOapErrorAudit(err, reply, deps, req, 'addOrUpdate', verb, 
catalog, q.name);
+    }
+  });
+
+  app.post(
+    '/api/rule/inactivate',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const q = req.query as Record<string, string | undefined>;
+      const catalog = parseRequiredCatalog(q, reply);
+      if (!catalog) return;
+      if (!q.name) return reply.code(400).send({ error: 'missing_name' });
+      if (!ensureVerb(req, reply, deps, 'rule:write')) return;
+      try {
+        const result = await clients().primary().inactivate(catalog, q.name);
+        auditMutation(deps, req, 'inactivate', 'rule:write', catalog, q.name, 
result.applyStatus);
+        return reply.send(result);
+      } catch (err) {
+        return passOapErrorAudit(
+          err,
+          reply,
+          deps,
+          req,
+          'inactivate',
+          'rule:write',
+          catalog,
+          q.name,
+        );
+      }
+    },
+  );
+
+  app.post(
+    '/api/rule/delete',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const q = req.query as Record<string, string | undefined>;
+      const catalog = parseRequiredCatalog(q, reply);
+      if (!catalog) return;
+      if (!q.name) return reply.code(400).send({ error: 'missing_name' });
+      const mode = parseDeleteMode(q.mode, reply);
+      if (mode === null) return;
+      // mode=revertToBundled is structural — swaps the active row's
+      // identity back to the bundled twin. A caller with only
+      // `rule:delete` must not be able to revert.
+      const verb = mode === 'revertToBundled' ? 'rule:write:structural' : 
'rule:delete';
+      if (!ensureVerb(req, reply, deps, verb)) return;
+      try {
+        const result = await clients().primary().delete(catalog, q.name, mode);
+        auditMutation(deps, req, 'delete', verb, catalog, q.name, 
result.applyStatus, { mode });
+        return reply.send(result);
+      } catch (err) {
+        return passOapErrorAudit(err, reply, deps, req, 'delete', verb, 
catalog, q.name, { mode });
+      }
+    },
+  );
+}
diff --git a/apps/bff/src/http/inspect.ts b/apps/bff/src/http/admin/inspect.ts
similarity index 95%
rename from apps/bff/src/http/inspect.ts
rename to apps/bff/src/http/admin/inspect.ts
index cbafcbc..21827bb 100644
--- a/apps/bff/src/http/inspect.ts
+++ b/apps/bff/src/http/admin/inspect.ts
@@ -50,16 +50,16 @@ import {
   type InspectMetricType,
   type InspectStep,
 } from '@skywalking-horizon-ui/api-client';
-import type { ConfigSource } from '../config/loader.js';
-import type { AuditLogger } from '../audit/logger.js';
-import { requireAuth } from '../auth/middleware.js';
-import { sessionHasVerb } from '../rbac/policy.js';
-import type { Session, SessionStore } from '../auth/sessions.js';
-import { buildOapClients, type OapClients } from './clients.js';
-import { AttributionCache, attributeOrUnknown } from 
'../inspect/attribution.js';
-import { MqeTargetCache } from './mqe-target.js';
-import { parseExecBody, fireMqe, MqeFireError } from './inspect-exec.js';
-import { ServerTimeCache } from './server-time.js';
+import type { ConfigSource } from '../../config/loader.js';
+import type { AuditLogger } from '../../audit/logger.js';
+import { requireAuth } from '../../user/middleware.js';
+import { sessionHasVerb } from '../../rbac/policy.js';
+import type { Session, SessionStore } from '../../user/sessions.js';
+import { buildOapClients, type OapClients } from '../../client/index.js';
+import { AttributionCache, attributeOrUnknown } from 
'../../logic/inspect/attribution.js';
+import { MqeTargetCache } from '../../util/mqe-target.js';
+import { parseExecBody, fireMqe, MqeFireError } from 
'../../logic/inspect/exec.js';
+import { ServerTimeCache } from '../../util/time.js';
 
 export interface InspectRouteDeps {
   config: ConfigSource;
diff --git a/apps/bff/src/http/debug.ts b/apps/bff/src/http/admin/live-debug.ts
similarity index 96%
rename from apps/bff/src/http/debug.ts
rename to apps/bff/src/http/admin/live-debug.ts
index 6021998..9c20221 100644
--- a/apps/bff/src/http/debug.ts
+++ b/apps/bff/src/http/admin/live-debug.ts
@@ -42,13 +42,13 @@ import {
   type Granularity,
   type StartSessionArgs,
 } from '@skywalking-horizon-ui/api-client';
-import type { ConfigSource } from '../config/loader.js';
-import type { AuditLogger } from '../audit/logger.js';
-import { requireAuth } from '../auth/middleware.js';
-import { sessionHasVerb } from '../rbac/policy.js';
-import type { Session } from '../auth/sessions.js';
-import type { SessionStore } from '../auth/sessions.js';
-import { buildOapClients, type OapClients } from './clients.js';
+import type { ConfigSource } from '../../config/loader.js';
+import type { AuditLogger } from '../../audit/logger.js';
+import { requireAuth } from '../../user/middleware.js';
+import { sessionHasVerb } from '../../rbac/policy.js';
+import type { Session } from '../../user/sessions.js';
+import type { SessionStore } from '../../user/sessions.js';
+import { buildOapClients, type OapClients } from '../../client/index.js';
 import type { FetchLike } from '@skywalking-horizon-ui/api-client';
 
 export interface DebugRouteDeps {
diff --git a/apps/bff/src/http/config/alarms.ts 
b/apps/bff/src/http/config/alarms.ts
new file mode 100644
index 0000000..17e9e84
--- /dev/null
+++ b/apps/bff/src/http/config/alarms.ts
@@ -0,0 +1,91 @@
+/*
+ * 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.
+ */
+
+/**
+ * `/api/alarms/config` — read + write the alarm-page setup
+ * (per-traffic-layer MQE expression list). The `serviceLayer` cache is
+ * invalidated on save so the next `GET /api/alarms` picks up any newly
+ * configured layer immediately instead of waiting for the 60s TTL.
+ */
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import { z } from 'zod';
+import type { ConfigSource } from '../../config/loader.js';
+import type { SessionStore } from '../../user/sessions.js';
+import type { AuditLogger } from '../../audit/logger.js';
+import { requireAuth } from '../../user/middleware.js';
+import type { ServiceLayerMap } from '../../logic/alarms/service-layer-map.js';
+import type { AlarmsStore, AlarmsConfig } from '../../logic/alarms/store.js';
+
+export interface AlarmsConfigRouteDeps {
+  config: ConfigSource;
+  sessions: SessionStore;
+  audit: AuditLogger;
+  store: AlarmsStore;
+  serviceLayer: ServiceLayerMap;
+}
+
+const configSaveSchema = z.object({
+  trafficLayers: z
+    .array(
+      z.object({
+        layerKey: z.string().min(1),
+        mqe: z.string().min(1),
+        label: z.string().optional(),
+      }).strict(),
+    )
+    .max(8),
+});
+
+export function registerAlarmsConfigRoutes(
+  app: FastifyInstance,
+  deps: AlarmsConfigRouteDeps,
+): void {
+  const auth = requireAuth(deps);
+
+  app.get(
+    '/api/alarms/config',
+    { preHandler: auth },
+    async (_req: FastifyRequest, reply: FastifyReply) => {
+      const cfg = await deps.store.load();
+      return reply.send(cfg);
+    },
+  );
+
+  app.post(
+    '/api/alarms/config',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const parsed = configSaveSchema.safeParse(req.body);
+      if (!parsed.success) {
+        return reply.code(400).send({ error: 'invalid_body', detail: 
parsed.error.flatten() });
+      }
+      const next: AlarmsConfig = { trafficLayers: parsed.data.trafficLayers };
+      await deps.store.save(next);
+      deps.serviceLayer.invalidate();
+      deps.audit.record({
+        action: 'alarms.config.save',
+        actor: req.session?.username ?? null,
+        outcome: 'ok',
+        details: { layers: next.trafficLayers.map((l) => 
`${l.layerKey}:${l.mqe}`) },
+        fromIp: req.ip,
+        sessionId: req.session?.sid,
+      });
+      return reply.send(next);
+    },
+  );
+}
diff --git a/apps/bff/src/http/config/dashboard.ts 
b/apps/bff/src/http/config/dashboard.ts
new file mode 100644
index 0000000..2551e9e
--- /dev/null
+++ b/apps/bff/src/http/config/dashboard.ts
@@ -0,0 +1,61 @@
+/*
+ * 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.
+ */
+
+/**
+ * `GET /api/layer/:key/dashboard/config` — returns the default widget
+ * set for one (layer, scope) without running any MQE. The SPA renders
+ * the empty grid first, then fires `POST /api/layer/:key/dashboard` to
+ * populate cells. Accepts `?scope=service|instance|endpoint|…` and
+ * defaults to `service`.
+ */
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import type { ConfigSource } from '../../config/loader.js';
+import type { SessionStore } from '../../user/sessions.js';
+import { requireAuth } from '../../user/middleware.js';
+import {
+  getLayerTemplate,
+  widgetsForScope,
+} from '../../logic/layers/loader.js';
+import { defaultWidgetsFor } from '../../logic/dashboard/defaults.js';
+import { scopeSchema } from '../query/dashboard.js';
+
+export interface DashboardConfigDeps {
+  config: ConfigSource;
+  sessions: SessionStore;
+}
+
+export function registerDashboardConfigRoute(app: FastifyInstance, deps: 
DashboardConfigDeps): void {
+  const auth = requireAuth(deps);
+  app.get(
+    '/api/layer/:key/dashboard/config',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const params = req.params as { key: string };
+      const layerKey = params.key;
+      if (!layerKey || !/^[a-z0-9_]+$/i.test(layerKey)) {
+        return reply.code(400).send({ error: 'invalid_layer_key' });
+      }
+      const q = req.query as { scope?: string };
+      const scopeParsed = q.scope ? scopeSchema.safeParse(q.scope) : null;
+      const scope = scopeParsed?.success ? scopeParsed.data : 'service';
+      const tpl = getLayerTemplate(layerKey);
+      const widgets = tpl ? widgetsForScope(tpl, scope) : 
defaultWidgetsFor(layerKey);
+      return reply.send({ layer: layerKey, scope, widgets });
+    },
+  );
+}
diff --git a/apps/bff/src/http/config/layer-template.ts 
b/apps/bff/src/http/config/layer-template.ts
new file mode 100644
index 0000000..4c46afe
--- /dev/null
+++ b/apps/bff/src/http/config/layer-template.ts
@@ -0,0 +1,166 @@
+/*
+ * 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.
+ */
+
+/**
+ * `/api/admin/layer-templates*` — admin CRUD for the per-layer JSON
+ * templates that drive the dashboards / service-list / overview blocks.
+ *
+ *   GET  /api/admin/layer-templates           — list every loaded layer.
+ *   POST /api/admin/layer-templates/:key      — write one template back
+ *                                                to its JSON file; the
+ *                                                in-memory cache is
+ *                                                invalidated so the
+ *                                                next read sees the new
+ *                                                shape immediately.
+ */
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import { z } from 'zod';
+import type { ConfigSource } from '../../config/loader.js';
+import type { SessionStore } from '../../user/sessions.js';
+import { requireAuth } from '../../user/middleware.js';
+import {
+  allLayerTemplates,
+  getLayerTemplate,
+  writeLayerTemplate,
+  type LayerTemplate,
+} from '../../logic/layers/loader.js';
+import { widgetSchema } from '../query/dashboard.js';
+
+export interface LayerTemplateConfigDeps {
+  config: ConfigSource;
+  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(),
+});
+
+export function registerLayerTemplateRoutes(
+  app: FastifyInstance,
+  deps: LayerTemplateConfigDeps,
+): void {
+  const auth = requireAuth(deps);
+  app.get('/api/admin/layer-templates', { preHandler: auth }, async (_req, 
reply) => {
+    return reply.send({ templates: allLayerTemplates() });
+  });
+
+  app.post(
+    '/api/admin/layer-templates/:key',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const params = req.params as { key: string };
+      const layerKey = params.key.toUpperCase();
+      const parsed = adminTemplateSchema.safeParse(req.body);
+      if (!parsed.success) {
+        return reply.code(400).send({ error: 'invalid_template', detail: 
parsed.error.flatten() });
+      }
+      if (parsed.data.key.toUpperCase() !== layerKey) {
+        return reply
+          .code(400)
+          .send({ error: 'key_mismatch', detail: 'URL key does not match body 
key' });
+      }
+      try {
+        writeLayerTemplate(parsed.data as LayerTemplate);
+      } catch (err) {
+        return reply.code(500).send({
+          error: 'write_failed',
+          detail: err instanceof Error ? err.message : String(err),
+        });
+      }
+      const refreshed = getLayerTemplate(layerKey);
+      return reply.send({ template: refreshed });
+    },
+  );
+}
diff --git a/apps/bff/src/http/overview.ts 
b/apps/bff/src/http/config/overview.ts
similarity index 91%
rename from apps/bff/src/http/overview.ts
rename to apps/bff/src/http/config/overview.ts
index e0c8326..c52c6a5 100644
--- a/apps/bff/src/http/overview.ts
+++ b/apps/bff/src/http/config/overview.ts
@@ -35,10 +35,10 @@ import type {
   OverviewDashboardListResponse,
   OverviewDashboardResponse,
 } from '@skywalking-horizon-ui/api-client';
-import type { ConfigSource } from '../config/loader.js';
-import type { SessionStore } from '../auth/sessions.js';
-import { requireAuth } from '../auth/middleware.js';
-import { getOverviewDashboard, loadOverviewDashboards } from './loader.js';
+import type { ConfigSource } from '../../config/loader.js';
+import type { SessionStore } from '../../user/sessions.js';
+import { requireAuth } from '../../user/middleware.js';
+import { getOverviewDashboard, loadOverviewDashboards } from 
'../../logic/overview/loader.js';
 
 export interface OverviewRouteDeps {
   config: ConfigSource;
diff --git a/apps/bff/src/http/setup.ts b/apps/bff/src/http/config/setup.ts
similarity index 92%
rename from apps/bff/src/http/setup.ts
rename to apps/bff/src/http/config/setup.ts
index 965a384..b4272b9 100644
--- a/apps/bff/src/http/setup.ts
+++ b/apps/bff/src/http/config/setup.ts
@@ -18,12 +18,12 @@
 import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
 import { z } from 'zod';
 import type { SetupResponse, SetupSavePayload } from 
'@skywalking-horizon-ui/api-client';
-import type { AuditLogger } from '../audit/logger.js';
-import { requireAuth } from '../auth/middleware.js';
-import type { ConfigSource } from '../config/loader.js';
-import type { SessionStore } from '../auth/sessions.js';
-import { badRequest } from '../errors.js';
-import type { SetupStore } from './store.js';
+import type { AuditLogger } from '../../audit/logger.js';
+import { requireAuth } from '../../user/middleware.js';
+import type { ConfigSource } from '../../config/loader.js';
+import type { SessionStore } from '../../user/sessions.js';
+import { badRequest } from '../../errors.js';
+import type { SetupStore } from '../../logic/setup/store.js';
 
 export interface SetupRouteDeps {
   config: ConfigSource;
diff --git a/apps/bff/src/http/dsl.ts b/apps/bff/src/http/dsl.ts
deleted file mode 100644
index 18076df..0000000
--- a/apps/bff/src/http/dsl.ts
+++ /dev/null
@@ -1,482 +0,0 @@
-/*
- * 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.
- */
-
-/**
- * Studio's `/api/*` surface — eight route families that together cover
- * everything v1's catalog browse, editor, cluster matrix, and dump UIs
- * need.
- *
- * Each handler:
- *   1. Runs through `requireAuth` (handled by route preHandler).
- *   2. Resolves the verb required for this call.
- *   3. Calls the OAP client, mapping errors back to HTTP via the
- *      `RuntimeRuleApiError` envelope so OAP's applyStatus codes
- *      reach the SPA verbatim.
- *   4. Audits every mutating call, with the actor / verb / outcome.
- */
-
-import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
-import {
-  RuntimeRuleApiError,
-  isCatalog,
-  type Catalog,
-  type DeleteMode,
-} from '@skywalking-horizon-ui/api-client';
-import type { ConfigSource } from '../config/loader.js';
-import type { AuditLogger } from '../audit/logger.js';
-import { requireAuth } from '../auth/middleware.js';
-import { sessionHasVerb } from '../rbac/policy.js';
-import type { Session } from '../auth/sessions.js';
-import type { SessionStore } from '../auth/sessions.js';
-import { buildOapClients, type OapClients } from './clients.js';
-import { fetchPerNode, pivotClusterState } from './cluster.js';
-import type { FetchLike } from '@skywalking-horizon-ui/api-client';
-
-export interface OapRouteDeps {
-  config: ConfigSource;
-  sessions: SessionStore;
-  audit: AuditLogger;
-  /** Test seam — replaces `globalThis.fetch` in every OAP call. */
-  fetch?: FetchLike;
-}
-
-const TRUTHY = new Set(['true', '1', 'yes']);
-
-/** Register every `/api/*` route on the given Fastify instance. The
- *  text/plain content-type parser must already be registered (see
- *  server.ts). */
-export function registerOapRoutes(app: FastifyInstance, deps: OapRouteDeps): 
void {
-  const auth = requireAuth(deps);
-
-  function clients(): OapClients {
-    return buildOapClients(deps.config.current, { fetch: deps.fetch });
-  }
-
-  // ── catalog browse ────────────────────────────────────────────────
-
-  app.get(
-    '/api/catalog/list',
-    { preHandler: auth },
-    async (req: FastifyRequest, reply: FastifyReply) => {
-      if (!ensureVerb(req, reply, deps, 'rule:read')) return;
-      const catalog = parseOptionalCatalog(req.query, reply);
-      if (catalog === undefined && hasCatalogParam(req.query)) return;
-      try {
-        const env = await clients().primary().list(catalog);
-        return reply.send(env);
-      } catch (err) {
-        return passOapError(err, reply);
-      }
-    },
-  );
-
-  app.get(
-    '/api/catalog/bundled',
-    { preHandler: auth },
-    async (req: FastifyRequest, reply: FastifyReply) => {
-      if (!ensureVerb(req, reply, deps, 'rule:read')) return;
-      const catalog = parseRequiredCatalog(req.query, reply);
-      if (!catalog) return;
-      const withContent = parseBoolean((req.query as Record<string, 
string>).withContent, true);
-      try {
-        const list = await clients().primary().listBundled(catalog, 
withContent);
-        return reply.send(list);
-      } catch (err) {
-        return passOapError(err, reply);
-      }
-    },
-  );
-
-  // ── single rule fetch ─────────────────────────────────────────────
-
-  app.get('/api/rule', { preHandler: auth }, async (req: FastifyRequest, 
reply: FastifyReply) => {
-    if (!ensureVerb(req, reply, deps, 'rule:read')) return;
-    const q = req.query as Record<string, string | undefined>;
-    const catalog = parseRequiredCatalog(q, reply);
-    if (!catalog) return;
-    if (!q.name) {
-      return reply.code(400).send({ error: 'missing_name' });
-    }
-    const source = q.source as 'runtime' | 'bundled' | undefined;
-    if (source !== undefined && source !== 'runtime' && source !== 'bundled') {
-      return reply.code(400).send({ error: 'invalid_source', value: source });
-    }
-    const ifNoneMatch = req.headers['if-none-match'] as string | undefined;
-    try {
-      const got = await clients()
-        .primary()
-        .get({
-          catalog,
-          name: q.name,
-          ...(source !== undefined ? { source } : {}),
-          ...(ifNoneMatch !== undefined ? { ifNoneMatch } : {}),
-        });
-      if ('notModified' in got) {
-        reply.header('etag', got.etag);
-        reply.header('x-sw-content-hash', got.contentHash);
-        reply.header('x-sw-status', got.status);
-        return reply.code(304).send();
-      }
-      reply.header('content-type', 'application/x-yaml; charset=utf-8');
-      reply.header('etag', got.etag);
-      reply.header('x-sw-content-hash', got.contentHash);
-      reply.header('x-sw-status', got.status);
-      reply.header('x-sw-source', got.source);
-      reply.header('x-sw-update-time', String(got.updateTime));
-      return reply.send(got.content);
-    } catch (err) {
-      return passOapError(err, reply);
-    }
-  });
-
-  // ── write paths (audit on every call) ─────────────────────────────
-
-  app.post('/api/rule', { preHandler: auth }, async (req: FastifyRequest, 
reply: FastifyReply) => {
-    const q = req.query as Record<string, string | undefined>;
-    const catalog = parseRequiredCatalog(q, reply);
-    if (!catalog) return;
-    if (!q.name) return reply.code(400).send({ error: 'missing_name' });
-    if (typeof req.body !== 'string' || req.body.length === 0) {
-      return reply.code(400).send({ error: 'empty_body' });
-    }
-    const allowStorageChange = parseBoolean(q.allowStorageChange, false);
-    const force = parseBoolean(q.force, false);
-    const verb = allowStorageChange || force ? 'rule:write:structural' : 
'rule:write';
-    if (!ensureVerb(req, reply, deps, verb)) return;
-
-    try {
-      const result = await clients().primary().addOrUpdate({
-        catalog,
-        name: q.name,
-        body: req.body,
-        allowStorageChange,
-        force,
-      });
-      auditMutation(deps, req, 'addOrUpdate', verb, catalog, q.name, 
result.applyStatus, {
-        allowStorageChange,
-        force,
-      });
-      return reply.send(result);
-    } catch (err) {
-      return passOapErrorAudit(err, reply, deps, req, 'addOrUpdate', verb, 
catalog, q.name);
-    }
-  });
-
-  app.post(
-    '/api/rule/inactivate',
-    { preHandler: auth },
-    async (req: FastifyRequest, reply: FastifyReply) => {
-      const q = req.query as Record<string, string | undefined>;
-      const catalog = parseRequiredCatalog(q, reply);
-      if (!catalog) return;
-      if (!q.name) return reply.code(400).send({ error: 'missing_name' });
-      if (!ensureVerb(req, reply, deps, 'rule:write')) return;
-      try {
-        const result = await clients().primary().inactivate(catalog, q.name);
-        auditMutation(deps, req, 'inactivate', 'rule:write', catalog, q.name, 
result.applyStatus);
-        return reply.send(result);
-      } catch (err) {
-        return passOapErrorAudit(
-          err,
-          reply,
-          deps,
-          req,
-          'inactivate',
-          'rule:write',
-          catalog,
-          q.name,
-        );
-      }
-    },
-  );
-
-  app.post(
-    '/api/rule/delete',
-    { preHandler: auth },
-    async (req: FastifyRequest, reply: FastifyReply) => {
-      const q = req.query as Record<string, string | undefined>;
-      const catalog = parseRequiredCatalog(q, reply);
-      if (!catalog) return;
-      if (!q.name) return reply.code(400).send({ error: 'missing_name' });
-      const mode = parseDeleteMode(q.mode, reply);
-      if (mode === null) return;
-      /* `mode=revertToBundled` is a structural change — it swaps the
-       * active row's identity back to the bundled twin, the same
-       * write-class that `rule:write:structural` already gates on
-       * the addOrUpdate path. A caller with only `rule:delete`
-       * must not be able to revert. */
-      const verb = mode === 'revertToBundled' ? 'rule:write:structural' : 
'rule:delete';
-      if (!ensureVerb(req, reply, deps, verb)) return;
-      try {
-        const result = await clients().primary().delete(catalog, q.name, mode);
-        auditMutation(deps, req, 'delete', verb, catalog, q.name, 
result.applyStatus, {
-          mode,
-        });
-        return reply.send(result);
-      } catch (err) {
-        return passOapErrorAudit(err, reply, deps, req, 'delete', verb, 
catalog, q.name, {
-          mode,
-        });
-      }
-    },
-  );
-
-  // ── cluster state (BFF fan-out) ───────────────────────────────────
-
-  app.get(
-    '/api/cluster/state',
-    { preHandler: auth },
-    async (req: FastifyRequest, reply: FastifyReply) => {
-      if (!ensureVerb(req, reply, deps, 'cluster:read')) return;
-      const c = clients();
-      const perNode = await fetchPerNode(c);
-      return reply.send(pivotClusterState(perNode));
-    },
-  );
-
-  // ── dump (streaming passthrough) ──────────────────────────────────
-
-  const dumpHandler = (catalog: Catalog | null) =>
-    async function (req: FastifyRequest, reply: FastifyReply) {
-      if (!ensureVerb(req, reply, deps, 'rule:read')) return;
-      try {
-        const upstream = await clients()
-          .primary()
-          .dump(catalog ?? undefined);
-        const ct = upstream.headers.get('content-type') ?? 
'application/octet-stream';
-        const cd = upstream.headers.get('content-disposition');
-        reply.header('content-type', ct);
-        if (cd) reply.header('content-disposition', cd);
-        return reply.send(upstream.body ?? '');
-      } catch (err) {
-        return passOapError(err, reply);
-      }
-    };
-
-  app.get('/api/dump', { preHandler: auth }, dumpHandler(null));
-
-  app.get(
-    '/api/dump/:catalog',
-    { preHandler: auth },
-    async (req: FastifyRequest, reply: FastifyReply) => {
-      const params = req.params as { catalog: string };
-      if (!isCatalog(params.catalog)) {
-        return reply.code(400).send({ error: 'invalid_catalog', value: 
params.catalog });
-      }
-      return dumpHandler(params.catalog)(req, reply);
-    },
-  );
-
-  // ── OAL read-only browse (SWIP-13 §4.1) ──────────────────────────
-  // Read-only — no audit. `rule:read` gate.
-  //
-  // Wire shape from RuntimeOalRestHandler.java:
-  //   /files          — { files: string[], count }
-  //   /files/{name}   — text/plain raw .oal content
-  //   /rules          — per-dispatcher listing { sources, count }
-  //   /rules/{source} — single source detail with `status: live | no_holder`
-
-  app.get(
-    '/api/oal/files',
-    { preHandler: auth },
-    async (req: FastifyRequest, reply: FastifyReply) => {
-      if (!ensureVerb(req, reply, deps, 'rule:read')) return;
-      try {
-        const list = await clients().oal().listFiles();
-        return reply.send(list);
-      } catch (err) {
-        return passOapError(err, reply);
-      }
-    },
-  );
-
-  app.get(
-    '/api/oal/files/:name',
-    { preHandler: auth },
-    async (req: FastifyRequest, reply: FastifyReply) => {
-      if (!ensureVerb(req, reply, deps, 'rule:read')) return;
-      const params = req.params as { name: string };
-      if (!params.name) return reply.code(400).send({ error: 'missing_name' });
-      try {
-        const content = await clients().oal().getFileContent(params.name);
-        if (content === null) return reply.code(404).send({ error: 'not_found' 
});
-        reply.header('content-type', 'text/plain; charset=utf-8');
-        return reply.send(content);
-      } catch (err) {
-        return passOapError(err, reply);
-      }
-    },
-  );
-
-  app.get(
-    '/api/oal/rules',
-    { preHandler: auth },
-    async (req: FastifyRequest, reply: FastifyReply) => {
-      if (!ensureVerb(req, reply, deps, 'rule:read')) return;
-      try {
-        const sources = await clients().oal().listSources();
-        return reply.send(sources);
-      } catch (err) {
-        return passOapError(err, reply);
-      }
-    },
-  );
-
-  app.get(
-    '/api/oal/rules/:source',
-    { preHandler: auth },
-    async (req: FastifyRequest, reply: FastifyReply) => {
-      if (!ensureVerb(req, reply, deps, 'rule:read')) return;
-      const params = req.params as { source: string };
-      if (!params.source) return reply.code(400).send({ error: 
'missing_source' });
-      try {
-        const detail = await clients().oal().getSource(params.source);
-        if (detail === null) return reply.code(404).send({ error: 'not_found' 
});
-        return reply.send(detail);
-      } catch (err) {
-        return passOapError(err, reply);
-      }
-    },
-  );
-}
-
-// ── helpers ─────────────────────────────────────────────────────────
-
-function parseBoolean(v: string | undefined, fallback: boolean): boolean {
-  if (v === undefined) return fallback;
-  return TRUTHY.has(v.toLowerCase());
-}
-
-function hasCatalogParam(q: unknown): boolean {
-  return typeof q === 'object' && q !== null && 'catalog' in q;
-}
-
-/** When the `catalog` query is missing, returns `undefined` and lets
- *  the caller proceed. When present-but-invalid, sends 400 and returns
- *  `undefined`; the caller should check `hasCatalogParam` to disambiguate. */
-function parseOptionalCatalog(q: unknown, reply: FastifyReply): Catalog | 
undefined {
-  const raw = (q as Record<string, string | undefined>).catalog;
-  if (raw === undefined || raw === '') return undefined;
-  if (!isCatalog(raw)) {
-    reply.code(400).send({ error: 'invalid_catalog', value: raw });
-    return undefined;
-  }
-  return raw;
-}
-
-function parseRequiredCatalog(q: unknown, reply: FastifyReply): Catalog | null 
{
-  const raw = (q as Record<string, string | undefined>).catalog;
-  if (!raw) {
-    reply.code(400).send({ error: 'missing_catalog' });
-    return null;
-  }
-  if (!isCatalog(raw)) {
-    reply.code(400).send({ error: 'invalid_catalog', value: raw });
-    return null;
-  }
-  return raw;
-}
-
-/** Returns the parsed `DeleteMode` or `null` when the wire value was
- *  invalid (and a 400 was sent). */
-function parseDeleteMode(raw: string | undefined, reply: FastifyReply): 
DeleteMode | null {
-  if (raw === undefined || raw === '') return '';
-  if (raw === 'revertToBundled') return 'revertToBundled';
-  reply.code(400).send({ error: 'invalid_delete_mode', value: raw });
-  return null;
-}
-
-function ensureVerb(
-  req: FastifyRequest,
-  reply: FastifyReply,
-  deps: OapRouteDeps,
-  verb: string,
-): boolean {
-  const session: Session | undefined = req.session;
-  if (!session) {
-    reply.code(401).send({ error: 'unauthenticated' });
-    return false;
-  }
-  if (!sessionHasVerb(deps.config.current, session.roles, verb)) {
-    reply.code(403).send({ error: 'permission_denied', verb });
-    return false;
-  }
-  return true;
-}
-
-function passOapError(err: unknown, reply: FastifyReply): FastifyReply {
-  if (err instanceof RuntimeRuleApiError) {
-    return reply.code(err.status).send(err.body);
-  }
-  return reply.code(502).send({
-    error: 'oap_unreachable',
-    message: err instanceof Error ? err.message : String(err),
-  });
-}
-
-function passOapErrorAudit(
-  err: unknown,
-  reply: FastifyReply,
-  deps: OapRouteDeps,
-  req: FastifyRequest,
-  action: string,
-  verb: string,
-  catalog: Catalog,
-  name: string,
-  details: Record<string, unknown> = {},
-): FastifyReply {
-  let outcome = 'oap_unreachable';
-  if (err instanceof RuntimeRuleApiError) {
-    const apiErr: RuntimeRuleApiError = err;
-    const body = apiErr.body;
-    outcome =
-      typeof body === 'object' && body !== null && 'applyStatus' in body
-        ? body.applyStatus
-        : `http_${apiErr.status}`;
-  }
-  deps.audit.record({
-    action,
-    verb,
-    actor: req.session?.username ?? null,
-    outcome,
-    details: { catalog, name, ...details },
-    fromIp: req.ip,
-    sessionId: req.session?.sid,
-  });
-  return passOapError(err, reply);
-}
-
-function auditMutation(
-  deps: OapRouteDeps,
-  req: FastifyRequest,
-  action: string,
-  verb: string,
-  catalog: Catalog,
-  name: string,
-  outcome: string,
-  details: Record<string, unknown> = {},
-): void {
-  deps.audit.record({
-    action,
-    verb,
-    actor: req.session?.username ?? null,
-    outcome,
-    details: { catalog, name, ...details },
-    fromIp: req.ip,
-    sessionId: req.session?.sid,
-  });
-}
diff --git a/apps/bff/src/http/alarms.ts b/apps/bff/src/http/query/alarms.ts
similarity index 89%
rename from apps/bff/src/http/alarms.ts
rename to apps/bff/src/http/query/alarms.ts
index faf101b..fa72a10 100644
--- a/apps/bff/src/http/alarms.ts
+++ b/apps/bff/src/http/query/alarms.ts
@@ -40,19 +40,22 @@
 import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
 import type { FetchLike } from '@skywalking-horizon-ui/api-client';
 import { z } from 'zod';
-import { requireAuth } from '../auth/middleware.js';
-import type { ConfigSource } from '../config/loader.js';
-import type { SessionStore } from '../auth/sessions.js';
-import type { AuditLogger } from '../audit/logger.js';
-import { badRequest } from '../errors.js';
-import { buildOapOpts, graphqlPost } from '../oap/graphql-client.js';
-import { ServiceLayerMap } from './service-layer-map.js';
-import type { AlarmsStore, AlarmsConfig } from './store.js';
-
-export interface AlarmsRouteDeps {
+import { requireAuth } from '../../user/middleware.js';
+import type { ConfigSource } from '../../config/loader.js';
+import type { SessionStore } from '../../user/sessions.js';
+import { badRequest } from '../../errors.js';
+import { buildOapOpts, graphqlPost } from '../../client/graphql.js';
+import type { ServiceLayerMap } from '../../logic/alarms/service-layer-map.js';
+import type { AlarmsStore } from '../../logic/alarms/store.js';
+
+export interface AlarmsQueryRouteDeps {
   config: ConfigSource;
   sessions: SessionStore;
-  audit: AuditLogger;
+  /** Shared with config/alarms.ts so a config save can invalidate
+   *  the cache and the next list call picks up the new layers. */
+  serviceLayer: ServiceLayerMap;
+  /** Used by `/api/alarms/traffic` to read which layers the operator
+   *  configured as traffic backdrops. */
   store: AlarmsStore;
   fetch?: FetchLike;
 }
@@ -322,21 +325,9 @@ const trafficQuerySchema = z.object({
   endTime: z.coerce.number().int().positive(),
 });
 
-const configSaveSchema = z.object({
-  trafficLayers: z
-    .array(
-      z.object({
-        layerKey: z.string().min(1),
-        mqe: z.string().min(1),
-        label: z.string().optional(),
-      }).strict(),
-    )
-    .max(8),
-});
-
-export function registerAlarmsRoutes(app: FastifyInstance, deps: 
AlarmsRouteDeps): void {
+export function registerAlarmsQueryRoutes(app: FastifyInstance, deps: 
AlarmsQueryRouteDeps): void {
   const auth = requireAuth(deps);
-  const serviceLayer = new ServiceLayerMap({ config: deps.config, fetch: 
deps.fetch });
+  const serviceLayer = deps.serviceLayer;
 
   // ── GET /api/alarms ────────────────────────────────────────────────
   app.get('/api/alarms', { preHandler: auth }, async (req: FastifyRequest, 
reply: FastifyReply) => {
@@ -557,39 +548,6 @@ export function registerAlarmsRoutes(app: FastifyInstance, 
deps: AlarmsRouteDeps
     },
   );
 
-  // ── GET /api/alarms/config ─────────────────────────────────────────
-  app.get(
-    '/api/alarms/config',
-    { preHandler: auth },
-    async (_req: FastifyRequest, reply: FastifyReply) => {
-      const cfg = await deps.store.load();
-      return reply.send(cfg);
-    },
-  );
-
-  // ── POST /api/alarms/config ────────────────────────────────────────
-  app.post(
-    '/api/alarms/config',
-    { preHandler: auth },
-    async (req: FastifyRequest, reply: FastifyReply) => {
-      const parsed = configSaveSchema.safeParse(req.body);
-      if (!parsed.success) {
-        return reply.code(400).send({ error: 'invalid_body', detail: 
parsed.error.flatten() });
-      }
-      const next: AlarmsConfig = { trafficLayers: parsed.data.trafficLayers };
-      await deps.store.save(next);
-      serviceLayer.invalidate();
-      deps.audit.record({
-        action: 'alarms.config.save',
-        actor: req.session?.username ?? null,
-        outcome: 'ok',
-        details: { layers: next.trafficLayers.map((l) => 
`${l.layerKey}:${l.mqe}`) },
-        fromIp: req.ip,
-        sessionId: req.session?.sid,
-      });
-      return reply.send(next);
-    },
-  );
 }
 
 function prettyLayer(key: string): string {
diff --git a/apps/bff/src/http/async-profile.ts 
b/apps/bff/src/http/query/async-profile.ts
similarity index 98%
rename from apps/bff/src/http/async-profile.ts
rename to apps/bff/src/http/query/async-profile.ts
index 1c1c6c8..a950dce 100644
--- a/apps/bff/src/http/async-profile.ts
+++ b/apps/bff/src/http/query/async-profile.ts
@@ -48,10 +48,10 @@ import type {
   PprofTaskCreationResponse,
   PprofTaskListResponse,
 } from '@skywalking-horizon-ui/api-client';
-import type { ConfigSource } from '../config/loader.js';
-import type { SessionStore } from '../auth/sessions.js';
-import { requireAuth } from '../auth/middleware.js';
-import { graphqlPost, buildOapOpts } from './graphql-client.js';
+import type { ConfigSource } from '../../config/loader.js';
+import type { SessionStore } from '../../user/sessions.js';
+import { requireAuth } from '../../user/middleware.js';
+import { graphqlPost, buildOapOpts } from '../../client/graphql.js';
 
 export interface AsyncProfileRouteDeps {
   config: ConfigSource;
diff --git a/apps/bff/src/http/dashboard.ts 
b/apps/bff/src/http/query/dashboard.ts
similarity index 79%
rename from apps/bff/src/http/dashboard.ts
rename to apps/bff/src/http/query/dashboard.ts
index e3e2883..8d03a12 100644
--- a/apps/bff/src/http/dashboard.ts
+++ b/apps/bff/src/http/query/dashboard.ts
@@ -39,18 +39,15 @@ import type {
   DashboardWidgetResult,
   FetchLike,
 } from '@skywalking-horizon-ui/api-client';
-import type { ConfigSource } from '../config/loader.js';
-import type { SessionStore } from '../auth/sessions.js';
-import { requireAuth } from '../auth/middleware.js';
-import {  graphqlPost, buildOapOpts } from '../oap/graphql-client.js';
+import type { ConfigSource } from '../../config/loader.js';
+import type { SessionStore } from '../../user/sessions.js';
+import { requireAuth } from '../../user/middleware.js';
+import { graphqlPost, buildOapOpts } from '../../client/graphql.js';
 import {
-  allLayerTemplates,
   getLayerTemplate,
   widgetsForScope,
-  writeLayerTemplate,
-  type LayerTemplate,
-} from '../layers/loader.js';
-import { defaultWidgetsFor } from './defaults.js';
+} from '../../logic/layers/loader.js';
+import { defaultWidgetsFor } from '../../logic/dashboard/defaults.js';
 
 export interface DashboardRouteDeps {
   config: ConfigSource;
@@ -58,7 +55,10 @@ export interface DashboardRouteDeps {
   fetch?: FetchLike;
 }
 
-const widgetSchema = z.object({
+/** Shared with config/layer-template.ts — kept here because the
+ *  runtime POST handler is the canonical user; the admin-template
+ *  schema reuses widgetSchema for nested validation. */
+export const widgetSchema = z.object({
   id: z.string().min(1),
   title: z.string(),
   tip: z.string().optional(),
@@ -83,7 +83,8 @@ const widgetSchema = z.object({
   w: z.number().int().positive().optional(),
   h: z.number().int().positive().optional(),
 });
-const scopeSchema = z.enum([
+/** Shared with config/dashboard.ts (GET-config handler). */
+export const scopeSchema = z.enum([
   'service',
   'instance',
   'endpoint',
@@ -307,7 +308,7 @@ function parseTopList(
   });
 }
 
-export function registerDashboardRoute(app: FastifyInstance, deps: 
DashboardRouteDeps): void {
+export function registerDashboardQueryRoute(app: FastifyInstance, deps: 
DashboardRouteDeps): void {
   const auth = requireAuth(deps);
   app.post(
     '/api/layer/:key/dashboard',
@@ -555,143 +556,4 @@ export function registerDashboardRoute(app: 
FastifyInstance, deps: DashboardRout
       return reply.send({ ...baseResp, widgets: results });
     },
   );
-
-  // GET version returns the default widget config without running queries —
-  // useful for the SPA to know what to render before invoking POST.
-  // Accepts 
?scope=service|instance|endpoint|dependency|topology|trace|logs|traceProfiling|ebpfProfiling|asyncProfiling.
-  app.get(
-    '/api/layer/:key/dashboard/config',
-    { preHandler: auth },
-    async (req: FastifyRequest, reply: FastifyReply) => {
-      const params = req.params as { key: string };
-      const layerKey = params.key;
-      if (!layerKey || !/^[a-z0-9_]+$/i.test(layerKey)) {
-        return reply.code(400).send({ error: 'invalid_layer_key' });
-      }
-      const q = req.query as { scope?: string };
-      const scopeParsed = q.scope ? scopeSchema.safeParse(q.scope) : null;
-      const scope = scopeParsed?.success ? scopeParsed.data : 'service';
-      const tpl = getLayerTemplate(layerKey);
-      const widgets = tpl ? widgetsForScope(tpl, scope) : 
defaultWidgetsFor(layerKey);
-      return reply.send({ layer: layerKey, scope, widgets });
-    },
-  );
-
-  // Admin: enumerate every loaded JSON layer template. Used by the
-  // /admin/layer-dashboards page to render a layer picker + current
-  // widget set per layer.
-  app.get('/api/admin/layer-templates', { preHandler: auth }, async (_req, 
reply) => {
-    return reply.send({ templates: allLayerTemplates() });
-  });
-
-  // Admin: persist an operator-edited template back to its JSON file.
-  // Body is the whole template; the BFF rewrites the file and
-  // invalidates its in-memory cache so subsequent reads see the new
-  // shape immediately.
-  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(),
-  });
-
-  app.post(
-    '/api/admin/layer-templates/:key',
-    { preHandler: auth },
-    async (req: FastifyRequest, reply: FastifyReply) => {
-      const params = req.params as { key: string };
-      const layerKey = params.key.toUpperCase();
-      const parsed = adminTemplateSchema.safeParse(req.body);
-      if (!parsed.success) {
-        return reply.code(400).send({ error: 'invalid_template', detail: 
parsed.error.flatten() });
-      }
-      if (parsed.data.key.toUpperCase() !== layerKey) {
-        return reply.code(400).send({ error: 'key_mismatch', detail: 'URL key 
does not match body key' });
-      }
-      try {
-        writeLayerTemplate(parsed.data as LayerTemplate);
-      } catch (err) {
-        return reply.code(500).send({
-          error: 'write_failed',
-          detail: err instanceof Error ? err.message : String(err),
-        });
-      }
-      const refreshed = getLayerTemplate(layerKey);
-      return reply.send({ template: refreshed });
-    },
-  );
 }
diff --git a/apps/bff/src/http/ebpf.ts b/apps/bff/src/http/query/ebpf.ts
similarity index 98%
rename from apps/bff/src/http/ebpf.ts
rename to apps/bff/src/http/query/ebpf.ts
index 408869c..cbb613b 100644
--- a/apps/bff/src/http/ebpf.ts
+++ b/apps/bff/src/http/query/ebpf.ts
@@ -44,10 +44,10 @@ import type {
   NetworkProfilingKeepAliveResponse,
   ProcessTopologyResponse,
 } from '@skywalking-horizon-ui/api-client';
-import type { ConfigSource } from '../config/loader.js';
-import type { SessionStore } from '../auth/sessions.js';
-import { requireAuth } from '../auth/middleware.js';
-import { graphqlPost, buildOapOpts } from './graphql-client.js';
+import type { ConfigSource } from '../../config/loader.js';
+import type { SessionStore } from '../../user/sessions.js';
+import { requireAuth } from '../../user/middleware.js';
+import { graphqlPost, buildOapOpts } from '../../client/graphql.js';
 
 export interface EBPFRouteDeps {
   config: ConfigSource;
diff --git a/apps/bff/src/http/endpoint-dependency.ts 
b/apps/bff/src/http/query/endpoint-dependency.ts
similarity index 98%
rename from apps/bff/src/http/endpoint-dependency.ts
rename to apps/bff/src/http/query/endpoint-dependency.ts
index 34963e6..312d29f 100644
--- a/apps/bff/src/http/endpoint-dependency.ts
+++ b/apps/bff/src/http/query/endpoint-dependency.ts
@@ -29,8 +29,8 @@
  */
 
 import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
-import type { ConfigSource } from '../config/loader.js';
-import type { SessionStore } from '../auth/sessions.js';
+import type { ConfigSource } from '../../config/loader.js';
+import type { SessionStore } from '../../user/sessions.js';
 import type {
   EndpointDependencyCall,
   EndpointDependencyConfig,
@@ -39,9 +39,9 @@ import type {
   FetchLike,
   TopologyMetricDef,
 } from '@skywalking-horizon-ui/api-client';
-import { requireAuth } from '../auth/middleware.js';
-import {  graphqlPost, buildOapOpts } from './graphql-client.js';
-import { endpointDependencyConfigFor, getLayerTemplate } from 
'../layers/loader.js';
+import { requireAuth } from '../../user/middleware.js';
+import {  graphqlPost, buildOapOpts } from '../../client/graphql.js';
+import { endpointDependencyConfigFor, getLayerTemplate } from 
'../../logic/layers/loader.js';
 
 export interface EndpointDependencyRouteDeps {
   config: ConfigSource;
diff --git a/apps/bff/src/http/endpoint.ts b/apps/bff/src/http/query/endpoint.ts
similarity index 96%
rename from apps/bff/src/http/endpoint.ts
rename to apps/bff/src/http/query/endpoint.ts
index d910341..354c1d8 100644
--- a/apps/bff/src/http/endpoint.ts
+++ b/apps/bff/src/http/query/endpoint.ts
@@ -34,11 +34,11 @@
  */
 
 import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
-import type { ConfigSource } from '../config/loader.js';
-import type { SessionStore } from '../auth/sessions.js';
+import type { ConfigSource } from '../../config/loader.js';
+import type { SessionStore } from '../../user/sessions.js';
 import type { FetchLike } from '@skywalking-horizon-ui/api-client';
-import { requireAuth } from '../auth/middleware.js';
-import {  graphqlPost, buildOapOpts } from './graphql-client.js';
+import { requireAuth } from '../../user/middleware.js';
+import {  graphqlPost, buildOapOpts } from '../../client/graphql.js';
 
 export interface EndpointRouteDeps {
   config: ConfigSource;
diff --git a/apps/bff/src/http/info.ts b/apps/bff/src/http/query/info.ts
similarity index 92%
rename from apps/bff/src/http/info.ts
rename to apps/bff/src/http/query/info.ts
index 39941eb..dee427b 100644
--- a/apps/bff/src/http/info.ts
+++ b/apps/bff/src/http/query/info.ts
@@ -17,10 +17,10 @@
 
 import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
 import type { FetchLike } from '@skywalking-horizon-ui/api-client';
-import type { ConfigSource } from '../config/loader.js';
-import type { SessionStore } from '../auth/sessions.js';
-import { requireAuth } from '../auth/middleware.js';
-import { buildOapOpts, graphqlPost } from './graphql-client.js';
+import type { ConfigSource } from '../../config/loader.js';
+import type { SessionStore } from '../../user/sessions.js';
+import { requireAuth } from '../../user/middleware.js';
+import { buildOapOpts, graphqlPost } from '../../client/graphql.js';
 
 /**
  * One round-trip combining `version`, `getTimeInfo`, and `checkHealth`.
diff --git a/apps/bff/src/http/instance.ts b/apps/bff/src/http/query/instance.ts
similarity index 96%
rename from apps/bff/src/http/instance.ts
rename to apps/bff/src/http/query/instance.ts
index 457a93b..699429d 100644
--- a/apps/bff/src/http/instance.ts
+++ b/apps/bff/src/http/query/instance.ts
@@ -32,11 +32,11 @@
  */
 
 import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
-import type { ConfigSource } from '../config/loader.js';
-import type { SessionStore } from '../auth/sessions.js';
+import type { ConfigSource } from '../../config/loader.js';
+import type { SessionStore } from '../../user/sessions.js';
 import type { FetchLike } from '@skywalking-horizon-ui/api-client';
-import { requireAuth } from '../auth/middleware.js';
-import {  graphqlPost, buildOapOpts } from './graphql-client.js';
+import { requireAuth } from '../../user/middleware.js';
+import {  graphqlPost, buildOapOpts } from '../../client/graphql.js';
 
 export interface InstanceRouteDeps {
   config: ConfigSource;
diff --git a/apps/bff/src/http/landing.ts b/apps/bff/src/http/query/landing.ts
similarity index 98%
rename from apps/bff/src/http/landing.ts
rename to apps/bff/src/http/query/landing.ts
index d0bdb91..2f25c85 100644
--- a/apps/bff/src/http/landing.ts
+++ b/apps/bff/src/http/query/landing.ts
@@ -45,11 +45,11 @@ import type {
   LandingResponse,
   LandingServiceRow,
 } from '@skywalking-horizon-ui/api-client';
-import type { ConfigSource } from '../config/loader.js';
-import type { SessionStore } from '../auth/sessions.js';
-import { requireAuth } from '../auth/middleware.js';
-import {  graphqlPost, buildOapOpts } from './graphql-client.js';
-import { expressionForServiceMetricSeries } from './mqe-catalog.js';
+import type { ConfigSource } from '../../config/loader.js';
+import type { SessionStore } from '../../user/sessions.js';
+import { requireAuth } from '../../user/middleware.js';
+import {  graphqlPost, buildOapOpts } from '../../client/graphql.js';
+import { expressionForServiceMetricSeries } from '../../util/mqe-catalog.js';
 
 export interface LandingRouteDeps {
   config: ConfigSource;
diff --git a/apps/bff/src/http/log.ts b/apps/bff/src/http/query/log.ts
similarity index 98%
rename from apps/bff/src/http/log.ts
rename to apps/bff/src/http/query/log.ts
index 9813628..25c82e1 100644
--- a/apps/bff/src/http/log.ts
+++ b/apps/bff/src/http/query/log.ts
@@ -38,10 +38,10 @@ import type {
   LogRow,
   LogsResponse,
 } from '@skywalking-horizon-ui/api-client';
-import type { ConfigSource } from '../config/loader.js';
-import type { SessionStore } from '../auth/sessions.js';
-import { requireAuth } from '../auth/middleware.js';
-import {  graphqlPost, buildOapOpts, type GraphqlOptions } from 
'./graphql-client.js';
+import type { ConfigSource } from '../../config/loader.js';
+import type { SessionStore } from '../../user/sessions.js';
+import { requireAuth } from '../../user/middleware.js';
+import {  graphqlPost, buildOapOpts, type GraphqlOptions } from 
'../../client/graphql.js';
 
 export interface LogRouteDeps {
   config: ConfigSource;
diff --git a/apps/bff/src/http/menu.ts b/apps/bff/src/http/query/menu.ts
similarity index 97%
rename from apps/bff/src/http/menu.ts
rename to apps/bff/src/http/query/menu.ts
index 25b8e48..d75486c 100644
--- a/apps/bff/src/http/menu.ts
+++ b/apps/bff/src/http/query/menu.ts
@@ -23,11 +23,11 @@ import type {
   LayerSlots,
   MenuResponse,
 } from '@skywalking-horizon-ui/api-client';
-import type { ConfigSource } from '../config/loader.js';
-import type { SessionStore } from '../auth/sessions.js';
-import { requireAuth } from '../auth/middleware.js';
-import { buildOapOpts, graphqlPost, type GraphqlOptions } from 
'./graphql-client.js';
-import { getLayerTemplate, type LayerComponentFlags } from 
'../layers/loader.js';
+import type { ConfigSource } from '../../config/loader.js';
+import type { SessionStore } from '../../user/sessions.js';
+import { requireAuth } from '../../user/middleware.js';
+import { buildOapOpts, graphqlPost, type GraphqlOptions } from 
'../../client/graphql.js';
+import { getLayerTemplate, type LayerComponentFlags } from 
'../../logic/layers/loader.js';
 
 /**
  * Map the JSON config's `components.*` flags onto the wire `caps`
@@ -253,7 +253,7 @@ async function fetchCountsForLayers(
 }
 
 // Local re-import to avoid a circular dep — graphqlPost is in the same dir.
-import { graphqlPost as graphqlPostShim } from './graphql-client.js';
+import { graphqlPost as graphqlPostShim } from '../../client/graphql.js';
 
 export function registerMenuRoute(app: FastifyInstance, deps: MenuRouteDeps): 
void {
   const auth = requireAuth(deps);
diff --git a/apps/bff/src/http/preflight.ts 
b/apps/bff/src/http/query/preflight.ts
similarity index 87%
rename from apps/bff/src/http/preflight.ts
rename to apps/bff/src/http/query/preflight.ts
index 960bbc2..99a2902 100644
--- a/apps/bff/src/http/preflight.ts
+++ b/apps/bff/src/http/query/preflight.ts
@@ -17,10 +17,10 @@
 
 import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
 import type { FetchLike } from '@skywalking-horizon-ui/api-client';
-import type { ConfigSource } from '../config/loader.js';
-import { requireAuth } from '../auth/middleware.js';
-import type { SessionStore } from '../auth/sessions.js';
-import { runPreflight } from './preflight.js';
+import type { ConfigSource } from '../../config/loader.js';
+import { requireAuth } from '../../user/middleware.js';
+import type { SessionStore } from '../../user/sessions.js';
+import { runPreflight } from '../../logic/preflight/preflight.js';
 
 export interface PreflightRouteDeps {
   config: ConfigSource;
diff --git a/apps/bff/src/http/profile.ts b/apps/bff/src/http/query/profile.ts
similarity index 97%
rename from apps/bff/src/http/profile.ts
rename to apps/bff/src/http/query/profile.ts
index 669afcd..0c88789 100644
--- a/apps/bff/src/http/profile.ts
+++ b/apps/bff/src/http/query/profile.ts
@@ -44,10 +44,10 @@ import type {
   ProfileTaskListResponse,
   ProfileTaskLogsResponse,
 } from '@skywalking-horizon-ui/api-client';
-import type { ConfigSource } from '../config/loader.js';
-import type { SessionStore } from '../auth/sessions.js';
-import { requireAuth } from '../auth/middleware.js';
-import { graphqlPost, buildOapOpts } from './graphql-client.js';
+import type { ConfigSource } from '../../config/loader.js';
+import type { SessionStore } from '../../user/sessions.js';
+import { requireAuth } from '../../user/middleware.js';
+import { graphqlPost, buildOapOpts } from '../../client/graphql.js';
 
 export interface ProfileRouteDeps {
   config: ConfigSource;
diff --git a/apps/bff/src/http/topology.ts b/apps/bff/src/http/query/topology.ts
similarity index 98%
rename from apps/bff/src/http/topology.ts
rename to apps/bff/src/http/query/topology.ts
index 19de95d..7309b53 100644
--- a/apps/bff/src/http/topology.ts
+++ b/apps/bff/src/http/query/topology.ts
@@ -34,8 +34,8 @@
  */
 
 import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
-import type { ConfigSource } from '../config/loader.js';
-import type { SessionStore } from '../auth/sessions.js';
+import type { ConfigSource } from '../../config/loader.js';
+import type { SessionStore } from '../../user/sessions.js';
 import type {
   FetchLike,
   TopologyCall,
@@ -44,9 +44,9 @@ import type {
   TopologyNode,
   TopologyResponse,
 } from '@skywalking-horizon-ui/api-client';
-import { requireAuth } from '../auth/middleware.js';
-import {  graphqlPost, buildOapOpts } from './graphql-client.js';
-import { getLayerTemplate, topologyConfigFor } from '../layers/loader.js';
+import { requireAuth } from '../../user/middleware.js';
+import {  graphqlPost, buildOapOpts } from '../../client/graphql.js';
+import { getLayerTemplate, topologyConfigFor } from 
'../../logic/layers/loader.js';
 
 export interface TopologyRouteDeps {
   config: ConfigSource;
diff --git a/apps/bff/src/http/trace-tag.ts 
b/apps/bff/src/http/query/trace-tag.ts
similarity index 95%
rename from apps/bff/src/http/trace-tag.ts
rename to apps/bff/src/http/query/trace-tag.ts
index 2001c00..7acb00d 100644
--- a/apps/bff/src/http/trace-tag.ts
+++ b/apps/bff/src/http/query/trace-tag.ts
@@ -33,10 +33,10 @@
 
 import type { FetchLike } from '@skywalking-horizon-ui/api-client';
 import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
-import type { ConfigSource } from '../config/loader.js';
-import type { SessionStore } from '../auth/sessions.js';
-import { requireAuth } from '../auth/middleware.js';
-import { buildOapOpts, graphqlPost } from './graphql-client.js';
+import type { ConfigSource } from '../../config/loader.js';
+import type { SessionStore } from '../../user/sessions.js';
+import { requireAuth } from '../../user/middleware.js';
+import { buildOapOpts, graphqlPost } from '../../client/graphql.js';
 
 export interface TraceTagRouteDeps {
   config: ConfigSource;
diff --git a/apps/bff/src/http/trace.ts b/apps/bff/src/http/query/trace.ts
similarity index 97%
rename from apps/bff/src/http/trace.ts
rename to apps/bff/src/http/query/trace.ts
index 709145c..ab42c75 100644
--- a/apps/bff/src/http/trace.ts
+++ b/apps/bff/src/http/query/trace.ts
@@ -47,13 +47,13 @@ import type {
   ZipkinTraceDetailResponse,
   ZipkinTraceListResponse,
 } from '@skywalking-horizon-ui/api-client';
-import type { ConfigSource } from '../config/loader.js';
-import type { SessionStore } from '../auth/sessions.js';
-import { requireAuth } from '../auth/middleware.js';
-import {  graphqlPost, buildOapOpts, type GraphqlOptions } from 
'./graphql-client.js';
-import { getLayerTemplate, tracesConfigFor } from '../layers/loader.js';
-import { detectTraceProtocol } from './trace-protocol-cache.js';
-import { zipkinFetchTraces, zipkinFetchTraceById, summariseZipkinTrace } from 
'./zipkin-client.js';
+import type { ConfigSource } from '../../config/loader.js';
+import type { SessionStore } from '../../user/sessions.js';
+import { requireAuth } from '../../user/middleware.js';
+import {  graphqlPost, buildOapOpts, type GraphqlOptions } from 
'../../client/graphql.js';
+import { getLayerTemplate, tracesConfigFor } from 
'../../logic/layers/loader.js';
+import { detectTraceProtocol } from '../../util/trace-protocol-cache.js';
+import { zipkinFetchTraces, zipkinFetchTraceById, summariseZipkinTrace } from 
'../../client/zipkin.js';
 
 export interface TraceRouteDeps {
   config: ConfigSource;
diff --git a/apps/bff/src/http/zipkin.ts b/apps/bff/src/http/query/zipkin.ts
similarity index 98%
rename from apps/bff/src/http/zipkin.ts
rename to apps/bff/src/http/query/zipkin.ts
index ab06a72..f03f287 100644
--- a/apps/bff/src/http/zipkin.ts
+++ b/apps/bff/src/http/query/zipkin.ts
@@ -47,10 +47,10 @@ import type {
   ZipkinTraceDetailResponse,
 } from '@skywalking-horizon-ui/api-client';
 import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
-import type { ConfigSource } from '../config/loader.js';
-import type { SessionStore } from '../auth/sessions.js';
-import { requireAuth } from '../auth/middleware.js';
-import { basicAuthHeader } from './graphql-client.js';
+import type { ConfigSource } from '../../config/loader.js';
+import type { SessionStore } from '../../user/sessions.js';
+import { requireAuth } from '../../user/middleware.js';
+import { basicAuthHeader } from '../../client/graphql.js';
 
 export interface ZipkinRouteDeps {
   config: ConfigSource;
diff --git a/apps/bff/src/http/user.ts b/apps/bff/src/http/user.ts
index 2fdf0ff..05db26f 100644
--- a/apps/bff/src/http/user.ts
+++ b/apps/bff/src/http/user.ts
@@ -21,8 +21,8 @@ import type { AuditLogger } from '../audit/logger.js';
 import { badRequest, unauthorized } from '../errors.js';
 import type { ConfigSource } from '../config/loader.js';
 import { resolveVerbsForRoles } from '../rbac/verbs.js';
-import { verifyLocalCredentials } from './local.js';
-import type { SessionStore } from './sessions.js';
+import { verifyLocalCredentials } from '../user/local.js';
+import type { SessionStore } from '../user/sessions.js';
 
 const loginBodySchema = z.object({
   username: z.string().min(1),
diff --git a/apps/bff/src/logic/alarms/service-layer-map.ts 
b/apps/bff/src/logic/alarms/service-layer-map.ts
index f4792b6..d9b7189 100644
--- a/apps/bff/src/logic/alarms/service-layer-map.ts
+++ b/apps/bff/src/logic/alarms/service-layer-map.ts
@@ -26,10 +26,10 @@
  * a `listServices` fan-out on every alarms poll.
  */
 
-import { buildOapOpts, graphqlPost } from '../oap/graphql-client.js';
-import type { ConfigSource } from '../config/loader.js';
+import { buildOapOpts, graphqlPost } from '../../client/graphql.js';
+import type { ConfigSource } from '../../config/loader.js';
 import type { FetchLike } from '@skywalking-horizon-ui/api-client';
-import { logger } from '../logger.js';
+import { logger } from '../../logger.js';
 
 interface ServiceLayerMapDeps {
   config: ConfigSource;
diff --git a/apps/bff/src/logic/alarms/store.ts 
b/apps/bff/src/logic/alarms/store.ts
index 1534da3..4ce091d 100644
--- a/apps/bff/src/logic/alarms/store.ts
+++ b/apps/bff/src/logic/alarms/store.ts
@@ -32,7 +32,7 @@
 import { existsSync } from 'node:fs';
 import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
 import { dirname, resolve } from 'node:path';
-import { logger } from '../logger.js';
+import { logger } from '../../logger.js';
 
 /** One background-traffic series the alarms timeline renders. */
 export interface AlarmTrafficLayer {
diff --git a/apps/bff/src/logic/inspect/exec.ts 
b/apps/bff/src/logic/inspect/exec.ts
index 9e34dc3..66b5eab 100644
--- a/apps/bff/src/logic/inspect/exec.ts
+++ b/apps/bff/src/logic/inspect/exec.ts
@@ -34,7 +34,7 @@
 import type { FastifyReply } from 'fastify';
 import type { FetchLike, ExpressionResult, MqeEntity } from 
'@skywalking-horizon-ui/api-client';
 import { INSPECT_STEPS, isInspectDate, type InspectStep } from 
'@skywalking-horizon-ui/api-client';
-import type { MqeTarget } from './mqe-target.js';
+import type { MqeTarget } from '../../util/mqe-target.js';
 
 interface DurationInput {
   start: string;
diff --git a/apps/bff/src/logic/preflight/preflight.ts 
b/apps/bff/src/logic/preflight/preflight.ts
index d10623c..1bc9f72 100644
--- a/apps/bff/src/logic/preflight/preflight.ts
+++ b/apps/bff/src/logic/preflight/preflight.ts
@@ -36,7 +36,7 @@ import type {
   PreflightModule,
   PreflightResult,
 } from '@skywalking-horizon-ui/api-client';
-import type { HorizonConfig } from '../config/schema.js';
+import type { HorizonConfig } from '../../config/schema.js';
 
 export type { PreflightModule, PreflightResult };
 
diff --git a/apps/bff/src/logic/setup/store.ts 
b/apps/bff/src/logic/setup/store.ts
index 1bf20b1..fbabdb5 100644
--- a/apps/bff/src/logic/setup/store.ts
+++ b/apps/bff/src/logic/setup/store.ts
@@ -19,7 +19,7 @@ import { existsSync } from 'node:fs';
 import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
 import { dirname, resolve } from 'node:path';
 import type { LayerConfig } from '@skywalking-horizon-ui/api-client';
-import { logger } from '../logger.js';
+import { logger } from '../../logger.js';
 
 /**
  * File-backed store for per-layer setup overrides.
diff --git a/apps/bff/src/rbac/policy.ts b/apps/bff/src/rbac/policy.ts
index 86c5f5d..7062902 100644
--- a/apps/bff/src/rbac/policy.ts
+++ b/apps/bff/src/rbac/policy.ts
@@ -17,8 +17,8 @@
 
 import type { FastifyReply, FastifyRequest } from 'fastify';
 import { forbidden, unauthorized } from '../errors.js';
-import type { Session } from '../auth/sessions.js';
-import type { SessionStore } from '../auth/sessions.js';
+import type { Session } from '../user/sessions.js';
+import type { SessionStore } from '../user/sessions.js';
 import type { ConfigSource } from '../config/loader.js';
 import type { HorizonConfig } from '../config/schema.js';
 import { hasVerb, resolveVerbsForRoles, type Verb } from './verbs.js';
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index 3e9c50c..c97394d 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -21,33 +21,46 @@ import Fastify from 'fastify';
 import cookie from '@fastify/cookie';
 import fastifyStatic from '@fastify/static';
 import { AuditLogger } from './audit/logger.js';
-import { registerAuthRoutes } from './auth/routes.js';
-import { SessionStore } from './auth/sessions.js';
+import { SessionStore } from './user/sessions.js';
 import { loadConfig, type ConfigSource } from './config/loader.js';
-import { registerDashboardRoute } from './dashboard/routes.js';
-import { registerEndpointRoute } from './oap/endpoint-routes.js';
-import { registerEndpointDependencyRoute } from 
'./oap/endpoint-dependency-routes.js';
-import { registerOapInfoRoute } from './oap/info-routes.js';
-import { registerInstanceRoute } from './oap/instance-routes.js';
-import { registerLandingRoute } from './oap/landing-routes.js';
-import { registerLogRoute } from './oap/log-routes.js';
-import { registerMenuRoute } from './oap/menu-routes.js';
-import { registerOapRoutes } from './oap/routes.js';
-import { registerPreflightRoutes } from './oap/preflight-routes.js';
-import { registerDebugRoutes } from './oap/debug-routes.js';
-import { registerInspectRoutes } from './oap/inspect-routes.js';
-import { registerAlarmsRoutes } from './alarms/routes.js';
-import { AlarmsStore } from './alarms/store.js';
-import { registerProfileRoutes } from './oap/profile-routes.js';
-import { registerEBPFRoutes } from './oap/ebpf-routes.js';
-import { registerAsyncProfileRoutes } from './oap/async-profile-routes.js';
-import { registerTopologyRoute } from './oap/topology-routes.js';
-import { registerTraceRoutes } from './oap/trace-routes.js';
-import { registerTraceTagRoutes } from './oap/trace-tag-routes.js';
-import { registerZipkinRoutes } from './oap/zipkin-routes.js';
-import { registerOverviewRoutes } from './overview/routes.js';
-import { registerSetupRoutes } from './setup/routes.js';
-import { SetupStore } from './setup/store.js';
+// User
+import { registerAuthRoutes } from './http/user.js';
+// Query (read-only data from OAP)
+import { registerOapInfoRoute } from './http/query/info.js';
+import { registerMenuRoute } from './http/query/menu.js';
+import { registerLandingRoute } from './http/query/landing.js';
+import { registerInstanceRoute } from './http/query/instance.js';
+import { registerEndpointRoute } from './http/query/endpoint.js';
+import { registerTopologyRoute } from './http/query/topology.js';
+import { registerEndpointDependencyRoute } from 
'./http/query/endpoint-dependency.js';
+import { registerTraceRoutes } from './http/query/trace.js';
+import { registerTraceTagRoutes } from './http/query/trace-tag.js';
+import { registerZipkinRoutes } from './http/query/zipkin.js';
+import { registerLogRoute } from './http/query/log.js';
+import { registerDashboardQueryRoute } from './http/query/dashboard.js';
+import { registerAlarmsQueryRoutes } from './http/query/alarms.js';
+import { registerPreflightRoutes } from './http/query/preflight.js';
+import { registerProfileRoutes } from './http/query/profile.js';
+import { registerEBPFRoutes } from './http/query/ebpf.js';
+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 { registerAlarmsConfigRoutes } from './http/config/alarms.js';
+import { registerSetupRoutes } from './http/config/setup.js';
+import { registerOverviewRoutes } from './http/config/overview.js';
+// Admin (operational tools)
+import { registerDslCatalogRoutes } from './http/admin/dsl/catalog.js';
+import { registerDslRuleRoutes } from './http/admin/dsl/rule.js';
+import { registerDslDumpRoutes } from './http/admin/dsl/dump.js';
+import { registerDslOalRoutes } from './http/admin/dsl/oal.js';
+import { registerClusterRoutes } from './http/admin/cluster.js';
+import { registerDebugRoutes } from './http/admin/live-debug.js';
+import { registerInspectRoutes } from './http/admin/inspect.js';
+// Logic / stores
+import { AlarmsStore } from './logic/alarms/store.js';
+import { SetupStore } from './logic/setup/store.js';
+import { ServiceLayerMap } from './logic/alarms/service-layer-map.js';
 import { HttpError } from './errors.js';
 import { logger, loggerOptions } from './logger.js';
 
@@ -75,13 +88,18 @@ const setupStore = new 
SetupStore(source.current.setup.file);
 await setupStore.load();
 const alarmsStore = new AlarmsStore(source.current.alarms.file);
 await alarmsStore.load();
+// Shared between alarms query (read) + alarms config (write+invalidate).
+const serviceLayer = new ServiceLayerMap({ config: source });
 
 await app.register(cookie);
 
 // Text/plain body parser — the rule editor sends raw YAML to /api/rule.
 app.addContentTypeParser('text/plain', { parseAs: 'string' }, (_req, body, 
done) => done(null, body));
 
+// ── User ───────────────────────────────────────────────────────────
 registerAuthRoutes(app, source, sessions, audit);
+
+// ── Query ──────────────────────────────────────────────────────────
 registerOapInfoRoute(app, { config: source, sessions });
 registerMenuRoute(app, { config: source, sessions });
 registerLandingRoute(app, { config: source, sessions });
@@ -93,23 +111,29 @@ registerTraceRoutes(app, { config: source, sessions });
 registerTraceTagRoutes(app, { config: source, sessions });
 registerZipkinRoutes(app, { config: source, sessions });
 registerLogRoute(app, { config: source, sessions });
-registerOverviewRoutes(app, { config: source, sessions });
-registerDashboardRoute(app, { config: source, sessions });
-registerSetupRoutes(app, { config: source, sessions, audit, store: setupStore 
});
-registerOapRoutes(app, { config: source, sessions, audit });
+registerDashboardQueryRoute(app, { config: source, sessions });
+registerAlarmsQueryRoutes(app, { config: source, sessions, serviceLayer, 
store: alarmsStore });
 registerPreflightRoutes(app, { config: source, sessions });
-// Live debugger — `/api/debug/*` proxies for SWIP-13's
-// `/dsl-debugging/*` wire (start / poll / stop session, list active
-// sessions, per-node status fan-out).
-registerDebugRoutes(app, { config: source, sessions, audit });
-// Inspect — OAP metric catalog browse + MQE ad-hoc execution.
-registerInspectRoutes(app, { config: source, sessions, audit });
-// Alarms — getAlarm proxy + traffic-background fan-out + config CRUD.
-registerAlarmsRoutes(app, { config: source, sessions, audit, store: 
alarmsStore });
 registerProfileRoutes(app, { config: source, sessions });
 registerEBPFRoutes(app, { config: source, sessions });
 registerAsyncProfileRoutes(app, { config: source, sessions });
 
+// ── Config ─────────────────────────────────────────────────────────
+registerDashboardConfigRoute(app, { config: source, sessions });
+registerLayerTemplateRoutes(app, { config: source, sessions });
+registerAlarmsConfigRoutes(app, { config: source, sessions, audit, store: 
alarmsStore, serviceLayer });
+registerSetupRoutes(app, { config: source, sessions, audit, store: setupStore 
});
+registerOverviewRoutes(app, { config: source, sessions });
+
+// ── Admin ──────────────────────────────────────────────────────────
+registerDslCatalogRoutes(app, { config: source, sessions, audit });
+registerDslRuleRoutes(app, { config: source, sessions, audit });
+registerDslDumpRoutes(app, { config: source, sessions, audit });
+registerDslOalRoutes(app, { config: source, sessions, audit });
+registerClusterRoutes(app, { config: source, sessions, audit });
+registerDebugRoutes(app, { config: source, sessions, audit });
+registerInspectRoutes(app, { config: source, sessions, audit });
+
 // 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
diff --git a/apps/bff/src/util/trace-protocol-cache.ts 
b/apps/bff/src/util/trace-protocol-cache.ts
index b94398e..e7f93a7 100644
--- a/apps/bff/src/util/trace-protocol-cache.ts
+++ b/apps/bff/src/util/trace-protocol-cache.ts
@@ -32,7 +32,7 @@
  * again.
  */
 
-import { graphqlPost } from './graphql-client.js';
+import { graphqlPost } from '../client/graphql.js';
 import type { FetchLike } from '@skywalking-horizon-ui/api-client';
 
 export type TraceProtocol = 'v2' | 'v3';
diff --git a/apps/ui/src/views/auth/LoginView.vue 
b/apps/ui/src/views/auth/LoginView.vue
index b427fd3..dca2f21 100644
--- a/apps/ui/src/views/auth/LoginView.vue
+++ b/apps/ui/src/views/auth/LoginView.vue
@@ -84,10 +84,6 @@ async function submit(): Promise<void> {
       <button class="sw-btn is-primary submit" type="submit" 
:disabled="submitting">
         {{ submitting ? 'Signing in…' : 'Sign in' }}
       </button>
-
-      <div class="foot">
-        Local + LDAP auth. OIDC and SSO are out of scope for v1.
-      </div>
     </form>
   </div>
 </template>
@@ -181,11 +177,4 @@ async function submit(): Promise<void> {
   opacity: 0.6;
   cursor: not-allowed;
 }
-.foot {
-  margin-top: 18px;
-  font-size: 11px;
-  color: var(--sw-fg-3);
-  text-align: center;
-  line-height: 1.5;
-}
 </style>

Reply via email to