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&rsquo;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;
   /**


Reply via email to