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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -548,6 +549,7 @@ function App() {

return (
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<LocaleProvider defaultLocale="auto" storageKey="vite-ui-locale">
<div className="app">
<main className="content">
{!token ? (
Expand Down Expand Up @@ -611,6 +613,7 @@ function App() {
</main>
<WindowDock />
</div>
</LocaleProvider>
</ThemeProvider>
)
}
Expand Down
112 changes: 112 additions & 0 deletions src/ui/src/components/locale/locale-provider.tsx
Original file line number Diff line number Diff line change
@@ -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<LocaleProviderState>(initialState)

export function LocaleProvider({
children,
defaultLocale = "auto",
storageKey = "vite-ui-locale",
...props
}: LocaleProviderProps) {
const [locale, setLocaleState] = useState<LocalePref>(() =>
readStoredPref(storageKey, defaultLocale),
)

useEffect(() => {
writeStoredPref(storageKey, locale)
}, [locale, storageKey])
Comment thread
fredrik-dahlgren marked this conversation as resolved.

const setLocale = useCallback(
(next: LocalePref) => {
setLocaleState(isValidPref(next) ? next : defaultLocale)
},
[defaultLocale],
)

const value = useMemo<LocaleProviderState>(
() => {
const auto = browserLocale()
return {
locale,
setLocale,
resolved: locale === "auto" ? auto : locale,
auto,
}
Comment thread
fredrik-dahlgren marked this conversation as resolved.
},
[locale, setLocale],
)

return (
<LocaleProviderContext.Provider {...props} value={value}>
{children}
</LocaleProviderContext.Provider>
)
}

// 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
}
Comment on lines +107 to +112
5 changes: 4 additions & 1 deletion src/ui/src/dashboard/CallFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -15,6 +16,7 @@ interface CallFlowProps {
}

export default function CallFlow({ items, timeZone, onClickMessage }: CallFlowProps) {
const { resolved: locale } = useLocale()
const {
filters,
setFilters,
Expand All @@ -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])
Expand Down
29 changes: 14 additions & 15 deletions src/ui/src/dashboard/MessageModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (
<dl className="grid grid-cols-2 gap-x-4 gap-y-1 text-[11px] sm:grid-cols-3">
Expand All @@ -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'
Expand All @@ -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('')
Expand Down Expand Up @@ -286,7 +285,7 @@ export default function MessageModal({ modal, onClose, timeZone }) {

{!loading && !error && (
<>
<MetaGrid data={data} timeZone={timeZone} />
<MetaGrid data={data} timeZone={timeZone} locale={locale} />

<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Expand Down
23 changes: 12 additions & 11 deletions src/ui/src/dashboard/OTLPLogRowModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand All @@ -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')

Expand All @@ -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 = '—'
Expand All @@ -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 =
Expand Down Expand Up @@ -200,7 +201,7 @@ export default function OTLPLogRowModal({ modal, timeZone, onClose }) {
{sev}
</span>
<span className="font-mono text-muted-foreground">
{formatTs(row?.timestamp ?? row?.TIMESTAMP, timeZone)}
{formatTs(row?.timestamp ?? row?.TIMESTAMP, locale, timeZone)}
</span>
</div>
{bodyText ? (
Expand Down
15 changes: 8 additions & 7 deletions src/ui/src/dashboard/OTLPLogsTraceModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)

Expand All @@ -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)
}
Expand Down
15 changes: 8 additions & 7 deletions src/ui/src/dashboard/OTLPMetricsSeriesModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down
Loading
Loading