This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit c7d8640e58ab3b8d43b01896d659587794d685b7
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 12 17:17:40 2026 +0800

    layer: collapse Services + Dashboards into one Service page
    
    The per-layer Services page was a thin shell of drill links + apdex
    distribution; the Dashboards page was the widget grid that operators
    actually use. They're the same concept — the layer's primary landing.
    Collapse them:
    
    - Canonical route renamed /dashboards → /service. Legacy /services,
      /services/:id, /dashboards all redirect to /service.
    - Sidebar entry replaces 'Services' + 'Dashboards' children with one
      'Service' link (with the service-count badge moved onto it).
    - LayerServicesView.vue removed.
    - LayerDashboardsView kicker reads 'Service' now; 'Customize widgets'
      link points at the upcoming /admin/layer-dashboards page (admin
      sub-feature lands in the next commit).
    - Single-feature layer direct link (virtual_database etc.) goes to
      /service, matching the canonical name.
---
 apps/ui/src/components/shell/AppSidebar.vue     |  25 +-
 apps/ui/src/router/index.ts                     |  40 +--
 apps/ui/src/views/layer/LayerDashboardsView.vue |   4 +-
 apps/ui/src/views/layer/LayerServicesView.vue   | 342 ------------------------
 4 files changed, 36 insertions(+), 375 deletions(-)

diff --git a/apps/ui/src/components/shell/AppSidebar.vue 
b/apps/ui/src/components/shell/AppSidebar.vue
index c8b88eb..e62657b 100644
--- a/apps/ui/src/components/shell/AppSidebar.vue
+++ b/apps/ui/src/components/shell/AppSidebar.vue
@@ -175,11 +175,11 @@ const sections: NavSection[] = [
       </div>
       <template v-for="L in orderedLayers" :key="L.key">
         <!-- Single-feature layer (virtual_database, virtual_cache, etc.):
-             render as a direct link — no expander, no children. The
-             services page is the layer's effective dashboard. -->
+             render as a direct link to the layer's Service page — no
+             expander, no children. -->
         <RouterLink
           v-if="isSingleFeatureLayer(L)"
-          :to="`/layer/${L.key}/services`"
+          :to="`/layer/${L.key}/service`"
           class="layer-row direct"
           :class="{ 'is-active': isActive(`/layer/${L.key}`) }"
         >
@@ -209,13 +209,16 @@ const sections: NavSection[] = [
           </span>
         </div>
         <div v-if="!isSingleFeatureLayer(L) && expandedLayer === L.key" 
class="layer-children">
+          <!-- Service = the layer's primary landing page (widget grid).
+               This single entry replaces the old separate Services list
+               + Dashboards entries — they were the same page in concept. -->
           <RouterLink
-            v-if="L.slots.services"
-            :to="`/layer/${L.key}/services`"
+            v-if="L.slots.services || L.caps.dashboards"
+            :to="`/layer/${L.key}/service`"
             class="sw-nav-item"
-            :class="{ 'is-active': isActive(`/layer/${L.key}/services`) || 
route.path === `/layer/${L.key}` }"
+            :class="{ 'is-active': isActive(`/layer/${L.key}/service`) || 
route.path === `/layer/${L.key}` }"
           >
-            <Icon name="svc" /><span>{{ L.slots.services }}</span>
+            <Icon name="svc" /><span>Service</span>
             <span class="sw-badge" style="margin-left: auto">{{ L.serviceCount 
}}</span>
           </RouterLink>
           <RouterLink
@@ -251,14 +254,6 @@ const sections: NavSection[] = [
           >
             <Icon name="ep" /><span>{{ L.slots.endpointDependency ?? 
`${L.slots.endpoints ?? 'Endpoint'} dependency` }}</span>
           </RouterLink>
-          <RouterLink
-            v-if="L.caps.dashboards"
-            :to="`/layer/${L.key}/dashboards`"
-            class="sw-nav-item"
-            :class="{ 'is-active': isActive(`/layer/${L.key}/dashboards`) }"
-          >
-            <Icon name="metric" /><span>Dashboards</span>
-          </RouterLink>
           <RouterLink
             v-if="L.caps.traces"
             :to="`/layer/${L.key}/traces`"
diff --git a/apps/ui/src/router/index.ts b/apps/ui/src/router/index.ts
index 77b7bb1..b978a6c 100644
--- a/apps/ui/src/router/index.ts
+++ b/apps/ui/src/router/index.ts
@@ -28,9 +28,9 @@ function humanKey(k: string): string {
 // reads `:layerKey` from the URL and pulls layer config / live data.
 // Sub-route components fill the tab body via a nested router-view.
 function layerRoute(): RouteRecordRaw {
-  // Tabs that still render generic placeholders. Services drops out
-  // because it has a real component; service-detail is a nested child
-  // of services so the breadcrumb stays clean.
+  // Per-layer sub-routes that still render generic placeholders until
+  // their phases land. The canonical landing is `/service` — that's
+  // the widget-grid view operators see when they click a layer.
   const placeholderTabs: { path: string; label: string; phase: string }[] = [
     { path: 'instances', label: 'Instances', phase: 'Phase 2 / 3' },
     { path: 'endpoints', label: 'Endpoints', phase: 'Phase 2 / 3' },
@@ -44,25 +44,33 @@ function layerRoute(): RouteRecordRaw {
     path: 'layer/:layerKey',
     component: () => import('@/views/layer/LayerShell.vue'),
     children: [
-      // Bare /layer/:layerKey lands on Services — the default entry.
-      { path: '', redirect: (to) => ({ path: 
`/layer/${to.params.layerKey}/services` }) },
-      // Services list — live constellation + sortable table. Selected
-      // service rides on `?service=<id>` in the URL (single source of
-      // truth across all tabs); the selector zone in LayerShell pins it.
-      { path: 'services', component: () => 
import('@/views/layer/LayerServicesView.vue') },
-      // Legacy route — redirect to query-string form so old bookmarks
-      // keep working.
+      // Bare /layer/:layerKey lands on the Service view — the per-layer
+      // widget grid driven by the dashboard config.
+      { path: '', redirect: (to) => ({ path: 
`/layer/${to.params.layerKey}/service` }) },
+      // Canonical per-layer landing page.
+      { path: 'service', component: () => 
import('@/views/layer/LayerDashboardsView.vue') },
+      // Legacy routes — redirect to /service so old bookmarks keep working.
+      {
+        path: 'services',
+        redirect: (to) => ({
+          path: `/layer/${to.params.layerKey}/service`,
+          query: to.query,
+        }),
+      },
       {
         path: 'services/:serviceId',
         redirect: (to) => ({
-          path: `/layer/${to.params.layerKey}/services`,
+          path: `/layer/${to.params.layerKey}/service`,
           query: { service: String(to.params.serviceId) },
         }),
       },
-      // Real dashboards (Phase 3). Widget set comes from the BFF's
-      // defaults (lifted from booster-ui templates); MQE runs live via
-      // /api/layer/:key/dashboard.
-      { path: 'dashboards', component: () => 
import('@/views/layer/LayerDashboardsView.vue') },
+      {
+        path: 'dashboards',
+        redirect: (to) => ({
+          path: `/layer/${to.params.layerKey}/service`,
+          query: to.query,
+        }),
+      },
       ...placeholderTabs.map<RouteRecordRaw>((f) => ({
         path: f.path,
         component: () => import('@/views/layer/LayerTabPlaceholder.vue'),
diff --git a/apps/ui/src/views/layer/LayerDashboardsView.vue 
b/apps/ui/src/views/layer/LayerDashboardsView.vue
index ffdbc59..066c872 100644
--- a/apps/ui/src/views/layer/LayerDashboardsView.vue
+++ b/apps/ui/src/views/layer/LayerDashboardsView.vue
@@ -57,11 +57,11 @@ const errorText = computed(() => data.value?.error ?? 
(error.value ? String(erro
   <div class="dash-tab">
     <header class="dash-head">
       <div>
-        <div class="kicker">Dashboards</div>
+        <div class="kicker">Service</div>
         <h2>{{ widgets.length }} widget{{ widgets.length === 1 ? '' : 's' 
}}</h2>
         <p class="sub">
           MQE scoped to <code>{{ serviceText }}</code>.
-          Refreshes every 60s. <RouterLink to="/setup">Customize columns + 
KPIs</RouterLink>.
+          Refreshes every 60s. <RouterLink 
to="/admin/layer-dashboards">Customize widgets</RouterLink>.
         </p>
       </div>
       <div class="state">
diff --git a/apps/ui/src/views/layer/LayerServicesView.vue 
b/apps/ui/src/views/layer/LayerServicesView.vue
deleted file mode 100644
index 28f5f96..0000000
--- a/apps/ui/src/views/layer/LayerServicesView.vue
+++ /dev/null
@@ -1,342 +0,0 @@
-<!--
-  Licensed to the Apache Software Foundation (ASF) under one or more
-  contributor license agreements.  See the NOTICE file distributed with
-  this work for additional information regarding copyright ownership.
-  The ASF licenses this file to You under the Apache License, Version 2.0
-  (the "License"); you may not use this file except in compliance with
-  the License.  You may obtain a copy of the License at
-
-      http://www.apache.org/licenses/LICENSE-2.0
-
-  Unless required by applicable law or agreed to in writing, software
-  distributed under the License is distributed on an "AS IS" BASIS,
-  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-  See the License for the specific language governing permissions and
-  limitations under the License.
--->
-<!--
-  Services tab body. 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 } from 'vue';
-import { useRoute, RouterLink } from 'vue-router';
-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';
-
-const route = useRoute();
-const layerKey = computed(() => String(route.params.layerKey ?? ''));
-const { layers } = useLayers();
-const layer = computed<LayerDef | null>(() => layers.value.find((l) => l.key 
=== layerKey.value) ?? null);
-const store = useSetupStore();
-const cfg = computed(() => {
-  if (!layer.value) return null;
-  return store.ensure(layer.value.key, { slots: layer.value.slots, caps: 
layer.value.caps });
-});
-
-const safeLayer = computed<LayerDef>(() => layer.value ?? {
-  key: layerKey.value,
-  name: layerKey.value,
-  color: 'var(--sw-fg-2)',
-  serviceCount: -1,
-  active: false,
-  level: null,
-  slots: {},
-  caps: {},
-});
-const safeCfg = computed(() => cfg.value?.landing ?? {
-  priority: 99,
-  topN: 5,
-  orderBy: 'cpm',
-  columns: [],
-  style: 'table' as const,
-});
-const landing = useLayerLanding(safeLayer, safeCfg);
-const sampled = computed(() => landing.data.value?.sampledRows ?? 
landing.rows.value ?? []);
-const { selectedId } = useSelectedService();
-
-// 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 },
-    { label: '0.85 – 0.95', min: 0.85, color: 'var(--sw-info)', count: 0 },
-    { label: '0.70 – 0.85', min: 0.70, color: 'var(--sw-warn)', count: 0 },
-    { label: '< 0.70', min: -Infinity, color: 'var(--sw-err)', count: 0 },
-  ];
-  for (const row of sampled.value) {
-    const v = row.metrics['apdex'];
-    if (v === null || v === undefined || !Number.isFinite(v)) continue;
-    for (const b of buckets) {
-      if (v >= b.min) {
-        b.count++;
-        break;
-      }
-    }
-  }
-  return buckets;
-});
-const hasApdex = computed(() =>
-  (cfg.value?.landing.columns ?? []).some((c) => c.metric === 'apdex'),
-);
-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>
-
-<template>
-  <div class="services-tab">
-    <div v-if="!reachable" class="banner err">
-      <strong>OAP unreachable.</strong>
-      Live service data is unavailable for this layer. Showing what's cached.
-    </div>
-
-    <div class="grid">
-      <section class="sw-card drill-card">
-        <div class="card-head">
-          <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>
-      </section>
-
-      <section v-if="hasApdex && totalApdex > 0" class="sw-card apdex-card">
-        <div class="card-head">
-          <h4>Apdex distribution</h4>
-          <span class="sub">{{ totalApdex }} services bucketed</span>
-        </div>
-        <div class="apdex-body">
-          <div v-for="b in apdexBuckets" :key="b.label" class="apdex-row">
-            <span class="sw-tag">{{ b.label }}</span>
-            <div class="bar">
-              <div
-                class="bar-fill"
-                :style="{ width: `${(b.count / totalApdex) * 100}%`, 
background: b.color }"
-              />
-            </div>
-            <span class="count">{{ b.count }}</span>
-          </div>
-        </div>
-      </section>
-    </div>
-  </div>
-</template>
-
-<style scoped>
-.services-tab {
-  display: flex;
-  flex-direction: column;
-  gap: 14px;
-}
-.banner.err {
-  padding: 8px 12px;
-  background: var(--sw-err-soft);
-  border: 1px solid rgba(239, 68, 68, 0.3);
-  border-radius: 6px;
-  color: #f87171;
-  font-size: 11.5px;
-}
-.grid {
-  display: grid;
-  grid-template-columns: 1.4fr 1fr;
-  gap: 14px;
-  align-items: start;
-}
-.card-head {
-  display: flex;
-  align-items: baseline;
-  gap: 10px;
-  padding: 10px 14px;
-  border-bottom: 1px solid var(--sw-line);
-}
-.card-head h4 {
-  margin: 0;
-  font-size: 12px;
-  font-weight: 600;
-  color: var(--sw-fg-0);
-}
-.card-head .sub {
-  font-size: 10.5px;
-  color: var(--sw-fg-3);
-}
-.card-head .sub code {
-  font-family: var(--sw-mono);
-  font-size: 10px;
-  background: var(--sw-bg-2);
-  padding: 0 3px;
-  border-radius: 2px;
-}
-.drill-grid {
-  display: grid;
-  grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
-  gap: 10px;
-  padding: 12px 14px;
-}
-.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;
-}
-.drill:hover {
-  background: var(--sw-bg-2);
-  border-color: var(--sw-line-3);
-}
-.drill.disabled {
-  border-style: dashed;
-  opacity: 0.7;
-  pointer-events: none;
-}
-.drill-head {
-  display: flex;
-  align-items: baseline;
-  justify-content: space-between;
-  gap: 6px;
-}
-.drill-label {
-  font-size: 12px;
-  font-weight: 600;
-  color: var(--sw-fg-0);
-}
-.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;
-  display: flex;
-  flex-direction: column;
-  gap: 8px;
-}
-.apdex-row {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-}
-.apdex-row .sw-tag {
-  width: 96px;
-  font-size: 10px;
-  text-align: center;
-}
-.apdex-row .bar {
-  flex: 1;
-  height: 6px;
-  background: var(--sw-bg-3);
-  border-radius: 3px;
-  overflow: hidden;
-}
-.apdex-row .bar-fill {
-  height: 100%;
-  border-radius: 3px;
-  transition: width 0.2s ease-out;
-}
-.apdex-row .count {
-  width: 30px;
-  text-align: right;
-  font-family: var(--sw-mono);
-  font-size: 10.5px;
-  color: var(--sw-fg-0);
-  font-variant-numeric: tabular-nums;
-}
-@media (max-width: 1100px) {
-  .grid {
-    grid-template-columns: 1fr;
-  }
-}
-</style>

Reply via email to