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 1c8f90e  overview-templates admin: type-aware editor + 
add/remove/preview + metric-composite replaces k8s/pilot/service-count
1c8f90e is described below

commit 1c8f90eff729ce1c2f04c0ff12c2efcbbbfc6ea0
Author: Wu Sheng <[email protected]>
AuthorDate: Sun May 17 22:46:37 2026 +0800

    overview-templates admin: type-aware editor + add/remove/preview + 
metric-composite replaces k8s/pilot/service-count
---
 apps/bff/src/bundled_templates/overviews/mesh.json |   53 +-
 .../src/bundled_templates/overviews/services.json  |   37 +-
 apps/bff/src/http/admin/overview-templates.ts      |   83 +-
 apps/bff/src/logic/overview/loader.ts              |   46 +-
 apps/ui/src/api/scopes/overview.ts                 |   13 +
 .../overview-templates/OverviewTemplatesAdmin.vue  | 1254 ++++++++++++++++++--
 .../src/render/overview/OverviewDashboardView.vue  |   26 +-
 .../ui/src/render/overview/useOverviewDashboard.ts |  163 +--
 apps/ui/src/render/widgets/K8sSummaryWidget.vue    |  115 --
 apps/ui/src/render/widgets/KpiTileWidget.vue       |   65 +-
 .../src/render/widgets/MetricCompositeWidget.vue   |  166 +++
 apps/ui/src/render/widgets/PilotSummaryWidget.vue  |   87 --
 apps/ui/src/render/widgets/ServiceCountWidget.vue  |   45 -
 packages/api-client/src/overview.ts                |   35 +-
 14 files changed, 1644 insertions(+), 544 deletions(-)

diff --git a/apps/bff/src/bundled_templates/overviews/mesh.json 
b/apps/bff/src/bundled_templates/overviews/mesh.json
index 3c75d45..bc2c26c 100644
--- a/apps/bff/src/bundled_templates/overviews/mesh.json
+++ b/apps/bff/src/bundled_templates/overviews/mesh.json
@@ -31,11 +31,21 @@
     {
       "id": "pilot_summary",
       "title": "Istio pilot",
-      "tip": "xDS push count (cds / eds / lds / rds), reject errors, and proxy 
push time.",
-      "type": "pilot-summary",
+      "tip": "xDS push count + reject errors + the layer's service count.",
+      "type": "metric-composite",
       "layer": "MESH_CP",
       "span": 8,
-      "rowSpan": 2
+      "rowSpan": 2,
+      "kpis": [
+        { "label": "xDS pushes",      "mqe": "meter_istio_pilot_xds_pushes", 
"aggregation": "sum" },
+        { "label": "xDS connections", "mqe": "meter_istio_pilot_xds",        
"aggregation": "avg" },
+        { "label": "Services",        "source": "service-count" },
+        {
+          "label": "Pilot errors",
+          "mqe": 
"meter_istio_pilot_xds_cds_reject+meter_istio_pilot_xds_eds_reject+meter_istio_pilot_xds_lds_reject+meter_istio_pilot_xds_rds_reject+meter_istio_pilot_xds_write_timeout",
+          "aggregation": "sum"
+        }
+      ]
     },
     {
       "id": "topo_alarms_break",
@@ -72,10 +82,43 @@
       "id": "k8s_summary",
       "title": "Cluster capacity & utilisation",
       "tip": "Nodes / namespaces / workloads + cluster-wide CPU, memory, and 
disk usage.",
-      "type": "k8s-summary",
+      "type": "metric-composite",
       "layer": "K8S",
       "span": 12,
-      "rowSpan": 3
+      "rowSpan": 3,
+      "kpis": [
+        { "label": "Nodes",        "mqe": "latest(k8s_cluster_node_total)",    
    "aggregation": "avg" },
+        { "label": "Namespaces",   "mqe": 
"latest(k8s_cluster_namespace_total)",   "aggregation": "avg" },
+        { "label": "Deployments",  "mqe": 
"latest(k8s_cluster_deployment_total)",  "aggregation": "avg" },
+        { "label": "StatefulSets", "mqe": 
"latest(k8s_cluster_statefulset_total)", "aggregation": "avg" },
+        { "label": "DaemonSets",   "mqe": 
"latest(k8s_cluster_daemonset_total)",   "aggregation": "avg" },
+        { "label": "Services",     "mqe": "latest(k8s_cluster_service_total)", 
    "aggregation": "avg" },
+        { "label": "Containers",   "mqe": 
"latest(k8s_cluster_container_total)",   "aggregation": "avg" },
+        {
+          "label": "CPU",
+          "mqe": "k8s_cluster_cpu_cores_requests/k8s_cluster_cpu_cores*100",
+          "unit": "%",
+          "aggregation": "avg",
+          "style": "progress-bar",
+          "max": 100
+        },
+        {
+          "label": "Memory",
+          "mqe": "k8s_cluster_memory_requests/k8s_cluster_memory_total*100",
+          "unit": "%",
+          "aggregation": "avg",
+          "style": "progress-bar",
+          "max": 100
+        },
+        {
+          "label": "Storage",
+          "mqe": 
"(k8s_cluster_storage_total-k8s_cluster_storage_allocatable)/k8s_cluster_storage_total*100",
+          "unit": "%",
+          "aggregation": "avg",
+          "style": "progress-bar",
+          "max": 100
+        }
+      ]
     }
   ]
 }
diff --git a/apps/bff/src/bundled_templates/overviews/services.json 
b/apps/bff/src/bundled_templates/overviews/services.json
index 4c85dd7..7fcf0c0 100644
--- a/apps/bff/src/bundled_templates/overviews/services.json
+++ b/apps/bff/src/bundled_templates/overviews/services.json
@@ -130,10 +130,43 @@
       "id": "k8s_summary",
       "title": "Cluster capacity & utilisation",
       "tip": "Nodes / namespaces / workloads + cluster-wide CPU, memory, and 
disk usage.",
-      "type": "k8s-summary",
+      "type": "metric-composite",
       "layer": "K8S",
       "span": 12,
-      "rowSpan": 3
+      "rowSpan": 3,
+      "kpis": [
+        { "label": "Nodes",        "mqe": "latest(k8s_cluster_node_total)",    
    "aggregation": "avg" },
+        { "label": "Namespaces",   "mqe": 
"latest(k8s_cluster_namespace_total)",   "aggregation": "avg" },
+        { "label": "Deployments",  "mqe": 
"latest(k8s_cluster_deployment_total)",  "aggregation": "avg" },
+        { "label": "StatefulSets", "mqe": 
"latest(k8s_cluster_statefulset_total)", "aggregation": "avg" },
+        { "label": "DaemonSets",   "mqe": 
"latest(k8s_cluster_daemonset_total)",   "aggregation": "avg" },
+        { "label": "Services",     "mqe": "latest(k8s_cluster_service_total)", 
    "aggregation": "avg" },
+        { "label": "Containers",   "mqe": 
"latest(k8s_cluster_container_total)",   "aggregation": "avg" },
+        {
+          "label": "CPU",
+          "mqe": "k8s_cluster_cpu_cores_requests/k8s_cluster_cpu_cores*100",
+          "unit": "%",
+          "aggregation": "avg",
+          "style": "progress-bar",
+          "max": 100
+        },
+        {
+          "label": "Memory",
+          "mqe": "k8s_cluster_memory_requests/k8s_cluster_memory_total*100",
+          "unit": "%",
+          "aggregation": "avg",
+          "style": "progress-bar",
+          "max": 100
+        },
+        {
+          "label": "Storage",
+          "mqe": 
"(k8s_cluster_storage_total-k8s_cluster_storage_allocatable)/k8s_cluster_storage_total*100",
+          "unit": "%",
+          "aggregation": "avg",
+          "style": "progress-bar",
+          "max": 100
+        }
+      ]
     }
   ]
 }
diff --git a/apps/bff/src/http/admin/overview-templates.ts 
b/apps/bff/src/http/admin/overview-templates.ts
index a803633..f0eecf6 100644
--- a/apps/bff/src/http/admin/overview-templates.ts
+++ b/apps/bff/src/http/admin/overview-templates.ts
@@ -35,6 +35,8 @@ import type { ConfigSource } from '../../config/loader.js';
 import type { SessionStore } from '../../user/sessions.js';
 import { requireAuth } from '../../user/middleware.js';
 import {
+  createOverviewDashboard,
+  deleteOverviewDashboard,
   findOverviewFile,
   getOverviewDashboard,
   loadOverviewDashboards,
@@ -47,22 +49,41 @@ export interface OverviewTemplatesAdminDeps {
 }
 
 const WIDGET_TYPES = [
-  'service-count',
   'metric',
   'topology',
   'section-break',
   'kpi-tile',
   'alarms',
-  'k8s-summary',
-  'pilot-summary',
+  'metric-composite',
 ] as const;
 
-const kpiSchema = z.object({
-  label: z.string().min(1),
-  mqe: z.string().min(1),
-  unit: z.string().optional(),
-  aggregation: z.enum(['sum', 'avg']).optional(),
-});
+const kpiSchema = z
+  .object({
+    label: z.string().min(1),
+    /* `mqe` is required when source is 'mqe' (the default), optional
+     * when source is 'service-count'. Cross-field check below. */
+    mqe: z.string().optional(),
+    unit: z.string().optional(),
+    aggregation: z.enum(['sum', 'avg']).optional(),
+    style: z.enum(['number', 'progress-bar']).optional(),
+    /* `max` is only meaningful when style is `progress-bar` — but
+     * we accept it for any style and let the renderer ignore it
+     * otherwise. Validating that pairing here would add false
+     * positives during in-flight edits. */
+    max: z.number().positive().optional(),
+    source: z.enum(['mqe', 'service-count']).optional(),
+  })
+  .refine(
+    (k) => {
+      const src = k.source ?? 'mqe';
+      if (src === 'mqe') return typeof k.mqe === 'string' && k.mqe.length > 0;
+      return true;
+    },
+    { message: 'mqe is required when source is "mqe"' },
+  )
+  .refine((k) => k.style !== 'progress-bar' || (typeof k.max === 'number' && 
k.max > 0), {
+    message: 'progress-bar style requires `max > 0`',
+  });
 
 const widgetSchema = z.object({
   id: z.string().min(1),
@@ -161,4 +182,48 @@ export function registerOverviewTemplatesAdminRoutes(
       return reply.send({ ok: true, id });
     },
   );
+
+  /* POST /api/admin/overview-templates — create a brand-new
+   * dashboard. The body is a full OverviewDashboard JSON; the id is
+   * pulled from the body (matching the editor flow where the
+   * operator types the id once at creation time). */
+  app.post(
+    '/api/admin/overview-templates',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const parsed = dashboardSchema.safeParse(req.body);
+      if (!parsed.success) {
+        return reply.code(400).send({ error: 'invalid_body', detail: 
parsed.error.flatten() });
+      }
+      try {
+        createOverviewDashboard(parsed.data as OverviewDashboard);
+      } catch (err) {
+        return reply.code(409).send({
+          error: 'create_failed',
+          message: err instanceof Error ? err.message : String(err),
+        });
+      }
+      return reply.send({ ok: true, id: parsed.data.id });
+    },
+  );
+
+  /* DELETE /api/admin/overview-templates/:id — remove the file.
+   * No-op when the file doesn't exist; the cache is invalidated
+   * either way so the list reflects the removal. */
+  app.delete(
+    '/api/admin/overview-templates/:id',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const { id } = req.params as { id: string };
+      try {
+        deleteOverviewDashboard(id);
+      } catch (err) {
+        return reply.code(500).send({
+          error: 'delete_failed',
+          message: err instanceof Error ? err.message : String(err),
+        });
+      }
+      return reply.send({ ok: true, id });
+    },
+  );
 }
diff --git a/apps/bff/src/logic/overview/loader.ts 
b/apps/bff/src/logic/overview/loader.ts
index e9ec1b6..73c4b20 100644
--- a/apps/bff/src/logic/overview/loader.ts
+++ b/apps/bff/src/logic/overview/loader.ts
@@ -62,14 +62,12 @@ function isString(v: unknown): v is string {
 }
 
 const WIDGET_TYPES: ReadonlySet<OverviewWidgetType> = new Set([
-  'service-count',
   'metric',
   'topology',
   'section-break',
   'kpi-tile',
   'alarms',
-  'k8s-summary',
-  'pilot-summary',
+  'metric-composite',
 ]);
 // `layer` is required for data-bound widgets; layout-only / aggregate
 // widgets resolve their data without an explicit layer binding.
@@ -84,13 +82,25 @@ function parseKpis(raw: unknown): OverviewKpi[] | undefined 
{
   for (const k of raw) {
     if (!k || typeof k !== 'object') continue;
     const rec = k as Record<string, unknown>;
-    if (!isString(rec.label) || !isString(rec.mqe)) continue;
+    if (!isString(rec.label)) continue;
+    const source = rec.source === 'service-count' ? 'service-count' : 'mqe';
+    /* MQE source requires a non-empty `mqe`; service-count source
+     * doesn't need one (the layer's listServices count is the value). */
+    if (source === 'mqe' && !isString(rec.mqe)) continue;
+    const style = rec.style === 'progress-bar' ? 'progress-bar' : rec.style 
=== 'number' ? 'number' : undefined;
+    const max =
+      style === 'progress-bar' && typeof rec.max === 'number' && 
Number.isFinite(rec.max) && rec.max > 0
+        ? rec.max
+        : undefined;
     out.push({
       label: rec.label,
-      mqe: rec.mqe,
+      mqe: isString(rec.mqe) ? rec.mqe : undefined,
       unit: isString(rec.unit) ? rec.unit : undefined,
       aggregation:
         rec.aggregation === 'sum' ? 'sum' : rec.aggregation === 'avg' ? 'avg' 
: undefined,
+      style,
+      max,
+      source,
     });
   }
   return out.length > 0 ? out : undefined;
@@ -229,3 +239,29 @@ export function writeOverviewDashboard(id: string, dash: 
OverviewDashboard): voi
   fs.renameSync(tmp, file);
   invalidateOverviewCache();
 }
+
+/** Create a new dashboard JSON in the bundled dir. Fails when an
+ *  existing file already declares the same id (the admin should
+ *  surface this as "id already in use" rather than overwriting). */
+export function createOverviewDashboard(dash: OverviewDashboard): void {
+  if (findOverviewFile(dash.id)) {
+    throw new Error(`overview/${dash.id}: dashboard already exists`);
+  }
+  if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true 
});
+  /* Filename = `<id>.json` so the on-disk layout matches the id
+   * exactly. Sanitise to guard against accidental path traversal —
+   * loader id-equality is what really binds the file to the
+   * dashboard, but the operator's also editing on disk later. */
+  const safe = dash.id.replace(/[^A-Za-z0-9_\-]/g, '_');
+  const file = path.join(CONFIG_DIR, `${safe}.json`);
+  fs.writeFileSync(file, JSON.stringify(dash, null, 2), 'utf8');
+  invalidateOverviewCache();
+}
+
+/** Remove the JSON file backing `id`. No-op when there's no file. */
+export function deleteOverviewDashboard(id: string): void {
+  const file = findOverviewFile(id);
+  if (!file) return;
+  fs.unlinkSync(file);
+  invalidateOverviewCache();
+}
diff --git a/apps/ui/src/api/scopes/overview.ts 
b/apps/ui/src/api/scopes/overview.ts
index 2c741a9..cf60b46 100644
--- a/apps/ui/src/api/scopes/overview.ts
+++ b/apps/ui/src/api/scopes/overview.ts
@@ -71,4 +71,17 @@ export class OverviewApi {
       body,
     );
   }
+  adminCreate(body: OverviewDashboard): Promise<{ ok: true; id: string }> {
+    return this.bff.request<{ ok: true; id: string }>(
+      'POST',
+      '/api/admin/overview-templates',
+      body,
+    );
+  }
+  adminDelete(id: string): Promise<{ ok: true; id: string }> {
+    return this.bff.request<{ ok: true; id: string }>(
+      'DELETE',
+      `/api/admin/overview-templates/${encodeURIComponent(id)}`,
+    );
+  }
 }
diff --git 
a/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue 
b/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue
index 3a742bf..a5c2f71 100644
--- a/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue
+++ b/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue
@@ -15,17 +15,34 @@
   limitations under the License.
 -->
 <!--
-  Admin: overview-dashboard templates. Lists every bundled dashboard,
-  lets the operator edit per-widget fields that don't need code
-  changes — layer / limit / title / tip / span / rowSpan. Type,
-  MQE, KPIs, and the widget set stay frozen at the bundled value;
-  changing those is still a code edit (the bundled JSON is the
-  source of truth, this admin is an in-place tweak layer).
+  Admin: overview-dashboard templates. List on the left, per-widget
+  editor on the right. The editor is TYPE-AWARE — each widget kind
+  exposes only the fields it actually consumes, so the operator
+  isn't asked to set a `limit` on a topology card or an MQE on a
+  metric-composite card.
+
+  Editable per type:
+   - section-break  : title, cols
+   - metric         : layer, title, tip, mqe, unit, aggregation, span/rowSpan
+   - topology       : layer, title, tip, span/rowSpan
+   - alarms         : layer, title, tip, limit, span/rowSpan
+   - kpi-tile       : layer, title, tip, showCount, KPI rows (add/remove +
+                      label/MQE/unit/aggregation/style/max), span/rowSpan
+   - metric-composite : layer, title, tip, KPI rows (mixed MQE +
+                        service-count source, number / progress-bar
+                        style), span/rowSpan
+
+  Widget type / id / order stay frozen — those are code-shape
+  decisions, not config tweaks.
 -->
 <script setup lang="ts">
 import { computed, ref, watch } from 'vue';
 import { useQuery } from '@tanstack/vue-query';
-import type { OverviewDashboard, OverviewWidget } from 
'@skywalking-horizon-ui/api-client';
+import type {
+  OverviewDashboard,
+  OverviewKpi,
+  OverviewWidget,
+} from '@skywalking-horizon-ui/api-client';
 import { bff } from '@/api/client';
 import { useLayers } from '@/shell/useLayers';
 
@@ -37,13 +54,197 @@ const listQuery = useQuery({
 const dashboards = computed(() => listQuery.data.value?.dashboards ?? []);
 
 const selectedId = ref<string>('');
+/* Auto-select rule: if nothing is selected (cold start), pick the
+ * first dashboard. If the currently-selected id disappears from the
+ * list (just deleted by the operator), fall back to the new first. */
 watch(
   () => dashboards.value,
   (list) => {
-    if (!selectedId.value && list.length > 0) selectedId.value = list[0]!.id;
+    if (list.length === 0) {
+      selectedId.value = '';
+      return;
+    }
+    const stillExists = list.some((d) => d.id === selectedId.value);
+    if (!selectedId.value || !stillExists) {
+      selectedId.value = list[0]!.id;
+    }
   },
+  { immediate: true },
 );
 
+// ── New-dashboard composer ─────────────────────────────────────────
+const newDashOpen = ref(false);
+const newDashId = ref('');
+const newDashTitle = ref('');
+const newDashError = ref<string | null>(null);
+
+function openNewDash(): void {
+  newDashOpen.value = true;
+  newDashId.value = '';
+  newDashTitle.value = '';
+  newDashError.value = null;
+}
+function cancelNewDash(): void {
+  newDashOpen.value = false;
+  newDashError.value = null;
+}
+async function createDash(): Promise<void> {
+  const id = newDashId.value.trim();
+  const title = newDashTitle.value.trim();
+  if (!id) {
+    newDashError.value = 'id is required';
+    return;
+  }
+  if (!/^[a-z0-9_-]+$/i.test(id)) {
+    newDashError.value = 'id may only contain letters, digits, _ and -';
+    return;
+  }
+  if (!title) {
+    newDashError.value = 'title is required';
+    return;
+  }
+  if (dashboards.value.some((d) => d.id === id)) {
+    newDashError.value = `dashboard "${id}" already exists`;
+    return;
+  }
+  try {
+    await bff.overview.adminCreate({ id, title, widgets: [] });
+    await listQuery.refetch();
+    selectedId.value = id;
+    newDashOpen.value = false;
+    setFlash(`created · ${id}`);
+  } catch (err) {
+    newDashError.value = err instanceof Error ? err.message : 'create failed';
+  }
+}
+
+// ── Preview pane ──────────────────────────────────────────────────
+/* Lightweight layout preview — recreates the dashboard's grid + a
+ * summary card per widget (kind / title / KPI labels) so the
+ * operator can see "is the layout right?" without saving + tab-
+ * switching to the actual overview. Mock values fill anywhere the
+ * widget would normally show a number (random 0..999 for counts,
+ * 0..100 for percent / progress-bar). Not pixel-fidelity vs the real
+ * widget components — that would require running the orchestrator
+ * against mock OAP data — but enough to validate placement + scope. */
+/* Detail-pane view mode — `config` (the per-widget editor) or
+ * `preview` (the layout + mock-data sanity board). Tabs in the
+ * detail head switch between them. Defaults to config so an
+ * operator who lands on the admin sees the editor immediately. */
+const viewMode = ref<'config' | 'preview'>('config');
+
+interface PreviewSection {
+  cols: number;
+  widgets: typeof draft.value extends infer T
+    ? T extends { widgets: infer W }
+      ? W
+      : never
+    : never;
+}
+const previewSections = computed<PreviewSection[]>(() => {
+  if (!draft.value) return [];
+  const out: PreviewSection[] = [];
+  let cur: PreviewSection | null = null;
+  for (const w of draft.value.widgets) {
+    if (w.type === 'section-break') {
+      cur = { cols: w.cols ?? 12, widgets: [] as never };
+      out.push(cur);
+      continue;
+    }
+    if (!cur) {
+      cur = { cols: 12, widgets: [] as never };
+      out.push(cur);
+    }
+    (cur.widgets as unknown as OverviewWidget[]).push(w);
+  }
+  return out;
+});
+
+/* Cheap deterministic mock values — keyed by widget id + KPI label
+ * so re-renders return the same number (no jitter while editing).
+ * Hash-based so different ids look different but each is stable. */
+function hash(s: string): number {
+  let h = 0;
+  for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
+  return h;
+}
+function mockNumber(seed: string, max = 999): number {
+  return hash(seed) % max;
+}
+function mockPercent(seed: string): number {
+  return 10 + (hash(seed) % 85);
+}
+/** Mock alarm rows for the preview's alarms widget. Deterministic
+ *  per widget id so the rows don't churn while editing. */
+const MOCK_ALARM_MSGS = [
+  'Response time of service mesh-svr::cart is more than 20ms.',
+  'JVM old-gen GC > 5s/min on agent::orders',
+  'p95 SLA below threshold for service mesh-svr::reviews',
+];
+const MOCK_ALARM_SCOPES = ['Service · mesh-svr::cart', 'Instance · pod-2 of 
agent::orders', 'Service · reviews'];
+const MOCK_ALARM_AGES = ['2m', '14m', '47m'];
+function mockAlarms(seed: string, n: number): Array<{
+  key: string;
+  firing: boolean;
+  msg: string;
+  scope: string;
+  since: string;
+}> {
+  const out: Array<{ key: string; firing: boolean; msg: string; scope: string; 
since: string }> = [];
+  const cap = Math.max(0, Math.min(n, MOCK_ALARM_MSGS.length));
+  for (let i = 0; i < cap; i++) {
+    out.push({
+      key: `${seed}::${i}`,
+      firing: (hash(seed + i) % 4) !== 0, // ~3/4 firing
+      msg: MOCK_ALARM_MSGS[i]!,
+      scope: MOCK_ALARM_SCOPES[i]!,
+      since: MOCK_ALARM_AGES[i]!,
+    });
+  }
+  return out;
+}
+
+/** Split a kpi-tile / metric-composite widget's rows into the two
+ *  visual partitions the real renderer uses: number tiles (count
+ *  grid) vs progress-bar rows. Same rule the runtime uses —
+ *  `style === 'progress-bar'` OR `unit === '%'` go to bars. */
+function kpiCountRows(w: OverviewWidget): OverviewKpi[] {
+  return (w.kpis ?? []).filter((k) => k.style !== 'progress-bar' && k.unit !== 
'%');
+}
+function kpiBarRows(w: OverviewWidget): OverviewKpi[] {
+  return (w.kpis ?? []).filter((k) => k.style === 'progress-bar' || k.unit === 
'%');
+}
+
+function previewSectionTitle(idx: number): string {
+  if (!draft.value) return '';
+  /* Walk the original widget array up to the idx-th section-break
+   * to recover its title (the preview groups widgets under each
+   * break but only stores cols + widget rows in the section). */
+  let n = 0;
+  for (const w of draft.value.widgets) {
+    if (w.type !== 'section-break') continue;
+    if (n === idx) return w.title;
+    n += 1;
+  }
+  return '';
+}
+
+// ── Delete current dashboard ───────────────────────────────────────
+async function deleteCurrentDash(): Promise<void> {
+  const id = selectedId.value;
+  if (!id) return;
+  if (!window.confirm(`Delete dashboard "${id}"? This removes the JSON file 
from disk.`)) {
+    return;
+  }
+  try {
+    await bff.overview.adminDelete(id);
+    await listQuery.refetch();
+    setFlash(`deleted · ${id}`);
+  } catch (err) {
+    setFlash(err instanceof Error ? `error: ${err.message}` : 'delete failed');
+  }
+}
+
 const detailQuery = useQuery({
   queryKey: computed(() => ['admin/overview-templates', selectedId.value]),
   queryFn: () => bff.overview.adminGet(selectedId.value),
@@ -52,14 +253,9 @@ const detailQuery = useQuery({
 });
 
 const { availableLayers } = useLayers();
-/** Layer dropdown options. Starts from the layers the connected OAP
- *  is actively reporting on (so the operator sees real data sources
- *  first) but ALSO includes any layer key already referenced by a
- *  widget in the current draft — even if that layer has no live
- *  data. Without this fallback, configuring a widget for a layer
- *  that's "configured but quiet" (e.g. VIRTUAL_GENAI on a deployment
- *  with no AI traffic) would silently blank the dropdown and lose
- *  the operator's prior choice. */
+/** Union of live layers + any layer already referenced by the draft
+ *  so a "configured but quiet" layer (VIRTUAL_GENAI on a deployment
+ *  with no AI traffic, etc.) stays selectable. */
 const layerOptions = computed<string[]>(() => {
   const live = new Set((availableLayers.value ?? []).map((l) => 
l.key.toUpperCase()));
   for (const w of draft.value?.widgets ?? []) {
@@ -68,13 +264,15 @@ const layerOptions = computed<string[]>(() => {
   return Array.from(live).sort();
 });
 
-/* Local working copy — deep-cloned so edits don't mutate the
- * react-query cache. Reset on dashboard switch + on save. */
 const draft = ref<OverviewDashboard | null>(null);
 watch(
   () => detailQuery.data.value,
   (resp) => {
-    draft.value = resp ? (JSON.parse(JSON.stringify(resp.dashboard)) as 
OverviewDashboard) : null;
+    if (!resp) {
+      draft.value = null;
+      return;
+    }
+    draft.value = JSON.parse(JSON.stringify(resp.dashboard)) as 
OverviewDashboard;
   },
 );
 
@@ -113,16 +311,135 @@ function onReset(): void {
   if (orig) draft.value = JSON.parse(JSON.stringify(orig)) as 
OverviewDashboard;
 }
 
-/** Which widget fields are editable per-type. Type / MQE / KPIs stay
- *  frozen — they're code-shape decisions, not config tweaks. */
-function showsLayer(w: OverviewWidget): boolean {
-  return w.type !== 'section-break' && w.type !== 'alarms' ? true : w.type === 
'alarms';
+// ── KPI row helpers (kpi-tile only) ────────────────────────────────
+function addKpi(w: OverviewWidget): void {
+  const next = [...(w.kpis ?? []), { label: 'new KPI', mqe: '' } as 
OverviewKpi];
+  w.kpis = next;
+}
+function removeKpi(w: OverviewWidget, idx: number): void {
+  if (!w.kpis) return;
+  w.kpis = w.kpis.filter((_, i) => i !== idx);
+}
+function moveKpi(w: OverviewWidget, idx: number, dir: -1 | 1): void {
+  if (!w.kpis) return;
+  const next = [...w.kpis];
+  const j = idx + dir;
+  if (j < 0 || j >= next.length) return;
+  [next[idx], next[j]] = [next[j], next[idx]];
+  w.kpis = next;
+}
+/* Switching a KPI to progress-bar makes `max` mandatory — seed a
+ * sensible default if it isn't set so the BFF doesn't 400 on save. */
+function onKpiStyleChange(k: OverviewKpi): void {
+  if (k.style === 'progress-bar' && (k.max === undefined || k.max === null)) {
+    k.max = 100;
+  }
+}
+
+// ── Widget reorder + delete ────────────────────────────────────────
+function moveWidget(idx: number, dir: -1 | 1): void {
+  if (!draft.value) return;
+  const next = [...draft.value.widgets];
+  const j = idx + dir;
+  if (j < 0 || j >= next.length) return;
+  [next[idx], next[j]] = [next[j], next[idx]];
+  draft.value = { ...draft.value, widgets: next };
+}
+function removeWidget(idx: number): void {
+  if (!draft.value) return;
+  const next = draft.value.widgets.filter((_, i) => i !== idx);
+  draft.value = { ...draft.value, widgets: next };
+}
+
+// ── Add-widget composer ────────────────────────────────────────────
+/* Small inline form that gates a new widget on (type, width, height)
+ * — the operator picks those first per the spec, then the widget
+ * is appended with sensible defaults and the existing per-widget
+ * editor handles everything else. */
+const composerOpen = ref(false);
+const composerType = ref<OverviewWidget['type']>('metric');
+const composerSpan = ref<number>(3);
+const composerRowSpan = ref<number>(1);
+
+function openComposer(): void {
+  composerOpen.value = true;
+  /* Default sizing matches the most common shape per type so the
+   * operator doesn't have to remember "topology wants 9x6" etc. */
+  composerType.value = 'metric';
+  composerSpan.value = 3;
+  composerRowSpan.value = 1;
+}
+function cancelComposer(): void {
+  composerOpen.value = false;
+}
+
+/** Auto-id: `<type>_<n>` where n increments to avoid collisions with
+ *  any existing widget id in the dashboard. */
+function nextWidgetId(type: string): string {
+  const existing = new Set(draft.value?.widgets.map((w) => w.id) ?? []);
+  let n = 1;
+  while (existing.has(`${type}_${n}`)) n++;
+  return `${type}_${n}`;
+}
+
+/** Per-type seed values — title + any required type-specific fields
+ *  so the new widget renders something sensible immediately and the
+ *  save round-trip passes the BFF's zod schema. */
+function widgetDefaults(type: OverviewWidget['type']): Partial<OverviewWidget> 
{
+  switch (type) {
+    case 'section-break':
+      return { title: 'New section', cols: 12 };
+    case 'metric':
+      return { title: 'New metric', mqe: '', aggregation: 'avg' };
+    case 'topology':
+      return { title: 'Topology' };
+    case 'alarms':
+      return { title: 'Active alarms', limit: 10 };
+    case 'kpi-tile':
+      return { title: 'KPI tile', kpis: [], showCount: false };
+    case 'metric-composite':
+      return { title: 'Composite metrics', kpis: [] };
+    default:
+      return { title: 'New widget' };
+  }
 }
-function showsLimit(w: OverviewWidget): boolean {
-  return w.type === 'alarms';
+
+function createWidget(): void {
+  if (!draft.value) return;
+  const type = composerType.value;
+  const defaults = widgetDefaults(type);
+  const widget: OverviewWidget = {
+    id: nextWidgetId(type),
+    title: defaults.title ?? 'New widget',
+    type,
+    span: type === 'section-break' ? undefined : composerSpan.value,
+    rowSpan: type === 'section-break' ? undefined : composerRowSpan.value,
+    ...defaults,
+  };
+  draft.value = { ...draft.value, widgets: [...draft.value.widgets, widget] };
+  composerOpen.value = false;
 }
-function showsLayout(w: OverviewWidget): boolean {
-  return w.type !== 'section-break';
+
+// ── Widget meta ────────────────────────────────────────────────────
+const WIDGET_TYPE_OPTIONS: ReadonlyArray<{ type: OverviewWidget['type']; 
label: string }> = [
+  { type: 'section-break', label: 'Section break' },
+  { type: 'metric', label: 'Metric' },
+  { type: 'topology', label: 'Topology' },
+  { type: 'alarms', label: 'Alarms' },
+  { type: 'kpi-tile', label: 'KPI tile (number + metrics)' },
+  { type: 'metric-composite', label: 'Composite metrics (mixed)' },
+];
+
+function widgetKindLabel(type: OverviewWidget['type']): string {
+  switch (type) {
+    case 'metric-composite': return 'Composite metrics';
+    case 'section-break': return 'Section break';
+    case 'metric': return 'Metric';
+    case 'topology': return 'Topology';
+    case 'alarms': return 'Alarms';
+    case 'kpi-tile': return 'KPI tile';
+    default: return type;
+  }
 }
 </script>
 
@@ -133,10 +450,11 @@ function showsLayout(w: OverviewWidget): boolean {
         <div class="ot__kicker">Dashboard setup · Overviews</div>
         <h1>Overview templates</h1>
         <p class="ot__lede">
-          Per-widget tweaks for the bundled overview dashboards. Edits write 
back to
-          <code>bundled_templates/overviews/*.json</code> and invalidate the 
BFF cache
-          so the next dashboard fetch picks them up. Widget type / MQE / KPI 
definitions
-          stay code-managed — only layout + scope fields are editable here.
+          Per-widget editor for the bundled overview dashboards. Each widget 
kind shows only
+          the fields it consumes — e.g. <code>kpi-tile</code> exposes its KPI 
row list with
+          number / progress-bar style; <code>alarms</code> exposes the row 
limit. Type and
+          widget set are code-shape decisions and stay frozen; edits write 
back to
+          <code>bundled_templates/overviews/*.json</code> and refresh the BFF 
cache.
         </p>
       </div>
     </header>
@@ -160,6 +478,41 @@ function showsLayout(w: OverviewWidget): boolean {
           </div>
         </li>
         <li v-if="dashboards.length === 0" class="ot__list-empty">No overview 
templates loaded.</li>
+        <li class="ot__list-add">
+          <button
+            v-if="!newDashOpen"
+            type="button"
+            class="ot__add-trigger ot__add-trigger--list"
+            @click="openNewDash"
+          >+ New dashboard</button>
+          <div v-else class="ot__newdash">
+            <label class="ot__field">
+              <span>Id</span>
+              <input
+                v-model="newDashId"
+                type="text"
+                class="ot__in"
+                placeholder="my-overview"
+                @keyup.enter="createDash"
+              />
+            </label>
+            <label class="ot__field">
+              <span>Title</span>
+              <input
+                v-model="newDashTitle"
+                type="text"
+                class="ot__in"
+                placeholder="My overview"
+                @keyup.enter="createDash"
+              />
+            </label>
+            <div v-if="newDashError" class="ot__newdash-err">{{ newDashError 
}}</div>
+            <div class="ot__newdash-foot">
+              <button type="button" class="ot__btn" 
@click="cancelNewDash">cancel</button>
+              <button type="button" class="ot__btn ot__btn--primary" 
@click="createDash">create</button>
+            </div>
+          </div>
+        </li>
       </ul>
 
       <section class="ot__detail">
@@ -167,73 +520,430 @@ function showsLayout(w: OverviewWidget): boolean {
         <template v-else-if="draft">
           <header class="ot__detail-head">
             <h2><code>{{ draft.id }}</code></h2>
-            <span class="ot__count mono">{{ draft.widgets.length }} widget{{ 
draft.widgets.length === 1 ? '' : 's' }}</span>
+            <span class="ot__count mono">
+              {{ draft.widgets.length }} widget{{ draft.widgets.length === 1 ? 
'' : 's' }}
+            </span>
+            <!-- Tab strip — Config (editor) / Preview (mock board). -->
+            <div class="ot__tabs" role="tablist">
+              <button
+                type="button"
+                role="tab"
+                class="ot__tab"
+                :class="{ active: viewMode === 'config' }"
+                :aria-selected="viewMode === 'config'"
+                @click="viewMode = 'config'"
+              >Config</button>
+              <button
+                type="button"
+                role="tab"
+                class="ot__tab"
+                :class="{ active: viewMode === 'preview' }"
+                :aria-selected="viewMode === 'preview'"
+                @click="viewMode = 'preview'"
+              >Preview</button>
+            </div>
+            <button
+              type="button"
+              class="ot__head-btn ot__head-btn--danger"
+              :title="`Delete dashboard ${draft.id}`"
+              @click="deleteCurrentDash"
+            >delete</button>
           </header>
 
-          <table class="ot__table">
-            <thead>
-              <tr>
-                <th>Widget</th>
-                <th>Type</th>
-                <th>Layer</th>
-                <th>Limit</th>
-                <th>Span / Row</th>
-              </tr>
-            </thead>
-            <tbody>
-              <tr v-for="(w, i) in draft.widgets" :key="w.id">
-                <td>
-                  <input v-model="w.title" type="text" class="ot__in" 
:placeholder="w.id" />
-                  <input v-model="w.tip" type="text" class="ot__in 
ot__in--tip" placeholder="tip (optional)" />
-                  <code class="ot__wid-id">{{ w.id }}</code>
-                </td>
-                <td><span class="ot__type">{{ w.type }}</span></td>
-                <td>
-                  <select
-                    v-if="showsLayer(w)"
-                    v-model="w.layer"
-                    class="ot__in ot__in--narrow"
-                  >
+          <div v-if="viewMode === 'config'" class="ot__widgets">
+            <article
+              v-for="(w, wi) in draft.widgets"
+              :key="w.id"
+              class="ot__widget"
+              :class="`ot__widget--${w.type}`"
+            >
+              <!-- Header: type kicker + widget id + move/delete row actions. 
-->
+              <header class="ot__widget-head">
+                <span class="ot__widget-kind">{{ widgetKindLabel(w.type) 
}}</span>
+                <code class="ot__widget-id">{{ w.id }}</code>
+                <span class="ot__widget-actions">
+                  <button
+                    type="button"
+                    class="ot__arrow"
+                    :disabled="wi === 0"
+                    title="Move up"
+                    @click="moveWidget(wi, -1)"
+                  >‹</button>
+                  <button
+                    type="button"
+                    class="ot__arrow"
+                    :disabled="wi === draft.widgets.length - 1"
+                    title="Move down"
+                    @click="moveWidget(wi, 1)"
+                  >›</button>
+                  <button
+                    type="button"
+                    class="ot__del"
+                    title="Remove widget"
+                    @click="removeWidget(wi)"
+                  >×</button>
+                </span>
+              </header>
+
+              <!-- 1. Size (width / height) first per the spec — layout
+                   decisions before content. section-break has no
+                   per-cell span (it spans the whole row), so we show
+                   the grid `cols` field there instead. -->
+              <div class="ot__row">
+                <label v-if="w.type === 'section-break'" class="ot__field">
+                  <span>Columns (grid)</span>
+                  <input v-model.number="w.cols" type="number" min="1" 
max="12" class="ot__in ot__in--num" />
+                </label>
+                <template v-else>
+                  <label class="ot__field">
+                    <span>Width (span)</span>
+                    <input v-model.number="w.span" type="number" min="1" 
max="12" class="ot__in ot__in--num" />
+                  </label>
+                  <label class="ot__field">
+                    <span>Height (rows)</span>
+                    <input v-model.number="w.rowSpan" type="number" min="1" 
max="12" class="ot__in ot__in--num" />
+                  </label>
+                </template>
+              </div>
+
+              <!-- 2. Title + tip. -->
+              <div class="ot__row">
+                <label class="ot__field ot__field--wide">
+                  <span>Title</span>
+                  <input v-model="w.title" type="text" class="ot__in" />
+                </label>
+                <label v-if="w.type !== 'section-break'" class="ot__field 
ot__field--wide">
+                  <span>Tip</span>
+                  <input v-model="w.tip" type="text" class="ot__in" 
placeholder="(optional)" />
+                </label>
+              </div>
+
+              <!-- 3. Layer — universal for data widgets. -->
+              <div v-if="w.type !== 'section-break'" class="ot__row">
+                <label class="ot__field">
+                  <span>Layer</span>
+                  <select v-model="w.layer" class="ot__in ot__in--narrow">
                     <option :value="undefined">— any —</option>
                     <option v-for="k in layerOptions" :key="k" :value="k">{{ k 
}}</option>
                   </select>
-                  <span v-else class="ot__none">—</span>
-                </td>
-                <td>
-                  <input
-                    v-if="showsLimit(w)"
-                    v-model.number="w.limit"
-                    type="number"
-                    min="1"
-                    max="100"
-                    class="ot__in ot__in--num"
-                  />
-                  <span v-else class="ot__none">—</span>
-                </td>
-                <td>
-                  <template v-if="showsLayout(w)">
-                    <input
-                      v-model.number="w.span"
-                      type="number"
-                      min="1"
-                      max="12"
-                      class="ot__in ot__in--xnum"
-                      :title="`Widget ${i + 1} grid span (1..12)`"
-                    />
-                    <input
-                      v-model.number="w.rowSpan"
-                      type="number"
-                      min="1"
-                      max="12"
-                      class="ot__in ot__in--xnum"
-                      :title="`Widget ${i + 1} row span (1..12)`"
-                    />
+                </label>
+              </div>
+
+              <!-- metric: mqe + unit + aggregation -->
+              <div v-if="w.type === 'metric'" class="ot__row">
+                <label class="ot__field ot__field--wide">
+                  <span>MQE</span>
+                  <input v-model="w.mqe" type="text" class="ot__in 
ot__in--mono" placeholder="service_cpm" />
+                </label>
+                <label class="ot__field">
+                  <span>Unit</span>
+                  <input v-model="w.unit" type="text" class="ot__in 
ot__in--narrow" placeholder="rpm / ms / %" />
+                </label>
+                <label class="ot__field">
+                  <span>Aggregation</span>
+                  <select v-model="w.aggregation" class="ot__in 
ot__in--narrow">
+                    <option :value="undefined">—</option>
+                    <option value="avg">avg</option>
+                    <option value="sum">sum</option>
+                  </select>
+                </label>
+              </div>
+
+              <!-- alarms: limit -->
+              <div v-if="w.type === 'alarms'" class="ot__row">
+                <label class="ot__field">
+                  <span>Row limit</span>
+                  <input v-model.number="w.limit" type="number" min="1" 
max="100" class="ot__in ot__in--num" />
+                </label>
+              </div>
+
+              <!-- kpi-tile / metric-composite: KPI rows. -->
+              <template v-if="w.type === 'kpi-tile' || w.type === 
'metric-composite'">
+                <div v-if="w.type === 'kpi-tile'" class="ot__row">
+                  <label class="ot__field">
+                    <span>Show service count</span>
+                    <input type="checkbox" v-model="w.showCount" />
+                  </label>
+                </div>
+                <div class="ot__kpis">
+                  <div class="ot__kpis-head">
+                    <span>KPI rows ({{ (w.kpis ?? []).length }})</span>
+                    <button type="button" class="ot__add-btn" 
@click="addKpi(w)">+ add row</button>
+                  </div>
+                  <div v-if="(w.kpis ?? []).length === 0" 
class="ot__kpis-empty">
+                    No KPI rows. Add one to surface a metric on the tile.
+                  </div>
+                  <table v-else class="ot__kpi-table">
+                    <thead>
+                      <tr>
+                        <th></th>
+                        <th>Label</th>
+                        <th>Source</th>
+                        <th>MQE</th>
+                        <th>Unit</th>
+                        <th>Aggr</th>
+                        <th>Style</th>
+                        <th>Max</th>
+                        <th></th>
+                      </tr>
+                    </thead>
+                    <tbody>
+                      <tr v-for="(k, i) in w.kpis ?? []" :key="i">
+                        <td>
+                          <button
+                            type="button"
+                            class="ot__arrow"
+                            :disabled="i === 0"
+                            @click="moveKpi(w, i, -1)"
+                          >‹</button>
+                          <button
+                            type="button"
+                            class="ot__arrow"
+                            :disabled="i === (w.kpis ?? []).length - 1"
+                            @click="moveKpi(w, i, 1)"
+                          >›</button>
+                        </td>
+                        <td><input v-model="k.label" type="text" 
class="ot__in" /></td>
+                        <td>
+                          <select v-model="k.source" class="ot__in 
ot__in--narrow">
+                            <option :value="undefined">mqe</option>
+                            <option value="mqe">mqe</option>
+                            <option 
value="service-count">service-count</option>
+                          </select>
+                        </td>
+                        <td>
+                          <input
+                            v-if="(k.source ?? 'mqe') === 'mqe'"
+                            v-model="k.mqe"
+                            type="text"
+                            class="ot__in ot__in--mono"
+                          />
+                          <span v-else class="ot__none">— (listServices)</span>
+                        </td>
+                        <td><input v-model="k.unit" type="text" class="ot__in 
ot__in--xnarrow" /></td>
+                        <td>
+                          <select v-model="k.aggregation" class="ot__in 
ot__in--xnarrow">
+                            <option :value="undefined">—</option>
+                            <option value="avg">avg</option>
+                            <option value="sum">sum</option>
+                          </select>
+                        </td>
+                        <td>
+                          <select
+                            v-model="k.style"
+                            class="ot__in ot__in--narrow"
+                            @change="onKpiStyleChange(k)"
+                          >
+                            <option :value="undefined">number</option>
+                            <option value="number">number</option>
+                            <option value="progress-bar">progress-bar</option>
+                          </select>
+                        </td>
+                        <td>
+                          <input
+                            v-if="k.style === 'progress-bar'"
+                            v-model.number="k.max"
+                            type="number"
+                            min="0"
+                            step="any"
+                            class="ot__in ot__in--xnum"
+                            placeholder="100"
+                          />
+                          <span v-else class="ot__none">—</span>
+                        </td>
+                        <td>
+                          <button type="button" class="ot__del" 
@click="removeKpi(w, i)">×</button>
+                        </td>
+                      </tr>
+                    </tbody>
+                  </table>
+                </div>
+              </template>
+            </article>
+
+            <!-- Add-widget composer. Operator picks type + size FIRST
+                 (per the spec); on Create the widget is appended with
+                 sensible defaults and the inline editor handles the
+                 rest (title / layer / MQE / KPI rows / etc.). -->
+            <div class="ot__add-widget">
+              <button
+                v-if="!composerOpen"
+                type="button"
+                class="ot__add-trigger"
+                @click="openComposer"
+              >
+                + Add widget
+              </button>
+              <article v-else class="ot__widget ot__composer">
+                <header class="ot__widget-head">
+                  <span class="ot__widget-kind">New widget</span>
+                </header>
+                <div class="ot__row">
+                  <label class="ot__field">
+                    <span>Type</span>
+                    <select v-model="composerType" class="ot__in 
ot__in--narrow">
+                      <option v-for="opt in WIDGET_TYPE_OPTIONS" 
:key="opt.type" :value="opt.type">
+                        {{ opt.label }}
+                      </option>
+                    </select>
+                  </label>
+                  <label v-if="composerType !== 'section-break'" 
class="ot__field">
+                    <span>Width (span)</span>
+                    <input v-model.number="composerSpan" type="number" min="1" 
max="12" class="ot__in ot__in--num" />
+                  </label>
+                  <label v-if="composerType !== 'section-break'" 
class="ot__field">
+                    <span>Height (rows)</span>
+                    <input v-model.number="composerRowSpan" type="number" 
min="1" max="12" class="ot__in ot__in--num" />
+                  </label>
+                </div>
+                <div class="ot__composer-foot">
+                  <span class="ot__composer-hint">
+                    A default title + content scaffold is generated; you can 
edit everything below after creating.
+                  </span>
+                  <button type="button" class="ot__btn" 
@click="cancelComposer">cancel</button>
+                  <button type="button" class="ot__btn ot__btn--primary" 
@click="createWidget">
+                    create
+                  </button>
+                </div>
+              </article>
+            </div>
+          </div>
+
+          <!-- Layout + mock-data preview. Sits on its own tab so it
+               gets the full pane width, no scroll-past-editor needed. -->
+          <section v-if="viewMode === 'preview'" class="ot__preview">
+            <header class="ot__preview-head">
+              <h3>Preview</h3>
+              <span class="ot__preview-hint">layout + mock data · not live OAP 
values</span>
+            </header>
+            <div
+              v-for="(sec, si) in previewSections"
+              :key="si"
+              class="ot__pv-section"
+            >
+              <div v-if="previewSectionTitle(si)" class="ot__pv-section-head">
+                {{ previewSectionTitle(si) }}
+              </div>
+              <div
+                class="ot__pv-grid"
+                :style="{ gridTemplateColumns: `repeat(${sec.cols}, minmax(0, 
1fr))` }"
+              >
+                <article
+                  v-for="w in sec.widgets"
+                  :key="w.id"
+                  class="ot__pv-card"
+                  :style="{
+                    gridColumn: `span ${Math.min(w.span ?? 12, sec.cols)}`,
+                    gridRow: `span ${w.rowSpan ?? 1}`,
+                  }"
+                >
+                  <div class="ot__pv-card-head">
+                    <span class="ot__pv-kind">{{ widgetKindLabel(w.type) 
}}</span>
+                    <span v-if="w.layer" class="ot__pv-layer">{{ w.layer 
}}</span>
+                  </div>
+                  <div class="ot__pv-title">{{ w.title }}</div>
+                  <!-- Per-type mock body. -->
+                  <template v-if="w.type === 'metric'">
+                    <div class="ot__pv-bignum">
+                      {{ mockNumber(w.id) }}<span v-if="w.unit" 
class="ot__pv-unit">{{ w.unit }}</span>
+                    </div>
+                    <code v-if="w.mqe" class="ot__pv-mqe">{{ w.mqe }}</code>
+                  </template>
+                  <template v-else-if="w.type === 'alarms'">
+                    <!-- Mock alarm rows — same shape as AlarmsWidget so
+                         the operator sees the rail layout, not just a
+                         count. Severity / message / scope all mocked. -->
+                    <ul class="ot__pv-alarms">
+                      <li
+                        v-for="row in mockAlarms(w.id, Math.min(w.limit ?? 10, 
3))"
+                        :key="row.key"
+                        class="ot__pv-alarm"
+                      >
+                        <span class="ot__pv-alarm-dot" :class="row.firing ? 
'is-err' : 'is-ok'" />
+                        <div class="ot__pv-alarm-text">
+                          <div class="ot__pv-alarm-msg">{{ row.msg }}</div>
+                          <div class="ot__pv-alarm-scope">{{ row.scope }}</div>
+                        </div>
+                        <span class="ot__pv-alarm-time mono">{{ row.since 
}}</span>
+                      </li>
+                    </ul>
+                    <div class="ot__pv-sub">
+                      mock · max {{ w.limit ?? 10 }} rows
+                    </div>
+                  </template>
+                  <template v-else-if="w.type === 'topology'">
+                    <!-- Mock topology SVG: a small graph of circles + edges
+                         so the operator sees the shape of a topology tile
+                         in this widget's slot. -->
+                    <svg class="ot__pv-topo-svg" viewBox="0 0 220 100" 
preserveAspectRatio="xMidYMid meet">
+                      <line x1="40"  y1="20" x2="110" y2="50" />
+                      <line x1="40"  y1="80" x2="110" y2="50" />
+                      <line x1="110" y1="50" x2="180" y2="25" />
+                      <line x1="110" y1="50" x2="180" y2="75" />
+                      <line x1="180" y1="25" x2="180" y2="75" />
+                      <circle cx="40"  cy="20" r="8" />
+                      <circle cx="40"  cy="80" r="8" />
+                      <circle cx="110" cy="50" r="10" class="hub" />
+                      <circle cx="180" cy="25" r="8" />
+                      <circle cx="180" cy="75" r="8" />
+                    </svg>
+                    <div class="ot__pv-sub">topology · {{ w.layer ?? '—' 
}}</div>
+                  </template>
+                  <template v-else-if="w.type === 'kpi-tile' || w.type === 
'metric-composite'">
+                    <!-- Split layout: number-style → count tiles grid;
+                         progress-bar style → bars grid. Mirrors how the
+                         real MetricCompositeWidget partitions its KPIs. -->
+                    <div v-if="(w.kpis ?? []).length === 0" 
class="ot__pv-empty">
+                      no KPI rows configured
+                    </div>
+                    <template v-else>
+                      <div
+                        v-if="kpiCountRows(w).length > 0"
+                        class="ot__pv-num-grid"
+                      >
+                        <div
+                          v-for="k in kpiCountRows(w)"
+                          :key="k.label"
+                          class="ot__pv-num-tile"
+                        >
+                          <span class="ot__pv-num-val">
+                            {{ mockNumber(w.id + k.label) }}<span 
v-if="k.unit" class="ot__pv-num-unit">{{ k.unit }}</span>
+                          </span>
+                          <span class="ot__pv-num-label">{{ k.label }}</span>
+                        </div>
+                      </div>
+                      <div
+                        v-if="kpiBarRows(w).length > 0"
+                        class="ot__pv-bar-grid"
+                      >
+                        <div
+                          v-for="k in kpiBarRows(w)"
+                          :key="k.label"
+                          class="ot__pv-bar-row"
+                        >
+                          <div class="ot__pv-bar-head">
+                            <span class="ot__pv-bar-label">{{ k.label }}</span>
+                            <span class="ot__pv-bar-val">
+                              {{ mockPercent(w.id + k.label) }}{{ k.unit ?? 
'%' }}
+                            </span>
+                          </div>
+                          <div class="ot__pv-bar">
+                            <span
+                              class="ot__pv-bar-fill"
+                              :style="{ width: mockPercent(w.id + k.label) + 
'%' }"
+                            />
+                          </div>
+                        </div>
+                      </div>
+                    </template>
                   </template>
-                  <span v-else class="ot__none">— / —</span>
-                </td>
-              </tr>
-            </tbody>
-          </table>
+                </article>
+              </div>
+            </div>
+            <div v-if="previewSections.length === 0" class="ot__pv-empty">
+              No widgets yet — add one above to see the preview.
+            </div>
+          </section>
 
           <div class="ot__actions">
             <span v-if="flash" class="ot__flash">{{ flash }}</span>
@@ -287,6 +997,220 @@ function showsLayout(w: OverviewWidget): boolean {
 .ot__list-meta code { font-family: var(--sw-mono); color: var(--sw-fg-2); }
 .ot__readonly-tag { color: var(--sw-warn); font-style: italic; }
 .ot__list-empty { padding: 24px; text-align: center; font-size: 12px; color: 
var(--sw-fg-3); }
+.ot__list-add { padding: 8px; border-top: 1px solid var(--sw-line); }
+.ot__add-trigger--list { font-size: 11.5px; padding: 6px; }
+.ot__newdash { display: flex; flex-direction: column; gap: 6px; padding: 6px; }
+.ot__newdash-err { font-size: 11px; color: var(--sw-err); }
+.ot__newdash-foot { display: flex; gap: 6px; justify-content: flex-end; }
+.ot__head-btn {
+  background: transparent;
+  border: 1px solid var(--sw-line-2);
+  color: var(--sw-fg-2);
+  font: inherit;
+  font-size: 10.5px;
+  text-transform: uppercase;
+  letter-spacing: 0.05em;
+  padding: 4px 10px;
+  border-radius: 4px;
+  cursor: pointer;
+}
+.ot__head-btn:hover { background: var(--sw-bg-2); color: var(--sw-fg-0); }
+.ot__head-btn.active { background: var(--sw-bg-3); color: var(--sw-fg-0); 
border-color: var(--sw-accent); }
+.ot__head-btn--danger:hover {
+  background: transparent; color: var(--sw-err); border-color: 
rgba(239,68,68,0.4);
+}
+
+/* Config / Preview tab strip. Sits between the dashboard id + the
+ * delete action; consumes flex space so the delete button lands at
+ * the right edge. */
+.ot__tabs {
+  display: inline-flex;
+  background: var(--sw-bg-2);
+  border: 1px solid var(--sw-line);
+  border-radius: 5px;
+  padding: 2px;
+  margin-left: 12px;
+}
+.ot__tab {
+  background: transparent;
+  border: 0;
+  color: var(--sw-fg-2);
+  font: inherit;
+  font-size: 11px;
+  font-weight: 500;
+  padding: 4px 14px;
+  border-radius: 3px;
+  cursor: pointer;
+}
+.ot__tab:hover { color: var(--sw-fg-0); }
+.ot__tab.active {
+  background: var(--sw-bg-3);
+  color: var(--sw-fg-0);
+  font-weight: 600;
+}
+
+/* Preview pane */
+.ot__preview {
+  margin-top: 14px;
+  padding: 12px;
+  background: var(--sw-bg-2);
+  border: 1px solid var(--sw-line);
+  border-radius: 6px;
+}
+.ot__preview-head {
+  display: flex; align-items: baseline; gap: 8px;
+  margin-bottom: 10px;
+}
+.ot__preview-head h3 {
+  font-size: 11px;
+  text-transform: uppercase;
+  letter-spacing: 0.08em;
+  color: var(--sw-fg-2);
+  margin: 0;
+}
+.ot__preview-hint { font-size: 10.5px; color: var(--sw-fg-3); font-style: 
italic; }
+.ot__pv-section { margin-bottom: 10px; }
+.ot__pv-section-head {
+  font-size: 10.5px;
+  text-transform: uppercase;
+  letter-spacing: 0.08em;
+  color: var(--sw-fg-2);
+  padding-bottom: 6px;
+  border-bottom: 1px dashed var(--sw-line);
+  margin-bottom: 6px;
+}
+.ot__pv-grid { display: grid; gap: 6px; }
+.ot__pv-card {
+  background: var(--sw-bg-1);
+  border: 1px solid var(--sw-line);
+  border-radius: 4px;
+  padding: 6px 8px;
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  min-height: 60px;
+  overflow: hidden;
+}
+.ot__pv-card-head { display: flex; gap: 6px; align-items: baseline; }
+.ot__pv-kind {
+  font-size: 9px;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  color: var(--sw-accent);
+  font-weight: 600;
+}
+.ot__pv-layer {
+  font-family: var(--sw-mono);
+  font-size: 9px;
+  color: var(--sw-fg-3);
+  margin-left: auto;
+}
+.ot__pv-title { font-size: 11px; color: var(--sw-fg-0); font-weight: 500; }
+.ot__pv-bignum {
+  font-size: 20px; font-weight: 600; color: var(--sw-fg-0);
+  font-variant-numeric: tabular-nums; margin-top: auto;
+}
+.ot__pv-bignum--err { color: var(--sw-err); }
+.ot__pv-unit { font-size: 11px; color: var(--sw-fg-3); margin-left: 4px; }
+.ot__pv-sub { font-size: 10px; color: var(--sw-fg-3); }
+.ot__pv-mqe {
+  font-family: var(--sw-mono); font-size: 9px;
+  color: var(--sw-fg-3); white-space: nowrap; overflow: hidden; text-overflow: 
ellipsis;
+}
+/* Topology mock — SVG of a few nodes + edges. Uses currentColor /
+ * design tokens so the graph reads against the card background. */
+.ot__pv-topo-svg {
+  width: 100%;
+  flex: 1;
+  min-height: 80px;
+  max-height: 140px;
+}
+.ot__pv-topo-svg line {
+  stroke: var(--sw-line-2);
+  stroke-width: 1;
+  opacity: 0.7;
+}
+.ot__pv-topo-svg circle {
+  fill: var(--sw-bg-2);
+  stroke: var(--sw-fg-2);
+  stroke-width: 1.5;
+}
+.ot__pv-topo-svg circle.hub {
+  fill: var(--sw-accent-soft);
+  stroke: var(--sw-accent);
+}
+
+/* Alarms mock rows */
+.ot__pv-alarms { list-style: none; margin: 0; padding: 0; }
+.ot__pv-alarm {
+  display: flex; align-items: flex-start; gap: 6px;
+  padding: 4px 0;
+  border-top: 1px solid var(--sw-line);
+  font-size: 10.5px;
+}
+.ot__pv-alarm:first-child { border-top: none; }
+.ot__pv-alarm-dot {
+  width: 5px; height: 5px; border-radius: 50%; margin-top: 4px; flex-shrink: 0;
+}
+.ot__pv-alarm-dot.is-err { background: var(--sw-err); }
+.ot__pv-alarm-dot.is-ok { background: var(--sw-ok); }
+.ot__pv-alarm-text { flex: 1; min-width: 0; }
+.ot__pv-alarm-msg {
+  color: var(--sw-fg-1); white-space: nowrap;
+  overflow: hidden; text-overflow: ellipsis;
+}
+.ot__pv-alarm-scope {
+  font-family: var(--sw-mono); font-size: 9px;
+  color: var(--sw-fg-3); white-space: nowrap;
+  overflow: hidden; text-overflow: ellipsis;
+}
+.ot__pv-alarm-time { color: var(--sw-fg-3); font-size: 9px; flex-shrink: 0; }
+
+/* Composite metrics mock — same number-grid + bar-grid split the
+ * real MetricCompositeWidget uses. */
+.ot__pv-num-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(72px, 1fr));
+  gap: 4px;
+}
+.ot__pv-num-tile {
+  display: flex; flex-direction: column; gap: 1px;
+  padding: 5px 7px;
+  background: var(--sw-bg-2);
+  border: 1px solid var(--sw-line);
+  border-radius: 4px;
+}
+.ot__pv-num-val {
+  font-size: 14px; font-weight: 600; color: var(--sw-fg-0);
+  font-variant-numeric: tabular-nums;
+}
+.ot__pv-num-unit { font-size: 9px; color: var(--sw-fg-3); margin-left: 2px; }
+.ot__pv-num-label {
+  font-size: 9px; color: var(--sw-fg-3);
+  text-transform: uppercase; letter-spacing: 0.05em;
+}
+.ot__pv-bar-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
+  gap: 6px;
+  margin-top: 6px;
+}
+.ot__pv-bar-row { display: flex; flex-direction: column; gap: 3px; }
+.ot__pv-bar-head { display: flex; justify-content: space-between; align-items: 
baseline; }
+.ot__pv-bar-label { font-size: 10px; color: var(--sw-fg-2); }
+.ot__pv-bar-val {
+  font-size: 10.5px; font-weight: 600; color: var(--sw-fg-0);
+  font-variant-numeric: tabular-nums;
+}
+.ot__pv-bar {
+  height: 3px; background: var(--sw-bg-2);
+  border-radius: 2px; overflow: hidden;
+}
+.ot__pv-bar-fill { display: block; height: 100%; background: var(--sw-accent); 
}
+.ot__pv-empty {
+  padding: 14px; text-align: center;
+  font-size: 11px; color: var(--sw-fg-3); font-style: italic;
+}
 
 .ot__detail { background: var(--sw-bg-1); border: 1px solid var(--sw-line); 
border-radius: 8px; padding: 16px; }
 .ot__detail-head { display: flex; align-items: baseline; gap: 10px; 
margin-bottom: 12px; }
@@ -294,40 +1218,136 @@ function showsLayout(w: OverviewWidget): boolean {
 .ot__detail-head h2 code { font-family: var(--sw-mono); color: var(--sw-fg-0); 
}
 .ot__count { font-size: 11px; color: var(--sw-fg-3); margin-left: auto; }
 
-.ot__table { width: 100%; border-collapse: collapse; font-size: 11.5px; }
-.ot__table thead th {
-  text-align: left; font-size: 10px;
-  text-transform: uppercase; letter-spacing: 0.08em;
-  color: var(--sw-fg-3); padding: 6px 8px;
-  border-bottom: 1px solid var(--sw-line); font-weight: 600;
+/* Per-widget card */
+.ot__widgets { display: flex; flex-direction: column; gap: 10px; }
+.ot__widget {
+  background: var(--sw-bg-2);
+  border: 1px solid var(--sw-line);
+  border-radius: 6px;
+  padding: 10px 12px;
 }
-.ot__table tbody td {
-  padding: 8px; vertical-align: top;
-  border-bottom: 1px solid var(--sw-line);
+.ot__widget--section-break { border-left: 3px solid var(--sw-accent); }
+.ot__widget-head {
+  display: flex; align-items: baseline; gap: 8px;
+  margin-bottom: 8px;
+}
+.ot__widget-kind {
+  font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em;
+  color: var(--sw-accent); font-weight: 600;
+}
+.ot__widget-id {
+  font-family: var(--sw-mono); font-size: 10.5px; color: var(--sw-fg-3);
+  margin-left: auto;
+}
+.ot__widget-actions { display: inline-flex; gap: 2px; }
+.ot__widget-actions .ot__arrow { width: 22px; height: 22px; }
+.ot__widget-actions .ot__del { font-size: 16px; }
+
+.ot__row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px; }
+.ot__row--tight { gap: 4px; align-items: center; }
+.ot__field {
+  display: flex; flex-direction: column; gap: 3px;
+  min-width: 140px;
+}
+.ot__field--wide { flex: 1 1 220px; }
+.ot__field > span {
+  font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.08em;
+  color: var(--sw-fg-3); font-weight: 600;
 }
-.ot__table tbody tr:last-child td { border-bottom: none; }
 .ot__in {
-  background: var(--sw-bg-2); border: 1px solid var(--sw-line);
+  background: var(--sw-bg-1); border: 1px solid var(--sw-line);
   color: var(--sw-fg-0); font: inherit; font-size: 11.5px;
   padding: 4px 6px; border-radius: 4px;
 }
-.ot__in--narrow { width: 140px; }
-.ot__in--num { width: 70px; font-variant-numeric: tabular-nums; }
-.ot__in--xnum { width: 48px; font-variant-numeric: tabular-nums; margin-right: 
4px; }
-.ot__in--tip {
-  display: block; margin-top: 4px;
-  font-size: 10.5px; color: var(--sw-fg-2); width: 100%;
+.ot__in--narrow { width: 160px; }
+.ot__in--xnarrow { width: 70px; }
+.ot__in--mono { font-family: var(--sw-mono); }
+.ot__in--num { width: 80px; font-variant-numeric: tabular-nums; }
+.ot__in--xnum { width: 48px; font-variant-numeric: tabular-nums; }
+.ot__field input[type='checkbox'] { width: 14px; height: 14px; margin: 4px 0 
0; cursor: pointer; }
+.ot__none { color: var(--sw-fg-3); font-size: 11px; }
+
+/* KPI sub-table */
+.ot__kpis {
+  margin-top: 8px;
+  background: var(--sw-bg-1);
+  border: 1px solid var(--sw-line);
+  border-radius: 5px;
+  padding: 8px;
+}
+.ot__kpis-head {
+  display: flex; align-items: center; gap: 8px;
+  font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em;
+  color: var(--sw-fg-3); font-weight: 600;
+  margin-bottom: 6px;
+}
+.ot__kpis-empty {
+  padding: 14px; text-align: center;
+  font-size: 11.5px; color: var(--sw-fg-3);
+}
+.ot__add-btn {
+  margin-left: auto;
+  background: transparent; border: 1px dashed var(--sw-line-2);
+  color: var(--sw-fg-2); font: inherit; font-size: 11px;
+  padding: 2px 10px; border-radius: 4px; cursor: pointer;
+}
+.ot__add-btn:hover { color: var(--sw-fg-0); border-style: solid; }
+.ot__kpi-table { width: 100%; border-collapse: collapse; }
+.ot__kpi-table thead th {
+  text-align: left; font-size: 9px;
+  text-transform: uppercase; letter-spacing: 0.06em;
+  color: var(--sw-fg-3); font-weight: 600;
+  padding: 4px 6px;
+  border-bottom: 1px solid var(--sw-line);
+}
+.ot__kpi-table tbody td { padding: 4px; vertical-align: middle; }
+.ot__kpi-table input.ot__in,
+.ot__kpi-table select.ot__in { width: 100%; min-width: 0; }
+.ot__arrow {
+  background: transparent; border: 0;
+  color: var(--sw-fg-2); font: inherit; font-size: 12px;
+  width: 20px; height: 22px; cursor: pointer;
+}
+.ot__arrow:disabled { opacity: 0.3; cursor: not-allowed; }
+.ot__arrow:not(:disabled):hover { color: var(--sw-fg-0); }
+.ot__del {
+  background: transparent; border: 0;
+  color: var(--sw-fg-3); font: inherit; font-size: 14px; line-height: 1;
+  width: 22px; height: 22px; cursor: pointer; border-radius: 3px;
+}
+.ot__del:hover { color: var(--sw-err); background: var(--sw-bg-2); }
+
+/* Add-widget composer */
+.ot__add-widget { margin-top: 8px; }
+.ot__add-trigger {
+  width: 100%;
+  background: transparent;
+  border: 1px dashed var(--sw-line-2);
+  color: var(--sw-fg-2);
+  font: inherit;
+  font-size: 12px;
+  padding: 10px;
+  border-radius: 6px;
+  cursor: pointer;
+}
+.ot__add-trigger:hover {
+  border-color: var(--sw-accent);
+  color: var(--sw-fg-0);
+  background: var(--sw-bg-2);
 }
-.ot__wid-id {
-  display: block; margin-top: 4px;
-  font-family: var(--sw-mono); font-size: 10px; color: var(--sw-fg-3);
+.ot__composer { border-color: var(--sw-accent); }
+.ot__composer-foot {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-top: 10px;
 }
-.ot__type {
-  font-family: var(--sw-mono); font-size: 10.5px;
-  color: var(--sw-fg-1); background: var(--sw-bg-2);
-  padding: 2px 6px; border-radius: 3px;
+.ot__composer-hint {
+  font-size: 10.5px;
+  color: var(--sw-fg-3);
+  flex: 1;
+  font-style: italic;
 }
-.ot__none { color: var(--sw-fg-3); }
 
 .ot__actions { display: flex; align-items: center; gap: 10px; margin-top: 
14px; }
 .ot__flash { font-size: 11px; color: var(--sw-ok); margin-right: auto; }
diff --git a/apps/ui/src/render/overview/OverviewDashboardView.vue 
b/apps/ui/src/render/overview/OverviewDashboardView.vue
index de993ef..1a3a295 100644
--- a/apps/ui/src/render/overview/OverviewDashboardView.vue
+++ b/apps/ui/src/render/overview/OverviewDashboardView.vue
@@ -31,12 +31,10 @@ import { useRoute } from 'vue-router';
 import type { OverviewWidget } from '@skywalking-horizon-ui/api-client';
 import { useOverviewDashboard } from '@/render/overview/useOverviewDashboard';
 import SectionBreak from '@/render/widgets/SectionBreak.vue';
-import ServiceCountWidget from '@/render/widgets/ServiceCountWidget.vue';
 import MetricWidget from '@/render/widgets/MetricWidget.vue';
 import KpiTileWidget from '@/render/widgets/KpiTileWidget.vue';
 import AlarmsWidget from '@/render/widgets/AlarmsWidget.vue';
-import K8sSummaryWidget from '@/render/widgets/K8sSummaryWidget.vue';
-import PilotSummaryWidget from '@/render/widgets/PilotSummaryWidget.vue';
+import MetricCompositeWidget from '@/render/widgets/MetricCompositeWidget.vue';
 import LayerServiceMapView from '@/layer/service-map/LayerServiceMapView.vue';
 
 const route = useRoute();
@@ -88,15 +86,8 @@ function widgetStyle(span?: number, rowSpan?: number, cols = 
12): Record<string,
           :style="{ gridTemplateColumns: `repeat(${sec.cols}, minmax(0, 1fr))` 
}"
         >
           <template v-for="w in sec.widgets" :key="w.id">
-            <ServiceCountWidget
-              v-if="w.type === 'service-count'"
-              :title="w.title"
-              :tip="w.tip"
-              :value="values.values[w.id]"
-              :style="widgetStyle(w.span, w.rowSpan, sec.cols)"
-            />
             <MetricWidget
-              v-else-if="w.type === 'metric'"
+              v-if="w.type === 'metric'"
               :title="w.title"
               :tip="w.tip"
               :value="values.values[w.id]"
@@ -139,19 +130,12 @@ function widgetStyle(span?: number, rowSpan?: number, 
cols = 12): Record<string,
                    composables look up by. -->
               <LayerServiceMapView :layer-key="w.layer.toLowerCase()" 
:embedded="true" />
             </div>
-            <K8sSummaryWidget
-              v-else-if="w.type === 'k8s-summary'"
-              :title="w.title"
-              :tip="w.tip"
-              :layer="w.layer"
-              :kpi-values="values.kpiValues[w.id] ?? {}"
-              :style="widgetStyle(w.span, w.rowSpan, sec.cols)"
-            />
-            <PilotSummaryWidget
-              v-else-if="w.type === 'pilot-summary'"
+            <MetricCompositeWidget
+              v-else-if="w.type === 'metric-composite'"
               :title="w.title"
               :tip="w.tip"
               :layer="w.layer"
+              :kpis="w.kpis"
               :kpi-values="values.kpiValues[w.id] ?? {}"
               :style="widgetStyle(w.span, w.rowSpan, sec.cols)"
             />
diff --git a/apps/ui/src/render/overview/useOverviewDashboard.ts 
b/apps/ui/src/render/overview/useOverviewDashboard.ts
index cd78a12..07fd937 100644
--- a/apps/ui/src/render/overview/useOverviewDashboard.ts
+++ b/apps/ui/src/render/overview/useOverviewDashboard.ts
@@ -42,6 +42,12 @@ interface MqeRequest {
   mqe: string;
   aggregation: 'sum' | 'avg';
   unit?: string;
+  /** When set, the widget+kpi expects the layer's service count (from
+   *  the landing aggregate's `serviceCount`) instead of an MQE
+   *  result. The `mqe` field is filled with a placeholder so the
+   *  request shape stays uniform; the value-pickup pass below treats
+   *  it specially. */
+  isServiceCount?: boolean;
 }
 
 /**
@@ -54,13 +60,6 @@ function groupByLayer(widgets: OverviewWidget[]): 
Map<string, MqeRequest[]> {
     const layer = w.layer;
     if (!layer) continue;
     if (w.type === 'section-break' || w.type === 'alarms' || w.type === 
'topology') continue;
-    if (w.type === 'service-count') {
-      // Service count lives in the landing aggregates as `serviceCount`,
-      // no MQE column needed — but we still tag the layer so the call
-      // happens.
-      if (!out.has(layer)) out.set(layer, []);
-      continue;
-    }
     if (w.type === 'metric' && w.mqe) {
       const reqs = out.get(layer) ?? [];
       reqs.push({
@@ -72,43 +71,17 @@ function groupByLayer(widgets: OverviewWidget[]): 
Map<string, MqeRequest[]> {
       out.set(layer, reqs);
       continue;
     }
-    if (w.type === 'kpi-tile' && w.kpis) {
+    if ((w.type === 'kpi-tile' || w.type === 'metric-composite') && w.kpis) {
       const reqs = out.get(layer) ?? [];
       for (const k of w.kpis) {
+        const isCount = k.source === 'service-count';
         reqs.push({
           widgetId: w.id,
           kpiLabel: k.label,
-          mqe: k.mqe,
+          mqe: isCount ? '__service_count' : (k.mqe ?? ''),
           aggregation: k.aggregation ?? 'avg',
           unit: k.unit,
-        });
-      }
-      out.set(layer, reqs);
-      continue;
-    }
-    if (w.type === 'k8s-summary') {
-      const reqs = out.get(layer) ?? [];
-      for (const k of K8S_SUMMARY_KPIS) {
-        reqs.push({
-          widgetId: w.id,
-          kpiLabel: k.label,
-          mqe: k.mqe,
-          aggregation: k.aggregation,
-          unit: k.unit,
-        });
-      }
-      out.set(layer, reqs);
-      continue;
-    }
-    if (w.type === 'pilot-summary') {
-      const reqs = out.get(layer) ?? [];
-      for (const k of PILOT_SUMMARY_KPIS) {
-        reqs.push({
-          widgetId: w.id,
-          kpiLabel: k.label,
-          mqe: k.mqe,
-          aggregation: k.aggregation,
-          unit: k.unit,
+          isServiceCount: isCount,
         });
       }
       out.set(layer, reqs);
@@ -117,77 +90,6 @@ function groupByLayer(widgets: OverviewWidget[]): 
Map<string, MqeRequest[]> {
   return out;
 }
 
-/** Fixed-shape KPI set for the K8s capacity / utilisation block. The
- *  widget renderer reads these labels back to lay them out.
- *
- *  Count metrics are gauges — wrap with `latest()` so the per-bucket
- *  evaluation returns the most recent observed value rather than a
- *  series average. Usage % is derived per booster-ui's k8s-cluster
- *  template: requests-to-total for CPU + memory, and (total − allocatable)
- *  / total for storage (no requests meter exists for storage). */
-export const K8S_SUMMARY_KPIS: ReadonlyArray<{
-  label: string;
-  mqe: string;
-  unit?: string;
-  aggregation: 'sum' | 'avg';
-}> = [
-  { label: 'Nodes', mqe: 'latest(k8s_cluster_node_total)', aggregation: 'avg' 
},
-  { label: 'Namespaces', mqe: 'latest(k8s_cluster_namespace_total)', 
aggregation: 'avg' },
-  { label: 'Deployments', mqe: 'latest(k8s_cluster_deployment_total)', 
aggregation: 'avg' },
-  { label: 'StatefulSets', mqe: 'latest(k8s_cluster_statefulset_total)', 
aggregation: 'avg' },
-  { label: 'DaemonSets', mqe: 'latest(k8s_cluster_daemonset_total)', 
aggregation: 'avg' },
-  { label: 'Services', mqe: 'latest(k8s_cluster_service_total)', aggregation: 
'avg' },
-  { label: 'Containers', mqe: 'latest(k8s_cluster_container_total)', 
aggregation: 'avg' },
-  {
-    label: 'CPU',
-    mqe: 'k8s_cluster_cpu_cores_requests/k8s_cluster_cpu_cores*100',
-    unit: '%',
-    aggregation: 'avg',
-  },
-  {
-    label: 'Memory',
-    mqe: 'k8s_cluster_memory_requests/k8s_cluster_memory_total*100',
-    unit: '%',
-    aggregation: 'avg',
-  },
-  {
-    label: 'Storage',
-    mqe:
-      
'(k8s_cluster_storage_total-k8s_cluster_storage_allocatable)/k8s_cluster_storage_total*100',
-    unit: '%',
-    aggregation: 'avg',
-  },
-];
-
-/** Fixed-shape KPI set for the Istio pilot block. Compact 4-up layout
- *  to match the neighbour service tile's visual weight. Every metric
- *  is pulled verbatim from booster-ui's `mesh-control-plane-service.json`
- *  (entity = Service, layer = MESH_CP).
- *
- *  "Pilot errors" mirrors booster-ui's widget title of the same name —
- *  there's no single error counter in OAP, so we sum the four xDS
- *  reject types plus the write-timeout meter via MQE arithmetic to
- *  surface one number. Individual reject types remain visible on the
- *  per-layer Control Plane page's Pilot Errors chart. */
-export const PILOT_SUMMARY_KPIS: ReadonlyArray<{
-  label: string;
-  mqe: string;
-  unit?: string;
-  aggregation: 'sum' | 'avg';
-}> = [
-  { label: 'xDS pushes', mqe: 'meter_istio_pilot_xds_pushes', aggregation: 
'sum' },
-  { label: 'xDS connections', mqe: 'meter_istio_pilot_xds', aggregation: 'avg' 
},
-  { label: 'Services', mqe: 'meter_istio_pilot_services', aggregation: 'avg' },
-  {
-    label: 'Pilot errors',
-    mqe:
-      'meter_istio_pilot_xds_cds_reject+meter_istio_pilot_xds_eds_reject+' +
-      'meter_istio_pilot_xds_lds_reject+meter_istio_pilot_xds_rds_reject+' +
-      'meter_istio_pilot_xds_write_timeout',
-    aggregation: 'sum',
-  },
-];
-
 /**
  * Load one overview dashboard + its widget values. Topology widgets are
  * resolved by the consumer (TopologySnapshotWidget hits the per-layer
@@ -214,6 +116,11 @@ export function useOverviewDashboard(idRef: Ref<string>) {
       return entries.map(([layer, reqs]) => ({
         queryKey: ['overview-dashboard-data', idRef.value, layer],
         queryFn: () => {
+          /* Service-count KPIs read from `aggregates.serviceCount`
+           * — strip them from the MQE column list to avoid sending
+           * a synthetic MQE upstream. They still ride in `reqs` so
+           * the value-pickup pass below can inject the count. */
+          const mqeReqs = reqs.filter((r) => !r.isServiceCount);
           // priority + style are required by the LandingConfig type
           // but ignored by the BFF route — the client only forwards
           // topN/orderBy/columns. Stubbed to satisfy the type.
@@ -221,8 +128,8 @@ export function useOverviewDashboard(idRef: Ref<string>) {
             priority: 0,
             style: 'table',
             topN: 1,
-            orderBy: reqs[0]?.mqe ?? 'service_cpm',
-            columns: reqs.map((r, i) => ({
+            orderBy: mqeReqs[0]?.mqe ?? 'service_cpm',
+            columns: mqeReqs.map((r, i) => ({
               metric: `w_${i}`,
               label: r.kpiLabel ?? r.widgetId,
               mqe: r.mqe,
@@ -233,6 +140,7 @@ export function useOverviewDashboard(idRef: Ref<string>) {
           return bffClient.layer.landing(layer, cfg).then((res) => ({
             layer,
             reqs,
+            mqeReqs,
             aggregates: res.aggregates,
           }));
         },
@@ -244,21 +152,18 @@ export function useOverviewDashboard(idRef: Ref<string>) {
 
   const values = computed<OverviewWidgetValues>(() => {
     const out: OverviewWidgetValues = { values: {}, kpiValues: {} };
-    // First pass: service-count widgets pull from the landing aggregate's
-    // `serviceCount` field on the matching layer.
-    for (const w of widgets.value) {
-      if (w.type === 'service-count' && w.layer) {
-        const r = layerQueries.value.find((q) => q.data?.layer === w.layer);
-        out.values[w.id] = r?.data?.aggregates.serviceCount ?? null;
-      }
-    }
-    // Second pass: metric / kpi-tile / k8s-summary / pilot-summary read
-    // their MQE values out of the layer aggregate keyed by `w_<idx>`.
+    // metric / kpi-tile / metric-composite read their MQE
+    // values out of the layer aggregate keyed by `w_<idx>`. KPI rows
+    // with `source: 'service-count'` instead pick up the landing
+    // aggregate's `serviceCount` directly.
     for (const q of layerQueries.value) {
       const data = q.data;
       if (!data) continue;
-      const { reqs, aggregates } = data;
-      reqs.forEach((r, i) => {
+      const { reqs, mqeReqs, aggregates } = data;
+      /* MQE rows map by position in `mqeReqs` (which is the only set
+       * the BFF actually evaluated). Service-count rows below pick
+       * up the count regardless of position. */
+      mqeReqs.forEach((r, i) => {
         const v = aggregates.metrics[`w_${i}`] ?? null;
         if (r.kpiLabel) {
           if (!out.kpiValues[r.widgetId]) out.kpiValues[r.widgetId] = {};
@@ -267,13 +172,17 @@ export function useOverviewDashboard(idRef: Ref<string>) {
           out.values[r.widgetId] = v;
         }
       });
-      // service-count widgets sharing the layer pick up the count from
-      // the same landing call. kpi-tile widgets with showCount=true do
-      // the same — the count slot above the KPI rows reads
-      // `values[widget.id]`, same as a standalone service-count tile.
+      for (const r of reqs) {
+        if (!r.isServiceCount || !r.kpiLabel) continue;
+        if (!out.kpiValues[r.widgetId]) out.kpiValues[r.widgetId] = {};
+        out.kpiValues[r.widgetId][r.kpiLabel] = aggregates.serviceCount;
+      }
+      // kpi-tile widgets with showCount=true pick up the layer's
+      // service count for the slot above the KPI rows. Read from the
+      // same landing aggregate — no separate listServices call.
       for (const w of widgets.value) {
         if (w.layer !== data.layer) continue;
-        if (w.type === 'service-count' || (w.type === 'kpi-tile' && 
w.showCount)) {
+        if (w.type === 'kpi-tile' && w.showCount) {
           out.values[w.id] = aggregates.serviceCount;
         }
       }
diff --git a/apps/ui/src/render/widgets/K8sSummaryWidget.vue 
b/apps/ui/src/render/widgets/K8sSummaryWidget.vue
deleted file mode 100644
index 4e1b65f..0000000
--- a/apps/ui/src/render/widgets/K8sSummaryWidget.vue
+++ /dev/null
@@ -1,115 +0,0 @@
-<!--
-  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.
--->
-<!--
-  Kubernetes capacity + utilisation block. Two rows:
-    - Counts: nodes, namespaces, deployments, statefulsets, daemonsets,
-      services, containers (per the layer's k8s_cluster_* family).
-    - Utilisation: CPU / memory / storage % across the cluster.
-  Both sourced from the K8S layer via the standard landing aggregate.
-  Click-through opens the K8S layer's Service tab.
--->
-<script setup lang="ts">
-import { computed } from 'vue';
-import { RouterLink } from 'vue-router';
-import { K8S_SUMMARY_KPIS } from '@/render/overview/useOverviewDashboard';
-import { formatValue } from './ValueFormat';
-
-const props = defineProps<{
-  title: string;
-  tip?: string;
-  layer?: string;
-  kpiValues: Record<string, number | null>;
-}>();
-
-const counts = computed(() => K8S_SUMMARY_KPIS.filter((k) => !k.unit));
-const usages = computed(() => K8S_SUMMARY_KPIS.filter((k) => k.unit === '%'));
-const tileTo = computed(() =>
-  props.layer ? `/layer/${props.layer.toLowerCase()}/service` : '',
-);
-
-function pct(v: number | null | undefined): string {
-  if (v === null || v === undefined || !Number.isFinite(v)) return '0%';
-  return `${Math.max(0, Math.min(100, v)).toFixed(1)}%`;
-}
-</script>
-
-<template>
-  <component :is="tileTo ? RouterLink : 'div'" :to="tileTo || undefined" 
class="tile-link">
-    <section class="sw-card k8s">
-      <header>
-        <h4>{{ title }}</h4>
-        <span v-if="tip" class="tip" :title="tip">?</span>
-      </header>
-      <div class="counts">
-        <div v-for="k in counts" :key="k.label" class="count">
-          <span class="count-value">{{ formatValue(kpiValues[k.label]) 
}}</span>
-          <span class="count-label">{{ k.label }}</span>
-        </div>
-      </div>
-      <div class="usages">
-        <div v-for="k in usages" :key="k.label" class="usage">
-          <div class="usage-head">
-            <span class="usage-label">{{ k.label }}</span>
-            <span class="usage-value">{{ formatValue(kpiValues[k.label], '%') 
}}</span>
-          </div>
-          <div class="bar">
-            <span class="fill" :style="{ width: pct(kpiValues[k.label]) }" />
-          </div>
-        </div>
-      </div>
-    </section>
-  </component>
-</template>
-
-<style scoped>
-.tile-link { display: block; text-decoration: none; color: inherit; height: 
100%; }
-.tile-link:hover .sw-card { border-color: var(--sw-line-3); }
-.k8s { display: flex; flex-direction: column; padding: 12px 14px; gap: 12px; 
min-height: 0; height: 100%; }
-header { display: flex; align-items: center; gap: 6px; }
-h4 { margin: 0; font-size: 12px; font-weight: 600; color: var(--sw-fg-0); }
-.tip {
-  font-size: 9px; color: var(--sw-fg-3); border: 1px solid var(--sw-line-2);
-  border-radius: 50%; width: 13px; height: 13px;
-  display: inline-flex; align-items: center; justify-content: center; cursor: 
help;
-}
-.counts {
-  display: grid; grid-template-columns: repeat(7, 1fr); gap: 8px;
-}
-.count {
-  display: flex; flex-direction: column; gap: 2px;
-  padding: 8px 10px;
-  background: var(--sw-bg-1);
-  border: 1px solid var(--sw-line);
-  border-radius: 6px;
-}
-.count-value {
-  font-size: 18px; font-weight: 600; color: var(--sw-fg-0);
-  font-variant-numeric: tabular-nums;
-}
-.count-label {
-  font-size: 10px; color: var(--sw-fg-3); text-transform: uppercase; 
letter-spacing: 0.05em;
-}
-.usages { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
-.usage-head { display: flex; justify-content: space-between; margin-bottom: 
4px; }
-.usage-label { font-size: 11px; color: var(--sw-fg-2); }
-.usage-value {
-  font-size: 12px; font-weight: 600; color: var(--sw-fg-0);
-  font-variant-numeric: tabular-nums;
-}
-.bar { height: 4px; background: var(--sw-bg-2); border-radius: 2px; overflow: 
hidden; }
-.fill { display: block; height: 100%; background: var(--sw-accent); 
transition: width 0.3s; }
-</style>
diff --git a/apps/ui/src/render/widgets/KpiTileWidget.vue 
b/apps/ui/src/render/widgets/KpiTileWidget.vue
index 1128f97..154f5c3 100644
--- a/apps/ui/src/render/widgets/KpiTileWidget.vue
+++ b/apps/ui/src/render/widgets/KpiTileWidget.vue
@@ -39,6 +39,16 @@ const props = defineProps<{
 const tileTo = computed(() =>
   props.layer ? `/layer/${props.layer.toLowerCase()}/service` : '',
 );
+
+/** Clamp value/max into a 0..100 percentage for the progress-bar
+ *  width. `null` / non-finite / max=0 collapse to 0. */
+function barPct(value: number | null | undefined, max: number): number {
+  if (value === null || value === undefined || !Number.isFinite(value) || max 
<= 0) return 0;
+  const pct = (value / max) * 100;
+  if (pct < 0) return 0;
+  if (pct > 100) return 100;
+  return Math.round(pct);
+}
 </script>
 
 <template>
@@ -54,8 +64,18 @@ const tileTo = computed(() =>
       </div>
       <div class="kpis">
         <div v-for="k in kpis" :key="k.label" class="kpi">
-          <span class="kpi-label">{{ k.label }}</span>
-          <span class="kpi-value">{{ formatValue(kpiValues[k.label], k.unit) 
}}</span>
+          <template v-if="k.style === 'progress-bar' && k.max">
+            <span class="kpi-label">{{ k.label }}</span>
+            <span class="kpi-value">{{ formatValue(kpiValues[k.label], k.unit) 
}}</span>
+            <div
+              class="kpi-bar"
+              :style="`--pct: ${barPct(kpiValues[k.label], k.max)}%`"
+            />
+          </template>
+          <template v-else>
+            <span class="kpi-label">{{ k.label }}</span>
+            <span class="kpi-value">{{ formatValue(kpiValues[k.label], k.unit) 
}}</span>
+          </template>
         </div>
       </div>
     </section>
@@ -72,8 +92,18 @@ const tileTo = computed(() =>
       </div>
       <div class="kpis">
         <div v-for="k in kpis" :key="k.label" class="kpi">
-          <span class="kpi-label">{{ k.label }}</span>
-          <span class="kpi-value">{{ formatValue(kpiValues[k.label], k.unit) 
}}</span>
+          <template v-if="k.style === 'progress-bar' && k.max">
+            <span class="kpi-label">{{ k.label }}</span>
+            <span class="kpi-value">{{ formatValue(kpiValues[k.label], k.unit) 
}}</span>
+            <div
+              class="kpi-bar"
+              :style="`--pct: ${barPct(kpiValues[k.label], k.max)}%`"
+            />
+          </template>
+          <template v-else>
+            <span class="kpi-label">{{ k.label }}</span>
+            <span class="kpi-value">{{ formatValue(kpiValues[k.label], k.unit) 
}}</span>
+          </template>
         </div>
       </div>
     </section>
@@ -101,11 +131,34 @@ h4 { margin: 0; font-size: 11px; font-weight: 600; color: 
var(--sw-fg-1); }
   font-size: 22px; font-weight: 600; color: var(--sw-fg-0);
   font-variant-numeric: tabular-nums; margin-left: auto;
 }
-.kpis { display: flex; flex-direction: column; gap: 4px; }
-.kpi { display: flex; align-items: baseline; justify-content: space-between; 
gap: 8px; }
+.kpis { display: flex; flex-direction: column; gap: 6px; }
+.kpi {
+  display: grid;
+  grid-template-columns: 1fr auto;
+  grid-template-rows: auto auto;
+  align-items: baseline;
+  column-gap: 8px;
+  row-gap: 2px;
+}
 .kpi-label { font-size: 11px; color: var(--sw-fg-2); }
 .kpi-value {
   font-size: 13px; font-weight: 600; color: var(--sw-fg-0);
   font-variant-numeric: tabular-nums;
 }
+.kpi-bar {
+  grid-column: 1 / -1;
+  position: relative;
+  height: 4px;
+  background: var(--sw-bg-2);
+  border-radius: 2px;
+  overflow: hidden;
+}
+.kpi-bar::before {
+  content: '';
+  position: absolute;
+  left: 0; top: 0; bottom: 0;
+  width: var(--pct, 0%);
+  background: var(--sw-accent);
+  border-radius: 2px;
+}
 </style>
diff --git a/apps/ui/src/render/widgets/MetricCompositeWidget.vue 
b/apps/ui/src/render/widgets/MetricCompositeWidget.vue
new file mode 100644
index 0000000..0cfa15f
--- /dev/null
+++ b/apps/ui/src/render/widgets/MetricCompositeWidget.vue
@@ -0,0 +1,166 @@
+<!--
+  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.
+-->
+<!--
+  Composable multi-metric tile. Renders an operator-supplied KPI list
+  as two visual sections:
+    - Number-style KPIs → count grid (auto-fit tiles, big number on top,
+      small label underneath).
+    - Progress-bar style KPIs → bar grid (label + value on the same row,
+      a horizontal fill below).
+
+  Replaces the bespoke k8s-summary / pilot-summary widgets — those
+  shipped as hardcoded KPI sets; this widget defers to the bundled
+  JSON's `kpis` array so operators can edit them inline through the
+  Overview templates admin. The bundled JSONs that used to ship
+  k8s-summary / pilot-summary now ship metric-composite + explicit
+  KPI lists.
+
+  Routing: clicking the tile navigates to the layer's Service tab —
+  same affordance the prior summary widgets had.
+-->
+<script setup lang="ts">
+import { computed } from 'vue';
+import { RouterLink } from 'vue-router';
+import type { OverviewKpi } from '@skywalking-horizon-ui/api-client';
+import { formatValue } from './ValueFormat';
+
+const props = defineProps<{
+  title: string;
+  tip?: string;
+  layer?: string;
+  kpiValues: Record<string, number | null>;
+  /** Required — the widget renders exactly what the operator
+   *  configures. Omitting this on a bundled JSON leaves the tile
+   *  empty (no fallback defaults). */
+  kpis?: OverviewKpi[];
+}>();
+
+const rows = computed<readonly OverviewKpi[]>(() => props.kpis ?? []);
+
+/* Split treatment: explicit progress-bar style OR percent unit goes
+ * to the bar section; everything else renders as a count tile. */
+function isBar(k: OverviewKpi): boolean {
+  return k.style === 'progress-bar' || k.unit === '%';
+}
+const counts = computed(() => rows.value.filter((k) => !isBar(k)));
+const bars = computed(() => rows.value.filter(isBar));
+
+const tileTo = computed(() =>
+  props.layer ? `/layer/${props.layer.toLowerCase()}/service` : '',
+);
+
+/* Bar fill: when `max` is set the bar plots `value / max`; otherwise
+ * the value is assumed to already be a 0..100 percentage (matches
+ * the convention for `unit: '%'` metrics). */
+function pct(v: number | null | undefined, max?: number): string {
+  if (v === null || v === undefined || !Number.isFinite(v)) return '0%';
+  const ratio = max && max > 0 ? (v / max) * 100 : v;
+  return `${Math.max(0, Math.min(100, ratio)).toFixed(1)}%`;
+}
+</script>
+
+<template>
+  <component :is="tileTo ? RouterLink : 'div'" :to="tileTo || undefined" 
class="tile-link">
+    <section class="sw-card mc">
+      <header>
+        <h4>{{ title }}</h4>
+        <span v-if="tip" class="tip" :title="tip">?</span>
+      </header>
+      <div v-if="rows.length === 0" class="empty">
+        No metrics configured yet — open 
<code>/admin/overview-templates</code> to add KPI rows.
+      </div>
+      <template v-else>
+        <div v-if="counts.length > 0" class="counts">
+          <div v-for="k in counts" :key="k.label" class="count">
+            <span class="count-value">{{ formatValue(kpiValues[k.label], 
k.unit) }}</span>
+            <span class="count-label">{{ k.label }}</span>
+          </div>
+        </div>
+        <div v-if="bars.length > 0" class="bars">
+          <div v-for="k in bars" :key="k.label" class="bar-row">
+            <div class="bar-head">
+              <span class="bar-label">{{ k.label }}</span>
+              <span class="bar-value">{{ formatValue(kpiValues[k.label], 
k.unit ?? '%') }}</span>
+            </div>
+            <div class="bar">
+              <span class="fill" :style="{ width: pct(kpiValues[k.label], 
k.max) }" />
+            </div>
+          </div>
+        </div>
+      </template>
+    </section>
+  </component>
+</template>
+
+<style scoped>
+.tile-link { display: block; text-decoration: none; color: inherit; height: 
100%; }
+.tile-link:hover .sw-card { border-color: var(--sw-line-3); }
+.mc { display: flex; flex-direction: column; padding: 12px 14px; gap: 12px; 
min-height: 0; height: 100%; }
+header { display: flex; align-items: center; gap: 6px; }
+h4 { margin: 0; font-size: 12px; font-weight: 600; color: var(--sw-fg-0); }
+.tip {
+  font-size: 9px; color: var(--sw-fg-3); border: 1px solid var(--sw-line-2);
+  border-radius: 50%; width: 13px; height: 13px;
+  display: inline-flex; align-items: center; justify-content: center; cursor: 
help;
+}
+.empty {
+  font-size: 11px; color: var(--sw-fg-3); font-style: italic;
+  padding: 14px 8px; text-align: center;
+}
+.empty code {
+  font-family: var(--sw-mono); font-size: 10.5px;
+  color: var(--sw-fg-2); background: var(--sw-bg-2);
+  padding: 1px 4px; border-radius: 3px;
+}
+/* Auto-fit count tiles: as many as fit at ~100px min width per
+ * tile — same layout principle as the prior k8s-summary widget
+ * but generalised, so 4 KPIs sit in a 4-up row and 8 KPIs wrap to
+ * two rows of 4 (depending on widget width). */
+.counts {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
+  gap: 8px;
+}
+.count {
+  display: flex; flex-direction: column; gap: 2px;
+  padding: 8px 10px;
+  background: var(--sw-bg-1);
+  border: 1px solid var(--sw-line);
+  border-radius: 6px;
+}
+.count-value {
+  font-size: 18px; font-weight: 600; color: var(--sw-fg-0);
+  font-variant-numeric: tabular-nums;
+}
+.count-label {
+  font-size: 10px; color: var(--sw-fg-3);
+  text-transform: uppercase; letter-spacing: 0.05em;
+}
+.bars {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+  gap: 12px;
+}
+.bar-head { display: flex; justify-content: space-between; margin-bottom: 4px; 
}
+.bar-label { font-size: 11px; color: var(--sw-fg-2); }
+.bar-value {
+  font-size: 12px; font-weight: 600; color: var(--sw-fg-0);
+  font-variant-numeric: tabular-nums;
+}
+.bar { height: 4px; background: var(--sw-bg-2); border-radius: 2px; overflow: 
hidden; }
+.fill { display: block; height: 100%; background: var(--sw-accent); 
transition: width 0.3s; }
+</style>
diff --git a/apps/ui/src/render/widgets/PilotSummaryWidget.vue 
b/apps/ui/src/render/widgets/PilotSummaryWidget.vue
deleted file mode 100644
index 3bb358e..0000000
--- a/apps/ui/src/render/widgets/PilotSummaryWidget.vue
+++ /dev/null
@@ -1,87 +0,0 @@
-<!--
-  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.
--->
-<!--
-  Istio pilot block. xDS push count, reject errors, write timeouts, and
-  p95 push time — sourced from the MESH_CP layer via the standard
-  landing aggregate. Click-through opens the MESH_CP layer's Instance
-  tab (where per-pilot drill-down lives).
--->
-<script setup lang="ts">
-import { computed } from 'vue';
-import { RouterLink } from 'vue-router';
-import { PILOT_SUMMARY_KPIS } from '@/render/overview/useOverviewDashboard';
-import { formatValue } from './ValueFormat';
-
-const props = defineProps<{
-  title: string;
-  tip?: string;
-  layer?: string;
-  kpiValues: Record<string, number | null>;
-}>();
-
-const tileTo = computed(() =>
-  props.layer ? `/layer/${props.layer.toLowerCase()}/instance` : '',
-);
-</script>
-
-<template>
-  <component :is="tileTo ? RouterLink : 'div'" :to="tileTo || undefined" 
class="tile-link">
-    <section class="sw-card pilot">
-      <header>
-        <h4>{{ title }}</h4>
-        <span v-if="tip" class="tip" :title="tip">?</span>
-      </header>
-      <div class="kpis">
-        <div v-for="k in PILOT_SUMMARY_KPIS" :key="k.label" class="kpi">
-          <span class="kpi-value">{{ formatValue(kpiValues[k.label], k.unit) 
}}</span>
-          <span class="kpi-label">{{ k.label }}</span>
-        </div>
-      </div>
-    </section>
-  </component>
-</template>
-
-<style scoped>
-.tile-link { display: block; text-decoration: none; color: inherit; height: 
100%; }
-.tile-link:hover .sw-card { border-color: var(--sw-line-3); }
-.pilot { display: flex; flex-direction: column; padding: 12px 14px; gap: 12px; 
min-height: 0; height: 100%; }
-header { display: flex; align-items: center; gap: 6px; }
-h4 { margin: 0; font-size: 12px; font-weight: 600; color: var(--sw-fg-0); }
-.tip {
-  font-size: 9px; color: var(--sw-fg-3); border: 1px solid var(--sw-line-2);
-  border-radius: 50%; width: 13px; height: 13px;
-  display: inline-flex; align-items: center; justify-content: center; cursor: 
help;
-}
-.kpis {
-  display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; flex: 1;
-}
-.kpi {
-  display: flex; flex-direction: column; gap: 4px;
-  padding: 10px 12px;
-  background: var(--sw-bg-1);
-  border: 1px solid var(--sw-line);
-  border-radius: 6px;
-  justify-content: center;
-}
-.kpi-value {
-  font-size: 20px; font-weight: 600; color: var(--sw-fg-0);
-  font-variant-numeric: tabular-nums;
-}
-.kpi-label {
-  font-size: 10.5px; color: var(--sw-fg-3); text-transform: uppercase; 
letter-spacing: 0.05em;
-}
-</style>
diff --git a/apps/ui/src/render/widgets/ServiceCountWidget.vue 
b/apps/ui/src/render/widgets/ServiceCountWidget.vue
deleted file mode 100644
index a88e252..0000000
--- a/apps/ui/src/render/widgets/ServiceCountWidget.vue
+++ /dev/null
@@ -1,45 +0,0 @@
-<!--
-  Licensed to the Apache Software Foundation (ASF) under one or more
-  contributor license agreements.  See the NOTICE file distributed with
-  this work for additional information regarding copyright ownership.
-  The ASF licenses this file to You under the Apache License, Version 2.0
-  (the "License"); you may not use this file except in compliance with
-  the License.  You may obtain a copy of the License at
-
-      http://www.apache.org/licenses/LICENSE-2.0
-
-  Unless required by applicable law or agreed to in writing, software
-  distributed under the License is distributed on an "AS IS" BASIS,
-  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-  See the License for the specific language governing permissions and
-  limitations under the License.
--->
-<script setup lang="ts">
-import { formatValue } from './ValueFormat';
-defineProps<{ title: string; tip?: string; value: number | null | undefined 
}>();
-</script>
-
-<template>
-  <section class="sw-card tile">
-    <header>
-      <h4>{{ title }}</h4>
-      <span v-if="tip" class="tip" :title="tip">?</span>
-    </header>
-    <div class="value">{{ formatValue(value) }}</div>
-  </section>
-</template>
-
-<style scoped>
-.tile { display: flex; flex-direction: column; padding: 10px 12px; gap: 4px; 
min-height: 0; }
-header { display: flex; align-items: center; gap: 6px; }
-h4 { margin: 0; font-size: 11px; font-weight: 600; color: var(--sw-fg-1); }
-.tip {
-  font-size: 9px; color: var(--sw-fg-3); border: 1px solid var(--sw-line-2);
-  border-radius: 50%; width: 13px; height: 13px;
-  display: inline-flex; align-items: center; justify-content: center; cursor: 
help;
-}
-.value {
-  font-size: 22px; font-weight: 600; color: var(--sw-fg-0); margin-top: auto;
-  font-variant-numeric: tabular-nums;
-}
-</style>
diff --git a/packages/api-client/src/overview.ts 
b/packages/api-client/src/overview.ts
index f248f5a..0578658 100644
--- a/packages/api-client/src/overview.ts
+++ b/packages/api-client/src/overview.ts
@@ -53,21 +53,46 @@
  */
 
 export type OverviewWidgetType =
-  | 'service-count'
   | 'metric'
   | 'topology'
   | 'section-break'
   | 'kpi-tile'
   | 'alarms'
-  | 'k8s-summary'
-  | 'pilot-summary';
+  | 'metric-composite';
 
-/** One KPI row inside a `kpi-tile` widget. */
+/** Visual style for a KPI row.
+ *   - `number`        — value rendered as a right-aligned number with
+ *                       an optional unit suffix. The default.
+ *   - `progress-bar`  — value rendered as a fill against `max`. Use
+ *                       for SLA, utilisation, or other 0..N metrics
+ *                       where the operator wants "how close to the
+ *                       ceiling" at a glance instead of the raw digit. */
+export type OverviewKpiStyle = 'number' | 'progress-bar';
+
+/** Value source for a KPI row.
+ *   - `mqe`            — execute the row's `mqe` expression against
+ *                        the widget's layer. The default.
+ *   - `service-count`  — count the layer's services via `listServices`
+ *                        and use the count as the row's value. Lets a
+ *                        `metric-composite` widget mix "5 services"
+ *                        with MQE-derived numbers like RPM / latency. */
+export type OverviewKpiSource = 'mqe' | 'service-count';
+
+/** One KPI row inside a `kpi-tile` / `metric-composite` widget. */
 export interface OverviewKpi {
   label: string;
-  mqe: string;
+  /** Required when `source === 'mqe'` (the default); ignored when
+   *  `source === 'service-count'`. */
+  mqe?: string;
   unit?: string;
   aggregation?: 'sum' | 'avg';
+  /** Defaults to `'number'`. */
+  style?: OverviewKpiStyle;
+  /** Required when `style === 'progress-bar'` — the value plotted at
+   *  100% fill. Ignored otherwise. */
+  max?: number;
+  /** Defaults to `'mqe'`. */
+  source?: OverviewKpiSource;
 }
 
 export interface OverviewWidget {

Reply via email to