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 ce3d758  dashboards: per-scope widget sets, flow grid, full admin edit 
UI
ce3d758 is described below

commit ce3d7580fbff1bef35b190d7c1d7267bc4232af4
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 12 21:01:18 2026 +0800

    dashboards: per-scope widget sets, flow grid, full admin edit UI
    
    Widget schema gains span/rowSpan/visibleWhen — operators describe a
    widget's width in a 12-col grid and the view lays it out via
    grid-auto-flow: dense with a uniform 180px row height. Legacy x/y/w/h
    still parsed and auto-converted (w/2 → span, h/8 → rowSpan) so older
    templates render without migration. visibleWhen supports
    '<metric> has value' predicates so widgets like JVM CPU only render
    when the instance reports the metric; '#entity.<key>' predicate is
    reserved for the upcoming entity-attribute feed.
    
    Layer templates carry a per-scope dashboards bundle now:
      dashboards: {
        service:   [...],   // primary landing
        instance:  [...],   // drill into a single instance
        endpoint:  [...],   // drill into a single endpoint
        trace:     [...],   // trace explorer scope
        profiling: [...]    // flame graphs scope
      }
    Legacy flat 'widgets' migrated to dashboards.service at load time.
    General template fully populated with span-based widgets across
    service / instance / endpoint scopes (instance includes JVM cards
    guarded by visibleWhen).
    
    Router gets per-scope routes: /service /instance /endpoint /trace
    /profiling all hit the same LayerDashboardsView, which infers scope
    from the URL and asks the BFF for the right widget set. Sidebar
    children point at the new singular routes; legacy /instances /
    /endpoints / /traces / /dashboards redirect.
    
    Admin → Layer dashboards is now a full editor:
      - Scope tabs (service / instance / endpoint / trace / profiling)
      - Per-widget inline editing: id / title / type / unit / span /
        rowSpan / visibleWhen / MQE expressions (one per line)
      - Add Widget + Delete + Move up/down
      - Save / Reset header buttons; dirty state drives enablement
      - Save writes the JSON file back via POST /api/admin/layer-templates/:key
        and reloads the BFF cache
    
    BFF: new GET /api/layer/:key/dashboard/config?scope=... param;
    POST /api/layer/:key/dashboard body gains scope; new
    POST /api/admin/layer-templates/:key writes the JSON via the loader's
    writeLayerTemplate helper.
---
 apps/bff/src/dashboard/routes.ts                 | 125 ++++-
 apps/bff/src/layers/config/general.json          | 235 +++++---
 apps/bff/src/layers/loader.ts                    |  58 +-
 apps/ui/src/api/client.ts                        |  14 +-
 apps/ui/src/components/shell/AppSidebar.vue      |  12 +-
 apps/ui/src/composables/useLayerDashboard.ts     |  19 +-
 apps/ui/src/router/index.ts                      |  40 +-
 apps/ui/src/views/admin/LayerDashboardsAdmin.vue | 655 +++++++++++++++--------
 apps/ui/src/views/layer/LayerDashboardsView.vue  |  83 ++-
 packages/api-client/src/dashboard.ts             |  45 +-
 packages/api-client/src/index.ts                 |   1 +
 11 files changed, 945 insertions(+), 342 deletions(-)

diff --git a/apps/bff/src/dashboard/routes.ts b/apps/bff/src/dashboard/routes.ts
index c9c120c..595079b 100644
--- a/apps/bff/src/dashboard/routes.ts
+++ b/apps/bff/src/dashboard/routes.ts
@@ -43,7 +43,13 @@ 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 {
+  allLayerTemplates,
+  getLayerTemplate,
+  widgetsForScope,
+  writeLayerTemplate,
+  type LayerTemplate,
+} from '../layers/loader.js';
 import { defaultWidgetsFor } from './defaults.js';
 
 export interface DashboardRouteDeps {
@@ -59,14 +65,20 @@ const widgetSchema = z.object({
   type: z.enum(['card', 'line']),
   expressions: z.array(z.string().min(1)).min(1).max(8),
   unit: z.string().optional(),
-  x: z.number().int().min(0),
-  y: z.number().int().min(0),
-  w: z.number().int().positive(),
-  h: z.number().int().positive(),
+  span: z.number().int().min(1).max(12).optional(),
+  rowSpan: z.number().int().min(1).max(64).optional(),
+  visibleWhen: z.string().optional(),
+  // Legacy x/y/w/h kept optional for back-compat.
+  x: z.number().int().min(0).optional(),
+  y: z.number().int().min(0).optional(),
+  w: z.number().int().positive().optional(),
+  h: z.number().int().positive().optional(),
 });
+const scopeSchema = z.enum(['service', 'instance', 'endpoint', 'trace', 
'profiling']);
 const bodySchema = z.object({
   service: z.string().optional(),
   widgets: z.array(widgetSchema).max(40).optional(),
+  scope: scopeSchema.optional(),
 });
 
 interface MqeValuesShape {
@@ -153,7 +165,11 @@ export function registerDashboardRoute(app: 
FastifyInstance, deps: DashboardRout
       if (!parsed.success) {
         return reply.code(400).send({ error: 'invalid_body', detail: 
parsed.error.flatten() });
       }
-      const widgets: DashboardWidget[] = parsed.data.widgets ?? 
defaultWidgetsFor(layerKey);
+      const scope = parsed.data.scope ?? 'service';
+      const tpl = getLayerTemplate(layerKey);
+      const widgets: DashboardWidget[] =
+        parsed.data.widgets ??
+        (tpl ? widgetsForScope(tpl, scope) : defaultWidgetsFor(layerKey));
       let serviceName = parsed.data.service ?? '';
       let normal = true;
       const cfgCurrent = deps.config.current;
@@ -256,6 +272,7 @@ export function registerDashboardRoute(app: 
FastifyInstance, deps: DashboardRout
 
   // GET version returns the default widget config without running queries —
   // useful for the SPA to know what to render before invoking POST.
+  // Accepts ?scope=service|instance|endpoint|trace|profiling.
   app.get(
     '/api/layer/:key/dashboard/config',
     { preHandler: auth },
@@ -265,7 +282,12 @@ export function registerDashboardRoute(app: 
FastifyInstance, deps: DashboardRout
       if (!layerKey || !/^[a-z0-9_]+$/i.test(layerKey)) {
         return reply.code(400).send({ error: 'invalid_layer_key' });
       }
-      return reply.send({ layer: layerKey, widgets: 
defaultWidgetsFor(layerKey) });
+      const q = req.query as { scope?: string };
+      const scopeParsed = q.scope ? scopeSchema.safeParse(q.scope) : null;
+      const scope = scopeParsed?.success ? scopeParsed.data : 'service';
+      const tpl = getLayerTemplate(layerKey);
+      const widgets = tpl ? widgetsForScope(tpl, scope) : 
defaultWidgetsFor(layerKey);
+      return reply.send({ layer: layerKey, scope, widgets });
     },
   );
 
@@ -275,4 +297,93 @@ export function registerDashboardRoute(app: 
FastifyInstance, deps: DashboardRout
   app.get('/api/admin/layer-templates', { preHandler: auth }, async (_req, 
reply) => {
     return reply.send({ templates: allLayerTemplates() });
   });
+
+  // Admin: persist an operator-edited template back to its JSON file.
+  // Body is the whole template; the BFF rewrites the file and
+  // invalidates its in-memory cache so subsequent reads see the new
+  // shape immediately.
+  const adminTemplateSchema = z.object({
+    key: z.string().regex(/^[A-Z][A-Z0-9_]*$/),
+    alias: z.string().optional(),
+    color: z.string().optional(),
+    documentLink: z.string().optional(),
+    slots: z
+      .object({
+        services: z.string().optional(),
+        instances: z.string().optional(),
+        endpoints: z.string().optional(),
+        endpointDependency: z.string().optional(),
+      })
+      .strict(),
+    components: z
+      .object({
+        service: z.boolean().optional(),
+        instances: z.boolean().optional(),
+        endpoints: z.boolean().optional(),
+        endpointDependency: z.boolean().optional(),
+        topology: z.boolean().optional(),
+        traces: z.boolean().optional(),
+        logs: z.boolean().optional(),
+        profiling: z.boolean().optional(),
+      })
+      .strict(),
+    metrics: z
+      .object({
+        orderBy: z.string().optional(),
+        throughput: z.string().optional(),
+        spark: z.string().optional(),
+        columns: z
+          .array(
+            z.object({
+              metric: z.string().min(1),
+              label: z.string(),
+              unit: z.string().optional(),
+              mqe: z.string().optional(),
+              aggregation: z.enum(['sum', 'avg']).optional(),
+              scale: z.number().finite().optional(),
+              precision: z.number().int().min(0).max(6).optional(),
+            }),
+          )
+          .max(5)
+          .optional(),
+      })
+      .strict(),
+    dashboards: z
+      .object({
+        service: z.array(widgetSchema).max(40).optional(),
+        instance: z.array(widgetSchema).max(40).optional(),
+        endpoint: z.array(widgetSchema).max(40).optional(),
+        trace: z.array(widgetSchema).max(40).optional(),
+        profiling: z.array(widgetSchema).max(40).optional(),
+      })
+      .strict()
+      .optional(),
+    widgets: z.array(widgetSchema).max(40).optional(),
+  });
+
+  app.post(
+    '/api/admin/layer-templates/:key',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const params = req.params as { key: string };
+      const layerKey = params.key.toUpperCase();
+      const parsed = adminTemplateSchema.safeParse(req.body);
+      if (!parsed.success) {
+        return reply.code(400).send({ error: 'invalid_template', detail: 
parsed.error.flatten() });
+      }
+      if (parsed.data.key.toUpperCase() !== layerKey) {
+        return reply.code(400).send({ error: 'key_mismatch', detail: 'URL key 
does not match body key' });
+      }
+      try {
+        writeLayerTemplate(parsed.data as LayerTemplate);
+      } catch (err) {
+        return reply.code(500).send({
+          error: 'write_failed',
+          detail: err instanceof Error ? err.message : String(err),
+        });
+      }
+      const refreshed = getLayerTemplate(layerKey);
+      return reply.send({ template: refreshed });
+    },
+  );
 }
diff --git a/apps/bff/src/layers/config/general.json 
b/apps/bff/src/layers/config/general.json
index f932be7..7d45f66 100644
--- a/apps/bff/src/layers/config/general.json
+++ b/apps/bff/src/layers/config/general.json
@@ -30,72 +30,171 @@
       { "metric": "err", "label": "err", "unit": "%", "mqe": "100 - 
service_sla/100", "aggregation": "avg" }
     ]
   },
-  "widgets": [
-    {
-      "id": "apdex",
-      "title": "Apdex",
-      "tip": "User satisfaction score on a 0–1 scale. service_apdex is 
integer-times-10000 on the server side; the expression scales it.",
-      "type": "card",
-      "expressions": ["avg(service_apdex)/10000"],
-      "x": 0, "y": 0, "w": 8, "h": 5
-    },
-    {
-      "id": "sla",
-      "title": "Success Rate",
-      "tip": "Percentage of successful requests.",
-      "type": "card",
-      "unit": "%",
-      "expressions": ["avg(service_sla)/100"],
-      "x": 8, "y": 0, "w": 8, "h": 5
-    },
-    {
-      "id": "traffic",
-      "title": "Traffic",
-      "tip": "Requests per minute. For HTTP / gRPC / RPC services this 
reflects request throughput.",
-      "type": "card",
-      "unit": "rpm",
-      "expressions": ["avg(service_cpm)"],
-      "x": 16, "y": 0, "w": 8, "h": 5
-    },
-    {
-      "id": "resp_time",
-      "title": "Avg Response Time",
-      "tip": "Mean latency across all calls in the window.",
-      "type": "line",
-      "unit": "ms",
-      "expressions": ["service_resp_time"],
-      "x": 0, "y": 5, "w": 12, "h": 14
-    },
-    {
-      "id": "percentile",
-      "title": "Response Time Percentile",
-      "tip": "Latency at p50 / p75 / p90 / p95 / p99 — useful for tail 
behavior.",
-      "type": "line",
-      "unit": "ms",
-      "expressions": [
-        "service_percentile{p='50'}",
-        "service_percentile{p='75'}",
-        "service_percentile{p='90'}",
-        "service_percentile{p='95'}",
-        "service_percentile{p='99'}"
-      ],
-      "x": 12, "y": 5, "w": 12, "h": 14
-    },
-    {
-      "id": "traffic_line",
-      "title": "Traffic",
-      "type": "line",
-      "unit": "rpm",
-      "expressions": ["service_cpm"],
-      "x": 0, "y": 19, "w": 12, "h": 12
-    },
-    {
-      "id": "sla_line",
-      "title": "Success Rate",
-      "type": "line",
-      "unit": "%",
-      "expressions": ["service_sla/100"],
-      "x": 12, "y": 19, "w": 12, "h": 12
-    }
-  ]
+  "dashboards": {
+    "service": [
+      {
+        "id": "apdex",
+        "title": "Apdex",
+        "tip": "User satisfaction score on a 0–1 scale. service_apdex is 
integer-times-10000 server-side.",
+        "type": "card",
+        "expressions": ["avg(service_apdex)/10000"],
+        "span": 4, "rowSpan": 1
+      },
+      {
+        "id": "sla",
+        "title": "Success Rate",
+        "type": "card",
+        "unit": "%",
+        "expressions": ["avg(service_sla)/100"],
+        "span": 4, "rowSpan": 1
+      },
+      {
+        "id": "traffic",
+        "title": "Traffic",
+        "type": "card",
+        "unit": "rpm",
+        "expressions": ["avg(service_cpm)"],
+        "span": 4, "rowSpan": 1
+      },
+      {
+        "id": "resp_time",
+        "title": "Avg Response Time",
+        "type": "line",
+        "unit": "ms",
+        "expressions": ["service_resp_time"],
+        "span": 6, "rowSpan": 2
+      },
+      {
+        "id": "percentile",
+        "title": "Response Time Percentile",
+        "tip": "p50 / p75 / p90 / p95 / p99 — useful for tail behavior.",
+        "type": "line",
+        "unit": "ms",
+        "expressions": [
+          "service_percentile{p='50'}",
+          "service_percentile{p='75'}",
+          "service_percentile{p='90'}",
+          "service_percentile{p='95'}",
+          "service_percentile{p='99'}"
+        ],
+        "span": 6, "rowSpan": 2
+      },
+      {
+        "id": "traffic_line",
+        "title": "Traffic",
+        "type": "line",
+        "unit": "rpm",
+        "expressions": ["service_cpm"],
+        "span": 6, "rowSpan": 2
+      },
+      {
+        "id": "sla_line",
+        "title": "Success Rate",
+        "type": "line",
+        "unit": "%",
+        "expressions": ["service_sla/100"],
+        "span": 6, "rowSpan": 2
+      }
+    ],
+    "instance": [
+      {
+        "id": "instance_cpm",
+        "title": "Instance Traffic",
+        "type": "card",
+        "unit": "rpm",
+        "expressions": ["avg(service_instance_cpm)"],
+        "span": 4, "rowSpan": 1
+      },
+      {
+        "id": "instance_resp",
+        "title": "Instance Avg Response Time",
+        "type": "card",
+        "unit": "ms",
+        "expressions": ["avg(service_instance_resp_time)"],
+        "span": 4, "rowSpan": 1
+      },
+      {
+        "id": "instance_sla",
+        "title": "Instance Success Rate",
+        "type": "card",
+        "unit": "%",
+        "expressions": ["avg(service_instance_sla)/100"],
+        "span": 4, "rowSpan": 1
+      },
+      {
+        "id": "instance_cpm_line",
+        "title": "Traffic",
+        "type": "line",
+        "unit": "rpm",
+        "expressions": ["service_instance_cpm"],
+        "span": 6, "rowSpan": 2
+      },
+      {
+        "id": "instance_resp_line",
+        "title": "Response Time",
+        "type": "line",
+        "unit": "ms",
+        "expressions": ["service_instance_resp_time"],
+        "span": 6, "rowSpan": 2
+      },
+      {
+        "id": "jvm_cpu",
+        "title": "JVM CPU",
+        "tip": "Renders only when the instance reports JVM metrics.",
+        "type": "line",
+        "unit": "%",
+        "expressions": ["instance_jvm_cpu"],
+        "visibleWhen": "instance_jvm_cpu has value",
+        "span": 6, "rowSpan": 2
+      },
+      {
+        "id": "jvm_heap",
+        "title": "JVM Heap (bytes)",
+        "type": "line",
+        "expressions": ["instance_jvm_memory{name='heap'}"],
+        "visibleWhen": "instance_jvm_memory has value",
+        "span": 6, "rowSpan": 2
+      }
+    ],
+    "endpoint": [
+      {
+        "id": "endpoint_cpm",
+        "title": "Endpoint Traffic",
+        "type": "card",
+        "unit": "rpm",
+        "expressions": ["avg(endpoint_cpm)"],
+        "span": 4, "rowSpan": 1
+      },
+      {
+        "id": "endpoint_resp",
+        "title": "Avg Response Time",
+        "type": "card",
+        "unit": "ms",
+        "expressions": ["avg(endpoint_resp_time)"],
+        "span": 4, "rowSpan": 1
+      },
+      {
+        "id": "endpoint_sla",
+        "title": "Success Rate",
+        "type": "card",
+        "unit": "%",
+        "expressions": ["avg(endpoint_sla)/100"],
+        "span": 4, "rowSpan": 1
+      },
+      {
+        "id": "endpoint_percentile",
+        "title": "Endpoint Response Time Percentile",
+        "type": "line",
+        "unit": "ms",
+        "expressions": [
+          "endpoint_percentile{p='50'}",
+          "endpoint_percentile{p='95'}",
+          "endpoint_percentile{p='99'}"
+        ],
+        "span": 12, "rowSpan": 2
+      }
+    ],
+    "trace": [],
+    "profiling": []
+  }
 }
diff --git a/apps/bff/src/layers/loader.ts b/apps/bff/src/layers/loader.ts
index 6627752..e0a1b4f 100644
--- a/apps/bff/src/layers/loader.ts
+++ b/apps/bff/src/layers/loader.ts
@@ -32,10 +32,10 @@
  *     widget catalog)
  */
 
-import { readdirSync, readFileSync } from 'node:fs';
+import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
 import { dirname, join, basename } from 'node:path';
 import { fileURLToPath } from 'node:url';
-import type { DashboardWidget } from '@skywalking-horizon-ui/api-client';
+import type { DashboardScope, DashboardWidget } from 
'@skywalking-horizon-ui/api-client';
 
 export interface LayerComponentFlags {
   service?: boolean;
@@ -75,6 +75,21 @@ export interface LayerMetricsConfig {
   columns?: LayerMetricColumn[];
 }
 
+/**
+ * Per-scope dashboards bundled with a layer template. Each scope is an
+ * independent widget set; the SPA picks one based on the active route
+ * (`/layer/:key/service`, `/instance`, `/endpoint`, `/trace`,
+ * `/profiling`). Legacy `widgets` (flat array) is migrated to
+ * `dashboards.service` at load time.
+ */
+export interface LayerDashboards {
+  service?: DashboardWidget[];
+  instance?: DashboardWidget[];
+  endpoint?: DashboardWidget[];
+  trace?: DashboardWidget[];
+  profiling?: DashboardWidget[];
+}
+
 export interface LayerTemplate {
   /** UPPER_SNAKE enum key (matches OAP). */
   key: string;
@@ -87,7 +102,10 @@ export interface LayerTemplate {
   slots: LayerSlotsConfig;
   components: LayerComponentFlags;
   metrics: LayerMetricsConfig;
-  widgets: DashboardWidget[];
+  /** Per-scope widget sets. `service` is the layer's primary landing. */
+  dashboards?: LayerDashboards;
+  /** Legacy single widget list — treated as `dashboards.service`. */
+  widgets?: DashboardWidget[];
 }
 
 const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -106,19 +124,49 @@ function load(): Map<string, LayerTemplate> {
     } catch (err) {
       throw new Error(`failed to parse layer config ${file}: ${err instanceof 
Error ? err.message : err}`);
     }
-    // File name should match the layer key (case-insensitive) so the
-    // operator can rename / locate the right file without grepping JSON.
     const expected = basename(file, '.json').toUpperCase();
     if (parsed.key && parsed.key.toUpperCase() !== expected) {
       throw new Error(
         `layer config ${file}: file basename does not match \`key\` 
(${parsed.key})`,
       );
     }
+    // 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)) {
+      parsed.dashboards = { ...parsed.dashboards, service: parsed.widgets };
+    }
     out.set(parsed.key.toUpperCase(), parsed);
   }
   return out;
 }
 
+/** Resolve the widget set for a given scope, falling back to service. */
+export function widgetsForScope(
+  template: LayerTemplate,
+  scope: DashboardScope,
+): DashboardWidget[] {
+  const d = template.dashboards;
+  if (!d) return template.widgets ?? [];
+  return d[scope] ?? d.service ?? template.widgets ?? [];
+}
+
+/**
+ * Persist an operator-edited template back to its JSON file. Validates
+ * the basic shape, sorts keys for stable diffs, then refreshes the
+ * in-memory cache so subsequent reads see the new state. Intentionally
+ * naive: no concurrency control, no schema migrations — operators on
+ * single-node BFF deployments, single admin user.
+ */
+export function writeLayerTemplate(template: LayerTemplate): void {
+  if (!template.key || !/^[A-Z][A-Z0-9_]*$/.test(template.key)) {
+    throw new Error('invalid template key (must be UPPER_SNAKE_CASE)');
+  }
+  const file = join(CONFIG_DIR, `${template.key.toLowerCase()}.json`);
+  const serialised = JSON.stringify(template, null, 2) + '\n';
+  writeFileSync(file, serialised, 'utf-8');
+  reloadLayerTemplates();
+}
+
 /**
  * Lookup a layer template by enum key (case-insensitive). Returns
  * `null` when no template is defined for the layer — call sites should
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index 652b38a..6247ba2 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -187,15 +187,16 @@ export class BffClient {
   }
 
   // ── dashboards (per-layer widget data) ───────────────────────────────
-  dashboardConfig(layerKey: string): Promise<DashboardConfig> {
+  dashboardConfig(layerKey: string, scope?: string): Promise<DashboardConfig> {
+    const qs = scope ? `?scope=${encodeURIComponent(scope)}` : '';
     return this.request<DashboardConfig>(
       'GET',
-      `/api/layer/${encodeURIComponent(layerKey)}/dashboard/config`,
+      `/api/layer/${encodeURIComponent(layerKey)}/dashboard/config${qs}`,
     );
   }
   dashboard(
     layerKey: string,
-    body: { service?: string; widgets?: DashboardWidget[] } = {},
+    body: { service?: string; widgets?: DashboardWidget[]; scope?: string } = 
{},
   ): Promise<DashboardResponse> {
     return this.request<DashboardResponse>(
       'POST',
@@ -203,6 +204,13 @@ export class BffClient {
       body,
     );
   }
+  saveLayerTemplate(template: AdminLayerTemplate): Promise<{ template: 
AdminLayerTemplate }> {
+    return this.request<{ template: AdminLayerTemplate }>(
+      'POST',
+      `/api/admin/layer-templates/${encodeURIComponent(template.key)}`,
+      template,
+    );
+  }
 
   /** Admin: list every loaded layer template (alias / components / widgets). 
*/
   adminLayerTemplates(): Promise<{ templates: AdminLayerTemplate[] }> {
diff --git a/apps/ui/src/components/shell/AppSidebar.vue 
b/apps/ui/src/components/shell/AppSidebar.vue
index f5dff5b..40d7f16 100644
--- a/apps/ui/src/components/shell/AppSidebar.vue
+++ b/apps/ui/src/components/shell/AppSidebar.vue
@@ -224,17 +224,17 @@ const sections: NavSection[] = [
           </RouterLink>
           <RouterLink
             v-if="L.slots.instances"
-            :to="`/layer/${L.key}/instances`"
+            :to="`/layer/${L.key}/instance`"
             class="sw-nav-item"
-            :class="{ 'is-active': isActive(`/layer/${L.key}/instances`) }"
+            :class="{ 'is-active': isActive(`/layer/${L.key}/instance`) }"
           >
             <Icon name="prof" /><span>{{ L.slots.instances }}</span>
           </RouterLink>
           <RouterLink
             v-if="L.slots.endpoints"
-            :to="`/layer/${L.key}/endpoints`"
+            :to="`/layer/${L.key}/endpoint`"
             class="sw-nav-item"
-            :class="{ 'is-active': isActive(`/layer/${L.key}/endpoints`) }"
+            :class="{ 'is-active': isActive(`/layer/${L.key}/endpoint`) }"
           >
             <Icon name="ep" /><span>{{ L.slots.endpoints }}</span>
           </RouterLink>
@@ -257,9 +257,9 @@ const sections: NavSection[] = [
           </RouterLink>
           <RouterLink
             v-if="L.caps.traces"
-            :to="`/layer/${L.key}/traces`"
+            :to="`/layer/${L.key}/trace`"
             class="sw-nav-item"
-            :class="{ 'is-active': isActive(`/layer/${L.key}/traces`) }"
+            :class="{ 'is-active': isActive(`/layer/${L.key}/trace`) }"
           >
             <Icon name="trace" /><span>Traces</span>
           </RouterLink>
diff --git a/apps/ui/src/composables/useLayerDashboard.ts 
b/apps/ui/src/composables/useLayerDashboard.ts
index d66f33b..703d52c 100644
--- a/apps/ui/src/composables/useLayerDashboard.ts
+++ b/apps/ui/src/composables/useLayerDashboard.ts
@@ -31,10 +31,10 @@ import { computed, type Ref } from 'vue';
 import { useQuery } from '@tanstack/vue-query';
 import { bffClient } from '@/api/client';
 
-export function useLayerDashboardConfig(layerKey: Ref<string>) {
+export function useLayerDashboardConfig(layerKey: Ref<string>, scope?: 
Ref<string>) {
   const q = useQuery({
-    queryKey: ['dashboard-config', layerKey],
-    queryFn: () => bffClient.dashboardConfig(layerKey.value),
+    queryKey: ['dashboard-config', layerKey, scope ?? computed(() => 
'service')],
+    queryFn: () => bffClient.dashboardConfig(layerKey.value, scope?.value),
     enabled: computed(() => layerKey.value.length > 0),
     staleTime: 5 * 60_000,
   });
@@ -45,11 +45,18 @@ export function useLayerDashboardConfig(layerKey: 
Ref<string>) {
   };
 }
 
-export function useLayerDashboard(layerKey: Ref<string>, service: Ref<string | 
null>) {
+export function useLayerDashboard(
+  layerKey: Ref<string>,
+  service: Ref<string | null>,
+  scope?: Ref<string>,
+) {
   const q = useQuery({
-    queryKey: ['dashboard', layerKey, service],
+    queryKey: ['dashboard', layerKey, service, scope ?? computed(() => 
'service')],
     queryFn: () =>
-      bffClient.dashboard(layerKey.value, service.value ? { service: 
service.value } : {}),
+      bffClient.dashboard(layerKey.value, {
+        ...(service.value ? { service: service.value } : {}),
+        ...(scope?.value ? { scope: scope.value } : {}),
+      }),
     enabled: computed(() => layerKey.value.length > 0),
     staleTime: 45_000,
     refetchInterval: 60_000,
diff --git a/apps/ui/src/router/index.ts b/apps/ui/src/router/index.ts
index 58fa910..f92be31 100644
--- a/apps/ui/src/router/index.ts
+++ b/apps/ui/src/router/index.ts
@@ -31,14 +31,14 @@ function layerRoute(): RouteRecordRaw {
   // 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.
+  // Tabs that still don't have a per-scope dashboard set. Topology +
+  // dependency + logs need their own page treatments (Phase 4 / 5);
+  // their JSON template `components.*` flag still gates the sidebar
+  // entry, this just keeps the URL routing legible.
   const placeholderTabs: { path: string; label: string; phase: string }[] = [
-    { path: 'instances', label: 'Instances', phase: 'Phase 2 / 3' },
-    { path: 'endpoints', label: 'Endpoints', phase: 'Phase 2 / 3' },
     { path: 'topology', label: 'Topology', phase: 'Phase 4' },
     { path: 'dependency', label: 'API dependency', phase: 'Phase 4' },
-    { path: 'traces', label: 'Traces', phase: 'Phase 5' },
     { path: 'logs', label: 'Logs', phase: 'Phase 5' },
-    { path: 'profiling', label: 'Profiling', phase: 'Phase 8' },
   ];
   return {
     path: 'layer/:layerKey',
@@ -47,15 +47,18 @@ function layerRoute(): RouteRecordRaw {
       // 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.
+      // Per-scope dashboards. Same view component, scope inferred from
+      // the URL — widget set differs per scope via the JSON template's
+      // `dashboards.<scope>` array.
       { path: 'service', component: () => 
import('@/views/layer/LayerDashboardsView.vue') },
-      // Legacy routes — redirect to /service so old bookmarks keep working.
+      { path: 'instance', component: () => 
import('@/views/layer/LayerDashboardsView.vue') },
+      { path: 'endpoint', component: () => 
import('@/views/layer/LayerDashboardsView.vue') },
+      { path: 'trace', component: () => 
import('@/views/layer/LayerDashboardsView.vue') },
+      { path: 'profiling', component: () => 
import('@/views/layer/LayerDashboardsView.vue') },
+      // Legacy routes redirect to /service.
       {
         path: 'services',
-        redirect: (to) => ({
-          path: `/layer/${to.params.layerKey}/service`,
-          query: to.query,
-        }),
+        redirect: (to) => ({ path: `/layer/${to.params.layerKey}/service`, 
query: to.query }),
       },
       {
         path: 'services/:serviceId',
@@ -66,10 +69,19 @@ function layerRoute(): RouteRecordRaw {
       },
       {
         path: 'dashboards',
-        redirect: (to) => ({
-          path: `/layer/${to.params.layerKey}/service`,
-          query: to.query,
-        }),
+        redirect: (to) => ({ path: `/layer/${to.params.layerKey}/service`, 
query: to.query }),
+      },
+      {
+        path: 'instances',
+        redirect: (to) => ({ path: `/layer/${to.params.layerKey}/instance`, 
query: to.query }),
+      },
+      {
+        path: 'endpoints',
+        redirect: (to) => ({ path: `/layer/${to.params.layerKey}/endpoint`, 
query: to.query }),
+      },
+      {
+        path: 'traces',
+        redirect: (to) => ({ path: `/layer/${to.params.layerKey}/trace`, 
query: to.query }),
       },
       ...placeholderTabs.map<RouteRecordRaw>((f) => ({
         path: f.path,
diff --git a/apps/ui/src/views/admin/LayerDashboardsAdmin.vue 
b/apps/ui/src/views/admin/LayerDashboardsAdmin.vue
index f9265b8..16c40db 100644
--- a/apps/ui/src/views/admin/LayerDashboardsAdmin.vue
+++ b/apps/ui/src/views/admin/LayerDashboardsAdmin.vue
@@ -15,37 +15,158 @@
   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.
+  Admin / Layer dashboards. List every loaded layer template, pick one
+  on the left, edit its per-scope widget set on the right. Saves write
+  the JSON file back via POST /api/admin/layer-templates/:key so the
+  BFF refreshes its in-memory cache.
+
+  Widget editor presents the new span-based fields (12-col flow
+  layout): operator picks a column span, optional row span, MQE
+  expressions, type, title, unit, and an optional visibility predicate.
+  Legacy x/y/w/h are NOT shown — they're kept on the wire for
+  back-compat with older JSONs but operators don't edit them.
 -->
 <script setup lang="ts">
-import { computed, ref, onMounted } from 'vue';
+import { computed, reactive, ref, onMounted, watch } from 'vue';
 import type { AdminLayerTemplate } from '@/api/client';
+import type { DashboardScope, DashboardWidget } from 
'@skywalking-horizon-ui/api-client';
 import { bffClient } from '@/api/client';
 
+const SCOPES: DashboardScope[] = ['service', 'instance', 'endpoint', 'trace', 
'profiling'];
+
 const templates = ref<AdminLayerTemplate[]>([]);
 const isLoading = ref(true);
 const error = ref<string | null>(null);
 const selectedKey = ref<string>('');
+const activeScope = ref<DashboardScope>('service');
+const isSaving = ref(false);
+const saveMsg = ref<string | null>(null);
 
-onMounted(async () => {
+/** Working copy — reactively edited. Diffs against `templates` to drive
+ *  the Save / Reset state. */
+const draft = reactive<{ template: AdminLayerTemplate | null }>({ template: 
null });
+
+async function loadAll(): Promise<void> {
+  isLoading.value = true;
+  error.value = null;
   try {
     const res = await bffClient.adminLayerTemplates();
     templates.value = res.templates;
-    if (res.templates.length > 0) selectedKey.value = res.templates[0].key;
+    if (res.templates.length > 0 && !selectedKey.value) {
+      selectedKey.value = res.templates[0].key;
+    }
+    syncDraft();
   } catch (err) {
     error.value = err instanceof Error ? err.message : String(err);
   } finally {
     isLoading.value = false;
   }
+}
+
+function syncDraft(): void {
+  const tpl = templates.value.find((t) => t.key === selectedKey.value);
+  draft.template = tpl ? JSON.parse(JSON.stringify(tpl)) : null;
+  saveMsg.value = null;
+}
+
+watch(selectedKey, syncDraft);
+onMounted(loadAll);
+
+const dirty = computed(() => {
+  const original = templates.value.find((t) => t.key === selectedKey.value);
+  if (!original || !draft.template) return false;
+  return JSON.stringify(original) !== JSON.stringify(draft.template);
 });
 
-const selected = computed<AdminLayerTemplate | null>(
-  () => templates.value.find((t) => t.key === selectedKey.value) ?? null,
-);
+function widgetsFor(scope: DashboardScope): DashboardWidget[] {
+  const tpl = draft.template;
+  if (!tpl) return [];
+  // Read from `dashboards.<scope>`, falling back to legacy `widgets`
+  // for the service scope so the existing JSONs keep their content
+  // until we explicitly migrate them.
+  const d = (tpl as unknown as { dashboards?: Record<string, 
DashboardWidget[]> }).dashboards;
+  if (d?.[scope]) return d[scope];
+  if (scope === 'service' && tpl.widgets) return tpl.widgets;
+  return [];
+}
+
+function setWidgetsFor(scope: DashboardScope, widgets: DashboardWidget[]): 
void {
+  const tpl = draft.template;
+  if (!tpl) return;
+  const dashboards =
+    (tpl as unknown as { dashboards?: Record<string, DashboardWidget[]> 
}).dashboards ?? {};
+  dashboards[scope] = widgets;
+  (tpl as unknown as { dashboards?: Record<string, DashboardWidget[]> 
}).dashboards = dashboards;
+  // Drop the legacy `widgets` once we've split — keeps the JSON clean.
+  if (scope === 'service' && tpl.widgets) {
+    (tpl as unknown as { widgets?: DashboardWidget[] }).widgets = undefined;
+  }
+}
+
+function addWidget(): void {
+  const widgets = [...widgetsFor(activeScope.value)];
+  const idx = widgets.length;
+  widgets.push({
+    id: `widget_${idx + 1}`,
+    title: `Widget ${idx + 1}`,
+    type: 'card',
+    expressions: [''],
+    span: 4,
+    rowSpan: 1,
+  });
+  setWidgetsFor(activeScope.value, widgets);
+}
+
+function deleteWidget(i: number): void {
+  const widgets = [...widgetsFor(activeScope.value)];
+  widgets.splice(i, 1);
+  setWidgetsFor(activeScope.value, widgets);
+}
+
+function moveWidget(i: number, dir: -1 | 1): void {
+  const widgets = [...widgetsFor(activeScope.value)];
+  const j = i + dir;
+  if (j < 0 || j >= widgets.length) return;
+  [widgets[i], widgets[j]] = [widgets[j], widgets[i]];
+  setWidgetsFor(activeScope.value, widgets);
+}
+
+function expressionsToText(arr: string[]): string {
+  return arr.join('\n');
+}
+function textToExpressions(s: string): string[] {
+  return s
+    .split('\n')
+    .map((x) => x.trim())
+    .filter((x) => x.length > 0);
+}
+
+async function save(): Promise<void> {
+  if (!draft.template || isSaving.value) return;
+  isSaving.value = true;
+  saveMsg.value = null;
+  try {
+    const res = await bffClient.saveLayerTemplate(draft.template);
+    // Splice the returned template back into the list so subsequent
+    // dirty diffs are against the persisted state.
+    const idx = templates.value.findIndex((t) => t.key === selectedKey.value);
+    if (idx >= 0 && res.template) templates.value[idx] = res.template;
+    syncDraft();
+    saveMsg.value = 'Saved.';
+    setTimeout(() => (saveMsg.value = null), 2400);
+  } catch (err) {
+    saveMsg.value = err instanceof Error ? err.message : String(err);
+  } finally {
+    isSaving.value = false;
+  }
+}
+
+function reset(): void {
+  syncDraft();
+}
+
+const selectedTpl = computed(() => draft.template);
+const currentWidgets = computed(() => widgetsFor(activeScope.value));
 
 function componentFlags(t: AdminLayerTemplate): string[] {
   const c = t.components;
@@ -69,9 +190,9 @@ function componentFlags(t: AdminLayerTemplate): string[] {
         <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.
+          Each layer ships with a JSON template (alias, components, metric 
columns, widgets).
+          Pick a layer on the left, switch scopes (service / instance / 
endpoint / trace /
+          profiling), edit widgets in place, and save back to the JSON.
         </p>
       </div>
     </header>
@@ -81,7 +202,6 @@ function componentFlags(t: AdminLayerTemplate): string[] {
     <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>
@@ -96,111 +216,147 @@ function componentFlags(t: AdminLayerTemplate): string[] 
{
         >
           <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>
+      <main v-if="selectedTpl" class="detail">
+        <!-- Identity strip + save controls -->
+        <section class="sw-card identity-card">
+          <div class="identity-row">
+            <span class="dot inline" :style="{ background: selectedTpl.color 
|| 'var(--sw-fg-3)' }" />
+            <div>
+              <h2>{{ selectedTpl.alias || selectedTpl.key }}</h2>
+              <div class="meta">
+                <code>{{ selectedTpl.key }}</code>
+                <span v-for="c in componentFlags(selectedTpl)" :key="c" 
class="chip on">{{ c }}</span>
+              </div>
+            </div>
+            <div class="actions">
+              <span v-if="saveMsg" class="save-msg">{{ saveMsg }}</span>
+              <button
+                class="sw-btn"
+                type="button"
+                :disabled="!dirty || isSaving"
+                @click="reset"
+              >
+                Reset
+              </button>
+              <button
+                class="sw-btn is-primary"
+                type="button"
+                :disabled="!dirty || isSaving"
+                @click="save"
+              >
+                {{ isSaving ? 'Saving…' : 'Save' }}
+              </button>
+            </div>
           </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>
+        <!-- Scope tabs -->
+        <nav class="scope-tabs sw-card">
+          <button
+            v-for="s in SCOPES"
+            :key="s"
+            class="scope-tab"
+            :class="{ on: activeScope === s }"
+            type="button"
+            @click="activeScope = s"
+          >
+            {{ s }}
+            <span class="count">{{ widgetsFor(s).length }}</span>
+          </button>
+        </nav>
 
-        <section class="sw-card">
+        <!-- Widget editor -->
+        <section class="sw-card widgets-card">
           <div class="card-head">
-            <h4>Slots</h4>
-            <span class="sub">term aliases for service / instance / endpoint 
scopes</span>
+            <h4>{{ activeScope }} widgets</h4>
+            <span class="sub">12-col flow grid · uniform 180px row height · 
drag-free</span>
+            <button class="sw-btn add" type="button" @click="addWidget">+ Add 
widget</button>
           </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 v-if="currentWidgets.length === 0" class="empty inset">
+            No widgets defined for this scope. Click "Add widget" to start.
           </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>
+          <ul v-else class="widget-list">
+            <li v-for="(w, i) in currentWidgets" :key="i" class="widget-edit">
+              <div class="we-row">
+                <div class="we-handle">
+                  <button
+                    class="sw-btn ghost small"
+                    type="button"
+                    :disabled="i === 0"
+                    title="Move up"
+                    @click="moveWidget(i, -1)"
+                  >↑</button>
+                  <button
+                    class="sw-btn ghost small"
+                    type="button"
+                    :disabled="i === currentWidgets.length - 1"
+                    title="Move down"
+                    @click="moveWidget(i, 1)"
+                  >↓</button>
+                </div>
+                <div class="we-fields">
+                  <div class="row">
+                    <label>
+                      <span>id</span>
+                      <input class="mono" v-model="w.id" />
+                    </label>
+                    <label class="grow">
+                      <span>Title</span>
+                      <input v-model="w.title" />
+                    </label>
+                    <label>
+                      <span>Type</span>
+                      <select v-model="w.type">
+                        <option value="card">card</option>
+                        <option value="line">line</option>
+                      </select>
+                    </label>
+                    <label>
+                      <span>Unit</span>
+                      <input v-model="w.unit" placeholder="—" />
+                    </label>
+                    <label>
+                      <span>Span (1–12)</span>
+                      <input type="number" min="1" max="12" 
v-model.number="w.span" />
+                    </label>
+                    <label>
+                      <span>Row span</span>
+                      <input type="number" min="1" max="6" 
v-model.number="w.rowSpan" />
+                    </label>
+                  </div>
+                  <div class="row">
+                    <label class="grow wide">
+                      <span>Visible when (optional)</span>
+                      <input
+                        class="mono"
+                        v-model="w.visibleWhen"
+                        placeholder="#entity.jvm   or   service_jvm_cpu has 
value"
+                      />
+                    </label>
+                  </div>
+                  <div class="row">
+                    <label class="grow wide">
+                      <span>MQE expressions (one per line)</span>
+                      <textarea
+                        class="mono"
+                        rows="3"
+                        :value="expressionsToText(w.expressions)"
+                        @input="w.expressions = 
textToExpressions(($event.target as HTMLTextAreaElement).value)"
+                      ></textarea>
+                    </label>
+                  </div>
+                </div>
+                <button class="sw-btn danger" type="button" title="Delete" 
@click="deleteWidget(i)">
+                  ✕
+                </button>
+              </div>
+            </li>
+          </ul>
         </section>
       </main>
     </div>
@@ -213,9 +369,7 @@ function componentFlags(t: AdminLayerTemplate): string[] {
   max-width: 1440px;
   margin: 0 auto;
 }
-.page-head {
-  margin-bottom: 18px;
-}
+.page-head { margin-bottom: 18px; }
 .kicker {
   font-size: 10px;
   text-transform: uppercase;
@@ -252,10 +406,15 @@ function componentFlags(t: AdminLayerTemplate): string[] {
   color: var(--sw-fg-3);
   font-size: 12px;
 }
+.empty.inset {
+  padding: 18px;
+  font-size: 11.5px;
+}
 .grid {
   display: grid;
   grid-template-columns: 220px 1fr;
   gap: 14px;
+  align-items: start;
 }
 .layer-list {
   padding: 8px;
@@ -263,6 +422,8 @@ function componentFlags(t: AdminLayerTemplate): string[] {
   flex-direction: column;
   gap: 2px;
   align-self: start;
+  position: sticky;
+  top: 16px;
 }
 .list-head {
   padding: 6px 10px 10px;
@@ -293,17 +454,14 @@ function componentFlags(t: AdminLayerTemplate): string[] {
   text-align: left;
   font: inherit;
 }
-.layer-row:hover {
-  background: var(--sw-bg-2);
-}
+.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;
+  width: 7px; height: 7px;
   border-radius: 50%;
   flex: 0 0 7px;
 }
@@ -313,144 +471,217 @@ function componentFlags(t: AdminLayerTemplate): 
string[] {
   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;
+  min-width: 0;
 }
-.card-head {
+.identity-card { padding: 12px 16px; }
+.identity-row {
   display: flex;
-  align-items: baseline;
-  gap: 10px;
-  padding: 10px 14px;
-  border-bottom: 1px solid var(--sw-line);
+  align-items: center;
+  gap: 14px;
 }
-.card-head h4 {
+.identity-row h2 {
   margin: 0;
-  font-size: 12px;
+  font-size: 15px;
   font-weight: 600;
   color: var(--sw-fg-0);
 }
-.card-head .sub {
+.identity-row .meta {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  flex-wrap: wrap;
+  margin-top: 4px;
   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 {
+.identity-row .meta code {
   font-family: var(--sw-mono);
-  font-size: 11.5px;
+  background: var(--sw-bg-2);
+  padding: 1px 6px;
+  border-radius: 3px;
   color: var(--sw-fg-1);
 }
-.mono code {
-  background: transparent;
-  padding: 0;
+.chip {
+  font-size: 10px;
+  padding: 1px 6px;
+  border-radius: 3px;
+  background: var(--sw-bg-2);
+  color: var(--sw-fg-2);
+}
+.chip.on {
+  background: var(--sw-accent-soft);
+  color: var(--sw-accent-2);
 }
 .dot.inline {
-  display: inline-block;
-  width: 8px;
-  height: 8px;
+  width: 12px; height: 12px;
   border-radius: 50%;
-  margin-right: 6px;
-  vertical-align: middle;
+  display: inline-block;
 }
-.chips {
+.actions {
+  margin-left: auto;
   display: flex;
-  flex-wrap: wrap;
-  gap: 6px;
-  padding: 12px 14px;
+  align-items: center;
+  gap: 8px;
 }
-.chip {
-  font-size: 10.5px;
-  padding: 3px 8px;
+.actions .sw-btn { font-size: 11.5px; }
+.actions .sw-btn[disabled] { opacity: 0.4; pointer-events: none; }
+.save-msg {
+  font-size: 11px;
+  color: var(--sw-ok);
+}
+
+.scope-tabs {
+  display: flex;
+  gap: 2px;
+  padding: 6px;
+}
+.scope-tab {
+  flex: 1;
+  padding: 8px 12px;
+  font-size: 11.5px;
+  font-weight: 500;
+  color: var(--sw-fg-2);
+  background: transparent;
+  border: none;
   border-radius: 4px;
-  background: var(--sw-bg-2);
-  color: var(--sw-fg-1);
-  border: 1px solid var(--sw-line-2);
+  cursor: pointer;
+  text-transform: capitalize;
+  font: inherit;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 6px;
 }
-.chip.on {
+.scope-tab:hover { background: var(--sw-bg-2); color: var(--sw-fg-1); }
+.scope-tab.on {
   background: var(--sw-accent-soft);
   color: var(--sw-accent-2);
-  border-color: var(--sw-accent-line);
+  font-weight: 600;
 }
-.chip.off {
+.scope-tab .count {
+  font-family: var(--sw-mono);
+  font-size: 10px;
   color: var(--sw-fg-3);
 }
-.sw-table {
-  width: 100%;
-  font-size: 11.5px;
+.scope-tab.on .count { color: var(--sw-accent-2); }
+
+.widgets-card { padding: 0; }
+.card-head {
+  display: flex;
+  align-items: baseline;
+  gap: 10px;
+  padding: 12px 16px;
+  border-bottom: 1px solid var(--sw-line);
 }
-.sw-table th {
-  text-align: left;
-  font-size: 10px;
-  text-transform: uppercase;
-  letter-spacing: 0.06em;
+.card-head h4 {
+  margin: 0;
+  font-size: 12px;
+  font-weight: 600;
+  color: var(--sw-fg-0);
+  text-transform: capitalize;
+}
+.card-head .sub {
+  font-size: 10.5px;
   color: var(--sw-fg-3);
-  font-weight: 500;
-  padding: 6px 14px;
-  border-bottom: 1px solid var(--sw-line);
 }
-.sw-table td {
-  padding: 6px 14px;
+.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);
+}
+
+.widget-list {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+}
+.widget-edit {
   border-bottom: 1px solid var(--sw-line);
-  color: var(--sw-fg-1);
-  vertical-align: top;
 }
-.sw-table td.mqe div + div {
-  margin-top: 2px;
+.widget-edit:last-child { border-bottom: none; }
+.we-row {
+  display: flex;
+  align-items: flex-start;
+  gap: 10px;
+  padding: 12px 16px;
+}
+.we-handle {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  padding-top: 18px;
 }
-.tag {
+.we-handle .sw-btn {
+  width: 24px;
+  height: 22px;
+  padding: 0;
   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 {
+.we-fields {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+.row {
   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);
+  gap: 8px;
 }
-.extras strong {
+.row label {
+  display: flex;
+  flex-direction: column;
+  gap: 3px;
+  font-size: 10.5px;
   color: var(--sw-fg-3);
-  font-weight: 500;
-  text-transform: uppercase;
-  font-size: 9.5px;
-  letter-spacing: 0.08em;
-  margin-right: 4px;
+  flex: 0 1 auto;
+}
+.row label.grow { flex: 1 1 auto; min-width: 140px; }
+.row label.wide { flex: 1 1 100%; }
+.row input,
+.row select,
+.row textarea {
+  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;
+}
+.row textarea {
+  height: auto;
+  padding: 6px 8px;
+  resize: vertical;
 }
-.extras code {
+.row input.mono,
+.row textarea.mono {
   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);
+  font-size: 11px;
+}
+.row input:focus,
+.row select:focus,
+.row textarea:focus {
+  outline: none;
+  border-color: var(--sw-accent-line);
+}
+.sw-btn.danger {
+  width: 26px;
+  height: 26px;
+  padding: 0;
+  font-size: 11px;
+  margin-top: 18px;
+  border-color: rgba(239, 68, 68, 0.3);
+  color: #f87171;
+}
+.sw-btn.danger:hover {
+  background: var(--sw-err-soft);
 }
 </style>
diff --git a/apps/ui/src/views/layer/LayerDashboardsView.vue 
b/apps/ui/src/views/layer/LayerDashboardsView.vue
index 6372c45..b55c5b1 100644
--- a/apps/ui/src/views/layer/LayerDashboardsView.vue
+++ b/apps/ui/src/views/layer/LayerDashboardsView.vue
@@ -39,14 +39,21 @@ import { fmtMetric } from '@/utils/formatters';
 
 const route = useRoute();
 const layerKey = computed(() => String(route.params.layerKey ?? ''));
+// Scope is inferred from the active sub-route (`service` / `instance`
+// / `endpoint` / `trace` / `profiling`). The view is shared across
+// all per-layer scope routes, the BFF returns a different widget set
+// per scope.
+const scope = computed<string>(() => {
+  const path = route.path;
+  for (const s of ['instance', 'endpoint', 'trace', 'profiling']) {
+    if (path.endsWith(`/${s}`)) return s;
+  }
+  return 'service';
+});
 const { selectedId } = useSelectedService();
 const { layers } = useLayers();
 const layer = computed<LayerDef | null>(() => layers.value.find((l) => l.key 
=== layerKey.value) ?? null);
 
-// Look up the service NAME from landing data — selectedId is the
-// base64 OAP service id, which MQE doesn't accept; MQE entities are
-// keyed by serviceName. We share the landing query with the rest of
-// the per-layer page so this is free (cached by vue-query).
 const store = useSetupStore();
 const safeLayer = computed<LayerDef>(() => layer.value ?? {
   key: layerKey.value, name: layerKey.value, color: 'var(--sw-fg-2)',
@@ -63,8 +70,8 @@ const serviceName = computed<string | null>(() => {
   return match?.serviceName ?? null;
 });
 
-const { config, isLoading: configLoading } = useLayerDashboardConfig(layerKey);
-const { data, isFetching, error } = useLayerDashboard(layerKey, serviceName);
+const { config, isLoading: configLoading } = useLayerDashboardConfig(layerKey, 
scope);
+const { data, isFetching, error } = useLayerDashboard(layerKey, serviceName, 
scope);
 
 const widgets = computed(() => config.value?.widgets ?? []);
 const resultsById = computed(() => {
@@ -75,6 +82,50 @@ const resultsById = computed(() => {
 const reachable = computed(() => data.value?.reachable !== false);
 const errorText = computed(() => data.value?.error ?? (error.value ? 
String(error.value) : null));
 const headerTitle = computed(() => serviceName.value ?? data.value?.service ?? 
'Pick a service');
+
+/** Map a widget's grid footprint into the new 12-col flow grid. Honors
+ *  `span` / `rowSpan` first; falls back to legacy `w` / `h` (24-col
+ *  scaled to 12 by halving) so older templates still render. */
+function gridStyle(w: { span?: number; rowSpan?: number; w?: number; h?: 
number }): Record<string, string> {
+  const span = w.span ?? (w.w ? Math.max(1, Math.min(12, Math.round(w.w / 2))) 
: 4);
+  const rowSpan = w.rowSpan ?? (w.h ? Math.max(1, Math.round(w.h / 8)) : 1);
+  return {
+    gridColumn: `span ${span}`,
+    gridRow: `span ${rowSpan}`,
+  };
+}
+
+/**
+ * Evaluate a widget's `visibleWhen` predicate.
+ *   - `<metric_name> has value`  → the widget's result has a non-null
+ *     scalar / a non-empty series.
+ *   - `#entity.<key>`             → entity attribute exists (deferred —
+ *     we don't surface entity attributes yet; defaults true).
+ *   - anything else               → treated as "always visible".
+ *
+ * Empty / unset → always visible. Predicates that mention a metric not
+ * in the widget's own results never hide the widget either; they're
+ * advisory hints for the operator's mental model.
+ */
+function isVisible(
+  w: { id: string; visibleWhen?: string },
+  result: { value?: number | null; series?: Array<{ data: Array<number | null> 
}> } | undefined,
+): boolean {
+  const cond = w.visibleWhen?.trim();
+  if (!cond) return true;
+  const hasValueMatch = /^(\S+)\s+has\s+value$/i.exec(cond);
+  if (hasValueMatch && result) {
+    if (result.value !== undefined && result.value !== null) return true;
+    if (result.series && result.series.some((s) => s.data.some((v) => v !== 
null))) return true;
+    return false;
+  }
+  if (cond.startsWith('#entity.')) {
+    // Entity-attribute predicates need an attributes feed we don't
+    // surface yet (Phase 7-ish). Render the widget for now.
+    return true;
+  }
+  return true;
+}
 </script>
 
 <template>
@@ -98,13 +149,10 @@ const headerTitle = computed(() => serviceName.value ?? 
data.value?.service ?? '
     </div>
     <div v-else class="grid">
       <div
-        v-for="w in widgets"
+        v-for="w in widgets.filter((wi) => isVisible(wi, 
resultsById.get(wi.id)))"
         :key="w.id"
         class="widget sw-card"
-        :style="{
-          gridColumn: `span ${w.w}`,
-          gridRow: `span ${w.h}`,
-        }"
+        :style="gridStyle(w)"
       >
         <div class="w-head" :title="w.tip">
           <h4>{{ w.title }}</h4>
@@ -127,7 +175,7 @@ const headerTitle = computed(() => serviceName.value ?? 
data.value?.service ?? '
               v-if="resultsById.get(w.id)?.series?.length"
               :series="resultsById.get(w.id)!.series!"
               :unit="w.unit"
-              :height="Math.max(120, w.h * 14)"
+              :height="(w.rowSpan ?? 1) * 160 - 60"
             />
             <span v-else class="muted">no data</span>
           </template>
@@ -193,10 +241,15 @@ const headerTitle = computed(() => serviceName.value ?? 
data.value?.service ?? '
   font-size: 12px;
 }
 .grid {
+  /* 12-col flow grid with fixed row height. `grid-auto-flow: dense`
+   * back-fills gaps so a span-12 widget after several span-4s doesn't
+   * leave a hole. Widget heights are deliberately uniform — operators
+   * vary span (width) more than rowSpan. */
   display: grid;
-  grid-template-columns: repeat(24, 1fr);
-  grid-auto-rows: 14px;
-  gap: 10px;
+  grid-template-columns: repeat(12, minmax(0, 1fr));
+  grid-auto-rows: 180px;
+  grid-auto-flow: row dense;
+  gap: 12px;
 }
 .widget {
   display: flex;
diff --git a/packages/api-client/src/dashboard.ts 
b/packages/api-client/src/dashboard.ts
index 9f93bec..2b73727 100644
--- a/packages/api-client/src/dashboard.ts
+++ b/packages/api-client/src/dashboard.ts
@@ -31,6 +31,19 @@
 
 export type DashboardWidgetType = 'card' | 'line';
 
+/**
+ * Per-entity dashboard scope. Each layer carries an independent widget
+ * set per scope; the SPA picks the right set based on the active
+ * sub-route under `/layer/:key/`.
+ *
+ *   `service`   = the layer's primary landing (was `/dashboards`)
+ *   `instance`  = drill into a single service instance
+ *   `endpoint`  = drill into a single endpoint
+ *   `trace`     = trace explorer for the selected entity
+ *   `profiling` = flame graphs / sampled stacks
+ */
+export type DashboardScope = 'service' | 'instance' | 'endpoint' | 'trace' | 
'profiling';
+
 export interface DashboardWidget {
   /** Stable id within the layer's dashboard. */
   id: string;
@@ -43,17 +56,37 @@ export interface DashboardWidget {
   expressions: string[];
   /** Suffix unit (`%`, `ms`, `calls / min`). */
   unit?: string;
-  /** 24-column grid coordinates — operator can re-layout later. */
-  x: number;
-  y: number;
-  w: number;
-  h: number;
+  /**
+   * Column span in a 12-column flow grid. Default 4. Widgets pack via
+   * `grid-auto-flow: dense` so positions are dynamic — operators
+   * describe a widget's width once and the grid lays it out.
+   */
+  span?: number;
+  /** Row span (number of 14px rows). Default 8. */
+  rowSpan?: number;
+  /**
+   * Optional visibility predicate. When set, the widget only renders if
+   * the predicate is truthy for the active entity. Supported forms:
+   *   - `#entity.<key>`                — entity attribute exists
+   *   - `<metric_name> has value`      — at least one bucket is non-null
+   * Future-compatible; the SPA evaluates this client-side.
+   */
+  visibleWhen?: string;
+  /** Legacy 24-col grid coordinates — kept for back-compat during the
+   *  span-based flow-layout migration. New widgets should leave these
+   *  unset and use `span` / `rowSpan` instead. */
+  x?: number;
+  y?: number;
+  w?: number;
+  h?: number;
 }
 
 export interface DashboardConfig {
   /** Layer enum (UPPER_SNAKE). */
   layer: string;
-  /** Widget set. Order is irrelevant — grid coords drive placement. */
+  /** Widget set for the requested scope. */
+  scope?: DashboardScope;
+  /** Order is irrelevant — flow grid drives placement. */
   widgets: DashboardWidget[];
 }
 
diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts
index 02852a9..feeda31 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -30,6 +30,7 @@ export type { LandingAggregates, LandingResponse, 
LandingServiceRow } from './la
 export type {
   DashboardConfig,
   DashboardResponse,
+  DashboardScope,
   DashboardSeries,
   DashboardWidget,
   DashboardWidgetResult,

Reply via email to