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 d2166a2  tests: add 107 UTs covering entity-scope construction + 
routing decisions
d2166a2 is described below

commit d2166a2daccd9fa82fa84bf9a4a86efe0383bb08
Author: Wu Sheng <[email protected]>
AuthorDate: Sun May 17 07:36:00 2026 +0800

    tests: add 107 UTs covering entity-scope construction + routing decisions
    
    Focus is the entity / scope plumbing where the recent manual-review
    bugs surfaced (mesh_dp/instance, Top 20 APIs layerScope leak, loader
    path break, sidebar tab routing). All tests are pure-logic — no
    component mounting, no router/queryClient mocking.
    
    UI (67 tests, 5 files):
      utils/serviceName.test.ts          17  parser + identity resolver
                                               (legacy `::` group, cluster
                                               rule, partial / invalid
                                               regex, stacking).
      shell/useLayers.test.ts            14  firstLayerTab decision tree
                                               — dashboards/instance/
                                               endpoint/topology/dep/
                                               trace/logs/profiling order;
                                               mesh_dp shape (instances-
                                               only); explicit false beats
                                               truthy slot.
      shell/icons.test.ts                15  layerIcon + sectionIcon
                                               taxonomy; extracted from
                                               AppSidebar.vue into a
                                               testable module.
      api/scopes/layer.test.ts           14  URL construction for landing,
                                               dashboard, dashboardConfig,
                                               endpoints, instances,
                                               topology, endpointDependency
                                               — verifies `::` encoding,
                                               mockTop passthrough, body
                                               shape.
      api/scopes/alarms.test.ts           7  alarms list / traffic /
                                               services / config endpoints.
    
    BFF (40 tests, 2 files):
      http/query/dashboard.test.ts       28  buildFragment entity scope:
                                                Service / ServiceInstance /
                                                Endpoint / `{scope: All}`
                                                (layerScope), precedence
                                                (instance > endpoint),
                                                layerScope:true wins over
                                                both, special-char encoding
                                                (::, /, "). Plus
                                                parseSeries / avgOf /
                                                parseLabeledSeries /
                                                parseTopList — owner-scope
                                                display priority, sparse
                                                series, error / empty cases.
      logic/layers/loader.test.ts        12  widgetsForScope fallback
                                                chain (scope -> service ->
                                                template.widgets -> []);
                                                mesh_dp instance-only shape
                                                CANNOT leak instance widgets
                                                into a service request.
                                                Plus topology / endpoint-
                                                dependency / traces config
                                                override + defaults.
    
    Required exports + one refactor:
      - buildFragment / parseSeries / avgOf / parseLabeledSeries /
        parseTopList / Window + MqeResultShape are now exported from
        http/query/dashboard.ts (`@internal` hint via JSDoc).
      - layerIcon / sectionIcon extracted from AppSidebar.vue to
        shell/icons.ts so they can be imported by tests.
    
    Also rolled in two layout fixes from manual review:
      mesh/service dashboard:
        - drop the now-redundant top_endpoints_in_service (the restored
          top_apis covers the same metric)
        - top_instances bumped to span 9 / rowSpan 2 so it lines up with
          sidecar_internal_latency (span 3 / rowSpan 2) instead of leaving
          an empty right-edge column + a mismatched-height seam.
    
    Run: pnpm -r test:unit (vitest scripts already wired up).
---
 apps/bff/src/bundled_templates/layers/mesh.json |  27 +-
 apps/bff/src/http/query/dashboard.test.ts       | 318 ++++++++++++++++++++++++
 apps/bff/src/http/query/dashboard.ts            |  28 ++-
 apps/bff/src/logic/layers/loader.test.ts        | 131 ++++++++++
 apps/ui/src/api/scopes/alarms.test.ts           |  99 ++++++++
 apps/ui/src/api/scopes/layer.test.ts            | 198 +++++++++++++++
 apps/ui/src/shell/AppSidebar.vue                |  47 +---
 apps/ui/src/shell/icons.test.ts                 | 130 ++++++++++
 apps/ui/src/shell/icons.ts                      |  79 ++++++
 apps/ui/src/shell/useLayers.test.ts             | 112 +++++++++
 apps/ui/src/utils/serviceName.test.ts           | 204 +++++++++++++++
 11 files changed, 1293 insertions(+), 80 deletions(-)

diff --git a/apps/bff/src/bundled_templates/layers/mesh.json 
b/apps/bff/src/bundled_templates/layers/mesh.json
index 296c889..5ed2463 100644
--- a/apps/bff/src/bundled_templates/layers/mesh.json
+++ b/apps/bff/src/bundled_templates/layers/mesh.json
@@ -233,31 +233,8 @@
           "ms",
           "%"
         ],
-        "span": 6,
-        "rowSpan": 3
-      },
-      {
-        "id": "top_endpoints_in_service",
-        "title": "Top 10 endpoints in service",
-        "tip": "Ranked across the selected service's endpoints.",
-        "type": "top",
-        "expressions": [
-          "top_n(endpoint_cpm,10,des)",
-          "top_n(endpoint_resp_time,10,des)",
-          "top_n(endpoint_sla,10,asc)/100"
-        ],
-        "expressionLabels": [
-          "Traffic",
-          "Slow",
-          "Successful Rate"
-        ],
-        "expressionUnits": [
-          "rpm",
-          "ms",
-          "%"
-        ],
-        "span": 6,
-        "rowSpan": 3
+        "span": 9,
+        "rowSpan": 2
       }
     ],
     "instance": [
diff --git a/apps/bff/src/http/query/dashboard.test.ts 
b/apps/bff/src/http/query/dashboard.test.ts
new file mode 100644
index 0000000..4aed812
--- /dev/null
+++ b/apps/bff/src/http/query/dashboard.test.ts
@@ -0,0 +1,318 @@
+/*
+ * 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.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+  buildFragment,
+  parseSeries,
+  avgOf,
+  parseLabeledSeries,
+  parseTopList,
+  type Window,
+  type MqeResultShape,
+} from './dashboard.js';
+
+const W: Window = { start: '2026-05-17 1000', end: '2026-05-17 1100' };
+
+/** Extract just the `entity: { ... }` literal from a built fragment.
+ *  Lets assertions target the actual entity-construction logic without
+ *  matching against the trailing GraphQL result-shape block (which
+ *  always names `serviceName / serviceInstanceName / endpointName` as
+ *  field selectors regardless of the entity scope). */
+function entityOf(fragment: string): string {
+  const m = fragment.match(/entity:\s*(\{[^}]*\})/);
+  if (!m) throw new Error(`no entity literal in fragment:\n${fragment}`);
+  return m[1];
+}
+
+describe('buildFragment — entity scope construction', () => {
+  it('default → Service scope with serviceName + normal flag', () => {
+    const entity = entityOf(buildFragment('w0', 'service_cpm', 'frontend', 
true, W));
+    expect(entity).toContain('scope: Service');
+    expect(entity).toContain('serviceName: "frontend"');
+    expect(entity).toContain('normal: true');
+    expect(entity).not.toContain('serviceInstanceName');
+    expect(entity).not.toContain('endpointName');
+  });
+
+  it('default → serializes normal: false when normal flag is false (VIRTUAL_*, 
AWS_*)', () => {
+    const entity = entityOf(buildFragment('w0', 'service_cpm', 'mq-broker', 
false, W));
+    expect(entity).toContain('normal: false');
+  });
+
+  it('serviceInstanceName set → ServiceInstance scope, carries instance name', 
() => {
+    const entity = entityOf(
+      buildFragment('w0', 'jvm_cpu', 'frontend', true, W, {
+        serviceInstanceName: 'frontend-pod-1',
+      }),
+    );
+    expect(entity).toContain('scope: ServiceInstance');
+    expect(entity).toContain('serviceName: "frontend"');
+    expect(entity).toContain('serviceInstanceName: "frontend-pod-1"');
+    expect(entity).not.toContain('endpointName');
+  });
+
+  it('endpointName set → Endpoint scope', () => {
+    const entity = entityOf(
+      buildFragment('w0', 'endpoint_cpm', 'frontend', true, W, {
+        endpointName: '/api/order',
+      }),
+    );
+    expect(entity).toContain('scope: Endpoint');
+    expect(entity).toContain('endpointName: "/api/order"');
+    expect(entity).not.toContain('serviceInstanceName');
+  });
+
+  it('layerScope:true → {scope: All}, no serviceName / normal / instance / 
endpoint in entity', () => {
+    const entity = entityOf(
+      buildFragment('w0', 'top_n(endpoint_cpm,20,des)', 'frontend', true, W, {
+        layerScope: true,
+      }),
+    );
+    expect(entity).toContain('scope: All');
+    expect(entity).not.toContain('serviceName');
+    expect(entity).not.toContain('serviceInstanceName');
+    expect(entity).not.toContain('endpointName');
+    expect(entity).not.toContain('normal:');
+  });
+
+  it('layerScope:true wins over both serviceInstanceName AND endpointName', () 
=> {
+    // Defensive: if a caller sets layerScope:true AND instance/endpoint,
+    // we should still produce All scope (layerScope is the explicit
+    // opt-out from per-entity filtering).
+    const entity = entityOf(
+      buildFragment('w0', 'endpoint_cpm', 'frontend', true, W, {
+        layerScope: true,
+        serviceInstanceName: 'should-be-ignored',
+        endpointName: '/should/be/ignored',
+      }),
+    );
+    expect(entity).toContain('scope: All');
+    expect(entity).not.toContain('should-be-ignored');
+    expect(entity).not.toContain('/should/be/ignored');
+  });
+
+  it('serviceInstanceName takes precedence over endpointName when both set', 
() => {
+    const entity = entityOf(
+      buildFragment('w0', 'm', 'svc', true, W, {
+        serviceInstanceName: 'inst',
+        endpointName: '/ep',
+      }),
+    );
+    expect(entity).toContain('scope: ServiceInstance');
+    expect(entity).not.toContain('scope: Endpoint');
+    expect(entity).not.toContain('/ep');
+  });
+
+  it('JSON-stringifies values containing OAP special characters (::, /, 
quotes)', () => {
+    const entity = entityOf(
+      buildFragment('w0', 'm', 'mesh-svr::reviews', true, W, {
+        endpointName: '/api/"order"',
+      }),
+    );
+    expect(entity).toContain('serviceName: "mesh-svr::reviews"');
+    expect(entity).toContain('endpointName: "/api/\\"order\\""');
+  });
+
+  it('alias + expression + duration window land in the output', () => {
+    const frag = buildFragment('w7', 'service_cpm', 'frontend', true, {
+      start: '2026-05-17 1000',
+      end: '2026-05-17 1100',
+    });
+    expect(frag.trimStart().startsWith('w7: execExpression(')).toBe(true);
+    expect(frag).toContain('expression: "service_cpm"');
+    expect(frag).toContain('start: "2026-05-17 1000"');
+    expect(frag).toContain('end: "2026-05-17 1100"');
+    expect(frag).toContain('step: MINUTE');
+  });
+
+  it('result block requests metric.labels + owner fields (TopList / relabels 
support)', () => {
+    const frag = buildFragment('w0', 'm', 'svc', true, W);
+    expect(frag).toContain('metric { labels { key value } }');
+    expect(frag).toContain('owner { scope serviceName serviceInstanceName 
endpointName }');
+  });
+});
+
+describe('parseSeries — single-series numeric extraction', () => {
+  it('returns null for an undefined / errored result', () => {
+    expect(parseSeries(undefined)).toBeNull();
+    expect(parseSeries({ type: 'TIME_SERIES_VALUES', error: 'boom' 
})).toBeNull();
+  });
+
+  it('returns null when results / values are missing or empty', () => {
+    expect(parseSeries({ type: 'TIME_SERIES_VALUES' })).toBeNull();
+    expect(parseSeries({ type: 'TIME_SERIES_VALUES', results: [] 
})).toBeNull();
+    expect(
+      parseSeries({ type: 'TIME_SERIES_VALUES', results: [{ values: [] }] }),
+    ).toBeNull();
+  });
+
+  it('maps string values to numbers, preserves null gaps', () => {
+    const r: MqeResultShape = {
+      type: 'TIME_SERIES_VALUES',
+      results: [
+        {
+          values: [
+            { value: '10' },
+            { value: null },
+            { value: '3.14' },
+            { value: undefined },
+            { value: 'not-a-number' },
+          ],
+        },
+      ],
+    };
+    expect(parseSeries(r)).toEqual([10, null, 3.14, null, null]);
+  });
+});
+
+describe('avgOf — mean over a possibly-sparse series', () => {
+  it('returns null on null input', () => {
+    expect(avgOf(null)).toBeNull();
+  });
+  it('returns null on a fully-null series', () => {
+    expect(avgOf([null, null, null])).toBeNull();
+  });
+  it('averages only the non-null values', () => {
+    expect(avgOf([10, null, 20, null, 30])).toBe(20);
+  });
+  it('handles a single value', () => {
+    expect(avgOf([42])).toBe(42);
+  });
+});
+
+describe('parseLabeledSeries — relabels() multi-result extraction', () => {
+  it('falls back to the supplied label when metric.labels is empty', () => {
+    const r: MqeResultShape = {
+      type: 'TIME_SERIES_VALUES',
+      results: [{ values: [{ value: '1' }, { value: '2' }] }],
+    };
+    expect(parseLabeledSeries(r, 'service_cpm')).toEqual([
+      { label: 'service_cpm', data: [1, 2] },
+    ]);
+  });
+
+  it('reads the LAST label.value when metric.labels is set (most-derived 
label, e.g. percentile=99)', () => {
+    const r: MqeResultShape = {
+      type: 'TIME_SERIES_VALUES',
+      results: [
+        {
+          metric: {
+            labels: [
+              { key: 'p', value: '99' },
+              { key: 'percentile', value: '99' },
+            ],
+          },
+          values: [{ value: '12' }],
+        },
+      ],
+    };
+    expect(parseLabeledSeries(r, 'fallback')).toEqual([{ label: '99', data: 
[12] }]);
+  });
+
+  it('returns null on error / empty / no values', () => {
+    expect(parseLabeledSeries(undefined, 'x')).toBeNull();
+    expect(parseLabeledSeries({ type: 'X', error: 'boom' }, 'x')).toBeNull();
+    expect(parseLabeledSeries({ type: 'X', results: [{ values: [] }] }, 
'x')).toBeNull();
+  });
+});
+
+describe('parseTopList — owner-scope priority for display names', () => {
+  it('endpoint owner → "service · endpoint"', () => {
+    const r: MqeResultShape = {
+      type: 'SORTED_LIST',
+      results: [
+        {
+          values: [
+            {
+              value: '100',
+              owner: { scope: 'Endpoint', serviceName: 'frontend', 
endpointName: '/api/order' },
+            },
+          ],
+        },
+      ],
+    };
+    expect(parseTopList(r)).toEqual([{ name: 'frontend · /api/order', value: 
100 }]);
+  });
+
+  it('endpoint owner without serviceName → endpoint alone', () => {
+    const r: MqeResultShape = {
+      type: 'SORTED_LIST',
+      results: [
+        {
+          values: [{ value: '5', owner: { scope: 'Endpoint', endpointName: 
'/loose' } }],
+        },
+      ],
+    };
+    expect(parseTopList(r)).toEqual([{ name: '/loose', value: 5 }]);
+  });
+
+  it('instance owner → "service · instance"', () => {
+    const r: MqeResultShape = {
+      type: 'SORTED_LIST',
+      results: [
+        {
+          values: [
+            {
+              value: '7',
+              owner: { scope: 'ServiceInstance', serviceName: 'svc-a', 
serviceInstanceName: 'pod-1' },
+            },
+          ],
+        },
+      ],
+    };
+    expect(parseTopList(r)).toEqual([{ name: 'svc-a · pod-1', value: 7 }]);
+  });
+
+  it('service-only owner → service name', () => {
+    const r: MqeResultShape = {
+      type: 'SORTED_LIST',
+      results: [{ values: [{ value: '3', owner: { scope: 'Service', 
serviceName: 'frontend' } }] }],
+    };
+    expect(parseTopList(r)).toEqual([{ name: 'frontend', value: 3 }]);
+  });
+
+  it('no owner but value.id present → falls back to id', () => {
+    const r: MqeResultShape = {
+      type: 'SORTED_LIST',
+      results: [{ values: [{ id: 'fallback-id', value: '9' }] }],
+    };
+    expect(parseTopList(r)).toEqual([{ name: 'fallback-id', value: 9 }]);
+  });
+
+  it('no owner, no id → em-dash placeholder', () => {
+    const r: MqeResultShape = {
+      type: 'SORTED_LIST',
+      results: [{ values: [{ value: '1' }] }],
+    };
+    expect(parseTopList(r)).toEqual([{ name: '—', value: 1 }]);
+  });
+
+  it('non-numeric value collapses to null in the rendered list', () => {
+    const r: MqeResultShape = {
+      type: 'SORTED_LIST',
+      results: [{ values: [{ value: 'NaN', owner: { serviceName: 'svc' } }] }],
+    };
+    expect(parseTopList(r)).toEqual([{ name: 'svc', value: null }]);
+  });
+
+  it('null / errored / empty result → null', () => {
+    expect(parseTopList(undefined)).toBeNull();
+    expect(parseTopList({ type: 'X', error: 'boom' })).toBeNull();
+    expect(parseTopList({ type: 'X', results: [{ values: [] }] })).toBeNull();
+  });
+});
diff --git a/apps/bff/src/http/query/dashboard.ts 
b/apps/bff/src/http/query/dashboard.ts
index 8d03a12..fa009ed 100644
--- a/apps/bff/src/http/query/dashboard.ts
+++ b/apps/bff/src/http/query/dashboard.ts
@@ -134,7 +134,7 @@ interface MqeValuesShape {
   metric?: MqeMetadataShape | null;
   values?: MqeValueShape[];
 }
-interface MqeResultShape {
+export interface MqeResultShape {
   type: string;
   error?: string | null;
   results?: MqeValuesShape[];
@@ -148,7 +148,7 @@ const LIST_FIRST_SERVICE = /* GraphQL */ `
 
 const DEFAULT_WINDOW_MIN = 60;
 
-interface Window {
+export interface Window {
   start: string;
   end: string;
 }
@@ -167,7 +167,18 @@ function defaultWindow(): Window {
   return { start: fmtMinute(start), end: fmtMinute(end) };
 }
 
-function buildFragment(
+/** Build one aliased `execExpression` GraphQL fragment for a single
+ *  widget expression. The entity scope flips based on opts:
+ *    - `layerScope: true` → `{ scope: All }` (no service filter — GLOBAL,
+ *      not layer-restricted; use with care since OAP's Entity has no
+ *      `layer` field, so this leaks across layers if the metric is
+ *      shared between layers)
+ *    - `serviceInstanceName` set → ServiceInstance scope
+ *    - `endpointName` set → Endpoint scope
+ *    - otherwise → Service scope with the supplied serviceName
+ *
+ *  Exported for unit testing (see dashboard.test.ts). */
+export function buildFragment(
   alias: string,
   expression: string,
   serviceName: string,
@@ -175,10 +186,7 @@ function buildFragment(
   w: Window,
   opts: {
     layerScope?: boolean;
-    /** When set, the MQE entity flips to ServiceInstance scope and
-     *  carries the selected instance name. Drives the Instance page. */
     serviceInstanceName?: string | null;
-    /** When set, flips to Endpoint scope with this endpoint name. */
     endpointName?: string | null;
   } = {},
 ): string {
@@ -221,7 +229,7 @@ function buildFragment(
   );
 }
 
-function parseSeries(r: MqeResultShape | undefined): Array<number | null> | 
null {
+export function parseSeries(r: MqeResultShape | undefined): Array<number | 
null> | null {
   if (!r || r.error) return null;
   const values = r.results?.[0]?.values ?? [];
   if (values.length === 0) return null;
@@ -231,7 +239,7 @@ function parseSeries(r: MqeResultShape | undefined): 
Array<number | null> | null
     return Number.isFinite(n) ? n : null;
   });
 }
-function avgOf(series: Array<number | null> | null): number | null {
+export function avgOf(series: Array<number | null> | null): number | null {
   if (!series) return null;
   const xs = series.filter((v): v is number => v !== null);
   if (xs.length === 0) return null;
@@ -249,7 +257,7 @@ function avgOf(series: Array<number | null> | null): number 
| null {
  * returns the per-bucket timestamp/index as the value id, which is
  * useless as a series label.
  */
-function parseLabeledSeries(
+export function parseLabeledSeries(
   r: MqeResultShape | undefined,
   fallbackLabel: string,
 ): Array<{ label: string; data: Array<number | null> }> | null {
@@ -283,7 +291,7 @@ function parseLabeledSeries(
  *   Service     →  service
  *   fallback    →  raw id
  */
-function parseTopList(
+export function parseTopList(
   r: MqeResultShape | undefined,
 ): Array<{ name: string; value: number | null }> | null {
   if (!r || r.error) return null;
diff --git a/apps/bff/src/logic/layers/loader.test.ts 
b/apps/bff/src/logic/layers/loader.test.ts
new file mode 100644
index 0000000..bf5aa8f
--- /dev/null
+++ b/apps/bff/src/logic/layers/loader.test.ts
@@ -0,0 +1,131 @@
+/*
+ * 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.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+  topologyConfigFor,
+  endpointDependencyConfigFor,
+  tracesConfigFor,
+  widgetsForScope,
+  type LayerTemplate,
+} from './loader.js';
+import type { DashboardWidget } from '@skywalking-horizon-ui/api-client';
+
+function widget(id: string): DashboardWidget {
+  return {
+    id,
+    title: id,
+    type: 'line',
+    expressions: ['service_cpm'],
+  };
+}
+
+/** Helper to build a minimal LayerTemplate. We only test the
+ *  scope-resolution / fallback behavior, so most fields can be left
+ *  empty / placeholder. */
+function tpl(overrides: Partial<LayerTemplate> = {}): LayerTemplate {
+  return {
+    key: 'TEST',
+    components: { service: true },
+    metrics: {},
+    ...overrides,
+  } as LayerTemplate;
+}
+
+describe('widgetsForScope — scope-resolution + fallback chain', () => {
+  it('returns the exact dashboards[scope] when present', () => {
+    const instance = [widget('inst-1')];
+    const service = [widget('svc-1')];
+    const t = tpl({ dashboards: { service, instance } });
+    expect(widgetsForScope(t, 'instance')).toBe(instance);
+  });
+
+  it('falls back to dashboards.service when the requested scope is missing', 
() => {
+    const service = [widget('svc-1')];
+    const t = tpl({ dashboards: { service } });
+    expect(widgetsForScope(t, 'instance')).toBe(service);
+    expect(widgetsForScope(t, 'endpoint')).toBe(service);
+    expect(widgetsForScope(t, 'trace')).toBe(service);
+  });
+
+  it('falls back to template.widgets when both scope and service missing', () 
=> {
+    const legacy = [widget('legacy-1')];
+    const t = tpl({ dashboards: { instance: [widget('only-instance')] }, 
widgets: legacy });
+    expect(widgetsForScope(t, 'endpoint')).toBe(legacy);
+  });
+
+  it('returns template.widgets when dashboards block is entirely absent', () 
=> {
+    const legacy = [widget('legacy-only')];
+    const t = tpl({ widgets: legacy });
+    expect(widgetsForScope(t, 'service')).toBe(legacy);
+    expect(widgetsForScope(t, 'instance')).toBe(legacy);
+  });
+
+  it('returns [] when nothing is defined (no dashboards, no legacy widgets)', 
() => {
+    const t = tpl({});
+    expect(widgetsForScope(t, 'service')).toEqual([]);
+    expect(widgetsForScope(t, 'instance')).toEqual([]);
+  });
+
+  it('mesh_dp shape: instance-only template, service request → empty (no 
leakage)', () => {
+    // mesh_dp has components.service:false and dashboards.instance only.
+    // A stray request with scope='service' must NOT return the instance
+    // widgets — those run envoy_cluster_* metrics that are instance-
+    // scope and would render empty / errored at service scope.
+    const instance = [widget('envoy-cluster-up-rq-active')];
+    const t = tpl({
+      dashboards: { instance },
+      components: { service: false, instances: true },
+    });
+    expect(widgetsForScope(t, 'service')).toEqual([]);
+    expect(widgetsForScope(t, 'instance')).toBe(instance);
+  });
+});
+
+describe('topologyConfigFor / endpointDependencyConfigFor / tracesConfigFor — 
defaults', () => {
+  it('topologyConfigFor returns operator override when set', () => {
+    const override = {
+      nodeMetrics: [],
+      serverMetrics: [],
+      clientMetrics: [],
+    } as unknown as LayerTemplate['topology'];
+    const t = tpl({ topology: override });
+    expect(topologyConfigFor(t)).toBe(override);
+  });
+  it('topologyConfigFor falls back to booster defaults when null template', () 
=> {
+    const def = topologyConfigFor(null);
+    expect(def).toBeDefined();
+    expect(def.nodeMetrics).toBeDefined();
+  });
+
+  it('endpointDependencyConfigFor returns the configured block', () => {
+    const cfg = { nodeMetrics: [], serverMetrics: [] } as unknown as 
LayerTemplate['endpointDependency'];
+    expect(endpointDependencyConfigFor(tpl({ endpointDependency: cfg 
}))).toBe(cfg);
+  });
+  it('endpointDependencyConfigFor falls back to booster defaults when null', 
() => {
+    expect(endpointDependencyConfigFor(null)).toBeDefined();
+  });
+
+  it('tracesConfigFor defaults source to "both" when unspecified', () => {
+    expect(tracesConfigFor(null)).toEqual({ source: 'both' });
+    expect(tracesConfigFor(tpl({}))).toEqual({ source: 'both' });
+  });
+  it('tracesConfigFor honors the template override (e.g. zipkin for mesh)', () 
=> {
+    const t = tpl({ traces: { source: 'zipkin' } });
+    expect(tracesConfigFor(t)).toEqual({ source: 'zipkin' });
+  });
+});
diff --git a/apps/ui/src/api/scopes/alarms.test.ts 
b/apps/ui/src/api/scopes/alarms.test.ts
new file mode 100644
index 0000000..01d517a
--- /dev/null
+++ b/apps/ui/src/api/scopes/alarms.test.ts
@@ -0,0 +1,99 @@
+/*
+ * 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.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { AlarmsApi } from './alarms';
+import type { BffClient } from '../client';
+
+function makeStub() {
+  const calls: Array<[string, string, unknown?]> = [];
+  const bff = {
+    request: vi.fn(async (method: string, path: string, body?: unknown) => {
+      calls.push([method, path, body]);
+      return {} as unknown;
+    }),
+  } as unknown as BffClient;
+  return { bff, calls };
+}
+
+describe('AlarmsApi.list — query param assembly', () => {
+  it('includes only the required fields when filters are empty', async () => {
+    const { bff, calls } = makeStub();
+    await new AlarmsApi(bff).list({ startTime: 100, endTime: 200 });
+    
expect(calls[0][1]).toBe('/api/alarms?startTime=100&endTime=200&pageNum=1&pageSize=100');
+  });
+
+  it('defaults pageNum=1 and pageSize=100 when not provided', async () => {
+    const { bff, calls } = makeStub();
+    await new AlarmsApi(bff).list({ startTime: 1, endTime: 2 });
+    expect(calls[0][1]).toContain('pageNum=1');
+    expect(calls[0][1]).toContain('pageSize=100');
+  });
+
+  it('forwards entity filters (service / instance / endpoint) with URL 
encoding', async () => {
+    const { bff, calls } = makeStub();
+    await new AlarmsApi(bff).list({
+      startTime: 1,
+      endTime: 2,
+      service: 'mesh-svr::reviews',
+      instance: 'reviews-pod-1',
+      endpoint: '/api/orders',
+    });
+    expect(calls[0][1]).toBe(
+      
'/api/alarms?startTime=1&endTime=2&pageNum=1&pageSize=100&service=mesh-svr%3A%3Areviews&instance=reviews-pod-1&endpoint=%2Fapi%2Forders',
+    );
+  });
+
+  it('forwards scope + keyword when present', async () => {
+    const { bff, calls } = makeStub();
+    await new AlarmsApi(bff).list({
+      startTime: 1,
+      endTime: 2,
+      scope: 'Service',
+      keyword: 'slow query',
+    });
+    expect(calls[0][1]).toContain('scope=Service');
+    expect(calls[0][1]).toContain('keyword=slow+query');
+  });
+});
+
+describe('AlarmsApi.traffic + services + config', () => {
+  it('traffic GETs /api/alarms/traffic with start + end', async () => {
+    const { bff, calls } = makeStub();
+    await new AlarmsApi(bff).traffic(1000, 2000);
+    expect(calls[0]).toEqual(['GET', 
'/api/alarms/traffic?startTime=1000&endTime=2000', undefined]);
+  });
+
+  it('services GETs with layer param', async () => {
+    const { bff, calls } = makeStub();
+    await new AlarmsApi(bff).services('MESH');
+    expect(calls[0][1]).toBe('/api/alarms/services?layer=MESH');
+  });
+
+  it('config GET / saveConfig POST hit /api/alarms/config', async () => {
+    const { bff, calls } = makeStub();
+    const api = new AlarmsApi(bff);
+    await api.config();
+    await api.saveConfig({ trafficLayers: [{ layerKey: 'MESH', mqe: 
'service_cpm' }] });
+    expect(calls[0]).toEqual(['GET', '/api/alarms/config', undefined]);
+    expect(calls[1][0]).toBe('POST');
+    expect(calls[1][1]).toBe('/api/alarms/config');
+    expect(calls[1][2]).toEqual({
+      trafficLayers: [{ layerKey: 'MESH', mqe: 'service_cpm' }],
+    });
+  });
+});
diff --git a/apps/ui/src/api/scopes/layer.test.ts 
b/apps/ui/src/api/scopes/layer.test.ts
new file mode 100644
index 0000000..5357577
--- /dev/null
+++ b/apps/ui/src/api/scopes/layer.test.ts
@@ -0,0 +1,198 @@
+/*
+ * 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.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { LayerApi } from './layer';
+import type { BffClient } from '../client';
+
+/**
+ * Sub-client URL / body construction is what makes the entity round-
+ * trip work. A wrong encoding (raw `::` not escaped) or a stale URL
+ * shape silently sends the request to the wrong service / no service.
+ * Mock the BffClient.request and assert the exact call shape.
+ */
+function makeStub(): { bff: BffClient; calls: Array<[string, string, 
unknown?]> } {
+  const calls: Array<[string, string, unknown?]> = [];
+  const bff = {
+    request: vi.fn(async (method: string, path: string, body?: unknown) => {
+      calls.push([method, path, body]);
+      return {} as unknown;
+    }),
+  } as unknown as BffClient;
+  return { bff, calls };
+}
+
+describe('LayerApi.landing', () => {
+  it('POSTs to /api/layer/<key>/landing and forwards cfg + range', async () => 
{
+    const { bff, calls } = makeStub();
+    const api = new LayerApi(bff);
+    await api.landing(
+      'mesh',
+      {
+        priority: 1,
+        topN: 5,
+        orderBy: 'cpm',
+        columns: [{ metric: 'cpm', label: 'CPM', mqe: 'service_cpm', 
aggregation: 'sum' }],
+        spark: { metric: 'service_cpm', height: 18 },
+        throughput: { metric: 'cpm', mqe: 'service_cpm', label: 'CPM', unit: 
'rpm' },
+        style: 'table',
+      },
+      { step: 'MINUTE', startMs: 1, endMs: 2 },
+    );
+    expect(calls).toHaveLength(1);
+    expect(calls[0][0]).toBe('POST');
+    expect(calls[0][1]).toBe('/api/layer/mesh/landing');
+    expect(calls[0][2]).toMatchObject({
+      topN: 5,
+      orderBy: 'cpm',
+      step: 'MINUTE',
+      startMs: 1,
+      endMs: 2,
+      spark: { metric: 'service_cpm', height: 18 },
+    });
+  });
+
+  it('omits range fields when no range arg passed', async () => {
+    const { bff, calls } = makeStub();
+    await new LayerApi(bff).landing('general', {
+      priority: 1,
+      topN: 5,
+      orderBy: 'cpm',
+      columns: [],
+      style: 'table',
+    });
+    const body = calls[0][2] as Record<string, unknown>;
+    expect(body.step).toBeUndefined();
+    expect(body.startMs).toBeUndefined();
+    expect(body.endMs).toBeUndefined();
+  });
+
+  it('encodes layer keys with reserved chars', async () => {
+    const { bff, calls } = makeStub();
+    await new LayerApi(bff).landing('aws_eks', {
+      priority: 1,
+      topN: 5,
+      orderBy: 'cpm',
+      columns: [],
+      style: 'table',
+    });
+    expect(calls[0][1]).toBe('/api/layer/aws_eks/landing');
+  });
+});
+
+describe('LayerApi.dashboard', () => {
+  it('POSTs /api/layer/<key>/dashboard with the entity body verbatim', async 
() => {
+    const { bff, calls } = makeStub();
+    await new LayerApi(bff).dashboard('mesh', {
+      service: 'mesh-svr::reviews',
+      serviceInstance: 'reviews-pod-1',
+      endpoint: '/api/orders',
+      scope: 'endpoint',
+    });
+    expect(calls[0]).toEqual([
+      'POST',
+      '/api/layer/mesh/dashboard',
+      {
+        service: 'mesh-svr::reviews',
+        serviceInstance: 'reviews-pod-1',
+        endpoint: '/api/orders',
+        scope: 'endpoint',
+      },
+    ]);
+  });
+
+  it('appends ?mockTop=N when mockTop opt is set; not otherwise', async () => {
+    const { bff, calls } = makeStub();
+    const api = new LayerApi(bff);
+    await api.dashboard('general', {}, { mockTop: 20 });
+    await api.dashboard('general', {});
+    expect(calls[0][1]).toBe('/api/layer/general/dashboard?mockTop=20');
+    expect(calls[1][1]).toBe('/api/layer/general/dashboard');
+  });
+
+  it('does NOT add mockTop when value is 0 or negative', async () => {
+    const { bff, calls } = makeStub();
+    const api = new LayerApi(bff);
+    await api.dashboard('g', {}, { mockTop: 0 });
+    await api.dashboard('g', {}, { mockTop: -5 });
+    expect(calls[0][1]).toBe('/api/layer/g/dashboard');
+    expect(calls[1][1]).toBe('/api/layer/g/dashboard');
+  });
+});
+
+describe('LayerApi.dashboardConfig', () => {
+  it('GETs without query when scope omitted', async () => {
+    const { bff, calls } = makeStub();
+    await new LayerApi(bff).dashboardConfig('mesh');
+    expect(calls[0]).toEqual(['GET', '/api/layer/mesh/dashboard/config', 
undefined]);
+  });
+
+  it('GETs with ?scope=<scope> when scope provided and URL-encodes', async () 
=> {
+    const { bff, calls } = makeStub();
+    await new LayerApi(bff).dashboardConfig('mesh', 'instance');
+    
expect(calls[0][1]).toBe('/api/layer/mesh/dashboard/config?scope=instance');
+  });
+});
+
+describe('LayerApi.endpoints / instances — entity query params', () => {
+  it('endpoints encodes service / q / limit', async () => {
+    const { bff, calls } = makeStub();
+    await new LayerApi(bff).endpoints('mesh', 'mesh-svr::reviews', '/api/', 
50);
+    // URLSearchParams encodes `::` as %3A%3A
+    expect(calls[0][1]).toBe(
+      
'/api/layer/mesh/endpoints?service=mesh-svr%3A%3Areviews&q=%2Fapi%2F&limit=50',
+    );
+  });
+
+  it('endpoints defaults limit to 20', async () => {
+    const { bff, calls } = makeStub();
+    await new LayerApi(bff).endpoints('mesh', 'svc', '');
+    
expect(calls[0][1]).toBe('/api/layer/mesh/endpoints?service=svc&q=&limit=20');
+  });
+
+  it('instances encodes the service param', async () => {
+    const { bff, calls } = makeStub();
+    await new LayerApi(bff).instances('mesh_dp', 
'mesh-svr::app.sample-services');
+    expect(calls[0][1]).toBe(
+      '/api/layer/mesh_dp/instances?service=mesh-svr%3A%3Aapp.sample-services',
+    );
+  });
+});
+
+describe('LayerApi.topology / endpointDependency', () => {
+  it('topology defaults depth=1, omits service param when undefined', async () 
=> {
+    const { bff, calls } = makeStub();
+    await new LayerApi(bff).topology('mesh');
+    expect(calls[0][1]).toBe('/api/layer/mesh/topology?depth=1');
+  });
+
+  it('topology forwards service + depth when provided', async () => {
+    const { bff, calls } = makeStub();
+    await new LayerApi(bff).topology('mesh', 'mesh-svr::reviews', 3);
+    expect(calls[0][1]).toBe(
+      '/api/layer/mesh/topology?service=mesh-svr%3A%3Areviews&depth=3',
+    );
+  });
+
+  it('endpointDependency requires + encodes service + endpoint', async () => {
+    const { bff, calls } = makeStub();
+    await new LayerApi(bff).endpointDependency('general', 'frontend', 
'/api/order');
+    expect(calls[0][1]).toBe(
+      
'/api/layer/general/endpoint-dependency?service=frontend&endpoint=%2Fapi%2Forder',
+    );
+  });
+});
diff --git a/apps/ui/src/shell/AppSidebar.vue b/apps/ui/src/shell/AppSidebar.vue
index 3a96087..b3661dd 100644
--- a/apps/ui/src/shell/AppSidebar.vue
+++ b/apps/ui/src/shell/AppSidebar.vue
@@ -35,52 +35,9 @@ const { availableLayers, oapReachable, oapError, hasTopology 
} = useLayers();
 const orderedLayers = useLandingOrder(availableLayers);
 const { publicOverviews } = useOverviewDashboards();
 
-function sectionIcon(label: string): IconName {
-  const k = label.toLowerCase();
-  if (k === 'overviews' || k === 'overview') return 'dash';
-  if (k === 'layers') return 'svc';
-  if (k === 'platform monitoring') return 'flame';
-  if (k === 'operate') return 'set';
-  if (k === 'dashboard setup') return 'metric';
-  if (k === 'admin') return 'user';
-  if (k.startsWith('istio') || k.includes('mesh') || k.includes('envoy') || 
k.includes('cilium')) {
-    return 'mesh';
-  }
-  if (k.includes('kubernetes') || k.includes('k8s') || k.includes('eks')) 
return 'cluster';
-  if (k.includes('browser') || k.includes('rum') || k.includes('mini')) return 
'web';
-  if (k.includes('database') || k.includes('sql') || k.includes('mongo')) 
return 'db';
-  if (k.includes('cache') || k.includes('redis')) return 'cache';
-  if (k.includes('mq') || k.includes('kafka') || k.includes('queue')) return 
'topic';
-  if (k.includes('faas') || k.includes('function')) return 'fn';
-  if (k.includes('aws') || k.includes('cloud')) return 'cluster';
-  if (k.includes('agent') || k.includes('so11y') || k.includes('satellite')) 
return 'flame';
-  return 'dash';
-}
-
+import { sectionIcon, layerIcon as layerIconByKey } from './icons';
 function layerIcon(L: SidebarLayer): IconName {
-  const k = L.key.toLowerCase();
-  if (k === 'general' || k === 'general_service') return 'sky';
-  if (k.startsWith('mesh') || k.startsWith('istio') || k.startsWith('envoy') 
|| k.startsWith('cilium')) {
-    return 'mesh';
-  }
-  if (k.startsWith('k8s') || k === 'aws_eks') return 'cluster';
-  if (k === 'browser' || k === 'ios' || k.includes('mini_program')) return 
'web';
-  if (k === 'faas') return 'fn';
-  if (
-    k === 'virtual_mq' || k === 'kafka' || k === 'rocketmq' || k === 
'rabbitmq' ||
-    k === 'pulsar' || k === 'activemq' || k === 'bookkeeper'
-  ) {
-    return 'topic';
-  }
-  if (
-    k === 'virtual_database' || k === 'mysql' || k === 'postgresql' || k === 
'mongodb' ||
-    k === 'clickhouse' || k === 'elasticsearch' || k === 'aws_dynamodb'
-  ) {
-    return 'db';
-  }
-  if (k === 'virtual_cache' || k === 'redis') return 'cache';
-  if (k.startsWith('so11y') || k.includes('agent') || k.includes('satellite')) 
return 'flame';
-  return 'svc';
+  return layerIconByKey(L.key);
 }
 
 type SidebarLayer = (typeof orderedLayers.value)[number];
diff --git a/apps/ui/src/shell/icons.test.ts b/apps/ui/src/shell/icons.test.ts
new file mode 100644
index 0000000..736f11d
--- /dev/null
+++ b/apps/ui/src/shell/icons.test.ts
@@ -0,0 +1,130 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { sectionIcon, layerIcon } from './icons';
+
+describe('sectionIcon — L0 header icon mapping', () => {
+  it('returns fixed icons for the canonical sections', () => {
+    expect(sectionIcon('Overviews')).toBe('dash');
+    expect(sectionIcon('overview')).toBe('dash');
+    expect(sectionIcon('Layers')).toBe('svc');
+    expect(sectionIcon('Platform monitoring')).toBe('flame');
+    expect(sectionIcon('Operate')).toBe('set');
+    expect(sectionIcon('Dashboard setup')).toBe('metric');
+    expect(sectionIcon('Admin')).toBe('user');
+  });
+
+  it('case-insensitive — uppercase, mixed case all map the same', () => {
+    expect(sectionIcon('LAYERS')).toBe('svc');
+    expect(sectionIcon('lAyErs')).toBe('svc');
+  });
+
+  it('maps in-Layers sub-group buckets to their family glyph', () => {
+    expect(sectionIcon('Istio')).toBe('mesh');
+    expect(sectionIcon('Kubernetes')).toBe('cluster');
+    expect(sectionIcon('Browser')).toBe('web');
+    expect(sectionIcon('Databases')).toBe('db');
+    expect(sectionIcon('Caches')).toBe('cache');
+    expect(sectionIcon('Message queues')).toBe('topic');
+    expect(sectionIcon('AWS')).toBe('cluster');
+  });
+
+  it('falls back to dash for unknown labels', () => {
+    expect(sectionIcon('Custom Section')).toBe('dash');
+    expect(sectionIcon('')).toBe('dash');
+  });
+});
+
+describe('layerIcon — per-layer L1 icon mapping', () => {
+  it('General service uses the Apache feather brand mark', () => {
+    expect(layerIcon('general')).toBe('sky');
+    expect(layerIcon('GENERAL')).toBe('sky');
+    expect(layerIcon('general_service')).toBe('sky');
+  });
+
+  it('mesh family (mesh / mesh_cp / mesh_dp / istio* / envoy* / cilium*) → 
mesh', () => {
+    expect(layerIcon('mesh')).toBe('mesh');
+    expect(layerIcon('mesh_cp')).toBe('mesh');
+    expect(layerIcon('mesh_dp')).toBe('mesh');
+    expect(layerIcon('istio')).toBe('mesh');
+    expect(layerIcon('envoy_ai_gateway')).toBe('mesh');
+    expect(layerIcon('cilium_service')).toBe('mesh');
+  });
+
+  it('k8s / aws_eks → cluster', () => {
+    expect(layerIcon('k8s')).toBe('cluster');
+    expect(layerIcon('k8s_service')).toBe('cluster');
+    expect(layerIcon('aws_eks')).toBe('cluster');
+  });
+
+  it('browser / ios / mini-programs → web', () => {
+    expect(layerIcon('browser')).toBe('web');
+    expect(layerIcon('ios')).toBe('web');
+    expect(layerIcon('alipay_mini_program')).toBe('web');
+    expect(layerIcon('wechat_mini_program')).toBe('web');
+  });
+
+  it('faas → fn', () => {
+    expect(layerIcon('faas')).toBe('fn');
+  });
+
+  it('message-queue family → topic', () => {
+    expect(layerIcon('virtual_mq')).toBe('topic');
+    expect(layerIcon('kafka')).toBe('topic');
+    expect(layerIcon('rocketmq')).toBe('topic');
+    expect(layerIcon('rabbitmq')).toBe('topic');
+    expect(layerIcon('pulsar')).toBe('topic');
+    expect(layerIcon('activemq')).toBe('topic');
+    expect(layerIcon('bookkeeper')).toBe('topic');
+  });
+
+  it('database family → db', () => {
+    expect(layerIcon('virtual_database')).toBe('db');
+    expect(layerIcon('mysql')).toBe('db');
+    expect(layerIcon('postgresql')).toBe('db');
+    expect(layerIcon('mongodb')).toBe('db');
+    expect(layerIcon('clickhouse')).toBe('db');
+    expect(layerIcon('elasticsearch')).toBe('db');
+    expect(layerIcon('aws_dynamodb')).toBe('db');
+  });
+
+  it('cache family → cache', () => {
+    expect(layerIcon('virtual_cache')).toBe('cache');
+    expect(layerIcon('redis')).toBe('cache');
+  });
+
+  it('self-observability + agents → flame', () => {
+    expect(layerIcon('so11y_oap')).toBe('flame');
+    expect(layerIcon('so11y_satellite')).toBe('flame');
+    expect(layerIcon('so11y_java_agent')).toBe('flame');
+    expect(layerIcon('so11y_go_agent')).toBe('flame');
+  });
+
+  it('falls back to svc for unknown / non-family layers', () => {
+    expect(layerIcon('apisix')).toBe('svc');
+    expect(layerIcon('nginx')).toBe('svc');
+    expect(layerIcon('flink')).toBe('svc');
+    expect(layerIcon('os_linux')).toBe('svc');
+  });
+
+  it('precedence — mesh prefix wins over generic substrings', () => {
+    // `mesh_cp` starts with `mesh`, so it picks `mesh` even though
+    // the key also matches the substring `cp` (which isn't a mapping).
+    expect(layerIcon('mesh_cp')).toBe('mesh');
+  });
+});
diff --git a/apps/ui/src/shell/icons.ts b/apps/ui/src/shell/icons.ts
new file mode 100644
index 0000000..9d6bd53
--- /dev/null
+++ b/apps/ui/src/shell/icons.ts
@@ -0,0 +1,79 @@
+/*
+ * 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.
+ */
+
+/**
+ * Icon mappers used by AppSidebar. Extracted from the component so
+ * they can be unit-tested directly — the mappings encode our entire
+ * layer / section taxonomy, and a wrong icon ships a confusing menu.
+ */
+
+import type { IconName } from '@/components/icons/Icon.vue';
+
+/** Pick the L0 group-header icon for a given section label.
+ *  Canonical sections (Overviews / Layers / Operate / …) get fixed
+ *  icons; everything else falls through to a substring match against
+ *  the known layer / family names. Default `dash`. */
+export function sectionIcon(label: string): IconName {
+  const k = label.toLowerCase();
+  if (k === 'overviews' || k === 'overview') return 'dash';
+  if (k === 'layers') return 'svc';
+  if (k === 'platform monitoring') return 'flame';
+  if (k === 'operate') return 'set';
+  if (k === 'dashboard setup') return 'metric';
+  if (k === 'admin') return 'user';
+  if (k.startsWith('istio') || k.includes('mesh') || k.includes('envoy') || 
k.includes('cilium')) {
+    return 'mesh';
+  }
+  if (k.includes('kubernetes') || k.includes('k8s') || k.includes('eks')) 
return 'cluster';
+  if (k.includes('browser') || k.includes('rum') || k.includes('mini')) return 
'web';
+  if (k.includes('database') || k.includes('sql') || k.includes('mongo')) 
return 'db';
+  if (k.includes('cache') || k.includes('redis')) return 'cache';
+  if (k.includes('mq') || k.includes('kafka') || k.includes('queue')) return 
'topic';
+  if (k.includes('faas') || k.includes('function')) return 'fn';
+  if (k.includes('aws') || k.includes('cloud')) return 'cluster';
+  if (k.includes('agent') || k.includes('so11y') || k.includes('satellite')) 
return 'flame';
+  return 'dash';
+}
+
+/** Pick the per-layer L1 icon from the layer key. Decoupled from
+ *  `LayerDef` so the helper stays pure / testable; callers pass
+ *  `layer.key` directly. */
+export function layerIcon(layerKey: string): IconName {
+  const k = layerKey.toLowerCase();
+  if (k === 'general' || k === 'general_service') return 'sky';
+  if (k.startsWith('mesh') || k.startsWith('istio') || k.startsWith('envoy') 
|| k.startsWith('cilium')) {
+    return 'mesh';
+  }
+  if (k.startsWith('k8s') || k === 'aws_eks') return 'cluster';
+  if (k === 'browser' || k === 'ios' || k.includes('mini_program')) return 
'web';
+  if (k === 'faas') return 'fn';
+  if (
+    k === 'virtual_mq' || k === 'kafka' || k === 'rocketmq' || k === 
'rabbitmq' ||
+    k === 'pulsar' || k === 'activemq' || k === 'bookkeeper'
+  ) {
+    return 'topic';
+  }
+  if (
+    k === 'virtual_database' || k === 'mysql' || k === 'postgresql' || k === 
'mongodb' ||
+    k === 'clickhouse' || k === 'elasticsearch' || k === 'aws_dynamodb'
+  ) {
+    return 'db';
+  }
+  if (k === 'virtual_cache' || k === 'redis') return 'cache';
+  if (k.startsWith('so11y') || k.includes('agent') || k.includes('satellite')) 
return 'flame';
+  return 'svc';
+}
diff --git a/apps/ui/src/shell/useLayers.test.ts 
b/apps/ui/src/shell/useLayers.test.ts
new file mode 100644
index 0000000..220d089
--- /dev/null
+++ b/apps/ui/src/shell/useLayers.test.ts
@@ -0,0 +1,112 @@
+/*
+ * 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.
+ */
+
+import { describe, it, expect } from 'vitest';
+import type { LayerDef } from '@skywalking-horizon-ui/api-client';
+import { firstLayerTab } from './useLayers';
+
+/**
+ * Helper to build a partial-but-typed LayerDef. Real layers carry
+ * dozens of fields the tab decision doesn't read; we only need the
+ * caps + slots shape `firstLayerTab` consults.
+ */
+function L(caps: Partial<LayerDef['caps']> = {}, slots: 
Partial<LayerDef['slots']> = {}): LayerDef {
+  return {
+    key: 'TEST',
+    name: 'Test',
+    color: '#fff',
+    serviceCount: 1,
+    active: true,
+    level: null,
+    slots,
+    caps,
+  } as LayerDef;
+}
+
+describe('firstLayerTab — routing decision per layer caps', () => {
+  it('returns `service` for an undefined layer (safe default)', () => {
+    expect(firstLayerTab(undefined)).toBe('service');
+  });
+
+  it('returns `service` when caps.dashboards is true (canonical full layer)', 
() => {
+    expect(firstLayerTab(L({ dashboards: true }))).toBe('service');
+  });
+
+  it('returns `service` when caps + many tabs all enabled — dashboards wins', 
() => {
+    expect(
+      firstLayerTab(L({ dashboards: true, instances: true, endpoints: true, 
traces: true })),
+    ).toBe('service');
+  });
+
+  it('returns `instance` when dashboards false but instances cap is true 
(mesh_dp shape)', () => {
+    expect(firstLayerTab(L({ instances: true }))).toBe('instance');
+  });
+
+  it('returns `instance` when only `slots.instances` label is set + 
caps.instances undefined', () => {
+    expect(firstLayerTab(L({}, { instances: 'Sidecars' }))).toBe('instance');
+  });
+
+  it('explicit `caps.instances: false` beats a truthy slot — falls through 
past instance', () => {
+    expect(firstLayerTab(L({ instances: false }, { instances: 'Sidecars', 
endpoints: 'EP' }))).toBe(
+      'endpoint',
+    );
+  });
+
+  it('returns `endpoint` when only endpoints cap', () => {
+    expect(firstLayerTab(L({ endpoints: true }))).toBe('endpoint');
+  });
+
+  it('returns `topology` when any topology sub-cap is on (serviceMap / 
instanceTopology / processTopology)', () => {
+    expect(firstLayerTab(L({ serviceMap: true }))).toBe('topology');
+    expect(firstLayerTab(L({ instanceTopology: true }))).toBe('topology');
+    expect(firstLayerTab(L({ processTopology: true }))).toBe('topology');
+  });
+
+  it('returns `dependency` when only endpointDependency', () => {
+    expect(firstLayerTab(L({ endpointDependency: true }))).toBe('dependency');
+  });
+
+  it('returns `trace` when only traces', () => {
+    expect(firstLayerTab(L({ traces: true }))).toBe('trace');
+  });
+
+  it('returns `logs` when only logs', () => {
+    expect(firstLayerTab(L({ logs: true }))).toBe('logs');
+  });
+
+  it('returns the right profiling kind when only that profiling cap is on', () 
=> {
+    expect(firstLayerTab(L({ traceProfiling: true }))).toBe('trace-profiling');
+    expect(firstLayerTab(L({ ebpfProfiling: true }))).toBe('ebpf-profiling');
+    expect(firstLayerTab(L({ networkProfiling: true 
}))).toBe('network-profiling');
+    expect(firstLayerTab(L({ asyncProfiling: true }))).toBe('async-profiling');
+    expect(firstLayerTab(L({ pprofProfiling: true }))).toBe('pprof');
+  });
+
+  it('falls back to `service` when no caps at all (empty layer)', () => {
+    expect(firstLayerTab(L({}))).toBe('service');
+  });
+
+  it('priority order matches the sidebar tab order — instance beats endpoint 
beats topology', () => {
+    expect(firstLayerTab(L({ instances: true, endpoints: true, serviceMap: 
true }))).toBe(
+      'instance',
+    );
+    expect(firstLayerTab(L({ endpoints: true, serviceMap: true 
}))).toBe('endpoint');
+    expect(firstLayerTab(L({ serviceMap: true, traces: true 
}))).toBe('topology');
+    expect(firstLayerTab(L({ traces: true, logs: true }))).toBe('trace');
+    expect(firstLayerTab(L({ logs: true, traceProfiling: true 
}))).toBe('logs');
+  });
+});
diff --git a/apps/ui/src/utils/serviceName.test.ts 
b/apps/ui/src/utils/serviceName.test.ts
new file mode 100644
index 0000000..6f4a5ac
--- /dev/null
+++ b/apps/ui/src/utils/serviceName.test.ts
@@ -0,0 +1,204 @@
+/*
+ * 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.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+  parseServiceName,
+  serviceBaseName,
+  serviceGroupName,
+  resolveServiceIdentity,
+} from './serviceName';
+
+describe('parseServiceName', () => {
+  it('returns null group + raw base for nullish input', () => {
+    expect(parseServiceName(null)).toEqual({ group: null, base: '', raw: '' });
+    expect(parseServiceName(undefined)).toEqual({ group: null, base: '', raw: 
'' });
+    expect(parseServiceName('')).toEqual({ group: null, base: '', raw: '' });
+  });
+
+  it('returns null group for plain names without `::`', () => {
+    expect(parseServiceName('frontend')).toEqual({
+      group: null,
+      base: 'frontend',
+      raw: 'frontend',
+    });
+  });
+
+  it('splits `<group>::<base>` correctly', () => {
+    expect(parseServiceName('agent::checkout')).toEqual({
+      group: 'agent',
+      base: 'checkout',
+      raw: 'agent::checkout',
+    });
+  });
+
+  it('preserves dots in the base segment', () => {
+    expect(parseServiceName('mesh-svr::reviews.default')).toEqual({
+      group: 'mesh-svr',
+      base: 'reviews.default',
+      raw: 'mesh-svr::reviews.default',
+    });
+  });
+
+  it('treats a leading `::` as no group (idx === 0 fails the > 0 guard)', () 
=> {
+    expect(parseServiceName('::orphan')).toEqual({
+      group: null,
+      base: '::orphan',
+      raw: '::orphan',
+    });
+  });
+
+  it('keeps trailing `::` as raw (no base)', () => {
+    expect(parseServiceName('foo::')).toEqual({
+      group: 'foo',
+      base: '',
+      raw: 'foo::',
+    });
+  });
+
+  it('only splits on the first `::` — second `::` stays in base', () => {
+    expect(parseServiceName('a::b::c')).toEqual({
+      group: 'a',
+      base: 'b::c',
+      raw: 'a::b::c',
+    });
+  });
+});
+
+describe('serviceBaseName / serviceGroupName convenience helpers', () => {
+  it('serviceBaseName returns the stripped name', () => {
+    expect(serviceBaseName('mesh-svr::reviews')).toBe('reviews');
+    expect(serviceBaseName('frontend')).toBe('frontend');
+    expect(serviceBaseName(null)).toBe('');
+  });
+
+  it('serviceGroupName returns the group (or null)', () => {
+    expect(serviceGroupName('mesh-svr::reviews')).toBe('mesh-svr');
+    expect(serviceGroupName('frontend')).toBeNull();
+    expect(serviceGroupName(null)).toBeNull();
+  });
+});
+
+describe('resolveServiceIdentity', () => {
+  it('legacy `::` group is stripped to base when no cluster rule', () => {
+    const r = resolveServiceIdentity('agent::checkout', null);
+    expect(r).toEqual({
+      display: 'checkout',
+      cluster: null,
+      clusterAlias: null,
+      legacyGroup: 'agent',
+    });
+  });
+
+  it('plain name passes through with no group / cluster', () => {
+    const r = resolveServiceIdentity('frontend', null);
+    expect(r).toEqual({
+      display: 'frontend',
+      cluster: null,
+      clusterAlias: null,
+      legacyGroup: null,
+    });
+  });
+
+  it('applies a `<service>.<namespace>` cluster rule', () => {
+    const rule = {
+      pattern: '^(?<service>[^.]+)\\.(?<namespace>[^.]+)(?:\\..*)?$',
+      displayGroup: 'service',
+      valueGroup: 'namespace',
+      alias: 'namespace',
+    };
+    expect(resolveServiceIdentity('reviews.default', rule)).toEqual({
+      display: 'reviews',
+      cluster: 'default',
+      clusterAlias: 'namespace',
+      legacyGroup: null,
+    });
+  });
+
+  it('stacks legacy + cluster rule — strips `mesh-svr::` from captured 
service', () => {
+    const rule = {
+      pattern: '^(?<service>[^.]+)\\.(?<namespace>[^.]+)(?:\\..*)?$',
+      displayGroup: 'service',
+      valueGroup: 'namespace',
+      alias: 'namespace',
+    };
+    expect(resolveServiceIdentity('mesh-svr::reviews.default', rule)).toEqual({
+      display: 'reviews',
+      cluster: 'default',
+      clusterAlias: 'namespace',
+      legacyGroup: 'mesh-svr',
+    });
+  });
+
+  it('partial cluster match (display only, no value) preserves display + 
legacy group', () => {
+    const rule = {
+      pattern: '^(?<service>[^.]+)$',
+      displayGroup: 'service',
+      valueGroup: 'group',
+      alias: 'group',
+    };
+    expect(resolveServiceIdentity('mesh-svr::reviews', rule)).toEqual({
+      display: 'reviews',
+      cluster: null,
+      clusterAlias: null,
+      legacyGroup: 'mesh-svr',
+    });
+  });
+
+  it('falls back to legacy parser when the cluster regex does not match', () 
=> {
+    const rule = {
+      pattern: '^impossible-\\d+$',
+      displayGroup: 'service',
+      valueGroup: 'group',
+      alias: 'group',
+    };
+    expect(resolveServiceIdentity('agent::checkout', rule)).toEqual({
+      display: 'checkout',
+      cluster: null,
+      clusterAlias: null,
+      legacyGroup: 'agent',
+    });
+  });
+
+  it('invalid regex in the cluster rule is swallowed (treated as no rule)', () 
=> {
+    const rule = {
+      pattern: '[invalid(regex',
+      displayGroup: 'service',
+      valueGroup: 'group',
+      alias: 'group',
+    };
+    expect(resolveServiceIdentity('frontend', rule)).toEqual({
+      display: 'frontend',
+      cluster: null,
+      clusterAlias: null,
+      legacyGroup: null,
+    });
+  });
+
+  it('default capture-group names (service / group) work when omitted', () => {
+    const rule = {
+      pattern: '^(?<service>[^.]+)\\.(?<group>[^.]+)$',
+      alias: 'namespace',
+    };
+    expect(resolveServiceIdentity('reviews.default', rule)).toEqual({
+      display: 'reviews',
+      cluster: 'default',
+      clusterAlias: 'namespace',
+      legacyGroup: null,
+    });
+  });
+});

Reply via email to