From e9ab528c080555271da3ccb8ead4346fdbc3a39b Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sat, 22 Aug 2026 16:15:41 -0700 Subject: [PATCH] Read query times in the display timezone, and stop capping result width Two things that made reading logs harder than it needed to be. Query input: the API accepts an absolute time only if it is quoted AND carries an explicit offset, so someone reading logs in America/Denver who wanted "9am today" had to convert to UTC in their head and remember the quotes. Now a time typed without an offset is read as wall-clock time in that reader's display timezone and converted to the instant it names; anything with an explicit offset is taken at its word, and relative ranges never depended on a zone. This widens what's accepted rather than reinterpreting anything -- every naive form now handled is one the parser rejects outright today, so no query that works now can change meaning. The conversion happens before a query is sent *or saved*: a stored dashboard range becomes an explicit instant, because storing "2026-08-22 10:00" would mean 10am in whatever zone each viewer sat in, and one shared dashboard would show two people two different windows. It also fixes two bugs that predate the timezone work. injectTimeRange emitted absolute values unquoted, which the parser rejects -- so zooming a time-series chart into a range, and clicking a chart to drill down, both produced a syntax error on every panel. Both fed an ISO string straight into that unquoted path. Width: the query page's 64rem cap is gone, so the query bar and results table use the whole window -- a log table is the widest thing in this app and that cap was the horizontal scrolling. Prose keeps a readable measure, since full-width paragraphs are harder to read, not easier. Ambiguous local times -- the hour that repeats when clocks go back, the hour skipped when they go forward -- resolve to one instant. That is inherent to naming a moment by wall clock; an explicit offset sidesteps it. Documented at the conversion. --- web/README.md | 15 ++- web/src/lib/api.ts | 19 ++- web/src/lib/components/PanelEditor.svelte | 3 +- web/src/lib/querytime.ts | 126 ++++++++++++++++++++ web/src/routes/dashboards/[id]/+page.svelte | 23 +++- web/src/routes/search/+page.svelte | 25 +++- web/src/routes/settings/+page.svelte | 6 +- 7 files changed, 202 insertions(+), 15 deletions(-) create mode 100644 web/src/lib/querytime.ts diff --git a/web/README.md b/web/README.md index 9ebc22c..1a86f43 100644 --- a/web/README.md +++ b/web/README.md @@ -49,9 +49,18 @@ line see the same instant written two ways. *browser's* zone with no way to override it, which would put a chart's clock out of step with the table beside it. -The one place the UTC baseline is still visible to a user is query input: -`earliest=`/`latest=` are parsed as UTC regardless of this setting. The -Settings page says so explicitly rather than leaving it to be discovered. +Query *input* follows the same setting: an absolute time written without +an offset (`earliest=2026-08-22 09:00`) is read as wall-clock time in the +reader's zone and converted to UTC before the query is sent -- see +`src/lib/querytime.ts`. Anything with an explicit offset is taken at its +word, and relative ranges (`-24h`) never depended on a zone. + +That conversion happens before a query is sent *or saved*, so a stored +dashboard range is an explicit instant rather than "10am, whoever you +are" -- otherwise one shared dashboard would show two different windows +to two people. The API itself is unchanged and still accepts only quoted +RFC3339 with an offset; everything this adds is a form it rejects today, +so no query that works now can change meaning. ## Building & running diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 30d7884..ce02477 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -3,6 +3,8 @@ // (query + dashboards + panels + export/import); still zero-dependency, // a thin fetch wrapper, not a generated client. +import { toQueryTimeValue } from '$lib/querytime'; + export const apiBase = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080'; export const alertingBase = import.meta.env.VITE_ALERTING_API_BASE_URL ?? 'http://localhost:8081'; // Optional third backend (Phase 4; enterprise/ is AGPLv3 same as core @@ -422,9 +424,19 @@ export function resolveTimeRange( // running this against the live stack. Omitting the latest= clause // entirely is the query language's own way of saying "no upper bound", // which is exactly what "now" means here. -export function injectTimeRange(query: string, earliest: string, latest: string): string { - const clauses = [`earliest=${earliest}`]; - if (latest && latest !== 'now') clauses.push(`latest=${latest}`); +export function injectTimeRange( + query: string, + earliest: string, + latest: string, + zone: string +): string { + // toQueryTimeValue does two things the old string interpolation + // didn't: it quotes absolute values (the parser rejects them bare -- + // which silently broke chart-zoom-to-time-range), and it resolves a + // value typed without an offset as wall-clock time in `zone`. See + // $lib/querytime.ts. + const clauses = [`earliest=${toQueryTimeValue(earliest, zone)}`]; + if (latest && latest !== 'now') clauses.push(`latest=${toQueryTimeValue(latest, zone)}`); return `${clauses.join(' ')} ${query}`; } @@ -441,7 +453,6 @@ export type LocalSession = { timezone?: string; }; - export function login(username: string, password: string): Promise { return request('/auth/login', { method: 'POST', diff --git a/web/src/lib/components/PanelEditor.svelte b/web/src/lib/components/PanelEditor.svelte index f64e6c9..13719c4 100644 --- a/web/src/lib/components/PanelEditor.svelte +++ b/web/src/lib/components/PanelEditor.svelte @@ -6,6 +6,7 @@ // and renders it through the real PanelViz, not a separate mock-up, // so what you see here is exactly what lands on the dashboard, not // an approximation of it. + import { getTimezone } from '$lib/timezone.svelte'; import { Modal, Button, Input, Select, Tabs } from '$lib/components/ui'; import QueryBar from '$lib/QueryBar.svelte'; import PanelViz from '$lib/PanelViz.svelte'; @@ -102,7 +103,7 @@ try { const earliest = earliestOverride || dashboardEarliest; const latest = latestOverride || dashboardLatest; - previewResult = await runQuery(injectTimeRange(query, earliest, latest), language); + previewResult = await runQuery(injectTimeRange(query, earliest, latest, getTimezone()), language); } catch (e) { previewError = e instanceof Error ? e.message : String(e); previewResult = null; diff --git a/web/src/lib/querytime.ts b/web/src/lib/querytime.ts new file mode 100644 index 0000000..e5ec904 --- /dev/null +++ b/web/src/lib/querytime.ts @@ -0,0 +1,126 @@ +// Turning what a human typed into a time range the query API accepts. +// +// The API's rule (api/internal/querylang) is strict and unforgiving of +// human input: an absolute time must be quoted AND carry an explicit +// offset. `earliest="2026-08-22T10:00:00Z"` works; the same value +// unquoted is a syntax error, and with the offset left off it's +// "invalid absolute timestamp ... want RFC3339". So someone reading logs +// in America/Denver who wants "10am today" has to do the arithmetic into +// UTC in their head and remember the quotes -- which is exactly the +// human error worth designing out. +// +// What this does instead: a time typed WITHOUT an offset is read as a +// wall-clock time in the reader's display timezone and converted to the +// UTC instant it refers to, then quoted. A time typed WITH an offset +// (including Z) is taken at its word and only quoted. Relative +// expressions (-1h, now) are left completely alone -- they're already +// zone-independent. +// +// This widens what's accepted rather than reinterpreting anything: every +// naive form handled here is one the API rejects outright today, so no +// query that works now can change meaning. And because the conversion +// happens before the query is sent or saved, what lands in a stored +// dashboard is an explicit UTC instant -- two people in two zones open +// that dashboard and see the same window, not their own local 10am. +import { offsetMinutes } from '$lib/time'; + +// Date, optionally followed by a time -- the shapes people actually +// type. A bare date means midnight, the same assumption every log tool +// makes for "from the 22nd". +const naiveDateTime = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?(\.\d+)?)?$/; + +/** True for values that already pin an instant: Z or ±HH:MM. */ +function hasExplicitOffset(value: string): boolean { + return /(?:Z|[+-]\d{2}:?\d{2})$/.test(value); +} + +/** True for relative/keyword forms the query language resolves itself. */ +function isRelative(value: string): boolean { + return value === 'now' || /^-\d+[smhdw]$/.test(value); +} + +/** + * Reads `input` as a wall-clock time in `zone` and returns the UTC + * instant it names, as RFC3339 with a Z. Null if it isn't a naive + * date/time. + * + * The two-pass offset lookup is the standard fix for a real trap: the + * offset to apply depends on the instant, and the instant is what we're + * solving for. Guessing the offset from the wall time read as UTC lands + * within a day of the answer -- close enough that a second lookup at + * that candidate instant lands on the right side of any DST boundary. + * + * Two local times can't be resolved unambiguously by anyone: the hour + * repeated when clocks go back (this picks one) and the hour skipped + * when they go forward (this yields the instant the clock jumps to). + * Both are inherent to naming an instant by wall clock, not a defect of + * this conversion -- typing an explicit offset sidesteps them. + */ +export function naiveToUTC(input: string, zone: string): string | null { + const m = naiveDateTime.exec(input.trim()); + if (!m) return null; + const [, y, mo, d, h = '00', mi = '00', s = '00', frac = ''] = m; + const ms = frac ? Math.round(parseFloat(frac) * 1000) : 0; + const asIfUTC = Date.UTC(+y, +mo - 1, +d, +h, +mi, +s, ms); + if (Number.isNaN(asIfUTC)) return null; + + const firstPass = asIfUTC - offsetMinutes(zone, new Date(asIfUTC)) * 60_000; + const secondPass = asIfUTC - offsetMinutes(zone, new Date(firstPass)) * 60_000; + return new Date(secondPass).toISOString(); +} + +/** + * What to put after `earliest=` / `latest=` for a value a human typed + * into a time-range field. + * + * Also fixes a bug that predates the timezone work: absolute values were + * injected unquoted, which the parser rejects outright -- so zooming a + * dashboard's time-series chart (which feeds an ISO string straight into + * the range picker) produced a syntax error on every panel. + */ +export function toQueryTimeValue(input: string, zone: string): string { + const raw = input.trim(); + if (!raw) return raw; + + // Unwrap an already-quoted value so the same rules apply to it, and + // remember to put the quotes back. + const quoted = /^"(.*)"$/.exec(raw) ?? /^'(.*)'$/.exec(raw); + const value = quoted ? quoted[1] : raw; + + if (isRelative(value)) return value; + if (hasExplicitOffset(value)) return `"${value}"`; + + const utc = naiveToUTC(value, zone); + if (utc) return `"${utc}"`; + + // Not something we recognize -- pass it through untouched and let the + // API's own error message be the one the user sees, rather than + // inventing a second opinion here. + return raw; +} + +// Matches an earliest=/latest= clause and its value: quoted, or bare. +// +// The bare form deliberately reaches across a single space to pick up a +// following clock time, so `earliest=2026-08-22 09:00` is one value +// rather than a date plus a stray `09:00` left dangling in the query -- +// which is precisely how someone types a date and time by hand. The +// trailing group only matches something shaped like a time, so +// `earliest=2026-08-22 service=api` still ends the value at the date. +const timeClause = + /\b(earliest|latest)\s*=\s*("[^"]*"|'[^']*'|[^\s|]+(?:[ ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?)?)/g; + +/** + * Rewrites the time clauses inside a query someone typed by hand, the + * same way the range picker's fields are rewritten. + * + * Only for pipe-syntax queries: the raw-SQL escape hatch is passed to + * ClickHouse verbatim, and rewriting anything inside it would be this + * layer inventing SQL semantics it has no business having an opinion on. + */ +export function normalizeQueryTimes(query: string, zone: string): string { + return query.replace(timeClause, (whole, field: string, value: string) => { + const next = toQueryTimeValue(value, zone); + return next === value ? whole : `${field}=${next}`; + }); +} diff --git a/web/src/routes/dashboards/[id]/+page.svelte b/web/src/routes/dashboards/[id]/+page.svelte index ef449d6..5e17c3b 100644 --- a/web/src/routes/dashboards/[id]/+page.svelte +++ b/web/src/routes/dashboards/[id]/+page.svelte @@ -1,4 +1,6 @@