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 1c5f8e4 overview: auto-built landing at /; sidebar follows landing
priority
1c5f8e4 is described below
commit 1c5f8e4c92bd57422e448795c7ba67c4083cec29
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 12 14:31:51 2026 +0800
overview: auto-built landing at /; sidebar follows landing priority
- Split routes: / is now the Overview (cross-layer landing composed
from each enabled layer's setup); /setup moves to its own route.
- New composable useLandingOrder: shared priority-based sort used by
the sidebar's Layers section AND the Overview cards so the two stay
in lockstep. useLandingLayers filters to landing.enabled.
- LayerLandingCard renders each enabled layer with its configured
columns + topN as a placeholder table; live service data plugs in
during Stage 2.4.
- Sidebar adds a 'Setup' lead link beside Overview so operators bounce
between the two during initial configuration.
- Setup page now shares the same useLandingOrder so its ordering
matches the sidebar/overview.
---
apps/ui/src/components/shell/AppSidebar.vue | 18 ++-
apps/ui/src/composables/useLandingOrder.ts | 53 +++++++
apps/ui/src/router/index.ts | 6 +-
apps/ui/src/views/overview/LayerLandingCard.vue | 188 ++++++++++++++++++++++++
apps/ui/src/views/overview/OverviewView.vue | 155 +++++++++++++++++++
apps/ui/src/views/setup/SetupView.vue | 16 +-
6 files changed, 419 insertions(+), 17 deletions(-)
diff --git a/apps/ui/src/components/shell/AppSidebar.vue
b/apps/ui/src/components/shell/AppSidebar.vue
index d09d05e..bd054ee 100644
--- a/apps/ui/src/components/shell/AppSidebar.vue
+++ b/apps/ui/src/components/shell/AppSidebar.vue
@@ -21,6 +21,7 @@ import Icon, { type IconName } from
'@/components/icons/Icon.vue';
import logoSw from '@/assets/icons/logo-sw.svg?raw';
import { useAuthStore } from '@/stores/auth';
import { useLayers } from '@/composables/useLayers';
+import { useLandingOrder } from '@/composables/useLandingOrder';
const auth = useAuthStore();
const router = useRouter();
@@ -30,13 +31,15 @@ async function signOut(): Promise<void> {
}
const { availableLayers, oapReachable, oapError, hasTopology } = useLayers();
+// Sidebar shares the landing's priority order so the two views stay in sync.
+const orderedLayers = useLandingOrder(availableLayers);
// Default-open the first available layer once data arrives; user clicks
// thereafter take over.
const expandedLayer = ref<string | null>(null);
let userTouched = false;
watch(
- availableLayers,
+ orderedLayers,
(rows) => {
if (userTouched || expandedLayer.value) return;
if (rows.length > 0) expandedLayer.value = rows[0].key;
@@ -68,6 +71,10 @@ interface NavSection {
// One leading row before the Layers block — the cross-layer landing.
const overview: NavRow = { icon: 'dash', label: 'Overview', to: '/' };
+// Setup sits next to Overview as a leading link too — operators bounce
+// between these two during initial configuration.
+const setup: NavRow = { icon: 'set', label: 'Setup', to: '/setup' };
+
// Vantage-style flat kickers for the Operate / Admin half of the sidebar.
// Alarms is user-facing so it sits before the Operate block (between user
// observability concerns and OAP operator concerns).
@@ -136,6 +143,13 @@ const sections: NavSection[] = [
>
<Icon :name="overview.icon" /><span>{{ overview.label }}</span>
</RouterLink>
+ <RouterLink
+ :to="setup.to"
+ class="sw-nav-item lead"
+ :class="{ 'is-active': isActive(setup.to) }"
+ >
+ <Icon :name="setup.icon" /><span>{{ setup.label }}</span>
+ </RouterLink>
<div class="sw-nav-section sw-row" style="justify-content:
space-between">
<span>Layers</span>
@@ -152,7 +166,7 @@ const sections: NavSection[] = [
set up a layer
</RouterLink>
</div>
- <template v-for="L in availableLayers" :key="L.key">
+ <template v-for="L in orderedLayers" :key="L.key">
<div
class="layer-row"
:class="{ 'is-active': expandedLayer === L.key }"
diff --git a/apps/ui/src/composables/useLandingOrder.ts
b/apps/ui/src/composables/useLandingOrder.ts
new file mode 100644
index 0000000..2f5f78f
--- /dev/null
+++ b/apps/ui/src/composables/useLandingOrder.ts
@@ -0,0 +1,53 @@
+/*
+ * 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.
+ */
+
+import { computed, type ComputedRef } from 'vue';
+import type { LayerDef } from '@skywalking-horizon-ui/api-client';
+import { useSetupStore } from '@/stores/setup';
+
+/**
+ * Sort layers by `landing.priority` (lower first). Ties break by the OAP
+ * catalog order already present in `layers` (since the BFF returned them
+ * that way). Lazy-creates a default config for each layer the user hasn't
+ * touched, using `defaultPriority` baked into the setup store.
+ *
+ * Used by BOTH the Overview page (card order) and the sidebar's Layers
+ * section (row order) so the two stay in lockstep.
+ */
+export function useLandingOrder(layers: ComputedRef<readonly LayerDef[]>) {
+ const store = useSetupStore();
+ return computed<LayerDef[]>(() => {
+ return [...layers.value].sort((a, b) => {
+ const pa = store.ensure(a.key, { slots: a.slots, caps: a.caps
}).landing.priority;
+ const pb = store.ensure(b.key, { slots: b.slots, caps: b.caps
}).landing.priority;
+ if (pa !== pb) return pa - pb;
+ return 0; // preserve incoming catalog order
+ });
+ });
+}
+
+/** Layers that opted in to the landing (`landing.enabled === true`),
+ * sorted by priority. Drives the Overview's card list. */
+export function useLandingLayers(layers: ComputedRef<readonly LayerDef[]>) {
+ const store = useSetupStore();
+ const ordered = useLandingOrder(layers);
+ return computed<LayerDef[]>(() =>
+ ordered.value.filter(
+ (L) => store.ensure(L.key, { slots: L.slots, caps: L.caps
}).landing.enabled,
+ ),
+ );
+}
diff --git a/apps/ui/src/router/index.ts b/apps/ui/src/router/index.ts
index 0bdd479..9e9892c 100644
--- a/apps/ui/src/router/index.ts
+++ b/apps/ui/src/router/index.ts
@@ -65,10 +65,8 @@ function layerSubRoutes(): RouteRecordRaw[] {
}
const shellRoutes: RouteRecordRaw[] = [
- { path: '', name: 'setup', component: () =>
import('@/views/setup/SetupView.vue') },
- // The eventual landing route — wired live in Stage 2.4 once the layer
- // landing data endpoint exists. For now it shares the setup component.
- { path: 'landing', name: 'landing', component: () =>
import('@/views/setup/SetupView.vue') },
+ { path: '', name: 'overview', component: () =>
import('@/views/overview/OverviewView.vue') },
+ { path: 'setup', name: 'setup', component: () =>
import('@/views/setup/SetupView.vue') },
...layerSubRoutes(),
// Alerts (user-facing — alarms are observability data, not operator-only)
{ path: 'alarms', component: placeholder, props: { title: 'Alarms', phase:
'Phase 5', note: 'Read-only; recovery is backend-auto. Live debug card via
admin REST.' } },
diff --git a/apps/ui/src/views/overview/LayerLandingCard.vue
b/apps/ui/src/views/overview/LayerLandingCard.vue
new file mode 100644
index 0000000..9496e2e
--- /dev/null
+++ b/apps/ui/src/views/overview/LayerLandingCard.vue
@@ -0,0 +1,188 @@
+<!--
+ 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.
+-->
+<script setup lang="ts">
+import { computed } from 'vue';
+import { RouterLink } from 'vue-router';
+import type { LayerDef } from '@skywalking-horizon-ui/api-client';
+import Icon from '@/components/icons/Icon.vue';
+import { useSetupStore } from '@/stores/setup';
+
+const props = defineProps<{ layer: LayerDef }>();
+const store = useSetupStore();
+const cfg = computed(() => store.ensure(props.layer.key, { slots:
props.layer.slots, caps: props.layer.caps }));
+const slotName = computed(() => cfg.value.slots.services ?? 'Services');
+const detailHref = computed(() => `/layer/${props.layer.key}`);
+</script>
+
+<template>
+ <section class="sw-card layer-landing">
+ <header class="head">
+ <span class="dot" :style="{ background: layer.color }" />
+ <div class="title-block">
+ <h2>
+ <RouterLink :to="detailHref">{{ cfg.displayName || layer.name
}}</RouterLink>
+ </h2>
+ <div class="sub">
+ {{ layer.serviceCount }} {{ slotName.toLowerCase() }}
+ <span v-if="cfg.landing.priority !== undefined" class="sep">·</span>
+ <span class="kicker">priority {{ cfg.landing.priority }}</span>
+ <span class="sep">·</span>
+ <span class="kicker">top {{ cfg.landing.topN }} by {{
cfg.landing.orderBy }}</span>
+ </div>
+ </div>
+ <RouterLink class="sw-btn" :to="detailHref">
+ <span>View all</span>
+ <Icon name="chev" :size="10" />
+ </RouterLink>
+ </header>
+
+ <div class="body">
+ <table class="sw-table">
+ <thead>
+ <tr>
+ <th class="svc-col">{{ slotName }}</th>
+ <th v-for="c in cfg.landing.columns" :key="c.metric" class="num">
+ {{ c.label }}<span v-if="c.unit" class="unit">{{ c.unit }}</span>
+ </th>
+ <th v-if="cfg.landing.spark" class="spark-col">trend</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="i in cfg.landing.topN" :key="i" class="placeholder-row">
+ <td class="svc-col">
+ <span class="shim w-name" />
+ </td>
+ <td v-for="c in cfg.landing.columns" :key="c.metric" class="num">
+ <span class="shim w-num" />
+ </td>
+ <td v-if="cfg.landing.spark" class="spark-col">
+ <span class="shim w-spark" />
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ <div class="card-foot">
+ <span class="placeholder-note">
+ Live service data lands in Stage 2.4 — <RouterLink
to="/setup">customize this card</RouterLink>.
+ </span>
+ </div>
+ </div>
+ </section>
+</template>
+
+<style scoped>
+.layer-landing {
+ margin-bottom: 12px;
+}
+.head {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px 14px;
+ border-bottom: 1px solid var(--sw-line);
+}
+.head .dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex: 0 0 8px;
+}
+.title-block {
+ flex: 1;
+ min-width: 0;
+}
+h2 {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--sw-fg-0);
+ letter-spacing: -0.01em;
+}
+h2 a {
+ color: inherit;
+ text-decoration: none;
+}
+h2 a:hover {
+ color: var(--sw-accent-2);
+}
+.sub {
+ font-size: 11px;
+ color: var(--sw-fg-2);
+ display: flex;
+ gap: 6px;
+ align-items: center;
+ flex-wrap: wrap;
+ margin-top: 2px;
+}
+.sub .kicker {
+ color: var(--sw-fg-3);
+}
+.sub .sep {
+ color: var(--sw-fg-3);
+ opacity: 0.5;
+}
+.head .sw-btn {
+ height: 26px;
+ font-size: 11px;
+ text-decoration: none;
+}
+.body {
+ padding: 4px 0 8px;
+}
+.svc-col {
+ width: 28%;
+ min-width: 160px;
+}
+.spark-col {
+ width: 80px;
+}
+th .unit {
+ margin-left: 3px;
+ color: var(--sw-fg-3);
+ font-weight: 400;
+}
+td.num {
+ font-variant-numeric: tabular-nums;
+ text-align: right;
+}
+.placeholder-row .shim {
+ display: inline-block;
+ height: 9px;
+ background: var(--sw-bg-3);
+ border-radius: 3px;
+}
+.placeholder-row .w-name {
+ width: 60%;
+}
+.placeholder-row .w-num {
+ width: 36px;
+}
+.placeholder-row .w-spark {
+ width: 64px;
+ height: 14px;
+}
+.card-foot {
+ padding: 8px 14px 4px;
+ border-top: 1px dashed var(--sw-line);
+ font-size: 10.5px;
+ color: var(--sw-fg-3);
+}
+.placeholder-note a {
+ color: var(--sw-accent-2);
+ text-decoration: none;
+}
+</style>
diff --git a/apps/ui/src/views/overview/OverviewView.vue
b/apps/ui/src/views/overview/OverviewView.vue
new file mode 100644
index 0000000..e8df152
--- /dev/null
+++ b/apps/ui/src/views/overview/OverviewView.vue
@@ -0,0 +1,155 @@
+<!--
+ 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.
+-->
+<script setup lang="ts">
+import { computed } from 'vue';
+import { RouterLink } from 'vue-router';
+import { useLayers } from '@/composables/useLayers';
+import { useLandingLayers } from '@/composables/useLandingOrder';
+import LayerLandingCard from './LayerLandingCard.vue';
+
+const { availableLayers, oapReachable, oapError, isLoading } = useLayers();
+const enabledLayers = useLandingLayers(availableLayers);
+
+// "No one opted in yet" → guide the operator to Setup before anything
+// useful can render.
+const empty = computed(() => !isLoading.value && enabledLayers.value.length
=== 0);
+</script>
+
+<template>
+ <div class="overview">
+ <header class="page-head">
+ <div>
+ <div class="kicker">Overview</div>
+ <h1>Cross-layer landing</h1>
+ <p class="lede">
+ Auto-built from the layers you've enabled in
+ <RouterLink to="/setup">Setup</RouterLink>, in the order each
layer's priority defines.
+ Each card shows the top services for that layer with its configured
metrics.
+ </p>
+ </div>
+ </header>
+
+ <div v-if="!oapReachable && !isLoading" class="banner err">
+ <strong>OAP unreachable.</strong>
+ {{ oapError ?? 'Check that the OAP query host is up and reachable from
the BFF.' }}
+ </div>
+
+ <div v-if="empty" class="empty">
+ <div class="empty-card">
+ <h2>Nothing on the landing yet</h2>
+ <p v-if="availableLayers.length === 0">
+ No layer is reporting services right now. Once data starts flowing
through OAP, the
+ layers appear in <RouterLink to="/setup">Setup</RouterLink> for you
to enable here.
+ </p>
+ <p v-else>
+ {{ availableLayers.length }} layer{{ availableLayers.length === 1 ?
'' : 's' }} reporting,
+ none enabled on the landing yet. Open <RouterLink
to="/setup">Setup</RouterLink>, toggle
+ "Show this layer on the landing" for the ones you care about, and
they'll appear here in
+ priority order.
+ </p>
+ <RouterLink class="sw-btn is-primary" to="/setup">
+ Open Setup
+ </RouterLink>
+ </div>
+ </div>
+
+ <div v-else class="cards">
+ <LayerLandingCard v-for="L in enabledLayers" :key="L.key" :layer="L" />
+ </div>
+ </div>
+</template>
+
+<style scoped>
+.overview {
+ padding: 20px 20px 60px;
+ max-width: 1140px;
+ margin: 0 auto;
+}
+.page-head {
+ margin-bottom: 18px;
+}
+.kicker {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--sw-accent);
+ margin-bottom: 6px;
+}
+.page-head h1 {
+ font-size: 22px;
+ font-weight: 600;
+ letter-spacing: -0.02em;
+ color: var(--sw-fg-0);
+ margin: 0 0 8px;
+}
+.lede {
+ font-size: 12.5px;
+ color: var(--sw-fg-1);
+ line-height: 1.5;
+ margin: 0;
+ max-width: 720px;
+}
+.lede a {
+ color: var(--sw-accent-2);
+ text-decoration: none;
+}
+.banner.err {
+ margin: 0 0 16px;
+ padding: 10px 12px;
+ background: var(--sw-err-soft);
+ border: 1px solid rgba(239, 68, 68, 0.3);
+ border-radius: 6px;
+ color: #f87171;
+ font-size: 12px;
+ line-height: 1.5;
+}
+.empty {
+ margin-top: 20px;
+}
+.empty-card {
+ background: var(--sw-bg-1);
+ border: 1px dashed var(--sw-line-2);
+ border-radius: 10px;
+ padding: 28px;
+ text-align: center;
+ max-width: 600px;
+ margin: 0 auto;
+}
+.empty-card h2 {
+ font-size: 15px;
+ color: var(--sw-fg-0);
+ margin: 0 0 8px;
+}
+.empty-card p {
+ font-size: 12px;
+ color: var(--sw-fg-2);
+ margin: 0 0 16px;
+ line-height: 1.5;
+}
+.empty-card .sw-btn {
+ display: inline-flex;
+ text-decoration: none;
+}
+.empty-card a {
+ color: var(--sw-accent-2);
+ text-decoration: none;
+}
+.cards {
+ display: flex;
+ flex-direction: column;
+}
+</style>
diff --git a/apps/ui/src/views/setup/SetupView.vue
b/apps/ui/src/views/setup/SetupView.vue
index 6cea498..eff3230 100644
--- a/apps/ui/src/views/setup/SetupView.vue
+++ b/apps/ui/src/views/setup/SetupView.vue
@@ -18,22 +18,16 @@
import { computed, ref } from 'vue';
import LayerSetupCard from './LayerSetupCard.vue';
import { useLayers } from '@/composables/useLayers';
+import { useLandingOrder } from '@/composables/useLandingOrder';
import { useSetupStore } from '@/stores/setup';
const { layers, oapReachable, oapError, isLoading } = useLayers();
const store = useSetupStore();
-// Order by priority (lower first), with active layers always above inactive at
-// the same priority. The layer order on the landing will mirror this.
-const orderedLayers = computed(() =>
- [...layers.value].sort((a, b) => {
- const pa = store.ensure(a.key, { slots: a.slots, caps: a.caps
}).landing.priority;
- const pb = store.ensure(b.key, { slots: b.slots, caps: b.caps
}).landing.priority;
- if (pa !== pb) return pa - pb;
- if (a.active !== b.active) return a.active ? -1 : 1;
- return a.name.localeCompare(b.name);
- }),
-);
+// Order by priority (lower first) so this page lines up with the sidebar
+// and the Overview. Setup shows ALL layers (active or not) — operators
+// configure layers ahead of receivers coming online.
+const orderedLayers = useLandingOrder(layers);
const enabledOnLanding = computed(() =>
orderedLayers.value.filter((L) => store.ensure(L.key, { slots: L.slots,
caps: L.caps }).landing.enabled),