From 71d8d37f2053c8ea47fb3c19db494a1b586a3819 Mon Sep 17 00:00:00 2001 From: remote service administrator Date: Thu, 30 Jul 2026 21:42:33 -0400 Subject: [PATCH 1/3] fix(local-ui): scope log service facets to logs --- .../src/hooks/use-local-log-services.test.ts | 15 ++++++++ .../src/hooks/use-local-log-services.ts | 34 +++++++++++++++++++ apps/local-ui/src/hooks/use-local-services.ts | 30 ---------------- apps/local-ui/src/views/logs-view.tsx | 4 +-- 4 files changed, 51 insertions(+), 32 deletions(-) create mode 100644 apps/local-ui/src/hooks/use-local-log-services.test.ts create mode 100644 apps/local-ui/src/hooks/use-local-log-services.ts delete mode 100644 apps/local-ui/src/hooks/use-local-services.ts diff --git a/apps/local-ui/src/hooks/use-local-log-services.test.ts b/apps/local-ui/src/hooks/use-local-log-services.test.ts new file mode 100644 index 000000000..eb8cbd726 --- /dev/null +++ b/apps/local-ui/src/hooks/use-local-log-services.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest" +import { compileLocalLogServicesQuery } from "./use-local-log-services" + +describe("compileLocalLogServicesQuery", () => { + it("builds the Logs service filter from log-producing services", () => { + const { sql } = compileLocalLogServicesQuery("2026-07-30 00:00:00", "2026-07-30 23:59:59") + + expect(sql).toContain("FROM logs_aggregates_hourly") + expect(sql).toContain("ServiceName AS serviceName") + expect(sql).toContain("GROUP BY serviceName") + expect(sql).toContain("'service' AS facetType") + expect(sql).not.toContain("UNION ALL") + expect(sql).not.toContain("service_overview") + }) +}) diff --git a/apps/local-ui/src/hooks/use-local-log-services.ts b/apps/local-ui/src/hooks/use-local-log-services.ts new file mode 100644 index 000000000..5b77f99c1 --- /dev/null +++ b/apps/local-ui/src/hooks/use-local-log-services.ts @@ -0,0 +1,34 @@ +import { useQuery } from "@tanstack/react-query" +import { CH } from "@maple/query-engine" +import { executeLocalCompiledQuery } from "@/lib/query" +import { LOCAL_ORG_ID } from "../lib/constants" +import { boundsForRange } from "../lib/time" +import type { FilterOption } from "@maple/ui/components/filters/filter-section" + +/** + * Distinct services that emitted logs in the selected window. This deliberately + * uses the logs-specific facet instead of the trace-derived services facet so a + * logs-only service remains selectable on the Logs tab. + */ +export function compileLocalLogServicesQuery(startTime: string, endTime: string) { + return CH.compileUnion(CH.logsFacetsQuery({}, "service"), { + orgId: LOCAL_ORG_ID, + startTime, + endTime, + }) +} + +export function useLocalLogServices(range: string | undefined) { + return useQuery>({ + queryKey: ["local", "logs", "services", range], + staleTime: 60_000, + queryFn: async () => { + const { startTime, endTime } = boundsForRange(range) + const compiled = compileLocalLogServicesQuery(startTime, endTime) + const rows = await executeLocalCompiledQuery(compiled) + return rows + .filter((row) => row.facetType === "service" && row.serviceName) + .map((row) => ({ name: row.serviceName, count: Number(row.count) })) + }, + }) +} diff --git a/apps/local-ui/src/hooks/use-local-services.ts b/apps/local-ui/src/hooks/use-local-services.ts deleted file mode 100644 index d9d130762..000000000 --- a/apps/local-ui/src/hooks/use-local-services.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useQuery } from "@tanstack/react-query" -import { CH } from "@maple/query-engine" -import { executeLocalCompiledQuery } from "@/lib/query" -import { LOCAL_ORG_ID } from "../lib/constants" -import { boundsForRange } from "../lib/time" -import type { FilterOption } from "@maple/ui/components/filters/filter-section" - -/** - * Distinct service names in the window, shaped as filter options. Drives the - * "Service" facet on the Traces and Logs sidebars. Backed by the - * service_overview MV scan, so it's cheap to refresh alongside the list. - */ -export function useLocalServices(range: string | undefined) { - return useQuery>({ - queryKey: ["local", "services", range], - staleTime: 60_000, - queryFn: async () => { - const { startTime, endTime } = boundsForRange(range) - const compiled = CH.compileUnion(CH.servicesFacetsQuery(), { - orgId: LOCAL_ORG_ID, - startTime, - endTime, - }) - const rows = await executeLocalCompiledQuery(compiled) - return rows - .filter((row) => row.facetType === "service" && row.name) - .map((row) => ({ name: row.name, count: row.count })) - }, - }) -} diff --git a/apps/local-ui/src/views/logs-view.tsx b/apps/local-ui/src/views/logs-view.tsx index 55becd46b..96387bcc6 100644 --- a/apps/local-ui/src/views/logs-view.tsx +++ b/apps/local-ui/src/views/logs-view.tsx @@ -6,7 +6,7 @@ import { Spinner } from "@maple/ui/components/ui/spinner" import { pickImportantAttributes } from "@maple/ui/lib/log-attributes" import { getSeverityColor } from "@maple/ui/lib/severity" import { useLocalLogs, useLocalLogSeverities } from "../hooks/use-local-logs" -import { useLocalServices } from "../hooks/use-local-services" +import { useLocalLogServices } from "../hooks/use-local-log-services" import { useQueryParams } from "../lib/router" import { DEFAULT_RANGE } from "../lib/time" import { normalizeLog, type LocalLog } from "../lib/log-shape" @@ -38,7 +38,7 @@ export function LogsView() { const severity = query.get("severity") || undefined const search = query.get("q") || undefined - const services = useLocalServices(range) + const services = useLocalLogServices(range) const severities = useLocalLogSeverities(range) const { data, isPending, isError, error, refetch, fetchNextPage, hasNextPage, isFetchingNextPage } = useLocalLogs({ service, severity, search, range }) From a454214a432cccef05cf4bef068eb0cde83963a8 Mon Sep 17 00:00:00 2001 From: remote service administrator Date: Fri, 31 Jul 2026 14:15:45 -0400 Subject: [PATCH 2/3] fix(local-ui): align log service facets with raw data --- .../src/hooks/use-local-log-services.test.ts | 23 ++++++++++++++----- .../src/hooks/use-local-log-services.ts | 11 ++++----- .../query-engine/src/ch/queries/logs.test.ts | 14 +++++++++++ packages/query-engine/src/ch/queries/logs.ts | 4 +++- 4 files changed, 39 insertions(+), 13 deletions(-) diff --git a/apps/local-ui/src/hooks/use-local-log-services.test.ts b/apps/local-ui/src/hooks/use-local-log-services.test.ts index eb8cbd726..39ce086b8 100644 --- a/apps/local-ui/src/hooks/use-local-log-services.test.ts +++ b/apps/local-ui/src/hooks/use-local-log-services.test.ts @@ -3,13 +3,24 @@ import { compileLocalLogServicesQuery } from "./use-local-log-services" describe("compileLocalLogServicesQuery", () => { it("builds the Logs service filter from log-producing services", () => { - const { sql } = compileLocalLogServicesQuery("2026-07-30 00:00:00", "2026-07-30 23:59:59") + const { sql } = compileLocalLogServicesQuery("2026-07-30 13:05:00", "2026-07-30 14:05:00") - expect(sql).toContain("FROM logs_aggregates_hourly") - expect(sql).toContain("ServiceName AS serviceName") - expect(sql).toContain("GROUP BY serviceName") - expect(sql).toContain("'service' AS facetType") + expect(sql).toContain("FROM logs") + expect(sql).toContain("ServiceName AS name") + expect(sql).toContain("TimestampTime >= '2026-07-30 13:05:00'") + expect(sql).toContain("TimestampTime <= '2026-07-30 14:05:00'") + expect(sql).toContain("Timestamp >= '2026-07-30 13:05:00'") + expect(sql).toContain("Timestamp <= '2026-07-30 14:05:00'") + expect(sql).toContain("GROUP BY name") + expect(sql).toContain("LIMIT 500") expect(sql).not.toContain("UNION ALL") - expect(sql).not.toContain("service_overview") + expect(sql).not.toContain("logs_aggregates_hourly") + }) + + it("cannot offer a stale service that exists only in the hourly aggregate", () => { + const { sql } = compileLocalLogServicesQuery("2026-07-01 00:00:00", "2026-07-30 23:59:59") + + expect(sql).toContain("FROM logs") + expect(sql).not.toContain("logs_aggregates_hourly") }) }) diff --git a/apps/local-ui/src/hooks/use-local-log-services.ts b/apps/local-ui/src/hooks/use-local-log-services.ts index 5b77f99c1..2c46aefcd 100644 --- a/apps/local-ui/src/hooks/use-local-log-services.ts +++ b/apps/local-ui/src/hooks/use-local-log-services.ts @@ -7,11 +7,12 @@ import type { FilterOption } from "@maple/ui/components/filters/filter-section" /** * Distinct services that emitted logs in the selected window. This deliberately - * uses the logs-specific facet instead of the trace-derived services facet so a - * logs-only service remains selectable on the Logs tab. + * scans the same raw table and exact timestamp bounds as the rendered list, so + * neither partial hourly buckets nor longer-lived aggregates can add or hide a + * service option. */ export function compileLocalLogServicesQuery(startTime: string, endTime: string) { - return CH.compileUnion(CH.logsFacetsQuery({}, "service"), { + return CH.compile(CH.logsBreakdownQuery({ groupBy: "service", limit: 500, source: "raw" }), { orgId: LOCAL_ORG_ID, startTime, endTime, @@ -26,9 +27,7 @@ export function useLocalLogServices(range: string | undefined) { const { startTime, endTime } = boundsForRange(range) const compiled = compileLocalLogServicesQuery(startTime, endTime) const rows = await executeLocalCompiledQuery(compiled) - return rows - .filter((row) => row.facetType === "service" && row.serviceName) - .map((row) => ({ name: row.serviceName, count: Number(row.count) })) + return rows.filter((row) => row.name).map((row) => ({ name: row.name, count: Number(row.count) })) }, }) } diff --git a/packages/query-engine/src/ch/queries/logs.test.ts b/packages/query-engine/src/ch/queries/logs.test.ts index 462bda3f7..074a32636 100644 --- a/packages/query-engine/src/ch/queries/logs.test.ts +++ b/packages/query-engine/src/ch/queries/logs.test.ts @@ -293,6 +293,20 @@ describe("logsBreakdownQuery", () => { const { sql } = compileCH(q, baseParams) expect(sql).toContain("LIMIT 25") }) + + it("can force an exact raw scan for retention-aligned service membership", () => { + const { sql } = compileCH(logsBreakdownQuery({ groupBy: "service", limit: 500, source: "raw" }), { + ...baseParams, + startTime: "2024-01-01 13:05:00", + endTime: "2024-01-01 14:05:00", + }) + expect(sql).toContain("FROM logs") + expect(sql).not.toContain("logs_aggregates_hourly") + expect(sql).toContain("TimestampTime >= '2024-01-01 13:05:00'") + expect(sql).toContain("TimestampTime <= '2024-01-01 14:05:00'") + expect(sql).toContain("Timestamp >= '2024-01-01 13:05:00'") + expect(sql).toContain("Timestamp <= '2024-01-01 14:05:00'") + }) }) // --------------------------------------------------------------------------- diff --git a/packages/query-engine/src/ch/queries/logs.ts b/packages/query-engine/src/ch/queries/logs.ts index 3ca21b4b8..3a91046c5 100644 --- a/packages/query-engine/src/ch/queries/logs.ts +++ b/packages/query-engine/src/ch/queries/logs.ts @@ -346,6 +346,8 @@ function buildLogsGroupNameExpr( export interface LogsBreakdownOpts extends LogsQueryOpts { groupBy: "service" | "severity" limit?: number + /** Force an exact raw-log scan when aggregate retention is not semantically equivalent. */ + source?: "auto" | "raw" } export interface LogsBreakdownOutput { @@ -361,7 +363,7 @@ function logsBreakdownName( } export function logsBreakdownQuery(opts: LogsBreakdownOpts): CHQuery { - if (!canUseLogsAggregateInterior(opts)) { + if (opts.source === "raw" || !canUseLogsAggregateInterior(opts)) { const raw = from(Logs) .select(($) => ({ name: logsBreakdownName($, opts.groupBy), From 59211afa91d61f86585f40ff73b7b689b93fbde7 Mon Sep 17 00:00:00 2001 From: remote service administrator Date: Fri, 31 Jul 2026 14:59:48 -0400 Subject: [PATCH 3/3] fix(local-ui): synchronize log facet windows --- apps/local-ui/package.json | 2 ++ apps/local-ui/src/components/toolbar.tsx | 13 ++++++-- .../src/hooks/use-local-log-services.test.ts | 2 +- .../src/hooks/use-local-log-services.ts | 11 +++---- apps/local-ui/src/hooks/use-local-logs.ts | 19 ++++------- .../src/hooks/use-log-time-window.test.ts | 30 +++++++++++++++++ .../local-ui/src/hooks/use-log-time-window.ts | 11 +++++++ apps/local-ui/src/lib/time.test.ts | 24 ++++++++++++++ apps/local-ui/src/lib/time.ts | 7 ++-- apps/local-ui/src/views/logs-view.tsx | 32 +++++++++++++------ bun.lock | 2 ++ .../query-engine/src/ch/queries/logs.test.ts | 5 +-- packages/query-engine/src/ch/queries/logs.ts | 14 ++++---- 13 files changed, 128 insertions(+), 44 deletions(-) create mode 100644 apps/local-ui/src/hooks/use-log-time-window.test.ts create mode 100644 apps/local-ui/src/hooks/use-log-time-window.ts create mode 100644 apps/local-ui/src/lib/time.test.ts diff --git a/apps/local-ui/package.json b/apps/local-ui/package.json index 1c28ebcda..c7ce76b4a 100644 --- a/apps/local-ui/package.json +++ b/apps/local-ui/package.json @@ -23,9 +23,11 @@ }, "devDependencies": { "@tailwindcss/vite": "catalog:tailwind", + "@testing-library/react": "^16.3.2", "@types/react": "catalog:react", "@types/react-dom": "catalog:react", "@vitejs/plugin-react": "^6.0.2", + "jsdom": "^29.1.1", "tailwindcss": "catalog:tailwind", "tw-animate-css": "catalog:tailwind", "typescript": "catalog:tooling", diff --git a/apps/local-ui/src/components/toolbar.tsx b/apps/local-ui/src/components/toolbar.tsx index e756b7c29..8558ea2c9 100644 --- a/apps/local-ui/src/components/toolbar.tsx +++ b/apps/local-ui/src/components/toolbar.tsx @@ -17,9 +17,18 @@ export { Toolbar, ToolbarSearch, ToolbarStat, ToolbarStats } from "@maple/ui/com * so invalidating that prefix refetches exactly the mounted view's queries * (list + facets) — React Query only refetches active observers. */ -export function RefreshButton({ className }: { className?: string }) { +export function RefreshButton({ + className, + onBeforeRefresh, +}: { + className?: string + onBeforeRefresh?: () => void +}) { const queryClient = useQueryClient() - const onRefresh = useCallback(() => queryClient.invalidateQueries({ queryKey: ["local"] }), [queryClient]) + const onRefresh = useCallback(() => { + onBeforeRefresh?.() + return queryClient.invalidateQueries({ queryKey: ["local"] }) + }, [onBeforeRefresh, queryClient]) return } diff --git a/apps/local-ui/src/hooks/use-local-log-services.test.ts b/apps/local-ui/src/hooks/use-local-log-services.test.ts index 39ce086b8..da5eefd91 100644 --- a/apps/local-ui/src/hooks/use-local-log-services.test.ts +++ b/apps/local-ui/src/hooks/use-local-log-services.test.ts @@ -12,7 +12,7 @@ describe("compileLocalLogServicesQuery", () => { expect(sql).toContain("Timestamp >= '2026-07-30 13:05:00'") expect(sql).toContain("Timestamp <= '2026-07-30 14:05:00'") expect(sql).toContain("GROUP BY name") - expect(sql).toContain("LIMIT 500") + expect(sql).not.toContain("LIMIT") expect(sql).not.toContain("UNION ALL") expect(sql).not.toContain("logs_aggregates_hourly") }) diff --git a/apps/local-ui/src/hooks/use-local-log-services.ts b/apps/local-ui/src/hooks/use-local-log-services.ts index 2c46aefcd..23bf7589a 100644 --- a/apps/local-ui/src/hooks/use-local-log-services.ts +++ b/apps/local-ui/src/hooks/use-local-log-services.ts @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query" import { CH } from "@maple/query-engine" import { executeLocalCompiledQuery } from "@/lib/query" import { LOCAL_ORG_ID } from "../lib/constants" -import { boundsForRange } from "../lib/time" +import type { TimeBounds } from "../lib/time" import type { FilterOption } from "@maple/ui/components/filters/filter-section" /** @@ -12,20 +12,19 @@ import type { FilterOption } from "@maple/ui/components/filters/filter-section" * service option. */ export function compileLocalLogServicesQuery(startTime: string, endTime: string) { - return CH.compile(CH.logsBreakdownQuery({ groupBy: "service", limit: 500, source: "raw" }), { + return CH.compile(CH.logsBreakdownQuery({ groupBy: "service", limit: null, source: "raw" }), { orgId: LOCAL_ORG_ID, startTime, endTime, }) } -export function useLocalLogServices(range: string | undefined) { +export function useLocalLogServices(bounds: TimeBounds) { return useQuery>({ - queryKey: ["local", "logs", "services", range], + queryKey: ["local", "logs", "services", bounds.startTime, bounds.endTime], staleTime: 60_000, queryFn: async () => { - const { startTime, endTime } = boundsForRange(range) - const compiled = compileLocalLogServicesQuery(startTime, endTime) + const compiled = compileLocalLogServicesQuery(bounds.startTime, bounds.endTime) const rows = await executeLocalCompiledQuery(compiled) return rows.filter((row) => row.name).map((row) => ({ name: row.name, count: Number(row.count) })) }, diff --git a/apps/local-ui/src/hooks/use-local-logs.ts b/apps/local-ui/src/hooks/use-local-logs.ts index 30e017061..f15f7db52 100644 --- a/apps/local-ui/src/hooks/use-local-logs.ts +++ b/apps/local-ui/src/hooks/use-local-logs.ts @@ -2,7 +2,7 @@ import { useInfiniteQuery, useQuery } from "@tanstack/react-query" import { CH } from "@maple/query-engine" import { executeLocalCompiledQuery } from "@/lib/query" import { LOCAL_ORG_ID } from "../lib/constants" -import { boundsForRange } from "../lib/time" +import type { TimeBounds } from "../lib/time" import type { FilterOption } from "@maple/ui/components/filters/filter-section" const PAGE_SIZE = 50 @@ -14,20 +14,17 @@ export interface LogFilters { severity?: string /** Substring match on the log body. */ search?: string - /** Time-range preset key (see `TIME_RANGES`). */ - range?: string } /** * Infinite log stream, newest first. Keyset pagination on `Timestamp` — the * cursor is the last row's `timestamp`. */ -export function useLocalLogs(filters: LogFilters) { +export function useLocalLogs(filters: LogFilters, bounds: TimeBounds) { return useInfiniteQuery({ - queryKey: ["local", "logs", filters], + queryKey: ["local", "logs", filters, bounds.startTime, bounds.endTime], initialPageParam: undefined as string | undefined, queryFn: async ({ pageParam }) => { - const { startTime, endTime } = boundsForRange(filters.range) const compiled = CH.compile( CH.logsListQuery({ limit: PAGE_SIZE, @@ -36,7 +33,7 @@ export function useLocalLogs(filters: LogFilters) { severity: filters.severity, search: filters.search, }), - { orgId: LOCAL_ORG_ID, startTime, endTime }, + { orgId: LOCAL_ORG_ID, ...bounds }, ) return executeLocalCompiledQuery(compiled) }, @@ -49,16 +46,14 @@ export function useLocalLogs(filters: LogFilters) { * Distinct severity values in the window (with counts), for the severity facet. * Derived from the data so the option casing always matches what's stored. */ -export function useLocalLogSeverities(range: string | undefined) { +export function useLocalLogSeverities(bounds: TimeBounds) { return useQuery>({ - queryKey: ["local", "log-severities", range], + queryKey: ["local", "log-severities", bounds.startTime, bounds.endTime], staleTime: 60_000, queryFn: async () => { - const { startTime, endTime } = boundsForRange(range) const compiled = CH.compile(CH.logsBreakdownQuery({ groupBy: "severity", limit: 20 }), { orgId: LOCAL_ORG_ID, - startTime, - endTime, + ...bounds, }) const rows = await executeLocalCompiledQuery(compiled) return rows.filter((row) => row.name).map((row) => ({ name: row.name, count: row.count })) diff --git a/apps/local-ui/src/hooks/use-log-time-window.test.ts b/apps/local-ui/src/hooks/use-log-time-window.test.ts new file mode 100644 index 000000000..abc997143 --- /dev/null +++ b/apps/local-ui/src/hooks/use-log-time-window.test.ts @@ -0,0 +1,30 @@ +// @vitest-environment jsdom + +import { act, renderHook } from "@testing-library/react" +import { afterEach, describe, expect, it, vi } from "vitest" +import { useLogTimeWindow } from "./use-log-time-window" + +describe("useLogTimeWindow", () => { + afterEach(() => vi.useRealTimers()) + + it("advances one shared window when a log filter changes later", () => { + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-07-30T14:05:00Z")) + const { result, rerender } = renderHook( + ({ range, severity }) => ({ severity, ...useLogTimeWindow(range) }), + { initialProps: { range: "1h", severity: undefined as string | undefined } }, + ) + + expect(result.current.bounds.startTime).toBe("2026-07-30 13:05:00") + + vi.setSystemTime(new Date("2026-07-30T16:05:00Z")) + act(() => result.current.advance()) + rerender({ range: "1h", severity: "ERROR" }) + + expect(result.current.severity).toBe("ERROR") + expect(result.current.bounds).toEqual({ + startTime: "2026-07-30 15:05:00", + endTime: "2026-07-30 17:05:00", + }) + }) +}) diff --git a/apps/local-ui/src/hooks/use-log-time-window.ts b/apps/local-ui/src/hooks/use-log-time-window.ts new file mode 100644 index 000000000..22748e02d --- /dev/null +++ b/apps/local-ui/src/hooks/use-log-time-window.ts @@ -0,0 +1,11 @@ +import { useCallback, useMemo, useState } from "react" +import { boundsForRange } from "../lib/time" + +/** One page-owned time window shared by the log list and all of its facets. */ +export function useLogTimeWindow(range: string | undefined) { + const [anchorMs, setAnchorMs] = useState(() => Date.now()) + const bounds = useMemo(() => boundsForRange(range, anchorMs), [anchorMs, range]) + const advance = useCallback(() => setAnchorMs(Date.now()), []) + + return { bounds, advance } +} diff --git a/apps/local-ui/src/lib/time.test.ts b/apps/local-ui/src/lib/time.test.ts new file mode 100644 index 000000000..10bd3e0a2 --- /dev/null +++ b/apps/local-ui/src/lib/time.test.ts @@ -0,0 +1,24 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { boundsForRange } from "./time" + +describe("boundsForRange", () => { + afterEach(() => vi.useRealTimers()) + + it("can advance every consumer from one explicit page anchor", () => { + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-07-30T14:05:00Z")) + const initial = boundsForRange("1h", Date.now()) + + vi.setSystemTime(new Date("2026-07-30T16:05:00Z")) + const advanced = boundsForRange("1h", Date.now()) + + expect(initial).toEqual({ + startTime: "2026-07-30 13:05:00", + endTime: "2026-07-30 15:05:00", + }) + expect(advanced).toEqual({ + startTime: "2026-07-30 15:05:00", + endTime: "2026-07-30 17:05:00", + }) + }) +}) diff --git a/apps/local-ui/src/lib/time.ts b/apps/local-ui/src/lib/time.ts index 8f25f922f..819967d81 100644 --- a/apps/local-ui/src/lib/time.ts +++ b/apps/local-ui/src/lib/time.ts @@ -36,12 +36,11 @@ export const TIME_RANGES: ReadonlyArray = [ export const DEFAULT_RANGE = "30d" /** Resolve a range key to ClickHouse DateTime bounds, padding the upper bound for clock skew. */ -export function boundsForRange(key: string | undefined): TimeBounds { +export function boundsForRange(key: string | undefined, anchorMs = Date.now()): TimeBounds { const range = TIME_RANGES.find((r) => r.key === key) ?? TIME_RANGES[TIME_RANGES.length - 1] - const now = Date.now() return { - startTime: toClickHouseDateTime(now - range.minutes * 60 * 1000), - endTime: toClickHouseDateTime(now + 60 * 60 * 1000), + startTime: toClickHouseDateTime(anchorMs - range.minutes * 60 * 1000), + endTime: toClickHouseDateTime(anchorMs + 60 * 60 * 1000), } } diff --git a/apps/local-ui/src/views/logs-view.tsx b/apps/local-ui/src/views/logs-view.tsx index 96387bcc6..30209e923 100644 --- a/apps/local-ui/src/views/logs-view.tsx +++ b/apps/local-ui/src/views/logs-view.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useVirtualizer } from "@tanstack/react-virtual" import { LogAttributeChip } from "@maple/ui/components/logs/log-attribute-chip" import { CodeIcon } from "@maple/ui/components/icons" @@ -7,6 +7,7 @@ import { pickImportantAttributes } from "@maple/ui/lib/log-attributes" import { getSeverityColor } from "@maple/ui/lib/severity" import { useLocalLogs, useLocalLogSeverities } from "../hooks/use-local-logs" import { useLocalLogServices } from "../hooks/use-local-log-services" +import { useLogTimeWindow } from "../hooks/use-log-time-window" import { useQueryParams } from "../lib/router" import { DEFAULT_RANGE } from "../lib/time" import { normalizeLog, type LocalLog } from "../lib/log-shape" @@ -37,11 +38,19 @@ export function LogsView() { const service = query.get("service") || undefined const severity = query.get("severity") || undefined const search = query.get("q") || undefined + const timeWindow = useLogTimeWindow(range) + const updateParamsWithFreshTimeWindow = useCallback( + (updates: Record) => { + timeWindow.advance() + setParams(updates) + }, + [setParams, timeWindow.advance], + ) - const services = useLocalLogServices(range) - const severities = useLocalLogSeverities(range) + const services = useLocalLogServices(timeWindow.bounds) + const severities = useLocalLogSeverities(timeWindow.bounds) const { data, isPending, isError, error, refetch, fetchNextPage, hasNextPage, isFetchingNextPage } = - useLocalLogs({ service, severity, search, range }) + useLocalLogs({ service, severity, search }, timeWindow.bounds) const rows = useMemo>(() => (data?.pages.flat() ?? []).map(normalizeLog), [data]) const scrollRef = useRef(null) @@ -79,20 +88,20 @@ export function LogsView() { > setParams({ service: null, severity: null })} + onClear={() => updateParamsWithFreshTimeWindow({ service: null, severity: null })} /> ({ name: o.name, count: o.count }))} selected={severity ? [severity] : []} - onChange={(vals) => setParams({ severity: vals.at(-1) ?? null })} + onChange={(vals) => updateParamsWithFreshTimeWindow({ severity: vals.at(-1) ?? null })} /> ({ name: o.name, count: o.count }))} selected={service ? [service] : []} - onChange={(vals) => setParams({ service: vals.at(-1) ?? null })} + onChange={(vals) => updateParamsWithFreshTimeWindow({ service: vals.at(-1) ?? null })} /> @@ -102,13 +111,16 @@ export function LogsView() { setParams({ q: value ?? null })} + onSearch={(value) => updateParamsWithFreshTimeWindow({ q: value ?? null })} placeholder="Search log bodies…" /> - - setParams({ range: next })} /> + + updateParamsWithFreshTimeWindow({ range: next })} + /> ) diff --git a/bun.lock b/bun.lock index 1ff7de397..ce15abccf 100644 --- a/bun.lock +++ b/bun.lock @@ -200,9 +200,11 @@ }, "devDependencies": { "@tailwindcss/vite": "catalog:tailwind", + "@testing-library/react": "^16.3.2", "@types/react": "catalog:react", "@types/react-dom": "catalog:react", "@vitejs/plugin-react": "^6.0.2", + "jsdom": "^29.1.1", "tailwindcss": "catalog:tailwind", "tw-animate-css": "catalog:tailwind", "typescript": "catalog:tooling", diff --git a/packages/query-engine/src/ch/queries/logs.test.ts b/packages/query-engine/src/ch/queries/logs.test.ts index 074a32636..6e1c6aa88 100644 --- a/packages/query-engine/src/ch/queries/logs.test.ts +++ b/packages/query-engine/src/ch/queries/logs.test.ts @@ -294,8 +294,8 @@ describe("logsBreakdownQuery", () => { expect(sql).toContain("LIMIT 25") }) - it("can force an exact raw scan for retention-aligned service membership", () => { - const { sql } = compileCH(logsBreakdownQuery({ groupBy: "service", limit: 500, source: "raw" }), { + it("can force an unbounded exact raw scan for retention-aligned service membership", () => { + const { sql } = compileCH(logsBreakdownQuery({ groupBy: "service", limit: null, source: "raw" }), { ...baseParams, startTime: "2024-01-01 13:05:00", endTime: "2024-01-01 14:05:00", @@ -306,6 +306,7 @@ describe("logsBreakdownQuery", () => { expect(sql).toContain("TimestampTime <= '2024-01-01 14:05:00'") expect(sql).toContain("Timestamp >= '2024-01-01 13:05:00'") expect(sql).toContain("Timestamp <= '2024-01-01 14:05:00'") + expect(sql).not.toContain("LIMIT") }) }) diff --git a/packages/query-engine/src/ch/queries/logs.ts b/packages/query-engine/src/ch/queries/logs.ts index 3a91046c5..aba6ef1e0 100644 --- a/packages/query-engine/src/ch/queries/logs.ts +++ b/packages/query-engine/src/ch/queries/logs.ts @@ -345,7 +345,8 @@ function buildLogsGroupNameExpr( export interface LogsBreakdownOpts extends LogsQueryOpts { groupBy: "service" | "severity" - limit?: number + /** Maximum groups to return. Pass `null` when complete membership is required. */ + limit?: number | null /** Force an exact raw-log scan when aggregate retention is not semantically equivalent. */ source?: "auto" | "raw" } @@ -384,9 +385,8 @@ export function logsBreakdownQuery(opts: LogsBreakdownOpts): CHQuery + const result = opts.limit === null ? raw.format("JSON") : raw.limit(opts.limit ?? 10).format("JSON") + return result as unknown as CHQuery } const rawEdges = from(Logs) @@ -433,9 +433,9 @@ export function logsBreakdownQuery(opts: LogsBreakdownOpts): CHQuery + const result = + opts.limit === null ? combined.format("JSON") : combined.limit(opts.limit ?? 10).format("JSON") + return result as unknown as CHQuery } // ---------------------------------------------------------------------------