diff --git a/src/ui/src/App.tsx b/src/ui/src/App.tsx index e9f7c454..c8dca361 100644 --- a/src/ui/src/App.tsx +++ b/src/ui/src/App.tsx @@ -27,6 +27,7 @@ import { import DashboardHeader from './dashboard/DashboardHeader' import { fetchMeAvatarObjectUrl, handleUnauthorized } from './api' import { ThemeProvider } from "@/components/theme/theme-provider" +import { LocaleProvider } from "@/components/locale/locale-provider" import { WindowDock } from "@/components/ui/window-dock" import { useConfirm } from "@/components/ui/confirm-dialog" import { LoginPage } from './LoginPage' @@ -548,6 +549,7 @@ function App() { return ( +
{!token ? ( @@ -611,6 +613,7 @@ function App() {
+
) } diff --git a/src/ui/src/components/locale/locale-provider.tsx b/src/ui/src/components/locale/locale-provider.tsx new file mode 100644 index 00000000..ccb43eb6 --- /dev/null +++ b/src/ui/src/components/locale/locale-provider.tsx @@ -0,0 +1,112 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react" + +type LocalePref = "auto" | string + +type LocaleProviderProps = { + children: React.ReactNode + defaultLocale?: LocalePref + storageKey?: string +} + +type LocaleProviderState = { + locale: LocalePref + setLocale: (locale: LocalePref) => void + /** BCP-47 tag safe to pass to Intl APIs. When `locale` is 'auto', equals the browser default. */ + resolved: string + /** What 'auto' would resolve to right now, independent of the current selection. */ + auto: string +} + +/** Accept the 'auto' sentinel or any string Intl.Locale can parse. */ +function isValidPref(value: string): boolean { + if (value === "auto") return true + try { + new Intl.Locale(value) + return true + } catch { + return false + } +} + +function browserLocale(): string { + if (typeof navigator === "undefined") return "en-US" + return navigator.languages?.[0] || navigator.language || "en-US" +} + +/** Read+validate the stored pref. Falls back to `fallback` on missing/invalid/inaccessible storage. */ +function readStoredPref(storageKey: string, fallback: LocalePref): LocalePref { + try { + const stored = localStorage.getItem(storageKey) + if (stored && isValidPref(stored)) return stored + } catch { + // localStorage can throw when disabled (e.g. private mode, security policy). + } + return fallback +} + +/** Best-effort persist. Storage failures are silently ignored so the in-memory state still works. */ +function writeStoredPref(storageKey: string, value: LocalePref): void { + try { + localStorage.setItem(storageKey, value) + } catch { + // Quota exceeded / storage disabled — preference will be session-only. + } +} + +const initialState: LocaleProviderState = { + locale: "auto", + setLocale: () => null, + resolved: "en-US", + auto: "en-US", +} + +const LocaleProviderContext = createContext(initialState) + +export function LocaleProvider({ + children, + defaultLocale = "auto", + storageKey = "vite-ui-locale", + ...props +}: LocaleProviderProps) { + const [locale, setLocaleState] = useState(() => + readStoredPref(storageKey, defaultLocale), + ) + + useEffect(() => { + writeStoredPref(storageKey, locale) + }, [locale, storageKey]) + + const setLocale = useCallback( + (next: LocalePref) => { + setLocaleState(isValidPref(next) ? next : defaultLocale) + }, + [defaultLocale], + ) + + const value = useMemo( + () => { + const auto = browserLocale() + return { + locale, + setLocale, + resolved: locale === "auto" ? auto : locale, + auto, + } + }, + [locale, setLocale], + ) + + return ( + + {children} + + ) +} + +// eslint-disable-next-line react-refresh/only-export-components +export const useLocale = () => { + const context = useContext(LocaleProviderContext) + if (context === undefined) + throw new Error("useLocale must be used within a LocaleProvider") + return context +} diff --git a/src/ui/src/dashboard/CallFlow.tsx b/src/ui/src/dashboard/CallFlow.tsx index ad890dd5..d6c8bb31 100644 --- a/src/ui/src/dashboard/CallFlow.tsx +++ b/src/ui/src/dashboard/CallFlow.tsx @@ -7,6 +7,7 @@ import { FlowItem } from './flow/FlowItem' import type { FlowItemData, RawMessage } from './flow/flow-data' import { buildFlow, buildCallIdLegend } from './flow/flow-data' import { useFlowFilters } from './flow/useFlowFilters' +import { useLocale } from '@/components/locale/locale-provider' interface CallFlowProps { items: RawMessage[] | null | undefined @@ -15,6 +16,7 @@ interface CallFlowProps { } export default function CallFlow({ items, timeZone, onClickMessage }: CallFlowProps) { + const { resolved: locale } = useLocale() const { filters, setFilters, @@ -30,8 +32,9 @@ export default function CallFlow({ items, timeZone, onClickMessage }: CallFlowPr buildFlow(filteredItems, { timeZone, grouping: filters.hostGrouping, + locale, }), - [filteredItems, timeZone, filters.hostGrouping], + [filteredItems, timeZone, filters.hostGrouping, locale], ) const callIds = useMemo(() => buildCallIdLegend(items), [items]) diff --git a/src/ui/src/dashboard/MessageModal.tsx b/src/ui/src/dashboard/MessageModal.tsx index 171ba0c2..0bc552ff 100644 --- a/src/ui/src/dashboard/MessageModal.tsx +++ b/src/ui/src/dashboard/MessageModal.tsx @@ -7,6 +7,7 @@ import { FloatingWindow } from '@/components/ui/floating-window' import { ScrollArea } from '@/components/ui/scroll-area' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { displayDstIp, displaySrcIp } from '@/lib/ipAliasDisplay' +import { useLocale } from '@/components/locale/locale-provider' function escapeHtml(str) { if (typeof str !== 'string') return str @@ -158,30 +159,27 @@ function parseTimestampValue(value) { return null } -function formatDateTime(value, timeZone, dateOnly = false) { +function formatDateTime(value, locale, timeZone, dateOnly = false) { const date = parseTimestampValue(value) if (!date) return value const options = { year: 'numeric', - month: '2-digit', - day: '2-digit', + month: 'numeric', + day: 'numeric', ...(dateOnly ? {} : { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', fractionalSecondDigits: 3, - hour12: false, }), } - const formatter = timeZone && timeZone !== 'local' - ? new Intl.DateTimeFormat('en-GB', { ...options, timeZone }) - : new Intl.DateTimeFormat('en-GB', options) - return formatter.format(date).replace(',', '') + if (timeZone && timeZone !== 'local') options.timeZone = timeZone + return new Intl.DateTimeFormat(locale, options).format(date) } -function MetaGrid({ data, timeZone }) { +function MetaGrid({ data, timeZone, locale }) { if (!data) return null return (
@@ -190,9 +188,9 @@ function MetaGrid({ data, timeZone }) { const display = raw === undefined ? '—' : field === 'timestamp' - ? formatDateTime(raw, timeZone) + ? formatDateTime(raw, locale, timeZone) : field === 'date' - ? formatDateTime(raw, timeZone, true) + ? formatDateTime(raw, locale, timeZone, true) : field === 'src_ip' ? displaySrcIp(data) : field === 'dst_ip' @@ -218,6 +216,7 @@ function MetaGrid({ data, timeZone }) { } export default function MessageModal({ modal, onClose, timeZone }) { + const { resolved: locale } = useLocale() const [decoded, setDecoded] = React.useState(null) const [decoding, setDecoding] = React.useState(false) const [decodeError, setDecodeError] = React.useState('') @@ -286,7 +285,7 @@ export default function MessageModal({ modal, onClose, timeZone }) { {!loading && !error && ( <> - +
diff --git a/src/ui/src/dashboard/OTLPLogRowModal.tsx b/src/ui/src/dashboard/OTLPLogRowModal.tsx index 0fd50e7b..66da1bd0 100644 --- a/src/ui/src/dashboard/OTLPLogRowModal.tsx +++ b/src/ui/src/dashboard/OTLPLogRowModal.tsx @@ -4,6 +4,7 @@ import { FloatingWindow } from '@/components/ui/floating-window' import { ScrollArea } from '@/components/ui/scroll-area' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { cn } from '@/lib/utils' +import { useLocale } from '@/components/locale/locale-provider' function escapeHtml(str) { if (typeof str !== 'string') return str @@ -79,23 +80,22 @@ function severityBadgeClass(label) { return 'border-border bg-card text-foreground' } -function formatTs(val, timeZone) { +function formatTs(val, locale, timeZone) { if (!val) return '—' try { const d = val instanceof Date ? val : new Date(val) if (Number.isNaN(d.getTime())) return String(val) const opts = { year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', fractionalSecondDigits: 3, - hour12: false, } if (timeZone && timeZone !== 'local') opts.timeZone = timeZone - return new Intl.DateTimeFormat('en-GB', opts).format(d).replace(',', '') + return new Intl.DateTimeFormat(locale, opts).format(d) } catch { return String(val) } @@ -120,6 +120,7 @@ const DETAIL_PRIORITY = [ ] export default function OTLPLogRowModal({ modal, timeZone, onClose }) { + const { resolved: locale } = useLocale() const { modalKey, row } = modal const [jsonTab, setJsonTab] = useState('details') @@ -134,7 +135,7 @@ export default function OTLPLogRowModal({ modal, timeZone, onClose }) { seen.add(k) const v = row[k] let display = v - if (k === 'timestamp' || k === 'TIMESTAMP') display = formatTs(v, timeZone) + if (k === 'timestamp' || k === 'TIMESTAMP') display = formatTs(v, locale, timeZone) else if (v != null && typeof v === 'object') display = JSON.stringify(v) else if (v != null) display = String(v) else display = '—' @@ -159,7 +160,7 @@ export default function OTLPLogRowModal({ modal, timeZone, onClose }) { } const body = row?.body ?? row?.BODY ?? '' return { detailEntries: entries, jsonText: text, bodyText: String(body ?? '') } - }, [row, timeZone]) + }, [row, timeZone, locale]) const traceId = row?.trace_id ?? row?.TRACE_ID ?? '' const shortTrace = @@ -200,7 +201,7 @@ export default function OTLPLogRowModal({ modal, timeZone, onClose }) { {sev} - {formatTs(row?.timestamp ?? row?.TIMESTAMP, timeZone)} + {formatTs(row?.timestamp ?? row?.TIMESTAMP, locale, timeZone)}
{bodyText ? ( diff --git a/src/ui/src/dashboard/OTLPLogsTraceModal.tsx b/src/ui/src/dashboard/OTLPLogsTraceModal.tsx index e95a36ec..0b2eb8f1 100644 --- a/src/ui/src/dashboard/OTLPLogsTraceModal.tsx +++ b/src/ui/src/dashboard/OTLPLogsTraceModal.tsx @@ -5,6 +5,7 @@ import { ScrollArea } from '@/components/ui/scroll-area' import { Alert, AlertDescription } from '@/components/ui/alert' import { cn } from '@/lib/utils' import OTLPLogRowModal from './OTLPLogRowModal' +import { useLocale } from '@/components/locale/locale-provider' /** OpenTelemetry SeverityNumber ranges (stable mapping when severity_text is empty). */ function severityFromNumber(n) { @@ -53,6 +54,7 @@ function rowTimestampMs(row) { } export default function OTLPLogsTraceModal({ modal, timeZone, onClose }) { + const { resolved: locale } = useLocale() const { modalKey, traceId, loading, items, error } = modal const [detailModal, setDetailModal] = useState(null) @@ -69,16 +71,15 @@ export default function OTLPLogsTraceModal({ modal, timeZone, onClose }) { if (Number.isNaN(d.getTime())) return String(val) const opts = { year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', fractionalSecondDigits: 3, - hour12: false, } if (timeZone && timeZone !== 'local') opts.timeZone = timeZone - return new Intl.DateTimeFormat('en-GB', opts).format(d).replace(',', '') + return new Intl.DateTimeFormat(locale, opts).format(d) } catch { return String(val) } diff --git a/src/ui/src/dashboard/OTLPMetricsSeriesModal.tsx b/src/ui/src/dashboard/OTLPMetricsSeriesModal.tsx index 3f665385..53a41d54 100644 --- a/src/ui/src/dashboard/OTLPMetricsSeriesModal.tsx +++ b/src/ui/src/dashboard/OTLPMetricsSeriesModal.tsx @@ -20,6 +20,7 @@ import { normalizeOtlpMetricChartType, rowTimestampMs, } from './otlpMetricsSeriesChart' +import { useLocale } from '@/components/locale/locale-provider' function formatValue(row) { const vd = row?.value_double ?? row?.VALUE_DOUBLE @@ -30,6 +31,7 @@ function formatValue(row) { } export default function OTLPMetricsSeriesModal({ modal, timeZone, onClose }) { + const { resolved: locale } = useLocale() const { modalKey, metricName, loading, items, error } = modal const chartElRef = useRef(null) const disposeRef = useRef(null) @@ -75,16 +77,15 @@ export default function OTLPMetricsSeriesModal({ modal, timeZone, onClose }) { if (Number.isNaN(d.getTime())) return String(val) const opts = { year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', fractionalSecondDigits: 3, - hour12: false, } if (timeZone && timeZone !== 'local') opts.timeZone = timeZone - return new Intl.DateTimeFormat('en-GB', opts).format(d).replace(',', '') + return new Intl.DateTimeFormat(locale, opts).format(d) } catch { return String(val) } diff --git a/src/ui/src/dashboard/OTLPTraceModal.tsx b/src/ui/src/dashboard/OTLPTraceModal.tsx index 5476db2c..79fb3ae2 100644 --- a/src/ui/src/dashboard/OTLPTraceModal.tsx +++ b/src/ui/src/dashboard/OTLPTraceModal.tsx @@ -7,6 +7,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Alert, AlertDescription } from '@/components/ui/alert' import { Input } from '@/components/ui/input' import { cn } from '@/lib/utils' +import { useLocale } from '@/components/locale/locale-provider' function isEmptyParentSpanId(v) { if (v == null) return true @@ -351,6 +352,7 @@ function TraceMinimap({ items, traceMinMs, rangeMs, dark }) { } export default function OTLPTraceModal({ modal, timeZone, onClose }) { + const { resolved: locale } = useLocale() const { modalKey, traceId, loading, items, error } = modal const byParent = useMemo(() => buildSpanForest(items), [items]) const [selected, setSelected] = useState(null) @@ -432,16 +434,15 @@ export default function OTLPTraceModal({ modal, timeZone, onClose }) { const d = new Date(ms) const opts = { year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', fractionalSecondDigits: 3, - hour12: false, } if (timeZone && timeZone !== 'local') opts.timeZone = timeZone - return new Intl.DateTimeFormat('en-GB', opts).format(d).replace(',', '') + return new Intl.DateTimeFormat(locale, opts).format(d) } catch { return String(ms) } diff --git a/src/ui/src/dashboard/QosPanel.tsx b/src/ui/src/dashboard/QosPanel.tsx index a6383d68..e4c1dd8f 100644 --- a/src/ui/src/dashboard/QosPanel.tsx +++ b/src/ui/src/dashboard/QosPanel.tsx @@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { cn } from '@/lib/utils' import { qosRouteArrow } from '@/lib/ipAliasDisplay' +import { useLocale } from '@/components/locale/locale-provider' const METRIC_COLORS_RTCP = { packets: { bg: 'rgba(244, 67, 54, 0.5)', border: 'rgba(244, 67, 54, 1)' }, @@ -102,18 +103,17 @@ function eventTimeMs(row) { return 0 } -function formatAxisTime(unixSec, timeZone) { +function formatAxisTime(unixSec, timeZone, locale) { const ms = unixSec * 1000 if (!Number.isFinite(ms)) return '—' const opts = { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: false, + hour: 'numeric', + minute: 'numeric', + second: 'numeric', } try { if (timeZone && timeZone !== 'local') { - return new Intl.DateTimeFormat('en-GB', { ...opts, timeZone }).format(new Date(ms)).replace(',', '') + return new Intl.DateTimeFormat(locale, { ...opts, timeZone }).format(new Date(ms)) } const d = new Date(ms) return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}:${String(d.getUTCSeconds()).padStart(2, '0')}` @@ -255,7 +255,7 @@ function computeStats(allPoints, metricKeys) { return stats } -function CombinedChart({ allPoints, metricKeys, colors, streams, height, chartType, timeZone }) { +function CombinedChart({ allPoints, metricKeys, colors, streams, height, chartType, timeZone, locale }) { const containerRef = useRef(null) const chartRef = useRef(null) const rafRef = useRef(null) @@ -319,7 +319,7 @@ function CombinedChart({ allPoints, metricKeys, colors, streams, height, chartTy grid: { stroke: gridStroke, width: 1 }, ticks: { stroke: gridStroke, width: 1 }, font: '10px Inter, sans-serif', - values: (u, vals) => vals.map(v => formatAxisTime(v, timeZone)), + values: (u, vals) => vals.map(v => formatAxisTime(v, timeZone, locale)), gap: 4, }, { @@ -340,7 +340,7 @@ function CombinedChart({ allPoints, metricKeys, colors, streams, height, chartTy if (chartRef.current) chartRef.current.destroy() chartRef.current = new uPlot(opts, seriesData, el) - }, [allPoints, metricKeys, colors, streams, height, chartType, timeZone]) + }, [allPoints, metricKeys, colors, streams, height, chartType, timeZone, locale]) useEffect(() => { narrowWidthRafAttempts.current = 0 @@ -462,6 +462,7 @@ function StreamCheckboxes({ streams, metricKeys, colors, onChange }) { } export default function QosPanel({ qosData, timeZone }) { + const { resolved: locale } = useLocale() const [subTab, setSubTab] = useState('rtcp') const [chartType, setChartType] = useState('bar') const [rtcpStreams, setRtcpStreams] = useState([]) @@ -561,6 +562,7 @@ export default function QosPanel({ qosData, timeZone }) { height={QOS_CHART_HEIGHT} chartType={chartType} timeZone={timeZone} + locale={locale} /> diff --git a/src/ui/src/dashboard/TransactionModal.tsx b/src/ui/src/dashboard/TransactionModal.tsx index 461c1577..fe841b6d 100644 --- a/src/ui/src/dashboard/TransactionModal.tsx +++ b/src/ui/src/dashboard/TransactionModal.tsx @@ -24,6 +24,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { displayDstIp, displaySrcIp } from '@/lib/ipAliasDisplay' import { cn } from '@/lib/utils' import { newModalKey } from '@/lib/modalKey' +import { useLocale } from '@/components/locale/locale-provider' import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react' import { getMethodColor } from './flow-utils' import { resolveTimeRange } from './utils/resolveTimeRange' @@ -145,7 +146,7 @@ function compareMessageRowsForSort(colKey, dir, rowA, origA, rowB, origB) { return origA - origB } -function formatMessageTableCell(col, row, timeZone) { +function formatMessageTableCell(col, row, timeZone, locale) { let text if (col.accessor) { text = col.accessor(row) @@ -156,7 +157,7 @@ function formatMessageTableCell(col, row, timeZone) { } else if (col.key === 'dst_ip') { value = displayDstIp(row) } - if (col.format === 'datetime') value = formatDateTime(value, timeZone) + if (col.format === 'datetime') value = formatDateTime(value, locale, timeZone) text = value === undefined || value === null ? '' : String(value) } if (text === '' || text == null) return '—' @@ -240,23 +241,20 @@ function buildTransactionTabBody(sessionIdsForApi, items, timeRange, timeZone, e return body } -function formatDateTime(value, timeZone) { +function formatDateTime(value, locale, timeZone) { const date = parseTimestampValue(value) if (!date) return value const options = { year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', fractionalSecondDigits: 3, - hour12: false, } - const formatter = timeZone && timeZone !== 'local' - ? new Intl.DateTimeFormat('en-GB', { ...options, timeZone }) - : new Intl.DateTimeFormat('en-GB', options) - return formatter.format(date).replace(',', '') + if (timeZone && timeZone !== 'local') options.timeZone = timeZone + return new Intl.DateTimeFormat(locale, options).format(date) } /** Transaction → Events (HEP LOG / proto 100): fixed columns; table-fixed + col widths keep cells from overflowing. */ @@ -287,10 +285,10 @@ function pickFirstField(row, keys) { return null } -function formatEventsCell(value, col, timeZone) { +function formatEventsCell(value, col, timeZone, locale) { if (value == null || value === '') return '—' if (col.isDate) { - const s = formatDateTime(value, timeZone) + const s = formatDateTime(value, locale, timeZone) return s || '—' } if (typeof value === 'object') { @@ -317,7 +315,7 @@ function serializeRowJSONForWindow(row) { } } -function EventsTab({ data, isLoading, err, emptyLabel, timeZone }) { +function EventsTab({ data, isLoading, err, emptyLabel, timeZone, locale }) { const [detailRow, setDetailRow] = React.useState(null) const items = Array.isArray(data?.items) ? data.items : null @@ -378,7 +376,7 @@ function EventsTab({ data, isLoading, err, emptyLabel, timeZone }) { col.cellBreak, )} > - {formatEventsCell(pickFirstField(row, col.keys), col, timeZone)} + {formatEventsCell(pickFirstField(row, col.keys), col, timeZone, locale)} ))} @@ -435,6 +433,7 @@ function OtlpLogsTab({ resultData, hasSearched, timeZone, + locale, }) { const [detailRow, setDetailRow] = React.useState(null) const items = Array.isArray(resultData?.items) ? resultData.items : null @@ -510,7 +509,7 @@ function OtlpLogsTab({ col.cellBreak, )} > - {formatEventsCell(pickFirstField(row, col.keys), col, timeZone)} + {formatEventsCell(pickFirstField(row, col.keys), col, timeZone, locale)} ))} @@ -559,6 +558,7 @@ function OtlpLogsTab({ export default function TransactionModal({ modal, onClose, timeZone }) { + const { resolved: locale } = useLocale() const [activeTab, setActiveTab] = React.useState('messages') const [qosData, setQosData] = React.useState(null) const [qosLoading, setQosLoading] = React.useState(false) @@ -853,7 +853,7 @@ export default function TransactionModal({ modal, onClose, timeZone }) { {MESSAGE_TABLE_COLUMNS.map((col) => ( - {formatMessageTableCell(col, row, timeZone)} + {formatMessageTableCell(col, row, timeZone, locale)} ))} @@ -904,6 +904,7 @@ export default function TransactionModal({ modal, onClose, timeZone }) { err={eventsError} emptyLabel="No events available for this transaction." timeZone={timeZone} + locale={locale} /> @@ -917,6 +918,7 @@ export default function TransactionModal({ modal, onClose, timeZone }) { resultData={otlpLogData} hasSearched={otlpLogSearched} timeZone={timeZone} + locale={locale} /> diff --git a/src/ui/src/dashboard/components/TimeRangePicker.tsx b/src/ui/src/dashboard/components/TimeRangePicker.tsx index cc0cbd87..76a2a3fe 100644 --- a/src/ui/src/dashboard/components/TimeRangePicker.tsx +++ b/src/ui/src/dashboard/components/TimeRangePicker.tsx @@ -7,6 +7,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { ScrollArea } from '@/components/ui/scroll-area' import { cn } from '@/lib/utils' import type { CalendarPreset } from '../utils/resolveTimeRange' +import { useLocale } from '@/components/locale/locale-provider' const QUICK_RANGES = [ { label: 'Last 5 minutes', minutes: 5 }, @@ -71,18 +72,17 @@ function formatForInput(date, tz) { return `${v.year}-${v.month}-${v.day}T${hr}:${v.minute}:${v.second}` } -function formatDisplay(date: Date | null, tz: string) { +function formatDisplay(date: Date | null, tz: string, locale: string | undefined) { if (!date) return '—' const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: false, + hour: 'numeric', + minute: 'numeric', + second: 'numeric', } if (tz && tz !== 'local') opts.timeZone = tz - return new Intl.DateTimeFormat('en-GB', opts).format(date).replace(',', '') + return new Intl.DateTimeFormat(locale, opts).format(date) } function parseInputToMs(value, tz) { @@ -139,6 +139,7 @@ export default function TimeRangePicker({ timeZone, onTimeZoneChange, }: TimeRangePickerProps) { + const { resolved: locale } = useLocale() const [open, setOpen] = useState(false) const [tab, setTab] = useState('quick') const [absFrom, setAbsFrom] = useState('') @@ -207,7 +208,7 @@ export default function TimeRangePicker({ presetLabel || calendarLabel || (fromDate && toDate - ? `${formatDisplay(fromDate, timeZone)} — ${formatDisplay(toDate, timeZone)}` + ? `${formatDisplay(fromDate, timeZone, locale)} — ${formatDisplay(toDate, timeZone, locale)}` : 'Select time range') return ( diff --git a/src/ui/src/dashboard/flow/flow-data.ts b/src/ui/src/dashboard/flow/flow-data.ts index 9088825e..4ffa6d25 100644 --- a/src/ui/src/dashboard/flow/flow-data.ts +++ b/src/ui/src/dashboard/flow/flow-data.ts @@ -336,6 +336,7 @@ function indexOfHost( interface BuildOpts { timeZone?: string grouping: HostGrouping + locale?: string } export interface BuildResult { @@ -372,18 +373,17 @@ export function buildFlow(items: RawMessage[] | null | undefined, opts: BuildOpt const fmt: Intl.DateTimeFormatOptions = { year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', fractionalSecondDigits: 3, - hour12: false, } const formatter = opts.timeZone && opts.timeZone !== 'local' - ? new Intl.DateTimeFormat('en-GB', { ...fmt, timeZone: opts.timeZone }) - : new Intl.DateTimeFormat('en-GB', fmt) + ? new Intl.DateTimeFormat(opts.locale, { ...fmt, timeZone: opts.timeZone }) + : new Intl.DateTimeFormat(opts.locale, fmt) let prevTs = 0 const flowItems: FlowItemData[] = sorted.map((msg, idx) => { @@ -429,7 +429,7 @@ export function buildFlow(items: RawMessage[] | null | undefined, opts: BuildOpt const diffMs = ts - prevTs prevTs = ts - const fullDateStr = date ? formatter.format(date).replace(',', '') : '' + const fullDateStr = date ? formatter.format(date) : '' const diffStr = `+${diffMs.toFixed(1)}ms` let description = diff --git a/src/ui/src/dashboard/widgets/ClockPanel.tsx b/src/ui/src/dashboard/widgets/ClockPanel.tsx index e6b41b82..534c2427 100644 --- a/src/ui/src/dashboard/widgets/ClockPanel.tsx +++ b/src/ui/src/dashboard/widgets/ClockPanel.tsx @@ -13,6 +13,7 @@ import { import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { useLocale } from '@/components/locale/locale-provider' const MAX_ZONES = 8 @@ -80,12 +81,11 @@ function normalizeZones(config?: ClockPanelConfig): string[] { return ['local'] } -function formatClock(time: Date, timeZone: string) { +function formatClock(time: Date, timeZone: string, locale: string | undefined) { const opts: Intl.DateTimeFormatOptions = { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: false, + hour: 'numeric', + minute: 'numeric', + second: 'numeric', } const dateOpts: Intl.DateTimeFormatOptions = { weekday: 'short', @@ -98,13 +98,14 @@ function formatClock(time: Date, timeZone: string) { dateOpts.timeZone = timeZone } return { - time: new Intl.DateTimeFormat('en-GB', opts).format(time), - date: new Intl.DateTimeFormat('en-GB', dateOpts).format(time), + time: new Intl.DateTimeFormat(locale, opts).format(time), + date: new Intl.DateTimeFormat(locale, dateOpts).format(time), label: timeZone === 'local' ? 'Local' : timeZone, } } export default function ClockPanel({ config, onConfigChange }: ClockPanelProps) { + const { resolved: locale } = useLocale() const [now, setNow] = useState(() => new Date()) const [settingsOpen, setSettingsOpen] = useState(false) const [customTz, setCustomTz] = useState('') @@ -245,7 +246,7 @@ export default function ClockPanel({ config, onConfigChange }: ClockPanelProps)
{zones.map(z => { - const { time: timeStr, date, label } = formatClock(now, z) + const { time: timeStr, date, label } = formatClock(now, z, locale) return (
makeDateTimeFormatter(resolved, opts, timeZone), [key]) +} diff --git a/src/ui/src/settings/ProfilePanel.tsx b/src/ui/src/settings/ProfilePanel.tsx index b6b88327..5672bef3 100644 --- a/src/ui/src/settings/ProfilePanel.tsx +++ b/src/ui/src/settings/ProfilePanel.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useMemo, useState } from 'react' import { RefreshCw, Save } from 'lucide-react' import { Button } from '@/components/ui/button' import { @@ -13,10 +13,64 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Alert, AlertDescription } from '@/components/ui/alert' import { Badge } from '@/components/ui/badge' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' import { cn } from '@/lib/utils' import { SettingsPageHeader } from './SettingsPageHeader' import UserAvatar from '@/components/UserAvatar' import { apiPatch } from '../api' +import { useLocale } from '@/components/locale/locale-provider' + +const LOCALE_TAGS = [ + 'ar-EG', 'ar-SA', 'bg-BG', 'ca-ES', 'cs-CZ', 'da-DK', + 'de-AT', 'de-CH', 'de-DE', 'el-GR', + 'en-AU', 'en-CA', 'en-GB', 'en-IE', 'en-IN', 'en-NZ', 'en-US', 'en-ZA', + 'es-AR', 'es-ES', 'es-MX', 'et-EE', 'fi-FI', + 'fr-BE', 'fr-CA', 'fr-CH', 'fr-FR', + 'he-IL', 'hi-IN', 'hr-HR', 'hu-HU', 'id-ID', 'is-IS', + 'it-CH', 'it-IT', 'ja-JP', 'ko-KR', + 'lt-LT', 'lv-LV', 'ms-MY', + 'nb-NO', 'nl-BE', 'nl-NL', 'nn-NO', + 'pl-PL', 'pt-BR', 'pt-PT', + 'ro-RO', 'ru-RU', 'sk-SK', 'sl-SI', 'sr-RS', + 'sv-FI', 'sv-SE', + 'th-TH', 'tr-TR', 'uk-UA', 'vi-VN', + 'zh-CN', 'zh-HK', 'zh-TW', +] + +function localeLabel(tag: string, displayLocale: string): string { + try { + const loc = new Intl.Locale(tag) + const langNames = new Intl.DisplayNames([displayLocale, 'en'], { type: 'language' }) + const regionNames = new Intl.DisplayNames([displayLocale, 'en'], { type: 'region' }) + const lang = langNames.of(loc.language) || loc.language + const region = loc.region ? regionNames.of(loc.region) : '' + return region ? `${lang} (${region})` : lang + } catch { + return tag + } +} + +function previewDate(locale: string): string { + const sample = new Date(2026, 5, 1, 14, 30, 0) + try { + return new Intl.DateTimeFormat(locale, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + }).format(sample) + } catch { + return '' + } +} interface Me { username?: string @@ -48,6 +102,14 @@ export default function ProfilePanel({ const [status, setStatus] = useState('') const [error, setError] = useState('') const [saving, setSaving] = useState(false) + const { locale, setLocale, resolved, auto } = useLocale() + const localeChoices = useMemo(() => { + const collator = new Intl.Collator(resolved) + return LOCALE_TAGS + .map((tag) => ({ value: tag, label: localeLabel(tag, resolved) })) + .sort((a, b) => collator.compare(a.label, b.label)) + }, [resolved]) + const sample = previewDate(resolved) const saveProfile = async () => { if (readOnly) { @@ -176,6 +238,38 @@ export default function ProfilePanel({ + + + + Date & time format + + Controls how dates and times are rendered across the dashboard. Auto follows the + primary language of this browser ({auto}). Stored in this browser only. + + + +
+ + +
+
+ Sample: + {sample} +
+
+
) }