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 6be5e33  config: aliases supersedes slots in JSON; admin gains metrics 
editor
6be5e33 is described below

commit 6be5e332ee131ecdace24bcb96d0977a6678f26a
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 12 22:18:25 2026 +0800

    config: aliases supersedes slots in JSON; admin gains metrics editor
    
    JSON schema rename: per-layer entity-term aliases live under
    'aliases' in the JSON now (plural, distinct from the existing
    top-level 'alias' which is the layer display name). Operators write
    'aliases' going forward; the loader still accepts legacy 'slots'
    files and normalizes to the internal 'slots' field, so existing
    templates keep working unchanged. general.json migrated.
    
    Admin / Layer dashboards page gains a Metrics editor section above
    the scope tabs:
      - Per-column inline editing: metric / label / unit / aggregation /
        MQE override / scale / precision. Add column + delete row.
      - Top-of-block dropdowns for orderBy / throughput / spark — each
        selects from the defined columns.
      - Mutations flow through the existing draft → save path, so saving
        rewrites the JSON template with the new metric columns and the
        BFF reloads its cache.
    
    AdminLayerTemplate type gains scale + precision on column entries to
    match the BFF zod schema. No wire-protocol break — the BFF already
    accepted these fields in admin saves; the UI just couldn't bind to
    them before.
---
 apps/bff/src/layers/config/general.json          |   2 +-
 apps/bff/src/layers/loader.ts                    |  14 +-
 apps/ui/src/api/client.ts                        |   2 +
 apps/ui/src/views/admin/LayerDashboardsAdmin.vue | 188 +++++++++++++++++++++++
 4 files changed, 203 insertions(+), 3 deletions(-)

diff --git a/apps/bff/src/layers/config/general.json 
b/apps/bff/src/layers/config/general.json
index ff00563..07911c3 100644
--- a/apps/bff/src/layers/config/general.json
+++ b/apps/bff/src/layers/config/general.json
@@ -3,7 +3,7 @@
   "alias": "General Service",
   "color": "var(--sw-accent)",
   "documentLink": 
"https://skywalking.apache.org/docs/main/latest/en/setup/service-agent/";,
-  "slots": {
+  "aliases": {
     "services": "Services",
     "instances": "Instances",
     "endpoints": "API",
diff --git a/apps/bff/src/layers/loader.ts b/apps/bff/src/layers/loader.ts
index e0a1b4f..bf69aa0 100644
--- a/apps/bff/src/layers/loader.ts
+++ b/apps/bff/src/layers/loader.ts
@@ -118,9 +118,9 @@ function load(): Map<string, LayerTemplate> {
   for (const file of readdirSync(CONFIG_DIR)) {
     if (!file.endsWith('.json')) continue;
     const raw = readFileSync(join(CONFIG_DIR, file), 'utf-8');
-    let parsed: LayerTemplate;
+    let parsed: LayerTemplate & { alias_terms?: LayerSlotsConfig; alias?: 
LayerSlotsConfig | string };
     try {
-      parsed = JSON.parse(raw) as LayerTemplate;
+      parsed = JSON.parse(raw);
     } catch (err) {
       throw new Error(`failed to parse layer config ${file}: ${err instanceof 
Error ? err.message : err}`);
     }
@@ -130,6 +130,16 @@ function load(): Map<string, LayerTemplate> {
         `layer config ${file}: file basename does not match \`key\` 
(${parsed.key})`,
       );
     }
+    // Schema migration: per-layer entity term overrides used to live
+    // under `slots` in the JSON; the more readable `aliases` (plural,
+    // distinct from the existing top-level `alias` = layer display
+    // name) is now accepted as the primary key. TS code keeps `slots`
+    // internally — we normalize here so the rest of the BFF + UI
+    // doesn't need to know which the operator wrote.
+    const aliases = (parsed as { aliases?: LayerSlotsConfig }).aliases;
+    if (!parsed.slots && aliases) {
+      parsed.slots = aliases;
+    }
     // Migrate legacy `widgets` (flat array) → `dashboards.service` so
     // the rest of the codebase only needs to know about the new shape.
     if (parsed.widgets && (!parsed.dashboards || !parsed.dashboards.service)) {
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index a13827b..932acca 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -80,6 +80,8 @@ export interface AdminLayerTemplate {
       unit?: string;
       mqe?: string;
       aggregation?: 'sum' | 'avg';
+      scale?: number;
+      precision?: number;
     }>;
   };
   widgets: DashboardWidget[];
diff --git a/apps/ui/src/views/admin/LayerDashboardsAdmin.vue 
b/apps/ui/src/views/admin/LayerDashboardsAdmin.vue
index 16c40db..fb30e9d 100644
--- a/apps/ui/src/views/admin/LayerDashboardsAdmin.vue
+++ b/apps/ui/src/views/admin/LayerDashboardsAdmin.vue
@@ -168,6 +168,47 @@ function reset(): void {
 const selectedTpl = computed(() => draft.template);
 const currentWidgets = computed(() => widgetsFor(activeScope.value));
 
+/**
+ * Metrics-block editor. The metrics block lives directly on the
+ * draft template; mutations flow through Vue's reactive proxy so the
+ * dirty diff picks them up and Save enables. We ensure the
+ * `metrics.columns` array exists before binding so the template can
+ * safely v-model into it.
+ */
+function ensureMetrics(): NonNullable<AdminLayerTemplate['metrics']> {
+  if (!draft.template) throw new Error('no template selected');
+  if (!draft.template.metrics) {
+    (draft.template as AdminLayerTemplate).metrics = {};
+  }
+  return draft.template.metrics as NonNullable<AdminLayerTemplate['metrics']>;
+}
+const metricsModel = computed(() => {
+  if (!draft.template) return null;
+  // Touch ensureMetrics on read so the keys are present for v-model.
+  ensureMetrics();
+  return draft.template.metrics as NonNullable<AdminLayerTemplate['metrics']>;
+});
+const metricsColumns = computed(() => {
+  if (!draft.template) return [];
+  const m = ensureMetrics();
+  if (!m.columns) m.columns = [];
+  return m.columns;
+});
+function addMetricColumn(): void {
+  if (!draft.template) return;
+  const m = ensureMetrics();
+  if (!m.columns) m.columns = [];
+  m.columns.push({
+    metric: `metric_${m.columns.length + 1}`,
+    label: `Metric ${m.columns.length + 1}`,
+    aggregation: 'avg',
+  });
+}
+function deleteMetricColumn(i: number): void {
+  if (!draft.template?.metrics?.columns) return;
+  draft.template.metrics.columns.splice(i, 1);
+}
+
 function componentFlags(t: AdminLayerTemplate): string[] {
   const c = t.components;
   const out: string[] = [];
@@ -253,6 +294,76 @@ function componentFlags(t: AdminLayerTemplate): string[] {
           </div>
         </section>
 
+        <!-- Metrics editor (the layer's summary KPI columns + the
+             orderBy / throughput / spark selectors). These drive the
+             Overview KPI tile and the per-layer header summary. -->
+        <section class="sw-card metrics-card">
+          <div class="card-head">
+            <h4>Summary metrics</h4>
+            <span class="sub">columns shown on the Overview KPI tile + 
per-layer header</span>
+            <button class="sw-btn add" type="button" 
@click="addMetricColumn">+ Add column</button>
+          </div>
+          <div v-if="metricsModel" class="metrics-keys">
+            <label>
+              <span>orderBy</span>
+              <select v-model="metricsModel.orderBy">
+                <option :value="undefined">(first column)</option>
+                <option v-for="c in metricsColumns" :key="c.metric" 
:value="c.metric">{{ c.metric }}</option>
+              </select>
+            </label>
+            <label>
+              <span>throughput</span>
+              <select v-model="metricsModel.throughput">
+                <option :value="undefined">(orderBy)</option>
+                <option v-for="c in metricsColumns" :key="c.metric" 
:value="c.metric">{{ c.metric }}</option>
+              </select>
+            </label>
+            <label>
+              <span>spark</span>
+              <select v-model="metricsModel.spark">
+                <option :value="undefined">(throughput)</option>
+                <option v-for="c in metricsColumns" :key="c.metric" 
:value="c.metric">{{ c.metric }}</option>
+              </select>
+            </label>
+          </div>
+          <div v-if="metricsColumns.length === 0" class="empty inset">
+            No metric columns defined. Click "Add column" to start.
+          </div>
+          <table v-else class="sw-table metrics-table">
+            <thead>
+              <tr>
+                <th>metric</th>
+                <th>label</th>
+                <th>unit</th>
+                <th>aggregation</th>
+                <th class="grow">mqe</th>
+                <th>scale</th>
+                <th>precision</th>
+                <th></th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr v-for="(c, i) in metricsColumns" :key="i">
+                <td><input class="mono" v-model="c.metric" /></td>
+                <td><input v-model="c.label" /></td>
+                <td><input v-model="c.unit" placeholder="—" /></td>
+                <td>
+                  <select v-model="c.aggregation">
+                    <option value="sum">sum</option>
+                    <option value="avg">avg</option>
+                  </select>
+                </td>
+                <td><input class="mono" v-model="c.mqe" placeholder="catalog 
default" /></td>
+                <td><input type="number" step="any" v-model.number="c.scale" 
placeholder="1" /></td>
+                <td><input type="number" min="0" max="6" 
v-model.number="c.precision" placeholder="auto" /></td>
+                <td>
+                  <button class="sw-btn danger" type="button" 
@click="deleteMetricColumn(i)">✕</button>
+                </td>
+              </tr>
+            </tbody>
+          </table>
+        </section>
+
         <!-- Scope tabs -->
         <nav class="scope-tabs sw-card">
           <button
@@ -568,6 +679,83 @@ function componentFlags(t: AdminLayerTemplate): string[] {
 }
 .scope-tab.on .count { color: var(--sw-accent-2); }
 
+.metrics-card { padding: 0; }
+.metrics-card .card-head .add {
+  margin-left: auto;
+  font-size: 11.5px;
+  background: var(--sw-accent-soft);
+  color: var(--sw-accent-2);
+  border-color: var(--sw-accent-line);
+}
+.metrics-keys {
+  display: flex;
+  gap: 14px;
+  padding: 10px 16px;
+  border-bottom: 1px dashed var(--sw-line);
+}
+.metrics-keys label {
+  display: flex;
+  flex-direction: column;
+  gap: 3px;
+  font-size: 10.5px;
+  color: var(--sw-fg-3);
+  min-width: 120px;
+}
+.metrics-keys select {
+  height: 26px;
+  padding: 0 8px;
+  background: var(--sw-bg-2);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 4px;
+  color: var(--sw-fg-0);
+  font: inherit;
+  font-size: 11.5px;
+}
+.metrics-table {
+  width: 100%;
+}
+.metrics-table th {
+  text-align: left;
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  color: var(--sw-fg-3);
+  font-weight: 500;
+  padding: 8px 10px 6px;
+  border-bottom: 1px solid var(--sw-line);
+}
+.metrics-table th.grow {
+  width: 35%;
+}
+.metrics-table td {
+  padding: 6px 10px;
+  border-bottom: 1px solid var(--sw-line);
+}
+.metrics-table input,
+.metrics-table select {
+  width: 100%;
+  height: 26px;
+  padding: 0 6px;
+  background: var(--sw-bg-2);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 3px;
+  color: var(--sw-fg-0);
+  font: inherit;
+  font-size: 11.5px;
+}
+.metrics-table input.mono {
+  font-family: var(--sw-mono);
+  font-size: 11px;
+}
+.metrics-table .sw-btn.danger {
+  width: 26px;
+  height: 26px;
+  padding: 0;
+  font-size: 11px;
+  border-color: rgba(239, 68, 68, 0.3);
+  color: #f87171;
+}
+
 .widgets-card { padding: 0; }
 .card-head {
   display: flex;

Reply via email to