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,
+ });
+ });
+});