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 ddb74e2  admin: layer dashboards setup page — read-only template 
browser
ddb74e2 is described below

commit ddb74e22b397d0f923174ccb6bc60ffef1a70ff0
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 12 20:36:16 2026 +0800

    admin: layer dashboards setup page — read-only template browser
    
    New page at /admin/layer-dashboards (sidebar entry 'Layer dashboards'
    under Admin) lets operators see every loaded layer template at a
    glance:
      - Layer picker on the left (one row per JSON template)
      - Right pane shows identity (alias / color / docs link), enabled
        components (chip row), slot aliases, landing card metric columns
        with MQE expressions, and the full dashboard widget set with grid
        coordinates and expressions
    
    New BFF endpoint GET /api/admin/layer-templates returns
    allLayerTemplates() — used to drive this view. Editing + persist
    operator overrides comes in the next commit; this one ships the
    read-only browser so the structure is visible.
    
    Sidebar Admin section gains the new link between 'Overview setup' and
    'Users'.
---
 apps/bff/src/dashboard/routes.ts                 |   8 +
 apps/ui/src/api/client.ts                        |  40 ++
 apps/ui/src/components/shell/AppSidebar.vue      |   1 +
 apps/ui/src/router/index.ts                      |   4 +
 apps/ui/src/views/admin/LayerDashboardsAdmin.vue | 456 +++++++++++++++++++++++
 5 files changed, 509 insertions(+)

diff --git a/apps/bff/src/dashboard/routes.ts b/apps/bff/src/dashboard/routes.ts
index 8a00fc0..c9c120c 100644
--- a/apps/bff/src/dashboard/routes.ts
+++ b/apps/bff/src/dashboard/routes.ts
@@ -43,6 +43,7 @@ import type { ConfigSource } from '../config/loader.js';
 import type { SessionStore } from '../auth/sessions.js';
 import { requireAuth } from '../auth/middleware.js';
 import { graphqlPost } from '../oap/graphql-client.js';
+import { allLayerTemplates } from '../layers/loader.js';
 import { defaultWidgetsFor } from './defaults.js';
 
 export interface DashboardRouteDeps {
@@ -267,4 +268,11 @@ export function registerDashboardRoute(app: 
FastifyInstance, deps: DashboardRout
       return reply.send({ layer: layerKey, widgets: 
defaultWidgetsFor(layerKey) });
     },
   );
+
+  // Admin: enumerate every loaded JSON layer template. Used by the
+  // /admin/layer-dashboards page to render a layer picker + current
+  // widget set per layer.
+  app.get('/api/admin/layer-templates', { preHandler: auth }, async (_req, 
reply) => {
+    return reply.send({ templates: allLayerTemplates() });
+  });
 }
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index d0bbd92..652b38a 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -53,6 +53,38 @@ export interface MeResponse {
   verbs: string[];
 }
 
+/** Wire shape returned by GET /api/admin/layer-templates. */
+export interface AdminLayerTemplate {
+  key: string;
+  alias?: string;
+  color?: string;
+  documentLink?: string;
+  slots: { services?: string; instances?: string; endpoints?: string; 
endpointDependency?: string };
+  components: {
+    service?: boolean;
+    instances?: boolean;
+    endpoints?: boolean;
+    endpointDependency?: boolean;
+    topology?: boolean;
+    traces?: boolean;
+    logs?: boolean;
+    profiling?: boolean;
+  };
+  metrics: {
+    orderBy?: string;
+    throughput?: string;
+    spark?: string;
+    columns?: Array<{
+      metric: string;
+      label: string;
+      unit?: string;
+      mqe?: string;
+      aggregation?: 'sum' | 'avg';
+    }>;
+  };
+  widgets: DashboardWidget[];
+}
+
 export class BffApiError extends Error {
   readonly status: number;
   readonly body: unknown;
@@ -172,6 +204,14 @@ export class BffClient {
     );
   }
 
+  /** Admin: list every loaded layer template (alias / components / widgets). 
*/
+  adminLayerTemplates(): Promise<{ templates: AdminLayerTemplate[] }> {
+    return this.request<{ templates: AdminLayerTemplate[] }>(
+      'GET',
+      '/api/admin/layer-templates',
+    );
+  }
+
   // ── cluster / preflight ──────────────────────────────────────────────
   preflight(): Promise<unknown> {
     return this.request('GET', '/api/preflight');
diff --git a/apps/ui/src/components/shell/AppSidebar.vue 
b/apps/ui/src/components/shell/AppSidebar.vue
index e62657b..f5dff5b 100644
--- a/apps/ui/src/components/shell/AppSidebar.vue
+++ b/apps/ui/src/components/shell/AppSidebar.vue
@@ -135,6 +135,7 @@ const sections: NavSection[] = [
     kicker: 'Admin',
     links: [
       { icon: 'set', label: 'Overview setup', to: '/setup' },
+      { icon: 'metric', label: 'Layer dashboards', to: 
'/admin/layer-dashboards' },
       { icon: 'user', label: 'Users', to: '/admin/users' },
       { icon: 'set', label: 'Roles', to: '/admin/roles' },
     ],
diff --git a/apps/ui/src/router/index.ts b/apps/ui/src/router/index.ts
index b978a6c..58fa910 100644
--- a/apps/ui/src/router/index.ts
+++ b/apps/ui/src/router/index.ts
@@ -105,6 +105,10 @@ const shellRoutes: RouteRecordRaw[] = [
   // Dump
   { path: 'operate/dump', component: placeholder, props: { title: 'Dump & 
restore', phase: 'Phase 6', note: 'Stream OAP runtime-rule dump as tar.gz. 
Restore is deferred (no OAP endpoint yet).' } },
   // Admin
+  {
+    path: 'admin/layer-dashboards',
+    component: () => import('@/views/admin/LayerDashboardsAdmin.vue'),
+  },
   { path: 'admin/users', component: placeholder, props: { title: 'Users', 
phase: 'Phase 7' } },
   { path: 'admin/roles', component: placeholder, props: { title: 'Roles & 
permissions', phase: 'Phase 7' } },
 ];
diff --git a/apps/ui/src/views/admin/LayerDashboardsAdmin.vue 
b/apps/ui/src/views/admin/LayerDashboardsAdmin.vue
new file mode 100644
index 0000000..f9265b8
--- /dev/null
+++ b/apps/ui/src/views/admin/LayerDashboardsAdmin.vue
@@ -0,0 +1,456 @@
+<!--
+  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.
+-->
+<!--
+  Admin / Layer dashboards setup. Lists every layer template the BFF
+  loaded from JSON and shows its current configuration: alias, enabled
+  components, landing card metrics, dashboard widget set. Editing comes
+  in the next iteration — this commit gets the view live so operators
+  can see what's configured per layer.
+-->
+<script setup lang="ts">
+import { computed, ref, onMounted } from 'vue';
+import type { AdminLayerTemplate } from '@/api/client';
+import { bffClient } from '@/api/client';
+
+const templates = ref<AdminLayerTemplate[]>([]);
+const isLoading = ref(true);
+const error = ref<string | null>(null);
+const selectedKey = ref<string>('');
+
+onMounted(async () => {
+  try {
+    const res = await bffClient.adminLayerTemplates();
+    templates.value = res.templates;
+    if (res.templates.length > 0) selectedKey.value = res.templates[0].key;
+  } catch (err) {
+    error.value = err instanceof Error ? err.message : String(err);
+  } finally {
+    isLoading.value = false;
+  }
+});
+
+const selected = computed<AdminLayerTemplate | null>(
+  () => templates.value.find((t) => t.key === selectedKey.value) ?? null,
+);
+
+function componentFlags(t: AdminLayerTemplate): string[] {
+  const c = t.components;
+  const out: string[] = [];
+  if (c.service) out.push('service');
+  if (c.instances) out.push('instances');
+  if (c.endpoints) out.push('endpoints');
+  if (c.endpointDependency) out.push('api dependency');
+  if (c.topology) out.push('topology');
+  if (c.traces) out.push('traces');
+  if (c.logs) out.push('logs');
+  if (c.profiling) out.push('profiling');
+  return out;
+}
+</script>
+
+<template>
+  <div class="admin-page">
+    <header class="page-head">
+      <div>
+        <div class="kicker">Admin</div>
+        <h1>Layer dashboards</h1>
+        <p class="lede">
+          Each layer ships with a JSON template defining its alias, enabled 
components,
+          landing card metrics, and dashboard widgets. This view shows the 
current
+          template per layer. Inline editing + operator overrides are next.
+        </p>
+      </div>
+    </header>
+
+    <div v-if="error" class="banner err">{{ error }}</div>
+    <div v-if="isLoading" class="empty">Loading templates…</div>
+    <div v-else-if="templates.length === 0" class="empty">No layer templates 
loaded.</div>
+
+    <div v-else class="grid">
+      <!-- Layer picker (left) -->
+      <aside class="sw-card layer-list">
+        <div class="list-head">
+          <h4>Layers</h4>
+          <span class="sub">{{ templates.length }} template{{ templates.length 
=== 1 ? '' : 's' }}</span>
+        </div>
+        <button
+          v-for="t in templates"
+          :key="t.key"
+          class="layer-row"
+          :class="{ active: selectedKey === t.key }"
+          @click="selectedKey = t.key"
+        >
+          <span class="dot" :style="{ background: t.color || 'var(--sw-fg-3)' 
}" />
+          <span class="name">{{ t.alias || t.key }}</span>
+          <span class="badge">{{ t.widgets.length }}</span>
+        </button>
+      </aside>
+
+      <!-- Template detail (right) -->
+      <main v-if="selected" class="detail">
+        <section class="sw-card">
+          <div class="card-head">
+            <h4>Identity</h4>
+          </div>
+          <table class="kv">
+            <tbody>
+              <tr><th>Key</th><td class="mono">{{ selected.key }}</td></tr>
+              <tr><th>Alias</th><td>{{ selected.alias || '—' }}</td></tr>
+              <tr><th>Color</th><td>
+                <span class="dot inline" :style="{ background: selected.color 
|| 'var(--sw-fg-3)' }" />
+                <code>{{ selected.color || '—' }}</code>
+              </td></tr>
+              <tr v-if="selected.documentLink"><th>Docs</th>
+                <td><a :href="selected.documentLink" target="_blank" 
rel="noopener noreferrer">{{ selected.documentLink }} ↗</a></td>
+              </tr>
+            </tbody>
+          </table>
+        </section>
+
+        <section class="sw-card">
+          <div class="card-head"><h4>Components enabled</h4></div>
+          <div class="chips">
+            <span v-for="c in componentFlags(selected)" :key="c" class="chip 
on">{{ c }}</span>
+            <span v-if="componentFlags(selected).length === 0" class="chip 
off">none</span>
+          </div>
+        </section>
+
+        <section class="sw-card">
+          <div class="card-head">
+            <h4>Slots</h4>
+            <span class="sub">term aliases for service / instance / endpoint 
scopes</span>
+          </div>
+          <table class="kv">
+            <tbody>
+              <tr><th>Services</th><td>{{ selected.slots.services || '—' 
}}</td></tr>
+              <tr><th>Instances</th><td>{{ selected.slots.instances || '—' 
}}</td></tr>
+              <tr><th>Endpoints</th><td>{{ selected.slots.endpoints || '—' 
}}</td></tr>
+              <tr v-if="selected.slots.endpointDependency">
+                <th>Endpoint dependency</th><td>{{ 
selected.slots.endpointDependency }}</td>
+              </tr>
+            </tbody>
+          </table>
+        </section>
+
+        <section class="sw-card">
+          <div class="card-head">
+            <h4>Landing card metrics</h4>
+            <span class="sub">columns shown on the Overview KPI tile + 
per-layer header</span>
+          </div>
+          <table v-if="selected.metrics.columns?.length" class="sw-table">
+            <thead>
+              <tr>
+                
<th>metric</th><th>label</th><th>unit</th><th>aggregation</th><th>mqe</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr v-for="c in selected.metrics.columns" :key="c.metric">
+                <td class="mono">{{ c.metric }}</td>
+                <td>{{ c.label }}</td>
+                <td>{{ c.unit || '—' }}</td>
+                <td><span class="tag">{{ c.aggregation || 'avg' }}</span></td>
+                <td class="mono">{{ c.mqe || '(catalog default)' }}</td>
+              </tr>
+            </tbody>
+          </table>
+          <p v-else class="empty">No columns defined.</p>
+          <div class="extras">
+            <span><strong>orderBy:</strong> <code>{{ selected.metrics.orderBy 
|| '—' }}</code></span>
+            <span><strong>throughput:</strong> <code>{{ 
selected.metrics.throughput || '—' }}</code></span>
+            <span><strong>spark:</strong> <code>{{ selected.metrics.spark || 
'—' }}</code></span>
+          </div>
+        </section>
+
+        <section class="sw-card">
+          <div class="card-head">
+            <h4>Dashboard widgets</h4>
+            <span class="sub">{{ selected.widgets.length }} widget{{ 
selected.widgets.length === 1 ? '' : 's' }} · grid is 24-col</span>
+          </div>
+          <table v-if="selected.widgets.length > 0" class="sw-table">
+            <thead>
+              <tr>
+                
<th>id</th><th>title</th><th>type</th><th>unit</th><th>x,y</th><th>w×h</th><th>expressions</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr v-for="w in selected.widgets" :key="w.id">
+                <td class="mono">{{ w.id }}</td>
+                <td>{{ w.title }}</td>
+                <td><span class="tag">{{ w.type }}</span></td>
+                <td>{{ w.unit || '—' }}</td>
+                <td class="mono">{{ w.x }},{{ w.y }}</td>
+                <td class="mono">{{ w.w }}×{{ w.h }}</td>
+                <td class="mono mqe">
+                  <div v-for="(e, i) in w.expressions" :key="i">{{ e }}</div>
+                </td>
+              </tr>
+            </tbody>
+          </table>
+          <p v-else class="empty">No widgets defined.</p>
+        </section>
+      </main>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.admin-page {
+  padding: 20px 20px 60px;
+  max-width: 1440px;
+  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;
+}
+.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: 12px;
+  margin-bottom: 14px;
+}
+.empty {
+  padding: 32px;
+  text-align: center;
+  color: var(--sw-fg-3);
+  font-size: 12px;
+}
+.grid {
+  display: grid;
+  grid-template-columns: 220px 1fr;
+  gap: 14px;
+}
+.layer-list {
+  padding: 8px;
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+  align-self: start;
+}
+.list-head {
+  padding: 6px 10px 10px;
+  border-bottom: 1px solid var(--sw-line);
+  margin-bottom: 6px;
+}
+.list-head h4 {
+  margin: 0;
+  font-size: 11.5px;
+  font-weight: 600;
+  color: var(--sw-fg-0);
+}
+.list-head .sub {
+  font-size: 10px;
+  color: var(--sw-fg-3);
+}
+.layer-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 7px 10px;
+  border-radius: 5px;
+  background: transparent;
+  border: none;
+  color: var(--sw-fg-1);
+  font-size: 12px;
+  cursor: pointer;
+  text-align: left;
+  font: inherit;
+}
+.layer-row:hover {
+  background: var(--sw-bg-2);
+}
+.layer-row.active {
+  background: var(--sw-bg-3);
+  color: var(--sw-fg-0);
+  box-shadow: inset 2px 0 0 var(--sw-accent);
+}
+.layer-row .dot {
+  width: 7px;
+  height: 7px;
+  border-radius: 50%;
+  flex: 0 0 7px;
+}
+.layer-row .name {
+  flex: 1;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.layer-row .badge {
+  font-family: var(--sw-mono);
+  font-size: 10px;
+  color: var(--sw-fg-3);
+}
+.detail {
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+}
+.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);
+}
+.kv {
+  width: 100%;
+  font-size: 12px;
+}
+.kv th, .kv td {
+  padding: 6px 14px;
+  text-align: left;
+  border-bottom: 1px solid var(--sw-line);
+  vertical-align: top;
+}
+.kv th {
+  width: 140px;
+  color: var(--sw-fg-3);
+  font-weight: 500;
+}
+.kv tr:last-child th, .kv tr:last-child td {
+  border-bottom: none;
+}
+.mono {
+  font-family: var(--sw-mono);
+  font-size: 11.5px;
+  color: var(--sw-fg-1);
+}
+.mono code {
+  background: transparent;
+  padding: 0;
+}
+.dot.inline {
+  display: inline-block;
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  margin-right: 6px;
+  vertical-align: middle;
+}
+.chips {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+  padding: 12px 14px;
+}
+.chip {
+  font-size: 10.5px;
+  padding: 3px 8px;
+  border-radius: 4px;
+  background: var(--sw-bg-2);
+  color: var(--sw-fg-1);
+  border: 1px solid var(--sw-line-2);
+}
+.chip.on {
+  background: var(--sw-accent-soft);
+  color: var(--sw-accent-2);
+  border-color: var(--sw-accent-line);
+}
+.chip.off {
+  color: var(--sw-fg-3);
+}
+.sw-table {
+  width: 100%;
+  font-size: 11.5px;
+}
+.sw-table th {
+  text-align: left;
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  color: var(--sw-fg-3);
+  font-weight: 500;
+  padding: 6px 14px;
+  border-bottom: 1px solid var(--sw-line);
+}
+.sw-table td {
+  padding: 6px 14px;
+  border-bottom: 1px solid var(--sw-line);
+  color: var(--sw-fg-1);
+  vertical-align: top;
+}
+.sw-table td.mqe div + div {
+  margin-top: 2px;
+}
+.tag {
+  font-size: 10px;
+  padding: 1px 6px;
+  border-radius: 3px;
+  background: var(--sw-bg-2);
+  color: var(--sw-fg-2);
+  font-family: var(--sw-mono);
+}
+.extras {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 18px;
+  padding: 10px 14px;
+  border-top: 1px dashed var(--sw-line);
+  font-size: 11px;
+  color: var(--sw-fg-2);
+}
+.extras strong {
+  color: var(--sw-fg-3);
+  font-weight: 500;
+  text-transform: uppercase;
+  font-size: 9.5px;
+  letter-spacing: 0.08em;
+  margin-right: 4px;
+}
+.extras code {
+  font-family: var(--sw-mono);
+  font-size: 10.5px;
+  background: var(--sw-bg-2);
+  padding: 1px 4px;
+  border-radius: 3px;
+  color: var(--sw-fg-1);
+}
+</style>

Reply via email to