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"> </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;