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";
