This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch feat/layer-group-menu-split
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit bd021efc38a5cb4e460f8ed4ff425e2bdd043f8e
Author: Wu Sheng <[email protected]>
AuthorDate: Thu Jun 18 17:17:29 2026 +0800

    feat(layers): split a layer's menu by service group, + resizable sidebar
    
    Opt-in per-layer "Split menu by service group" (template
    `splitByServiceGroup`, default off, toggled in the Layer dashboards admin
    right after Alias) fans a layer into one level-0 sidebar entry per OAP
    `Service.group`, each scoped to its group.
    
    - The shared ServiceLayerCatalog fan-out now carries `Service.group`; the
      menu route enumerates distinct groups per opted-in layer and emits one
      LayerDef per group — composite `<layer>~<group>` key, group-first
      display name (`agent · General Service`, so it reads on the header /
      KPI tile and survives narrow-sidebar truncation), per-group count.
    - The UI api-client splits the composite key on the first `~` into the
      real layer + a `?group=` query, so the 18 layer-view components stay
      untouched; the service-scoped BFF endpoints (services, landing,
      topology seed, dashboard auto-pick) filter the roster by `Service.group`.
    - Split entries stay contiguous in the sidebar (base-layer priority, then
      group). Group is OAP data — shown verbatim, never translated; no new
      i18n keys. Bundled templates unchanged (default off).
    
    Also:
    - The per-layer service picker shows each service's group chip even on
      layers with no topology-cluster naming rule (was cluster-only, blank on
      GENERAL).
    - The navigation sidebar is drag-resizable: a divider drives `--sw-side-w`
      (persisted per browser, double-click to reset, clamped 160-480px).
---
 CHANGELOG.md                                       |  3 ++
 apps/bff/src/http/query/dashboard.ts               | 17 +++---
 apps/bff/src/http/query/landing.ts                 |  6 +++
 apps/bff/src/http/query/menu.ts                    | 48 +++++++++++++++--
 apps/bff/src/http/query/services.ts                |  6 ++-
 apps/bff/src/http/query/topology.ts                |  8 ++-
 apps/bff/src/logic/layers/loader.ts                |  5 ++
 .../src/logic/services/service-layer-catalog.ts    | 14 +++--
 apps/ui/src/api/client.ts                          | 19 ++++++-
 apps/ui/src/controls/sidebar.ts                    | 36 +++++++++++++
 .../admin/layer-templates/LayerDashboardsAdmin.vue | 23 +++++++++
 apps/ui/src/layer/LayerServiceSelector.vue         | 32 ++++++++----
 apps/ui/src/shell/AppShell.vue                     | 60 ++++++++++++++++++++--
 apps/ui/src/shell/useLandingOrder.ts               | 10 +++-
 docs/customization/layer-templates.md              |  1 +
 packages/api-client/src/menu.ts                    |  8 +++
 packages/design-tokens/src/tokens.css              |  6 ++-
 17 files changed, 268 insertions(+), 34 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 80ff715..2c1c641 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,9 @@ The version line is shared by every package in the monorepo 
(apps + shared packa
 
 ### Layers
 
+- **Split a layer's menu by service group.** A new per-layer **Split menu by 
service group** toggle (Layer dashboards admin, right after **Alias**; default 
off) fans the layer out into one **level-0 sidebar entry per OAP 
`Service.group`** — the `<group>::` prefix. The entry's display name leads with 
the group so it reads everywhere (sidebar, page header, KPI tile) and survives 
narrow-sidebar truncation — e.g. `agent · General Service`. Each entry is 
scoped to its group: the service heade [...]
+- **The navigation sidebar is now resizable** — drag the divider between the 
sidebar and the page to widen or narrow it (double-click the divider to reset 
to the default width); the chosen width persists per browser. Useful when long 
entries — like group-split names (`agent · General Service`) or deep namespaces 
— would otherwise truncate.
+- **The per-layer service picker shows each service's group.** When a layer 
has no topology-cluster naming rule, the service-list rows now surface the OAP 
`<group>::` prefix (e.g. `agent`) as the group chip — so the group is visible 
there as it already is on the topology map.
 - **Every layer OAP reports now appears in the sidebar**, including ones with 
no Horizon template (they render with default capabilities — a plain Service 
page). The previous hard-coded hidden-layer list (which dropped `BanyanDB`) is 
gone; a layer is hidden only when an admin explicitly disables its template, or 
when it is listed in the new config-driven `layers.excluded` block in 
`horizon.yaml` (defaults: `FAAS` and `VIRTUAL_GATEWAY`; clear the list to 
surface every reported layer).
 - **The admin Layer dashboards page is now layer-list-oriented.** It lists 
every available layer — not just the ones shipping a bundled JSON or living on 
OAP. A layer with no template yet opens on a blank default you can configure 
(components, metric columns, widgets, topology) and **Save**, which publishes 
the template to OAP on first save. No per-layer JSON has to be shipped for a 
layer to be configurable. The picker gains a **Not configured** filter (beside 
Diverged / Local) and the s [...]
 - **Removed the legacy per-layer `overview` block** from every bundled layer 
template (and its translation overlays). It no longer rendered anything — the 
standalone **Overview Dashboards** replaced the old per-layer Overview tile — 
so it was dead config; the per-layer KPI strip is driven by the layer-header 
columns.
diff --git a/apps/bff/src/http/query/dashboard.ts 
b/apps/bff/src/http/query/dashboard.ts
index 6d152f9..b679e4e 100644
--- a/apps/bff/src/http/query/dashboard.ts
+++ b/apps/bff/src/http/query/dashboard.ts
@@ -192,7 +192,7 @@ export interface MqeResultShape {
 
 const LIST_FIRST_SERVICE = /* GraphQL */ `
   query FirstService($layer: String!) {
-    services: listServices(layer: $layer) { id name normal }
+    services: listServices(layer: $layer) { id name normal group }
   }
 `;
 
@@ -580,12 +580,15 @@ export function registerDashboardQueryRoute(app: 
FastifyInstance, deps: Dashboar
       // those layers comes back null because the entity-scope filter
       // doesn't match the data dimension OAP stored them under.
       try {
-        const data = await graphqlPost<{ services: Array<{ id: string; name: 
string; normal: boolean }> }>(
-          opts,
-          LIST_FIRST_SERVICE,
-          { layer: layerKey.toUpperCase() },
-        );
+        const data = await graphqlPost<{
+          services: Array<{ id: string; name: string; normal: boolean; group?: 
string | null }>;
+        }>(opts, LIST_FIRST_SERVICE, { layer: layerKey.toUpperCase() });
         const all = data.services ?? [];
+        // `?group=` (split-by-service-group menu entry) constrains the
+        // auto-pick default to that OAP Service.group; an explicit
+        // `service` is honored regardless of group.
+        const group = (req.query as { group?: string }).group;
+        const inGroup = group === undefined ? all : all.filter((s) => (s.group 
?? '') === group);
         let picked: { id: string; name: string; normal: boolean } | undefined;
         if (serviceName) {
           picked = all.find((s) => s.name === serviceName) ?? all.find((s) => 
s.id === serviceName);
@@ -597,7 +600,7 @@ export function registerDashboardQueryRoute(app: 
FastifyInstance, deps: Dashboar
             });
           }
         } else {
-          picked = all[0];
+          picked = inGroup[0];
           if (!picked) {
             return reply.send({
               ...baseResp,
diff --git a/apps/bff/src/http/query/landing.ts 
b/apps/bff/src/http/query/landing.ts
index 3f8ef37..5f176e7 100644
--- a/apps/bff/src/http/query/landing.ts
+++ b/apps/bff/src/http/query/landing.ts
@@ -285,6 +285,12 @@ export function registerLandingRoute(app: FastifyInstance, 
deps: LandingRouteDep
           { layer: oapLayer },
         );
         services = data.services ?? [];
+        // Optional `?group=` (split-by-service-group menu entry) — narrow
+        // the roster to that OAP Service.group before the top-N rollup.
+        const group = (req.query as { group?: string }).group;
+        if (group !== undefined) {
+          services = services.filter((s) => ((s as { group?: string }).group 
?? '') === group);
+        }
       } catch (err) {
         const body: LandingResponse = {
           layer: layerKey,
diff --git a/apps/bff/src/http/query/menu.ts b/apps/bff/src/http/query/menu.ts
index 7b6750a..2429a0b 100644
--- a/apps/bff/src/http/query/menu.ts
+++ b/apps/bff/src/http/query/menu.ts
@@ -360,10 +360,20 @@ export function registerMenuRoute(app: FastifyInstance, 
deps: MenuRouteDeps): vo
       const catalog = await deps.serviceCatalog.get();
       const countByCanonical = new Map<string, number>();
       const normalByCanonical = new Map<string, boolean | null>();
+      // Per-group service counts per canonical layer — drives the optional
+      // per-group menu split (`splitByServiceGroup`). Keyed by the OAP
+      // `Service.group` ('' = ungrouped).
+      const groupsByCanonical = new Map<string, Map<string, number>>();
       for (const rawLayer of raw.layers) {
         const key = canonical(rawLayer);
         const rows = catalog.byLayer.get(rawLayer) ?? [];
         countByCanonical.set(key, (countByCanonical.get(key) ?? 0) + 
rows.length);
+        let gm = groupsByCanonical.get(key);
+        if (!gm) {
+          gm = new Map<string, number>();
+          groupsByCanonical.set(key, gm);
+        }
+        for (const r of rows) gm.set(r.group, (gm.get(r.group) ?? 0) + 1);
         // First non-null `normal` value wins for the canonical key —
         // raw layers that fold into one canonical (e.g. mesh / mesh_cp)
         // share the same `normal` in practice, so collisions are safe.
@@ -404,8 +414,8 @@ export function registerMenuRoute(app: FastifyInstance, 
deps: MenuRouteDeps): vo
       // like disabled overviews) or config-excluded (`layers.excluded`).
       const layers = ordered
         .filter((key) => !disabled.has(key) && 
!excludedLayers.has(key.toUpperCase()))
-        .map((key) =>
-          deriveLayer(
+        .flatMap((key): LayerDef[] => {
+          const base = deriveLayer(
             key,
             activeCanonical.has(key),
             levelByCanonical.has(key) ? (levelByCanonical.get(key) ?? null) : 
null,
@@ -414,8 +424,38 @@ export function registerMenuRoute(app: FastifyInstance, 
deps: MenuRouteDeps): vo
             locale,
             layerRowsByName,
             oapOverlayContentFromRows(rows, 'layer', key, locale),
-          ),
-        );
+          );
+          // Per-group menu split — opt-in per layer via the template. One
+          // level-0 entry per distinct OAP Service.group (sorted; '' =
+          // ungrouped → plain layer name). The composite `<key>~<group>`
+          // keeps sidebar identity unique while the REAL layer key stays
+          // `base.key` (so routes / the BFF still see `general`, with the
+          // group carried separately as `?group=`). Off ⇒ one combined entry.
+          const tpl = resolveLayerTemplate(key, layerRowsByName);
+          const groups = groupsByCanonical.get(key);
+          if (!tpl?.splitByServiceGroup || !groups || groups.size === 0) 
return [base];
+          return [...groups.keys()]
+            .sort((a, b) => a.localeCompare(b))
+            .map((g): LayerDef => ({
+              // The display NAME carries the group when split (`General
+              // Service · agent`), so every surface — sidebar, page header,
+              // landing KPI tile — reads the group, not just one sidebar
+              // tag. `serviceGroup` still carries the raw value for
+              // ordering / data scoping. The composite `<layerKey>~<group>`
+              // is the route + sidebar key; the UI api-client splits it on
+              // the first `~` into the real layer key + `?group=` for the
+              // BFF (raw group — OAP groups are service-name-shaped,
+              // URL-safe; layer keys never contain `~`).
+              ...base,
+              key: `${base.key}~${g}`,
+              serviceGroup: g,
+              // Group FIRST so the distinguishing part survives sidebar
+              // truncation (`agent · General Ser…` rather than
+              // `General Service · …`).
+              name: g ? `${g} · ${base.name}` : base.name,
+              serviceCount: groups.get(g) ?? 0,
+            }));
+        });
 
       const body: MenuResponse = {
         layers,
diff --git a/apps/bff/src/http/query/services.ts 
b/apps/bff/src/http/query/services.ts
index 43e0f63..68bf1b8 100644
--- a/apps/bff/src/http/query/services.ts
+++ b/apps/bff/src/http/query/services.ts
@@ -61,9 +61,13 @@ export function registerLayerServicesRoute(
         return reply.code(400).send({ error: 'invalid_layer_key' });
       }
       const layerUpper = layerKey.toUpperCase();
+      // Optional `?group=` (from a split-by-service-group menu entry) —
+      // narrow the roster to that OAP Service.group. Absent ⇒ all groups.
+      const group = (req.query as { group?: string }).group;
       try {
         const snap = await catalog.get();
-        const rows = snap.byLayer.get(layerUpper) ?? [];
+        const all = snap.byLayer.get(layerUpper) ?? [];
+        const rows = group === undefined ? all : all.filter((r) => r.group === 
group);
         return reply.send({
           reachable: true,
           layer: layerUpper,
diff --git a/apps/bff/src/http/query/topology.ts 
b/apps/bff/src/http/query/topology.ts
index 1794b6d..cbe4d32 100644
--- a/apps/bff/src/http/query/topology.ts
+++ b/apps/bff/src/http/query/topology.ts
@@ -101,6 +101,7 @@ const LIST_SERVICES_FOR_RESOLVE = /* GraphQL */ `
       id
       name
       normal
+      group
     }
   }
 `;
@@ -358,7 +359,12 @@ export function registerTopologyRoute(app: 
FastifyInstance, deps: TopologyRouteD
           // batch-size worry, but the MQE step already chunks at 150
           // fragments per query (see below), so a layer with hundreds
           // of services scales fine.
-          seedIds = data.services.map((s) => s.id);
+          // `?group=` (split-by-service-group menu entry) scopes the
+          // layer-overview seed to one OAP Service.group; absent ⇒ all.
+          const group = (req.query as { group?: string }).group;
+          seedIds = data.services
+            .filter((s) => group === undefined || ((s as { group?: string 
}).group ?? '') === group)
+            .map((s) => s.id);
           // Debug log so the response size is visible while we
           // diagnose why layers with many services come back small.
           console.log(`[topology] layer=${oapLayer} 
seed-services=${seedIds.length}`);
diff --git a/apps/bff/src/logic/layers/loader.ts 
b/apps/bff/src/logic/layers/loader.ts
index 8f903db..94474dc 100644
--- a/apps/bff/src/logic/layers/loader.ts
+++ b/apps/bff/src/logic/layers/loader.ts
@@ -188,6 +188,11 @@ export interface LayerTemplate {
   key: string;
   /** Display name override. */
   alias?: string;
+  /** When true, split this layer into one sidebar menu entry PER OAP
+   *  `Service.group`, each scoped to that group. Off (default) keeps the
+   *  single combined entry holding all groups. Edited in the Layer
+   *  dashboards admin (after Alias); travels with template export/import. */
+  splitByServiceGroup?: boolean;
   /** Sidebar grouping label. Layers that share a `group` value collapse
    *  under one expandable section in the sidebar — used to keep related
    *  layers together (Istio Managed SVCs / Control Plane / Data Plane
diff --git a/apps/bff/src/logic/services/service-layer-catalog.ts 
b/apps/bff/src/logic/services/service-layer-catalog.ts
index adb88d9..bf91dea 100644
--- a/apps/bff/src/logic/services/service-layer-catalog.ts
+++ b/apps/bff/src/logic/services/service-layer-catalog.ts
@@ -51,6 +51,10 @@ export interface ServiceRow {
   /** Per-layer `normal` flag from `listServices` — drives MQE entity
    *  scope (`{ normal: true|false }`) without a second roundtrip. */
   normal: boolean | null;
+  /** OAP `Service.group` — the `<group>::` prefix (empty string when the
+   *  service has no group). Drives the per-group menu split + the
+   *  `?group=` service filter. */
+  group: string;
 }
 
 export interface ServiceCatalog {
@@ -124,14 +128,13 @@ export class ServiceLayerCatalog {
     // One aliased GraphQL call instead of N separate roundtrips —
     // a single TCP/TLS handshake amortises across every layer.
     const aliased = layers
-      .map((l, i) => `_${i}: listServices(layer: ${JSON.stringify(l)}) { id 
name normal }`)
+      .map((l, i) => `_${i}: listServices(layer: ${JSON.stringify(l)}) { id 
name normal group }`)
       .join('\n');
     const query = `query HorizonServiceCatalogServices { ${aliased} }`;
     try {
-      const data = await graphqlPost<Record<string, Array<{ id: string; name: 
string; normal?: boolean | null }>>>(
-        opts,
-        query,
-      );
+      const data = await graphqlPost<
+        Record<string, Array<{ id: string; name: string; normal?: boolean | 
null; group?: string | null }>>
+      >(opts, query);
       const byLayer = new Map<string, ServiceRow[]>();
       const byName = new Map<string, string>();
       layers.forEach((layer, i) => {
@@ -139,6 +142,7 @@ export class ServiceLayerCatalog {
           id: r.id,
           name: r.name,
           normal: r.normal === true ? true : r.normal === false ? false : null,
+          group: r.group ?? '',
         }));
         byLayer.set(layer, rows);
         for (const r of rows) if (r.name) byName.set(r.name.toLowerCase(), 
layer);
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index 935a13a..f290628 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -279,6 +279,9 @@ export interface MeResponse {
 export interface AdminLayerTemplate {
   key: string;
   alias?: string;
+  /** When true, the sidebar splits this layer into one entry per OAP
+   *  `Service.group`. Edited via the toggle after Alias. Default off. */
+  splitByServiceGroup?: boolean;
   color?: string;
   documentLink?: string;
   /** `public` (default) surfaces in the Layers section; `operate`
@@ -754,7 +757,21 @@ export class BffClient {
       },
     };
     if (body !== undefined) init.body = JSON.stringify(body);
-    const url = withBase(path);
+    // Split a composite `<layer>~<group>` key in a `/api/layer/<key>/…`
+    // path into the real layer + a `group` query param, so a
+    // split-by-service-group menu entry scopes its data without every
+    // endpoint having to parse the composite. The slices stay
+    // URL-encoded (the caller already `encodeURIComponent`'d the key, and
+    // `~` is unreserved). Non-layer paths and plain keys pass through.
+    const splitGroupKeyPath = (p: string): string => {
+      const m = /^(\/api\/layer\/)([^/?]+)(.*)$/.exec(p);
+      if (!m) return p;
+      const i = m[2].indexOf('~');
+      if (i < 0) return p;
+      const sep = m[3].includes('?') ? '&' : '?';
+      return `${m[1]}${m[2].slice(0, i)}${m[3]}${sep}group=${m[2].slice(i + 
1)}`;
+    };
+    const url = withBase(splitGroupKeyPath(path));
     let res: Response;
     try {
       res = await fetch(url, init);
diff --git a/apps/ui/src/controls/sidebar.ts b/apps/ui/src/controls/sidebar.ts
index 1628ff3..a1f6764 100644
--- a/apps/ui/src/controls/sidebar.ts
+++ b/apps/ui/src/controls/sidebar.ts
@@ -26,13 +26,29 @@
 import { ref, watch } from 'vue';
 
 const STORAGE_KEY = 'horizon:sidebarCollapsed:v1';
+const WIDTH_KEY = 'horizon:sidebarWidth:v1';
+
+/** Expanded sidebar width bounds (px). The grab handle clamps to these;
+ *  `SIDEBAR_DEFAULT_WIDTH` matches the `--sw-side-w` fallback in tokens.css. 
*/
+export const SIDEBAR_MIN_WIDTH = 160;
+export const SIDEBAR_MAX_WIDTH = 480;
+export const SIDEBAR_DEFAULT_WIDTH = 220;
 
 function detectInitial(): boolean {
   if (typeof localStorage === 'undefined') return false;
   return localStorage.getItem(STORAGE_KEY) === '1';
 }
 
+function detectWidth(): number {
+  if (typeof localStorage === 'undefined') return SIDEBAR_DEFAULT_WIDTH;
+  const raw = Number(localStorage.getItem(WIDTH_KEY));
+  return Number.isFinite(raw) && raw >= SIDEBAR_MIN_WIDTH && raw <= 
SIDEBAR_MAX_WIDTH
+    ? raw
+    : SIDEBAR_DEFAULT_WIDTH;
+}
+
 const collapsed = ref<boolean>(detectInitial());
+const width = ref<number>(detectWidth());
 
 watch(collapsed, (on) => {
   if (typeof localStorage === 'undefined') return;
@@ -43,14 +59,34 @@ watch(collapsed, (on) => {
   }
 });
 
+watch(width, (w) => {
+  if (typeof localStorage === 'undefined') return;
+  try {
+    localStorage.setItem(WIDTH_KEY, String(Math.round(w)));
+  } catch {
+    /* private mode / quota — degrade silently */
+  }
+});
+
+/** Set the expanded sidebar width, clamped to the [min, max] bounds. */
+function setWidth(px: number): void {
+  width.value = Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, 
Math.round(px)));
+}
+
 export function useSidebar(): {
   collapsed: typeof collapsed;
+  width: typeof width;
   toggle: () => void;
+  setWidth: (px: number) => void;
+  resetWidth: () => void;
 } {
   return {
     collapsed,
+    width,
     toggle: () => {
       collapsed.value = !collapsed.value;
     },
+    setWidth,
+    resetWidth: () => setWidth(SIDEBAR_DEFAULT_WIDTH),
   };
 }
diff --git 
a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue 
b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
index d82ae75..e3ac142 100644
--- a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
+++ b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
@@ -2501,6 +2501,14 @@ const namingTest = computed<NamingTestResult>(() => {
                 />
                 <span class="alias-hint">Display name in the sidebar, layer 
list, and landing KPI tile. Defaults to the layer key.</span>
               </label>
+              <div class="alias-field">
+                <span>Group split</span>
+                <label class="split-check">
+                  <input type="checkbox" 
v-model="selectedTpl.splitByServiceGroup" />
+                  <span>Split this layer's menu by service group</span>
+                </label>
+                <span class="alias-hint">One sidebar entry per OAP 
<code>Service.group</code> (the <code>group::</code> prefix), each scoped to 
its group. Off keeps all groups in one menu.</span>
+              </div>
               <div class="setup-section-head">
                 <h4>Components</h4>
                 <span class="sub">which sub-views this layer exposes</span>
@@ -4658,6 +4666,21 @@ const namingTest = computed<NamingTestResult>(() => {
 }
 .alias-input:focus { outline: none; border-color: var(--sw-accent); }
 .alias-hint { font-size: 10.5px; color: var(--sw-fg-3); line-height: 1.4; }
+.alias-hint code {
+  font-family: var(--sw-mono);
+  background: var(--sw-bg-2);
+  padding: 0 3px;
+  border-radius: 3px;
+}
+.split-check {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  cursor: pointer;
+  font-size: 12px;
+  color: var(--sw-fg-1);
+}
+.split-check input { accent-color: var(--sw-accent); }
 .setup-section-head {
   display: flex;
   align-items: baseline;
diff --git a/apps/ui/src/layer/LayerServiceSelector.vue 
b/apps/ui/src/layer/LayerServiceSelector.vue
index f9a7f80..2689081 100644
--- a/apps/ui/src/layer/LayerServiceSelector.vue
+++ b/apps/ui/src/layer/LayerServiceSelector.vue
@@ -108,9 +108,22 @@ const page = ref(0);
 // service that ranked below the metric cap — id+name with no numbers.
 // `blank` flags OAP's synthetic `_blank` bucket — shown so the operator
 // knows it exists, but rendered disabled (never selectable / queryable).
+interface GroupChip { alias: string; value: string }
 type PickerRow =
-  | { kind: 'probed'; id: string; name: string; blank: boolean; row: 
LandingServiceRow }
-  | { kind: 'tail'; id: string; name: string; blank: boolean };
+  | { kind: 'probed'; id: string; name: string; blank: boolean; chip: 
GroupChip | null; row: LandingServiceRow }
+  | { kind: 'tail'; id: string; name: string; blank: boolean; chip: GroupChip 
| null };
+
+// The compact chip before a service name: the per-layer topology-cluster
+// (k8s / mesh namespace) when the naming rule matches, else OAP's own
+// `Service.group` (the `<group>::` prefix, e.g. `agent`) so the group is
+// visible even on layers with no cluster rule (GENERAL). Null when the
+// service has neither.
+function resolveChip(name: string, group?: string | null): GroupChip | null {
+  const id = identity(name);
+  if (id.cluster) return { alias: id.clusterAlias ?? '', value: id.cluster };
+  const g = group || id.legacyGroup;
+  return g ? { alias: 'group', value: g } : null;
+}
 
 // Probed rows first (already sorted by orderBy from the BFF), then the
 // roster tail that wasn't metric-probed. Without a roster prop the
@@ -121,6 +134,7 @@ const allRows = computed<PickerRow[]>(() => {
     id: r.serviceId,
     name: r.serviceName,
     blank: isBlankServiceName(r.serviceName),
+    chip: resolveChip(r.serviceName, r.group),
     row: r,
   }));
   const roster = props.roster;
@@ -128,7 +142,7 @@ const allRows = computed<PickerRow[]>(() => {
   const probedIds = new Set(probed.map((p) => p.id));
   const tail: PickerRow[] = roster
     .filter((r) => !probedIds.has(r.id))
-    .map((r) => ({ kind: 'tail', id: r.id, name: r.name, blank: 
isBlankServiceName(r.name) }));
+    .map((r) => ({ kind: 'tail', id: r.id, name: r.name, blank: 
isBlankServiceName(r.name), chip: resolveChip(r.name, null) }));
   return [...probed, ...tail];
 });
 
@@ -217,9 +231,9 @@ function colorForStatus(s: 'ok' | 'warn' | 'err'): string {
             </td>
             <td class="svc-col" :title="r.blank ? BLANK_SERVICE_NAME : r.name">
               <span class="pulse" :style="{ background: 
colorForStatus(statusForMetrics(r.row.metrics)) }" />
-              <span v-if="!r.blank && identity(r.name).cluster" 
class="group-chip">
-                <span class="chip-alias">{{ identity(r.name).clusterAlias 
}}</span>
-                <span class="chip-val">{{ identity(r.name).cluster }}</span>
+              <span v-if="!r.blank && r.chip" class="group-chip">
+                <span class="chip-alias">{{ r.chip.alias }}</span>
+                <span class="chip-val">{{ r.chip.value }}</span>
               </span>
               <span class="name-text">{{ r.blank ? BLANK_SERVICE_NAME : 
(r.row.shortName || identity(r.name).display) }}</span>
             </td>
@@ -257,9 +271,9 @@ function colorForStatus(s: 'ok' | 'warn' | 'err'): string {
             </td>
             <td class="svc-col" :title="r.blank ? BLANK_SERVICE_NAME : r.name">
               <span class="pulse tail-dot" />
-              <span v-if="!r.blank && identity(r.name).cluster" 
class="group-chip">
-                <span class="chip-alias">{{ identity(r.name).clusterAlias 
}}</span>
-                <span class="chip-val">{{ identity(r.name).cluster }}</span>
+              <span v-if="!r.blank && r.chip" class="group-chip">
+                <span class="chip-alias">{{ r.chip.alias }}</span>
+                <span class="chip-val">{{ r.chip.value }}</span>
               </span>
               <span class="name-text">{{ r.blank ? BLANK_SERVICE_NAME : 
identity(r.name).display }}</span>
             </td>
diff --git a/apps/ui/src/shell/AppShell.vue b/apps/ui/src/shell/AppShell.vue
index 16a0697..c5bc335 100644
--- a/apps/ui/src/shell/AppShell.vue
+++ b/apps/ui/src/shell/AppShell.vue
@@ -15,7 +15,7 @@
   limitations under the License.
 -->
 <script setup lang="ts">
-import { computed, onMounted } from 'vue';
+import { computed, onMounted, ref } from 'vue';
 import { RouterView } from 'vue-router';
 import { useI18n } from 'vue-i18n';
 import AppSidebar from './AppSidebar.vue';
@@ -107,13 +107,45 @@ const initReady = computed<boolean>(
 // 260px permanently would waste real estate.
 const { enabled: debugPanelEnabled } = useDebugPanel();
 // Drives the `.sw` grid column width — see `.sw.side-collapsed` below.
-const { collapsed: sidebarCollapsed } = useSidebar();
+const { collapsed: sidebarCollapsed, width: sidebarWidth, setWidth: 
setSidebarWidth, resetWidth: resetSidebarWidth } = useSidebar();
+
+// Drag-to-resize the expanded sidebar: a thin grab strip on the
+// sidebar/main boundary drives `--sw-side-w` (persisted in useSidebar).
+// Listeners live on `window` so the drag survives the cursor leaving the
+// 6px strip; no-op while collapsed.
+const resizingSidebar = ref(false);
+function startSidebarResize(e: PointerEvent): void {
+  if (sidebarCollapsed.value) return;
+  e.preventDefault();
+  resizingSidebar.value = true;
+  document.body.style.cursor = 'col-resize';
+  document.body.style.userSelect = 'none';
+  const onMove = (ev: PointerEvent): void => setSidebarWidth(ev.clientX);
+  const onUp = (): void => {
+    resizingSidebar.value = false;
+    document.body.style.cursor = '';
+    document.body.style.userSelect = '';
+    window.removeEventListener('pointermove', onMove);
+    window.removeEventListener('pointerup', onUp);
+  };
+  window.addEventListener('pointermove', onMove);
+  window.addEventListener('pointerup', onUp);
+}
 </script>
 
 <template>
-  <div class="sw" :class="{ 'side-collapsed': sidebarCollapsed }">
+  <div class="sw" :class="{ 'side-collapsed': sidebarCollapsed, resizing: 
resizingSidebar }" :style="{ '--sw-side-w': sidebarWidth + 'px' }">
     <AppSidebar />
     <AppTopbar />
+    <!-- Drag handle on the sidebar/main boundary; hidden when collapsed.
+         Double-click resets to the default width. -->
+    <div
+      v-if="!sidebarCollapsed"
+      class="sw-resize"
+      title="Drag to resize · double-click to reset"
+      @pointerdown="startSidebarResize"
+      @dblclick="resetSidebarWidth"
+    />
     <main class="sw-main" :class="{ 'has-debug-panel': debugPanelEnabled }">
       <!-- Sticky strip under the topbar; only renders when the graphql
            (`:12800`) poll reports unreachable. Admin-port (`:17128`)
@@ -171,6 +203,28 @@ const { collapsed: sidebarCollapsed } = useSidebar();
  * Animates in step with the sidebar's own collapse transition. */
 .sw.side-collapsed { grid-template-columns: 48px 1fr; }
 .sw { transition: grid-template-columns 160ms ease; }
+/* During a drag the column must follow the cursor 1:1, not lag through
+ * the collapse ease. */
+.sw.resizing { transition: none; }
+
+/* Drag-to-resize handle straddling the sidebar/main boundary. Tracks
+ * `--sw-side-w` live as the operator drags. Full height so it's easy to
+ * grab; transparent until hovered/active. While dragging the grid
+ * transition is suppressed (the column should follow the cursor 1:1,
+ * not lag through the 160ms ease). */
+.sw-resize {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  left: calc(var(--sw-side-w, 220px) - 3px);
+  width: 6px;
+  z-index: 50;
+  cursor: col-resize;
+}
+.sw-resize:hover,
+.sw-resize:active {
+  background: var(--sw-accent-line);
+}
 
 .sw-init {
   padding: 48px 20px;
diff --git a/apps/ui/src/shell/useLandingOrder.ts 
b/apps/ui/src/shell/useLandingOrder.ts
index a4ff146..1bdaade 100644
--- a/apps/ui/src/shell/useLandingOrder.ts
+++ b/apps/ui/src/shell/useLandingOrder.ts
@@ -41,9 +41,15 @@ export function useLandingOrder(layers: ComputedRef<readonly 
LayerDef[]>) {
   const store = useSetupStore();
   return computed<LayerDef[]>(() => {
     return [...layers.value].sort((a, b) => {
-      const pa = store.priorityFor(a.key);
-      const pb = store.priorityFor(b.key);
+      // A split layer's group-entries carry a composite `<layer>~<group>`
+      // key; resolve priority from the BASE layer so they all land in the
+      // layer's one slot, then keep them contiguous + group-sorted.
+      const ba = a.key.split('~', 1)[0];
+      const bb = b.key.split('~', 1)[0];
+      const pa = store.priorityFor(ba);
+      const pb = store.priorityFor(bb);
       if (pa !== pb) return pa - pb;
+      if (ba === bb) return (a.serviceGroup ?? 
'').localeCompare(b.serviceGroup ?? '');
       return 0; // preserve incoming catalog order
     });
   });
diff --git a/docs/customization/layer-templates.md 
b/docs/customization/layer-templates.md
index 02b7e66..18899e3 100644
--- a/docs/customization/layer-templates.md
+++ b/docs/customization/layer-templates.md
@@ -45,6 +45,7 @@ Every field is optional except `key`. Defaults are baked in 
for the rest.
 |---|---|---|---|
 | `key` | string (UPPER_SNAKE) | **required** | Matches the OAP layer enum. 
The filename is the lowercased key. |
 | `alias` | string | OAP-reported name | Display name in the sidebar and page 
headers. |
+| `splitByServiceGroup` | boolean | `false` | Split this layer into one 
sidebar entry per OAP service group (the `<group>::` prefix), each scoped to 
that group. Off keeps a single combined entry. Toggled in the admin right after 
**Alias**. |
 | `group` | string | — | Sidebar grouping label. Layers sharing a `group` 
collapse together. |
 | `visibility` | `public` \| `operate` | `public` | Section placement. 
`operate` puts the layer under the Operate group. |
 | `color` | string | `var(--sw-accent)` | Hex or CSS variable for the layer's 
accent. |
diff --git a/packages/api-client/src/menu.ts b/packages/api-client/src/menu.ts
index 8318856..beb6839 100644
--- a/packages/api-client/src/menu.ts
+++ b/packages/api-client/src/menu.ts
@@ -231,6 +231,14 @@ export interface LayerDef {
    *  sidebar (e.g. `Istio` aggregates mesh / mesh_cp / mesh_dp). When
    *  absent the layer renders at the top level on its own. */
   group?: string;
+  /** When this layer is split by service group (template
+   *  `splitByServiceGroup`), the OAP `Service.group` this menu entry is
+   *  scoped to. The sidebar appends it to the display name and forwards
+   *  it as `?group=`. Absent on unsplit layers. Distinct from `group`
+   *  above (the sidebar-section label). The `key` of a split entry is the
+   *  composite `<layerKey>~<serviceGroup>`; the real layer key is `key`
+   *  with the `~…` suffix stripped. */
+  serviceGroup?: string;
   /** Sidebar placement — `public` (default) for layers visible to
    *  everyone in the Layers section, `operate` for operations-only
    *  layers like the SO11Y self-observability tree that surface under
diff --git a/packages/design-tokens/src/tokens.css 
b/packages/design-tokens/src/tokens.css
index 73eb23b..c0deff7 100644
--- a/packages/design-tokens/src/tokens.css
+++ b/packages/design-tokens/src/tokens.css
@@ -157,8 +157,12 @@
   line-height: 1.45;
   font-feature-settings: "cv11", "ss01", "tnum";
   display: grid;
-  grid-template-columns: 220px 1fr;
+  /* `--sw-side-w` is driven by the drag-to-resize handle (AppShell);
+   *  falls back to 220px. The collapsed state overrides this column to a
+   *  48px rail. */
+  grid-template-columns: var(--sw-side-w, 220px) 1fr;
   grid-template-rows: 44px 1fr;
+  position: relative;
   grid-template-areas:
     "side  top"
     "side  main";


Reply via email to