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 d868cbf  layer: merge Switch + General header, drop tabs, colorful 
KPIs with sparklines
d868cbf is described below

commit d868cbfbc23a91b31f2854ffd686298912ad77b7
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 12 17:08:21 2026 +0800

    layer: merge Switch + General header, drop tabs, colorful KPIs with 
sparklines
    
    Header restructure: the layer header card now carries both the layer
    identity (row 1: dot + 'General Service' + tags + count) and the
    service-context row (row 2: Switch button + selected service name + KPI
    strip). Per-KPI sparklines render under each value at 18px tall; values
    are tinted by metric (cpm orange, p99 yellow, sla purple, err red —
    matches design landing-layer.jsx top row).
    
    The tab strip is removed entirely — operators navigate to /services
    /dashboards /traces /etc via the per-layer entries in the sidebar
    already. No more duplicate Selected service panels anywhere on the
    page.
    
    Service picker dropdown: rebuilt as the design's 'Services in this
    layer' table — status pulse dot per row, threshold-colored metric
    cells (p99 > 400ms = warn, err > 1% = err, sla < 99% = err, etc.).
    Sits BELOW the layer header when toggled open; collapses on row
    select.
    
    BFF landing route now fetches time-series MQE (strips outer avg() via
    new expressionForServiceMetricSeries) so every metric returns 15
    buckets per query — sparklines actually render now instead of falling
    back to em-dash. Scalar values still come from bucket averages so KPI
    cells are unchanged. New aggregates.seriesByMetric on the response
    exposes a per-column aggregated series the layer header consumes for
    its per-KPI trend lines.
    
    General dashboard rename: 'Service Load' card → 'Traffic' with unit
    'rpm' (also: cpm_line widget → traffic_line) per operator preference.
    
    Sidebar drops the per-layer Events link (already dropped from layer
    header tabs).
---
 apps/bff/src/dashboard/defaults.ts               |  14 +-
 apps/bff/src/oap/landing-routes.ts               |  51 +++-
 apps/bff/src/oap/mqe-catalog.ts                  |  19 ++
 apps/ui/src/components/shell/AppSidebar.vue      |   8 -
 apps/ui/src/composables/metricColor.ts           |  94 +++++++
 apps/ui/src/views/layer/LayerServiceSelector.vue | 305 ++++++---------------
 apps/ui/src/views/layer/LayerServicesView.vue    | 324 +++++++++--------------
 apps/ui/src/views/layer/LayerShell.vue           | 296 +++++++++++----------
 packages/api-client/src/landing.ts               |   6 +
 9 files changed, 550 insertions(+), 567 deletions(-)

diff --git a/apps/bff/src/dashboard/defaults.ts 
b/apps/bff/src/dashboard/defaults.ts
index 3945896..e62a38d 100644
--- a/apps/bff/src/dashboard/defaults.ts
+++ b/apps/bff/src/dashboard/defaults.ts
@@ -48,11 +48,11 @@ const SERVICE_WIDGETS: DashboardWidget[] = [
     x: 8, y: 0, w: 8, h: 5,
   },
   {
-    id: 'cpm',
-    title: 'Service Load',
-    tip: 'Calls per minute. For HTTP / gRPC / RPC services the value reflects 
request throughput.',
+    id: 'traffic',
+    title: 'Traffic',
+    tip: 'Requests per minute (cpm). For HTTP / gRPC / RPC services this 
reflects request throughput.',
     type: 'card',
-    unit: 'calls / min',
+    unit: 'rpm',
     expressions: ['avg(service_cpm)'],
     x: 16, y: 0, w: 8, h: 5,
   },
@@ -81,10 +81,10 @@ const SERVICE_WIDGETS: DashboardWidget[] = [
     x: 12, y: 5, w: 12, h: 14,
   },
   {
-    id: 'cpm_line',
-    title: 'Service Load (line)',
+    id: 'traffic_line',
+    title: 'Traffic (line)',
     type: 'line',
-    unit: 'calls / min',
+    unit: 'rpm',
     expressions: ['service_cpm'],
     x: 0, y: 19, w: 12, h: 12,
   },
diff --git a/apps/bff/src/oap/landing-routes.ts 
b/apps/bff/src/oap/landing-routes.ts
index 5ca7242..2444f92 100644
--- a/apps/bff/src/oap/landing-routes.ts
+++ b/apps/bff/src/oap/landing-routes.ts
@@ -49,7 +49,7 @@ import type { ConfigSource } from '../config/loader.js';
 import type { SessionStore } from '../auth/sessions.js';
 import { requireAuth } from '../auth/middleware.js';
 import { graphqlPost } from './graphql-client.js';
-import { expressionForServiceMetric } from './mqe-catalog.js';
+import { expressionForServiceMetricSeries } from './mqe-catalog.js';
 
 export interface LandingRouteDeps {
   config: ConfigSource;
@@ -141,11 +141,20 @@ function defaultWindow(): Window {
   return { start: fmtMinute(start), end: fmtMinute(end), step: 'MINUTE' };
 }
 
-/** Pick the expression to fire for `(metric, layer)`. Honors the operator's
- *  explicit `mqe` override when set. */
+/**
+ * Pick the time-series expression to fire for `(metric, layer)`. We
+ * always go through the series variant (`avg(...)` stripped) so OAP
+ * returns TIME_SERIES_VALUES; the BFF then collapses to a scalar via
+ * bucket-average. This way every metric supports a sparkline AND a
+ * KPI cell from the same query — no double round-trip.
+ *
+ * Honors the operator's explicit `mqe` override when set; the override
+ * is assumed to already be the desired shape (we don't try to strip avg
+ * from custom expressions).
+ */
 function resolveMqe(metric: string, mqe: string | undefined, layerKey: 
string): string | null {
   if (mqe && mqe.trim().length > 0) return mqe.trim();
-  return expressionForServiceMetric(metric, layerKey);
+  return expressionForServiceMetricSeries(metric, layerKey);
 }
 
 /** Apply optional scale + precision to a raw MQE value. */
@@ -282,7 +291,7 @@ export function registerLandingRoute(app: FastifyInstance, 
deps: LandingRouteDep
           durationStart: window.start,
           durationEnd: window.end,
           rows: [],
-          aggregates: { serviceCount: 0, metrics: {} },
+          aggregates: { serviceCount: 0, metrics: {}, seriesByMetric: {} },
           reachable: false,
           error: err instanceof Error ? err.message : String(err),
         };
@@ -300,7 +309,7 @@ export function registerLandingRoute(app: FastifyInstance, 
deps: LandingRouteDep
           durationStart: window.start,
           durationEnd: window.end,
           rows: [],
-          aggregates: { serviceCount: totalServiceCount, metrics: {} },
+          aggregates: { serviceCount: totalServiceCount, metrics: {}, 
seriesByMetric: {} },
           reachable: true,
         };
         return reply.send(body);
@@ -337,13 +346,29 @@ export function registerLandingRoute(app: 
FastifyInstance, deps: LandingRouteDep
         }
       }
 
-      // Step 3 — assemble per-row metrics (pre-aggregation, post-scale).
+      // Step 3 — assemble per-row metrics + retain the per-bucket
+      // series so the layer header can render a sparkline under each
+      // KPI (aggregated point-wise across topN below).
+      const seriesByServiceMetric = new Map<string, Map<string, Array<number | 
null>>>();
       const rows: LandingServiceRow[] = sampled.map((svc, sIdx) => {
         const metrics: Record<string, number | null> = {};
+        const seriesMap = new Map<string, Array<number | null>>();
         resolved.forEach(({ column }, cIdx) => {
-          const raw = collapseToScalar(mqeData[alias(sIdx, cIdx)]);
-          metrics[column.metric] = postProcess(raw, column.scale, 
column.precision);
+          const raw = mqeData[alias(sIdx, cIdx)];
+          const series = collapseToSeries(raw);
+          if (series) {
+            seriesMap.set(
+              column.metric,
+              series.map((v) => postProcess(v, column.scale, 
column.precision)),
+            );
+          }
+          metrics[column.metric] = postProcess(
+            collapseToScalar(raw),
+            column.scale,
+            column.precision,
+          );
         });
+        seriesByServiceMetric.set(svc.id, seriesMap);
         return {
           serviceId: svc.id,
           serviceName: svc.value,
@@ -430,6 +455,7 @@ export function registerLandingRoute(app: FastifyInstance, 
deps: LandingRouteDep
       const aggregates: LandingAggregates = {
         serviceCount: totalServiceCount,
         metrics: {},
+        seriesByMetric: {},
       };
       for (const col of cfg.columns) {
         const kind: AggregationKind = col.aggregation ?? 'avg';
@@ -437,6 +463,13 @@ export function registerLandingRoute(app: FastifyInstance, 
deps: LandingRouteDep
           topRows.map((r) => r.metrics[col.metric] ?? null),
           kind,
         );
+        // Per-column aggregated time series — derived from the per-service
+        // series we retained in step 3, aggregated point-wise.
+        const colSeries = topRows.map(
+          (r) => seriesByServiceMetric.get(r.serviceId)?.get(col.metric),
+        );
+        const agg = aggregateSeries(colSeries, kind);
+        if (agg) aggregates.seriesByMetric[col.metric] = agg;
       }
       if (throughputCol) {
         const kind: AggregationKind = throughputCol.aggregation ?? 'sum';
diff --git a/apps/bff/src/oap/mqe-catalog.ts b/apps/bff/src/oap/mqe-catalog.ts
index e649382..ae3bedd 100644
--- a/apps/bff/src/oap/mqe-catalog.ts
+++ b/apps/bff/src/oap/mqe-catalog.ts
@@ -156,3 +156,22 @@ export function resolveColumnExpressions(
     expression: expressionForServiceMetric(c.metric, layerKey),
   }));
 }
+
+/**
+ * Time-series variant of the catalog expression. The default catalog
+ * wraps most metrics in `avg(...)` which makes OAP return a SINGLE_VALUE
+ * — fine for KPI cells, useless for sparklines (one bucket can't
+ * render as a line). This helper strips the avg wrappers so OAP returns
+ * the full TIME_SERIES_VALUES at the configured step granularity. The
+ * caller can still recover the scalar by averaging the buckets.
+ */
+export function expressionForServiceMetricSeries(
+  metricKey: string,
+  layerKey: string,
+): string | null {
+  const scalar = expressionForServiceMetric(metricKey, layerKey);
+  if (!scalar) return null;
+  // Strip every `avg(...)` wrapper. Matches expressions like
+  // `avg(service_sla)/100`, `100 - avg(service_sla)/100`, etc.
+  return scalar.replace(/avg\(([^()]+)\)/g, '$1');
+}
diff --git a/apps/ui/src/components/shell/AppSidebar.vue 
b/apps/ui/src/components/shell/AppSidebar.vue
index f8214b4..c8b88eb 100644
--- a/apps/ui/src/components/shell/AppSidebar.vue
+++ b/apps/ui/src/components/shell/AppSidebar.vue
@@ -283,14 +283,6 @@ const sections: NavSection[] = [
           >
             <Icon name="flame" /><span>Profiling</span>
           </RouterLink>
-          <RouterLink
-            v-if="L.caps.events"
-            :to="`/layer/${L.key}/events`"
-            class="sw-nav-item"
-            :class="{ 'is-active': isActive(`/layer/${L.key}/events`) }"
-          >
-            <Icon name="event" /><span>Events</span>
-          </RouterLink>
         </div>
       </template>
 
diff --git a/apps/ui/src/composables/metricColor.ts 
b/apps/ui/src/composables/metricColor.ts
new file mode 100644
index 0000000..ebd4aa5
--- /dev/null
+++ b/apps/ui/src/composables/metricColor.ts
@@ -0,0 +1,94 @@
+/*
+ * 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.
+ */
+
+/**
+ * Per-metric color mapping for KPI tiles. Keeps the per-layer header
+ * + selector pinned-bar visually consistent with the design's
+ * `Landing / Layer · General` top KPI row (landing-layer.jsx:67-83).
+ *
+ *   Services / count       → info  (blue)
+ *   cpm / throughput        → accent (orange)
+ *   p99 / percentile / resp → warn  (yellow)
+ *   sla / apdex             → purple
+ *   err / failure           → err   (red)
+ *   alarms                  → err
+ *
+ * Falls back to fg-0 for anything we don't recognise — operators can
+ * still add custom metrics without the page going monochrome.
+ */
+export function colorForMetric(metricKey: string): string {
+  const k = metricKey.toLowerCase();
+  if (k === 'cpm' || k.endsWith('.msg-rate') || k.endsWith('.qps') || 
k.endsWith('.req') || k.endsWith('.invocations') || k.endsWith('.tokens') || 
k.endsWith('.pv')) {
+    return 'var(--sw-accent)';
+  }
+  if (k.includes('resp') || k.startsWith('p50') || k.startsWith('p75') || 
k.startsWith('p90') || k.startsWith('p95') || k.startsWith('p99') || 
k.includes('percentile') || k.endsWith('latency') || k.endsWith('duration') || 
k.endsWith('page-load') || k.endsWith('ajax-resp')) {
+    return 'var(--sw-warn)';
+  }
+  if (k === 'sla' || k === 'apdex' || k.endsWith('hit-rate')) {
+    return 'var(--sw-purple)';
+  }
+  if (k === 'err' || k.endsWith('.js-err') || k.endsWith('-err') || 
k.endsWith('lag') || k.endsWith('slow-queries') || k.endsWith('cold-start') || 
k.endsWith('restart')) {
+    return 'var(--sw-err)';
+  }
+  if (k === 'services' || k === 'instances' || k === 'endpoints') {
+    return 'var(--sw-info)';
+  }
+  return 'var(--sw-fg-0)';
+}
+
+/**
+ * Threshold-based color for a row cell. Used by the "Services in this
+ * layer" table to color individual metric values by their health band:
+ * p99 > 400ms = warn, err > 1% = err, sla < 99% = err, apdex < 0.85 =
+ * warn. Returns null when the metric isn't one we threshold on, so the
+ * cell can render in the default foreground.
+ */
+export function thresholdColor(metricKey: string, value: number | null): 
string | null {
+  if (value === null || !Number.isFinite(value)) return null;
+  const k = metricKey.toLowerCase();
+  if (k === 'err' || k.endsWith('-err') || k.endsWith('.js-err')) {
+    if (value > 1) return 'var(--sw-err)';
+    if (value > 0.5) return 'var(--sw-warn)';
+  }
+  if (k === 'sla') {
+    if (value < 99) return 'var(--sw-err)';
+    if (value < 99.9) return 'var(--sw-warn)';
+  }
+  if (k === 'apdex') {
+    if (value < 0.85) return 'var(--sw-err)';
+    if (value < 0.95) return 'var(--sw-warn)';
+  }
+  if (k.startsWith('p99') || k.startsWith('p95')) {
+    if (value > 1000) return 'var(--sw-err)';
+    if (value > 400) return 'var(--sw-warn)';
+  }
+  return null;
+}
+
+/**
+ * Service-status from a row's metrics — used for the pulse dot and
+ * traffic-share bar color in the expanded selector table.
+ */
+export function statusForMetrics(metrics: Record<string, number | null>): 'ok' 
| 'warn' | 'err' {
+  const err = metrics['err'];
+  const sla = metrics['sla'];
+  if (err !== null && err !== undefined && err > 1) return 'err';
+  if (sla !== null && sla !== undefined && sla < 99) return 'err';
+  if (err !== null && err !== undefined && err > 0.5) return 'warn';
+  if (sla !== null && sla !== undefined && sla < 99.9) return 'warn';
+  return 'ok';
+}
diff --git a/apps/ui/src/views/layer/LayerServiceSelector.vue 
b/apps/ui/src/views/layer/LayerServiceSelector.vue
index 3e4f247..c819beb 100644
--- a/apps/ui/src/views/layer/LayerServiceSelector.vue
+++ b/apps/ui/src/views/layer/LayerServiceSelector.vue
@@ -15,20 +15,18 @@
   limitations under the License.
 -->
 <!--
-  Sticky selector zone at the top of every per-layer tab. The currently
-  selected service is pinned (name + inline KPIs); click "switch" to
-  expand a filterable + paginated table of all sampled services. Picking
-  a row updates the page-wide selection (driven via URL `?service=`),
-  which downstream widgets (constellation, dashboards, traces tab once
-  it lands) consume to scope their queries.
-
-  Default selection: the first row of `services` (sorted desc by orderBy
-  in the BFF), so opening a layer lands on the highest-traffic service.
+  Expanded service-picker dropdown — rendered by LayerShell beneath the
+  layer header card when the Switch button is open. Search by name +
+  pagination over the sampled service set; each row uses the design's
+  "Services in this layer" styling (status pulse dot + threshold-colored
+  metric cells). Picking a row emits `select(id)`, which LayerShell uses
+  to update the URL `?service=` state and close the dropdown.
 -->
 <script setup lang="ts">
 import { computed, ref, watch } from 'vue';
 import type { LandingColumn, LandingServiceRow } from 
'@skywalking-horizon-ui/api-client';
 import { metricMeta } from '@/composables/metricCatalog';
+import { statusForMetrics, thresholdColor } from '@/composables/metricColor';
 import { fmtMetric } from '@/utils/formatters';
 
 const props = withDefaults(
@@ -36,9 +34,7 @@ const props = withDefaults(
     services: ReadonlyArray<LandingServiceRow>;
     columns: ReadonlyArray<LandingColumn>;
     selectedId: string | null;
-    /** Layer color — used for the pinned service dot. */
     accent?: string;
-    /** Rows per page in expanded mode. */
     pageSize?: number;
   }>(),
   {
@@ -48,29 +44,13 @@ const props = withDefaults(
 );
 const emit = defineEmits<{ (e: 'select', id: string): void }>();
 
-const expanded = ref(false);
 const filter = ref('');
 const page = ref(0);
 
-// Default-select highest-traffic (first row) on initial render when the
-// caller hasn't set anything. Watch responds to delayed data loads too.
-watch(
-  () => props.services,
-  (rows) => {
-    if (!props.selectedId && rows.length > 0) emit('select', 
rows[0].serviceId);
-  },
-  { immediate: true },
-);
-
-const selectedRow = computed<LandingServiceRow | null>(
-  () => props.services.find((s) => s.serviceId === props.selectedId) ?? 
props.services[0] ?? null,
-);
-
 const filtered = computed(() => {
   const q = filter.value.trim().toLowerCase();
-  const base = props.services;
-  if (q.length === 0) return base;
-  return base.filter((s) => s.serviceName.toLowerCase().includes(q));
+  if (q.length === 0) return props.services;
+  return props.services.filter((s) => s.serviceName.toLowerCase().includes(q));
 });
 const pageCount = computed(() => Math.max(1, Math.ceil(filtered.value.length / 
props.pageSize)));
 const currentPage = computed(() => Math.min(page.value, pageCount.value - 1));
@@ -80,206 +60,88 @@ const visible = computed(() => {
 });
 watch(filter, () => (page.value = 0));
 
-function selectAndCollapse(id: string): void {
-  emit('select', id);
-  expanded.value = false;
-}
-function toggle(): void {
-  expanded.value = !expanded.value;
+function colorForStatus(s: 'ok' | 'warn' | 'err'): string {
+  return s === 'err' ? 'var(--sw-err)' : s === 'warn' ? 'var(--sw-warn)' : 
'var(--sw-ok)';
 }
 </script>
 
 <template>
-  <section class="sw-card selector" :class="{ expanded }">
-    <div class="pin">
-      <span class="dot" :style="{ background: accent }" />
-      <div class="pin-title">
-        <span class="kicker">Selected {{ services.length > 0 ? 'service' : '' 
}}</span>
-        <div class="name">
-          {{ selectedRow?.serviceName || (services.length > 0 ? 'pick a 
service' : '—') }}
-        </div>
-      </div>
-      <div class="pin-kpis">
-        <div
-          v-for="c in columns"
-          :key="c.metric"
-          class="pin-kpi"
-          
:title="`${metricMeta(c.metric).longLabel}\n\n${metricMeta(c.metric).tip}`"
-        >
-          <span class="pin-kpi-label">{{ c.label }}<span v-if="c.unit" 
class="unit">{{ c.unit }}</span></span>
-          <span class="pin-kpi-value" :class="{ muted: 
selectedRow?.metrics[c.metric] == null }">
-            {{ fmtMetric(selectedRow?.metrics[c.metric] ?? null) }}
-          </span>
-        </div>
-      </div>
-      <button class="sw-btn ghost small toggle" type="button" @click="toggle">
-        <span>{{ expanded ? 'Close' : 'Switch' }}</span>
-        <span class="caret" :class="{ open: expanded }">▾</span>
-      </button>
-    </div>
-
-    <div v-if="expanded" class="picker">
-      <div class="picker-head">
-        <input
-          v-model="filter"
-          class="search"
-          placeholder="filter by name…"
-          spellcheck="false"
-          autocomplete="off"
-        />
-        <span class="count">
-          {{ filtered.length }} of {{ services.length }}
-        </span>
-      </div>
-      <table class="sw-table picker-table">
-        <thead>
-          <tr>
-            <th class="svc-col">Service</th>
-            <th v-for="c in columns" :key="c.metric" class="num">
-              {{ c.label }}<span v-if="c.unit" class="unit">{{ c.unit }}</span>
-            </th>
-          </tr>
-        </thead>
-        <tbody>
-          <tr
-            v-for="row in visible"
-            :key="row.serviceId"
-            class="row"
-            :class="{ active: row.serviceId === selectedRow?.serviceId }"
-            @click="selectAndCollapse(row.serviceId)"
+  <section class="sw-card picker">
+    <header class="picker-head">
+      <input
+        v-model="filter"
+        class="search"
+        placeholder="filter by name…"
+        spellcheck="false"
+        autocomplete="off"
+      />
+      <span class="count">{{ filtered.length }} of {{ services.length }}</span>
+    </header>
+    <table class="sw-table picker-table">
+      <thead>
+        <tr>
+          <th class="svc-col">Service</th>
+          <th
+            v-for="c in columns"
+            :key="c.metric"
+            class="num"
+            
:title="`${metricMeta(c.metric).longLabel}\n\n${metricMeta(c.metric).tip}`"
           >
-            <td class="svc-col" :title="row.serviceName">
-              <span class="name-text">{{ row.shortName || row.serviceName 
}}</span>
-            </td>
-            <td
-              v-for="c in columns"
-              :key="c.metric"
-              class="num"
-              :class="{ muted: row.metrics[c.metric] == null }"
-            >
-              {{ fmtMetric(row.metrics[c.metric]) }}
-            </td>
-          </tr>
-          <tr v-if="visible.length === 0">
-            <td :colspan="columns.length + 1" class="empty">
-              No services match <code>{{ filter }}</code>.
-            </td>
-          </tr>
-        </tbody>
-      </table>
-      <div v-if="pageCount > 1" class="pager">
-        <button class="sw-btn ghost small" :disabled="currentPage === 0" 
@click="page = currentPage - 1">←</button>
-        <span class="page-info">{{ currentPage + 1 }} / {{ pageCount }}</span>
-        <button
-          class="sw-btn ghost small"
-          :disabled="currentPage >= pageCount - 1"
-          @click="page = currentPage + 1"
+            {{ c.label }}<span v-if="c.unit" class="unit">{{ c.unit }}</span>
+          </th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr
+          v-for="row in visible"
+          :key="row.serviceId"
+          class="row"
+          :class="{ active: row.serviceId === selectedId }"
+          @click="emit('select', row.serviceId)"
         >
-          →
-        </button>
-      </div>
+          <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>
+          </td>
+          <td
+            v-for="c in columns"
+            :key="c.metric"
+            class="num"
+            :class="{ muted: row.metrics[c.metric] == null }"
+            :style="{ color: thresholdColor(c.metric, row.metrics[c.metric] ?? 
null) ?? undefined }"
+          >
+            {{ fmtMetric(row.metrics[c.metric]) }}
+          </td>
+        </tr>
+        <tr v-if="visible.length === 0">
+          <td :colspan="columns.length + 1" class="empty">
+            No services match <code>{{ filter }}</code>.
+          </td>
+        </tr>
+      </tbody>
+    </table>
+    <div v-if="pageCount > 1" class="pager">
+      <button class="sw-btn ghost small" :disabled="currentPage === 0" 
@click="page = currentPage - 1">←</button>
+      <span class="page-info">{{ currentPage + 1 }} / {{ pageCount }}</span>
+      <button
+        class="sw-btn ghost small"
+        :disabled="currentPage >= pageCount - 1"
+        @click="page = currentPage + 1"
+      >→</button>
     </div>
   </section>
 </template>
 
 <style scoped>
-.selector {
-  margin-bottom: 14px;
-}
-.pin {
-  display: flex;
-  align-items: center;
-  gap: 12px;
-  padding: 10px 14px;
-}
-.pin .dot {
-  width: 8px;
-  height: 8px;
-  border-radius: 50%;
-  flex: 0 0 8px;
-}
-.pin-title {
-  min-width: 0;
-  flex: 0 0 220px;
-}
-.pin-title .kicker {
-  display: block;
-  font-size: 9.5px;
-  text-transform: uppercase;
-  letter-spacing: 0.08em;
-  color: var(--sw-fg-3);
-  line-height: 1;
-}
-.pin-title .name {
-  margin-top: 2px;
-  font-family: var(--sw-mono);
-  font-size: 13px;
-  font-weight: 600;
-  color: var(--sw-fg-0);
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-}
-.pin-kpis {
-  display: flex;
-  gap: 18px;
-  flex: 1;
-  flex-wrap: wrap;
-}
-.pin-kpi {
-  text-align: right;
-  min-width: 50px;
-}
-.pin-kpi-label {
-  display: block;
-  font-size: 9.5px;
-  text-transform: uppercase;
-  letter-spacing: 0.06em;
-  color: var(--sw-fg-3);
-  margin-bottom: 1px;
-}
-.pin-kpi-label .unit {
-  margin-left: 2px;
-  text-transform: none;
-  letter-spacing: 0;
-}
-.pin-kpi-value {
-  font-size: 14px;
-  font-weight: 600;
-  color: var(--sw-fg-0);
-  font-variant-numeric: tabular-nums;
-  letter-spacing: -0.01em;
-}
-.pin-kpi-value.muted {
-  color: var(--sw-fg-3);
-}
-.toggle {
-  margin-left: auto;
-  font-size: 11px;
-  height: 24px;
-  padding: 0 10px;
-  display: inline-flex;
-  align-items: center;
-  gap: 4px;
-}
-.caret {
-  display: inline-block;
-  transform: rotate(0);
-  transition: transform 0.12s;
-  font-size: 8px;
-}
-.caret.open {
-  transform: rotate(180deg);
-}
 .picker {
-  border-top: 1px solid var(--sw-line);
-  padding: 10px 14px 14px;
+  margin-bottom: 14px;
 }
 .picker-head {
   display: flex;
   align-items: center;
   gap: 10px;
-  margin-bottom: 8px;
+  padding: 10px 14px;
+  border-bottom: 1px solid var(--sw-line);
 }
 .search {
   flex: 1;
@@ -314,14 +176,14 @@ function toggle(): void {
   letter-spacing: 0.06em;
   color: var(--sw-fg-3);
   font-weight: 500;
-  padding: 4px 8px;
+  padding: 6px 12px;
   border-bottom: 1px solid var(--sw-line);
 }
 .picker-table th.num {
   text-align: right;
 }
 .picker-table td {
-  padding: 6px 8px;
+  padding: 7px 12px;
   color: var(--sw-fg-1);
   border-bottom: 1px solid var(--sw-line);
 }
@@ -347,7 +209,7 @@ function toggle(): void {
 }
 .picker-table .empty {
   text-align: center;
-  padding: 16px;
+  padding: 18px;
   color: var(--sw-fg-3);
   font-size: 11px;
 }
@@ -358,11 +220,20 @@ function toggle(): void {
   border-radius: 3px;
 }
 .svc-col {
-  max-width: 240px;
+  max-width: 280px;
   white-space: nowrap;
   overflow: hidden;
   text-overflow: ellipsis;
 }
+.pulse {
+  display: inline-block;
+  width: 7px;
+  height: 7px;
+  border-radius: 50%;
+  margin-right: 8px;
+  vertical-align: middle;
+  box-shadow: 0 0 0 0 currentColor;
+}
 .name-text {
   font-family: var(--sw-mono);
   color: var(--sw-fg-0);
@@ -373,7 +244,7 @@ function toggle(): void {
   align-items: center;
   justify-content: center;
   gap: 10px;
-  margin-top: 10px;
+  padding: 8px 0 12px;
 }
 .pager .sw-btn {
   height: 22px;
diff --git a/apps/ui/src/views/layer/LayerServicesView.vue 
b/apps/ui/src/views/layer/LayerServicesView.vue
index 1adb376..28f5f96 100644
--- a/apps/ui/src/views/layer/LayerServicesView.vue
+++ b/apps/ui/src/views/layer/LayerServicesView.vue
@@ -15,33 +15,23 @@
   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.
+  Services tab body. The page-wide selector zone (in LayerShell) already
+  carries the services table + pinned selected service, so this view
+  doesn't duplicate that — it surfaces layer-wide insight (apdex
+  distribution + counts) and drill links into the deeper per-service
+  tabs (Dashboards / Instances / Traces / Logs).
 -->
 <script setup lang="ts">
-import { computed, ref } from 'vue';
+import { computed } from 'vue';
 import { useRoute, RouterLink } from 'vue-router';
-import type { LandingServiceRow, LayerDef } from 
'@skywalking-horizon-ui/api-client';
-import { metricMeta } from '@/composables/metricCatalog';
+import type { LayerDef } from '@skywalking-horizon-ui/api-client';
 import { useLayerLanding } from '@/composables/useLayerLanding';
 import { useLayers } from '@/composables/useLayers';
 import { useSelectedService } from '@/composables/useSelectedService';
 import { useSetupStore } from '@/stores/setup';
-import { fmtMetric } from '@/utils/formatters';
 
 const route = useRoute();
 const layerKey = computed(() => String(route.params.layerKey ?? ''));
-const { selectedId, setSelected } = useSelectedService();
-
-function openService(row: LandingServiceRow): void {
-  setSelected(row.serviceId);
-}
 const { layers } = useLayers();
 const layer = computed<LayerDef | null>(() => layers.value.find((l) => l.key 
=== layerKey.value) ?? null);
 const store = useSetupStore();
@@ -68,43 +58,12 @@ const safeCfg = computed(() => cfg.value?.landing ?? {
   style: 'table' as const,
 });
 const landing = useLayerLanding(safeLayer, safeCfg);
-
-// Constellation uses the full sampled set (up to ~25 services) so the
-// long tail shows. Table shows the same set, sortable by any column.
 const sampled = computed(() => landing.data.value?.sampledRows ?? 
landing.rows.value ?? []);
+const { selectedId } = useSelectedService();
 
-// Table sort state. Defaults to descending on the layer's orderBy
-// metric — matches the BFF's pre-sort so the visible order is stable
-// when the user hasn't picked a different column yet.
-const sortKey = computed(() => cfg.value?.landing.orderBy ?? 'cpm');
-const sortMetric = ref<string>(sortKey.value);
-const sortDir = ref<'asc' | 'desc'>('desc');
-function setSort(metric: string): void {
-  if (sortMetric.value === metric) {
-    sortDir.value = sortDir.value === 'desc' ? 'asc' : 'desc';
-  } else {
-    sortMetric.value = metric;
-    sortDir.value = 'desc';
-  }
-}
-const sortedRows = computed(() => {
-  const rows = [...sampled.value];
-  const key = sortMetric.value;
-  const dir = sortDir.value === 'desc' ? -1 : 1;
-  rows.sort((a, b) => {
-    const av = a.metrics[key];
-    const bv = b.metrics[key];
-    if (av == null && bv == null) return 
a.serviceName.localeCompare(b.serviceName);
-    if (av == null) return 1;
-    if (bv == null) return -1;
-    return (av - bv) * dir;
-  });
-  return rows;
-});
-
-// Apdex distribution — bucket counts driven by the standard apdex
-// bands. When no apdex column exists in the setup, the right column
-// drops the tile so we don't show a hard-coded zero.
+// Apdex distribution — driven by the per-service apdex column when the
+// operator has it configured. Skips rendering when the column is
+// missing so we don't show a hard-coded zero histogram.
 const apdexBuckets = computed(() => {
   const buckets = [
     { label: '0.95 – 1.00', min: 0.95, color: 'var(--sw-ok)', count: 0 },
@@ -129,6 +88,68 @@ const hasApdex = computed(() =>
 );
 const totalApdex = computed(() => apdexBuckets.value.reduce((a, b) => a + 
b.count, 0));
 
+interface Drill {
+  to: string;
+  label: string;
+  desc: string;
+  enabled: boolean;
+}
+const drills = computed<Drill[]>(() => {
+  const L = layer.value;
+  if (!L) return [];
+  const k = layerKey.value;
+  const q = selectedId.value ? 
`?service=${encodeURIComponent(selectedId.value)}` : '';
+  const out: Drill[] = [];
+  if (L.caps.dashboards) {
+    out.push({
+      to: `/layer/${k}/dashboards${q}`,
+      label: 'Dashboards',
+      desc: 'Live widget grid driven by booster-ui templates.',
+      enabled: true,
+    });
+  }
+  if (L.slots.instances) {
+    out.push({
+      to: `/layer/${k}/instances${q}`,
+      label: cfg.value?.slots.instances || L.slots.instances || 'Instances',
+      desc: 'Per-instance metrics, agent status, JVM/process drill.',
+      enabled: false,
+    });
+  }
+  if (L.slots.endpoints) {
+    out.push({
+      to: `/layer/${k}/endpoints${q}`,
+      label: cfg.value?.slots.endpoints || L.slots.endpoints || 'Endpoints',
+      desc: 'API endpoints exposed by this service.',
+      enabled: false,
+    });
+  }
+  if (L.caps.traces) {
+    out.push({
+      to: `/layer/${k}/traces${q}`,
+      label: 'Traces',
+      desc: 'Trace explorer scoped to this service.',
+      enabled: false,
+    });
+  }
+  if (L.caps.logs) {
+    out.push({
+      to: `/layer/${k}/logs${q}`,
+      label: 'Logs',
+      desc: 'Log explorer scoped to this service.',
+      enabled: false,
+    });
+  }
+  if (L.caps.profiling) {
+    out.push({
+      to: `/layer/${k}/profiling${q}`,
+      label: 'Profiling',
+      desc: 'Flame graphs + sampled stacks.',
+      enabled: false,
+    });
+  }
+  return out;
+});
 const reachable = computed(() => landing.data.value?.reachable !== false);
 </script>
 
@@ -140,59 +161,35 @@ const reachable = computed(() => 
landing.data.value?.reachable !== false);
     </div>
 
     <div class="grid">
-      <section class="sw-card services-table-card">
+      <section class="sw-card drill-card">
         <div class="card-head">
-          <h4>Services in this layer</h4>
-          <span class="sub">{{ sampled.length }} sampled · click a column to 
re-sort · row click selects</span>
-          <RouterLink class="all-link" to="/setup">Customize</RouterLink>
+          <h4>Drill into the selected service</h4>
+          <span class="sub">other tabs auto-scope via 
<code>?service=</code></span>
+        </div>
+        <div class="drill-grid">
+          <RouterLink
+            v-for="d in drills"
+            :key="d.label"
+            :to="d.to"
+            class="drill"
+            :class="{ disabled: !d.enabled }"
+          >
+            <div class="drill-head">
+              <span class="drill-label">{{ d.label }}</span>
+              <span v-if="!d.enabled" class="sw-badge">soon</span>
+            </div>
+            <p class="drill-desc">{{ d.desc }}</p>
+          </RouterLink>
+          <p v-if="drills.length === 0" class="empty">
+            No deep-dive views configured for this layer.
+          </p>
         </div>
-        <table v-if="sortedRows.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 sortable"
-                :class="{ on: sortMetric === c.metric }"
-                
:title="`${metricMeta(c.metric).longLabel}\n\n${metricMeta(c.metric).tip}`"
-                @click="setSort(c.metric)"
-              >
-                {{ c.label }}<span v-if="c.unit" class="unit">{{ c.unit 
}}</span>
-                <span v-if="sortMetric === c.metric" class="caret">{{ sortDir 
=== 'desc' ? '▼' : '▲' }}</span>
-              </th>
-            </tr>
-          </thead>
-          <tbody>
-            <tr
-              v-for="row in sortedRows"
-              :key="row.serviceId"
-              class="clickable"
-              :class="{ active: row.serviceId === selectedId }"
-              @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>
       </section>
 
       <section v-if="hasApdex && totalApdex > 0" class="sw-card apdex-card">
         <div class="card-head">
           <h4>Apdex distribution</h4>
-          <span class="sub">services bucketed</span>
+          <span class="sub">{{ totalApdex }} services bucketed</span>
         </div>
         <div class="apdex-body">
           <div v-for="b in apdexBuckets" :key="b.label" class="apdex-row">
@@ -216,7 +213,6 @@ const reachable = computed(() => 
landing.data.value?.reachable !== false);
   display: flex;
   flex-direction: column;
   gap: 14px;
-  padding: 14px 0 0;
 }
 .banner.err {
   padding: 8px 12px;
@@ -228,7 +224,7 @@ const reachable = computed(() => 
landing.data.value?.reachable !== false);
 }
 .grid {
   display: grid;
-  grid-template-columns: 1.2fr 1fr;
+  grid-template-columns: 1.4fr 1fr;
   gap: 14px;
   align-items: start;
 }
@@ -244,99 +240,63 @@ const reachable = computed(() => 
landing.data.value?.reachable !== false);
   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 {
+.card-head .sub code {
+  font-family: var(--sw-mono);
   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);
+  padding: 0 3px;
+  border-radius: 2px;
 }
-.svc-link {
-  color: var(--sw-fg-0);
-}
-.services-table-card .sw-table tr.clickable:hover .svc-link {
-  color: var(--sw-accent-2);
-}
-.services-table-card .sw-table tr.clickable.active {
-  background: var(--sw-accent-soft);
-}
-.services-table-card .sw-table tr.clickable.active .svc-link {
-  color: var(--sw-accent-2);
-  font-weight: 600;
-}
-.services-table-card .sw-table th.sortable {
-  cursor: pointer;
-  user-select: none;
+.drill-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+  gap: 10px;
+  padding: 12px 14px;
 }
-.services-table-card .sw-table th.sortable:hover {
-  color: var(--sw-fg-1);
+.drill {
+  background: var(--sw-bg-1);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 6px;
+  padding: 10px 12px;
+  text-decoration: none;
+  color: inherit;
+  transition: border-color 0.12s, background 0.12s;
 }
-.services-table-card .sw-table th.sortable.on {
-  color: var(--sw-accent-2);
+.drill:hover {
+  background: var(--sw-bg-2);
+  border-color: var(--sw-line-3);
 }
-.services-table-card .sw-table th .caret {
-  margin-left: 3px;
-  font-size: 9px;
+.drill.disabled {
+  border-style: dashed;
+  opacity: 0.7;
+  pointer-events: none;
 }
-.apdex-card .card-head {
+.drill-head {
   display: flex;
   align-items: baseline;
-  gap: 10px;
-  padding: 10px 14px;
-  border-bottom: 1px solid var(--sw-line);
+  justify-content: space-between;
+  gap: 6px;
 }
-.apdex-card .card-head h4 {
-  margin: 0;
+.drill-label {
   font-size: 12px;
   font-weight: 600;
   color: var(--sw-fg-0);
 }
-.apdex-card .card-head .sub {
+.drill-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);
 }
 .apdex-body {
   padding: 12px 14px;
@@ -374,26 +334,6 @@ const reachable = computed(() => 
landing.data.value?.reachable !== false);
   color: var(--sw-fg-0);
   font-variant-numeric: tabular-nums;
 }
-.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;
diff --git a/apps/ui/src/views/layer/LayerShell.vue 
b/apps/ui/src/views/layer/LayerShell.vue
index dd5a28a..bdd7851 100644
--- a/apps/ui/src/views/layer/LayerShell.vue
+++ b/apps/ui/src/views/layer/LayerShell.vue
@@ -26,12 +26,14 @@
       default entry; the cross-layer Overview lives at `/`.
 -->
 <script setup lang="ts">
-import { computed } from 'vue';
+import { computed, ref } from 'vue';
 import { RouterLink, RouterView, useRoute } from 'vue-router';
 import type { LayerDef } from '@skywalking-horizon-ui/api-client';
 import Icon from '@/components/icons/Icon.vue';
+import Sparkline from '@/components/charts/Sparkline.vue';
 import LayerServiceSelector from './LayerServiceSelector.vue';
 import { metricMeta } from '@/composables/metricCatalog';
+import { colorForMetric } from '@/composables/metricColor';
 import { useLayerLanding } from '@/composables/useLayerLanding';
 import { useLayers } from '@/composables/useLayers';
 import { useSelectedService } from '@/composables/useSelectedService';
@@ -40,7 +42,7 @@ import { fmtMetric } from '@/utils/formatters';
 
 const route = useRoute();
 const layerKey = computed(() => String(route.params.layerKey ?? ''));
-const { layers, hasTopology } = useLayers();
+const { layers } = useLayers();
 const layer = computed<LayerDef | null>(() => {
   const found = layers.value.find((l) => l.key === layerKey.value);
   return found ?? null;
@@ -76,6 +78,26 @@ const aggregates = computed(() => 
landing.data.value?.aggregates ?? null);
 const { selectedId, setSelected } = useSelectedService();
 const sampledServices = computed(() => landing.data.value?.sampledRows ?? 
landing.rows.value ?? []);
 const selectorColumns = computed(() => safeCfg.value.columns);
+const selectedRow = computed(
+  () =>
+    sampledServices.value.find((s) => s.serviceId === selectedId.value) ??
+    sampledServices.value[0] ??
+    null,
+);
+const selectedName = computed(
+  () => selectedRow.value?.serviceName ?? (sampledServices.value.length > 0 ? 
'pick a service' : '—'),
+);
+
+// Picker toggle state. Lives at the shell level so the header's Switch
+// button and the picker section render against the same state.
+const pickerOpen = ref(false);
+function togglePicker(): void {
+  pickerOpen.value = !pickerOpen.value;
+}
+function pickService(id: string): void {
+  setSelected(id);
+  pickerOpen.value = false;
+}
 
 // ── Header identity ──────────────────────────────────────────────────
 function initialsFor(name: string): string {
@@ -90,40 +112,6 @@ const displayName = computed(() => cfg.value?.displayName 
|| layer.value?.name |
 const initials = computed(() => initialsFor(displayName.value));
 
 // ── Tabs ─────────────────────────────────────────────────────────────
-interface Tab {
-  to: string;
-  label: string;
-  icon?: string;
-}
-const tabs = computed<Tab[]>(() => {
-  if (!layer.value) return [];
-  const L = layer.value;
-  const out: Tab[] = [];
-  const base = `/layer/${L.key}`;
-  if (L.slots.services) out.push({ to: `${base}/services`, label: 
cfg.value?.slots.services || L.slots.services || 'Services' });
-  if (L.slots.instances) out.push({ to: `${base}/instances`, label: 
cfg.value?.slots.instances || L.slots.instances });
-  if (L.slots.endpoints) out.push({ to: `${base}/endpoints`, label: 
cfg.value?.slots.endpoints || L.slots.endpoints });
-  if (hasTopology(L)) out.push({ to: `${base}/topology`, label: 'Topology' });
-  if (L.caps.endpointDependency) {
-    out.push({
-      to: `${base}/dependency`,
-      label: cfg.value?.slots.endpointDependency || 
`${cfg.value?.slots.endpoints || 'Endpoint'} dependency`,
-    });
-  }
-  if (L.caps.dashboards) out.push({ to: `${base}/dashboards`, label: 
'Dashboards' });
-  if (L.caps.traces) out.push({ to: `${base}/traces`, label: 'Traces' });
-  if (L.caps.logs) out.push({ to: `${base}/logs`, label: 'Logs' });
-  if (L.caps.profiling) out.push({ to: `${base}/profiling`, label: 'Profiling' 
});
-  // `events` cap intentionally omitted — operators rely on the sidebar
-  // for cross-cutting event views; per-layer Events will get its own
-  // design pass later.
-  return out;
-});
-
-function isTabActive(to: string): boolean {
-  return route.path === to || route.path.startsWith(to + '/');
-}
-
 // ── Header KPI strip ─────────────────────────────────────────────────
 // Picks at most 5 metrics from the layer's setup columns; service count
 // always leads. Each KPI is read from /api/layer/:key/landing.aggregates,
@@ -132,25 +120,44 @@ interface HeaderKpi {
   label: string;
   value: number | null;
   unit?: string;
-  color?: string;
-  isService?: boolean;
-}
+  /** CSS color for the value text — per-metric color band so the
+   *  header reads at a glance (cpm orange, p99 yellow, sla purple,
+   *  err red — matches the design's landing-layer KPI row). */
+  color: string;
+  /** Trend series — rendered as a small inline sparkline under the
+   *  value when present. */
+  spark?: Array<number | null>;
+}
+/**
+ * Header KPIs scope to the *selected service*. Falls back to the
+ * layer-wide aggregates when no service is selectable (e.g. before
+ * data loads). The per-service sparkline comes from `row.spark` when
+ * present; otherwise falls through to the layer-wide
+ * `seriesByMetric[col.metric]` so the trend area never goes blank just
+ * because the BFF only built one spark series.
+ */
 const headerKpis = computed<HeaderKpi[]>(() => {
   const L = layer.value;
   if (!L) return [];
   const c = cfg.value;
   if (!c) return [];
   const a = aggregates.value;
-  const svcCount = a?.serviceCount ?? L.serviceCount;
-  const out: HeaderKpi[] = [
-    { label: c.slots.services || 'Services', value: svcCount, color: L.color, 
isService: true },
-  ];
+  const row = selectedRow.value;
+  const out: HeaderKpi[] = [];
   for (const col of c.landing.columns.slice(0, 5)) {
     const m = metricMeta(col.metric);
+    const value = row ? row.metrics[col.metric] ?? null : 
a?.metrics?.[col.metric] ?? null;
     out.push({
       label: col.label || m.label,
-      value: a?.metrics?.[col.metric] ?? null,
+      value,
       unit: col.unit || m.unit,
+      color: colorForMetric(col.metric),
+      // Prefer the selected service's spark; otherwise reuse the
+      // layer-aggregate series.
+      spark:
+        row?.spark && row.spark.length > 1
+          ? row.spark
+          : a?.seriesByMetric?.[col.metric],
     });
   }
   return out;
@@ -167,66 +174,74 @@ const sourceText = computed(() => {
 <template>
   <div class="layer-shell">
     <header v-if="layer" class="sw-card layer-head">
-      <div class="head-row">
-        <div class="identity">
-          <div class="icon-tile" :style="{ background: layer.color }">{{ 
initials }}</div>
-          <div class="identity-text">
-            <div class="title-row">
-              <h1>{{ displayName }}</h1>
-              <span class="sw-tag layer-tag">LAYER</span>
-              <span class="sw-tag">{{ sourceText }}</span>
-              <span v-if="layer.serviceCount === 0" class="sw-badge warn">no 
services</span>
-              <span v-else-if="!layer.active" class="sw-badge">no data</span>
-            </div>
-            <div class="sub">
-              {{ layer.serviceCount >= 0 ? `${layer.serviceCount} 
${(cfg?.slots.services || 'services').toLowerCase()}` : 'no service data' }}
-              <span v-if="layer.documentLink">·
-                <a :href="layer.documentLink" target="_blank" rel="noopener 
noreferrer">docs ↗</a>
-              </span>
-            </div>
+      <!-- Row 1: layer identity. -->
+      <div class="layer-id-row">
+        <div class="icon-tile" :style="{ background: layer.color }">{{ 
initials }}</div>
+        <div class="identity-text">
+          <div class="title-row">
+            <h1>{{ displayName }}</h1>
+            <span class="sw-tag layer-tag">LAYER</span>
+            <span class="sw-tag">{{ sourceText }}</span>
+            <span v-if="layer.serviceCount === 0" class="sw-badge warn">no 
services</span>
+            <span v-else-if="!layer.active" class="sw-badge">no data</span>
+          </div>
+          <div class="sub">
+            {{ layer.serviceCount >= 0 ? `${layer.serviceCount} 
${(cfg?.slots.services || 'services').toLowerCase()}` : 'no service data' }}
+            <span v-if="layer.documentLink">·
+              <a :href="layer.documentLink" target="_blank" rel="noopener 
noreferrer">docs ↗</a>
+            </span>
           </div>
         </div>
+      </div>
+
+      <!-- Row 2: Switch button + selected service name + KPI strip.
+           Merged into the same card as the layer header so there's no
+           duplicate "Selected service" zone elsewhere. -->
+      <div v-if="sampledServices.length > 0" class="service-row">
+        <button
+          class="sw-btn switch"
+          type="button"
+          :class="{ open: pickerOpen }"
+          @click="togglePicker"
+        >
+          <span class="caret">▾</span>
+          <span class="svc-name">{{ selectedName }}</span>
+        </button>
         <div class="kpi-strip">
           <div v-for="(k, i) in headerKpis" :key="i" class="kpi">
             <div class="kpi-label">{{ k.label }}</div>
-            <div class="kpi-value" :style="k.color && k.isService ? { color: 
k.color } : undefined">
+            <div class="kpi-value" :style="{ color: k.color }">
               <span :class="{ muted: k.value == null }">{{ fmtMetric(k.value) 
}}</span>
               <span v-if="k.unit" class="kpi-unit">{{ k.unit }}</span>
             </div>
+            <Sparkline
+              v-if="k.spark && k.spark.length > 1"
+              class="kpi-spark"
+              :values="k.spark"
+              :width="84"
+              :height="18"
+              :color="k.color"
+              :stroke="1.25"
+            />
+            <span v-else class="kpi-spark-empty">&nbsp;</span>
           </div>
         </div>
       </div>
-
     </header>
 
-    <!-- Service selector sits between the layer header and the tab
-         strip — operators pick context first, then drill into the
-         specific tab they want. Selection (URL ?service=) carries
-         across every tab. -->
+    <!-- Picker dropdown — only visible when the Switch button is open.
+         Sits below the General header so the page reads top-to-bottom:
+         layer identity → expanded service picker → sub-route body. -->
     <LayerServiceSelector
-      v-if="layer && sampledServices.length > 0"
+      v-if="layer && pickerOpen && sampledServices.length > 0"
       :services="sampledServices"
       :columns="selectorColumns"
       :selected-id="selectedId"
       :accent="layer.color"
-      @select="setSelected"
+      @select="pickService"
     />
 
-    <nav v-if="layer && tabs.length > 0" class="sw-card tab-strip-wrap">
-      <div class="tab-strip">
-        <RouterLink
-          v-for="t in tabs"
-          :key="t.to"
-          :to="t.to"
-          class="tab"
-          :class="{ on: isTabActive(t.to) }"
-        >
-          {{ t.label }}
-        </RouterLink>
-      </div>
-    </nav>
-
-    <div v-else class="missing">
+    <div v-if="!layer" class="missing">
       <div class="sw-card missing-card">
         <Icon name="alert" :size="18" />
         <div>
@@ -239,15 +254,9 @@ const sourceText = computed(() => {
       </div>
     </div>
 
+    <!-- Sub-route body. No tab strip — operators navigate via the
+         per-layer entries in the left sidebar. -->
     <div v-if="layer" class="tab-body">
-      <LayerServiceSelector
-        v-if="sampledServices.length > 0"
-        :services="sampledServices"
-        :columns="selectorColumns"
-        :selected-id="selectedId"
-        :accent="layer.color"
-        @select="setSelected"
-      />
       <RouterView />
     </div>
   </div>
@@ -263,18 +272,55 @@ const sourceText = computed(() => {
   padding: 14px;
   margin-bottom: 14px;
 }
-.head-row {
-  display: flex;
-  align-items: flex-start;
-  gap: 18px;
-  flex-wrap: wrap;
-}
-.identity {
+.layer-id-row {
   display: flex;
   align-items: center;
   gap: 12px;
   min-width: 0;
-  flex: 1 1 320px;
+}
+.identity-text {
+  min-width: 0;
+}
+.service-row {
+  display: flex;
+  align-items: flex-end;
+  gap: 18px;
+  flex-wrap: wrap;
+  margin-top: 14px;
+  padding-top: 14px;
+  border-top: 1px dashed var(--sw-line);
+}
+.switch {
+  /* Merged Switch button — sits at the start of the service row, ahead
+   * of the KPI strip. Click opens the picker dropdown below. */
+  height: 32px;
+  padding: 0 12px;
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 12.5px;
+  font-weight: 500;
+  border-color: var(--sw-line-2);
+}
+.switch:hover {
+  background: var(--sw-bg-2);
+}
+.switch.open {
+  background: var(--sw-bg-2);
+  border-color: var(--sw-line-3);
+}
+.switch .caret {
+  font-size: 10px;
+  color: var(--sw-fg-3);
+  transition: transform 0.12s;
+}
+.switch.open .caret {
+  transform: rotate(180deg);
+}
+.switch .svc-name {
+  font-family: var(--sw-mono);
+  color: var(--sw-fg-0);
+  letter-spacing: -0.01em;
 }
 .icon-tile {
   width: 40px;
@@ -292,9 +338,6 @@ const sourceText = computed(() => {
   background-blend-mode: multiply;
   box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
 }
-.identity-text {
-  min-width: 0;
-}
 .title-row {
   display: flex;
   align-items: baseline;
@@ -325,14 +368,14 @@ const sourceText = computed(() => {
 }
 .kpi-strip {
   display: flex;
-  gap: 20px;
+  gap: 22px;
   flex-wrap: wrap;
   align-items: flex-end;
   margin-left: auto;
 }
 .kpi {
   text-align: right;
-  min-width: 60px;
+  min-width: 80px;
 }
 .kpi-label {
   font-size: 10px;
@@ -342,48 +385,33 @@ const sourceText = computed(() => {
   margin-bottom: 2px;
 }
 .kpi-value {
-  font-size: 17px;
+  font-size: 18px;
   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);
 }
+.kpi-value > span:first-child {
+  /* Inherit color from the parent .kpi-value style binding (per-metric
+   * color band) — the muted modifier below overrides when value is null. */
+  color: inherit;
+}
 .kpi-unit {
   font-size: 10px;
   color: var(--sw-fg-3);
   margin-left: 2px;
 }
-.tab-strip-wrap {
-  margin-bottom: 14px;
-  padding: 0;
-}
-.tab-strip {
-  display: flex;
-  gap: 2px;
-  padding: 0 14px;
-  overflow-x: auto;
-}
-.tab {
-  padding: 8px 12px;
-  font-size: 12px;
-  font-weight: 500;
-  color: var(--sw-fg-2);
-  text-decoration: none;
-  border-bottom: 2px solid transparent;
-  margin-bottom: -1px;
-  white-space: nowrap;
-  transition: color 0.12s, border-color 0.12s;
-}
-.tab:hover {
-  color: var(--sw-fg-1);
+.kpi-spark {
+  display: block;
+  margin-top: 4px;
+  margin-left: auto;
 }
-.tab.on {
-  color: var(--sw-fg-0);
-  font-weight: 600;
-  border-bottom-color: var(--sw-accent);
+.kpi-spark-empty {
+  display: block;
+  height: 16px;
+  margin-top: 4px;
 }
 .tab-body {
   /* Sub-routes own their own internal layout / padding. */
diff --git a/packages/api-client/src/landing.ts 
b/packages/api-client/src/landing.ts
index 076cfc9..88d2757 100644
--- a/packages/api-client/src/landing.ts
+++ b/packages/api-client/src/landing.ts
@@ -53,6 +53,12 @@ export interface LandingAggregates {
   /** Aggregated value per column metric, keyed by metric short key.
    *  `null` when the column has no MQE mapping or every cell failed. */
   metrics: Record<string, number | null>;
+  /** Per-column aggregated time series (one entry per bucket in the
+   *  default MINUTE-stepped window). Aggregation kind comes from the
+   *  column's `aggregation` field — sum for cpm-shaped throughput
+   *  metrics, avg for ratio/latency metrics. Used by the per-layer
+   *  header to render a trend line under each KPI. */
+  seriesByMetric: Record<string, Array<number | null>>;
   /** Aggregated sparkline series for the `throughput.metric` (or `spark`)
    *  using the throughput aggregation. `null` when not configured. */
   spark?: Array<number | null> | null;

Reply via email to