diff --git a/api/cmd/api/main.go b/api/cmd/api/main.go index 54fdf89..6868c90 100644 --- a/api/cmd/api/main.go +++ b/api/cmd/api/main.go @@ -19,6 +19,13 @@ import ( "strings" "syscall" "time" + // Embeds the IANA tz database in the binary. This image is + // distroless/static -- it has no /usr/share/zoneinfo at all, so + // time.LoadLocation would fail for every zone except UTC, and + // localauth's timezone validation would reject every real name a + // user could pick. ~450KB of binary for a feature whose whole job is + // knowing what "America/New_York" means. + _ "time/tzdata" "github.com/ClickHouse/clickhouse-go/v2" "github.com/jackc/pgx/v5/pgxpool" diff --git a/api/localauth/fake_test.go b/api/localauth/fake_test.go index 09bbdeb..beab9c1 100644 --- a/api/localauth/fake_test.go +++ b/api/localauth/fake_test.go @@ -40,7 +40,10 @@ func (f *fakeStore) CreateUser(_ context.Context, username, passwordHash string, } f.nextID++ id := "user-" + strconv.Itoa(f.nextID) - u := &User{ID: id, Username: username, Role: role, CreatedAt: time.Now()} + // DisplayTimezone mirrors the schema's NOT NULL DEFAULT 'UTC' (see + // migration 0042) -- a fake that left it empty would let a handler + // bug that drops the default pass unnoticed. + u := &User{ID: id, Username: username, Role: role, DisplayTimezone: "UTC", CreatedAt: time.Now()} f.users[id] = u f.hashes[id] = passwordHash f.byUsername[username] = id @@ -114,6 +117,18 @@ func (f *fakeStore) SetRole(_ context.Context, userID string, role authz.Role) e return nil } +// SetDisplayTimezone deliberately does not touch f.sessions -- unlike +// SetRole/SetPasswordHash above, changing a rendering preference is not +// a reason to sign anyone out, and the test for that asserts it. +func (f *fakeStore) SetDisplayTimezone(_ context.Context, userID, tz string) error { + u, ok := f.users[userID] + if !ok { + return ErrNotFound + } + u.DisplayTimezone = tz + return nil +} + func (f *fakeStore) CountLocalUsers(_ context.Context) (int, error) { return len(f.users), nil } diff --git a/api/localauth/handler.go b/api/localauth/handler.go index 17d37ba..e28d759 100644 --- a/api/localauth/handler.go +++ b/api/localauth/handler.go @@ -25,6 +25,7 @@ type store interface { DeleteUser(ctx context.Context, id string) error SetPasswordHash(ctx context.Context, userID, hash string) error SetRole(ctx context.Context, userID string, role authz.Role) error + SetDisplayTimezone(ctx context.Context, userID, tz string) error CountUsersWithRole(ctx context.Context, role authz.Role) (int, error) CreateSession(ctx context.Context, userID, tenantID string, role authz.Role, ttl time.Duration) (string, error) DeleteSessionByHash(ctx context.Context, tokenHash string) error @@ -113,6 +114,11 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /auth/logout", h.handleLogout) mux.HandleFunc("GET /auth/session", authz.RequireRole(h.authorizer, authz.RoleViewer, h.handleGetSession)) mux.HandleFunc("POST /auth/password", authz.RequireRole(h.authorizer, authz.RoleViewer, h.handleChangeOwnPassword)) + // Self-service, same RoleViewer floor as the password change above: + // how a user's own clock is rendered is nobody else's permission to + // grant, and a Viewer -- the role most likely to be *only* reading + // logs -- needs it most. + mux.HandleFunc("PUT /auth/timezone", authz.RequireRole(h.authorizer, authz.RoleViewer, h.handleSetTimezone)) mux.HandleFunc("GET /auth/users", authz.RequireRole(h.authorizer, authz.RoleAdmin, h.handleListUsers)) mux.HandleFunc("POST /auth/users", authz.RequireRole(h.authorizer, authz.RoleAdmin, h.handleCreateUser)) @@ -138,6 +144,12 @@ type sessionResponse struct { TenantID string `json:"tenant_id"` Username string `json:"username"` Role string `json:"role"` + // Timezone is the user's stored display preference (IANA zone name, + // "UTC" by default). Sent on the session so the web UI knows which + // offset to render in from its very first paint, without a second + // round trip -- omitted from the login response, where the store + // lookup that produces it hasn't happened. + Timezone string `json:"timezone,omitempty"` } func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) { @@ -217,6 +229,7 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, sessionResponse{ UserID: user.ID, TenantID: identity.TenantID, Username: user.Username, Role: string(user.Role), + Timezone: user.DisplayTimezone, }) } @@ -542,6 +555,62 @@ func (h *Handler) handleChangeOwnPassword(w http.ResponseWriter, r *http.Request w.WriteHeader(http.StatusNoContent) } +type setTimezoneRequest struct { + Timezone string `json:"timezone"` +} + +// maxTimezoneLen bounds the input before it reaches LoadLocation, which +// takes the value as a filesystem-ish lookup key. The longest real IANA +// name is well under this ("America/Argentina/ComodRivadavia", 31). +const maxTimezoneLen = 64 + +// handleSetTimezone stores the caller's own display-timezone preference +// -- see metadata/migrations/0042_add_user_display_timezone.sql for why +// this is presentation-only and can never affect what data a query +// returns. +// +// Validation is time.LoadLocation against the tzdata embedded in this +// binary (see the time/tzdata import in cmd/api/main.go), not a +// hand-maintained allowlist: the set of valid zone names is the tz +// database's to define, and it changes a few times a year. Rejecting +// unknown names here matters because the value is echoed back to every +// client on the session response -- an unvalidated string would just be +// a stored round trip for whatever someone put in. +func (h *Handler) handleSetTimezone(w http.ResponseWriter, r *http.Request) { + var req setTimezoneRequest + if !decodeJSON(w, r, &req) { + return + } + if req.Timezone == "" { + writeError(w, http.StatusBadRequest, "timezone must not be empty") + return + } + if len(req.Timezone) > maxTimezoneLen { + writeError(w, http.StatusBadRequest, "timezone is not a valid IANA zone name") + return + } + // "Local" is a valid LoadLocation argument but means "whatever zone + // the *server* process is in", which is meaningless as a per-user + // display preference and would render differently depending on which + // host answered. The browser's own zone is the client's business to + // resolve into a real name before sending it. + if req.Timezone == "Local" { + writeError(w, http.StatusBadRequest, "timezone must be a specific IANA zone name, not \"Local\"") + return + } + if _, err := time.LoadLocation(req.Timezone); err != nil { + writeError(w, http.StatusBadRequest, "timezone is not a valid IANA zone name") + return + } + + identity, _ := authz.IdentityFromContext(r.Context()) + if err := h.store.SetDisplayTimezone(r.Context(), identity.UserID, req.Timezone); err != nil { + h.writeStoreErr(w, err, "setting timezone") + return + } + w.WriteHeader(http.StatusNoContent) +} + func (h *Handler) setCookie(w http.ResponseWriter, raw string, ttl time.Duration) { http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, diff --git a/api/localauth/handler_test.go b/api/localauth/handler_test.go index 76bb0e6..089e660 100644 --- a/api/localauth/handler_test.go +++ b/api/localauth/handler_test.go @@ -702,3 +702,93 @@ func TestChangeOwnPasswordRequiresAuth(t *testing.T) { t.Fatalf("status = %d, want 401 with no session", rec.Code) } } + +func TestSetTimezoneStoresAndReportsOnSession(t *testing.T) { + fs := newFakeStore() + // RoleViewer deliberately: a viewer is the role most likely to be + // only ever reading logs, and reading logs is what this setting is + // for -- if it needed a higher role it would be useless. + mustCreateUser(t, fs, "vince", "vincepassword", authz.RoleViewer) + _, mux := newTestHandler(t, fs) + + login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"vince","password":"vincepassword"}`, nil) + cookie := sessionCookieFrom(login) + + // Defaults to UTC before anything is set. + before := doRequest(t, mux, http.MethodGet, "/auth/session", "", cookie) + if got := decodeSessionTimezone(t, before); got != "UTC" { + t.Fatalf("initial session timezone = %q, want %q", got, "UTC") + } + + rec := doRequest(t, mux, http.MethodPut, "/auth/timezone", `{"timezone":"America/New_York"}`, cookie) + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204, body=%s", rec.Code, rec.Body.String()) + } + + // The same session keeps working -- unlike a password or role + // change, a rendering preference is no reason to sign anyone out. + after := doRequest(t, mux, http.MethodGet, "/auth/session", "", cookie) + if after.Code != http.StatusOK { + t.Fatalf("session after timezone change: status = %d, want 200", after.Code) + } + if got := decodeSessionTimezone(t, after); got != "America/New_York" { + t.Errorf("session timezone = %q, want %q", got, "America/New_York") + } +} + +func TestSetTimezoneRejectsInvalidZones(t *testing.T) { + fs := newFakeStore() + mustCreateUser(t, fs, "vince", "vincepassword", authz.RoleViewer) + _, mux := newTestHandler(t, fs) + + login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"vince","password":"vincepassword"}`, nil) + cookie := sessionCookieFrom(login) + + cases := map[string]string{ + "empty": `{"timezone":""}`, + "not a zone": `{"timezone":"Mars/Olympus_Mons"}`, + "fixed offset": `{"timezone":"-07:00"}`, + "server-local": `{"timezone":"Local"}`, + "absurdly long": `{"timezone":"` + strings.Repeat("x", 200) + `"}`, + "path traversal": `{"timezone":"../../etc/passwd"}`, + } + for name, body := range cases { + t.Run(name, func(t *testing.T) { + rec := doRequest(t, mux, http.MethodPut, "/auth/timezone", body, cookie) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400, body=%s", rec.Code, rec.Body.String()) + } + }) + } + + // Nothing above should have changed the stored value. + sess := doRequest(t, mux, http.MethodGet, "/auth/session", "", cookie) + if got := decodeSessionTimezone(t, sess); got != "UTC" { + t.Errorf("timezone after rejected requests = %q, want %q", got, "UTC") + } +} + +func TestSetTimezoneRequiresAuth(t *testing.T) { + fs := newFakeStore() + mustCreateUser(t, fs, "vince", "vincepassword", authz.RoleViewer) + _, mux := newTestHandler(t, fs) + + rec := doRequest(t, mux, http.MethodPut, "/auth/timezone", `{"timezone":"Europe/Berlin"}`, nil) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} + +func decodeSessionTimezone(t *testing.T, rec *httptest.ResponseRecorder) string { + t.Helper() + if rec.Code != http.StatusOK { + t.Fatalf("session status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + var body struct { + Timezone string `json:"timezone"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decoding session body: %v", err) + } + return body.Timezone +} diff --git a/api/localauth/store.go b/api/localauth/store.go index 2de693c..3d2ba33 100644 --- a/api/localauth/store.go +++ b/api/localauth/store.go @@ -52,10 +52,15 @@ var ( const defaultTenantID = "default" type User struct { - ID string - Username string - Role authz.Role - CreatedAt time.Time + ID string + Username string + Role authz.Role + // DisplayTimezone is an IANA zone name the web UI renders timestamps + // in -- presentation only, never applied to stored or queried data + // (see metadata/migrations/0042_add_user_display_timezone.sql). + // 'UTC' for any user who has never changed it. + DisplayTimezone string + CreatedAt time.Time } type Session struct { @@ -174,11 +179,11 @@ func (s *Store) GetUserByID(ctx context.Context, id string) (*User, error) { var u User var role string err := s.pool.QueryRow(ctx, ` - SELECT u.id, u.username, tm.role, u.created_at + SELECT u.id, u.username, tm.role, u.display_timezone, u.created_at FROM users u JOIN tenant_memberships tm ON tm.user_id = u.id AND tm.tenant_id = $1 WHERE u.id = $2 AND u.username IS NOT NULL`, defaultTenantID, id). - Scan(&u.ID, &u.Username, &role, &u.CreatedAt) + Scan(&u.ID, &u.Username, &role, &u.DisplayTimezone, &u.CreatedAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound @@ -189,6 +194,25 @@ func (s *Store) GetUserByID(ctx context.Context, id string) (*User, error) { return &u, nil } +// SetDisplayTimezone stores one user's display-timezone preference. It +// deliberately does NOT revoke sessions the way SetPasswordHash does -- +// this is a rendering preference, not a credential, and a user changing +// how their clock reads has no reason to be signed out. The caller is +// responsible for having validated tz against the tz database first +// (see handleSetTimezone); the column has no CHECK constraint. +func (s *Store) SetDisplayTimezone(ctx context.Context, id, tz string) error { + tag, err := s.pool.Exec(ctx, ` + UPDATE users SET display_timezone = $1, updated_at = now() + WHERE id = $2 AND username IS NOT NULL`, tz, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + // DeleteUser cascades to the user's tenant_memberships and // local_sessions rows (both ON DELETE CASCADE) -- a deleted user's // existing sessions stop validating immediately, not just their next diff --git a/api/localauth/store_integration_test.go b/api/localauth/store_integration_test.go index 5d23feb..0daf573 100644 --- a/api/localauth/store_integration_test.go +++ b/api/localauth/store_integration_test.go @@ -286,3 +286,56 @@ func TestIntegrationGetPasswordHashByID(t *testing.T) { } } +// TestIntegrationDisplayTimezoneDefaultsAndPersists is the half +// handler_test.go's fake can't prove: that migration 0042's column +// actually exists with its NOT NULL DEFAULT 'UTC', that GetUserByID's +// SELECT names it correctly, and that a session created before the +// change keeps working after it (the UPDATE deliberately doesn't touch +// local_sessions, unlike SetPasswordHash/SetRole). +func TestIntegrationDisplayTimezoneDefaultsAndPersists(t *testing.T) { + store := integrationStore(t) + ctx := context.Background() + username := testUsername(t) + + hash, _ := HashPassword("password1") + user, err := store.CreateUser(ctx, username, hash, authz.RoleViewer) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + t.Cleanup(func() { _ = store.DeleteUser(ctx, user.ID) }) + + fetched, err := store.GetUserByID(ctx, user.ID) + if err != nil { + t.Fatalf("GetUserByID: %v", err) + } + if fetched.DisplayTimezone != "UTC" { + t.Fatalf("new user DisplayTimezone = %q, want %q (schema default)", fetched.DisplayTimezone, "UTC") + } + + raw, err := store.CreateSession(ctx, user.ID, "default", authz.RoleViewer, time.Hour) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + + if err := store.SetDisplayTimezone(ctx, user.ID, "Australia/Adelaide"); err != nil { + t.Fatalf("SetDisplayTimezone: %v", err) + } + fetched, err = store.GetUserByID(ctx, user.ID) + if err != nil { + t.Fatalf("GetUserByID after set: %v", err) + } + if fetched.DisplayTimezone != "Australia/Adelaide" { + t.Errorf("DisplayTimezone = %q, want %q", fetched.DisplayTimezone, "Australia/Adelaide") + } + + if _, err := store.GetSession(ctx, hashToken(raw)); err != nil { + t.Errorf("session after timezone change: %v, want it to still be valid", err) + } +} + +func TestIntegrationSetDisplayTimezoneUnknownUser(t *testing.T) { + store := integrationStore(t) + if err := store.SetDisplayTimezone(context.Background(), uuid.NewString(), "UTC"); !errors.Is(err, ErrNotFound) { + t.Fatalf("SetDisplayTimezone on missing user = %v, want ErrNotFound", err) + } +} diff --git a/metadata/migrations/0042_add_user_display_timezone.sql b/metadata/migrations/0042_add_user_display_timezone.sql new file mode 100644 index 0000000..65fe70c --- /dev/null +++ b/metadata/migrations/0042_add_user_display_timezone.sql @@ -0,0 +1,23 @@ +-- Per-user display timezone: a *presentation* preference only. +-- +-- Every timestamp in this system is and stays UTC -- ingest records +-- Unix nanoseconds, ClickHouse stores DateTime64 in UTC, and both the +-- query API and every JSON response continue to emit RFC3339 with a Z +-- offset. This column changes nothing about any of that. It only tells +-- the web UI which offset to render those instants in, so two users in +-- two timezones looking at the same log line see the same instant +-- written two different ways, never two different log lines. +-- +-- Stored as an IANA zone name ('UTC', 'America/New_York', ...) rather +-- than a fixed numeric offset, because a fixed offset is wrong twice a +-- year for anywhere that observes DST -- the zone name is what carries +-- the rule, not just today's answer. Validated in Go against the +-- embedded tzdata (see api/localauth's handleSetTimezone) rather than +-- by a CHECK constraint: the valid set is the tz database's, which +-- Postgres would have no way to keep in sync here. +-- +-- Default 'UTC' matches the "standardize on UTC" baseline -- a user who +-- never touches this setting sees exactly what they saw before it +-- existed. +ALTER TABLE users + ADD COLUMN IF NOT EXISTS display_timezone TEXT NOT NULL DEFAULT 'UTC'; diff --git a/web/README.md b/web/README.md index 8c6ce67..9ebc22c 100644 --- a/web/README.md +++ b/web/README.md @@ -22,6 +22,37 @@ read at container start. Set it before `npm run build` (or pass `--build-arg VITE_API_BASE_URL=...` to `docker build`) — changing it later means rebuilding, not just restarting the container. +## Timestamps and the display timezone + +Everything in Cairn OBS is UTC: ingest records Unix nanoseconds, +ClickHouse stores UTC, and every API response is RFC3339 with a Z. The +web UI renders those instants in whichever zone the reader picked +(Settings → Display timezone), which changes **presentation only** -- +never which rows a query returns, never their order, and never what +`earliest=`/`latest=` mean. Two people in two zones looking at one log +line see the same instant written two ways. + +- `src/lib/time.ts` -- pure formatting. `formatTimestamp` is the one + entry point; it detects timestamps by value, not by column name, since + query output is arbitrary. Sub-second digits are copied verbatim from + the source string rather than round-tripped through a JS `Date`, which + is millisecond-precision and would silently drop the last six digits of + a ClickHouse nanosecond timestamp. +- `src/lib/timezone.svelte.ts` -- where the choice is *stored*, which + differs by deployment on purpose: per named user server-side when local + login is on (`PUT /auth/timezone`, so it follows a person across + browsers), per browser session on a public demo (a shared account's + visitors shouldn't inherit each other's settings), per browser + otherwise. +- Charts format their own axis and tooltip labels through the same + helper. ECharts' `type: 'time'` axis otherwise renders in the + *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. + ## Building & running ```sh diff --git a/web/src/lib/ResultsTable.svelte b/web/src/lib/ResultsTable.svelte index 3b40bd7..baf558f 100644 --- a/web/src/lib/ResultsTable.svelte +++ b/web/src/lib/ResultsTable.svelte @@ -11,6 +11,8 @@ // column whose JSON got cut off). import Table from '$lib/components/ui/Table.svelte'; import SeverityBadge from '$lib/components/ui/SeverityBadge.svelte'; + import { getTimezone } from '$lib/timezone.svelte'; + import { formatTimestamp, isTimestamp, zoneLabel } from '$lib/time'; let { columns, @@ -23,9 +25,27 @@ function formatCell(value: unknown): string { if (value === null || value === undefined) return ''; if (typeof value === 'object') return JSON.stringify(value); + // Timestamps are rendered in the reader's chosen zone; everything + // else is passed through untouched. Detection is per *value*, not + // per column name, because query output is arbitrary -- `stats + // max(timestamp) as newest` names the column whatever it likes, + // and an attribute called "timestamp" that holds something else + // shouldn't be mangled. See $lib/time.ts. + if (isTimestamp(value)) return formatTimestamp(value, getTimezone()); return String(value); } + // Which columns hold timestamps, judged from the first row -- the + // header gets a zone suffix so a table read on its own (or + // screenshotted) still says which offset its times are in. Sampling + // one row is enough: a column is one type in practice, and the cost + // of being wrong is a missing label, not a wrong time. + let timestampCols = $derived.by(() => { + const first = rows[0]; + if (!first) return new Set(); + return new Set(columns.map((_, i) => i).filter((i) => isTimestamp(first[i]))); + }); + let sortCol = $state(null); let sortDir = $state<1 | -1>(1); @@ -87,7 +107,9 @@ {#each columns as col, i (col)} {#each columns as col, j (col)}
{col}
-
{formatCell(row[j])}
+
+ {formatCell(row[j])} + {#if isTimestamp(row[j])} + + {row[j]} + {/if} +
{/each} @@ -165,6 +196,19 @@ .chevron.open { transform: rotate(90deg); } + .zone-tag { + margin-left: var(--space-1); + color: var(--color-text-faint); + font-weight: var(--font-weight-normal); + text-transform: none; + letter-spacing: 0; + } + .raw-utc { + display: block; + color: var(--color-text-faint); + font-family: var(--font-mono); + font-size: var(--text-xs); + } .sort-btn { background: none; border: none; diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 4f892be..30d7884 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -430,7 +430,17 @@ export function injectTimeRange(query: string, earliest: string, latest: string) // --- local login (single-tenant mode, see api/localauth) -------------- -export type LocalSession = { user_id: string; tenant_id: string; username: string; role: string }; +export type LocalSession = { + user_id: string; + tenant_id: string; + username: string; + role: string; + // The user's stored display-timezone preference (IANA name). + // Optional: absent on deployments whose api predates the setting, and + // on the login response, which doesn't carry it -- both mean "UTC". + timezone?: string; +}; + export function login(username: string, password: string): Promise { return request('/auth/login', { @@ -440,6 +450,19 @@ export function login(username: string, password: string): Promise { + return request('/auth/timezone', { + method: 'PUT', + credentials: 'include', + body: JSON.stringify({ timezone }) + }); +} + export function logout(): Promise { return request('/auth/logout', { method: 'POST', credentials: 'include' }); } diff --git a/web/src/lib/charts/TimeSeriesChart.svelte b/web/src/lib/charts/TimeSeriesChart.svelte index 6f49c9c..2bb8ba9 100644 --- a/web/src/lib/charts/TimeSeriesChart.svelte +++ b/web/src/lib/charts/TimeSeriesChart.svelte @@ -7,6 +7,8 @@ import EChart from './EChart.svelte'; import { readChartTokens, baseOption, SERIES_PALETTE } from './theme'; import { pivot } from './pivot'; + import { getTimezone } from '$lib/timezone.svelte'; + import { axisTimeLabel, formatTimestamp } from '$lib/time'; import type { QueryResult } from '$lib/api'; import type { EChartsOption } from './setup'; @@ -32,16 +34,55 @@ seriesColumn: config.series_column }); const multi = p.series.length > 1; + const zone = getTimezone(); + // Span drives label granularity; measured across every series, + // since one may cover a wider range than another. + const xs = p.series.flatMap((s) => s.data.map((d) => Number(d[0]))).filter((n) => !Number.isNaN(n)); + const spanMs = xs.length > 1 ? Math.max(...xs) - Math.min(...xs) : 0; return { ...baseOption(t), color: SERIES_PALETTE, legend: multi ? { ...baseOption(t).legend, show: true } : { show: false }, - xAxis: { - ...baseOption(t).xAxis, - type: p.isTime ? 'time' : 'category', - data: p.isTime ? undefined : p.categories - }, + // Built as two concrete axes rather than one object with a + // conditional `type`: a time axis and a category axis are + // different option types, and merging them into one shape is + // what TypeScript (correctly) refuses. + xAxis: p.isTime + ? { + ...baseOption(t).xAxis, + type: 'time' as const, + axisLabel: { + ...baseOption(t).xAxis.axisLabel, + formatter: (value: number) => axisTimeLabel(value, zone, spanMs) + } + } + : { ...baseOption(t).xAxis, type: 'category' as const, data: p.categories }, + tooltip: p.isTime + ? { + ...baseOption(t).tooltip, + trigger: 'axis' as const, + // Same reason as the axis labels: ECharts' default + // tooltip header is the browser's zone, which would + // disagree with everything else on the page. + formatter: (params: unknown) => { + const rows = Array.isArray(params) ? params : [params]; + const first = rows[0] as { value?: [number, number] }; + const at = first?.value?.[0]; + const head = + at === undefined + ? '' + : `${formatTimestamp(new Date(at).toISOString(), zone)} ${zone}`; + const body = rows + .map((r) => { + const s = r as { marker?: string; seriesName?: string; value?: [number, number] }; + return `${s.marker ?? ''}${s.seriesName ?? ''} ${s.value?.[1] ?? ''}`; + }) + .join('
'); + return [head, body].filter(Boolean).join('
'); + } + } + : baseOption(t).tooltip, yAxis: { ...baseOption(t).yAxis, type: 'value' }, dataZoom: p.isTime ? [ diff --git a/web/src/lib/components/DeliveryTimeline.svelte b/web/src/lib/components/DeliveryTimeline.svelte index 03454fd..d095131 100644 --- a/web/src/lib/components/DeliveryTimeline.svelte +++ b/web/src/lib/components/DeliveryTimeline.svelte @@ -5,6 +5,8 @@ // for "a timeline view of an alert's state history rather than just // a flat delivery log" (Phase 5 task 7); this is the same // DeliveryLogEntry list the old flat table read, framed differently. + import { getTimezone } from '$lib/timezone.svelte'; + import { formatTimestamp } from '$lib/time'; import type { DeliveryLogEntry } from '$lib/api'; let { deliveries }: { deliveries: DeliveryLogEntry[] } = $props(); @@ -33,7 +35,7 @@
{d.event_type} - +
{statusLabel(d)} diff --git a/web/src/lib/time.ts b/web/src/lib/time.ts new file mode 100644 index 0000000..a2b41ba --- /dev/null +++ b/web/src/lib/time.ts @@ -0,0 +1,169 @@ +// Timestamp rendering. Pure functions, no state -- the caller passes the +// zone in, and $lib/timezone.svelte.ts owns where that zone comes from. +// +// The contract this whole feature rests on: nothing here ever changes +// *which* instant a value refers to, only how it's written down. The +// data stays UTC end to end (ingest records Unix nanoseconds, ClickHouse +// stores UTC, the API emits RFC3339 with a Z), queries are still +// evaluated in UTC, and two users in two zones looking at one log line +// see the same instant rendered two ways -- never two different lines, +// and never a different sort order. + +// Deliberately narrow, matching what this system's own APIs emit: +// RFC3339/ISO-8601 with a date, a T (or space) separator, and a time. +// A fractional part and a zone suffix are both optional because the two +// sources differ -- ClickHouse query results come back like +// "2026-08-22T21:30:06.090041211Z" (nanoseconds), Postgres-backed JSON +// like "2026-08-22T21:30:06.477115Z" (microseconds). +// +// Being narrow is the point: ResultsTable runs this over every cell of +// every column, and a looser pattern would start reformatting values +// that merely resemble dates (a version string, an ID with dashes) and +// silently corrupt them. +const isoTimestamp = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(\.\d+)?(Z|[+-]\d{2}:?\d{2})$/; + +export function isTimestamp(value: unknown): value is string { + return typeof value === 'string' && isoTimestamp.test(value); +} + +// Intl.DateTimeFormat construction is expensive enough to matter when +// it's called once per cell on a 5,000-row result; the zone rarely +// changes, so one formatter per zone is cached for the page's lifetime. +const formatters = new Map(); + +function formatterFor(zone: string): Intl.DateTimeFormat { + let f = formatters.get(zone); + if (!f) { + f = new Intl.DateTimeFormat('en-CA', { + timeZone: zone, + hourCycle: 'h23', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + formatters.set(zone, f); + } + return f; +} + +// 'en-CA' yields YYYY-MM-DD natively, but only formatToParts is +// guaranteed to give the pieces without a locale-specific separator +// sneaking in, so the string is assembled by hand. +function parts(value: Date, zone: string): Record { + const out: Record = {}; + for (const p of formatterFor(zone).formatToParts(value)) out[p.type] = p.value; + return out; +} + +export type TimestampPrecision = 'seconds' | 'millis' | 'full'; + +/** + * Renders an ISO timestamp in `zone` as `YYYY-MM-DD HH:mm:ss[.fff]`. + * + * Anything that isn't a recognizable timestamp comes back untouched -- + * this runs over unknown query output, where guessing wrong is worse + * than doing nothing. + * + * Sub-second digits are taken verbatim from the source string rather + * than from the parsed Date: JS Dates are millisecond-precision, so + * round-tripping a ClickHouse nanosecond timestamp through one would + * silently drop six digits of a log's ordering information. + */ +export function formatTimestamp( + value: unknown, + zone: string, + precision: TimestampPrecision = 'millis' +): string { + if (!isTimestamp(value)) return value === null || value === undefined ? '' : String(value); + const ms = Date.parse(value); + if (Number.isNaN(ms)) return value; + + const p = parts(new Date(ms), zone); + const base = `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}:${p.second}`; + if (precision === 'seconds') return base; + + const fraction = isoTimestamp.exec(value)?.[7] ?? ''; + if (!fraction) return base; + return base + (precision === 'full' ? fraction : fraction.slice(0, 4)); +} + +/** Date only, for created-at style columns where the time of day is noise. */ +export function formatDate(value: unknown, zone: string): string { + if (!isTimestamp(value)) return value === null || value === undefined ? '' : String(value); + const p = parts(new Date(Date.parse(value)), zone); + return `${p.year}-${p.month}-${p.day}`; +} + +/** + * The UTC offset `zone` is at for a given instant, as `+HH:MM`. + * + * Computed for a specific moment, not for the zone in general, because + * half the world's zones have two answers depending on the date -- a + * label that says -08:00 in July for America/Los_Angeles is a lie, and + * the whole point of storing a zone name rather than a fixed offset is + * that the rules are what matter. + */ +export function offsetLabel(zone: string, at: Date = new Date()): string { + // 'longOffset' gives "GMT+11:00" directly. The arithmetic + // alternative -- formatting the same instant in two zones and + // subtracting -- means re-parsing a locale-formatted string, which is + // implementation-defined; this asks the platform the question + // outright instead. + const name = new Intl.DateTimeFormat('en-US', { timeZone: zone, timeZoneName: 'longOffset' }) + .formatToParts(at) + .find((p) => p.type === 'timeZoneName')?.value; + // Zero-offset zones format as a bare "GMT", with no numeric part. + return /GMT([+-]\d{2}:\d{2})/.exec(name ?? '')?.[1] ?? '+00:00'; +} + +/** + * The same offset as a signed number of minutes, for arithmetic -- + * `+05:30` is +330. Kept next to offsetLabel so the two can't disagree + * about what a zone's offset is. + */ +export function offsetMinutes(zone: string, at: Date = new Date()): number { + const [, sign, hh, mm] = /^([+-])(\d{2}):(\d{2})$/.exec(offsetLabel(zone, at)) ?? []; + if (!sign) return 0; + return (sign === '-' ? -1 : 1) * (Number(hh) * 60 + Number(mm)); +} + +/** "UTC" / "America/New_York +11:00" -- what a column header or picker shows. */ +export function zoneLabel(zone: string, at: Date = new Date()): string { + return zone === 'UTC' ? 'UTC' : `${zone} ${offsetLabel(zone, at)}`; +} + +/** + * Relative age ("3m ago"). Zone-independent by construction -- the gap + * between two instants is the same number everywhere on earth -- so it + * takes no zone argument, and pages that show only relative times need + * no timezone plumbing at all. + */ +export function relativeTime(value: string, now: number = Date.now()): string { + const ms = now - Date.parse(value); + if (Number.isNaN(ms)) return ''; + if (ms < 60_000) return `${Math.max(0, Math.round(ms / 1000))}s ago`; + if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m ago`; + if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h ago`; + return `${Math.round(ms / 86_400_000)}d ago`; +} + +/** + * Compact axis label for a time-series chart, rendered in `zone`. + * + * ECharts' own `type: 'time'` axis formats in the *browser's* zone with + * no way to tell it otherwise, which would put a chart's clock an hour + * or ten out of step with the table right beside it. Every time axis in + * this app therefore formats its own labels through here. + * + * The shape of the label follows the visible span, the way any chart's + * does: a few hours wants the time of day, a week wants the date. + */ +export function axisTimeLabel(ms: number, zone: string, spanMs: number): string { + const full = formatTimestamp(new Date(ms).toISOString(), zone, 'seconds'); + if (spanMs > 5 * 86_400_000) return full.slice(0, 10); // YYYY-MM-DD + if (spanMs > 86_400_000) return full.slice(5, 16); // MM-DD HH:mm + return full.slice(11, 16); // HH:mm +} diff --git a/web/src/lib/timezone.svelte.ts b/web/src/lib/timezone.svelte.ts new file mode 100644 index 0000000..ed758d5 --- /dev/null +++ b/web/src/lib/timezone.svelte.ts @@ -0,0 +1,116 @@ +// Which timezone the UI renders timestamps in. Display only -- see +// $lib/time.ts's header for why that distinction is the whole feature. +// +// Where the choice is *stored* depends on the deployment, and the three +// cases are genuinely different products rather than one with fallbacks: +// +// - A public demo (isPublicDemo) keeps it in sessionStorage, so every +// new session starts at UTC again. A shared demo account is used by +// strangers who have nothing to do with each other; one visitor's +// choice following the next one around would be a bug, not a +// feature. +// - A deployment with local login stores it server-side, per named +// user (PUT /auth/timezone), so it follows that person across +// browsers and survives logout -- the setting belongs to the +// account, not the machine. +// - Anything else (SSO-only, or no auth configured) has no per-user +// record to write to, so it falls back to localStorage: still +// persistent, just per-browser. +import { browser } from '$app/environment'; +import { isPublicDemo, localAuthEnabled, setDisplayTimezone } from '$lib/api'; + +export const DEFAULT_ZONE = 'UTC'; + +const STORAGE_KEY = 'cairnobs.timezone'; + +type Persistence = 'session' | 'account' | 'browser'; + +export function persistence(): Persistence { + if (isPublicDemo) return 'session'; + return localAuthEnabled ? 'account' : 'browser'; +} + +function storage(): Storage | null { + if (!browser) return null; + try { + return persistence() === 'session' ? sessionStorage : localStorage; + } catch { + // Storage can throw outright in some privacy modes, not just come + // back empty. + return null; + } +} + +function readStored(): string | null { + try { + return storage()?.getItem(STORAGE_KEY) ?? null; + } catch { + return null; + } +} + +let zone = $state(DEFAULT_ZONE); + +// Account-mode deployments learn the real value from GET /auth/session, +// which the layout's route guard already fetches on every navigation -- +// so this is initialized from that response rather than by issuing a +// second request of its own. +export function initTimezone(fromSession?: string | null) { + if (persistence() === 'account') { + zone = fromSession || DEFAULT_ZONE; + return; + } + zone = readStored() || DEFAULT_ZONE; +} + +export function getTimezone(): string { + return zone; +} + +/** + * Applies a zone immediately and persists it wherever this deployment + * keeps it. The UI updates on the local assignment, not on the server + * round trip: a failed PUT shouldn't leave someone staring at a control + * that appears not to respond, and the consequence of the failure is + * only that the choice won't survive their next login. + */ +export async function setTimezone(tz: string): Promise { + zone = tz; + if (persistence() === 'account') { + await setDisplayTimezone(tz); + return; + } + try { + storage()?.setItem(STORAGE_KEY, tz); + } catch { + // Storage unavailable -- the choice just won't outlive the page. + } +} + +/** The zone this browser thinks it's in, e.g. "Europe/Berlin". */ +export function browserTimezone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || DEFAULT_ZONE; + } catch { + return DEFAULT_ZONE; + } +} + +/** + * Every IANA zone the browser knows, straight from Intl -- no bundled + * zone list to go stale as the tz database changes a few times a year. + * UTC is forced to the front because it's this system's baseline and + * shouldn't have to be hunted for alphabetically. + */ +export function timezoneOptions(): string[] { + let all: string[] = []; + try { + all = Intl.supportedValuesOf('timeZone'); + } catch { + // Older engines without supportedValuesOf: offer the two zones + // that can be named without a list -- the baseline and this + // browser's own -- rather than nothing. + all = [browserTimezone()]; + } + return [DEFAULT_ZONE, ...all.filter((z) => z !== DEFAULT_ZONE)]; +} diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 873e601..ab7fb85 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -5,6 +5,7 @@ import CommandPalette from '$lib/components/CommandPalette.svelte'; import { page } from '$app/state'; import { getLocalSession } from '$lib/api'; + import { initTimezone } from '$lib/timezone.svelte'; let { children } = $props(); let paletteOpen = $state(false); @@ -41,6 +42,12 @@ // mid-use redirects without re-blanking an already-rendered page (a // full navigation to /login is already underway by the time that'd // matter anyway). + // Storage-backed timezone modes (demo, and deployments without local + // login) can resolve immediately; account mode learns the real value + // from the session response below. Both run before any timestamp is + // rendered, since nothing renders until the guard resolves. + initTimezone(); + $effect(() => { if (isLoginPage) { authorized = true; @@ -48,6 +55,7 @@ return; } getLocalSession().then((session) => { + if (session !== null && session !== 'disabled') initTimezone(session.timezone); if (session === null) { const next = encodeURIComponent(page.url.pathname + page.url.search); window.location.href = `/login?next=${next}`; diff --git a/web/src/routes/agents/+page.svelte b/web/src/routes/agents/+page.svelte index 408380d..e9bc3f4 100644 --- a/web/src/routes/agents/+page.svelte +++ b/web/src/routes/agents/+page.svelte @@ -1,4 +1,6 @@
@@ -77,7 +72,7 @@ {a.host} {a.service} {a.agent_version || '—'} - {relativeTime(a.last_seen_at)} + {relativeTime(a.last_seen_at)} {#if isStale(a)} stale diff --git a/web/src/routes/agents/[host]/+page.svelte b/web/src/routes/agents/[host]/+page.svelte index 319bd53..61d3fbb 100644 --- a/web/src/routes/agents/[host]/+page.svelte +++ b/web/src/routes/agents/[host]/+page.svelte @@ -1,4 +1,6 @@
@@ -202,9 +197,9 @@
Source
{agent.source_kind}{agent.source_detail ? ` (${agent.source_detail})` : ''}
First seen
-
{relativeTime(agent.first_seen_at)}
+
{relativeTime(agent.first_seen_at)}
Last seen
-
{relativeTime(agent.last_seen_at)}
+
{relativeTime(agent.last_seen_at)}
diff --git a/web/src/routes/hosts/[host]/+page.svelte b/web/src/routes/hosts/[host]/+page.svelte index a24a87f..a022248 100644 --- a/web/src/routes/hosts/[host]/+page.svelte +++ b/web/src/routes/hosts/[host]/+page.svelte @@ -1,4 +1,6 @@
@@ -278,6 +316,54 @@
+
+

Display timezone

+

+ Timestamps are stored and queried in UTC everywhere in Cairn OBS. This setting only changes + how they're written on screen — searches, dashboards, and alerts all keep returning exactly + the same records, so two people in two timezones are always looking at the same log line. +

+ +
+ + + {#if getTimezone() !== browserTimezone()} + + {/if} + {#if getTimezone() !== DEFAULT_ZONE} + + {/if} +
+ +

+ Now + {formatTimestamp(now.toISOString(), getTimezone(), 'seconds')} + {zoneLabel(getTimezone(), now)} + {#if getTimezone() !== DEFAULT_ZONE} + = {formatTimestamp(now.toISOString(), DEFAULT_ZONE, 'seconds')} UTC + {/if} +

+

{persistenceNote[persistence()]}

+

+ Time ranges you type into a query (earliest=/latest=) are still read + as UTC. +

+ {#if tzError}

Couldn't save timezone: {tzError}

{/if} +
+

Core

Single-tenant deployment settings live here. Nothing configurable yet.

@@ -484,6 +570,71 @@ .note a { color: var(--color-accent); } + .tz-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-3); + margin: var(--space-4) 0 var(--space-3); + } + .tz-label { + font-size: var(--text-sm); + color: var(--color-text-muted); + } + .tz-row select { + font-family: var(--font-ui); + font-size: var(--text-base); + color: var(--color-text); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: var(--space-2) var(--space-3); + min-width: 16rem; + } + .tz-detect { + font-family: var(--font-ui); + font-size: var(--text-sm); + color: var(--color-accent); + background: none; + border: none; + padding: 0; + cursor: pointer; + } + .tz-detect:disabled { + cursor: default; + opacity: 0.6; + } + /* The live sample is the part that makes the setting self-evident, + so it gets the surface treatment rather than sitting in body copy. */ + .tz-sample { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--space-3); + margin: 0 0 var(--space-3); + padding: var(--space-3) var(--space-4); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + } + .tz-sample-label { + font-size: var(--text-xs); + font-weight: var(--font-weight-bold); + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-text-faint); + } + .tz-sample-value { + font-family: var(--font-mono); + font-size: var(--text-md); + color: var(--color-text); + } + .tz-sample-zone, + .tz-sample-utc { + font-family: var(--font-mono); + font-size: var(--text-sm); + color: var(--color-text-muted); + } .option-group { display: flex; gap: var(--space-2); diff --git a/web/src/routes/users/+page.svelte b/web/src/routes/users/+page.svelte index c17b4a6..f4aeceb 100644 --- a/web/src/routes/users/+page.svelte +++ b/web/src/routes/users/+page.svelte @@ -1,4 +1,6 @@