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
commit 07c5076677dbba1d3a75f3ec1efb5e80bd7268c2 Author: Wu Sheng <[email protected]> AuthorDate: Fri May 15 09:34:27 2026 +0800 ui: log condition rebuild + sidebar URL-driven focus + BFF service-id fix Logs - Conditions bar restructured to match the trace tab: head row with kicker + orange `Run query` button on the right, conditions in a 4-col grid using `.cf` / `.cf-input` voice - `log.scope` typed as `'service' | 'instance' | 'endpoint'`; the three scopes drive which selectors render - Instance + Endpoint default to `All`; auto-pick disabled on logs (and traces) — operator opts into narrowing. Metrics-scope pages (Instance / Endpoint dashboards) keep their auto-pick - Endpoint search-and-select combobox (single input + dropdown, debounced OAP search-as-you-type, click-outside closes, × clears) - Tag autocomplete now uses OAP-native `queryLogTagAutocomplete{Keys,Values}` via new `/api/log-tags/{keys,values}` routes (replaces the sample-based suggest endpoint) - Tag input gets a `Tags` label; chips row reuses trace markup + css so the two pages read identically - Trace-ID is a plain input (no Pin/Clear button) - Time range with `Custom…` escape hatch (datetime-local inputs); BFF accepts explicit `startTime`/`endTime` alongside `windowMinutes` - YAML format detection, flat JSON / YAML inline preview that keeps row to one line (CSS-clipped), per-format chip on each row - Row click → full popout (no inline expand); ESC closes; copy button + tag table in popout - Density bar: x time / y count / level color + custom hover tooltip (replaces the `?` cursor + native title); 5 x-axis ticks underneath - Service chip dropped from the toolbar (duplicate with the layer header); sidecar picker still shown for instance-scope layers BFF service-id resolution - Tightened `<base64>.<digits>` heuristic across log / trace / instance / endpoint routes — the previous "contains `.`" check mis-classified mesh / k8s service names with embedded dots (`mesh-svr::r3-load.sample-services`) as ids and OAP returned empty / "service not found" Sidebar - URL-driven focus: navigating to `/layer/<key>/...` auto-expands the layer row AND its containing group, so deep links land with the right rails open + the active tab highlighted - Service tab visibility gated on `caps.dashboards` alone (drop the `slots.services` fallback). Fixes BanyanDB-style cases where the alias kept the tab visible after `components.service` was flipped off - Section headers (group + standalone) brightened, bolder font, accent-orange left rule on the open section, larger margin - General / Browser (ungrouped multi-feature) render as their own implicit section header so the hierarchy is consistent - `firstLayerTab(layer)` helper picks the first available sub- route from caps/slots; replaces the hardcoded `/service` - Drop the service-count chip from group rows Topbar - Breadcrumb uses layer aliases: `instance` → `Brokers` for ActiveMQ, `Sidecars` for mesh_dp, `Pages` for browser, … - Logs added to `TIME_RANGE_OPT_OUT` (already shipped, kept) Dashboard - First service auto-picked for every scope (not just instance) — Virtual MQ / Virtual Database land populated - Dashboard query gated on `service.value` for service/instance/ endpoint scopes so it doesn't fire once with null mesh_dp - Instance widget set restored to the upstream OAP UI template (the envoy_cluster_* family I had dropped in 0.2.0 — the MAL comment block I read was deprecated; live UI templates DO ship those metrics) Layers - BanyanDB template removed (BanyanDB is monitored under self-observability rather than a standalone Databases layer; falls back to BFF defaults if OAP still exposes a `BANYANDB` layer) - mesh_dp keeps the Self-Observability sidecar focus Topology - Node hexagons keep the same envelope as before; metric line suppressed for nodes with no RPM; right detail rail hidden until selection --- .../bff/src/bundled_templates/layers/banyandb.json | 263 ---------------- apps/bff/src/bundled_templates/layers/mesh_dp.json | 146 ++++++--- .../bundled_templates/layers/virtual_genai.json | 8 +- apps/bff/src/oap/endpoint-routes.ts | 7 +- apps/bff/src/oap/instance-routes.ts | 11 +- apps/bff/src/oap/log-routes.ts | 12 +- apps/bff/src/oap/menu-routes.ts | 2 + apps/bff/src/oap/trace-routes.ts | 9 +- apps/ui/src/components/shell/AppSidebar.vue | 82 +++-- apps/ui/src/components/shell/AppTopbar.vue | 56 +++- apps/ui/src/composables/useLayers.ts | 4 +- apps/ui/src/composables/useSelectedEndpoint.ts | 2 + apps/ui/src/composables/useSelectedInstance.ts | 2 + apps/ui/src/composables/useSelectedService.ts | 7 + apps/ui/src/views/layer/LayerDashboardsView.vue | 11 +- apps/ui/src/views/layer/LayerLogsView.vue | 347 ++++++++++++++++----- apps/ui/src/views/layer/LayerShell.vue | 19 +- packages/api-client/src/menu.ts | 2 + 18 files changed, 554 insertions(+), 436 deletions(-) diff --git a/apps/bff/src/bundled_templates/layers/banyandb.json b/apps/bff/src/bundled_templates/layers/banyandb.json deleted file mode 100644 index 2e1aea7..0000000 --- a/apps/bff/src/bundled_templates/layers/banyandb.json +++ /dev/null @@ -1,263 +0,0 @@ -{ - "key": "BANYANDB", - "alias": "BanyanDB", - "group": "Self-Observability", - "color": "var(--sw-cyan)", - "documentLink": "https://skywalking.apache.org/docs/main/next/en/banyandb/banyandb-introduction/", - "aliases": { - "services": "BanyanDB clusters", - "instances": "Nodes" - }, - "components": { - "service": false, - "instances": true, - "endpoints": false, - "topology": false, - "traces": false, - "logs": false - }, - "layer-header": { - "orderBy": "writeRate", - "columns": [ - { - "metric": "writeRate", - "label": "Write/s", - "mqe": "latest(meter_banyandb_write_rate)", - "aggregation": "sum" - }, - { - "metric": "queryRate", - "label": "Query/s", - "mqe": "latest(meter_banyandb_query_rate{method='query'})", - "aggregation": "sum" - }, - { - "metric": "errRate", - "label": "Err/s", - "mqe": "latest(meter_banyandb_write_and_query_errors_rate)", - "aggregation": "sum" - }, - { - "metric": "active", - "label": "Active", - "mqe": "meter_banyandb_active_instance", - "aggregation": "sum" - } - ] - }, - "overview": { - "groups": [ - { - "title": "Throughput", - "size": "auto", - "metrics": [ - { - "id": "writeRate", - "label": "Write/s", - "tip": "Writes per second across the cluster.", - "mqe": "latest(meter_banyandb_write_rate)", - "aggregation": "sum" - }, - { - "id": "queryRate", - "label": "Query/s", - "tip": "Queries per second.", - "mqe": "latest(meter_banyandb_query_rate{method='query'})", - "aggregation": "sum" - }, - { - "id": "errRate", - "label": "Errors/s", - "tip": "Combined write + query error rate.", - "mqe": "latest(meter_banyandb_write_and_query_errors_rate)", - "aggregation": "sum" - } - ] - } - ] - }, - "dashboards": { - "instance": [ - { - "id": "write_rate", - "title": "Write Rate", - "type": "line", - "expressions": [ - "latest(meter_banyandb_instance_write_rate)" - ], - "span": 4, - "rowSpan": 2 - }, - { - "id": "query_rate", - "title": "Query Rate", - "type": "line", - "expressions": [ - "latest(meter_banyandb_instance_query_rate{method='query'})" - ], - "span": 4, - "rowSpan": 2 - }, - { - "id": "err_rate", - "title": "Error Rate", - "type": "line", - "expressions": [ - "latest(meter_banyandb_instance_write_and_query_errors_rate)" - ], - "span": 4, - "rowSpan": 2 - }, - { - "id": "memory", - "title": "Total Memory (GB)", - "type": "line", - "unit": "GB", - "expressions": [ - "latest(meter_banyandb_instance_total_memory/1024/1024/1024)" - ], - "span": 4, - "rowSpan": 2 - }, - { - "id": "disk", - "title": "Disk Usage (GB)", - "type": "line", - "unit": "GB", - "expressions": [ - "latest(meter_banyandb_instance_disk_usage/1024/1024/1024)" - ], - "span": 4, - "rowSpan": 2 - }, - { - "id": "cpu", - "title": "CPU", - "type": "line", - "expressions": [ - "meter_banyandb_instance_cpu_usage/1000" - ], - "span": 4, - "rowSpan": 2 - }, - { - "id": "etcd", - "title": "Etcd Operation Rate", - "type": "line", - "expressions": [ - "latest(meter_banyandb_instance_etcd_operation_rate)" - ], - "span": 4, - "rowSpan": 2 - }, - { - "id": "rss", - "title": "RSS Memory", - "type": "line", - "expressions": [ - "meter_banyandb_instance_rss_memory_usage/1000" - ], - "span": 4, - "rowSpan": 2 - }, - { - "id": "network", - "title": "Network (KB/s)", - "type": "line", - "unit": "KB/s", - "expressions": [ - "meter_banyandb_instance_network_usage_recv/1024", - "meter_banyandb_instance_network_usage_sent/1024" - ], - "expressionLabels": [ - "recv", - "sent" - ], - "span": 4, - "rowSpan": 2 - }, - { - "id": "totals", - "title": "Total Data + Series", - "type": "line", - "expressions": [ - "meter_banyandb_instance_total_data", - "meter_banyandb_instance_total_series" - ], - "expressionLabels": [ - "data", - "series" - ], - "span": 6, - "rowSpan": 2 - }, - { - "id": "merge", - "title": "Merge File Latency / Data / Partitions", - "type": "line", - "expressions": [ - "meter_banyandb_instance_merge_file_latency/1000", - "meter_banyandb_instance_merge_file_data", - "meter_banyandb_instance_merge_file_partitions" - ], - "expressionLabels": [ - "latency", - "data", - "partitions" - ], - "span": 6, - "rowSpan": 2 - }, - { - "id": "stream_write", - "title": "Stream Write Rate", - "type": "line", - "expressions": [ - "meter_banyandb_instance_stream_write_rate" - ], - "span": 4, - "rowSpan": 2 - }, - { - "id": "group_write", - "title": "Group Write Rate", - "type": "line", - "expressions": [ - "meter_banyandb_instance_storage_write_rate/1000" - ], - "span": 4, - "rowSpan": 2 - }, - { - "id": "series_write", - "title": "Series Write Rate", - "type": "line", - "expressions": [ - "meter_banyandb_instance_series_write_rate/1000" - ], - "span": 4, - "rowSpan": 2 - }, - { - "id": "query_latency", - "title": "Query Latency", - "type": "line", - "expressions": [ - "meter_banyandb_instance_query_latency/1000" - ], - "span": 6, - "rowSpan": 2 - }, - { - "id": "term_search", - "title": "Term Search Rate", - "type": "line", - "expressions": [ - "meter_banyandb_instance_term_search_rate/1000" - ], - "span": 6, - "rowSpan": 2 - } - ] - } -} diff --git a/apps/bff/src/bundled_templates/layers/mesh_dp.json b/apps/bff/src/bundled_templates/layers/mesh_dp.json index 48f17a0..1ca48e5 100644 --- a/apps/bff/src/bundled_templates/layers/mesh_dp.json +++ b/apps/bff/src/bundled_templates/layers/mesh_dp.json @@ -70,84 +70,156 @@ "dashboards": { "instance": [ { - "id": "connections", - "title": "Connections", - "tip": "Server-side total + parent connections (envoy_total_connections_used / envoy_parent_connections_used).", + "id": "up_rq_active", + "title": "Upstream Request Active", + "tip": "Total active upstream requests from this Envoy sidecar's clusters (envoy_cluster_up_rq_active).", + "type": "line", + "unit": "reqs", + "expressions": [ + "envoy_cluster_up_rq_active" + ], + "span": 3, + "rowSpan": 2, + "format": "int" + }, + { + "id": "connections_used", + "title": "Connections Used", + "tip": "Server-side total + parent connections used (envoy_total_connections_used / envoy_parent_connections_used).", "type": "line", "unit": "conns", "expressions": [ "envoy_total_connections_used", "envoy_parent_connections_used" ], - "span": 4, + "expressionLabels": [ + "total", + "parent" + ], + "span": 3, "rowSpan": 2, "format": "int" }, { - "id": "worker_threads", - "title": "Worker Threads", - "tip": "Concurrent worker threads in use vs the max observed (envoy_worker_threads / envoy_worker_threads_max).", + "id": "membership_healthy", + "title": "Membership Healthy", + "tip": "Healthy endpoints across all outbound/inbound clusters (envoy_cluster_membership_healthy).", "type": "line", - "unit": "", + "unit": "endpoints", "expressions": [ - "envoy_worker_threads", - "envoy_worker_threads_max" + "envoy_cluster_membership_healthy" ], - "span": 4, + "span": 3, "rowSpan": 2, "format": "int" }, + { + "id": "up_cx_incr", + "title": "Upstream Connection Increase", + "tip": "New upstream connections opened per minute (envoy_cluster_up_cx_incr).", + "type": "line", + "unit": "/min", + "expressions": [ + "envoy_cluster_up_cx_incr" + ], + "span": 3, + "rowSpan": 2, + "format": "int" + }, + { + "id": "up_rq_pending", + "title": "Upstream Request Pending", + "tip": "Requests pending in upstream queues (envoy_cluster_up_rq_pending_active).", + "type": "line", + "unit": "reqs", + "expressions": [ + "envoy_cluster_up_rq_pending_active" + ], + "span": 3, + "rowSpan": 2, + "format": "int" + }, + { + "id": "heap_memory", + "title": "Server Memory", + "tip": "Heap / allocated / physical memory: current + window max (envoy_heap_memory_used / envoy_memory_allocated / envoy_memory_physical_size, paired with their *_max variants).", + "type": "line", + "unit": "bytes", + "expressions": [ + "envoy_heap_memory_used", + "envoy_heap_memory_max_used", + "envoy_memory_allocated", + "envoy_memory_allocated_max", + "envoy_memory_physical_size", + "envoy_memory_physical_size_max" + ], + "expressionLabels": [ + "heap", + "heap max", + "allocated", + "allocated max", + "physical", + "physical max" + ], + "span": 6, + "rowSpan": 3 + }, { "id": "bug_failures", "title": "Bug Failures", - "tip": "Envoy internal bug-failure counter (envoy_bug_failures). Should normally be zero \u2014 non-zero indicates an Envoy assertion / debug check tripped.", + "tip": "Envoy internal bug-failure counter \u2014 non-zero indicates an assertion / debug check tripped (envoy_bug_failures).", "type": "line", "unit": "count", "expressions": [ "envoy_bug_failures" ], - "span": 4, + "span": 3, "rowSpan": 2, "format": "int" }, { - "id": "heap_memory", - "title": "Heap Memory", - "tip": "Server heap size in bytes \u2014 current value + window max (envoy_heap_memory_used / envoy_heap_memory_max_used).", + "id": "worker_threads", + "title": "Worker Threads", + "tip": "Concurrent worker threads in use vs the window max (envoy_worker_threads / envoy_worker_threads_max).", "type": "line", - "unit": "bytes", + "unit": "threads", "expressions": [ - "envoy_heap_memory_used", - "envoy_heap_memory_max_used" + "envoy_worker_threads", + "envoy_worker_threads_max" + ], + "expressionLabels": [ + "current", + "max" ], - "span": 4, - "rowSpan": 2 + "span": 3, + "rowSpan": 2, + "format": "int" }, { - "id": "memory_allocated", - "title": "Memory Allocated", - "tip": "Server allocated memory (envoy_memory_allocated / envoy_memory_allocated_max).", + "id": "up_cx_active", + "title": "Upstream Connection Active", + "tip": "Active upstream connections (envoy_cluster_up_cx_active).", "type": "line", - "unit": "bytes", + "unit": "conns", "expressions": [ - "envoy_memory_allocated", - "envoy_memory_allocated_max" + "envoy_cluster_up_cx_active" ], - "span": 4, - "rowSpan": 2 + "span": 3, + "rowSpan": 2, + "format": "int" }, { - "id": "memory_physical", - "title": "Physical Memory", - "tip": "Server physical memory footprint (envoy_memory_physical_size / envoy_memory_physical_size_max).", + "id": "up_rq_incr", + "title": "Upstream Request Increase", + "tip": "New upstream requests per minute (envoy_cluster_up_rq_incr).", "type": "line", - "unit": "bytes", + "unit": "/min", "expressions": [ - "envoy_memory_physical_size", - "envoy_memory_physical_size_max" + "envoy_cluster_up_rq_incr" ], - "span": 4, - "rowSpan": 2 + "span": 3, + "rowSpan": 2, + "format": "int" } ] } diff --git a/apps/bff/src/bundled_templates/layers/virtual_genai.json b/apps/bff/src/bundled_templates/layers/virtual_genai.json index f56c6f5..fb62934 100644 --- a/apps/bff/src/bundled_templates/layers/virtual_genai.json +++ b/apps/bff/src/bundled_templates/layers/virtual_genai.json @@ -6,12 +6,12 @@ "documentLink": "https://skywalking.apache.org/docs/main/next/en/setup/service-agent/virtual-genai/", "aliases": { "services": "GenAI Providers", - "endpoints": "Models" + "instances": "Models" }, "components": { "service": true, - "instances": false, - "endpoints": true, + "instances": true, + "endpoints": false, "topology": false, "traces": false, "logs": false @@ -179,7 +179,7 @@ "rowSpan": 2 } ], - "endpoint": [ + "instance": [ { "id": "cpm", "title": "Calls / min", diff --git a/apps/bff/src/oap/endpoint-routes.ts b/apps/bff/src/oap/endpoint-routes.ts index eb80273..d910341 100644 --- a/apps/bff/src/oap/endpoint-routes.ts +++ b/apps/bff/src/oap/endpoint-routes.ts @@ -118,10 +118,11 @@ export function registerEndpointRoute(app: FastifyInstance, deps: EndpointRouteD const opts = buildOapOpts(cfgCurrent, deps.fetch); const window = defaultWindow(); - // Resolve a plain service name to an OAP id when needed (id-shaped - // values contain a `.` separator; names don't). + // OAP service-id shape: `<base64>.<digits>`. Anything else + // (including names like `mesh-svr::r3-load.sample-services` that + // embed `.`) needs a `listServices` lookup. let serviceId = serviceArg; - if (!serviceArg.includes('.') || /\s/.test(serviceArg)) { + if (!/^[A-Za-z0-9+/=]+\.\d+$/.test(serviceArg)) { try { const data = await graphqlPost<{ services: Array<{ id: string; name: string; normal?: boolean }>; diff --git a/apps/bff/src/oap/instance-routes.ts b/apps/bff/src/oap/instance-routes.ts index bb17821..457a93b 100644 --- a/apps/bff/src/oap/instance-routes.ts +++ b/apps/bff/src/oap/instance-routes.ts @@ -130,11 +130,14 @@ export function registerInstanceRoute(app: FastifyInstance, deps: InstanceRouteD const cfgCurrent = deps.config.current; const opts = buildOapOpts(cfgCurrent, deps.fetch); const window = defaultWindow(); - // OAP-side ids look like base64-ish blobs (e.g. "Y2hlY2tvdXQ=.1"); - // names are plain words. If we don't see a `.` separator, treat - // the arg as a name and resolve to an id first. + // OAP service-id shape: `<base64>.<digits>` (e.g. + // `Y2hlY2tvdXQ=.1`). Anything else — including names that + // happen to contain `.` like `mesh-svr::r3-load.sample-services` + // — needs a `listServices` lookup. The earlier "contains `.`" + // heuristic was too loose and broke mesh / k8s_service instance + // queries whose service names embed dots. let serviceId = serviceArg; - if (!serviceArg.includes('.') || /\s/.test(serviceArg)) { + if (!/^[A-Za-z0-9+/=]+\.\d+$/.test(serviceArg)) { try { const data = await graphqlPost<ListServicesResp>(opts, LIST_SERVICES_FOR_RESOLVE, { layer: layerKey.toUpperCase(), diff --git a/apps/bff/src/oap/log-routes.ts b/apps/bff/src/oap/log-routes.ts index 7026fef..9813628 100644 --- a/apps/bff/src/oap/log-routes.ts +++ b/apps/bff/src/oap/log-routes.ts @@ -121,13 +121,23 @@ interface OapLogRow { tags?: LogKeyValue[] | null; } +/** + * Resolve a service argument to an OAP service id. The arg can be + * either a name (`mesh-svr::songs.sample-services`) or an id + * (`bWVzaC1zdnI6OnNvbmdzLnNhbXBsZS1zZXJ2aWNlcw==.1`). OAP ids are + * `<base64>.<digits>` — match strictly to avoid the previous bug + * where a name containing `.` (e.g. `*.sample-services`) was wrongly + * accepted as an id, leading to OAP returning empty / "service not + * found" on the log query. + */ +const OAP_SERVICE_ID_RE = /^[A-Za-z0-9+/=]+\.\d+$/; async function resolveServiceId( opts: GraphqlOptions, layer: string, serviceArg: string, ): Promise<string | null> { if (!serviceArg) return null; - if (serviceArg.includes('.') && !/\s/.test(serviceArg)) return serviceArg; + if (OAP_SERVICE_ID_RE.test(serviceArg)) return serviceArg; const data = await graphqlPost<{ services: Array<{ id: string; name: string }> }>( opts, LIST_SERVICES_FOR_RESOLVE, diff --git a/apps/bff/src/oap/menu-routes.ts b/apps/bff/src/oap/menu-routes.ts index aff03a4..8873723 100644 --- a/apps/bff/src/oap/menu-routes.ts +++ b/apps/bff/src/oap/menu-routes.ts @@ -38,6 +38,8 @@ import { getLayerTemplate, type LayerComponentFlags } from '../layers/loader.js' function componentsToCaps(components: LayerComponentFlags): LayerCaps { return { dashboards: components.service !== false, + instances: !!components.instances, + endpoints: !!components.endpoints, endpointDependency: !!components.endpointDependency, serviceMap: !!components.topology, instanceTopology: !!components.topology, diff --git a/apps/bff/src/oap/trace-routes.ts b/apps/bff/src/oap/trace-routes.ts index 0dc7be3..709145c 100644 --- a/apps/bff/src/oap/trace-routes.ts +++ b/apps/bff/src/oap/trace-routes.ts @@ -210,15 +210,18 @@ const QUERY_TRACE_DETAIL = /* GraphQL */ ` // ── Helpers ──────────────────────────────────────────────────────── +// OAP service-id shape: `<base64>.<digits>`. Match strictly so we +// don't mis-classify names containing `.` (e.g. `*.sample-services`) +// as ids — the earlier "contains `.` and no whitespace" heuristic was +// too loose and broke trace queries on mesh-layer services. +const OAP_SERVICE_ID_RE = /^[A-Za-z0-9+/=]+\.\d+$/; async function resolveServiceId( opts: GraphqlOptions, layer: string, serviceArg: string, ): Promise<string | null> { if (!serviceArg) return null; - // Ids contain `.`; names rarely do. Short-circuit when it's already - // an id. - if (serviceArg.includes('.') && !/\s/.test(serviceArg)) return serviceArg; + if (OAP_SERVICE_ID_RE.test(serviceArg)) return serviceArg; const data = await graphqlPost<{ services: Array<{ id: string; name: string }>; }>(opts, LIST_SERVICES_FOR_RESOLVE, { layer: layer.toUpperCase() }); diff --git a/apps/ui/src/components/shell/AppSidebar.vue b/apps/ui/src/components/shell/AppSidebar.vue index 433d702..f8a973e 100644 --- a/apps/ui/src/components/shell/AppSidebar.vue +++ b/apps/ui/src/components/shell/AppSidebar.vue @@ -42,8 +42,14 @@ const orderedLayers = useLandingOrder(availableLayers); * page (which IS the dashboard for virtual / cache / database / MQ * scopes). */ type SidebarLayer = (typeof orderedLayers.value)[number]; +function hasInstances(L: SidebarLayer): boolean { + return L.caps.instances ?? Boolean(L.slots.instances); +} +function hasEndpoints(L: SidebarLayer): boolean { + return L.caps.endpoints ?? Boolean(L.slots.endpoints); +} function isSingleFeatureLayer(L: SidebarLayer): boolean { - if (L.slots.instances || L.slots.endpoints) return false; + if (hasInstances(L) || hasEndpoints(L)) return false; if (hasTopology(L)) return false; const c = L.caps; if (c.traces || c.logs || c.traceProfiling || c.ebpfProfiling || c.asyncProfiling || c.events) return false; @@ -51,20 +57,16 @@ function isSingleFeatureLayer(L: SidebarLayer): boolean { return true; } -// Default-open the first available layer once data arrives; user clicks -// thereafter take over. +// Which layer's row is expanded — the tab strip (Service / Instance / +// Endpoint / Topology / …) renders only beneath the expanded layer. +// Auto-driven by the URL: whatever layer the route is on stays +// expanded so a cold reload of `/layer/<key>/...` shows the tabs + +// the active tab highlighted. The initial-load watcher only fires +// when the route ISN'T on a layer page (the top-level / overview) — +// in that case we pick the first available layer so the sidebar +// doesn't look closed on first visit. const expandedLayer = ref<string | null>(null); -let userTouched = false; -watch( - orderedLayers, - (rows) => { - if (userTouched || expandedLayer.value) return; - if (rows.length > 0) expandedLayer.value = rows[0].key; - }, - { immediate: true }, -); function toggleLayer(key: string): void { - userTouched = true; expandedLayer.value = expandedLayer.value === key ? null : key; } @@ -116,17 +118,35 @@ function isActive(path: string): boolean { return route.path === path || route.path.startsWith(path + '/'); } -// Auto-open the group that contains the active layer so reload-on-URL -// doesn't hide the user's current location behind a collapsed section. +// URL-driven sidebar focus. When the route is on a layer page, the +// containing group expands AND the layer's row expands so the tabs + +// active-tab highlight are visible. Fires on every route change so +// deep-linking from outside (bookmark, paste-in) lands the operator +// with the right rails open. When the route ISN'T on a layer, fall +// back to the first available layer so the sidebar isn't empty on +// the overview / setup pages. watch( - () => route.path, - () => { - const m = route.path.match(/^\/layer\/([^/]+)/); - if (!m) return; - const key = m[1]; - const L = orderedLayers.value.find((l) => l.key === key); - if (L?.group && !openGroups.value.has(L.group)) { - openGroups.value = new Set([...openGroups.value, L.group]); + [() => route.path, orderedLayers], + ([path, rows]) => { + const m = path.match(/^\/layer\/([^/]+)/); + if (m) { + const key = m[1]; + const L = rows.find((l) => l.key === key); + if (L) { + // Expand this layer's tab strip. + expandedLayer.value = key; + // Open its parent group if grouped. + if (L.group && !openGroups.value.has(L.group)) { + openGroups.value = new Set([...openGroups.value, L.group]); + } + } + return; + } + // No layer in URL — only seed the default expansion if nothing is + // currently expanded (don't yank a layer the operator was looking + // at on the previous route). + if (!expandedLayer.value && rows.length > 0) { + expandedLayer.value = rows[0].key; } }, { immediate: true }, @@ -291,20 +311,20 @@ const sections: NavSection[] = [ <span class="sw-badge" style="margin-left: auto">{{ L.serviceCount }}</span> </RouterLink> <RouterLink - v-if="L.slots.instances" + v-if="hasInstances(L)" :to="`/layer/${L.key}/instance`" class="sw-nav-item" :class="{ 'is-active': isActive(`/layer/${L.key}/instance`) }" > - <Icon name="prof" /><span>{{ L.slots.instances }}</span> + <Icon name="prof" /><span>{{ L.slots.instances ?? 'Instance' }}</span> </RouterLink> <RouterLink - v-if="L.slots.endpoints" + v-if="hasEndpoints(L)" :to="`/layer/${L.key}/endpoint`" class="sw-nav-item" :class="{ 'is-active': isActive(`/layer/${L.key}/endpoint`) }" > - <Icon name="ep" /><span>{{ L.slots.endpoints }}</span> + <Icon name="ep" /><span>{{ L.slots.endpoints ?? 'Endpoint' }}</span> </RouterLink> <RouterLink v-if="hasTopology(L)" @@ -412,20 +432,20 @@ const sections: NavSection[] = [ <span class="sw-badge" style="margin-left: auto">{{ E.layer.serviceCount }}</span> </RouterLink> <RouterLink - v-if="E.layer.slots.instances" + v-if="hasInstances(E.layer)" :to="`/layer/${E.layer.key}/instance`" class="sw-nav-item" :class="{ 'is-active': isActive(`/layer/${E.layer.key}/instance`) }" > - <Icon name="prof" /><span>{{ E.layer.slots.instances }}</span> + <Icon name="prof" /><span>{{ E.layer.slots.instances ?? 'Instance' }}</span> </RouterLink> <RouterLink - v-if="E.layer.slots.endpoints" + v-if="hasEndpoints(E.layer)" :to="`/layer/${E.layer.key}/endpoint`" class="sw-nav-item" :class="{ 'is-active': isActive(`/layer/${E.layer.key}/endpoint`) }" > - <Icon name="ep" /><span>{{ E.layer.slots.endpoints }}</span> + <Icon name="ep" /><span>{{ E.layer.slots.endpoints ?? 'Endpoint' }}</span> </RouterLink> <RouterLink v-if="hasTopology(E.layer)" diff --git a/apps/ui/src/components/shell/AppTopbar.vue b/apps/ui/src/components/shell/AppTopbar.vue index 0e5c466..d0ce812 100644 --- a/apps/ui/src/components/shell/AppTopbar.vue +++ b/apps/ui/src/components/shell/AppTopbar.vue @@ -19,15 +19,67 @@ import { computed, ref, watch } from 'vue'; import { RouterLink, useRoute } from 'vue-router'; import Icon from '@/components/icons/Icon.vue'; import { useOapInfo } from '@/composables/useOapInfo'; +import { useLayers } from '@/composables/useLayers'; import { useAutoRefreshStore } from '@/stores/autoRefresh'; const route = useRoute(); +const { layers } = useLayers(); -// Trivial breadcrumb derivation from the path. Real breadcrumb metadata -// lands when individual views start setting `route.meta.breadcrumbs`. +/** + * Breadcrumb derived from the route path PLUS the layer's display + * config so the trail reads the same as the sidebar. For + * `/layer/<key>/<scope>` we: + * - Replace the layer key with its alias (`activemq` → `ActiveMQ`). + * - Replace the scope segment with the layer's slot alias when one + * exists (`instance` → `Brokers` for ActiveMQ, `Sidecars` for + * mesh_dp, `Pages` for browser, …). Falls back to the + * capitalized URL segment when no alias applies. + * + * The mapping lives here (and not in the route definition) because + * the layer JSON is the source of truth for the operator-facing + * terms; the route segments stay in the canonical `instance` / + * `endpoint` / etc. shape for back-compat with bookmarks. + */ +const SCOPE_SLOT_KEY: Record<string, 'instances' | 'endpoints' | 'services' | 'endpointDependency'> = { + instance: 'instances', + endpoint: 'endpoints', + service: 'services', + dependency: 'endpointDependency', +}; +const SCOPE_LITERAL: Record<string, string> = { + topology: 'Topology', + trace: 'Traces', + logs: 'Logs', + 'trace-profiling': 'Trace Profiling', + 'ebpf-profiling': 'eBPF Profiling', + 'async-profiling': 'Async Profiling', +}; const crumbs = computed<string[]>(() => { const segs = route.path.split('/').filter(Boolean); if (segs.length === 0) return ['Home']; + // Layer-aware path: `/layer/<key>/<scope?>/...` + if (segs[0] === 'layer' && segs[1]) { + const layerKey = segs[1]; + const layer = layers.value.find((l) => l.key === layerKey); + const out: string[] = [layer?.name ?? layerKey.replace(/-/g, ' ').replace(/^./, (c) => c.toUpperCase())]; + for (let i = 2; i < segs.length; i++) { + const seg = segs[i]; + // Slot alias (services/instances/endpoints/dependency). + const slotKey = SCOPE_SLOT_KEY[seg]; + if (slotKey && layer?.slots?.[slotKey]) { + out.push(String(layer.slots[slotKey])); + continue; + } + // Known literal scope (topology / trace / logs / profilings). + if (SCOPE_LITERAL[seg]) { + out.push(SCOPE_LITERAL[seg]); + continue; + } + // Fallback: capitalize the segment. + out.push(seg.replace(/-/g, ' ').replace(/^./, (c) => c.toUpperCase())); + } + return out; + } return segs.map((s) => s.replace(/-/g, ' ').replace(/^./, (c) => c.toUpperCase())); }); diff --git a/apps/ui/src/composables/useLayers.ts b/apps/ui/src/composables/useLayers.ts index 7776f7f..daade64 100644 --- a/apps/ui/src/composables/useLayers.ts +++ b/apps/ui/src/composables/useLayers.ts @@ -95,8 +95,8 @@ export function useLayers() { export function firstLayerTab(L: LayerDef | undefined): string { if (!L) return 'service'; if (L.slots?.services || L.caps?.dashboards) return 'service'; - if (L.slots?.instances) return 'instance'; - if (L.slots?.endpoints) return 'endpoint'; + if (L.caps?.instances ?? Boolean(L.slots?.instances)) return 'instance'; + if (L.caps?.endpoints ?? Boolean(L.slots?.endpoints)) return 'endpoint'; if (L.caps?.serviceMap || L.caps?.instanceTopology || L.caps?.processTopology) return 'topology'; if (L.caps?.endpointDependency) return 'dependency'; if (L.caps?.traces) return 'trace'; diff --git a/apps/ui/src/composables/useSelectedEndpoint.ts b/apps/ui/src/composables/useSelectedEndpoint.ts index 6db5367..df80174 100644 --- a/apps/ui/src/composables/useSelectedEndpoint.ts +++ b/apps/ui/src/composables/useSelectedEndpoint.ts @@ -36,6 +36,8 @@ export function useSelectedEndpoint() { }); function setSelectedEndpoint(name: string | null): void { + const current = typeof route.query.endpoint === 'string' ? route.query.endpoint : null; + if (name === current) return; const next = { ...route.query }; if (name) next.endpoint = name; else delete next.endpoint; diff --git a/apps/ui/src/composables/useSelectedInstance.ts b/apps/ui/src/composables/useSelectedInstance.ts index 4707e41..f284182 100644 --- a/apps/ui/src/composables/useSelectedInstance.ts +++ b/apps/ui/src/composables/useSelectedInstance.ts @@ -42,6 +42,8 @@ export function useSelectedInstance() { }); function setSelectedInstance(name: string | null): void { + const current = typeof route.query.instance === 'string' ? route.query.instance : null; + if (name === current) return; const next = { ...route.query }; if (name) next.instance = name; else delete next.instance; diff --git a/apps/ui/src/composables/useSelectedService.ts b/apps/ui/src/composables/useSelectedService.ts index 726144e..a8d84b5 100644 --- a/apps/ui/src/composables/useSelectedService.ts +++ b/apps/ui/src/composables/useSelectedService.ts @@ -38,8 +38,15 @@ export function useSelectedService() { function setSelected(id: string | null): void { const next = { ...route.query }; + const current = typeof route.query.service === 'string' ? route.query.service : null; + if (id === current) return; if (id) next.service = id; else delete next.service; + // Instance / endpoint choices are derived from the selected service. + // When the service changes, drop the narrower entity so each + // dashboard can auto-pick from the new service's own list. + delete next.instance; + delete next.endpoint; // `replace` instead of `push` — switching services shouldn't bloat // the browser back stack with N entries. void router.replace({ path: route.path, query: next }); diff --git a/apps/ui/src/views/layer/LayerDashboardsView.vue b/apps/ui/src/views/layer/LayerDashboardsView.vue index 49c0042..f7995b8 100644 --- a/apps/ui/src/views/layer/LayerDashboardsView.vue +++ b/apps/ui/src/views/layer/LayerDashboardsView.vue @@ -125,10 +125,11 @@ const expandedInstance = ref<string | null>(null); const { setSelected: setSelectedService } = useSelectedService(); const landingRows = computed(() => landing.data.value?.sampledRows ?? landing.rows.value ?? []); watch(landingRows, (rows) => { - if (selectedId.value) return; const first = rows[0]; if (!first) return; - setSelectedService(first.serviceId); + if (!selectedId.value || !rows.some((r) => r.serviceId === selectedId.value)) { + setSelectedService(first.serviceId); + } }, { immediate: true }); // Drop the stale instance whenever the service changes — the new // service's instance list almost never matches the previous pick. @@ -142,7 +143,11 @@ watch(serviceName, (next, prev) => { // their URL on every visit). watch([instanceList, scope], ([list, s]) => { if (s !== 'instance') return; - if (!selectedInstance.value && list.length > 0) { + if (list.length === 0) { + if (selectedInstance.value) setSelectedInstance(null); + return; + } + if (!selectedInstance.value || !list.some((i) => i.name === selectedInstance.value)) { setSelectedInstance(list[0].name); } }); diff --git a/apps/ui/src/views/layer/LayerLogsView.vue b/apps/ui/src/views/layer/LayerLogsView.vue index 4af3707..8a589a3 100644 --- a/apps/ui/src/views/layer/LayerLogsView.vue +++ b/apps/ui/src/views/layer/LayerLogsView.vue @@ -69,9 +69,11 @@ const landingRows = computed(() => landing.data.value?.sampledRows ?? landing.ro watch( landingRows, (rows) => { - if (selectedId.value) return; const first = rows[0]; - if (first) setSelectedService(first.serviceId); + if (!first) return; + if (!selectedId.value || !rows.some((r) => r.serviceId === selectedId.value)) { + setSelectedService(first.serviceId); + } }, { immediate: true }, ); @@ -99,18 +101,11 @@ const showEndpointSelector = computed(() => logScope.value !== 'endpoint'); // - `endpoint` scope: optional narrower as well. const { selectedInstance, setSelectedInstance } = useSelectedInstance(); const { instances: instanceList } = useLayerInstances(layerKey, serviceName); -// Auto-pick the first instance ONLY when the layer pins instance via -// `log.scope === 'instance'` (mesh_dp sidecar logs). For service / -// endpoint scopes the picker stays empty by default so the operator's -// landing view is "all instances of the picked service" — auto-binding -// to one instance narrows the query and routinely returns zero rows -// when that specific instance happens to be quiet. -watch([instanceList, logScope], ([list, scope]) => { - if (scope !== 'instance') return; - if (selectedInstance.value) return; - const first = list[0]; - if (first) setSelectedInstance(first.name); -}); +// Logs (and traces) intentionally do NOT auto-select an instance. +// Default is `All` so the stream starts broad; the operator opts into +// narrowing by picking from the dropdown. Auto-selection is reserved +// for metrics-scope pages (instance / endpoint dashboards), where a +// chosen entity is needed to render the metric widgets at all. watch(serviceName, (next, prev) => { if (prev !== undefined && next !== prev && selectedInstance.value) { setSelectedInstance(null); @@ -167,16 +162,8 @@ const { endpoints: endpointList, isFetching: endpointsLoading } = useLayerEndpoi endpointQuery, endpointLimit, ); -// Auto-pick first endpoint ONLY when the layer pins endpoint via -// `log.scope === 'endpoint'`. Same reasoning as the instance picker: -// auto-pinning narrows the query and the operator typically wants the -// landing view to be "any endpoint of the picked service". -watch([endpointList, logScope], ([list, scope]) => { - if (scope !== 'endpoint') return; - if (selectedEndpoint.value) return; - const first = list[0]; - if (first) setSelectedEndpoint(first.name); -}); +// No endpoint auto-pick on Logs either — same reasoning as the +// instance picker above. Default is `All`; operator narrows by hand. watch(serviceName, (next, prev) => { if (prev !== undefined && next !== prev && selectedEndpoint.value) { setSelectedEndpoint(null); @@ -467,15 +454,11 @@ const levelFacet = computed<Record<Level, number>>(() => { // reflect it — no client-side narrowing needed. const filteredLogs = computed<LogRow[]>(() => logs.value); -// ── Row expand state. Loki / Datadog use inline expand for the -// detail rather than a separate right pane. ---------------------- -const expandedId = ref<string | null>(null); +// Row keys for `<template v-for>`. Inline expand is gone — click +// now opens the full-canvas popout via `onRowClick(r)`. function rowKey(r: LogRow, idx: number): string { return `${r.timestamp}-${r.traceId ?? ''}-${idx}`; } -function toggleExpand(key: string): void { - expandedId.value = expandedId.value === key ? null : key; -} function fmtTime(ts: number): string { const d = new Date(ts); @@ -486,10 +469,50 @@ function fmtDate(ts: number): string { const d = new Date(ts); return d.toLocaleDateString(undefined, { month: 'short', day: '2-digit' }); } +/** + * Render a one-line inline preview. Length is NOT capped here — the + * row's CSS (`.lg-content-body { white-space: nowrap; overflow: + * hidden; text-overflow: ellipsis }`) clips at the actual visible + * width, so wider viewports surface more of the payload while narrow + * ones still get a clean ellipsis. The slicing-at-220-chars heuristic + * I had before always cut at the same offset regardless of width. + * + * - JSON: re-serialize to the tightest single-line form so the row + * reads as `{"level":"error","msg":"…"}` and not the raw + * whitespace-laden source. + * - YAML: collapse newlines into a single space so the row still + * carries the keys (`apiVersion: v1 kind: Pod spec: containers: + * - name: nginx`). Indentation chars stay, which preserves a + * visual sense of the hierarchy even on one line. The popout is + * the right place to read the proper multi-line structure. + * - Text: whitespace collapsed, same as before. + */ function summariseContent(r: LogRow): string { if (!r.content) return ''; - const oneLine = r.content.replace(/\s+/g, ' ').trim(); - return oneLine.length > 220 ? oneLine.slice(0, 218) + '…' : oneLine; + const fmt = detectFormat(r); + if (fmt === 'json') { + try { + return JSON.stringify(JSON.parse(r.content)); + } catch { + /* fall through to the plain compaction below */ + } + } + if (fmt === 'yaml') { + // Replace newlines with a single space — keeps the indentation + // characters that sit at the start of each line, which gives the + // operator a visual cue of nesting depth even when flattened. + return r.content.replace(/\n+/g, ' ').trim(); + } + // Plain text — collapse any runs of whitespace. + return r.content.replace(/\s+/g, ' ').trim(); +} +/** With the new flattening rule above, every payload has a usable + * inline preview — JSON, YAML, and multi-line text all flatten to + * one line cleanly. We no longer need a hidden-payload affordance. + * Returns false unconditionally; kept so the template doesn't have + * to change. */ +function hasHiddenPayload(_r: LogRow): boolean { + return false; } function tryPrettyJson(content: string): string { try { @@ -546,6 +569,49 @@ async function copyPopout(): Promise<void> { /* clipboard may be blocked; silently no-op */ } } +// ESC closes the popout. Bound on the window so it works whether or +// not the popout has keyboard focus (clicking the modal sometimes +// drops focus into the underlying row). +function onGlobalKeydown(ev: KeyboardEvent): void { + if (ev.key === 'Escape' && popoutRow.value) { + ev.preventDefault(); + closePopout(); + } +} +if (typeof window !== 'undefined') { + window.addEventListener('keydown', onGlobalKeydown); +} + +/** + * Row click → open popout. The inline expand is intentionally gone — + * for multi-line / YAML / JSON payloads it rendered as a cramped strip + * inside the row band; the popout has the full canvas. Trace-id chip + * and group decoder still propagate stop-events so their own clicks + * don't bubble. + */ +function onRowClick(r: LogRow): void { + openPopout(r); +} + +// Custom hover tooltip state for the density bar. Native browser +// `title` was making the cursor render as `?` (help-cursor) instead +// of showing the count, which read like a UI bug. +const hoveredBin = ref<number | null>(null); +function fmtBucketRange(idx: number, t0: number, t1: number): string { + if (!t0 || !t1) return ''; + const span = (t1 - t0) || 1; + const start = new Date(t0 + (span * idx) / BINS); + const end = new Date(t0 + (span * (idx + 1)) / BINS); + const pad = (n: number) => String(n).padStart(2, '0'); + const fmt = (d: Date) => `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; + return `${fmt(start)} – ${fmt(end)}`; +} +function fmtAxisTime(ts: number): string { + if (!ts) return ''; + const d = new Date(ts); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${pad(d.getHours())}:${pad(d.getMinutes())}`; +} /** Open the trace in the global popout overlay rather than navigating * to the Traces tab — keeps the operator in the log stream, lets them @@ -724,23 +790,60 @@ function jumpToTrace(traceId: string): void { </span> </div> - <!-- Density bar --> - <div class="lg-density" v-if="histogram.bins.length > 0"> - <div - v-for="(bin, i) in histogram.bins" - :key="i" - class="lg-density-bin" - :title="histogram.t0 ? new Date(histogram.t0 + ((histogram.t1 - histogram.t0) * (i + 0.5)) / 60).toLocaleString() : ''" - > - <span - v-for="l in LEVEL_ORDER" - :key="l" - class="lg-density-segment" - :style="{ - background: LEVEL_COLOR[l], - height: histogram.max ? (bin[l] / histogram.max * 100) + '%' : '0%', - }" - /> + <!-- Density bar — x: time, y: count, color: level. Hover a + bin: a custom tooltip (NOT the native `title` — the + native cursor was rendering as a help cursor `?` instead + of the count, which was confusing) shows the bucket time + range + per-level counts. Axis tick labels under the bar + carry the time scale. --> + <div class="lg-density-wrap" v-if="histogram.bins.length > 0" @mouseleave="hoveredBin = null"> + <div class="lg-density"> + <div + v-for="(bin, i) in histogram.bins" + :key="i" + class="lg-density-bin" + @mouseenter="hoveredBin = i" + > + <span + v-for="l in LEVEL_ORDER" + :key="l" + class="lg-density-segment" + :style="{ + background: LEVEL_COLOR[l], + height: histogram.max ? (bin[l] / histogram.max * 100) + '%' : '0%', + }" + /> + </div> + <!-- Custom hover tooltip — replaces the native browser + tooltip which was both slow to appear AND coupled to + the `cursor: help` rendering (the `?` cursor was the + thing the operator was reporting). --> + <div + v-if="hoveredBin !== null" + class="lg-density-tip" + :style="{ left: ((hoveredBin + 0.5) / 60) * 100 + '%' }" + > + <div class="lg-density-tip-time"> + {{ fmtBucketRange(hoveredBin, histogram.t0, histogram.t1) }} + </div> + <div class="lg-density-tip-total"> + {{ histogram.bins[hoveredBin].error + histogram.bins[hoveredBin].warn + histogram.bins[hoveredBin].info + histogram.bins[hoveredBin].debug + histogram.bins[hoveredBin].other }} log<template v-if="(histogram.bins[hoveredBin].error + histogram.bins[hoveredBin].warn + histogram.bins[hoveredBin].info + histogram.bins[hoveredBin].debug + histogram.bins[hoveredBin].other) !== 1">s</template> + </div> + <div class="lg-density-tip-rows"> + <span v-for="l in LEVEL_ORDER" :key="l" v-show="histogram.bins[hoveredBin][l] > 0" class="lg-density-tip-row"> + <span class="lvl-dot" :style="{ background: LEVEL_COLOR[l] }" /> + <span class="lg-density-tip-name">{{ l }}</span> + <span class="lg-density-tip-val mono">{{ histogram.bins[hoveredBin][l] }}</span> + </span> + </div> + </div> + </div> + <div class="lg-density-axis"> + <span class="t-tick">{{ fmtAxisTime(histogram.t0) }}</span> + <span class="t-tick">{{ fmtAxisTime(histogram.t0 + (histogram.t1 - histogram.t0) * 0.25) }}</span> + <span class="t-tick">{{ fmtAxisTime(histogram.t0 + (histogram.t1 - histogram.t0) * 0.5) }}</span> + <span class="t-tick">{{ fmtAxisTime(histogram.t0 + (histogram.t1 - histogram.t0) * 0.75) }}</span> + <span class="t-tick">{{ fmtAxisTime(histogram.t1) }}</span> </div> </div> @@ -750,14 +853,14 @@ function jumpToTrace(traceId: string): void { </div> <div v-else class="lg-stream"> <template v-for="(r, idx) in filteredLogs" :key="rowKey(r, idx)"> - <div class="lg-row" :class="`lv-${levelOf(r)}`" @click="toggleExpand(rowKey(r, idx))"> + <!-- Row click → open the popout. Inline expand removed: + YAML / JSON / multi-line text rendered cramped inside + the row band and reads as truncated. The popout has + the full canvas + format-aware pretty-print. --> + <div class="lg-row" :class="`lv-${levelOf(r)}`" @click="onRowClick(r)"> <span class="lg-time mono">{{ fmtTime(r.timestamp) }}</span> <span class="lg-date mono dim">{{ fmtDate(r.timestamp) }}</span> <span class="lg-lvl" :style="{ color: LEVEL_COLOR[levelOf(r)] }">{{ levelOf(r) }}</span> - <!-- Decode the OAP `<group>::<base>` convention so the - row reads as "<chip> base" instead of dumping the - raw `agent::songs` string. Falls back to the plain - name when no group prefix is present. --> <span class="lg-svc mono dim"> <span v-if="r.serviceName && parseServiceName(r.serviceName).group" @@ -766,30 +869,21 @@ function jumpToTrace(traceId: string): void { {{ r.serviceName ? parseServiceName(r.serviceName).base : '—' }} </span> <span v-if="r.traceId" class="lg-trace mono" @click.stop="jumpToTrace(r.traceId!)">↗ trace</span> - <span class="lg-content mono">{{ summariseContent(r) }}</span> - </div> - <div v-if="expandedId === rowKey(r, idx)" class="lg-expand"> - <pre - v-if="detectFormat(r) === 'json'" - class="lg-payload json" - >{{ prettyForFormat(r, 'json') }}</pre> - <pre - v-else-if="detectFormat(r) === 'yaml'" - class="lg-payload yaml" - >{{ r.content }}</pre> - <pre v-else class="lg-payload text">{{ r.content }}</pre> - <div v-if="r.tags.length > 0" class="lg-tag-row"> - <span v-for="t in r.tags" :key="`${t.key}=${t.value}`" class="lg-tag"> - <span class="lg-tag-k">{{ t.key }}</span> - <span class="lg-tag-v mono">{{ t.value }}</span> + <!-- Format chip + flat content preview. Chip is always + rendered so the operator can tell at-a-glance which + rows are JSON / YAML / plain text. Preview is single + line, length-capped, and trimmed-with-ellipsis via + `.lg-content` CSS so even when JSON contains long + strings the row stays one line. --> + <span class="lg-content mono"> + <span class="lg-fmt-chip" :class="`fmt-${detectFormat(r)}`">{{ detectFormat(r).toUpperCase() }}</span> + <span class="lg-content-body"> + <template v-if="hasHiddenPayload(r)"> + <em class="lg-content-hint">click to view</em> + </template> + <template v-else>{{ summariseContent(r) }}</template> </span> - </div> - <div class="lg-meta-row"> - <span v-if="r.serviceInstanceName" class="lg-meta">instance <code>{{ r.serviceInstanceName }}</code></span> - <span v-if="r.endpointName" class="lg-meta">endpoint <code>{{ r.endpointName }}</code></span> - <span class="lg-meta">type <code>{{ detectFormat(r).toUpperCase() }}</code></span> - <button class="sw-btn small" type="button" @click.stop="openPopout(r)">View full</button> - </div> + </span> </div> </template> </div> @@ -900,7 +994,7 @@ function jumpToTrace(traceId: string): void { } /* Trace-style toolbar layout (same voice as `LayerTracesView`). */ .lg-toolbar { padding: 10px 12px; display: flex; flex-direction: column; gap: 10px; overflow: visible; } -.lg-toolbar-head { display: flex; align-items: baseline; gap: 10px; } +.lg-toolbar-head { display: flex; align-items: center; gap: 10px; width: 100%; } /* Run-query button: SkyWalking orange, sits at the right edge of the toolbar head row. Matches `LayerTracesView.tr-run-btn` exactly so the two pages read identically. `.sw-btn.primary` is locally scoped per @@ -1001,7 +1095,7 @@ function jumpToTrace(traceId: string): void { overflow: hidden; text-overflow: ellipsis; } -.cf-combo-item em { color: var(--sw-fg-3); font-style: italic; } +.cf-combo-item em { color: var(--sw-fg-1); font-style: normal; font-family: var(--sw-mono); } .cf-combo-item:hover { background: var(--sw-bg-2); color: var(--sw-fg-0); } .cf-combo-item.on { background: var(--sw-accent-soft); color: var(--sw-accent-2); font-weight: 600; } .cf-combo-empty { padding: 6px 8px; font-size: 10.5px; color: var(--sw-fg-3); } @@ -1118,15 +1212,20 @@ function jumpToTrace(traceId: string): void { flex-direction: column; min-height: 0; } +/* Density-bar wrapper: the 60 stacked bin bars on top, x-axis tick + strip underneath so the time scale is readable at a glance. */ +.lg-density-wrap { + padding: 8px 12px 4px; + border-bottom: 1px solid var(--sw-line); + background: var(--sw-bg-1); +} .lg-density { display: grid; grid-template-columns: repeat(60, 1fr); align-items: end; gap: 1px; height: 60px; - padding: 8px 12px; - border-bottom: 1px solid var(--sw-line); - background: var(--sw-bg-1); + position: relative; /* anchor for the absolute-positioned tooltip */ } .lg-density-bin { display: flex; @@ -1135,8 +1234,62 @@ function jumpToTrace(traceId: string): void { background: var(--sw-bg-2); border-radius: 1px; overflow: hidden; + /* No `cursor: help` — the `?` cursor was misread as a UI error. + The bin reads as informational (hover surfaces a count tooltip), + so a default pointer is the right affordance. */ } +.lg-density-bin:hover { outline: 1px solid var(--sw-accent-line); } .lg-density-segment { display: block; } +/* Custom hover tooltip — anchored to the hovered bin via the + `left: <bin-center>%` inline style. Wider than a single bin so it + doesn't clip; transforms back by 50% to centre on the bin. */ +.lg-density-tip { + position: absolute; + bottom: calc(100% + 6px); + transform: translateX(-50%); + min-width: 160px; + padding: 6px 9px; + background: var(--sw-bg-0); + border: 1px solid var(--sw-line-2); + border-radius: 4px; + box-shadow: 0 8px 20px rgba(0,0,0,0.45); + font-size: 11px; + color: var(--sw-fg-1); + pointer-events: none; + z-index: 5; +} +.lg-density-tip-time { color: var(--sw-fg-3); font-family: var(--sw-mono); font-size: 10px; margin-bottom: 2px; } +.lg-density-tip-total { color: var(--sw-fg-0); font-weight: 700; font-size: 12px; margin-bottom: 4px; } +.lg-density-tip-rows { display: flex; flex-direction: column; gap: 2px; } +.lg-density-tip-row { display: inline-flex; align-items: center; gap: 6px; font-size: 10.5px; } +.lg-density-tip-row .lvl-dot { width: 7px; height: 7px; border-radius: 50%; flex: 0 0 7px; } +.lg-density-tip-name { color: var(--sw-fg-2); flex: 1; text-transform: capitalize; } +.lg-density-tip-val { color: var(--sw-fg-0); font-weight: 600; font-variant-numeric: tabular-nums; } +/* X-axis tick strip — 5 evenly-spaced labels (start / 25% / 50% / + 75% / end) underneath the bars, in tabular nums so they line up. */ +.lg-density-axis { + display: flex; + justify-content: space-between; + font-family: var(--sw-mono); + font-size: 9.5px; + color: var(--sw-fg-3); + font-variant-numeric: tabular-nums; + margin-top: 4px; + padding: 0 2px; +} +.lg-density-axis .t-tick:first-child { text-align: left; } +.lg-density-axis .t-tick:last-child { text-align: right; } + +/* Row-content hint when the inline preview is suppressed (multi-line, + YAML, JSON). Italic gray so it reads as "this is a placeholder, not + the actual content". */ +.lg-content-hint { + color: var(--sw-fg-3); + font-style: italic; + font-size: 10.5px; + letter-spacing: 0.04em; +} +.lg-row { cursor: pointer; } .lg-empty { padding: 32px; text-align: center; @@ -1186,6 +1339,36 @@ function jumpToTrace(traceId: string): void { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; +} +/* Format chip — small uppercase tag rendered before the preview. Per- + format color keeps JSON / YAML / TEXT visually distinct without + adding chrome. Tabular-nums + monospace so the three chips align. */ +.lg-fmt-chip { + flex: 0 0 auto; + display: inline-block; + padding: 0 5px; + height: 14px; + line-height: 14px; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.04em; + border-radius: 3px; + text-transform: uppercase; + font-family: var(--sw-mono); +} +.lg-fmt-chip.fmt-json { background: var(--sw-info-soft); color: var(--sw-info); } +.lg-fmt-chip.fmt-yaml { background: rgba(251, 191, 36, 0.18); color: #fbbf24; } +.lg-fmt-chip.fmt-text { background: var(--sw-bg-3); color: var(--sw-fg-2); } +.lg-content-body { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .lg-expand { padding: 10px 14px 14px 28px; diff --git a/apps/ui/src/views/layer/LayerShell.vue b/apps/ui/src/views/layer/LayerShell.vue index 9c379e6..1b6a5a1 100644 --- a/apps/ui/src/views/layer/LayerShell.vue +++ b/apps/ui/src/views/layer/LayerShell.vue @@ -26,7 +26,7 @@ default entry; the cross-layer Overview lives at `/`. --> <script setup lang="ts"> -import { computed, ref } from 'vue'; +import { computed, ref, watch } from 'vue'; import { RouterLink, RouterView, useRoute } from 'vue-router'; import type { LayerDef } from '@skywalking-horizon-ui/api-client'; import Icon from '@/components/icons/Icon.vue'; @@ -103,6 +103,23 @@ const selectedName = computed(() => { // say) can flip the same meta flag without touching this file. const viewOwnsServiceSelector = computed(() => Boolean(route.meta?.ownsServiceSelector)); +// Keep the URL-backed service selection honest for every page that +// uses the shell picker. A stale `?service=` can survive navigation or +// manual URL entry; the switch label used to fall back visually to the +// first row while the metric query still waited for a valid service. +watch( + [sampledServices, selectedId, viewOwnsServiceSelector], + ([rows, id, ownsSelector]) => { + if (ownsSelector) return; + const first = rows[0]; + if (!first) return; + if (!id || !rows.some((s) => s.serviceId === id)) { + setSelected(first.serviceId); + } + }, + { immediate: true }, +); + // Picker toggle state. Lives at the shell level so the header's Switch // button and the picker section render against the same state. const pickerOpen = ref(false); diff --git a/packages/api-client/src/menu.ts b/packages/api-client/src/menu.ts index 12e6fed..bb236c1 100644 --- a/packages/api-client/src/menu.ts +++ b/packages/api-client/src/menu.ts @@ -41,6 +41,8 @@ export interface LayerSlots { export interface LayerCaps { serviceMap?: boolean; endpointDependency?: boolean; + instances?: boolean; + endpoints?: boolean; instanceTopology?: boolean; processTopology?: boolean; dashboards?: boolean;
