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 new file mode 100644 index 000000000..da5eefd91 --- /dev/null +++ b/apps/local-ui/src/hooks/use-local-log-services.test.ts @@ -0,0 +1,26 @@ +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 13:05:00", "2026-07-30 14:05:00") + + 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).not.toContain("LIMIT") + expect(sql).not.toContain("UNION ALL") + 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 new file mode 100644 index 000000000..23bf7589a --- /dev/null +++ b/apps/local-ui/src/hooks/use-local-log-services.ts @@ -0,0 +1,32 @@ +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 type { TimeBounds } from "../lib/time" +import type { FilterOption } from "@maple/ui/components/filters/filter-section" + +/** + * Distinct services that emitted logs in the selected window. This deliberately + * 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.compile(CH.logsBreakdownQuery({ groupBy: "service", limit: null, source: "raw" }), { + orgId: LOCAL_ORG_ID, + startTime, + endTime, + }) +} + +export function useLocalLogServices(bounds: TimeBounds) { + return useQuery>({ + queryKey: ["local", "logs", "services", bounds.startTime, bounds.endTime], + staleTime: 60_000, + queryFn: async () => { + 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-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/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 55becd46b..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" @@ -6,7 +6,8 @@ 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 { 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 = useLocalServices(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 462bda3f7..6e1c6aa88 100644 --- a/packages/query-engine/src/ch/queries/logs.test.ts +++ b/packages/query-engine/src/ch/queries/logs.test.ts @@ -293,6 +293,21 @@ describe("logsBreakdownQuery", () => { const { sql } = compileCH(q, baseParams) expect(sql).toContain("LIMIT 25") }) + + 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", + }) + 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'") + 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 3ca21b4b8..aba6ef1e0 100644 --- a/packages/query-engine/src/ch/queries/logs.ts +++ b/packages/query-engine/src/ch/queries/logs.ts @@ -345,7 +345,10 @@ 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" } export interface LogsBreakdownOutput { @@ -361,7 +364,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), @@ -382,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) @@ -431,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 } // ---------------------------------------------------------------------------