Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/local-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 11 additions & 2 deletions apps/local-ui/src/components/toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <SharedRefreshButton onRefresh={onRefresh} className={className} />
}

Expand Down
26 changes: 26 additions & 0 deletions apps/local-ui/src/hooks/use-local-log-services.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
32 changes: 32 additions & 0 deletions apps/local-ui/src/hooks/use-local-log-services.ts
Original file line number Diff line number Diff line change
@@ -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<ReadonlyArray<FilterOption>>({
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) }))
},
})
}
19 changes: 7 additions & 12 deletions apps/local-ui/src/hooks/use-local-logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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)
},
Expand All @@ -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<ReadonlyArray<FilterOption>>({
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 }))
Expand Down
30 changes: 0 additions & 30 deletions apps/local-ui/src/hooks/use-local-services.ts

This file was deleted.

30 changes: 30 additions & 0 deletions apps/local-ui/src/hooks/use-log-time-window.test.ts
Original file line number Diff line number Diff line change
@@ -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",
})
})
})
11 changes: 11 additions & 0 deletions apps/local-ui/src/hooks/use-log-time-window.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
24 changes: 24 additions & 0 deletions apps/local-ui/src/lib/time.test.ts
Original file line number Diff line number Diff line change
@@ -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",
})
})
})
7 changes: 3 additions & 4 deletions apps/local-ui/src/lib/time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,11 @@ export const TIME_RANGES: ReadonlyArray<TimeRange> = [
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),
}
}

Expand Down
34 changes: 23 additions & 11 deletions apps/local-ui/src/views/logs-view.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
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"
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"
Expand Down Expand Up @@ -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<string, string | null | undefined>) => {
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<ReadonlyArray<LocalLog>>(() => (data?.pages.flat() ?? []).map(normalizeLog), [data])
const scrollRef = useRef<HTMLDivElement>(null)
Expand Down Expand Up @@ -79,20 +88,20 @@ export function LogsView() {
>
<FilterSidebarHeader
canClear={hasActiveFilters}
onClear={() => setParams({ service: null, severity: null })}
onClear={() => updateParamsWithFreshTimeWindow({ service: null, severity: null })}
/>
<FilterSidebarBody>
<FilterSection
title="Severity"
options={(severities.data ?? []).map((o) => ({ 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 })}
/>
<SearchableFilterSection
title="Service"
options={(services.data ?? []).map((o) => ({ 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 })}
/>
</FilterSidebarBody>
</FilterSidebarFrame>
Expand All @@ -102,13 +111,16 @@ export function LogsView() {
<Toolbar>
<ToolbarSearch
query={search ?? ""}
onSearch={(value) => setParams({ q: value ?? null })}
onSearch={(value) => updateParamsWithFreshTimeWindow({ q: value ?? null })}
placeholder="Search log bodies…"
/>
<ToolbarStats>
<ToolbarStat value={rows.length} label={hasNextPage ? "logs+" : "logs"} />
<RefreshButton />
<TimeRangeSelect value={range} onChange={(next) => setParams({ range: next })} />
<RefreshButton onBeforeRefresh={timeWindow.advance} />
<TimeRangeSelect
value={range}
onChange={(next) => updateParamsWithFreshTimeWindow({ range: next })}
/>
</ToolbarStats>
</Toolbar>
)
Expand Down
2 changes: 2 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions packages/query-engine/src/ch/queries/logs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
})

// ---------------------------------------------------------------------------
Expand Down
Loading