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 93525d3 layer: services tab with polar constellation + service-detail
drill
93525d3 is described below
commit 93525d38fe4464061e4cc295df28692e6cf226b2
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 12 16:12:32 2026 +0800
layer: services tab with polar constellation + service-detail drill
LayerConstellation renders the sampled service set as a polar plot —
angle from list order, radius from log of the configured traffic
metric, dot color from the configured error metric's band. Clicking a
dot fires a 'pick' event the parent uses to navigate to the service
detail view.
LayerServicesView wires the constellation alongside a Top-N service
table that reuses the Overview card row format. Both views read from
the same /api/layer/:key/landing query — vue-query caches across both
so opening the per-layer page after the Overview is instant.
LayerServiceDetailView opens at /layer/:key/services/:id when a service
is picked. Shows per-service KPI tiles built from the layer's column
setup, plus a 'deep dive' grid that previews where future per-service
views (instances, endpoints, traces, logs, dashboards, profiling) will
land — each card phase-tagged so operators see what's gated by which
phase.
BFF response gains `sampledRows` (full probed set, sorted) alongside
`rows` (topN slice). Backward compatible: existing Overview consumers
keep reading `rows`.
---
apps/bff/src/oap/landing-routes.ts | 4 +
apps/ui/src/router/index.ts | 18 +-
apps/ui/src/views/layer/LayerConstellation.vue | 274 ++++++++++++++++
apps/ui/src/views/layer/LayerServiceDetailView.vue | 347 +++++++++++++++++++++
apps/ui/src/views/layer/LayerServicesView.vue | 283 +++++++++++++++++
packages/api-client/src/landing.ts | 7 +
6 files changed, 928 insertions(+), 5 deletions(-)
diff --git a/apps/bff/src/oap/landing-routes.ts
b/apps/bff/src/oap/landing-routes.ts
index dd1ea22..5ca7242 100644
--- a/apps/bff/src/oap/landing-routes.ts
+++ b/apps/bff/src/oap/landing-routes.ts
@@ -473,6 +473,10 @@ export function registerLandingRoute(app: FastifyInstance,
deps: LandingRouteDep
durationStart: window.start,
durationEnd: window.end,
rows: topRows,
+ // `rows` is already sorted desc by orderBy and sliced to topN;
+ // `sampledRows` is the full set the BFF probed (post-sort), so
+ // per-layer views can render the long tail without a second call.
+ sampledRows: rows,
aggregates,
reachable: true,
};
diff --git a/apps/ui/src/router/index.ts b/apps/ui/src/router/index.ts
index 7f3ddff..50ce79b 100644
--- a/apps/ui/src/router/index.ts
+++ b/apps/ui/src/router/index.ts
@@ -28,8 +28,10 @@ function humanKey(k: string): string {
// reads `:layerKey` from the URL and pulls layer config / live data.
// Sub-route components fill the tab body via a nested router-view.
function layerRoute(): RouteRecordRaw {
- const features: { path: string; label: string; phase: string }[] = [
- { path: 'services', label: 'Services', phase: 'Phase 2 / 3' },
+ // Tabs that still render generic placeholders. Services drops out
+ // because it has a real component; service-detail is a nested child
+ // of services so the breadcrumb stays clean.
+ const placeholderTabs: { path: string; label: string; phase: string }[] = [
{ path: 'instances', label: 'Instances', phase: 'Phase 2 / 3' },
{ path: 'endpoints', label: 'Endpoints', phase: 'Phase 2 / 3' },
{ path: 'topology', label: 'Topology', phase: 'Phase 4' },
@@ -46,14 +48,20 @@ function layerRoute(): RouteRecordRaw {
children: [
// Bare /layer/:layerKey lands on Services — the default entry.
{ path: '', redirect: (to) => ({ path:
`/layer/${to.params.layerKey}/services` }) },
- ...features.map<RouteRecordRaw>((f) => ({
+ // Services list — live constellation + top-N table.
+ { path: 'services', component: () =>
import('@/views/layer/LayerServicesView.vue') },
+ // Service detail — KPIs scoped to one service. Component lazily
+ // loaded so the chunk doesn't bloat the layer-shell entry.
+ {
+ path: 'services/:serviceId',
+ component: () => import('@/views/layer/LayerServiceDetailView.vue'),
+ },
+ ...placeholderTabs.map<RouteRecordRaw>((f) => ({
path: f.path,
component: placeholder,
props: (r) => ({
title: `${humanKey(String(r.params.layerKey))} · ${f.label}`,
phase: f.phase,
- // The shell renders its own header — placeholder mode for tab
- // bodies shows just the phase note.
inset: true,
}),
})),
diff --git a/apps/ui/src/views/layer/LayerConstellation.vue
b/apps/ui/src/views/layer/LayerConstellation.vue
new file mode 100644
index 0000000..feed666
--- /dev/null
+++ b/apps/ui/src/views/layer/LayerConstellation.vue
@@ -0,0 +1,274 @@
+<!--
+ 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.
+-->
+<!--
+ Polar service-constellation plot. Each service is a dot; angle is
+ determined by position in the list, radius by log(traffic), color +
+ halo by error band.
+
+ Hand-rolled SVG rather than ECharts — the chart shape is bespoke
+ (radial-spokes + concentric-rings + log-scaled radius + error-band
+ coloring) and fitting it through ECharts' polar API costs more than
+ it saves. The component is small and disposes cleanly.
+-->
+<script setup lang="ts">
+import { computed } from 'vue';
+import type { LandingServiceRow } from '@skywalking-horizon-ui/api-client';
+
+const props = withDefaults(
+ defineProps<{
+ services: ReadonlyArray<LandingServiceRow>;
+ /** Metric key the radius is computed from (typically `cpm`). */
+ trafficMetric: string;
+ /** Metric key the error band is computed from (typically `err`). */
+ errorMetric?: string;
+ /** Error-band cutoffs: anything > warnAt is "warn", > errAt is "err". */
+ warnAt?: number;
+ errAt?: number;
+ /** SVG viewBox edge in px. */
+ size?: number;
+ }>(),
+ {
+ errorMetric: 'err',
+ warnAt: 0.5,
+ errAt: 1,
+ size: 400,
+ },
+);
+const emit = defineEmits<{ (e: 'pick', s: LandingServiceRow): void }>();
+
+/**
+ * Build per-service plot rows. Skips services with no traffic value —
+ * they can't be placed on a log radius. The angle is uniform (`i / N *
+ * 2π`) so the visual emphasis is on the cluster, not on ordering.
+ */
+const dots = computed(() => {
+ const N = props.services.length;
+ if (N === 0) return [];
+ const traffic = props.services.map((s) => s.metrics[props.trafficMetric] ??
null);
+ const maxT = Math.max(...traffic.filter((v): v is number => v !== null && v
> 0), 1);
+ const cx = props.size / 2;
+ const cy = props.size / 2;
+ const rMax = props.size * 0.4;
+ const rMin = props.size * 0.075;
+ return props.services.map((s, i) => {
+ const t = traffic[i] ?? 0;
+ const radius =
+ t > 0 ? rMin + (Math.log10(Math.max(1, t)) / Math.log10(Math.max(2,
maxT))) * (rMax - rMin) : rMin;
+ const angle = -Math.PI / 2 + (i / N) * Math.PI * 2;
+ const err = s.metrics[props.errorMetric] ?? null;
+ const status: 'ok' | 'warn' | 'err' =
+ err !== null && err > props.errAt
+ ? 'err'
+ : err !== null && err > props.warnAt
+ ? 'warn'
+ : 'ok';
+ return {
+ ...s,
+ angle,
+ radius,
+ x: cx + Math.cos(angle) * radius,
+ y: cy + Math.sin(angle) * radius,
+ traffic: t,
+ err,
+ status,
+ // Halo size correlates with log(traffic) so high-traffic services
+ // visually dominate.
+ halo: 4 + (t > 0 ? Math.log10(Math.max(1, t)) : 0) * 1.4,
+ };
+ });
+});
+
+const rings = computed(() => {
+ const cx = props.size / 2;
+ const cy = props.size / 2;
+ const rMax = props.size * 0.4;
+ // Decade markers between 1 and 1M-ish — labels are drawn separately.
+ return [0.25, 0.5, 0.75, 1].map((f) => ({ cx, cy, r: rMax * f }));
+});
+const counts = computed(() => {
+ const out = { ok: 0, warn: 0, err: 0 };
+ for (const d of dots.value) out[d.status]++;
+ return out;
+});
+
+const center = computed(() => ({ x: props.size / 2, y: props.size / 2 }));
+function colorFor(status: 'ok' | 'warn' | 'err'): string {
+ return status === 'err' ? 'var(--sw-err)' : status === 'warn' ?
'var(--sw-warn)' : 'var(--sw-ok)';
+}
+
+function textAnchorFor(angle: number): 'start' | 'end' | 'middle' {
+ const c = Math.cos(angle);
+ if (c > 0.3) return 'start';
+ if (c < -0.3) return 'end';
+ return 'middle';
+}
+</script>
+
+<template>
+ <div class="constellation">
+ <svg :viewBox="`0 0 ${size} ${size}`" :width="size" :height="size"
role="img" aria-label="Service constellation">
+ <!-- concentric rings -->
+ <circle
+ v-for="(r, i) in rings"
+ :key="`r-${i}`"
+ :cx="r.cx"
+ :cy="r.cy"
+ :r="r.r"
+ fill="none"
+ stroke="var(--sw-line)"
+ stroke-dasharray="3 4"
+ />
+ <!-- radial spokes -->
+ <line
+ v-for="(d, i) in dots"
+ :key="`s-${i}`"
+ :x1="center.x"
+ :y1="center.y"
+ :x2="center.x + Math.cos(d.angle) * (size * 0.42)"
+ :y2="center.y + Math.sin(d.angle) * (size * 0.42)"
+ stroke="var(--sw-line)"
+ stroke-width="0.4"
+ opacity="0.6"
+ />
+ <!-- dots -->
+ <g v-for="(d, i) in dots" :key="`d-${i}`" class="dot-group"
@click="emit('pick', d)">
+ <title>{{ d.serviceName }} · traffic {{ d.traffic.toFixed(1) }}{{
d.err !== null ? ` · err ${d.err.toFixed(2)}` : '' }}</title>
+ <circle :cx="d.x" :cy="d.y" :r="d.halo" :fill="colorFor(d.status)"
opacity="0.22" />
+ <circle :cx="d.x" :cy="d.y" :r="3.5" :fill="colorFor(d.status)" />
+ <text
+ :x="d.x + Math.cos(d.angle) * 14"
+ :y="d.y + Math.sin(d.angle) * 14 + 3"
+ font-size="8.5"
+ :text-anchor="textAnchorFor(d.angle)"
+ fill="var(--sw-fg-1)"
+ class="label"
+ >
+ {{ d.shortName || d.serviceName }}
+ </text>
+ </g>
+ <!-- center label -->
+ <text :x="center.x" :y="center.y - 4" text-anchor="middle"
font-size="12" font-weight="700" fill="var(--sw-fg-0)">
+ {{ dots.length }}
+ </text>
+ <text :x="center.x" :y="center.y + 9" text-anchor="middle" font-size="8"
fill="var(--sw-fg-3)">
+ services
+ </text>
+ </svg>
+
+ <aside class="legend">
+ <div class="legend-row">
+ <span class="swatch ok" />
+ <span class="legend-label">healthy</span>
+ <span class="legend-count">{{ counts.ok }}</span>
+ </div>
+ <div class="legend-row">
+ <span class="swatch warn" />
+ <span class="legend-label">warn</span>
+ <span class="legend-count">{{ counts.warn }}</span>
+ </div>
+ <div class="legend-row">
+ <span class="swatch err" />
+ <span class="legend-label">error</span>
+ <span class="legend-count">{{ counts.err }}</span>
+ </div>
+ <div class="sep" />
+ <p class="hint">
+ Angle = service order. Radius = log of <code>{{ trafficMetric
}}</code>. Halo grows with traffic.
+ Color reflects <code>{{ errorMetric }}</code> band.
+ </p>
+ </aside>
+ </div>
+</template>
+
+<style scoped>
+.constellation {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 14px;
+ align-items: center;
+}
+svg {
+ width: 100%;
+ height: auto;
+ max-width: 360px;
+ margin: 0 auto;
+ display: block;
+}
+.dot-group {
+ cursor: pointer;
+}
+.dot-group:hover .label {
+ fill: var(--sw-fg-0);
+ font-weight: 600;
+}
+.legend {
+ display: grid;
+ grid-auto-rows: min-content;
+ gap: 6px;
+ font-size: 10.5px;
+ align-self: start;
+ padding-top: 6px;
+}
+.legend-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+.swatch {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+}
+.swatch.ok {
+ background: var(--sw-ok);
+}
+.swatch.warn {
+ background: var(--sw-warn);
+}
+.swatch.err {
+ background: var(--sw-err);
+}
+.legend-label {
+ color: var(--sw-fg-2);
+ width: 50px;
+}
+.legend-count {
+ font-family: var(--sw-mono);
+ color: var(--sw-fg-0);
+ font-variant-numeric: tabular-nums;
+}
+.sep {
+ height: 1px;
+ background: var(--sw-line);
+ margin: 2px 0;
+}
+.hint {
+ margin: 0;
+ font-size: 9.5px;
+ color: var(--sw-fg-3);
+ line-height: 1.4;
+ max-width: 130px;
+}
+.hint code {
+ font-family: var(--sw-mono);
+ font-size: 9px;
+ color: var(--sw-fg-2);
+ background: var(--sw-bg-2);
+ padding: 0 3px;
+ border-radius: 2px;
+}
+</style>
diff --git a/apps/ui/src/views/layer/LayerServiceDetailView.vue
b/apps/ui/src/views/layer/LayerServiceDetailView.vue
new file mode 100644
index 0000000..fdbb263
--- /dev/null
+++ b/apps/ui/src/views/layer/LayerServiceDetailView.vue
@@ -0,0 +1,347 @@
+<!--
+ 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-detail view — opens when an operator picks a service from the
+ constellation or services table. URL pattern:
+ /layer/:layerKey/services/:serviceId
+
+ For Stage 2.8 this is a structural shell: name, breadcrumb back to the
+ services list, KPI tiles built from the layer's column setup, and a
+ reference card pointing at the deep views (instances / endpoints /
+ traces / logs) which will be populated as later phases land them.
+-->
+<script setup lang="ts">
+import { computed } from 'vue';
+import { RouterLink, useRoute } from 'vue-router';
+import type { LayerDef, LandingServiceRow } from
'@skywalking-horizon-ui/api-client';
+import Icon from '@/components/icons/Icon.vue';
+import { metricMeta } from '@/composables/metricCatalog';
+import { useLayerLanding } from '@/composables/useLayerLanding';
+import { useLayers } from '@/composables/useLayers';
+import { useSetupStore } from '@/stores/setup';
+import { fmtMetric } from '@/utils/formatters';
+
+const route = useRoute();
+const layerKey = computed(() => String(route.params.layerKey ?? ''));
+const serviceId = computed(() => String(route.params.serviceId ?? ''));
+const { layers } = useLayers();
+const layer = computed<LayerDef | null>(() => layers.value.find((l) => l.key
=== layerKey.value) ?? null);
+const store = useSetupStore();
+const cfg = computed(() => {
+ if (!layer.value) return null;
+ return store.ensure(layer.value.key, { slots: layer.value.slots, caps:
layer.value.caps });
+});
+
+const safeLayer = computed<LayerDef>(() => layer.value ?? {
+ key: layerKey.value,
+ name: layerKey.value,
+ color: 'var(--sw-fg-2)',
+ serviceCount: -1,
+ active: false,
+ level: null,
+ slots: {},
+ caps: {},
+});
+const safeCfg = computed(() => cfg.value?.landing ?? {
+ priority: 99,
+ topN: 5,
+ orderBy: 'cpm',
+ columns: [],
+ style: 'table' as const,
+});
+const landing = useLayerLanding(safeLayer, safeCfg);
+
+const allRows = computed(() => landing.data.value?.sampledRows ??
landing.rows.value ?? []);
+const service = computed<LandingServiceRow | null>(() =>
+ allRows.value.find((r) => r.serviceId === serviceId.value) ?? null,
+);
+const serviceLabel = computed(() => service.value?.serviceName ??
decodeURIComponent(serviceId.value));
+const servicesHref = computed(() => `/layer/${layerKey.value}/services`);
+
+interface DeepLink {
+ to: string;
+ label: string;
+ desc: string;
+ phase: string;
+ enabled: boolean;
+}
+// Per-service deep links — phase notes match what the layer-level tabs
+// expose. Disabled rows surface to the operator what _could_ be
+// reachable once the relevant cap is enabled (or once we wire the page).
+const deepLinks = computed<DeepLink[]>(() => {
+ const L = layer.value;
+ const k = layerKey.value;
+ const sid = serviceId.value;
+ if (!L) return [];
+ const links: DeepLink[] = [];
+ if (L.slots.instances) {
+ links.push({
+ to: `/layer/${k}/instances?service=${encodeURIComponent(sid)}`,
+ label: cfg.value?.slots.instances || L.slots.instances || 'Instances',
+ desc: 'Per-instance metrics, agent status, JVM/process drill.',
+ phase: 'Phase 2 / 3',
+ enabled: false,
+ });
+ }
+ if (L.slots.endpoints) {
+ links.push({
+ to: `/layer/${k}/endpoints?service=${encodeURIComponent(sid)}`,
+ label: cfg.value?.slots.endpoints || L.slots.endpoints || 'Endpoints',
+ desc: 'API endpoints exposed by this service.',
+ phase: 'Phase 2 / 3',
+ enabled: false,
+ });
+ }
+ if (L.caps.traces) {
+ links.push({
+ to: `/layer/${k}/traces?service=${encodeURIComponent(sid)}`,
+ label: 'Traces',
+ desc: 'Trace explorer scoped to this service.',
+ phase: 'Phase 5',
+ enabled: false,
+ });
+ }
+ if (L.caps.logs) {
+ links.push({
+ to: `/layer/${k}/logs?service=${encodeURIComponent(sid)}`,
+ label: 'Logs',
+ desc: 'Log explorer scoped to this service.',
+ phase: 'Phase 5',
+ enabled: false,
+ });
+ }
+ if (L.caps.dashboards) {
+ links.push({
+ to: `/layer/${k}/dashboards?service=${encodeURIComponent(sid)}`,
+ label: 'Dashboards',
+ desc: 'Widget grid for this service’s scope.',
+ phase: 'Phase 3',
+ enabled: false,
+ });
+ }
+ if (L.caps.profiling) {
+ links.push({
+ to: `/layer/${k}/profiling?service=${encodeURIComponent(sid)}`,
+ label: 'Profiling',
+ desc: 'Flame graphs + sampled stacks for this service.',
+ phase: 'Phase 8',
+ enabled: false,
+ });
+ }
+ return links;
+});
+</script>
+
+<template>
+ <div class="svc-detail">
+ <nav class="crumbs">
+ <RouterLink :to="servicesHref">
+ <Icon name="chev" :size="10" /> Back to {{ cfg?.slots.services ||
'services' }}
+ </RouterLink>
+ </nav>
+
+ <header class="head">
+ <h2>{{ serviceLabel }}</h2>
+ <span v-if="!service" class="sw-badge">awaiting data</span>
+ <span v-else class="sw-tag" :title="serviceId">id · {{
serviceId.slice(0, 12) }}{{ serviceId.length > 12 ? '…' : '' }}</span>
+ </header>
+
+ <section v-if="service" class="kpi-row">
+ <div
+ v-for="col in cfg?.landing.columns ?? []"
+ :key="col.metric"
+ class="kpi-tile sw-card"
+
:title="`${metricMeta(col.metric).longLabel}\n\n${metricMeta(col.metric).tip}`"
+ >
+ <div class="kpi-label">{{ col.label }}<span v-if="col.unit"
class="unit">{{ col.unit }}</span></div>
+ <div class="kpi-value" :class="{ muted: service.metrics[col.metric] ==
null }">
+ {{ fmtMetric(service.metrics[col.metric]) }}
+ </div>
+ </div>
+ </section>
+
+ <section v-else class="empty-card sw-card">
+ <p>
+ No live data found for service <code>{{ serviceId }}</code>.
+ It may not be in the current sample — open
+ <RouterLink :to="servicesHref">the services list</RouterLink>
+ and pick a service from the table.
+ </p>
+ </section>
+
+ <section class="sw-card deep-links">
+ <div class="card-head">
+ <h4>Deep dive</h4>
+ <span class="sub">scoped to this service · each opens with
<code>?service={{ serviceId }}</code></span>
+ </div>
+ <div class="link-grid">
+ <div v-for="L in deepLinks" :key="L.label" class="link-card">
+ <div class="link-head">
+ <span class="link-label">{{ L.label }}</span>
+ <span class="sw-badge">{{ L.phase }}</span>
+ </div>
+ <p class="link-desc">{{ L.desc }}</p>
+ </div>
+ <p v-if="deepLinks.length === 0" class="empty">
+ No deep-dive views available for this layer’s caps.
+ </p>
+ </div>
+ </section>
+ </div>
+</template>
+
+<style scoped>
+.svc-detail {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ padding: 14px 0 0;
+}
+.crumbs a {
+ font-size: 11.5px;
+ color: var(--sw-fg-2);
+ text-decoration: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+}
+.crumbs a:hover {
+ color: var(--sw-accent-2);
+}
+.head {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+.head h2 {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 600;
+ color: var(--sw-fg-0);
+ letter-spacing: -0.02em;
+ font-family: var(--sw-mono);
+}
+.kpi-row {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
+ gap: 10px;
+}
+.kpi-tile {
+ padding: 10px 14px;
+}
+.kpi-label {
+ font-size: 9.5px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--sw-fg-3);
+ margin-bottom: 4px;
+}
+.kpi-label .unit {
+ margin-left: 3px;
+ text-transform: none;
+ letter-spacing: 0;
+ font-size: 9px;
+}
+.kpi-value {
+ font-size: 20px;
+ font-weight: 600;
+ color: var(--sw-fg-0);
+ font-variant-numeric: tabular-nums;
+ letter-spacing: -0.02em;
+}
+.kpi-value.muted {
+ color: var(--sw-fg-3);
+}
+.empty-card {
+ padding: 18px 20px;
+ font-size: 11.5px;
+ color: var(--sw-fg-2);
+ line-height: 1.5;
+}
+.empty-card code {
+ font-family: var(--sw-mono);
+ font-size: 10.5px;
+ color: var(--sw-fg-2);
+ background: var(--sw-bg-2);
+ padding: 1px 4px;
+ border-radius: 3px;
+}
+.empty-card a {
+ color: var(--sw-accent-2);
+ text-decoration: none;
+}
+.deep-links .card-head {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--sw-line);
+}
+.deep-links .card-head h4 {
+ margin: 0;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--sw-fg-0);
+}
+.deep-links .card-head .sub {
+ font-size: 10.5px;
+ color: var(--sw-fg-3);
+}
+.deep-links .card-head .sub code {
+ font-family: var(--sw-mono);
+ font-size: 10px;
+ color: var(--sw-fg-2);
+ background: var(--sw-bg-2);
+ padding: 0 3px;
+ border-radius: 2px;
+}
+.link-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 10px;
+ padding: 12px 14px;
+}
+.link-card {
+ background: var(--sw-bg-1);
+ border: 1px dashed var(--sw-line-2);
+ border-radius: 6px;
+ padding: 10px 12px;
+}
+.link-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 6px;
+}
+.link-label {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--sw-fg-1);
+}
+.link-desc {
+ margin: 4px 0 0;
+ font-size: 10.5px;
+ color: var(--sw-fg-3);
+ line-height: 1.5;
+}
+.empty {
+ margin: 0;
+ font-size: 11.5px;
+ color: var(--sw-fg-3);
+}
+</style>
diff --git a/apps/ui/src/views/layer/LayerServicesView.vue
b/apps/ui/src/views/layer/LayerServicesView.vue
new file mode 100644
index 0000000..a940a8a
--- /dev/null
+++ b/apps/ui/src/views/layer/LayerServicesView.vue
@@ -0,0 +1,283 @@
+<!--
+ 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.
+-->
+<!--
+ Services tab body for the per-layer page. Renders the constellation
+ visualization (Stage 2.8) over the sampled service set and lists
+ services in a table below (Stage 2.9 — currently a structural
+ placeholder while the table column model finalizes).
+
+ Data flows in from the shared /api/layer/:key/landing endpoint via
+ useLayerLanding — same query the Overview card already runs, so the
+ data is cached and shared between the two views.
+-->
+<script setup lang="ts">
+import { computed } from 'vue';
+import { useRoute, useRouter, RouterLink } from 'vue-router';
+import type { LandingServiceRow, LayerDef } from
'@skywalking-horizon-ui/api-client';
+import LayerConstellation from './LayerConstellation.vue';
+import { metricMeta } from '@/composables/metricCatalog';
+import { useLayerLanding } from '@/composables/useLayerLanding';
+import { useLayers } from '@/composables/useLayers';
+import { useSetupStore } from '@/stores/setup';
+import { fmtMetric } from '@/utils/formatters';
+
+const route = useRoute();
+const router = useRouter();
+const layerKey = computed(() => String(route.params.layerKey ?? ''));
+
+function openService(row: LandingServiceRow): void {
+
router.push(`/layer/${layerKey.value}/services/${encodeURIComponent(row.serviceId)}`);
+}
+const { layers } = useLayers();
+const layer = computed<LayerDef | null>(() => layers.value.find((l) => l.key
=== layerKey.value) ?? null);
+const store = useSetupStore();
+const cfg = computed(() => {
+ if (!layer.value) return null;
+ return store.ensure(layer.value.key, { slots: layer.value.slots, caps:
layer.value.caps });
+});
+
+const safeLayer = computed<LayerDef>(() => layer.value ?? {
+ key: layerKey.value,
+ name: layerKey.value,
+ color: 'var(--sw-fg-2)',
+ serviceCount: -1,
+ active: false,
+ level: null,
+ slots: {},
+ caps: {},
+});
+const safeCfg = computed(() => cfg.value?.landing ?? {
+ priority: 99,
+ topN: 5,
+ orderBy: 'cpm',
+ columns: [],
+ style: 'table' as const,
+});
+const landing = useLayerLanding(safeLayer, safeCfg);
+
+// Constellation uses the full sampled set (up to ~25 services) so the
+// long tail shows. The table reuses the topN slice; Stage 2.9 will
+// upgrade this to a paginated browse of all sampled rows.
+const sampled = computed(() => landing.data.value?.sampledRows ??
landing.rows.value ?? []);
+const topRows = computed(() => landing.rows.value ?? []);
+
+const trafficMetric = computed(() => cfg.value?.landing.orderBy ?? 'cpm');
+const errorMetric = computed(() => {
+ // Prefer a column with 'err'-shaped semantics; otherwise fall through
+ // to the orderBy metric (constellation degrades gracefully — all dots
+ // render as 'ok' if the error metric isn't in the column set).
+ const cols = cfg.value?.landing.columns ?? [];
+ const match = cols.find((c) =>
+ /err|sla/.test(c.metric.toLowerCase()),
+ );
+ return match?.metric ?? 'err';
+});
+const reachable = computed(() => landing.data.value?.reachable !== false);
+</script>
+
+<template>
+ <div class="services-tab">
+ <div v-if="!reachable" class="banner err">
+ <strong>OAP unreachable.</strong>
+ Live service data is unavailable for this layer. Showing what's cached.
+ </div>
+
+ <div class="grid">
+ <section class="sw-card">
+ <div class="card-head">
+ <h4>Service health constellation</h4>
+ <span class="sub">angle · service order ⋅ radius · log({{
trafficMetric }}) ⋅ color · {{ errorMetric }} band</span>
+ </div>
+ <div class="card-body">
+ <LayerConstellation
+ v-if="sampled.length > 0"
+ :services="sampled"
+ :traffic-metric="trafficMetric"
+ :error-metric="errorMetric"
+ @pick="openService"
+ />
+ <p v-else-if="landing.isLoading.value" class="empty">Loading
services…</p>
+ <p v-else class="empty">
+ No services reporting on this layer yet. Once data flows the
constellation lights up
+ automatically.
+ </p>
+ </div>
+ </section>
+
+ <section class="sw-card services-table-card">
+ <div class="card-head">
+ <h4>Top services</h4>
+ <span class="sub">{{ topRows.length }} of {{ sampled.length }} shown
· sorted by {{ trafficMetric }}</span>
+ <RouterLink class="all-link" to="/setup">Customize</RouterLink>
+ </div>
+ <table v-if="topRows.length > 0" class="sw-table">
+ <thead>
+ <tr>
+ <th class="svc-col">Service</th>
+ <th
+ v-for="c in cfg?.landing.columns ?? []"
+ :key="c.metric"
+ class="num"
+
:title="`${metricMeta(c.metric).longLabel}\n\n${metricMeta(c.metric).tip}`"
+ >
+ {{ c.label }}<span v-if="c.unit" class="unit">{{ c.unit
}}</span>
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="row in topRows" :key="row.serviceId" class="clickable"
@click="openService(row)">
+ <td class="svc-col" :title="row.serviceName">
+ <span class="svc-link">{{ row.shortName || row.serviceName
}}</span>
+ </td>
+ <td
+ v-for="c in cfg?.landing.columns ?? []"
+ :key="c.metric"
+ class="num"
+ :class="{ muted: row.metrics[c.metric] == null }"
+ >
+ {{ fmtMetric(row.metrics[c.metric]) }}
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ <p v-else-if="landing.isLoading.value" class="empty">Loading…</p>
+ <p v-else class="empty">No services to show.</p>
+
+ <p class="phase-note">
+ Full sortable + paginated services table lands in Stage 2.9. For now
the top {{ topRows.length || 5 }}
+ appear above, identical to the Overview card row.
+ </p>
+ </section>
+ </div>
+ </div>
+</template>
+
+<style scoped>
+.services-tab {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ padding: 14px 0 0;
+}
+.banner.err {
+ padding: 8px 12px;
+ background: var(--sw-err-soft);
+ border: 1px solid rgba(239, 68, 68, 0.3);
+ border-radius: 6px;
+ color: #f87171;
+ font-size: 11.5px;
+}
+.grid {
+ display: grid;
+ grid-template-columns: 1.2fr 1fr;
+ gap: 14px;
+ align-items: start;
+}
+.card-head {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--sw-line);
+}
+.card-head h4 {
+ margin: 0;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--sw-fg-0);
+ letter-spacing: -0.01em;
+}
+.card-head .sub {
+ font-size: 10.5px;
+ color: var(--sw-fg-3);
+}
+.card-head .all-link {
+ margin-left: auto;
+ font-size: 11px;
+ color: var(--sw-accent-2);
+ text-decoration: none;
+}
+.card-body {
+ padding: 14px;
+}
+.empty {
+ margin: 0;
+ padding: 24px 8px;
+ text-align: center;
+ font-size: 11.5px;
+ color: var(--sw-fg-3);
+}
+.services-table-card .sw-table th {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--sw-fg-3);
+ text-align: left;
+ font-weight: 500;
+}
+.services-table-card .sw-table th.num,
+.services-table-card .sw-table td.num {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+}
+.services-table-card .sw-table td {
+ font-size: 11.5px;
+ color: var(--sw-fg-1);
+ padding: 6px 10px;
+ border-bottom: 1px solid var(--sw-line);
+}
+.services-table-card .sw-table td.muted {
+ color: var(--sw-fg-3);
+}
+.services-table-card .sw-table tr.clickable {
+ cursor: pointer;
+}
+.services-table-card .sw-table tr.clickable:hover {
+ background: var(--sw-bg-2);
+}
+.svc-link {
+ color: var(--sw-fg-0);
+}
+.services-table-card .sw-table tr.clickable:hover .svc-link {
+ color: var(--sw-accent-2);
+}
+.svc-col {
+ max-width: 200px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+th .unit {
+ margin-left: 3px;
+ color: var(--sw-fg-3);
+ font-weight: 400;
+}
+.phase-note {
+ margin: 0;
+ padding: 10px 14px;
+ font-size: 10.5px;
+ color: var(--sw-fg-3);
+ border-top: 1px dashed var(--sw-line);
+ background: var(--sw-bg-1);
+}
+
+@media (max-width: 1100px) {
+ .grid {
+ grid-template-columns: 1fr;
+ }
+}
+</style>
diff --git a/packages/api-client/src/landing.ts
b/packages/api-client/src/landing.ts
index b1d8c12..076cfc9 100644
--- a/packages/api-client/src/landing.ts
+++ b/packages/api-client/src/landing.ts
@@ -76,6 +76,13 @@ export interface LandingResponse {
durationStart: string;
durationEnd: string;
rows: LandingServiceRow[];
+ /**
+ * All services the BFF probed for this layer (up to its internal cap,
+ * currently 25). `rows` is a sorted+sliced subset of this — the Overview
+ * card uses `rows`, the per-layer constellation / table uses the full
+ * `sampledRows` so deep-dive views don't lose context.
+ */
+ sampledRows?: LandingServiceRow[];
/** Whole-layer rollup KPIs for the Overview strip tile. */
aggregates: LandingAggregates;
/**