This is an automated email from the ASF dual-hosted git repository. wu-sheng pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit 42897f1dac0810dd169455f21cfedf311c53fbfe Author: Wu Sheng <[email protected]> AuthorDate: Thu May 14 17:37:37 2026 +0800 ui: topology rework + Istio rename + virtual-mq normal-flag fix Topology view (LayerServiceMapView.vue + topology-routes.ts): - Drop heaviest-path overlay: legend caption, node halo, edge stroke width/opacity, side-panel "main path" tags, and the sort tiebreaker it fed. Every edge reads as visually peer now. - Animated directional flow on every edge via `stroke-dashoffset` scrolling source → target at 1.2s/cycle; selected edges brighten. - New in-box focus picker: searchable multi-select popover with group-prefixed (`<group>::`) section headers. Replaces the plain <select>. The "Hops" control hides when focus = all services (BFF already caps the seed at 30 there). - BFF: `?service=` accepts comma-separated names/ids so multi-seed rides the existing query param. Unmatched entries reported back inline instead of failing the whole request. Service-name parsing — new apps/ui/src/utils/serviceName.ts splits OAP's `<group>::<base>` (e.g. `agent::rating`). Applied at every service-name display point so the raw `::` syntax never bleeds into the UI: - Topology canvas node label = base only; group surfaces as accent chip in the right-sidebar tags - Layer service picker rows = [GROUP chip] base - LayerShell Switch button = same chip + base Mesh → Istio rename across the bundled templates: - MESH alias: Service Mesh → Istio Managed SVCs - MESH_CP alias: Mesh Control Plane → Istio Control Plane - MESH_DP alias: Mesh Data Plane → Istio Data Plane - overviews/mesh.json: title, description, every widget title + tip swept (Active Istio services / Istio RPM / Istio service topology / Istio data-plane). Other: - Default dashboard / landing / topology / instance / endpoint / dependency time window 15min → 60min. The old default was too narrow for sparse-traffic layers (VIRTUAL_MQ, AWS_*) — empty cards for what is actually live data. - Dashboard route: always look up the service in `listServices` to ride the correct `normal` flag through to the MQE entity. Fixes the empty-Virtual-MQ-dashboard bug — VIRTUAL_MQ / VIRTUAL_DATABASE / VIRTUAL_CACHE / AWS_* services are `normal: false` and the previous code defaulted to `true`, so every MQE returned null. - /api/menu now carries `layer.normal` sampled from the first service of each layer so the UI + future routes can pivot scope without a re-query (LayerDef.normal in @skywalking-horizon-ui/api-client). - Sidebar: drop the per-row service-count prefix chip. - LayerShell: topology route declares `meta.ownsServiceSelector: true` so the header service picker hides on the topology view (the in-box focus picker is the right surface for service scoping). --- apps/bff/src/bundled_templates/layers/mesh.json | 2 +- apps/bff/src/bundled_templates/layers/mesh_cp.json | 2 +- apps/bff/src/bundled_templates/layers/mesh_dp.json | 2 +- apps/bff/src/bundled_templates/overviews/mesh.json | 18 +- apps/bff/src/dashboard/routes.ts | 59 ++-- apps/bff/src/oap/endpoint-dependency-routes.ts | 2 +- apps/bff/src/oap/endpoint-routes.ts | 2 +- apps/bff/src/oap/instance-routes.ts | 2 +- apps/bff/src/oap/landing-routes.ts | 2 +- apps/bff/src/oap/menu-routes.ts | 38 ++- apps/bff/src/oap/topology-routes.ts | 23 +- apps/ui/src/components/shell/AppSidebar.vue | 27 -- apps/ui/src/router/index.ts | 10 +- apps/ui/src/utils/serviceName.ts | 60 ++++ apps/ui/src/views/layer/LayerServiceMapView.vue | 341 +++++++++++++-------- apps/ui/src/views/layer/LayerServiceSelector.vue | 25 +- apps/ui/src/views/layer/LayerShell.vue | 50 ++- packages/api-client/src/menu.ts | 7 + 18 files changed, 454 insertions(+), 218 deletions(-) diff --git a/apps/bff/src/bundled_templates/layers/mesh.json b/apps/bff/src/bundled_templates/layers/mesh.json index d7b34b1..f0cea8b 100644 --- a/apps/bff/src/bundled_templates/layers/mesh.json +++ b/apps/bff/src/bundled_templates/layers/mesh.json @@ -1,6 +1,6 @@ { "key": "MESH", - "alias": "Service Mesh", + "alias": "Istio Managed SVCs", "color": "var(--sw-info)", "documentLink": "https://skywalking.apache.org/docs/main/next/en/setup/envoy/als_setting/", "aliases": { diff --git a/apps/bff/src/bundled_templates/layers/mesh_cp.json b/apps/bff/src/bundled_templates/layers/mesh_cp.json index 15e03e5..f7272b7 100644 --- a/apps/bff/src/bundled_templates/layers/mesh_cp.json +++ b/apps/bff/src/bundled_templates/layers/mesh_cp.json @@ -1,6 +1,6 @@ { "key": "MESH_CP", - "alias": "Mesh Control Plane", + "alias": "Istio Control Plane", "color": "var(--sw-info)", "documentLink": "https://skywalking.apache.org/docs/main/next/en/setup/istio/readme/", "aliases": { "services": "Control Planes" }, diff --git a/apps/bff/src/bundled_templates/layers/mesh_dp.json b/apps/bff/src/bundled_templates/layers/mesh_dp.json index 1a56ef6..1a7cc0a 100644 --- a/apps/bff/src/bundled_templates/layers/mesh_dp.json +++ b/apps/bff/src/bundled_templates/layers/mesh_dp.json @@ -1,6 +1,6 @@ { "key": "MESH_DP", - "alias": "Mesh Data Plane", + "alias": "Istio Data Plane", "color": "var(--sw-info)", "documentLink": "https://skywalking.apache.org/docs/main/next/en/setup/envoy/metrics_service_setting/", "aliases": { "services": "Sidecar services", "instances": "Sidecars" }, diff --git a/apps/bff/src/bundled_templates/overviews/mesh.json b/apps/bff/src/bundled_templates/overviews/mesh.json index 700f7f2..a9c5ae9 100644 --- a/apps/bff/src/bundled_templates/overviews/mesh.json +++ b/apps/bff/src/bundled_templates/overviews/mesh.json @@ -1,12 +1,12 @@ { "id": "mesh", - "title": "Mesh service", - "description": "Service-mesh overview — control-plane + data-plane signals with the K8s service-count cross-referenced.", + "title": "Istio Managed SVCs", + "description": "Istio-mesh overview — control-plane + data-plane signals with the K8s service-count cross-referenced.", "widgets": [ { "id": "m_count", - "title": "Active mesh services", - "tip": "Services routed through the mesh data-plane.", + "title": "Active Istio services", + "tip": "Services routed through the Istio data-plane.", "layer": "MESH", "type": "service-count", "span": 3, @@ -14,8 +14,8 @@ }, { "id": "m_rpm", - "title": "Mesh RPM", - "tip": "Mesh-wide requests per minute.", + "title": "Istio RPM", + "tip": "Istio-mesh-wide requests per minute.", "layer": "MESH", "type": "metric", "mqe": "service_mesh_cpm", @@ -27,7 +27,7 @@ { "id": "m_p99", "title": "P99 latency", - "tip": "99th-percentile response time across mesh-routed traffic.", + "tip": "99th-percentile response time across Istio-routed traffic.", "layer": "MESH", "type": "metric", "mqe": "service_mesh_percentile{p='99'}", @@ -71,8 +71,8 @@ }, { "id": "m_topo", - "title": "Mesh service topology", - "tip": "Live service map for the mesh data-plane.", + "title": "Istio service topology", + "tip": "Live service map for the Istio data-plane.", "layer": "MESH", "type": "topology", "span": 12, diff --git a/apps/bff/src/dashboard/routes.ts b/apps/bff/src/dashboard/routes.ts index a09636b..e83c985 100644 --- a/apps/bff/src/dashboard/routes.ts +++ b/apps/bff/src/dashboard/routes.ts @@ -145,7 +145,7 @@ const LIST_FIRST_SERVICE = /* GraphQL */ ` } `; -const DEFAULT_WINDOW_MIN = 15; +const DEFAULT_WINDOW_MIN = 60; interface Window { start: string; @@ -349,33 +349,48 @@ export function registerDashboardRoute(app: FastifyInstance, deps: DashboardRout reachable: true, }; - // Step 1 — resolve service if not provided. - if (!serviceName) { - try { - const data = await graphqlPost<{ services: Array<{ id: string; name: string; normal: boolean }> }>( - opts, - LIST_FIRST_SERVICE, - { layer: layerKey.toUpperCase() }, - ); - const first = data.services?.[0]; - if (first) { - serviceName = first.name; - normal = first.normal !== false; - baseResp.service = serviceName; - } else { + // Step 1 — resolve service. We always probe `listServices` so the + // correct `normal` flag rides along with the service entity. Some + // layers (VIRTUAL_MQ, VIRTUAL_DATABASE, VIRTUAL_CACHE, AWS_*) use + // `normal: false` services — without this look-up every MQE on + // those layers comes back null because the entity-scope filter + // doesn't match the data dimension OAP stored them under. + try { + const data = await graphqlPost<{ services: Array<{ id: string; name: string; normal: boolean }> }>( + opts, + LIST_FIRST_SERVICE, + { layer: layerKey.toUpperCase() }, + ); + const all = data.services ?? []; + let picked: { id: string; name: string; normal: boolean } | undefined; + if (serviceName) { + picked = all.find((s) => s.name === serviceName) ?? all.find((s) => s.id === serviceName); + if (!picked) { + return reply.send({ + ...baseResp, + service: serviceName, + widgets: widgets.map((w) => ({ id: w.id, error: `service "${serviceName}" not in layer` })), + }); + } + } else { + picked = all[0]; + if (!picked) { return reply.send({ ...baseResp, widgets: widgets.map((w) => ({ id: w.id, error: 'no service in layer' })), }); } - } catch (err) { - return reply.send({ - ...baseResp, - reachable: false, - error: err instanceof Error ? err.message : String(err), - widgets: widgets.map((w) => ({ id: w.id, error: 'oap unreachable' })), - }); } + serviceName = picked.name; + normal = picked.normal !== false; + baseResp.service = serviceName; + } catch (err) { + return reply.send({ + ...baseResp, + reachable: false, + error: err instanceof Error ? err.message : String(err), + widgets: widgets.map((w) => ({ id: w.id, error: 'oap unreachable' })), + }); } // Step 2 — batch all widget × expression queries into one GraphQL trip. diff --git a/apps/bff/src/oap/endpoint-dependency-routes.ts b/apps/bff/src/oap/endpoint-dependency-routes.ts index ec7d186..34963e6 100644 --- a/apps/bff/src/oap/endpoint-dependency-routes.ts +++ b/apps/bff/src/oap/endpoint-dependency-routes.ts @@ -93,7 +93,7 @@ const ENDPOINT_DEPENDENCY = /* GraphQL */ ` } `; -const DEFAULT_WINDOW_MIN = 15; +const DEFAULT_WINDOW_MIN = 60; function fmtMinute(d: Date): string { const yyyy = d.getUTCFullYear(); const mm = String(d.getUTCMonth() + 1).padStart(2, '0'); diff --git a/apps/bff/src/oap/endpoint-routes.ts b/apps/bff/src/oap/endpoint-routes.ts index 6d6213e..eb80273 100644 --- a/apps/bff/src/oap/endpoint-routes.ts +++ b/apps/bff/src/oap/endpoint-routes.ts @@ -65,7 +65,7 @@ const FIND_ENDPOINTS = /* GraphQL */ ` } `; -const DEFAULT_WINDOW_MIN = 15; +const DEFAULT_WINDOW_MIN = 60; function fmtMinute(d: Date): string { const yyyy = d.getUTCFullYear(); diff --git a/apps/bff/src/oap/instance-routes.ts b/apps/bff/src/oap/instance-routes.ts index 8b62d71..bb17821 100644 --- a/apps/bff/src/oap/instance-routes.ts +++ b/apps/bff/src/oap/instance-routes.ts @@ -79,7 +79,7 @@ const LIST_INSTANCES = /* GraphQL */ ` } `; -const DEFAULT_WINDOW_MIN = 15; +const DEFAULT_WINDOW_MIN = 60; function fmtMinute(d: Date): string { const yyyy = d.getUTCFullYear(); diff --git a/apps/bff/src/oap/landing-routes.ts b/apps/bff/src/oap/landing-routes.ts index 1badbad..06f40d0 100644 --- a/apps/bff/src/oap/landing-routes.ts +++ b/apps/bff/src/oap/landing-routes.ts @@ -87,7 +87,7 @@ const LIST_SERVICES_QUERY = /* GraphQL */ ` } `; -const DEFAULT_WINDOW_MIN = 15; +const DEFAULT_WINDOW_MIN = 60; const SERVICE_QUERY_CAP = 25; const aggSchema = z.enum(['sum', 'avg']); diff --git a/apps/bff/src/oap/menu-routes.ts b/apps/bff/src/oap/menu-routes.ts index 29fef8a..99c9f94 100644 --- a/apps/bff/src/oap/menu-routes.ts +++ b/apps/bff/src/oap/menu-routes.ts @@ -168,6 +168,7 @@ function deriveLayer( active: boolean, level: number | null, serviceCount: number, + normal: boolean | null, items: MenuRaw['items'], ): LayerDef { const item = items.find((i) => canonical(i.layer) === rawKey); @@ -183,6 +184,7 @@ function deriveLayer( serviceCount, active, level, + normal, documentLink: tpl.documentLink ?? item?.documentLink ?? undefined, slots: tpl.slots, caps: componentsToCaps(tpl.components), @@ -199,6 +201,7 @@ function deriveLayer( serviceCount, active, level, + normal, documentLink: item?.documentLink ?? undefined, slots: def.slots, caps: def.caps, @@ -213,19 +216,29 @@ function deriveLayer( async function fetchCountsForLayers( layers: readonly string[], opts: GraphqlOptions, -): Promise<Map<string, number>> { - const map = new Map<string, number>(); +): Promise<Map<string, { count: number; normal: boolean | null }>> { + const map = new Map<string, { count: number; normal: boolean | null }>(); if (layers.length === 0) return map; - // GraphQL aliases must be valid identifiers — index-keyed. + // GraphQL aliases must be valid identifiers — index-keyed. Also pull + // the `normal` flag off the first service so callers can pivot the + // MQE entity scope (`{ normal: true|false }`) without a separate + // listServices roundtrip on every dashboard hit. const aliased = layers - .map((l, i) => `_${i}: listServices(layer: ${JSON.stringify(l)}) { id }`) + .map((l, i) => `_${i}: listServices(layer: ${JSON.stringify(l)}) { id normal }`) .join('\n'); const query = `query HorizonCounts { ${aliased} }`; try { - const data = await graphqlPostShim<Record<string, Array<{ id: string }>>>(opts, query); - layers.forEach((l, i) => map.set(l, (data[`_${i}`] ?? []).length)); + const data = await graphqlPostShim< + Record<string, Array<{ id: string; normal?: boolean | null }>> + >(opts, query); + layers.forEach((l, i) => { + const rows = data[`_${i}`] ?? []; + const first = rows[0]; + const normal = first ? (first.normal === false ? false : first.normal === true ? true : null) : null; + map.set(l, { count: rows.length, normal }); + }); } catch { - // Soft-fail: leave the map empty so deriveLayer falls back to -1. + // Soft-fail: leave the map empty so deriveLayer falls back to -1 / null. } return map; } @@ -250,9 +263,17 @@ export function registerMenuRoute(app: FastifyInstance, deps: MenuRouteDeps): vo // alias collapse is only a presentation concern. const counts = await fetchCountsForLayers(raw.layers, opts); const countByCanonical = new Map<string, number>(); + const normalByCanonical = new Map<string, boolean | null>(); for (const rawLayer of raw.layers) { const key = canonical(rawLayer); - countByCanonical.set(key, (countByCanonical.get(key) ?? 0) + (counts.get(rawLayer) ?? 0)); + const c = counts.get(rawLayer); + countByCanonical.set(key, (countByCanonical.get(key) ?? 0) + (c?.count ?? 0)); + // First non-null `normal` value wins for the canonical key — + // raw layers that fold into one canonical (e.g. mesh / mesh_cp) + // share the same `normal` in practice, so collisions are safe. + if (c?.normal !== undefined && c.normal !== null && !normalByCanonical.has(key)) { + normalByCanonical.set(key, c.normal); + } } // Catalog order = the order OAP returned `getMenuItems` (mirrors @@ -280,6 +301,7 @@ export function registerMenuRoute(app: FastifyInstance, deps: MenuRouteDeps): vo activeCanonical.has(key), levelByCanonical.has(key) ? (levelByCanonical.get(key) ?? null) : null, countByCanonical.get(key) ?? (activeCanonical.has(key) ? 0 : -1), + normalByCanonical.get(key) ?? null, raw.items, ), ); diff --git a/apps/bff/src/oap/topology-routes.ts b/apps/bff/src/oap/topology-routes.ts index 7ab9aa4..78e50e7 100644 --- a/apps/bff/src/oap/topology-routes.ts +++ b/apps/bff/src/oap/topology-routes.ts @@ -90,7 +90,7 @@ const LIST_SERVICES_FOR_RESOLVE = /* GraphQL */ ` } `; -const DEFAULT_WINDOW_MIN = 15; +const DEFAULT_WINDOW_MIN = 60; function fmtMinute(d: Date): string { const yyyy = d.getUTCFullYear(); const mm = String(d.getUTCMonth() + 1).padStart(2, '0'); @@ -284,16 +284,23 @@ export function registerTopologyRoute(app: FastifyInstance, deps: TopologyRouteD knownServices.set(s.id, { id: s.id, name: s.name, normal: s.normal !== false }); } if (serviceArg) { - const match = - data.services.find((s) => s.id === serviceArg) ?? - data.services.find((s) => s.name === serviceArg) ?? - null; - if (!match) { + // `service` accepts a comma-separated list of names/ids so + // the SPA can multi-seed without a separate query param. Any + // entry that doesn't resolve is reported back individually + // instead of failing the whole request. + const wants = serviceArg.split(',').map((s) => s.trim()).filter(Boolean); + const matches = wants.map((w) => + data.services.find((s) => s.id === w) ?? + data.services.find((s) => s.name === w) ?? + null, + ); + const missing = wants.filter((_, i) => matches[i] === null); + if (matches.every((m) => m === null)) { return reply.send( - emptyResponse(layerKey, serviceArg, depth, topoCfg, true, 'service not found'), + emptyResponse(layerKey, serviceArg, depth, topoCfg, true, `service${wants.length === 1 ? '' : 's'} not found: ${missing.join(', ')}`), ); } - seedIds = [match.id]; + seedIds = matches.filter((m): m is { id: string; name: string; normal?: boolean | null } => m !== null).map((m) => m.id); } else { seedIds = data.services.slice(0, 30).map((s) => s.id); } diff --git a/apps/ui/src/components/shell/AppSidebar.vue b/apps/ui/src/components/shell/AppSidebar.vue index 699a3dd..113c4e3 100644 --- a/apps/ui/src/components/shell/AppSidebar.vue +++ b/apps/ui/src/components/shell/AppSidebar.vue @@ -185,9 +185,6 @@ const sections: NavSection[] = [ :class="{ 'is-active': isActive(`/layer/${L.key}`) }" > <span class="layer-dot" :style="{ background: L.color }" /> - <span class="layer-count" :title="`${L.serviceCount} service${L.serviceCount === 1 ? '' : 's'} reporting`"> - {{ L.serviceCount }} - </span> <span class="layer-name">{{ L.name }}</span> </RouterLink> @@ -199,9 +196,6 @@ const sections: NavSection[] = [ @click="toggleLayer(L.key)" > <span class="layer-dot" :style="{ background: L.color }" /> - <span class="layer-count" :title="`${L.serviceCount} service${L.serviceCount === 1 ? '' : 's'} reporting`"> - {{ L.serviceCount }} - </span> <span class="layer-name" :style="{ fontWeight: expandedLayer === L.key ? 600 : 500 }"> {{ L.name }} </span> @@ -390,27 +384,6 @@ const sections: NavSection[] = [ text-overflow: ellipsis; white-space: nowrap; } -.layer-row .layer-count { - font-family: var(--sw-mono); - font-size: 10.5px; - font-variant-numeric: tabular-nums; - color: var(--sw-fg-1); - background: var(--sw-bg-2); - border: 1px solid var(--sw-line-2); - border-radius: 4px; - padding: 1px 4px; - /* Fixed width so every row's name starts at the same column even when - counts swing wildly (1 ↔ 1000+). The chip is right-aligned inside - so the digit always sits flush against the name. */ - width: 34px; - flex: 0 0 34px; - text-align: right; -} -.layer-row.is-active .layer-count { - color: var(--sw-accent-2); - background: var(--sw-accent-soft); - border-color: var(--sw-accent-line); -} .layer-row.direct { text-decoration: none; } diff --git a/apps/ui/src/router/index.ts b/apps/ui/src/router/index.ts index 1abdf0b..502dff2 100644 --- a/apps/ui/src/router/index.ts +++ b/apps/ui/src/router/index.ts @@ -49,7 +49,15 @@ function layerRoute(): RouteRecordRaw { { path: 'service', component: () => import('@/views/layer/LayerDashboardsView.vue') }, { path: 'instance', component: () => import('@/views/layer/LayerDashboardsView.vue') }, { path: 'endpoint', component: () => import('@/views/layer/LayerDashboardsView.vue') }, - { path: 'topology', component: () => import('@/views/layer/LayerServiceMapView.vue') }, + { + path: 'topology', + component: () => import('@/views/layer/LayerServiceMapView.vue'), + // The topology page ships its own in-box service-focus selector + // (the map is layer-wide by default). Declaring it here keeps + // the LayerShell's header picker hidden for this route — no + // route-string sniffing in the shell. + meta: { ownsServiceSelector: true }, + }, { path: 'dependency', component: () => import('@/views/layer/LayerEndpointDependencyView.vue') }, { path: 'trace', component: () => import('@/views/layer/LayerTracesView.vue') }, { path: 'logs', component: () => import('@/views/layer/LayerLogsView.vue') }, diff --git a/apps/ui/src/utils/serviceName.ts b/apps/ui/src/utils/serviceName.ts new file mode 100644 index 0000000..0f5e051 --- /dev/null +++ b/apps/ui/src/utils/serviceName.ts @@ -0,0 +1,60 @@ +/* + * 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. + */ + +/** + * Service-name group parsing. OAP encodes a group prefix with `::` — + * e.g. `agent::songs`, `mesh::checkout`. The prefix is a deployment + * grouping (k8s namespace, fleet, source) that operators want surfaced + * as a separate visual element rather than crowded into the service + * name itself. + * + * Rendering rule applied everywhere across the UI: + * - Service lists / pickers / KPI strips → render `<group-chip> <base-name>` + * so the group reads as a category tag and the eye lands on the base name + * - Topology nodes → render `base` only, with the group available in hover + * / detail panels (the SVG label area is too tight for both) + * + * Multiple `::` segments collapse to: first segment = group, remainder = base + * (so `eu::prod::checkout` → group: `eu`, base: `prod::checkout`). + */ +export interface ParsedServiceName { + /** Group prefix when the raw name contains `::`. */ + group: string | null; + /** Service name with the `<group>::` prefix stripped; equal to `raw` + * when there is no group. */ + base: string; + /** Original full name (echoed so callers don't have to keep it around). */ + raw: string; +} + +export function parseServiceName(raw: string | null | undefined): ParsedServiceName { + const r = raw ?? ''; + const idx = r.indexOf('::'); + if (idx <= 0) return { group: null, base: r, raw: r }; + return { group: r.slice(0, idx), base: r.slice(idx + 2), raw: r }; +} + +/** Display helper — base only. Use in tight spots (graph nodes, chips). */ +export function serviceBaseName(raw: string | null | undefined): string { + return parseServiceName(raw).base; +} + +/** Display helper — group only (null when no group). Renderers should + * treat null as "no group chip". */ +export function serviceGroupName(raw: string | null | undefined): string | null { + return parseServiceName(raw).group; +} diff --git a/apps/ui/src/views/layer/LayerServiceMapView.vue b/apps/ui/src/views/layer/LayerServiceMapView.vue index 0f284c9..4774c81 100644 --- a/apps/ui/src/views/layer/LayerServiceMapView.vue +++ b/apps/ui/src/views/layer/LayerServiceMapView.vue @@ -61,18 +61,17 @@ import type { TopologyNode, } from '@/api/client'; import { useLayerTopology } from '@/composables/useLayerTopology'; -import { useSelectedService } from '@/composables/useSelectedService'; import { useLayerLanding } from '@/composables/useLayerLanding'; import { useLayers } from '@/composables/useLayers'; import { useSetupStore } from '@/stores/setup'; import { fmtMetric } from '@/utils/formatters'; +import { parseServiceName, serviceBaseName } from '@/utils/serviceName'; import Sparkline from '@/components/charts/Sparkline.vue'; import { isUserNode } from '@/composables/useTopologyIcons'; const route = useRoute(); const router = useRouter(); const layerKey = computed(() => String(route.params.layerKey ?? '')); -const { selectedId, setSelected: setSelectedService } = useSelectedService(); const { layers } = useLayers(); const layer = computed<LayerDef | null>( @@ -90,22 +89,50 @@ const safeCfg = computed(() => { }).landing; }); const landing = useLayerLanding(safeLayer, safeCfg); -const serviceName = computed<string | null>(() => { - const rows = landing.data.value?.sampledRows ?? landing.rows.value ?? []; - const match = rows.find((r) => r.serviceId === selectedId.value); - return match?.serviceName ?? null; -}); const landingRows = computed(() => landing.data.value?.sampledRows ?? landing.rows.value ?? []); -watch( - landingRows, - (rows) => { - if (selectedId.value) return; - const first = rows[0]; - if (first) setSelectedService(first.serviceId); - }, - { immediate: true }, + +// Focus-service is local to the topology view (NOT the header's +// `useSelectedService` — the topology map is layer-wide by default). +// - empty array = no focus, BFF seeds from up-to-30 services in the +// layer for a layer-overview graph +// - one or more entries = comma-joined and passed as `?service=` so +// the BFF seeds BFS from each selected service (multi-select) +const focusServiceNames = ref<string[]>([]); +const focusSearch = ref<string>(''); +const focusPickerOpen = ref(false); +function toggleFocusPicker(): void { focusPickerOpen.value = !focusPickerOpen.value; } +function toggleService(name: string): void { + const i = focusServiceNames.value.indexOf(name); + if (i >= 0) focusServiceNames.value.splice(i, 1); + else focusServiceNames.value.push(name); +} +function clearFocus(): void { focusServiceNames.value = []; focusPickerOpen.value = false; } +const serviceName = computed<string | null>(() => + focusServiceNames.value.length === 0 ? null : focusServiceNames.value.join(','), ); +// Service-list rows grouped by `<group>::` prefix so the search panel +// can render "agent" / "mesh" / "" sections. +interface GroupedRow { group: string | null; name: string; id: string } +const groupedRows = computed<Map<string, GroupedRow[]>>(() => { + const map = new Map<string, GroupedRow[]>(); + const term = focusSearch.value.trim().toLowerCase(); + for (const r of landingRows.value) { + const { group, base } = parseServiceName(r.serviceName); + if (term && !r.serviceName.toLowerCase().includes(term)) continue; + const key = group ?? ''; + if (!map.has(key)) map.set(key, []); + map.get(key)!.push({ group, name: base, id: r.serviceId }); + } + return map; +}); + +// Defensive truncate for long node labels — preserves the head + an +// ellipsis so cluster IDs that share a long prefix still distinguish. +function truncateLabel(s: string, n: number): string { + return s.length > n ? s.slice(0, n - 2) + '…' : s; +} + const depth = ref<number>(2); const { nodes, calls, isLoading, isFetching, data, refetch } = useLayerTopology( layerKey, @@ -288,74 +315,9 @@ const layoutNodes = computed<LayoutNode[]>(() => { return all.map((n) => ({ ...n, layerIdx: layerOf.get(n.id)! })); }); -// ── Heaviest-path. Walk from the busiest entry; at each step pick the -// outgoing edge with the highest server-cpm (preferred) or client-cpm -// (fallback). The order of preference is operator-controlled via the -// node ordering in `linkServerMetrics` / `linkClientMetrics`. -function edgeWeight(c: TopologyCall): number { - const s = edgeVal(c, 'server', lineServerDef.value); - if (s !== null) return s; - const cl = edgeVal(c, 'client', lineClientDef.value); - if (cl !== null) return cl; - return 0; -} -const heaviestEdges = computed<Set<string>>(() => { - const out = new Set<string>(); - const callsList = calls.value; - if (callsList.length === 0) return out; - const byId = new Map(layoutNodes.value.map((n) => [n.id, n])); - const outBy = new Map<string, TopologyCall[]>(); - for (const c of callsList) { - if (!outBy.has(c.source)) outBy.set(c.source, []); - outBy.get(c.source)!.push(c); - } - const roots = layoutNodes.value.filter((n) => n.layerIdx === 0); - function rootScore(n: LayoutNode): number { - const own = nodeVal(n, centerDef.value); - if (own !== null) return own; - const outs = outBy.get(n.id) ?? []; - let best = 0; - for (const c of outs) { - const t = byId.get(c.target); - if (t) best = Math.max(best, nodeVal(t, centerDef.value) ?? 0, edgeWeight(c)); - } - return best; - } - const sortedRoots = [...roots].sort((a, b) => rootScore(b) - rootScore(a)); - const start = sortedRoots[0]; - if (!start) return out; - let cursor: LayoutNode | undefined = start; - const seen = new Set<string>(); - while (cursor && !seen.has(cursor.id)) { - seen.add(cursor.id); - const outs = outBy.get(cursor.id) ?? []; - if (outs.length === 0) break; - let best: TopologyCall | null = null; - let bestScore = -Infinity; - for (const c of outs) { - const score = edgeWeight(c); - if (score > bestScore) { - bestScore = score; - best = c; - } - } - if (!best) break; - out.add(best.id); - cursor = byId.get(best.target); - } - return out; -}); -const heaviestNodes = computed<Set<string>>(() => { - const set = new Set<string>(); - const byId = new Map(calls.value.map((c) => [c.id, c])); - for (const id of heaviestEdges.value) { - const c = byId.get(id); - if (!c) continue; - set.add(c.source); - set.add(c.target); - } - return set; -}); +// (Heaviest-path overlay removed — every edge now reads as equally +// important. The line is constant-weight; direction is conveyed by an +// animated dashed flow on every edge.) const NODES_PER_LAYER = 12; interface LayerColumn { @@ -371,18 +333,18 @@ const layerColumns = computed<LayerColumn[]>(() => { byLayer.get(n.layerIdx)!.push(n); } const indices = [...byLayer.keys()].sort((a, b) => a - b); - const heavy = heaviestNodes.value; return indices.map((i) => { + // Per-column sort: busiest by `center` metric first. No heaviest-path + // boost — every node is treated as a peer; overflow is purely by + // metric value (so the visible 12 are the loudest, not the ones on + // a synthetic critical path). const list = byLayer.get(i)!.slice().sort((a, b) => { - const hA = heavy.has(a.id) ? 1 : 0; - const hB = heavy.has(b.id) ? 1 : 0; - if (hA !== hB) return hB - hA; return (nodeVal(b, centerDef.value) ?? 0) - (nodeVal(a, centerDef.value) ?? 0); }); const keep: LayoutNode[] = []; const overflow: LayoutNode[] = []; for (const n of list) { - if (keep.length < NODES_PER_LAYER || heavy.has(n.id)) keep.push(n); + if (keep.length < NODES_PER_LAYER) keep.push(n); else overflow.push(n); } const label = i === 0 ? 'L0 · Entry' : `L${i} · Tier ${i}`; @@ -850,12 +812,67 @@ function fmtWithUnit(v: number | null | undefined, unit: string | undefined): st <header class="sm-toolbar sw-card"> <div class="left"> <span class="kicker">Topology</span> - <span v-if="serviceName" class="for-svc">centred on <b>{{ serviceName }}</b></span> - <span v-else class="for-svc">layer overview</span> + <span v-if="focusServiceNames.length === 0" class="for-svc">layer overview · all services</span> + <span v-else class="for-svc"> + focused on + <b>{{ focusServiceNames.length === 1 ? serviceBaseName(focusServiceNames[0]) : `${focusServiceNames.length} services` }}</b> + </span> <span v-if="isFetching" class="hint">refreshing…</span> </div> <div class="right"> - <label class="depth-pick"> + <!-- Focus picker — lives INSIDE the topology box so the + header service picker stays out of the layer-wide map. + Supports multi-select + search. Closes by clicking outside + the chips (via the wrapper @click.stop). --> + <div class="focus-wrap" @click.stop> + <button class="focus-btn sw-btn small" type="button" @click="toggleFocusPicker"> + <span class="focus-btn-label"> + {{ focusServiceNames.length === 0 ? 'All services' : focusServiceNames.length + ' selected' }} + </span> + <span class="caret" :class="{ open: focusPickerOpen }">▾</span> + </button> + <div v-if="focusPickerOpen" class="focus-pop sw-card"> + <input + v-model="focusSearch" + class="focus-search" + type="text" + placeholder="Search services…" + autofocus + /> + <div class="focus-list"> + <button + class="focus-row clear" + :class="{ selected: focusServiceNames.length === 0 }" + type="button" + @click="clearFocus" + > + <span class="focus-check">{{ focusServiceNames.length === 0 ? '●' : '○' }}</span> + <span class="focus-name">All services</span> + <span class="focus-aside">{{ landingRows.length }} total</span> + </button> + <template v-for="[gkey, rows] in groupedRows" :key="gkey"> + <div v-if="gkey" class="focus-group-head">{{ gkey }}</div> + <button + v-for="r in rows" + :key="r.id" + class="focus-row" + :class="{ selected: focusServiceNames.includes((r.group ? r.group + '::' : '') + r.name) }" + type="button" + @click="toggleService((r.group ? r.group + '::' : '') + r.name)" + > + <span class="focus-check">{{ focusServiceNames.includes((r.group ? r.group + '::' : '') + r.name) ? '●' : '○' }}</span> + <span class="focus-name">{{ r.name }}</span> + </button> + </template> + <div v-if="groupedRows.size === 0" class="focus-empty">no matches</div> + </div> + </div> + </div> + <!-- Depth is only meaningful when a focus seed is picked — + "All services" already seeds from up to 30 layer services + so a BFS-depth control would either be redundant or + explode the graph. --> + <label v-if="focusServiceNames.length > 0" class="depth-pick"> <span>Depth</span> <select v-model.number="depth"> <option :value="1">1 hop</option> @@ -928,36 +945,40 @@ function fmtWithUnit(v: number | null | undefined, unit: string | undefined): st stroke-width="14" style="cursor: pointer" /> + <!-- Base edge line. Uniform width across the canvas now + that heaviest-path is gone — every edge is visually + peer; selection brightens. --> <path :d="callPathD(c)" fill="none" :stroke="selectedCallId === c.id ? 'var(--sw-accent-2)' : 'var(--sw-accent)'" - :stroke-width="selectedCallId === c.id ? 3.4 : heaviestEdges.has(c.id) ? 2.8 : 1.4" - :opacity="selectedCallId === c.id ? 1 : heaviestEdges.has(c.id) ? 0.95 : 0.6" + :stroke-width="selectedCallId === c.id ? 3.2 : 1.8" + :opacity="selectedCallId === c.id ? 1 : 0.7" stroke-linecap="round" style="pointer-events: none" /> - <!-- Animated traffic dots — only on heavy / selected - edges so the canvas doesn't shimmer everywhere. - Mirrors the polished linear-chain design's "live - flow" suggestion. --> - <template v-if="heaviestEdges.has(c.id) || selectedCallId === c.id"> - <circle - v-for="off in [0, 0.5, 1.0]" - :key="off" - r="2.2" - :fill="selectedCallId === c.id ? 'var(--sw-accent-2)' : 'var(--sw-accent)'" - opacity="0.85" - style="pointer-events: none" - > - <animateMotion - :dur="`${2.4 + (off * 0.4)}s`" - :begin="`${off}s`" - repeatCount="indefinite" - :path="callPathD(c)" - /> - </circle> - </template> + <!-- Direction overlay: a dashed stroke that scrolls along + the path from source → target. Same stroke colour but + higher-frequency dashes so the motion reads even on + dense graphs without competing with the base line. --> + <path + :d="callPathD(c)" + fill="none" + :stroke="selectedCallId === c.id ? 'var(--sw-accent-2)' : 'var(--sw-accent)'" + :stroke-width="selectedCallId === c.id ? 3.2 : 1.8" + stroke-linecap="round" + stroke-dasharray="6 10" + opacity="0.9" + style="pointer-events: none" + > + <animate + attributeName="stroke-dashoffset" + from="16" + to="0" + dur="1.2s" + repeatCount="indefinite" + /> + </path> <!-- Edge metric chip — sits on the line midpoint with a pill background. Compact by design (edge metrics aren't the headline signal; they ride alongside the @@ -979,14 +1000,14 @@ function fmtWithUnit(v: number | null | undefined, unit: string | undefined): st height="20" rx="10" fill="var(--sw-bg-1)" - :stroke="selectedCallId === c.id ? 'var(--sw-accent-2)' : heaviestEdges.has(c.id) ? 'var(--sw-accent)' : 'var(--sw-line-2)'" + :stroke="selectedCallId === c.id ? 'var(--sw-accent-2)' : 'var(--sw-line-2)'" :stroke-width="selectedCallId === c.id ? 1.4 : 1" /> <text x="38" y="14" text-anchor="middle" - :fill="selectedCallId === c.id ? 'var(--sw-accent-2)' : heaviestEdges.has(c.id) ? 'var(--sw-accent-2)' : 'var(--sw-fg-1)'" + :fill="selectedCallId === c.id ? 'var(--sw-accent-2)' : 'var(--sw-fg-1)'" font-size="11" font-family="var(--sw-mono)" font-weight="700" @@ -1031,13 +1052,6 @@ function fmtWithUnit(v: number | null | undefined, unit: string | undefined): st opacity="0.7" /> </template> - <!-- Heavy-path halo when not selected. --> - <circle - v-else-if="heaviestNodes.has(n.id)" - r="50" - fill="var(--sw-accent)" - opacity="0.10" - /> <!-- Outer ring (health). Dashed for client / external to signal "untraced" (no agent here). Solid stroke for real services. --> @@ -1123,6 +1137,10 @@ function fmtWithUnit(v: number | null | undefined, unit: string | undefined): st <!-- Name below the node. Slightly larger now that the circle radius is smaller — keeps the label as the readable anchor for the node. --> + <!-- Node label = base name only. The group prefix + (`<group>::base`) appears as a chip in the right + sidebar; jamming it into the canvas label competes + with the metric line below. --> <text text-anchor="middle" y="58" @@ -1131,7 +1149,7 @@ function fmtWithUnit(v: number | null | undefined, unit: string | undefined): st font-family="var(--sw-mono)" :font-weight="selectedNodeId === n.id ? 700 : 600" > - {{ n.name.length > 22 ? n.name.slice(0, 20) + '…' : n.name }} + {{ truncateLabel(serviceBaseName(n.name), 22) }} </text> <!-- Metric line. Operator-configured `center` metric in the ring colour; `secondary` next to it muted. @@ -1185,9 +1203,7 @@ function fmtWithUnit(v: number | null | undefined, unit: string | undefined): st <div class="lg-row"> <span class="lg-swatch" style="background: var(--sw-accent)" /> <span>Calls</span> - <span class="lg-aside"> - thicker = heaviest (by {{ lineServerDef?.label ?? 'server' }}<template v-if="lineClientDef"> · falls back to {{ lineClientDef.label }}</template>) - </span> + <span class="lg-aside">direction shown by flow animation</span> </div> </div> <div v-if="elidedTotal > 0" class="cap-chip"> @@ -1208,9 +1224,9 @@ function fmtWithUnit(v: number | null | undefined, unit: string | undefined): st <div class="sp-id"> <div class="sp-mono">{{ selectedNode.name }}</div> <div class="sp-tags"> + <span v-if="parseServiceName(selectedNode.name).group" class="sw-tag accent">{{ parseServiceName(selectedNode.name).group }}</span> <span v-for="l in selectedNode.layers" :key="l" class="sw-tag">{{ l }}</span> <span v-if="!selectedNode.isReal" class="sw-tag">virtual</span> - <span v-if="heaviestNodes.has(selectedNode.id)" class="sw-tag accent">main path</span> </div> </div> <button class="sw-btn small" type="button" @click="selectedNodeId = null">×</button> @@ -1269,7 +1285,6 @@ function fmtWithUnit(v: number | null | undefined, unit: string | undefined): st </div> <div class="sp-tags"> <span class="sw-tag">{{ selectedCall.detectPoints.join(' · ') || 'relation' }}</span> - <span v-if="heaviestEdges.has(selectedCall.id)" class="sw-tag accent">main path</span> </div> </div> <button class="sw-btn small" type="button" @click="selectedCallId = null">×</button> @@ -1465,6 +1480,74 @@ function fmtWithUnit(v: number | null | undefined, unit: string | undefined): st font: inherit; font-size: 11px; } +/* Focus selector — opens a search-driven multi-select panel anchored + to the button. Wide enough to read group + name in one row even on + long k8s service names. */ +.focus-wrap { position: relative; } +.focus-btn { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 160px; + justify-content: space-between; +} +.focus-btn .caret { font-size: 9px; color: var(--sw-fg-3); transition: transform 0.12s; } +.focus-btn .caret.open { transform: rotate(180deg); } +.focus-pop { + position: absolute; + top: calc(100% + 6px); + right: 0; + width: 360px; + max-height: 380px; + display: flex; + flex-direction: column; + padding: 8px; + z-index: 30; +} +.focus-search { + height: 32px; + padding: 0 10px; + margin-bottom: 6px; + background: var(--sw-bg-2); + border: 1px solid var(--sw-line-2); + border-radius: 6px; + color: var(--sw-fg-0); + font: inherit; + font-size: 13px; + outline: none; +} +.focus-search:focus { border-color: var(--sw-accent-line); } +.focus-list { overflow-y: auto; flex: 1 1 auto; } +.focus-group-head { + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--sw-fg-3); + padding: 6px 8px 4px; +} +.focus-row { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 8px; + background: transparent; + border: 0; + border-radius: 4px; + color: var(--sw-fg-1); + font: inherit; + font-size: 12px; + text-align: left; + cursor: pointer; +} +.focus-row:hover { background: var(--sw-bg-2); color: var(--sw-fg-0); } +.focus-row.selected { color: var(--sw-accent-2); } +.focus-row .focus-check { width: 12px; text-align: center; color: var(--sw-accent); } +.focus-row .focus-name { flex: 1; font-family: var(--sw-mono); } +.focus-row .focus-aside { font-size: 10.5px; color: var(--sw-fg-3); } +.focus-row.clear { border-bottom: 1px dashed var(--sw-line); padding-bottom: 8px; margin-bottom: 4px; } +.focus-empty { padding: 16px; text-align: center; color: var(--sw-fg-3); font-size: 11.5px; } .banner.err { padding: 8px 12px; background: var(--sw-err-soft); diff --git a/apps/ui/src/views/layer/LayerServiceSelector.vue b/apps/ui/src/views/layer/LayerServiceSelector.vue index a1f1106..4e0dd2b 100644 --- a/apps/ui/src/views/layer/LayerServiceSelector.vue +++ b/apps/ui/src/views/layer/LayerServiceSelector.vue @@ -28,6 +28,7 @@ import type { LandingColumn, LandingServiceRow } from '@skywalking-horizon-ui/ap import { metricMeta } from '@/composables/metricCatalog'; import { statusForMetrics, thresholdColor } from '@/composables/metricColor'; import { fmtMetric } from '@/utils/formatters'; +import { parseServiceName } from '@/utils/serviceName'; const props = withDefaults( defineProps<{ @@ -101,7 +102,8 @@ function colorForStatus(s: 'ok' | 'warn' | 'err'): string { > <td class="svc-col" :title="row.serviceName"> <span class="pulse" :style="{ background: colorForStatus(statusForMetrics(row.metrics)) }" /> - <span class="name-text">{{ row.shortName || row.serviceName }}</span> + <span v-if="parseServiceName(row.serviceName).group" class="group-chip">{{ parseServiceName(row.serviceName).group }}</span> + <span class="name-text">{{ row.shortName || parseServiceName(row.serviceName).base }}</span> </td> <td v-for="c in columns" @@ -247,6 +249,27 @@ function colorForStatus(s: 'ok' | 'warn' | 'err'): string { color: var(--sw-fg-0); font-size: 11.5px; } +/* Group prefix from OAP's `<group>::<base>` naming — surfaced as a + compact tag so the base name is the first thing the eye lands on. + Trims `agent::rating` → [agent] rating, etc. */ +.group-chip { + display: inline-block; + margin-right: 6px; + padding: 1px 6px; + background: var(--sw-bg-2); + border: 1px solid var(--sw-line-2); + border-radius: 4px; + font-size: 9.5px; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--sw-fg-2); + text-transform: uppercase; + vertical-align: middle; +} +.row.active .group-chip { + color: var(--sw-accent-2); + border-color: var(--sw-accent-line); +} .pager { display: flex; align-items: center; diff --git a/apps/ui/src/views/layer/LayerShell.vue b/apps/ui/src/views/layer/LayerShell.vue index 76b0aba..9c379e6 100644 --- a/apps/ui/src/views/layer/LayerShell.vue +++ b/apps/ui/src/views/layer/LayerShell.vue @@ -39,6 +39,7 @@ import { useLayers } from '@/composables/useLayers'; import { useSelectedService } from '@/composables/useSelectedService'; import { useSetupStore } from '@/stores/setup'; import { fmtMetric } from '@/utils/formatters'; +import { parseServiceName } from '@/utils/serviceName'; const route = useRoute(); const layerKey = computed(() => String(route.params.layerKey ?? '')); @@ -84,9 +85,23 @@ const selectedRow = computed( sampledServices.value[0] ?? null, ); -const selectedName = computed( - () => selectedRow.value?.serviceName ?? (sampledServices.value.length > 0 ? 'pick a service' : '—'), -); +const selectedParsed = computed(() => parseServiceName(selectedRow.value?.serviceName)); +const selectedGroup = computed(() => selectedParsed.value.group); +// Switch-button label — base name only when the service has a group +// prefix (`<group>::<base>` from OAP). The group is rendered as a +// separate chip next to the caret so the user reads it without the +// raw `::` syntax bleeding into the UI. +const selectedName = computed(() => { + if (selectedRow.value) return selectedParsed.value.base; + return sampledServices.value.length > 0 ? 'pick a service' : '—'; +}); + +// Routes can declare `meta: { ownsServiceSelector: true }` to opt out +// of the shell-level service picker. Topology is the canonical example +// (its in-box focus selector is the right place to scope the map); +// future component-driven views (dashboards with their own filters, +// say) can flip the same meta flag without touching this file. +const viewOwnsServiceSelector = computed(() => Boolean(route.meta?.ownsServiceSelector)); // Picker toggle state. Lives at the shell level so the header's Switch // button and the picker section render against the same state. @@ -234,8 +249,13 @@ const serviceKpis = computed<HeaderKpi[]>(() => { <!-- Row 2: Switch button + selected-service KPIs. Distinct from the layer aggregates above — these are the picked service's per-row metric values (no sparklines; the service-scope - dashboard below carries the trend charts). --> - <div v-if="sampledServices.length > 0" class="service-row"> + dashboard below carries the trend charts). + + Hidden on the Topology route: the service map is layer-wide + by design (all services in the layer), and its in-box focus + selector is the right place to scope to a specific service + from inside that view. --> + <div v-if="sampledServices.length > 0 && !viewOwnsServiceSelector" class="service-row"> <button class="sw-btn switch" type="button" @@ -243,6 +263,7 @@ const serviceKpis = computed<HeaderKpi[]>(() => { @click="togglePicker" > <span class="caret">▾</span> + <span v-if="selectedGroup" class="svc-group">{{ selectedGroup }}</span> <span class="svc-name">{{ selectedName }}</span> </button> <div class="kpi-strip service-kpis"> @@ -262,7 +283,7 @@ const serviceKpis = computed<HeaderKpi[]>(() => { Sits below the General header so the page reads top-to-bottom: layer identity → expanded service picker → sub-route body. --> <LayerServiceSelector - v-if="layer && pickerOpen && sampledServices.length > 0" + v-if="layer && pickerOpen && sampledServices.length > 0 && !viewOwnsServiceSelector" :services="sampledServices" :columns="selectorColumns" :selected-id="selectedId" @@ -376,6 +397,23 @@ const serviceKpis = computed<HeaderKpi[]>(() => { color: var(--sw-fg-0); letter-spacing: -0.01em; } +/* Group chip on the Switch button — surfaces OAP's `<group>::<base>` + prefix so the base name reads clean (e.g. `[agent] rating` instead + of `agent::rating`). */ +.switch .svc-group { + display: inline-block; + margin-right: 6px; + padding: 1px 6px; + background: var(--sw-bg-2); + border: 1px solid var(--sw-line-2); + border-radius: 4px; + font-size: 9.5px; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--sw-fg-2); + text-transform: uppercase; + vertical-align: middle; +} .icon-tile { width: 40px; height: 40px; diff --git a/packages/api-client/src/menu.ts b/packages/api-client/src/menu.ts index 0aaf9a1..3195cac 100644 --- a/packages/api-client/src/menu.ts +++ b/packages/api-client/src/menu.ts @@ -173,6 +173,13 @@ export interface LayerDef { serviceCount: number; /** True iff OAP returned this layer in `listLayers` (services reporting). */ active: boolean; + /** OAP's per-service `normal` flag, sampled from the first service in + * the layer. In practice every service within a layer shares the + * same value (VIRTUAL_*, AWS_* are all `false`; GENERAL/MESH/etc are + * all `true`), so a single bool per layer is faithful. `null` when + * the layer has no services reporting. The MQE entity scope on the + * dashboard / landing routes pivots on this. */ + normal?: boolean | null; /** Hierarchy level from `listLayerLevels`; null if not in the hierarchy table. */ level: number | null; /** External documentation link from `getMenuItems.documentLink`. */
