mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 04:15:04 +03:00
## Problem The dashboard renders all timestamps in the browser's local timezone. When debugging app issues, users often want to see logs and timestamps in a different timezone (e.g. their app's deployment region) without changing their OS clock. ## Fix - New Timezone submenu in the user-avatar dropdown, sitting next to the existing Theme picker. Search-as-you-type combobox over the full IANA catalog plus an Auto detect option. - Selection persists in localStorage (`supabase-ui-timezone`) and survives `clearLocalStorage()`. No backend schema change. - New `lib/datetime.tsx` exposes pure timezone-aware formatters (`formatDateTime`, `formatDate`, `formatTime`, `formatFromNow`, `toTimezone`) plus a `TimezoneProvider` and matching React hooks (`useTimezone`, `useFormatDateTime`, ...). The pure functions take `tz` explicitly so they're easy to unit test (17 vitest cases covering DST transitions, multi-tz formatting, unix-micro/Date inputs, invalid-tz fallback). - The selected timezone propagates to every existing `<TimestampInfo>` in Studio via a new `TimestampInfoProvider` context exported from `ui-patterns`. No per-callsite changes needed for those ~20+ surfaces. - The `UnifiedLogs` date column migrates off `date-fns` to the new `useFormatDateTime` hook (the rest of the date-fns callers stay as-is, since they're either internal range math or non-display). - `ALL_TIMEZONES` (~600 entries) moves out of `PITR.constants.ts` into a shared `lib/constants/timezones.ts`. PITR keeps a re-export shim so its callers don't move. New `TIMEZONES_BY_IANA` dedupes the catalog by primary IANA name (the original list contains both PDT and PST rows for `America/Los_Angeles`, etc.) and `findTimezoneByIana` provides reverse lookup. - Telemetry: `timezone_picker_clicked` PostHog event with `previousTimezone`, `nextTimezone`, `isAutoDetected` properties. Notes for reviewers: - Bare `dayjs(x).format(...)` calls (~157 files) intentionally still render in browser-local time. Surfaces opt in by switching to the new wrappers, so this PR is the abstraction plus logs adoption; broader migration is a follow-up. - Two `// prettier-ignore` lines (`apps/studio/pages/_app.tsx`, `apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx`) work around a pre-existing local-tooling issue where `prettier-plugin-sql-cst` strips angle-bracket type arguments under certain conditions. Project's pinned prettier (3.8.1) does not strip; the issue surfaces with a globally-installed prettier. Worth tracking separately. - Hydration: `guessLocalTimezone()` and `useLocalStorageQuery` are client-only. Studio is mostly CSR via the Pages Router, but any SSR'd `<TimestampInfo>` may briefly render in the server's tz before client hydration. Existing behavior already had this mismatch with `.local()`; this PR does not regress it. - Backend timestamps round-tripped through query params and mutations stay UTC. The picker is display-only. ## How to test - Run `pnpm dev:studio`, sign in. - Open the user avatar dropdown (top right). Hover Timezone. - Search for "tokyo", pick `(UTC+09:00) Osaka, Sapporo, Tokyo`. - Open any project, navigate to Logs (e.g. `Project > Logs > Edge Functions`). Hover a log row's timestamp; the popover should show UTC, the chosen tz (`Asia/Tokyo`), and the relative time. Visible cell text should be in JST. - Visit any page that uses `<TimestampInfo>` (Database > Backups, Project Pause state, Edge Function details). Same tooltip should reflect Asia/Tokyo. - Refresh the page; timezone is still Asia/Tokyo. - Reopen the picker, choose Auto detect; timestamps revert to browser local. - Run `pnpm --filter studio test lib/datetime.test.ts`. 17 tests should pass. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Timezone selector added to the user menu with auto-detect and manual override * App-wide timezone provider and hooks plus a shared timezone catalog for consistent timezone-aware display * Timestamp components accept an optional timezone prop and respect user preference (persisted) * **Bug Fixes / Improvements** * Logs and timestamp displays now use the new timezone formatting hooks * **Tests** * Added comprehensive datetime and timezone catalog tests * **Telemetry** * Telemetry event added for timezone picker interactions <!-- end of auto-generated comment: release notes by coderabbit.ai -->
173 lines
5.2 KiB
TypeScript
173 lines
5.2 KiB
TypeScript
import { User } from 'lucide-react'
|
|
import { cn } from 'ui'
|
|
|
|
import { LOG_TYPES, METHODS, STATUS_CODE_LABELS } from './UnifiedLogs.constants'
|
|
import { ColumnSchema } from './UnifiedLogs.schema'
|
|
import { LogsMeta, SheetField } from './UnifiedLogs.types'
|
|
import { getLevelLabel } from './UnifiedLogs.utils'
|
|
import { LEVELS } from '@/components/ui/DataTable/DataTable.constants'
|
|
import { DataTableFilterField, Option } from '@/components/ui/DataTable/DataTable.types'
|
|
import { getLevelColor } from '@/components/ui/DataTable/DataTable.utils'
|
|
import { useFormatDateTime } from '@/lib/datetime'
|
|
|
|
const DateCell = (props: { date: ColumnSchema['date'] }) => {
|
|
const formatDateTime = useFormatDateTime()
|
|
const month = formatDateTime(props.date, 'MMM')
|
|
const day = formatDateTime(props.date, 'DD')
|
|
const year = formatDateTime(props.date, 'YYYY')
|
|
const time = formatDateTime(props.date, 'HH:mm:ss')
|
|
|
|
return (
|
|
<div className="font-mono whitespace-nowrap flex items-center gap-1 justify-end">
|
|
<span>{month}</span>
|
|
<span className="text-foreground/50">·</span>
|
|
<span>{day}</span>
|
|
<span className="text-foreground/50">·</span>
|
|
<span>{year}</span>
|
|
<span className="text-foreground/50">·</span>
|
|
<span>{time}</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// instead of filterFields, maybe just 'fields' with a filterDisabled prop?
|
|
// that way, we could have 'message' or 'headers' field with label and value as well as type!
|
|
export const filterFields = [
|
|
{
|
|
label: 'Time Range',
|
|
value: 'date',
|
|
type: 'timerange',
|
|
defaultOpen: true,
|
|
commandDisabled: true,
|
|
},
|
|
{
|
|
label: 'Log Type',
|
|
value: 'log_type',
|
|
type: 'checkbox',
|
|
defaultOpen: true,
|
|
options: LOG_TYPES.map((type) => ({ label: type, value: type })),
|
|
component: (props: Option) => {
|
|
return (
|
|
<div className="flex w-full items-center justify-between gap-2">
|
|
<span className="capitalize text-foreground/70 group-hover:text-accent-foreground text-xs">
|
|
{props.label.replace('_', ' ')}
|
|
</span>
|
|
</div>
|
|
)
|
|
},
|
|
},
|
|
{
|
|
label: 'Status',
|
|
value: 'status',
|
|
type: 'checkbox',
|
|
defaultOpen: true,
|
|
options: [],
|
|
hasDynamicOptions: true,
|
|
component: (props: Option) => {
|
|
if (typeof props.value === 'boolean') return null
|
|
if (typeof props.value === 'undefined') return null
|
|
|
|
const statusValue = String(props.value)
|
|
const statusLabel = STATUS_CODE_LABELS[statusValue as keyof typeof STATUS_CODE_LABELS]
|
|
|
|
return (
|
|
<div className="flex items-center gap-2 w-full min-w-0">
|
|
<span className="shrink-0 text-foreground">{statusValue}</span>
|
|
{statusLabel && (
|
|
<span className="text-[0.7rem] text-foreground-lighter truncate" title={statusLabel}>
|
|
{statusLabel}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)
|
|
},
|
|
},
|
|
{
|
|
label: 'Level',
|
|
value: 'level',
|
|
type: 'checkbox',
|
|
defaultOpen: true,
|
|
options: LEVELS.map((level) => ({ label: level, value: level })),
|
|
component: (props: Option) => {
|
|
// TODO: type `Option` with `options` values via Generics
|
|
const value = props.value as (typeof LEVELS)[number]
|
|
return (
|
|
<div className="flex w-full max-w-28 items-center justify-between gap-2">
|
|
<span className="capitalize text-foreground/70 group-hover:text-accent-foreground text-xs">
|
|
{props.label}
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
<div className={cn('h-2.5 w-2.5 rounded-[2px]', getLevelColor(value).bg)} />
|
|
<span className="text-xs text-muted-foreground/70">{getLevelLabel(value)}</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
},
|
|
},
|
|
{
|
|
label: 'Method',
|
|
value: 'method',
|
|
type: 'checkbox',
|
|
defaultOpen: true,
|
|
options: METHODS.map((method) => ({ label: method, value: method })),
|
|
component: (props: Option) => {
|
|
return (
|
|
<span className="truncate block text-[0.75rem]" title={props.value as string}>
|
|
{props.value}
|
|
</span>
|
|
)
|
|
},
|
|
},
|
|
{
|
|
label: 'Pathname',
|
|
value: 'pathname',
|
|
type: 'checkbox',
|
|
defaultOpen: false,
|
|
options: [],
|
|
hasDynamicOptions: true,
|
|
hasAsyncSearch: false,
|
|
component: (props: Option) => {
|
|
return (
|
|
<span className="truncate block w-full text-[0.75rem]" title={props.value as string}>
|
|
{props.value}
|
|
</span>
|
|
)
|
|
},
|
|
},
|
|
] satisfies DataTableFilterField<ColumnSchema>[]
|
|
|
|
export const sheetFields = [
|
|
{
|
|
id: 'id',
|
|
label: 'Request ID',
|
|
type: 'readonly',
|
|
skeletonClassName: 'w-64',
|
|
},
|
|
{
|
|
id: 'date',
|
|
label: 'Date',
|
|
type: 'timerange',
|
|
component: DateCell,
|
|
skeletonClassName: 'w-36',
|
|
},
|
|
{
|
|
id: 'auth_user',
|
|
label: 'Auth User',
|
|
type: 'readonly',
|
|
condition: (props) => Boolean(props.auth_user),
|
|
component: (props) => (
|
|
<div className="flex items-center gap-2">
|
|
<User size={14} className="text-foreground-lighter" />
|
|
<span className="font-mono">{props.auth_user}</span>
|
|
</div>
|
|
),
|
|
skeletonClassName: 'w-56',
|
|
},
|
|
{
|
|
id: 'pathname',
|
|
label: 'Pathname',
|
|
type: 'input',
|
|
skeletonClassName: 'w-56',
|
|
},
|
|
] satisfies SheetField<ColumnSchema, LogsMeta>[]
|