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 c58212a6e10a03d935543f6678a78d6a65c6df00
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 12 10:31:43 2026 +0800

    ui: per-layer menu replaces global telemetry section
---
 apps/ui/src/components/shell/AppSidebar.vue | 153 ++++++++++++++++++++--------
 apps/ui/src/components/shell/layers.ts      | 129 +++++++++++++++++++++++
 apps/ui/src/router/index.ts                 | 137 +++++++++++++------------
 3 files changed, 314 insertions(+), 105 deletions(-)

diff --git a/apps/ui/src/components/shell/AppSidebar.vue 
b/apps/ui/src/components/shell/AppSidebar.vue
index 6281ec0..948e983 100644
--- a/apps/ui/src/components/shell/AppSidebar.vue
+++ b/apps/ui/src/components/shell/AppSidebar.vue
@@ -20,6 +20,7 @@ import { RouterLink, useRoute, useRouter } from 'vue-router';
 import Icon, { type IconName } from '@/components/icons/Icon.vue';
 import logoSw from '@/assets/icons/logo-sw.svg?raw';
 import { useAuthStore } from '@/stores/auth';
+import { LAYERS } from './layers';
 
 const auth = useAuthStore();
 const router = useRouter();
@@ -28,17 +29,6 @@ async function signOut(): Promise<void> {
   await router.push({ name: 'login' });
 }
 
-// Phase 2 will replace this stub with real getMenuItems / listLayers data.
-const layers = ref([
-  { key: 'general', name: 'General Service', svc: 84, color: 
'var(--sw-accent)' },
-  { key: 'mesh', name: 'Service Mesh', svc: 22, color: 'var(--sw-info)' },
-  { key: 'k8s', name: 'Kubernetes', svc: 62, color: 'var(--sw-purple)' },
-  { key: 'rum', name: 'Browser (RUM)', svc: 8, color: 'var(--sw-cyan)' },
-  { key: 'mq', name: 'Virtual MQ', svc: 6, color: 'var(--sw-ok)' },
-  { key: 'db', name: 'Virtual Database', svc: 6, color: 'var(--sw-warn)' },
-  { key: 'otel', name: 'OpenTelemetry', svc: 18, color: 'var(--sw-purple)' },
-  { key: 'faas', name: 'FaaS', svc: 3, color: 'var(--sw-err)' },
-]);
 const expandedLayer = ref<string | null>('general');
 
 const route = useRoute();
@@ -53,15 +43,10 @@ interface NavRow {
   badge?: { text: string; kind?: 'ok' | 'warn' | 'err' | 'info' };
 }
 
-const telemetry: NavRow[] = [
-  { icon: 'metric', label: 'Dashboards', to: '/dashboards' },
-  { icon: 'trace', label: 'Traces', to: '/operate/traces' },
-  { icon: 'log', label: 'Logs', to: '/operate/logs' },
-  { icon: 'prof', label: 'Profiling', to: '/profiling' },
-  { icon: 'event', label: 'Events', to: '/operate/events' },
-];
+// Cross-layer operations stay global.
 const operate: NavRow[] = [
   { icon: 'alert', label: 'Alarms', to: '/operate/alarms', badge: { text: '7', 
kind: 'err' } },
+  { icon: 'trace', label: 'Trace search', to: '/operate/traces' },
 ];
 const admin: NavRow[] = [
   { icon: 'svc', label: 'Cluster status', to: '/cluster' },
@@ -81,9 +66,9 @@ const admin: NavRow[] = [
     <nav class="sw-nav">
       <div class="sw-nav-section sw-row" style="justify-content: 
space-between">
         <span>Layers</span>
-        <span style="color: var(--sw-fg-3); font-weight: 400">{{ layers.length 
}} layers</span>
+        <span style="color: var(--sw-fg-3); font-weight: 400">{{ LAYERS.length 
}} layers</span>
       </div>
-      <template v-for="L in layers" :key="L.key">
+      <template v-for="L in LAYERS" :key="L.key">
         <div
           class="sw-nav-item"
           :class="{ 'is-active': expandedLayer === L.key }"
@@ -91,42 +76,120 @@ const admin: NavRow[] = [
         >
           <span class="layer-dot" :style="{ background: L.color }" />
           <span :style="{ fontWeight: expandedLayer === L.key ? 600 : 500 
}">{{ L.name }}</span>
-          <span class="sw-badge" style="margin-left: auto">{{ L.svc }}</span>
-          <span class="caret" :class="{ open: expandedLayer === L.key }"><Icon 
name="caret" :size="10" /></span>
+          <span class="sw-badge" style="margin-left: auto">{{ L.serviceCount 
}}</span>
+          <span class="caret" :class="{ open: expandedLayer === L.key }">
+            <Icon name="caret" :size="10" />
+          </span>
         </div>
         <div v-if="expandedLayer === L.key" class="layer-children">
-          <RouterLink :to="`/layer/${L.key}`" class="sw-nav-item" :class="{ 
'is-active': isActive(`/layer/${L.key}`) }">
-            <Icon name="dash" /><span>Layer overview</span>
+          <RouterLink
+            v-if="L.caps.overview"
+            :to="`/layer/${L.key}`"
+            class="sw-nav-item"
+            :class="{ 'is-active': isActive(`/layer/${L.key}`) && route.path 
=== `/layer/${L.key}` }"
+          >
+            <Icon name="dash" /><span>Overview</span>
           </RouterLink>
-          <RouterLink :to="`/layer/${L.key}/services`" class="sw-nav-item" 
:class="{ 'is-active': isActive(`/layer/${L.key}/services`) }">
-            <Icon name="svc" /><span>Services</span><span class="sw-badge" 
style="margin-left: auto">{{ L.svc }}</span>
+
+          <RouterLink
+            v-if="L.slots.services"
+            :to="`/layer/${L.key}/services`"
+            class="sw-nav-item"
+            :class="{ 'is-active': isActive(`/layer/${L.key}/services`) }"
+          >
+            <Icon name="svc" /><span>{{ L.slots.services }}</span>
+            <span class="sw-badge" style="margin-left: auto">{{ L.serviceCount 
}}</span>
           </RouterLink>
-          <RouterLink :to="`/layer/${L.key}/instances`" class="sw-nav-item" 
:class="{ 'is-active': isActive(`/layer/${L.key}/instances`) }">
-            <Icon name="prof" /><span>Instances</span>
+          <RouterLink
+            v-if="L.slots.instances"
+            :to="`/layer/${L.key}/instances`"
+            class="sw-nav-item"
+            :class="{ 'is-active': isActive(`/layer/${L.key}/instances`) }"
+          >
+            <Icon name="prof" /><span>{{ L.slots.instances }}</span>
           </RouterLink>
-          <RouterLink :to="`/layer/${L.key}/endpoints`" class="sw-nav-item" 
:class="{ 'is-active': isActive(`/layer/${L.key}/endpoints`) }">
-            <Icon name="ep" /><span>Endpoints</span>
+          <RouterLink
+            v-if="L.slots.endpoints"
+            :to="`/layer/${L.key}/endpoints`"
+            class="sw-nav-item"
+            :class="{ 'is-active': isActive(`/layer/${L.key}/endpoints`) }"
+          >
+            <Icon name="ep" /><span>{{ L.slots.endpoints }}</span>
           </RouterLink>
-          <RouterLink :to="`/layer/${L.key}/topology`" class="sw-nav-item" 
:class="{ 'is-active': isActive(`/layer/${L.key}/topology`) }">
+
+          <RouterLink
+            v-if="L.caps.topology"
+            :to="`/layer/${L.key}/topology`"
+            class="sw-nav-item"
+            :class="{ 'is-active': isActive(`/layer/${L.key}/topology`) }"
+          >
             <Icon name="topo" /><span>Topology</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`"
+            class="sw-nav-item"
+            :class="{ 'is-active': isActive(`/layer/${L.key}/traces`) }"
+          >
+            <Icon name="trace" /><span>Traces</span>
+          </RouterLink>
+          <RouterLink
+            v-if="L.caps.logs"
+            :to="`/layer/${L.key}/logs`"
+            class="sw-nav-item"
+            :class="{ 'is-active': isActive(`/layer/${L.key}/logs`) }"
+          >
+            <Icon name="log" /><span>Logs</span>
+          </RouterLink>
+          <RouterLink
+            v-if="L.caps.profiling"
+            :to="`/layer/${L.key}/profiling`"
+            class="sw-nav-item"
+            :class="{ 'is-active': isActive(`/layer/${L.key}/profiling`) }"
+          >
+            <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>
 
-      <div class="sw-nav-section">Telemetry</div>
-      <RouterLink v-for="row in telemetry" :key="row.to" :to="row.to" 
class="sw-nav-item" :class="{ 'is-active': isActive(row.to) }">
-        <Icon :name="row.icon" /><span>{{ row.label }}</span>
-        <span v-if="row.badge" class="sw-badge" :class="row.badge.kind" 
style="margin-left: auto">{{ row.badge.text }}</span>
-      </RouterLink>
-
       <div class="sw-nav-section">Operate</div>
-      <RouterLink v-for="row in operate" :key="row.to" :to="row.to" 
class="sw-nav-item" :class="{ 'is-active': isActive(row.to) }">
+      <RouterLink
+        v-for="row in operate"
+        :key="row.to"
+        :to="row.to"
+        class="sw-nav-item"
+        :class="{ 'is-active': isActive(row.to) }"
+      >
         <Icon :name="row.icon" /><span>{{ row.label }}</span>
-        <span v-if="row.badge" class="sw-badge" :class="row.badge.kind" 
style="margin-left: auto">{{ row.badge.text }}</span>
+        <span v-if="row.badge" class="sw-badge" :class="row.badge.kind" 
style="margin-left: auto">
+          {{ row.badge.text }}
+        </span>
       </RouterLink>
 
       <div class="sw-nav-section">Admin</div>
-      <RouterLink v-for="row in admin" :key="row.to" :to="row.to" 
class="sw-nav-item" :class="{ 'is-active': isActive(row.to) }">
+      <RouterLink
+        v-for="row in admin"
+        :key="row.to"
+        :to="row.to"
+        class="sw-nav-item"
+        :class="{ 'is-active': isActive(row.to) }"
+      >
         <Icon :name="row.icon" /><span>{{ row.label }}</span>
       </RouterLink>
     </nav>
@@ -136,7 +199,15 @@ const admin: NavRow[] = [
         {{ auth.user?.username ? auth.user.username.slice(0, 2).toUpperCase() 
: '?' }}
       </div>
       <div style="line-height: 1.2; flex: 1; min-width: 0; overflow: hidden">
-        <div style="color: var(--sw-fg-0); font-weight: 600; overflow: hidden; 
text-overflow: ellipsis; white-space: nowrap">
+        <div
+          style="
+            color: var(--sw-fg-0);
+            font-weight: 600;
+            overflow: hidden;
+            text-overflow: ellipsis;
+            white-space: nowrap;
+          "
+        >
           {{ auth.user?.username ?? 'guest' }}
         </div>
         <div>{{ auth.user?.roles?.join(' · ') ?? 'not signed in' }}</div>
diff --git a/apps/ui/src/components/shell/layers.ts 
b/apps/ui/src/components/shell/layers.ts
new file mode 100644
index 0000000..6556a34
--- /dev/null
+++ b/apps/ui/src/components/shell/layers.ts
@@ -0,0 +1,129 @@
+/*
+ * 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.
+ */
+
+// Phase 2 will replace this static stub with real getMenuItems / listLayers
+// data + per-layer overrides from the BFF dashboard-template bundle. The
+// shape is what the sidebar and router will consume regardless.
+
+export interface LayerSlots {
+  /** Renamed service-equivalent (functions / workloads / clusters / apps / 
databases / …). */
+  services?: string;
+  /** Renamed instance-equivalent (versions / pods / brokers / sessions / 
nodes / …). */
+  instances?: string;
+  /** Renamed endpoint-equivalent (invocations / topics / pages / queries / 
…). */
+  endpoints?: string;
+}
+
+export interface LayerCaps {
+  /** Per-layer landing page with KPIs / constellation / health. */
+  overview?: boolean;
+  /** Topology graph. */
+  topology?: boolean;
+  /** Per-scope dashboards (Service / Instance / Endpoint / Glance). */
+  dashboards?: boolean;
+  /** Trace explorer (SkyWalking native or Zipkin sources). */
+  traces?: boolean;
+  /** Log explorer. */
+  logs?: boolean;
+  /** Any of the profiling subsystems (sampled / async-profiler / eBPF / 
pprof). */
+  profiling?: boolean;
+  /** Event timeline. */
+  events?: boolean;
+}
+
+export interface LayerDef {
+  key: string;
+  name: string;
+  /** CSS color (token var or hex). */
+  color: string;
+  /** Stub count — Phase 2 pulls the real number from listServices(layer). */
+  serviceCount: number;
+  slots: LayerSlots;
+  caps: LayerCaps;
+}
+
+export const LAYERS: readonly LayerDef[] = [
+  {
+    key: 'general',
+    name: 'General Service',
+    color: 'var(--sw-accent)',
+    serviceCount: 84,
+    slots: { services: 'Services', instances: 'Instances', endpoints: 
'Endpoints' },
+    caps: { overview: true, topology: true, dashboards: true, traces: true, 
logs: true, profiling: true, events: true },
+  },
+  {
+    key: 'mesh',
+    name: 'Service Mesh',
+    color: 'var(--sw-info)',
+    serviceCount: 22,
+    slots: { services: 'Services', instances: 'Sidecars', endpoints: 
'Endpoints' },
+    caps: { overview: true, topology: true, dashboards: true, traces: true, 
logs: true, events: true },
+  },
+  {
+    key: 'k8s',
+    name: 'Kubernetes',
+    color: 'var(--sw-purple)',
+    serviceCount: 62,
+    slots: { services: 'Workloads', instances: 'Pods' },
+    caps: { overview: true, topology: true, dashboards: true, events: true },
+  },
+  {
+    key: 'rum',
+    name: 'Browser (RUM)',
+    color: 'var(--sw-cyan)',
+    serviceCount: 8,
+    slots: { services: 'Applications', instances: 'Sessions', endpoints: 
'Pages' },
+    caps: { overview: true, dashboards: true, traces: true, logs: true },
+  },
+  {
+    key: 'mq',
+    name: 'Virtual MQ',
+    color: 'var(--sw-ok)',
+    serviceCount: 6,
+    slots: { services: 'Clusters', instances: 'Brokers', endpoints: 'Topics' },
+    caps: { overview: true, dashboards: true },
+  },
+  {
+    key: 'db',
+    name: 'Virtual Database',
+    color: 'var(--sw-warn)',
+    serviceCount: 6,
+    slots: { services: 'Databases', instances: 'Nodes' },
+    caps: { overview: true, dashboards: true },
+  },
+  {
+    key: 'otel',
+    name: 'OpenTelemetry',
+    color: 'var(--sw-purple)',
+    serviceCount: 18,
+    slots: { services: 'Services', instances: 'Instances', endpoints: 
'Endpoints' },
+    caps: { overview: true, topology: true, dashboards: true, traces: true, 
logs: true },
+  },
+  {
+    key: 'faas',
+    name: 'FaaS',
+    color: 'var(--sw-err)',
+    serviceCount: 3,
+    slots: { services: 'Functions', instances: 'Versions', endpoints: 
'Invocations' },
+    caps: { overview: true, dashboards: true, traces: true },
+  },
+];
+
+export function findLayer(key: string | undefined): LayerDef | undefined {
+  if (!key) return undefined;
+  return LAYERS.find((L) => L.key === key);
+}
diff --git a/apps/ui/src/router/index.ts b/apps/ui/src/router/index.ts
index a597c20..41c949a 100644
--- a/apps/ui/src/router/index.ts
+++ b/apps/ui/src/router/index.ts
@@ -15,75 +15,85 @@
  * limitations under the License.
  */
 import { createRouter, createWebHistory, type RouteRecordRaw } from 
'vue-router';
+import { findLayer } from '@/components/shell/layers';
 import { useAuthStore } from '@/stores/auth';
 
-const shellRoutes: RouteRecordRaw[] = [
-  {
-    path: '',
-    name: 'home',
-    component: () => import('@/views/landing/LandingView.vue'),
-  },
-  // Layer drill-down stubs
-  {
+const placeholder = () => import('@/views/PlaceholderView.vue');
+
+// Build a per-layer route bundle from the layer feature config. Each cap that
+// the layer declares becomes a sub-route under /layer/:layerKey/...
+// Unknown layer keys fall back to a generic "not found" via the catch-all.
+function layerSubRoutes(): RouteRecordRaw[] {
+  const sub: RouteRecordRaw[] = [];
+
+  sub.push({
     path: 'layer/:layerKey',
-    name: 'layer-overview',
-    component: () => import('@/views/PlaceholderView.vue'),
-    props: (route) => ({
-      title: `Layer · ${route.params.layerKey}`,
-      phase: 'Phase 2',
-      note: 'Layer overview · KPIs, throughput, services table, 
constellation.',
-    }),
-  },
-  {
-    path: 'layer/:layerKey/services',
-    component: () => import('@/views/PlaceholderView.vue'),
-    props: (route) => ({
-      title: `${route.params.layerKey} · Services`,
-      phase: 'Phase 2',
-    }),
-  },
-  {
-    path: 'layer/:layerKey/instances',
-    component: () => import('@/views/PlaceholderView.vue'),
-    props: (route) => ({
-      title: `${route.params.layerKey} · Instances`,
-      phase: 'Phase 3',
-    }),
-  },
-  {
-    path: 'layer/:layerKey/endpoints',
-    component: () => import('@/views/PlaceholderView.vue'),
-    props: (route) => ({
-      title: `${route.params.layerKey} · Endpoints`,
-      phase: 'Phase 3',
-    }),
-  },
-  {
-    path: 'layer/:layerKey/topology',
-    component: () => import('@/views/PlaceholderView.vue'),
-    props: (route) => ({
-      title: `${route.params.layerKey} · Topology`,
-      phase: 'Phase 4',
-      note: 'Three variants: force-directed, hierarchical DAG, hex/honeycomb.',
-    }),
-  },
+    component: placeholder,
+    props: (r) => {
+      const L = findLayer(String(r.params.layerKey));
+      return {
+        title: L ? `${L.name} · Overview` : `Layer · ${r.params.layerKey}`,
+        phase: 'Phase 2',
+        note: L
+          ? 'Per-layer landing: KPIs, throughput, service constellation, 
services table.'
+          : 'Unknown layer key.',
+      };
+    },
+  });
 
-  // Telemetry
-  { path: 'dashboards', component: () => 
import('@/views/PlaceholderView.vue'), props: { title: 'Dashboards', phase: 
'Phase 3', note: 'Widget grid, per-scope templates, MQE editor.' } },
-  { path: 'operate/traces', component: () => 
import('@/views/PlaceholderView.vue'), props: { title: 'Trace explorer', phase: 
'Phase 5', note: 'Native (v2/v1) + Zipkin Lens, switchable.' } },
-  { path: 'operate/traces/:traceId', component: () => 
import('@/views/PlaceholderView.vue'), props: (r) => ({ title: `Trace · 
${r.params.traceId}`, phase: 'Phase 5' }) },
-  { path: 'operate/logs', component: () => 
import('@/views/PlaceholderView.vue'), props: { title: 'Log explorer', phase: 
'Phase 5' } },
-  { path: 'profiling', component: () => import('@/views/PlaceholderView.vue'), 
props: { title: 'Profiling', phase: 'Phase 8', note: 'Sampled · async-profiler 
· eBPF · Go pprof — unified flame graph.' } },
-  { path: 'operate/events', component: () => 
import('@/views/PlaceholderView.vue'), props: { title: 'Events', phase: 'Phase 
5' } },
+  for (const slot of ['services', 'instances', 'endpoints'] as const) {
+    sub.push({
+      path: `layer/:layerKey/${slot}`,
+      component: placeholder,
+      props: (r) => {
+        const L = findLayer(String(r.params.layerKey));
+        const label = L?.slots[slot] ?? slot;
+        return {
+          title: L ? `${L.name} · ${label}` : `Layer · ${slot}`,
+          phase: 'Phase 2 / 3',
+        };
+      },
+    });
+  }
 
-  // Operate
-  { path: 'operate/alarms', component: () => 
import('@/views/PlaceholderView.vue'), props: { title: 'Alarms', phase: 'Phase 
5', note: 'Read-only; recovery is backend-auto. Live debug card via admin 
REST.' } },
+  const caps: { key: keyof NonNullable<ReturnType<typeof findLayer>>['caps']; 
label: string; phase: string }[] = [
+    { key: 'topology', label: 'Topology', phase: 'Phase 4' },
+    { key: 'dashboards', label: 'Dashboards', phase: 'Phase 3' },
+    { key: 'traces', label: 'Traces', phase: 'Phase 5' },
+    { key: 'logs', label: 'Logs', phase: 'Phase 5' },
+    { key: 'profiling', label: 'Profiling', phase: 'Phase 8' },
+    { key: 'events', label: 'Events', phase: 'Phase 5' },
+  ];
+  for (const c of caps) {
+    sub.push({
+      path: `layer/:layerKey/${c.key}`,
+      component: placeholder,
+      props: (r) => {
+        const L = findLayer(String(r.params.layerKey));
+        return {
+          title: L ? `${L.name} · ${c.label}` : `Layer · ${c.label}`,
+          phase: c.phase,
+          note: L && !L.caps[c.key] ? `${L.name} doesn't expose 
${c.label.toLowerCase()}.` : undefined,
+        };
+      },
+    });
+  }
+
+  return sub;
+}
 
+const shellRoutes: RouteRecordRaw[] = [
+  { path: '', name: 'home', component: () => 
import('@/views/landing/LandingView.vue') },
+  ...layerSubRoutes(),
+  // Cross-layer operate
+  { path: 'operate/alarms', component: placeholder, props: { title: 'Alarms', 
phase: 'Phase 5', note: 'Read-only; recovery is backend-auto.' } },
+  { path: 'operate/traces', component: placeholder, props: { title: 'Trace 
search', phase: 'Phase 5', note: 'Cross-layer trace search. Per-layer trace 
explorers live under /layer/:key/traces.' } },
+  { path: 'operate/traces/:traceId', component: placeholder, props: (r) => ({ 
title: `Trace · ${r.params.traceId}`, phase: 'Phase 5' }) },
   // Admin
-  { path: 'cluster', component: () => import('@/views/PlaceholderView.vue'), 
props: { title: 'Cluster status', phase: 'Phase 6 / 7', note: 'Module activity 
matrix · storage health · receiver activity · effective config tree · TTL 
grid.' } },
-  { path: 'admin/users', component: () => 
import('@/views/PlaceholderView.vue'), props: { title: 'Users', phase: 'Phase 
7' } },
-  { path: 'admin/roles', component: () => 
import('@/views/PlaceholderView.vue'), props: { title: 'Roles & permissions', 
phase: 'Phase 7' } },
-  { path: 'admin/audit', component: () => 
import('@/views/PlaceholderView.vue'), props: { title: 'Audit log', phase: 
'Phase 7' } },
+  { path: 'cluster', component: placeholder, props: { title: 'Cluster status', 
phase: 'Phase 6 / 7', note: 'Module activity matrix · storage health · receiver 
activity · effective config tree · TTL grid.' } },
+  { path: 'admin/users', component: placeholder, props: { title: 'Users', 
phase: 'Phase 7' } },
+  { path: 'admin/roles', component: placeholder, props: { title: 'Roles & 
permissions', phase: 'Phase 7' } },
+  { path: 'admin/audit', component: placeholder, props: { title: 'Audit log', 
phase: 'Phase 7' } },
 ];
 
 const router = createRouter({
@@ -102,14 +112,13 @@ const router = createRouter({
     },
     {
       path: '/:catchAll(.*)*',
-      component: () => import('@/views/PlaceholderView.vue'),
+      component: placeholder,
       props: { title: 'Not found', phase: 'never', note: 'No route matches.' },
     },
   ],
 });
 
 let bootstrapped = false;
-
 router.beforeEach(async (to) => {
   const auth = useAuthStore();
   if (!bootstrapped) {

Reply via email to