From 34b1c9abb55127159ec117617443d95d5d5e0e72 Mon Sep 17 00:00:00 2001 From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:04:26 +0000 Subject: [PATCH 1/3] chore(ui): Bump datum-ui to 3.11.0 --- ui/consumer/package.json | 2 +- ui/consumer/pnpm-lock.yaml | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/ui/consumer/package.json b/ui/consumer/package.json index 98a6881c..fe318acf 100644 --- a/ui/consumer/package.json +++ b/ui/consumer/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@datum-cloud/activity-ui": "0.7.1", - "@datum-cloud/datum-ui": "3.8.0", + "@datum-cloud/datum-ui": "3.11.0", "@tanstack/react-query": "5.102.8", "@tanstack/react-table": "^9.3.1", "@tanstack/react-virtual": "^3.14.26", diff --git a/ui/consumer/pnpm-lock.yaml b/ui/consumer/pnpm-lock.yaml index 18793a59..132be3ac 100644 --- a/ui/consumer/pnpm-lock.yaml +++ b/ui/consumer/pnpm-lock.yaml @@ -15,10 +15,10 @@ importers: dependencies: '@datum-cloud/activity-ui': specifier: 0.7.1 - version: 0.7.1(4d79df9ee4747b64609303fe30ef220b) + version: 0.7.1(4da054a7b55edf417d7fddf1f66c0e8f) '@datum-cloud/datum-ui': - specifier: 3.8.0 - version: 3.8.0(@monaco-editor/react@4.7.0(monaco-editor@0.52.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@tanstack/react-table@9.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@tanstack/react-virtual@3.14.26(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(ai@7.0.161(zod@4.6.7))(date-fns-tz@3.2.0(date-fns@4.4.0))(date-fns@4.4.0)(lucide-react@1.67.0(@types/react@19.3.0)(react@19.3.0))(monaco-editor@0.52.2)(motion@13.5.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nuqs@2.10.2(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0))(react-day-picker@10.0.2(@types/react@19.3.0)(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(recharts@3.10.1(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react-is@19.3.0)(react@19.3.0)(redux@5.0.1))(sonner@2.0.8(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(zod@4.6.7) + specifier: 3.11.0 + version: 3.11.0(@monaco-editor/react@4.7.0(monaco-editor@0.52.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@tanstack/react-table@9.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@tanstack/react-virtual@3.14.26(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(ai@7.0.161(zod@4.6.7))(date-fns-tz@3.2.0(date-fns@4.4.0))(date-fns@4.4.0)(lucide-react@1.67.0(@types/react@19.3.0)(react@19.3.0))(monaco-editor@0.52.2)(motion@13.5.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nuqs@2.10.2(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0))(react-day-picker@10.0.2(@types/react@19.3.0)(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(recharts@3.10.1(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react-is@19.3.0)(react@19.3.0)(redux@5.0.1))(sonner@2.0.8(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(zod@4.6.7) '@tanstack/react-query': specifier: 5.102.8 version: 5.102.8(react@19.3.0) @@ -263,8 +263,8 @@ packages: peerDependencies: ai: '>=7 <8' - '@datum-cloud/datum-ui@3.8.0': - resolution: {integrity: sha512-T0IJxfBllKP7/TWO1Pbi7XCKKUYa0icaK176Okoyy1k4gR6ibOVl9QZSDrqR7LiHKGOVar7htzbetDID6K9vsQ==} + '@datum-cloud/datum-ui@3.11.0': + resolution: {integrity: sha512-mRCIx9fvXPa69Z6pYDQ4s0tqQRlvZnT7aHx3aLmP1lun7hPqTIQls8Az8/avmN9W1Ehe9hCoGFVCPwx0w6+UVw==} peerDependencies: '@conform-to/react': '>=1.19.4 <2' '@conform-to/zod': '>=1.19.4 <2' @@ -2572,9 +2572,9 @@ snapshots: '@date-fns/tz@1.5.0': {} - '@datum-cloud/activity-ui@0.7.1(4d79df9ee4747b64609303fe30ef220b)': + '@datum-cloud/activity-ui@0.7.1(4da054a7b55edf417d7fddf1f66c0e8f)': dependencies: - '@datum-cloud/datum-ui': 3.8.0(@monaco-editor/react@4.7.0(monaco-editor@0.52.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@tanstack/react-table@9.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@tanstack/react-virtual@3.14.26(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(ai@7.0.161(zod@4.6.7))(date-fns-tz@3.2.0(date-fns@4.4.0))(date-fns@4.4.0)(lucide-react@1.67.0(@types/react@19.3.0)(react@19.3.0))(monaco-editor@0.52.2)(motion@13.5.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nuqs@2.10.2(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0))(react-day-picker@10.0.2(@types/react@19.3.0)(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(recharts@3.10.1(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react-is@19.3.0)(react@19.3.0)(redux@5.0.1))(sonner@2.0.8(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(zod@4.6.7) + '@datum-cloud/datum-ui': 3.11.0(@monaco-editor/react@4.7.0(monaco-editor@0.52.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@tanstack/react-table@9.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@tanstack/react-virtual@3.14.26(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(ai@7.0.161(zod@4.6.7))(date-fns-tz@3.2.0(date-fns@4.4.0))(date-fns@4.4.0)(lucide-react@1.67.0(@types/react@19.3.0)(react@19.3.0))(monaco-editor@0.52.2)(motion@13.5.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nuqs@2.10.2(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0))(react-day-picker@10.0.2(@types/react@19.3.0)(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(recharts@3.10.1(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react-is@19.3.0)(react@19.3.0)(redux@5.0.1))(sonner@2.0.8(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(zod@4.6.7) '@monaco-editor/react': 4.7.0(monaco-editor@0.52.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@radix-ui/react-tooltip': 1.2.16(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) class-variance-authority: 0.7.1 @@ -2590,7 +2590,7 @@ snapshots: dependencies: ai: 7.0.161(zod@4.6.7) - '@datum-cloud/datum-ui@3.8.0(@monaco-editor/react@4.7.0(monaco-editor@0.52.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@tanstack/react-table@9.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@tanstack/react-virtual@3.14.26(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(ai@7.0.161(zod@4.6.7))(date-fns-tz@3.2.0(date-fns@4.4.0))(date-fns@4.4.0)(lucide-react@1.67.0(@types/react@19.3.0)(react@19.3.0))(monaco-editor@0.52.2)(motion@13.5.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nuqs@2.10.2(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0))(react-day-picker@10.0.2(@types/react@19.3.0)(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(recharts@3.10.1(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react-is@19.3.0)(react@19.3.0)(redux@5.0.1))(sonner@2.0.8(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(zod@4.6.7)': + '@datum-cloud/datum-ui@3.11.0(@monaco-editor/react@4.7.0(monaco-editor@0.52.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@tanstack/react-table@9.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@tanstack/react-virtual@3.14.26(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(ai@7.0.161(zod@4.6.7))(date-fns-tz@3.2.0(date-fns@4.4.0))(date-fns@4.4.0)(lucide-react@1.67.0(@types/react@19.3.0)(react@19.3.0))(monaco-editor@0.52.2)(motion@13.5.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(nuqs@2.10.2(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0))(react-day-picker@10.0.2(@types/react@19.3.0)(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(recharts@3.10.1(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react-is@19.3.0)(react@19.3.0)(redux@5.0.1))(sonner@2.0.8(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(zod@4.6.7)': dependencies: '@datum-cloud/datum-ui-assistant-core': 0.1.0(ai@7.0.161(zod@4.6.7)) '@radix-ui/react-avatar': 1.2.6(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) From 40582b64926fc560a31e552dffb4755ef9e39ad8 Mon Sep 17 00:00:00 2001 From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:04:26 +0000 Subject: [PATCH 2/3] feat(ui): Fill the page height with the topology view The topology canvas grows to the bottom of the page instead of a fixed frame, and refits as it resizes until you pan or zoom. The page chrome and skeletons flex to match, so the logs tabs fill the height too. --- .../src/components/instance-page-chrome.tsx | 2 +- ui/consumer/src/components/skeletons.tsx | 7 ++-- .../src/components/topology-canvas.tsx | 34 +++++++++++++++---- ui/consumer/src/components/topology-card.tsx | 14 +++++--- .../src/components/workload-page-chrome.tsx | 2 +- 5 files changed, 44 insertions(+), 15 deletions(-) diff --git a/ui/consumer/src/components/instance-page-chrome.tsx b/ui/consumer/src/components/instance-page-chrome.tsx index 9f2cb3b7..8145f679 100644 --- a/ui/consumer/src/components/instance-page-chrome.tsx +++ b/ui/consumer/src/components/instance-page-chrome.tsx @@ -64,7 +64,7 @@ export function InstancePageChrome({ children: React.ReactNode; }) { return ( -
+
diff --git a/ui/consumer/src/components/skeletons.tsx b/ui/consumer/src/components/skeletons.tsx index 65541c09..bd5cb740 100644 --- a/ui/consumer/src/components/skeletons.tsx +++ b/ui/consumer/src/components/skeletons.tsx @@ -15,9 +15,10 @@ import { ChevronDown, ChevronUp } from 'lucide-react'; import type { CSSProperties } from 'react'; const PANEL_STYLE = { height: '27rem' } as const; -const EXPLORER_STYLE = { height: '32rem' } as const; +const EXPLORER_STYLE = { flex: '1 1 auto', minHeight: '32rem' } as const; const CHART_STYLE = { height: 224 } as const; -const TOPOLOGY_STYLE = { height: '28rem' } as const; +// Topology and the log explorers fill the page, so their skeletons do too. +const TOPOLOGY_STYLE = { flex: '1 0 auto', minHeight: 'calc(28rem + 3rem)' } as const; /** * The list table's columns before usage data arrives, each with a bone the @@ -374,7 +375,7 @@ export function WorkloadOverviewSkeleton() { /** Matches the Topology tab: one diagram panel. */ export function WorkloadTopologySkeleton() { return ( -
+
diff --git a/ui/consumer/src/components/topology-canvas.tsx b/ui/consumer/src/components/topology-canvas.tsx index 650e19c9..54512fc2 100644 --- a/ui/consumer/src/components/topology-canvas.tsx +++ b/ui/consumer/src/components/topology-canvas.tsx @@ -111,7 +111,10 @@ const COMPACT_LAYOUT = { columns: 1, limit: 3 } as const; * the page scrolls as usual and the canvas never traps a touch. */ const COMPACT_BREAKPOINT = 560; -/** Fixed frame height outside compact mode. Inline: the host does not compile `h-[28rem]`. */ +/** + * Smallest frame outside compact mode. The frame otherwise grows to fill the + * space its card gives it. Inline: the host does not compile `min-h-[28rem]`. + */ const FRAME_HEIGHT = '28rem'; /** Compact top inset, clearing the corner chips. */ const COMPACT_TOP = 52; @@ -555,6 +558,8 @@ export function TopologyCanvas({ const [expanded, setExpanded] = useState>(() => new Set()); const [compact, setCompact] = useState(false); const [compactHeight, setCompactHeight] = useState(0); + // Set once someone pans or zooms, so a resize doesn't throw their view away. + const touchedRef = useRef(false); viewRef.current = view; useLayoutEffect(() => { @@ -659,10 +664,19 @@ export function TopologyCanvas({ const world = worldRef.current; if (!viewport || !world) return; if (!compact) { - const next = fitView(viewport, world); - viewRef.current = next; - setView(next); - return; + // The frame fills its card, so its size follows the window. Refit as it + // changes until someone pans or zooms; Reset view refits on demand. + touchedRef.current = false; + const fit = () => { + if (touchedRef.current) return; + const next = fitView(viewport, world); + viewRef.current = next; + setView(next); + }; + fit(); + const observer = new ResizeObserver(fit); + observer.observe(viewport); + return () => observer.disconnect(); } // Compact: track the tree's size, since the frame grows with it. const apply = () => { @@ -695,6 +709,7 @@ export function TopologyCanvas({ event.clientY - rect.top, current.scale * Math.exp(-event.deltaY * 0.01) ); + touchedRef.current = true; viewRef.current = next; setView(next); }; @@ -721,6 +736,7 @@ export function TopologyCanvas({ x: origin.x + move.clientX - startX, y: origin.y + move.clientY - startY, }; + touchedRef.current = true; viewRef.current = next; setView(next); }; @@ -749,6 +765,7 @@ export function TopologyCanvas({ viewport.clientHeight / 2, viewRef.current.scale * factor ); + touchedRef.current = true; viewRef.current = next; setView(next); }; @@ -758,6 +775,7 @@ export function TopologyCanvas({ const world = worldRef.current; if (!viewport || !world) return; const next = fitView(viewport, world); + touchedRef.current = false; viewRef.current = next; setView(next); }; @@ -767,7 +785,11 @@ export function TopologyCanvas({ ref={viewportRef} data-testid="compute-plugin-topology-canvas" className={`cpt-root${panning ? ' cpt-panning' : ''}${compact ? ' cpt-compact' : ''}`} - style={{ position: 'relative', height: compact ? compactHeight || FRAME_HEIGHT : FRAME_HEIGHT }} + style={ + compact + ? { position: 'relative', height: compactHeight || FRAME_HEIGHT } + : { position: 'relative', flex: '1 1 auto', minHeight: FRAME_HEIGHT } + } aria-label={ compact ? 'Topology diagram.' diff --git a/ui/consumer/src/components/topology-card.tsx b/ui/consumer/src/components/topology-card.tsx index 85570fec..382feb30 100644 --- a/ui/consumer/src/components/topology-card.tsx +++ b/ui/consumer/src/components/topology-card.tsx @@ -42,6 +42,9 @@ import { Icon } from '@datum-cloud/datum-ui/icons'; import { WaypointsIcon } from 'lucide-react'; import { useCallback, useEffect, useMemo, useState } from 'react'; +/** Inline: the host does not compile `grow` / `basis-auto` reliably. */ +const FILL_STYLE = { flex: '1 0 auto' } as const; + function percent(numerator?: number, denominator?: number): string | undefined { if (numerator === undefined || !denominator) return undefined; const pct = (numerator / denominator) * 100; @@ -331,7 +334,10 @@ export function TopologyCard({ @@ -342,9 +348,9 @@ export function TopologyCard({ {summary} - {/* The canvas sets its own height: a fixed frame it pans inside, or on a - narrow screen the height of its stacked tree. */} - + {/* The canvas fills the card (at least 28rem) and pans inside it, or on a + narrow screen takes the height of its stacked tree. */} + +
From 6a0c8357d2650adab50cab96a45f080fb8d04eeb Mon Sep 17 00:00:00 2001 From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:04:26 +0000 Subject: [PATCH 3/3] feat(ui): Keep the logs view in the URL and count filters across the window - Time range, filters, search, live and the open row live in the query string, so a refresh or shared link reopens the same view. - Filter options come from label values for the whole window, and each shows how many lines match the other ticked filters (queryapi's sum by count_over_time), so ticking one never hides the others. - Source and Severity no longer go into the ALB LogQL. Source is a label the plugin adds, so Source = ALB matched no ALB rows; Severity now filters instance stdout. A query whose rows the filters rule out is skipped. - Host lists the host each row shows, not every host label on it. - Earlier rows stay on screen while the next request runs, with datum-ui's isFetching state. --- ui/consumer/src/components/instance-logs.tsx | 89 +-- ui/consumer/src/lib/logs-url-state.ts | 67 ++ ui/consumer/src/lib/o11y-logs.test.ts | 214 ++++++ ui/consumer/src/lib/o11y-logs.ts | 656 +++++++++++++++++-- 4 files changed, 946 insertions(+), 80 deletions(-) create mode 100644 ui/consumer/src/lib/logs-url-state.ts diff --git a/ui/consumer/src/components/instance-logs.tsx b/ui/consumer/src/components/instance-logs.tsx index b2b0d486..b0d8ed93 100644 --- a/ui/consumer/src/components/instance-logs.tsx +++ b/ui/consumer/src/components/instance-logs.tsx @@ -8,6 +8,7 @@ * workload has a published HTTPProxy. */ import { ApiError } from '../lib/api'; +import { useComputeLogsUrlState } from '../lib/logs-url-state'; import { ALB_LOGS_PREVIEW_LIMIT, combinedLogFacets, @@ -17,6 +18,8 @@ import { recentLogsRange, useInstanceLogs, useWorkloadLogs, + instanceUpstreamIPs, + useWindowLogFacets, } from '../lib/o11y-logs'; import { Badge } from '@datum-cloud/datum-ui/badge'; import { useCopyToClipboard } from '@datum-cloud/datum-ui/hooks'; @@ -32,11 +35,9 @@ import { Icon, SpinnerIcon } from '@datum-cloud/datum-ui/icons'; import { facetsFromEntries, httpStatusBadgeType, - lastThirtyMinutes, logRequestHost, Logs, parseLogLine, - resolveLogTimeRange, type LogColumn, type LogColumnSpec, type LogEntry, @@ -46,7 +47,7 @@ import { import { cn } from '@datum-cloud/datum-ui/utils'; import { formatDistanceToNowStrict } from 'date-fns'; import { CheckIcon, CopyIcon, LogsIcon, RadioIcon } from 'lucide-react'; -import { useCallback, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { Link } from 'react-router'; import { OPEN_ROW_PADDING } from './detail-list'; import { QuietNotice, SectionFrame } from './surface'; @@ -394,16 +395,9 @@ export function InstanceLogsExplorer({ upstreamIPs?: readonly string[]; className?: string; }) { - const [filters, setFilters] = useState({}); - const [search, setSearch] = useState(''); - const [live, setLive] = useState(false); - const [timeRange, setTimeRange] = useState(() => lastThirtyMinutes()); - - const handleRefresh = useCallback(() => { - setTimeRange((current) => - current.preset ? resolveLogTimeRange(current) : lastThirtyMinutes() - ); - }, []); + // Time range, filters, search, live and the open row live in the URL. + const logsState = useComputeLogsUrlState(); + const { timeRange, filters, search, live, refreshWindow } = logsState; const logsQuery = useInstanceLogs(projectId, proxyId, instanceName, { timeRange, @@ -418,7 +412,21 @@ export function InstanceLogsExplorer({ () => filterCombinedLogs(logsQuery.data ?? [], filters), [logsQuery.data, filters] ); - const facets = useMemo(() => combinedLogFacets(logsQuery.data ?? []), [logsQuery.data]); + // Every option in the window, whatever is ticked. Until those load, fall + // back to facets built from the fetched rows. + const ips = useMemo(() => instanceUpstreamIPs({ internalIPs: upstreamIPs }), [upstreamIPs]); + const instanceNames = useMemo(() => (instanceName ? [instanceName] : []), [instanceName]); + const windowFacets = useWindowLogFacets(projectId, { + proxyId: ips.length > 0 ? proxyId : undefined, + upstreamIPs: ips, + instanceNames, + filters, + search, + timeRange, + live, + }); + const rowFacets = useMemo(() => combinedLogFacets(logsQuery.data ?? []), [logsQuery.data]); + const facets = windowFacets ?? rowFacets; const denied = isLogsDenied(logsQuery.error); const errorMessage = logsQuery.error && !denied ? logsQuery.error.message : undefined; @@ -447,25 +455,20 @@ export function InstanceLogsExplorer({ {/* Same class string as the portal's AlbLogsExplorer so the filters bar matches. */} @@ -523,16 +526,9 @@ export function WorkloadLogsExplorer({ instanceNames: readonly string[]; className?: string; }) { - const [filters, setFilters] = useState({}); - const [search, setSearch] = useState(''); - const [live, setLive] = useState(false); - const [timeRange, setTimeRange] = useState(() => lastThirtyMinutes()); - - const handleRefresh = useCallback(() => { - setTimeRange((current) => - current.preset ? resolveLogTimeRange(current) : lastThirtyMinutes() - ); - }, []); + // Time range, filters, search, live and the open row live in the URL. + const logsState = useComputeLogsUrlState(); + const { timeRange, filters, search, live, refreshWindow } = logsState; const queryFilters = useMemo(() => { if (!filters[COMPUTE_LOG_INSTANCE_LABEL]) return filters; @@ -553,7 +549,18 @@ export function WorkloadLogsExplorer({ () => filterWorkloadExplorerLogs(logsQuery.data ?? [], filters), [logsQuery.data, filters] ); - const facets = useMemo(() => { + // Every option in the window, whatever is ticked, with every instance + // listed. Until those load, fall back to facets built from the fetched rows. + const windowFacets = useWindowLogFacets(projectId, { + proxyId, + instanceNames, + instanceFacet: true, + filters, + search, + timeRange, + live, + }); + const rowFacets = useMemo(() => { const entries = logsQuery.data ?? []; const instance = facetsFromEntries(entries, [COMPUTE_LOG_INSTANCE_LABEL]).map((facet) => ({ ...facet, @@ -561,6 +568,7 @@ export function WorkloadLogsExplorer({ })); return [...combinedLogFacets(entries), ...instance]; }, [logsQuery.data]); + const facets = windowFacets ?? rowFacets; const denied = isLogsDenied(logsQuery.error); const errorMessage = logsQuery.error && !denied ? logsQuery.error.message : undefined; @@ -589,25 +597,20 @@ export function WorkloadLogsExplorer({ diff --git a/ui/consumer/src/lib/logs-url-state.ts b/ui/consumer/src/lib/logs-url-state.ts new file mode 100644 index 00000000..ca681e6d --- /dev/null +++ b/ui/consumer/src/lib/logs-url-state.ts @@ -0,0 +1,67 @@ +/** + * URL state for the Logs tabs: time range, facet filters, search, live and the + * open row live in the query string (`?range=last-1h&filter=method:GET&q=…`), + * so a refresh or a shared link reopens the same view. + * + * Keys come from datum-ui's `useLogsUrlState` (`q`, `range`, `from`, `to`, + * `filter`, `live`, `log`) and don't overlap the plugin's other params + * (`timeRange`, `regions`, `instances`). + */ +import { + lastThirtyMinutes, + resolveLogTimeRange, + useLogsUrlState, + writeLogsUrlState, + type LogsUrlStateProps, + type LogTimeRange, + type SetLogsSearchParams, +} from '@datum-cloud/datum-ui/logs'; +import { useCallback, useMemo, useState } from 'react'; +import { useSearchParams } from 'react-router'; + +export interface ComputeLogsUrlState extends LogsUrlStateProps { + /** + * Re-run the query for "now": slides a preset window forward, and swaps a + * fixed range for the last 30 minutes (what Refresh did before). + */ + refreshWindow: () => void; +} + +export function useComputeLogsUrlState(): ComputeLogsUrlState { + // Go through the host's router (shared singleton) so its location stays + // current; replace, so the back button leaves the tab rather than stepping + // through every filter click. + const [searchParams, setRouterSearchParams] = useSearchParams(); + const setSearchParams = useCallback( + (update, options) => + setRouterSearchParams(update, { + replace: options?.replace ?? true, + preventScrollReset: true, + }), + [setRouterSearchParams] + ); + const logsState = useLogsUrlState({ searchParams, setSearchParams }); + + // The URL holds a preset by key, so writing the re-anchored window back + // changes nothing. A refresh re-anchors locally instead, and only for the + // range it was made for: the hook keeps one `timeRange` object until a logs + // param changes, and any change re-resolves the preset to now anyway. + const [refresh, setRefresh] = useState<{ base: LogTimeRange; at: number } | null>(null); + const timeRange = useMemo( + () => + refresh?.base === logsState.timeRange && logsState.timeRange.preset + ? resolveLogTimeRange(logsState.timeRange, new Date(refresh.at)) + : logsState.timeRange, + [logsState.timeRange, refresh] + ); + + const refreshWindow = useCallback(() => { + if (logsState.timeRange.preset) { + setRefresh({ base: logsState.timeRange, at: Date.now() }); + return; + } + setSearchParams((prev) => writeLogsUrlState(prev, { timeRange: lastThirtyMinutes() })); + }, [logsState.timeRange, setSearchParams]); + + return { ...logsState, timeRange, refreshWindow }; +} diff --git a/ui/consumer/src/lib/o11y-logs.test.ts b/ui/consumer/src/lib/o11y-logs.test.ts index aab9eec8..9a58f8cc 100644 --- a/ui/consumer/src/lib/o11y-logs.test.ts +++ b/ui/consumer/src/lib/o11y-logs.test.ts @@ -3,6 +3,14 @@ import { albUpstreamHostMatches, buildAlbLogQL, buildAlbUpstreamHostRegexp, + computeFacetsFromLabelValues, + COMPUTE_LOG_INSTANCE_LABEL, + facetWindowKey, + windowCountQueries, + windowFacetCounts, + filterCombinedLogs, + logSourcesWanted, + keepPreviousInScope, filterAlbLogsByUpstreamHost, instanceUpstreamIPs, LOG_SOURCE_ALB, @@ -104,6 +112,51 @@ describe("buildAlbLogQL", () => { '{route_name=~"httproute/[^/]+/my-proxy/.*"}', ); }); + + test("sends only ALB stream labels, never Source, Severity or Host", () => { + expect( + buildAlbLogQL("my-proxy", { + [LOG_SOURCE_LABEL]: [LOG_SOURCE_ALB], + severity: ["ERROR"], + host: ["a.example.com"], + method: ["GET"], + }), + ).toBe('{route_name=~"httproute/[^/]+/my-proxy/.*", method="GET"}'); + }); +}); + +describe("logSourcesWanted", () => { + test("queries both sources with no filters", () => { + expect(logSourcesWanted({})).toEqual({ alb: true, instance: true }); + }); + + test("follows the Source facet", () => { + expect(logSourcesWanted({ [LOG_SOURCE_LABEL]: [LOG_SOURCE_ALB] })).toEqual({ alb: true, instance: false }); + expect(logSourcesWanted({ [LOG_SOURCE_LABEL]: [LOG_SOURCE_INSTANCE] })).toEqual({ alb: false, instance: true }); + }); + + test("HTTP facets need ALB rows and Severity needs stdout", () => { + expect(logSourcesWanted({ method: ["GET"] })).toEqual({ alb: true, instance: false }); + expect(logSourcesWanted({ host: ["a.example.com"] })).toEqual({ alb: true, instance: false }); + expect(logSourcesWanted({ severity: ["ERROR"] })).toEqual({ alb: false, instance: true }); + }); +}); + +describe("filterCombinedLogs", () => { + const rows = [ + entry(LOG_SOURCE_ALB, { line: "alb", method: "GET" }), + entry(LOG_SOURCE_INSTANCE, { line: "err", severity: "ERROR" }), + entry(LOG_SOURCE_INSTANCE, { line: "info", severity: "INFO" }), + ]; + const lines = (filtered: LogEntry[]) => filtered.map((row) => row.labels.line); + + test("Source keeps only that source", () => { + expect(lines(filterCombinedLogs(rows, { [LOG_SOURCE_LABEL]: [LOG_SOURCE_ALB] }))).toEqual(["alb"]); + }); + + test("Severity filters stdout and hides ALB rows", () => { + expect(lines(filterCombinedLogs(rows, { severity: ["ERROR"] }))).toEqual(["err"]); + }); }); describe("filterAlbLogsByUpstreamHost", () => { @@ -145,3 +198,164 @@ describe("recentLogsRange", () => { expect(Date.parse(slid.to) - Date.parse(slid.from)).toBe(RECENT_LOGS_LOOKBACK_MS); }); }); + +describe("computeFacetsFromLabelValues", () => { + test("lists every option for the window in the explorer's facet order", () => { + const facets = computeFacetsFromLabelValues( + { alb: true, instances: true, instanceNames: ["web-b", "web-a"] }, + { + severity: ["INFO", "ERROR", "debug"], + method: ["POST", "GET", "GET"], + response_code: ["503", "200", "404"], + host: ["b.example.com", "-"], + authority: ["a.example.com", " b.example.com "], + requested_server_name: [""], + }, + ); + expect(facets.map((facet) => [facet.name, facet.options.map((option) => option.value)])).toEqual([ + [LOG_SOURCE_LABEL, [LOG_SOURCE_ALB, LOG_SOURCE_INSTANCE]], + ["severity", ["ERROR", "INFO", "debug"]], + ["method", ["GET", "POST"]], + ["response_code", ["200", "404", "503"]], + ["host", ["b.example.com"]], + [COMPUTE_LOG_INSTANCE_LABEL, ["web-a", "web-b"]], + ]); + }); + + test("leaves out sources and facets that are not in scope", () => { + const facets = computeFacetsFromLabelValues({ alb: false, instances: true }, { severity: ["WARN"] }); + expect(facets.map((facet) => facet.name)).toEqual([LOG_SOURCE_LABEL, "severity"]); + expect(facets[0].options).toEqual([{ value: LOG_SOURCE_INSTANCE }]); + }); +}); + +describe("keepPreviousInScope", () => { + const previous = ["row"]; + const withKey = (queryKey: unknown[]) => + ({ queryKey }) as unknown as Parameters>>[1]; + + test("keeps rows for the same project and scope, drops them otherwise", () => { + const keep = keepPreviousInScope("p1", "alb-a"); + expect(keep(previous, withKey(["compute", "o11y-logs", "alb", "p1", "alb-a", "q", "w"]))).toBe(previous); + expect(keep(previous, withKey(["compute", "o11y-logs", "alb", "p1", "alb-b", "q", "w"]))).toBe(undefined); + expect(keep(previous, withKey(["compute", "o11y-logs", "alb", "p2", "alb-a", "q", "w"]))).toBe(undefined); + }); +}); + +describe("facetWindowKey", () => { + test("keys a preset by name, so re-anchoring to now keeps it", () => { + expect(facetWindowKey({ from: "a", to: "b", preset: "last-30m" }, false)).toBe( + facetWindowKey({ from: "c", to: "d", preset: "last-30m" }, false), + ); + expect(facetWindowKey({ from: "a", to: "b" }, false)).toBe("a/b"); + }); +}); + +describe("windowCountQueries", () => { + const scope = { proxyId: "my-proxy", instanceNames: ["web-0", "web-1"], instanceFacet: true }; + const alb = 'route_name=~"httproute/[^/]+/my-proxy/.*"'; + + test("counts each facet with the others applied, not its own", () => { + const queries = windowCountQueries(scope, { method: ["GET"], response_code: ["503"] }, "", 1800); + expect(queries.albMethod).toBe( + `sum by (method) (count_over_time({${alb}, response_code="503"} [1800s]))`, + ); + expect(queries.albStatus).toBe(`sum by (response_code) (count_over_time({${alb}, method="GET"} [1800s]))`); + expect(queries.albHost).toBe( + `sum by (host, requested_server_name, x_forwarded_host, authority) (count_over_time({${alb}, method="GET", response_code="503"} [1800s]))`, + ); + // HTTP facets hide stdout, so nothing counts it. + expect(queries.stdoutSeverity).toBeNull(); + expect(queries.stdoutTotal).toBeNull(); + }); + + test("applies Severity and the picked instances to stdout", () => { + const queries = windowCountQueries( + scope, + { severity: ["ERROR", "WARN"], [COMPUTE_LOG_INSTANCE_LABEL]: ["web-1"] }, + "", + 300, + ); + expect(queries.stdoutSeverity).toBe( + `sum by (severity) (count_over_time({${COMPUTE_LOG_INSTANCE_LABEL}="web-1"} [300s]))`, + ); + expect(queries.stdoutInstance).toBe( + `sum by (${COMPUTE_LOG_INSTANCE_LABEL}) (count_over_time({${COMPUTE_LOG_INSTANCE_LABEL}=~"web-0|web-1", severity=~"ERROR|WARN"} [300s]))`, + ); + expect(queries.stdoutTotal).toBe( + `sum(count_over_time({${COMPUTE_LOG_INSTANCE_LABEL}="web-1", severity=~"ERROR|WARN"} [300s]))`, + ); + // Severity hides ALB rows. + expect(queries.albHost).toBeNull(); + }); + + test("runs nothing while searching, and no HTTP counts with a host ticked", () => { + expect(Object.values(windowCountQueries(scope, {}, "boom", 60)).every((query) => query === null)).toBe(true); + const queries = windowCountQueries(scope, { host: ["a.example.com"] }, "", 60); + expect(queries.albMethod).toBeNull(); + expect(queries.albHost).not.toBeNull(); + }); + + test("keeps the ALB total for Source when Source hides ALB rows", () => { + const queries = windowCountQueries(scope, { [LOG_SOURCE_LABEL]: [LOG_SOURCE_INSTANCE] }, "", 60); + expect(queries.albMethod).toBeNull(); + expect(queries.albHost).not.toBeNull(); + expect(queries.stdoutTotal).not.toBeNull(); + }); +}); + +describe("windowFacetCounts", () => { + const scope = { proxyId: "my-proxy", instanceNames: ["web-0"], instanceFacet: false }; + const get = (counts: ReturnType, name: string) => + counts[name] ? Object.fromEntries(counts[name]) : undefined; + + test("counts every facet, Source from the totals", () => { + const counts = windowFacetCounts(scope, {}, "", { + albMethod: [{ labels: { method: "GET" }, count: 4 }, { labels: { method: "-" }, count: 1 }], + albStatus: [{ labels: { response_code: "200" }, count: 5 }], + albHost: [ + { labels: { host: "a.example.com", authority: "httpbin.org" }, count: 3 }, + { labels: { host: "-", x_forwarded_host: "b.example.com" }, count: 2 }, + ], + stdoutSeverity: [{ labels: { severity: "ERROR" }, count: 7 }], + stdoutTotal: [{ labels: {}, count: 9 }], + }); + expect(get(counts, "method")).toEqual({ GET: 4 }); + expect(get(counts, "response_code")).toEqual({ "200": 5 }); + expect(get(counts, "host")).toEqual({ "a.example.com": 3, "b.example.com": 2 }); + expect(get(counts, "severity")).toEqual({ ERROR: 7 }); + expect(get(counts, LOG_SOURCE_LABEL)).toEqual({ [LOG_SOURCE_ALB]: 5, [LOG_SOURCE_INSTANCE]: 9 }); + }); + + test("zeros a source the other filters hide", () => { + const counts = windowFacetCounts(scope, { severity: ["ERROR"] }, "", { + stdoutSeverity: [{ labels: { severity: "ERROR" }, count: 2 }], + stdoutTotal: [{ labels: {}, count: 2 }], + }); + expect(get(counts, "method")).toEqual({}); + expect(get(counts, LOG_SOURCE_LABEL)).toEqual({ [LOG_SOURCE_ALB]: 0, [LOG_SOURCE_INSTANCE]: 2 }); + }); + + test("leaves a failed slot's facets uncounted, and everything while searching", () => { + const counts = windowFacetCounts(scope, {}, "", { albMethod: [] }); + expect(get(counts, "method")).toEqual({}); + expect(counts.response_code).toBeUndefined(); + expect(counts[LOG_SOURCE_LABEL]).toBeUndefined(); + expect(windowFacetCounts(scope, {}, "x", { albMethod: [] })).toEqual({}); + }); +}); + +describe("computeFacetsFromLabelValues with counts", () => { + test("gives counted facets a count per option, 0 when nothing matched", () => { + const facets = computeFacetsFromLabelValues( + { alb: true, instances: false }, + { method: ["GET", "POST"], response_code: ["200"] }, + { method: new Map([["GET", 3]]) }, + ); + expect(facets.find((facet) => facet.name === "method")?.options).toEqual([ + { value: "GET", count: 3 }, + { value: "POST", count: 0 }, + ]); + expect(facets.find((facet) => facet.name === "response_code")?.options).toEqual([{ value: "200" }]); + }); +}); diff --git a/ui/consumer/src/lib/o11y-logs.ts b/ui/consumer/src/lib/o11y-logs.ts index c071af5e..df0560f4 100644 --- a/ui/consumer/src/lib/o11y-logs.ts +++ b/ui/consumer/src/lib/o11y-logs.ts @@ -10,8 +10,14 @@ */ import { hostRouteIP } from "../adapter"; import { ApiError, PLUGIN_ID, getProjectScopedBase } from "./api"; -import { useMemo } from "react"; -import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { useCallback, useMemo } from "react"; +import { + keepPreviousData, + useQueries, + useQuery, + type Query, + type UseQueryResult, +} from "@tanstack/react-query"; import { buildLogQL, facetsFromEntries, @@ -27,6 +33,12 @@ import { type LokiQueryRangeResponse, } from "@datum-cloud/datum-ui/logs"; +export const O11Y_LOGS_LABEL_VALUES_PATH = + "/apis/o11y.miloapis.com/v1alpha1/logs/loki/api/v1/label/{name}/values"; + +/** Instant queries; queryapi answers `sum by (...) (count_over_time(...))` here. */ +export const O11Y_LOGS_QUERY_PATH = "/apis/o11y.miloapis.com/v1alpha1/logs/loki/api/v1/query"; + export const O11Y_LOGS_QUERY_RANGE_PATH = "/apis/o11y.miloapis.com/v1alpha1/logs/loki/api/v1/query_range"; @@ -73,6 +85,15 @@ const ALB_LOG_LABELS = [ const ALB_LOG_LABEL_SET = new Set(ALB_LOG_LABELS); +/** Labels the Host column reads, in the order it prefers them. */ +const ALB_HOST_SOURCE_LABELS = ["host", "requested_server_name", "x_forwarded_host", "authority"] as const; + +/** + * Host labels whose values list Host's options. A row's `x_forwarded_host` or + * `authority` is rarely its host, and Host's counts add any that are. + */ +const ALB_HOST_OPTION_LABELS = ["host", "requested_server_name"] as const; + const CLIENT_HOST_FILTERS = [ "host", "authority", @@ -83,6 +104,9 @@ const CLIENT_HOST_FILTERS = [ export const ALB_LOG_FACET_NAMES = ["method", "response_code", "host"] as const; +/** ALB facets that are stream labels, so LogQL can filter on them. */ +const ALB_SERVER_FILTER_LABELS = ["method", "response_code"] as const; + export const ALB_LOG_FACET_LABELS: Record< (typeof ALB_LOG_FACET_NAMES)[number], string @@ -127,16 +151,15 @@ export function albLogMatchers( _proxyId: string, extraFilters: LogFilters = {}, ): LogFilters { - const { - resource_name: _ignoredResource, - route_name: _ignoredRoute, - host: _ignoredHost, - authority: _ignoredAuthority, - requested_server_name: _ignoredSni, - x_forwarded_host: _ignoredXfh, - ...rest - } = extraFilters; - return rest; + // Only real ALB stream labels go to the server. Host is resolved from + // several labels in the browser, and Source and Severity don't exist on + // access logs, so sending them would match no ALB rows at all. + const matchers: LogFilters = {}; + for (const label of ALB_SERVER_FILTER_LABELS) { + const values = extraFilters[label]; + if (values?.length) matchers[label] = values; + } + return matchers; } /** Host-route IPs an ALB may dial for this instance. */ @@ -303,21 +326,19 @@ export function mergeLogEntries( }); } +/** + * The row's host, as the Host column shows it: the first host label set, so + * the filter and facet match what each row displays. `authority` comes last + * because the proxy rewrites it to the backend's name (e.g. httpbin.org). + * An array so callers can treat "no host" as empty. + */ export function albHostValues(labels: Record): string[] { - const seen = new Set(); - const values: string[] = []; - for (const raw of [ - labels.host, - labels.requested_server_name, - labels.x_forwarded_host, - labels.authority, - ]) { - const host = raw?.trim(); - if (!host || seen.has(host)) continue; - seen.add(host); - values.push(host); + for (const label of ALB_HOST_SOURCE_LABELS) { + const host = labels[label]?.trim(); + // Envoy writes "-" for a header it didn't get; that's not a host. + if (host && host !== "-") return [host]; } - return values; + return []; } export function filterAlbLogsByHost( @@ -336,26 +357,54 @@ export function filterAlbLogsByHost( ); } +/** True when any facet that only ALB rows carry is ticked. */ +function hasHttpFilter(filters: LogFilters): boolean { + return ( + (filters.method?.length ?? 0) > 0 || + (filters.response_code?.length ?? 0) > 0 || + CLIENT_HOST_FILTERS.some((key) => (filters[key]?.length ?? 0) > 0) + ); +} + /** - * Host / HTTP facets apply to ALB rows only. Instance stdout stays visible - * unless those facets (or an explicit Source=ALB filter) hide it. + * Which sources can still show rows under `filters`, so a hook can skip the + * other query. Source picks them directly; HTTP facets only match ALB rows, + * and Severity only matches instance stdout. + */ +export function logSourcesWanted(filters: LogFilters = {}): { + alb: boolean; + instance: boolean; +} { + const sources = filters[LOG_SOURCE_LABEL] ?? []; + const picked = (source: string) => sources.length === 0 || sources.includes(source); + return { + alb: picked(LOG_SOURCE_ALB) && (filters.severity?.length ?? 0) === 0, + instance: picked(LOG_SOURCE_INSTANCE) && !hasHttpFilter(filters), + }; +} + +/** + * Host / HTTP facets apply to ALB rows only, and Severity to instance stdout + * only, so ticking one hides the other source. Source filters by the label + * each row is tagged with when it's fetched. */ export function filterCombinedLogs( entries: readonly LogEntry[], filters: LogFilters = {}, ): LogEntry[] { - const httpPinned = - (filters.method?.length ?? 0) > 0 || - (filters.response_code?.length ?? 0) > 0 || - CLIENT_HOST_FILTERS.some((key) => (filters[key]?.length ?? 0) > 0); - let result = [...entries]; - if (httpPinned) { + if (hasHttpFilter(filters)) { result = result.filter( (entry) => entry.labels[LOG_SOURCE_LABEL] === LOG_SOURCE_ALB, ); } result = filterAlbLogsByHost(result, filters); + const severities = filters.severity; + if (severities?.length) { + result = result.filter((entry) => + severities.includes(entry.labels.severity ?? ""), + ); + } const sources = filters[LOG_SOURCE_LABEL]; if (sources?.length) { result = result.filter((entry) => @@ -452,6 +501,80 @@ async function queryComputeRange(params: { return queryRange(params, toComputeLogEntries); } +/** Every logs key is `[PLUGIN_ID, "o11y-logs", kind, projectId, scope, ...]`. */ +const KEY_PROJECT_INDEX = 3; + +/** + * `keepPreviousData`, but only within one project and scope (load balancer, + * instance set or scope query). A filter, search or range change keeps the + * old rows and options on screen until the new ones arrive, while switching + * workload or instance never shows another one's logs. + */ +export function keepPreviousInScope(projectId: string | undefined, scope: string | undefined) { + return (previous: T | undefined, previousQuery?: Query) => { + const key = previousQuery?.queryKey; + return key?.[KEY_PROJECT_INDEX] === projectId && key?.[KEY_PROJECT_INDEX + 1] === scope + ? keepPreviousData(previous) + : undefined; + }; +} + +/** + * Facet options don't depend on exact timestamps, so a preset window keys by + * its name: a filter click re-resolves "last 30m" to now, and keying by the + * timestamps would refetch every facet on every click. + */ +export function facetWindowKey(timeRange: LogTimeRange, live: boolean): string { + if (live) return `live:${timeRange.preset ?? "last-30m"}`; + return timeRange.preset ? `preset:${timeRange.preset}` : `${timeRange.from}/${timeRange.to}`; +} + +function windowFor(timeRange: LogTimeRange, live: boolean): LogTimeRange { + if (live) return resolveLogTimeRange(timeRange.preset ? timeRange : lastThirtyMinutes()); + return timeRange.preset ? resolveLogTimeRange(timeRange) : timeRange; +} + +async function labelValues(params: { + projectId: string; + name: string; + query: string; + start: string; + end: string; +}): Promise { + const search = new URLSearchParams({ query: params.query, start: params.start, end: params.end }); + const path = O11Y_LOGS_LABEL_VALUES_PATH.replace("{name}", encodeURIComponent(params.name)); + const res = await fetch(`${getProjectScopedBase(params.projectId)}${path}?${search.toString()}`, { + headers: { Accept: "application/json" }, + }); + if (!res.ok) throw new ApiError(res.status, `Label values failed (${res.status})`); + const body = (await res.json()) as { status?: string; data?: string[]; error?: string }; + if (body.status === "error") throw new ApiError(400, body.error || "Label values failed"); + return body.data ?? []; +} + +async function countByLabels(params: { + projectId: string; + query: string; + time: string; +}): Promise { + const search = new URLSearchParams({ query: params.query, time: params.time }); + const res = await fetch(`${getProjectScopedBase(params.projectId)}${O11Y_LOGS_QUERY_PATH}?${search.toString()}`, { + headers: { Accept: "application/json" }, + }); + if (!res.ok) throw new ApiError(res.status, `Log counts failed (${res.status})`); + const body = (await res.json()) as { + status?: string; + data?: { result?: Array<{ metric?: Record; value?: [number, string] }> }; + error?: string; + }; + if (body.status === "error") throw new ApiError(400, body.error || "Log counts failed"); + return (body.data?.result ?? []).flatMap((sample) => { + const count = Number(sample.value?.[1]); + if (!Number.isFinite(count) || count <= 0) return []; + return [{ labels: { ...sample.metric }, count }]; + }); +} + export interface UseAlbLogsOptions { timeRange: LogTimeRange; filters?: LogFilters; @@ -510,6 +633,7 @@ export function useAlbLogs( ); }, select: (entries) => filterEntries(entries, {}, search), + placeholderData: keepPreviousInScope(projectId, proxyId), refetchInterval: live ? ALB_LOGS_LIVE_POLL_MS : false, staleTime: live ? 0 : 15_000, retry: logsRetry, @@ -547,6 +671,7 @@ function useComputeLogsQuery( limit, }); }, + placeholderData: keepPreviousInScope(projectId, query), refetchInterval: live ? ALB_LOGS_LIVE_POLL_MS : false, staleTime: live ? 0 : 15_000, retry: logsRetry, @@ -567,7 +692,10 @@ function useComputeLogs( export interface UseInstanceLogsResult { data: LogEntry[]; + /** First load only: earlier rows stay on screen for later requests. */ isLoading: boolean; + /** A request is in flight, including while earlier rows are still showing. */ + isFetching: boolean; error: ApiError | null; } @@ -583,8 +711,9 @@ export function useWorkloadLogs( ): UseInstanceLogsResult { const { search, enabled = true, ...rest } = options; const computeQuery = buildComputeLogQLMany(instanceNames); - const albEnabled = enabled && !!proxyId; - const computeEnabled = enabled && !!computeQuery; + const wanted = logSourcesWanted(rest.filters); + const albEnabled = enabled && wanted.alb && !!proxyId; + const computeEnabled = enabled && wanted.instance && !!computeQuery; const alb = useAlbLogs(projectId, proxyId, { ...rest, search: undefined, enabled: albEnabled }); const compute = useComputeLogsQuery(projectId, computeQuery, { @@ -609,6 +738,8 @@ export function useWorkloadLogs( data, isLoading: (albEnabled && alb.isLoading) || (computeEnabled && compute.isLoading), + isFetching: + (albEnabled && alb.isFetching) || (computeEnabled && compute.isFetching), error, }; } @@ -629,8 +760,9 @@ export function useInstanceLogs( () => instanceUpstreamIPs({ internalIPs: upstreamIPs }), [upstreamIPs], ); - const albEnabled = enabled && !!proxyId && ips.length > 0; - const computeEnabled = enabled && !!instanceName; + const wanted = logSourcesWanted(rest.filters); + const albEnabled = enabled && wanted.alb && !!proxyId && ips.length > 0; + const computeEnabled = enabled && wanted.instance && !!instanceName; const alb = useAlbLogs(projectId, proxyId, { ...rest, @@ -664,7 +796,457 @@ export function useInstanceLogs( data, isLoading: (albEnabled && alb.isLoading) || (computeEnabled && compute.isLoading), + isFetching: + (albEnabled && alb.isFetching) || (computeEnabled && compute.isFetching), // Prefer stdout when ALB fails; only surface an error when nothing loaded. error, }; } + +/** Labels the Host facet merges, in the order `albHostValues` reads them. */ + +const SEVERITY_ORDER = ["ERROR", "FATAL", "WARN", "WARNING", "INFO", "DEBUG", "TRACE"]; + +function severityRank(value: string): number { + const index = SEVERITY_ORDER.indexOf(value.toUpperCase()); + return index === -1 ? SEVERITY_ORDER.length : index; +} + +function uniqueSorted(values: Iterable, compare?: (a: string, b: string) => number): string[] { + const seen = new Set(); + for (const raw of values) { + const value = raw.trim(); + // Envoy writes "-" for a header it didn't get; that's not a value. + if (value && value !== "-") seen.add(value); + } + return [...seen].sort(compare ?? ((a, b) => a.localeCompare(b, undefined, { numeric: true }))); +} + +interface FacetRequest { + name: string; + query: string; +} + +/** One group from a `sum by (...) (count_over_time(...))` vector. */ +export interface LogLabelCount { + labels: Record; + count: number; +} + +export interface WindowFacetsConfig { + /** ALB access logs are in scope (proxy attached, and for an instance, its upstream IPs known). */ + alb: boolean; + /** Instance stdout is in scope. */ + instances: boolean; + /** Offered as the Instance facet on the workload page; omit on an instance page. */ + instanceNames?: readonly string[]; +} + +/** Lines per option value, keyed by facet name. A facet left out shows no counts. */ +export type WindowFacetCounts = Partial>>; + +/** + * Builds the explorer's facets from `/label/{name}/values` for the whole + * window: Source, Severity, Method, Status code, Host and (workload) Instance. + * A facet in `counts` gives every option a count (0 when nothing matches the + * other filters). Exported for tests. + */ +export function computeFacetsFromLabelValues( + config: WindowFacetsConfig, + values: Readonly>, + counts: WindowFacetCounts = {}, +): LogFacet[] { + const facets: LogFacet[] = []; + const push = (name: string, label: string, options: readonly string[]) => { + const counted = counts[name]; + facets.push({ + name, + label, + options: options.map((value) => (counted ? { value, count: counted.get(value) ?? 0 } : { value })), + }); + }; + // A counted value can land between the two requests; list it rather than drop it. + const withCounted = (name: string, listed: Iterable) => [...listed, ...(counts[name]?.keys() ?? [])]; + + const sources = [ + ...(config.alb ? [LOG_SOURCE_ALB] : []), + ...(config.instances ? [LOG_SOURCE_INSTANCE] : []), + ]; + if (sources.length > 0) push(LOG_SOURCE_LABEL, "Source", sources); + const severity = uniqueSorted( + withCounted("severity", values.severity ?? []), + (a, b) => severityRank(a) - severityRank(b) || a.localeCompare(b), + ); + if (severity.length > 0) push("severity", "Severity", severity); + for (const name of ["method", "response_code"] as const) { + const options = uniqueSorted(withCounted(name, values[name] ?? [])); + if (options.length > 0) push(name, ALB_LOG_FACET_LABELS[name], options); + } + // With counts, Host's resolved hosts are exact, so list just those and + // `host` itself; without, SNI fills in for rows with no Host header. + const hostLabels = counts.host ? ["host"] : ALB_HOST_OPTION_LABELS; + const hosts = uniqueSorted(withCounted("host", hostLabels.flatMap((label) => values[label] ?? []))); + if (hosts.length > 0) push("host", ALB_LOG_FACET_LABELS.host, hosts); + const instances = uniqueSorted(config.instanceNames ?? []); + if (instances.length > 0) push(COMPUTE_LOG_INSTANCE_LABEL, "Instance", instances); + return facets; +} + +/** + * The count queries behind the facet counts. Each counts lines in the window + * per value of its facet, applying every other ticked facet but not its own, + * so ticking GET still shows how many POSTs there are. Fixed slots so each + * can be its own useQuery. + */ +export const WINDOW_COUNT_SLOTS = [ + "albMethod", + "albStatus", + "albHost", + "stdoutSeverity", + "stdoutInstance", + "stdoutTotal", +] as const; +export type WindowCountSlot = (typeof WINDOW_COUNT_SLOTS)[number]; + +export interface WindowCountScope { + /** Set when ALB access logs are in scope. */ + proxyId?: string; + upstreamIPs?: readonly string[]; + /** Instances whose stdout is in scope. */ + instanceNames: readonly string[]; + /** The Instance facet is shown (workload page). */ + instanceFacet?: boolean; +} + +const active = (filters: LogFilters, name: string) => (filters[name]?.length ?? 0) > 0; + +/** + * Which rows can show under `filters`, mirroring `filterCombinedLogs` and the + * workload Instance filter: HTTP facets match ALB rows only, Severity and + * Instance match stdout only, and Source picks one. + */ +function windowRowState(scope: WindowCountScope, filters: LogFilters) { + const sources = filters[LOG_SOURCE_LABEL] ?? []; + const sourceAllows = (source: string) => sources.length === 0 || sources.includes(source); + const hostFiltered = CLIENT_HOST_FILTERS.some((key) => active(filters, key)); + const httpFiltered = hostFiltered || active(filters, "method") || active(filters, "response_code"); + const stdoutFiltered = active(filters, "severity") || active(filters, COMPUTE_LOG_INSTANCE_LABEL); + const alb = !!scope.proxyId; + const stdout = scope.instanceNames.length > 0; + // `…Rows`: rows could show, Source aside. `…Shown`: they do. + const albRows = alb && !stdoutFiltered; + const stdoutRows = stdout && !httpFiltered; + return { + hostFiltered, + alb, + stdout, + albRows, + stdoutRows, + albShown: albRows && sourceAllows(LOG_SOURCE_ALB), + stdoutShown: stdoutRows && sourceAllows(LOG_SOURCE_INSTANCE), + }; +} + +function withLabelMatcher(selector: string, label: string, values: readonly string[] | undefined): string { + if (!values?.length) return selector; + const matcher = + values.length === 1 + ? `${label}="${escapeLogQLQuoted(values[0])}"` + : `${label}=~"${values.map(escapeLogQLRegexp).join("|")}"`; + return `${selector.slice(0, -1)}, ${matcher}}`; +} + +function countQuery(by: readonly string[], selector: string, seconds: number): string { + const range = `[${Math.max(1, Math.ceil(seconds))}s]`; + const inner = `count_over_time(${selector} ${range})`; + return by.length > 0 ? `sum by (${by.join(", ")}) (${inner})` : `sum(${inner})`; +} + +/** + * The query for each count slot, or null when it isn't needed: the server + * can't count it like the table (search is applied in the browser, and so is + * Host), or no row it counts could show. Exported for tests. + */ +export function windowCountQueries( + scope: WindowCountScope, + filters: LogFilters, + search: string, + seconds: number, +): Record { + const none: Record = { + albMethod: null, + albStatus: null, + albHost: null, + stdoutSeverity: null, + stdoutInstance: null, + stdoutTotal: null, + }; + if (search.trim()) return none; + const state = windowRowState(scope, filters); + const albSelector = (without?: string) => { + const rest = { ...filters }; + if (without) delete rest[without]; + return buildAlbLogQL(scope.proxyId as string, rest, scope.upstreamIPs); + }; + const picked = filters[COMPUTE_LOG_INSTANCE_LABEL] ?? []; + const stdoutSelector = (instances: readonly string[]) => + buildComputeLogQLMany(instances.length > 0 ? instances : scope.instanceNames); + const severities = filters.severity; + + return { + albMethod: + state.albShown && !state.hostFiltered ? countQuery(["method"], albSelector("method"), seconds) : null, + albStatus: + state.albShown && !state.hostFiltered + ? countQuery(["response_code"], albSelector("response_code"), seconds) + : null, + // Also gives the Source facet its ALB total, so it runs whatever Source says. + albHost: state.albRows ? countQuery(ALB_HOST_SOURCE_LABELS, albSelector(), seconds) : null, + stdoutSeverity: state.stdoutShown ? countQuery(["severity"], stdoutSelector(picked), seconds) : null, + stdoutInstance: + state.stdoutShown && scope.instanceFacet + ? countQuery( + [COMPUTE_LOG_INSTANCE_LABEL], + withLabelMatcher(stdoutSelector([]), "severity", severities), + seconds, + ) + : null, + stdoutTotal: + state.stdoutRows && !state.hostFiltered + ? countQuery([], withLabelMatcher(stdoutSelector(picked), "severity", severities), seconds) + : null, + }; +} + +function groupCounts(groups: readonly LogLabelCount[], valuesOf: (labels: Record) => string[]) { + const counts = new Map(); + for (const { labels, count } of groups) { + for (const raw of valuesOf(labels)) { + const value = raw.trim(); + if (!value || value === "-") continue; + counts.set(value, (counts.get(value) ?? 0) + count); + } + } + return counts; +} + +const total = (groups: readonly LogLabelCount[]) => groups.reduce((sum, group) => sum + group.count, 0); + +/** + * Facet counts from the count slots' results. A slot missing from `results` + * failed, and the facets it feeds show no counts; a facet whose rows can't + * show under the other filters gets zeros. Exported for tests. + */ +export function windowFacetCounts( + scope: WindowCountScope, + filters: LogFilters, + search: string, + results: Partial>, +): WindowFacetCounts { + if (search.trim()) return {}; + const state = windowRowState(scope, filters); + const counts: Record> = {}; + const set = (name: string, shown: boolean, groups: readonly LogLabelCount[] | undefined, valuesOf: (labels: Record) => string[]) => { + if (!shown) counts[name] = new Map(); + else if (groups) counts[name] = groupCounts(groups, valuesOf); + }; + + if (state.alb) { + if (!state.hostFiltered) { + set("method", state.albShown, results.albMethod, (labels) => [labels.method ?? ""]); + set("response_code", state.albShown, results.albStatus, (labels) => [labels.response_code ?? ""]); + } + // Each group counts under the host its rows show, as the Host filter matches. + set("host", state.albShown, results.albHost, albHostValues); + } + if (state.stdout) { + set("severity", state.stdoutShown, results.stdoutSeverity, (labels) => [labels.severity ?? ""]); + if (scope.instanceFacet) { + set(COMPUTE_LOG_INSTANCE_LABEL, state.stdoutShown, results.stdoutInstance, (labels) => [ + labels[COMPUTE_LOG_INSTANCE_LABEL] ?? "", + ]); + } + } + if (!state.hostFiltered) { + const source = new Map(); + let complete = true; + if (state.alb) { + if (!state.albRows) source.set(LOG_SOURCE_ALB, 0); + else if (results.albHost) source.set(LOG_SOURCE_ALB, total(results.albHost)); + else complete = false; + } + if (state.stdout) { + if (!state.stdoutRows) source.set(LOG_SOURCE_INSTANCE, 0); + else if (results.stdoutTotal) source.set(LOG_SOURCE_INSTANCE, total(results.stdoutTotal)); + else complete = false; + } + if (complete && source.size > 0) counts[LOG_SOURCE_LABEL] = source; + } + return counts; +} + +/** + * Facets for the whole window, independent of what is ticked: label values + * scoped to the load balancer (and instance upstreams) for the HTTP facets, + * and to the instances' stdout for severity, so ticking a value never removes + * the other options. Counts come from `windowCountQueries`. + * + * Returns `null` until the options and counts have loaded, or if an options + * request failed, so the caller can fall back to facets built from the + * fetched rows. A failed count only leaves its facets without counts. + */ +export function useWindowLogFacets( + projectId: string | undefined, + options: { + proxyId?: string; + upstreamIPs?: readonly string[]; + instanceNames: readonly string[]; + /** Offer an Instance facet listing `instanceNames` (workload page). */ + instanceFacet?: boolean; + filters?: LogFilters; + search?: string; + timeRange: LogTimeRange; + live?: boolean; + enabled?: boolean; + }, +): LogFacet[] | null { + const { + proxyId, + upstreamIPs, + instanceNames, + instanceFacet = false, + filters = EMPTY_FILTERS, + search = "", + timeRange, + live = false, + enabled = true, + } = options; + const albQuery = proxyId ? buildAlbLogQL(proxyId, undefined, upstreamIPs) : ""; + const computeQuery = buildComputeLogQLMany(instanceNames); + const windowKey = facetWindowKey(timeRange, live); + const isEnabled = enabled && !!projectId; + + const requests = useMemo( + () => [ + ...(albQuery + ? ["method", "response_code", ...ALB_HOST_OPTION_LABELS].map((name) => ({ name, query: albQuery })) + : []), + ...(computeQuery ? [{ name: "severity", query: computeQuery }] : []), + ], + [albQuery, computeQuery], + ); + + const combine = useCallback( + (results: ReadonlyArray<{ isSuccess: boolean; data?: string[] }>): Record | null => { + if (!results.every((result) => result.isSuccess)) return null; + const values: Record = {}; + requests.forEach((request, index) => { + values[request.name] = [...(values[request.name] ?? []), ...(results[index]?.data ?? [])]; + }); + return values; + }, + [requests], + ); + + const values = useQueries({ + queries: requests.map((request) => ({ + queryKey: [PLUGIN_ID, "o11y-logs", "label-values", projectId, request.query, request.name, windowKey], + queryFn: () => { + const range = windowFor(timeRange, live); + return labelValues({ + projectId: projectId as string, + name: request.name, + query: request.query, + start: range.from, + end: range.to, + }); + }, + enabled: isEnabled, + // Options change slowly; live mode refreshes them less often than rows. + refetchInterval: live ? ALB_LOGS_LIVE_POLL_MS * 6 : (false as const), + staleTime: 60_000, + retry: logsRetry, + })), + combine, + }); + + const scope = useMemo( + () => ({ proxyId: albQuery ? proxyId : undefined, upstreamIPs, instanceNames, instanceFacet }), + // Arrays may be rebuilt each render; their contents are what matter. + // eslint-disable-next-line react-hooks/exhaustive-deps + [albQuery, computeQuery, instanceFacet], + ); + const window = windowFor(timeRange, live); + const seconds = (Date.parse(window.to) - Date.parse(window.from)) / 1000; + const queries = windowCountQueries(scope, filters, search, seconds); + const countScope = `${albQuery}|${computeQuery}`; + const countOptions = { projectId, countScope, windowKey, timeRange, live, enabled: isEnabled }; + + // One useQuery per slot rather than more useQueries entries: a filter click + // changes these keys, and only a useQuery keeps its previous counts on + // screen while the new ones load (useQueries starts a fresh observer). + const albMethod = useCountSlot(queries.albMethod, countOptions); + const albStatus = useCountSlot(queries.albStatus, countOptions); + const albHost = useCountSlot(queries.albHost, countOptions); + const stdoutSeverity = useCountSlot(queries.stdoutSeverity, countOptions); + const stdoutInstance = useCountSlot(queries.stdoutInstance, countOptions); + const stdoutTotal = useCountSlot(queries.stdoutTotal, countOptions); + + return useMemo(() => { + if (!values) return null; + const slots = { albMethod, albStatus, albHost, stdoutSeverity, stdoutInstance, stdoutTotal }; + const results: Partial> = {}; + for (const slot of WINDOW_COUNT_SLOTS) { + const count = slots[slot]; + if (!count.needed) continue; + if (count.data) results[slot] = count.data; + // First load: wait for counts rather than show options that gain + // numbers a moment later. A failed count just leaves them off. + else if (!count.isError) return null; + } + return computeFacetsFromLabelValues( + { + alb: !!scope.proxyId, + instances: scope.instanceNames.length > 0, + instanceNames: instanceFacet ? scope.instanceNames : undefined, + }, + values, + windowFacetCounts(scope, filters, search, results), + ); + }, [values, albMethod, albStatus, albHost, stdoutSeverity, stdoutInstance, stdoutTotal, scope, instanceFacet, filters, search]); +} + +const EMPTY_FILTERS: LogFilters = {}; + +function useCountSlot( + query: string | null, + options: { + projectId: string | undefined; + countScope: string; + windowKey: string; + timeRange: LogTimeRange; + live: boolean; + enabled: boolean; + }, +) { + const { projectId, countScope, windowKey, timeRange, live, enabled } = options; + const result = useQuery({ + queryKey: [PLUGIN_ID, "o11y-logs", "label-counts", projectId, countScope, query, windowKey], + queryFn: () => + countByLabels({ + projectId: projectId as string, + query: query as string, + time: windowFor(timeRange, live).to, + }), + placeholderData: keepPreviousInScope(projectId, countScope), + enabled: enabled && !!query, + refetchInterval: live ? ALB_LOGS_LIVE_POLL_MS * 6 : false, + staleTime: 30_000, + retry: logsRetry, + }); + const needed = !!query; + return useMemo( + () => ({ needed, data: needed ? result.data : undefined, isError: result.isError }), + [needed, result.data, result.isError], + ); +}