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>
