+ {/* Project legend — decodes the per-span dots in the waterfall below. */}
+
-
{dashboardsQuery.isPending ? (
diff --git a/web/src/pages/IpGeolocationDetail.tsx b/web/src/pages/IpGeolocationDetail.tsx
index 99f98bfb2..f1ad36fb9 100644
--- a/web/src/pages/IpGeolocationDetail.tsx
+++ b/web/src/pages/IpGeolocationDetail.tsx
@@ -1,5 +1,6 @@
import { useEffect } from 'react'
-import { useParams, useNavigate } from 'react-router'
+import { useParams } from 'react-router'
+import { useGoBack } from '@/hooks/useGoBack'
import { useQuery } from '@tanstack/react-query'
import { getIpGeolocationOptions } from '@/api/client/@tanstack/react-query.gen'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -13,7 +14,7 @@ import { usePageTitle } from '@/hooks/usePageTitle'
export default function IpGeolocationDetail() {
const { ip } = useParams<{ ip: string }>()
- const navigate = useNavigate()
+ const goBack = useGoBack('/proxy-logs')
const { setBreadcrumbs } = useBreadcrumbs()
usePageTitle(`IP Geolocation - ${ip}`)
@@ -36,7 +37,7 @@ export default function IpGeolocationDetail() {
}, [setBreadcrumbs])
const handleBack = () => {
- navigate(-1)
+ goBack()
}
if (error) {
diff --git a/web/src/pages/MetricAlertForm.tsx b/web/src/pages/MetricAlertForm.tsx
index ac87fca50..e45f12117 100644
--- a/web/src/pages/MetricAlertForm.tsx
+++ b/web/src/pages/MetricAlertForm.tsx
@@ -93,6 +93,7 @@ import {
import { useEffect, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { Link, useNavigate, useParams } from 'react-router'
+import { useGoBack } from '@/hooks/useGoBack'
import { toast } from 'sonner'
import { z } from 'zod'
@@ -215,6 +216,7 @@ interface AlertFormBodyProps {
*/
function AlertFormBody({ project, isEditing, id, existing }: AlertFormBodyProps) {
const navigate = useNavigate()
+ const goBack = useGoBack(`/projects/${project.slug}/metrics/alerts`)
const queryClient = useQueryClient()
const namesQuery = useQuery({
@@ -417,7 +419,7 @@ function AlertFormBody({ project, isEditing, id, existing }: AlertFormBodyProps)
return key === 'listAlerts' || key === 'getAlert'
},
})
- navigate(-1)
+ goBack()
},
})
@@ -492,7 +494,7 @@ function AlertFormBody({ project, isEditing, id, existing }: AlertFormBodyProps)
return (
-
navigate(-1)}>
+ goBack()}>
@@ -1323,7 +1325,7 @@ function AlertFormBody({ project, isEditing, id, existing }: AlertFormBodyProps)
? 'Save changes'
: 'Create alert'}
- navigate(-1)}>
+ goBack()}>
Cancel
diff --git a/web/src/pages/MetricAlerts.tsx b/web/src/pages/MetricAlerts.tsx
index aebacf2ec..25364f433 100644
--- a/web/src/pages/MetricAlerts.tsx
+++ b/web/src/pages/MetricAlerts.tsx
@@ -5,6 +5,7 @@ import {
} from '@/api/client/@tanstack/react-query.gen'
import type { OtelMetricAlertRuleResponse } from '@/api/client'
import { Button } from '@/components/ui/button'
+import { CreateActionButton } from '@/components/ui/create-action-button'
import {
DropdownMenu,
DropdownMenuContent,
@@ -101,10 +102,13 @@ export default function MetricAlerts({ project }: MetricAlertsProps) {
projectSlug={project.slug}
projectName={project.name}
/>
-
-
- New alert
-
+ }
+ className="gap-1.5"
+ />
diff --git a/web/src/pages/ServiceDataBrowser.tsx b/web/src/pages/ServiceDataBrowser.tsx
index 6979668b1..53e95ea7c 100644
--- a/web/src/pages/ServiceDataBrowser.tsx
+++ b/web/src/pages/ServiceDataBrowser.tsx
@@ -118,7 +118,14 @@ import {
Type,
X,
} from 'lucide-react'
-import { useEffect, useMemo, useRef, useState } from 'react'
+import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
+import { useDefaultLayout } from 'react-resizable-panels'
+import { useIsMobile } from '@/components/hooks/use-mobile'
+import {
+ ResizableHandle,
+ ResizablePanel,
+ ResizablePanelGroup,
+} from '@/components/ui/resizable'
import { useNavigate, useParams, useSearchParams } from 'react-router'
interface TreeNode {
@@ -174,6 +181,8 @@ export function ServiceDataBrowser() {
// Filter state (for sidebar tree only)
const [filterText, setFilterText] = useState('')
+ const isMobile = useIsMobile()
+
// Sidebar toggle state (mobile responsive) - default closed on mobile, open on desktop
const [isSidebarOpen, setIsSidebarOpen] = useState(
typeof window !== 'undefined' ? window.innerWidth >= 768 : true
@@ -336,6 +345,62 @@ export function ServiceDataBrowser() {
)
}
+ // Persisted split between the tree and the table. Keyed per service so a
+ // wide-schema database can keep a wider tree than a simple one.
+ const { defaultLayout, onLayoutChanged } = useDefaultLayout({
+ id: `data-browser-split:${id ?? 'unknown'}`,
+ onlySaveAfterUserInteractions: true,
+ })
+
+ /**
+ * Desktop splits the tree and the table with a draggable separator; mobile
+ * keeps the overlay drawer, where a resize handle has nothing to drag
+ * against.
+ *
+ * Deliberately a function, not a component: a component declared during
+ * render is a new type every render, so React would unmount and remount the
+ * whole tree (losing expansion state and the filter input's focus) on every
+ * keystroke.
+ */
+ const renderShell = (
+ sidebar: ReactNode,
+ overlay: ReactNode,
+ content: ReactNode
+ ) => {
+ if (isMobile) {
+ return (
+
+ {sidebar}
+ {overlay}
+ {content}
+
+ )
+ }
+ return (
+
+ {/* Numbers are pixels in v4; the string maxSize is a percentage, so
+ the tree can never crowd out the table it exists to navigate. */}
+
+ {sidebar}
+
+
+
+ {content}
+
+
+ )
+ }
+
// Apply filter handler
// Page 1 is always offset 0. Every other reset path in this file already
// calls `setPage(1)` (filter change, entity change, sort change, tab
@@ -1796,6 +1861,8 @@ export function ServiceDataBrowser() {
variant="ghost"
size="icon"
className="md:hidden"
+ aria-label="Toggle containers sidebar"
+ aria-expanded={isSidebarOpen}
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
>
@@ -1859,21 +1926,21 @@ export function ServiceDataBrowser() {