+
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],
+ );
+}