From e2dc788457b792fe30046b08d92295946203bf9b Mon Sep 17 00:00:00 2001 From: David Viejo Date: Thu, 6 Aug 2026 10:42:48 +0200 Subject: [PATCH 1/6] fix(web): give sole create actions an N shortcut and guard it behind overlays Every list page whose only primary action is 'create' now uses CreateActionButton, so the button renders a visible N badge and the shortcut actually works: Teams, Dashboards, Metric alerts, S3 sources, Notification providers, Routes, Monitors, Feature flags, Error alert rules and Email domains. Where a page shows the create button in both the header and the empty state, only the header registers the shortcut so a single keypress can't fire twice; where the header button is hidden while the list is empty (notification providers, email domains) the two are mutually exclusive and the empty-state button carries it instead. useKeyboardShortcut also now bails while a Radix dialog, alert dialog, menu or select is open. Previously a bare N pressed with an edit dialog up would navigate the page out from under it. --- .../backups/S3SourcesManagement.tsx | 12 +++++------ .../email/EmailDomainsManagement.tsx | 20 ++++++++++-------- .../monitoring/AlertRulesManagement.tsx | 10 +++++---- .../monitoring/ProvidersManagement.tsx | 21 +++++++++++-------- .../components/project/ProjectMonitors.tsx | 12 +++++------ .../project/flags/ProjectFeatureFlags.tsx | 10 ++++----- .../components/routes/RoutesManagement.tsx | 11 +++++----- web/src/hooks/useKeyboardShortcut.ts | 19 ++++++++++++++++- web/src/pages/Dashboards.tsx | 12 +++++++---- web/src/pages/MetricAlerts.tsx | 12 +++++++---- web/src/pages/Teams.tsx | 10 +++++---- 11 files changed, 90 insertions(+), 59 deletions(-) diff --git a/web/src/components/backups/S3SourcesManagement.tsx b/web/src/components/backups/S3SourcesManagement.tsx index c45fa95b9..724cb5b44 100644 --- a/web/src/components/backups/S3SourcesManagement.tsx +++ b/web/src/components/backups/S3SourcesManagement.tsx @@ -19,6 +19,7 @@ import { } from '@/components/ui/alert-dialog' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { CreateActionButton } from '@/components/ui/create-action-button' import { Dialog, DialogContent, @@ -344,12 +345,11 @@ export function S3SourcesManagement() { Configure S3 storage for backups

- + diff --git a/web/src/components/email/EmailDomainsManagement.tsx b/web/src/components/email/EmailDomainsManagement.tsx index 7f39066f3..e48cbbd88 100644 --- a/web/src/components/email/EmailDomainsManagement.tsx +++ b/web/src/components/email/EmailDomainsManagement.tsx @@ -13,6 +13,7 @@ import { } from '@/api/client' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { CreateActionButton } from '@/components/ui/create-action-button' import { CopyButton } from '@/components/ui/copy-button' import { Dialog, @@ -70,7 +71,6 @@ import { Globe, HelpCircle, Loader2, - Plus, RefreshCw, } from 'lucide-react' import { useMemo, useState } from 'react' @@ -531,19 +531,21 @@ export function EmailDomainsManagement() { title="No email domains configured" description="Add a domain to start sending emails. You'll need to configure DNS records for verification." action={ - + /* Mutually exclusive with the list-header button below, so the + `N` shortcut is only ever registered once. */ + setIsCreateDialogOpen(true)} + label="Add domain" + /> } /> ) : ( <>
- + setIsCreateDialogOpen(true)} + label="Add Domain" + />
diff --git a/web/src/components/monitoring/AlertRulesManagement.tsx b/web/src/components/monitoring/AlertRulesManagement.tsx index 809cb4a35..3147e8a92 100644 --- a/web/src/components/monitoring/AlertRulesManagement.tsx +++ b/web/src/components/monitoring/AlertRulesManagement.tsx @@ -6,6 +6,7 @@ import { } from '@/api/client/@tanstack/react-query.gen' import { AlertRuleResponse } from '@/api/client/types.gen' import { Button } from '@/components/ui/button' +import { CreateActionButton } from '@/components/ui/create-action-button' import { Card, CardContent, @@ -177,10 +178,11 @@ export function AlertRulesManagement({ projectId: fixedProjectId }: AlertRulesMa )} - + navigate('new')} + disabled={!projectId} + label="Add Rule" + />
diff --git a/web/src/components/monitoring/ProvidersManagement.tsx b/web/src/components/monitoring/ProvidersManagement.tsx index b8e3b52a3..2530bfdfc 100644 --- a/web/src/components/monitoring/ProvidersManagement.tsx +++ b/web/src/components/monitoring/ProvidersManagement.tsx @@ -12,6 +12,7 @@ import { import { revealNotificationProviderConfig } from '@/api/client/sdk.gen' import { NotificationProviderResponse } from '@/api/client/types.gen' import { Button } from '@/components/ui/button' +import { CreateActionButton } from '@/components/ui/create-action-button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Dialog, @@ -31,7 +32,7 @@ import { EmptyState } from '@/components/ui/empty-state' import { Switch } from '@/components/ui/switch' import { zodResolver } from '@hookform/resolvers/zod' import { useMutation, useQuery } from '@tanstack/react-query' -import { Bell, EllipsisVertical, Plus } from 'lucide-react' +import { Bell, EllipsisVertical } from 'lucide-react' import { useNavigate } from 'react-router' import { useMemo, useState } from 'react' import { useForm, useWatch } from 'react-hook-form' @@ -339,11 +340,13 @@ export function ProvidersManagement() {

+ {/* Only one of this and the empty-state button is ever mounted, so + the `N` shortcut is registered exactly once either way. */} {hasProviders && ( - + navigate('/monitoring/providers/add')} + label="Add Provider" + /> )} @@ -353,10 +356,10 @@ export function ProvidersManagement() { title="No notification providers configured" description="Add your first notification provider to start receiving alerts about your deployments and infrastructure." action={ - + navigate('/monitoring/providers/add')} + label="Add Provider" + /> } /> ) : ( diff --git a/web/src/components/project/ProjectMonitors.tsx b/web/src/components/project/ProjectMonitors.tsx index ac1f24c06..c25c738a6 100644 --- a/web/src/components/project/ProjectMonitors.tsx +++ b/web/src/components/project/ProjectMonitors.tsx @@ -8,6 +8,7 @@ import { } from '@/api/client/@tanstack/react-query.gen' import { ProjectResponse, MonitorResponse, EnvironmentResponse } from '@/api/client' import { Button } from '@/components/ui/button' +import { CreateActionButton } from '@/components/ui/create-action-button' import { Card, CardContent } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Skeleton } from '@/components/ui/skeleton' @@ -49,7 +50,6 @@ import { DialogFooter, DialogHeader, DialogTitle, - DialogTrigger, } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { @@ -397,13 +397,11 @@ export function ProjectMonitors({ project }: ProjectMonitorsProps) { Monitor your project's uptime and performance

+ setIsCreateDialogOpen(true)} + label="Create Monitor" + /> - - - Create Monitor diff --git a/web/src/components/project/flags/ProjectFeatureFlags.tsx b/web/src/components/project/flags/ProjectFeatureFlags.tsx index 81787e4e8..4fefbe7d9 100644 --- a/web/src/components/project/flags/ProjectFeatureFlags.tsx +++ b/web/src/components/project/flags/ProjectFeatureFlags.tsx @@ -6,6 +6,7 @@ import { } from '@/api/client/@tanstack/react-query.gen' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { CreateActionButton } from '@/components/ui/create-action-button' import { DropdownMenu, DropdownMenuContent, @@ -137,13 +138,12 @@ export function ProjectFeatureFlags({ project }: ProjectFeatureFlagsProps) { {tab === 'setup' ? 'Back to flags' : 'Setup'} - + label="New flag" + icon={} + /> diff --git a/web/src/components/routes/RoutesManagement.tsx b/web/src/components/routes/RoutesManagement.tsx index ea7f797e5..8d7ef9690 100644 --- a/web/src/components/routes/RoutesManagement.tsx +++ b/web/src/components/routes/RoutesManagement.tsx @@ -18,6 +18,7 @@ import { } from '@/components/ui/alert-dialog' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { CreateActionButton } from '@/components/ui/create-action-button' import { Card } from '@/components/ui/card' import { Dialog, @@ -161,12 +162,10 @@ export function RoutesManagement({ Configure custom domain routing and load balancing

- + {isLoading ? ( diff --git a/web/src/hooks/useKeyboardShortcut.ts b/web/src/hooks/useKeyboardShortcut.ts index d428bc4c6..255627173 100644 --- a/web/src/hooks/useKeyboardShortcut.ts +++ b/web/src/hooks/useKeyboardShortcut.ts @@ -20,9 +20,22 @@ interface KeyboardShortcutOptions { enabled?: boolean } +/** + * Selector for the Radix overlays that own the keyboard while they're open. + * A bare letter shortcut must not fire underneath one of these — otherwise + * pressing `N` inside an edit dialog navigates the page out from under it. + */ +const OPEN_OVERLAY_SELECTOR = [ + '[role="dialog"][data-state="open"]', + '[role="alertdialog"][data-state="open"]', + '[role="menu"][data-state="open"]', + '[role="listbox"][data-state="open"]', +].join(',') + /** * Hook to register keyboard shortcuts that trigger navigation or callbacks. - * Prevents triggering when user is typing in input fields. + * Prevents triggering when the user is typing in an input field or when a + * dialog, menu or select is open on top of the page. * * @example * // Navigate to create page on 'N' key @@ -51,9 +64,13 @@ export function useKeyboardShortcut({ target.tagName === 'TEXTAREA' || target.isContentEditable + // A dialog, menu or select on top of the page owns the keyboard. + const overlayOpen = document.querySelector(OPEN_OVERLAY_SELECTOR) !== null + // Only trigger if not typing and no modifier keys are pressed if ( !isTyping && + !overlayOpen && e.key.toLowerCase() === key.toLowerCase() && !e.metaKey && !e.ctrlKey && diff --git a/web/src/pages/Dashboards.tsx b/web/src/pages/Dashboards.tsx index 0b3b6e79f..3795dc313 100644 --- a/web/src/pages/Dashboards.tsx +++ b/web/src/pages/Dashboards.tsx @@ -11,6 +11,7 @@ import { // REGEN: OtelDashboardResponse comes from the regenerated types.gen. import type { OtelDashboardResponse } from '@/api/client' import { Button } from '@/components/ui/button' +import { CreateActionButton } from '@/components/ui/create-action-button' import { DropdownMenu, DropdownMenuContent, @@ -115,10 +116,13 @@ export default function Dashboards({ project }: DashboardsProps) { Saved metric dashboards for {project.name}.

- + } + className="gap-1.5 self-start" + /> {dashboardsQuery.isPending ? ( 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} /> - + } + className="gap-1.5" + /> diff --git a/web/src/pages/Teams.tsx b/web/src/pages/Teams.tsx index a16951840..582e23bf4 100644 --- a/web/src/pages/Teams.tsx +++ b/web/src/pages/Teams.tsx @@ -27,6 +27,7 @@ import { } from '@/components/ui/alert-dialog' import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' +import { CreateActionButton } from '@/components/ui/create-action-button' import { Dialog, DialogContent, @@ -260,10 +261,11 @@ export function Teams() { should reach. Projects with no grants stay open to everyone.

- + setCreateOpen(true)} + label="Create team" + className="self-start" + /> From 97e5f8bb0d1f61856c17815f16766b676f88c420 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Thu, 6 Aug 2026 11:25:53 +0200 Subject: [PATCH 2/6] fix(web): rank palette results by relevance, add Teams, resizable data-browser tree Command palette: - Results are one list ordered by score instead of fixed sections rendered in a fixed order. A weak keyword hit in Navigation used to outrank an exact title match in Settings purely because Navigation renders first ('work' put Sandboxes above Worker Nodes). - Title matches now score explicitly on top of the Fuse score. Fuse ranks a zero-distance *keyword* hit above a fuzzy *title* hit at any per-key weight, which put Users (tagged 'team') above Teams. Tags still rank, capped well below any title match. - Teams and Create Team were missing entirely; the Teams dialog is now opened by ?new=1 so the palette can deep-link it. - Project sub-pages are reachable from anywhere ('demo deploy' -> that project's Deployments), for a bounded set of pages per project. Data browser: - Selecting a different table kept the previous table's sort column and filter, so the query sorted on a field the new table has no column for. Tree selection went through a bare setSearchParams instead of navigateTo, which is what resets sort/filter/page for a new target. - The tree/table split is draggable on desktop via a shadcn resizable wrapper (react-resizable-panels v4 renames PanelGroup/PanelResizeHandle to Group/Separator, so the published shadcn snippet does not compile). Width persists per service. Mobile keeps the overlay drawer, where a resize handle has nothing to drag against. - The mobile tree toggle had no accessible name. --- web/src/components/command/CommandPalette.tsx | 451 +++++++++++++----- web/src/components/ui/resizable.tsx | 64 +++ web/src/pages/ServiceDataBrowser.tsx | 143 ++++-- web/src/pages/Teams.tsx | 17 +- 4 files changed, 507 insertions(+), 168 deletions(-) create mode 100644 web/src/components/ui/resizable.tsx diff --git a/web/src/components/command/CommandPalette.tsx b/web/src/components/command/CommandPalette.tsx index f547b8d42..240452204 100644 --- a/web/src/components/command/CommandPalette.tsx +++ b/web/src/components/command/CommandPalette.tsx @@ -17,6 +17,7 @@ import { import { usePluginsContext } from '@/contexts/PluginsContext' import { useCanViewAuditLogs } from '@/hooks/useAuditAccess' import { useFrecency } from '@/hooks/useFrecency' +import { normalizeFrecency } from '@/lib/frecency' import { resolvePluginIcon } from '@/lib/pluginIcons' import { useQuery } from '@tanstack/react-query' import Fuse from 'fuse.js' @@ -58,11 +59,18 @@ import { SunMoon, Upload, Users, + UsersRound, Wand2, Workflow, type LucideIcon, } from 'lucide-react' -import { useEffect, useMemo, useState, type ReactNode } from 'react' +import { + useCallback, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react' import { useLocation, useNavigate } from 'react-router' interface NavigationItem { @@ -80,6 +88,63 @@ interface CommandAction { run: () => void } +/** + * One row in the flat, relevance-ranked result list used while the user is + * typing. `category` is only a label here — it no longer decides ordering. + */ +interface RankedResult { + key: string + title: string + subtitle?: string + category: string + icon: ReactNode + score: number + run: () => void +} + +/** + * The project sub-pages reachable from anywhere, without first navigating into + * the project. Indexing every entry in `projectNavItems` for every project + * would be projects x ~50 rows, which floods the palette and makes the Fuse + * build noticeably slower; these are the ones worth jumping straight to. + */ +const CROSS_PROJECT_PAGE_URLS = new Set([ + 'project', + 'deployments', + 'environments', + 'runtime', + 'analytics', + 'errors', + 'storage', + 'monitors', + 'metrics', + 'settings/general', + 'settings/domains', + 'settings/environment-variables', +]) + +/** + * How well the query matches an item's *title*, on the same 0..1 scale as + * `combinedScore`, to be added on top of the Fuse relevance. + * + * Fuse alone ranks an exact *keyword* hit above a near-exact *title* hit: + * searching "team" put Users (which lists "team" as a keyword) above Teams. + * Per-key weights don't fix it either — a zero-distance keyword match beats a + * fuzzy title match at any weight — so the title match is scored explicitly. + */ +function titleBoost(title: string, query: string): number { + const t = title.toLowerCase().trim() + const q = query.toLowerCase().trim() + if (!q || !t) return 0 + if (t === q) return 0.6 + // "team" should still find "Teams", and "teams" should find "Team". + if (t === `${q}s` || `${t}s` === q) return 0.5 + if (t.startsWith(q)) return 0.35 + if (t.split(/\s+/).some((word) => word.startsWith(q))) return 0.25 + if (t.includes(q)) return 0.15 + return 0 +} + const commandActions: CommandAction[] = [ { id: 'toggle-theme', @@ -191,6 +256,26 @@ const settingsNavItems: NavigationItem[] = [ icon: Users, keywords: ['team', 'members', 'people', 'accounts'], }, + { + title: 'Teams', + url: '/settings/teams', + icon: UsersRound, + keywords: [ + 'teams', + 'groups', + 'access', + 'permissions', + 'grants', + 'members', + 'rbac', + ], + }, + { + title: 'Create Team', + url: '/settings/teams?new=1', + icon: UsersRound, + keywords: ['new', 'create', 'add', 'team', 'group', 'access'], + }, { title: 'Authentication', url: '/settings/auth', @@ -825,7 +910,26 @@ export function CommandPalette() { return () => document.removeEventListener('keydown', down) }, []) - const { record, blend, recent } = useFrecency() + const { record, getScore, recent } = useFrecency() + + /** + * Rank one candidate. The title match dominates; the Fuse score — which is + * also what makes keyword/alias hits findable at all — and frecency only + * break ties. + * + * The weights are the point: a keyword-only hit scores at most 0.25, while + * any title hit starts at 0.15 and an exact one reaches 0.60. So a tag still + * ranks (searching "team" finds Users, which is tagged `team`) but never + * above the page actually called Teams. + */ + const rank = useCallback( + (key: string, title: string, fuseScore: number | undefined, damp = 1) => + titleBoost(title, search) * damp + + // Fuse score: 0 = perfect match, 1 = no match. Invert to relevance. + (1 - (fuseScore ?? 0)) * 0.25 + + normalizeFrecency(getScore(key)) * 0.15, + [search, getScore] + ) const runCommand = (command: () => void) => { setOpen(false) @@ -940,6 +1044,42 @@ export function CommandPalette() { }) }, [globalSkills]) + // Every project x a bounded set of its pages, so the palette can jump + // straight to " Deployments" from anywhere instead of only offering + // project sub-pages once you are already inside that project. + const crossProjectItems = useMemo(() => { + const pages = projectNavItems.filter((item) => + CROSS_PROJECT_PAGE_URLS.has(item.url) + ) + return projects.flatMap((project) => + pages.map((page) => ({ + title: page.title, + projectName: project.name, + url: `/projects/${project.slug}/${page.url}`, + icon: page.icon, + // One field so a two-word query ("demo deploy") can match the project + // and the page at once. + searchText: `${project.name} ${project.slug} ${page.title} ${( + page.keywords ?? [] + ).join(' ')}`, + })) + ) + }, [projects]) + + const crossProjectFuse = useMemo(() => { + return new Fuse(crossProjectItems, { + keys: ['searchText'], + // Extended search so each whitespace-separated token must match + // ("demo deploy" = 'demo AND 'deploy). Plain fuzzy treats the query as + // one contiguous pattern and would miss " ". + useExtendedSearch: true, + threshold: 0.3, + includeScore: true, + shouldSort: true, + minMatchCharLength: 1, + }) + }, [crossProjectItems]) + const mcpFuse = useMemo(() => { return new Fuse(globalMcpServers, { keys: [ @@ -954,9 +1094,9 @@ export function CommandPalette() { }) }, [globalMcpServers]) - // Perform fuzzy search - const searchResults = useMemo(() => { - // Prepare project navigation with full URLs + // Browse mode: the full, sectioned lists shown when the input is empty. + // Once the user types, `rankedResults` takes over — see the comment there. + const browseResults = useMemo(() => { const projectNavigation = currentProjectSlug && currentProject ? [...projectNavItems, ...projectPluginNavItems].map((item) => ({ @@ -965,125 +1105,159 @@ export function CommandPalette() { })) : [] - if (!search) { - return { - navigation: mainNavItems, - settings: settingsNavItems, - observe: observeNavItems, - account: accountNavItems, - plugins: pluginNavItems, - projectNav: projectNavigation, - projects: projects, - skills: globalSkills, - mcpServers: globalMcpServers, - actions: commandActions, - } - } - - // Search navigation items - const navResults = navFuse.search(search) - const groupedNavResults = { - navigation: [] as Array<{ item: NavigationItem; score: number }>, - settings: [] as Array<{ item: NavigationItem; score: number }>, - observe: [] as Array<{ item: NavigationItem; score: number }>, - account: [] as Array<{ item: NavigationItem; score: number }>, - plugins: [] as Array<{ item: NavigationItem; score: number }>, - projectNav: [] as Array<{ item: NavigationItem; score: number }>, + return { + navigation: mainNavItems, + settings: settingsNavItems, + observe: observeNavItems.filter( + (item) => canViewAuditLogs || item.url !== '/audit-logs' + ), + account: accountNavItems, + plugins: pluginNavItems, + projectNav: projectNavigation, + projects, + skills: globalSkills, + mcpServers: globalMcpServers, + actions: commandActions, } + }, [ + projects, + globalSkills, + globalMcpServers, + pluginNavItems, + projectPluginNavItems, + currentProjectSlug, + currentProject, + canViewAuditLogs, + ]) - navResults.forEach((result) => { + // Search mode: ONE list ordered by relevance (blended with frecency). + // + // This used to be grouped by section and each section rendered in a fixed + // order, so a weak keyword hit in "Navigation" beat an exact title match in + // "Settings" purely because Navigation renders first — searching "work" put + // Sandboxes (keyword: workspace) above Worker Nodes. Section is a label + // here, not an ordering. + const rankedResults = useMemo(() => { + if (!search) return [] + const out: RankedResult[] = [] + for (const result of navFuse.search(search)) { const item = result.item - const baseItem: NavigationItem = { + const Icon = item.icon + out.push({ + key: item.url, title: item.title, - url: item.url, - icon: item.icon, - keywords: item.keywords, - } - // Fuse score: 0 = perfect match, 1 = no match. Invert to relevance. - const relevance = 1 - (result.score ?? 0) - const ranked = { item: baseItem, score: blend(item.url, relevance) } + subtitle: + item.category === 'Project' && currentProject + ? currentProject.name + : undefined, + category: item.category, + icon: , + score: rank(item.url, item.title, result.score), + run: () => navigate(item.url), + }) + } - if (item.category === 'Navigation') { - groupedNavResults.navigation.push(ranked) - } else if (item.category === 'Settings') { - groupedNavResults.settings.push(ranked) - } else if (item.category === 'Observe') { - groupedNavResults.observe.push(ranked) - } else if (item.category === 'Account') { - groupedNavResults.account.push(ranked) - } else if (item.category === 'Plugins') { - groupedNavResults.plugins.push(ranked) - } else if (item.category === 'Project') { - groupedNavResults.projectNav.push(ranked) - } - }) + for (const result of projectsFuse.search(search)) { + const project = result.item + out.push({ + key: `project:${project.id}`, + title: project.slug, + category: 'Project', + icon: ( + + + {project.name.charAt(0)} + + ), + score: rank(`project:${project.id}`, project.name, result.score), + run: () => navigate(`/projects/${project.slug}`), + }) + } - const sortByScore = ( - list: Array<{ item: NavigationItem; score: number }> - ): NavigationItem[] => - list.sort((a, b) => b.score - a.score).map((entry) => entry.item) + const tokens = search.trim().split(/\s+/).filter(Boolean) + if (tokens.length > 0) { + const extendedQuery = tokens.map((token) => `'${token}`).join(' ') + for (const result of crossProjectFuse.search(extendedQuery)) { + const item = result.item + const Icon = item.icon + out.push({ + key: item.url, + title: item.title, + subtitle: item.projectName, + category: 'Project', + icon: , + // Damped: a top-level page named X should beat every project's X. + score: rank(item.url, item.title, result.score, 0.6), + run: () => navigate(item.url), + }) + } + } - // Search projects, blended with frecency - const projectResults = projectsFuse.search(search) - const filteredProjects = projectResults - .map((result) => ({ - item: result.item, - score: blend(`project:${result.item.id}`, 1 - (result.score ?? 0)), - })) - .sort((a, b) => b.score - a.score) - .map((entry) => entry.item) + for (const result of skillsFuse.search(search)) { + const skill = result.item + out.push({ + key: `skill:${skill.slug}`, + title: skill.name, + subtitle: skill.slug, + category: 'Skill', + icon: , + score: rank(`skill:${skill.slug}`, skill.name, result.score), + run: () => navigate(`/skills/${skill.slug}`), + }) + } - // Search skills & mcp servers, blended with frecency - const filteredSkills = skillsFuse - .search(search) - .map((r) => ({ - item: r.item, - score: blend(`skill:${r.item.slug}`, 1 - (r.score ?? 0)), - })) - .sort((a, b) => b.score - a.score) - .map((entry) => entry.item) - const filteredMcp = mcpFuse - .search(search) - .map((r) => ({ - item: r.item, - score: blend(`mcp:${r.item.slug}`, 1 - (r.score ?? 0)), - })) - .sort((a, b) => b.score - a.score) - .map((entry) => entry.item) + for (const result of mcpFuse.search(search)) { + const mcp = result.item + out.push({ + key: `mcp:${mcp.slug}`, + title: mcp.name, + subtitle: mcp.slug, + category: 'MCP Server', + icon: , + score: rank(`mcp:${mcp.slug}`, mcp.name, result.score), + run: () => navigate(`/mcp-servers/${mcp.slug}`), + }) + } - const actions = commandActions.filter((action) => { + for (const action of commandActions) { const actionFuse = new Fuse([action.title, ...action.keywords], { threshold: 0.4, + includeScore: true, + }) + const hit = actionFuse.search(search)[0] + if (!hit) continue + const Icon = action.icon + out.push({ + key: `action:${action.id}`, + title: action.title, + category: 'Action', + icon: , + score: rank(`action:${action.id}`, action.title, hit.score), + run: action.run, }) - return actionFuse.search(search).length > 0 - }) - - return { - navigation: sortByScore(groupedNavResults.navigation), - settings: sortByScore(groupedNavResults.settings), - observe: sortByScore(groupedNavResults.observe), - account: sortByScore(groupedNavResults.account), - plugins: sortByScore(groupedNavResults.plugins), - projectNav: sortByScore(groupedNavResults.projectNav), - projects: filteredProjects, - skills: filteredSkills, - mcpServers: filteredMcp, - actions: actions, } + + // The current project's pages are indexed by both navFuse and the + // cross-project index; sorting first means the dedupe keeps the better + // scoring copy. + const seen = new Set() + return out + .sort((a, b) => b.score - a.score) + .filter((entry) => { + if (seen.has(entry.key)) return false + seen.add(entry.key) + return true + }) }, [ search, navFuse, projectsFuse, + crossProjectFuse, skillsFuse, mcpFuse, - projects, - globalSkills, - globalMcpServers, - pluginNavItems, - projectPluginNavItems, - currentProjectSlug, currentProject, - blend, + rank, + navigate, ]) // Resolve recent frecency keys into renderable items (icon + title + run). @@ -1193,10 +1367,10 @@ export function CommandPalette() { navigate, ]) - const projectResultsGroup = searchResults.projects.length > 0 && ( + const projectResultsGroup = browseResults.projects.length > 0 && ( <> - {searchResults.projects.map((project) => ( + {browseResults.projects.map((project) => ( @@ -1237,6 +1411,33 @@ export function CommandPalette() { No results found. + {/* Typing: one list, best match first, regardless of section. The + section name rides along as a right-aligned label so you can + still tell a project page from a settings page. */} + {search && rankedResults.length > 0 && ( + + {rankedResults.slice(0, 30).map((entry) => ( + runWithFrecency(entry.key, entry.run)} + className="flex items-center gap-2" + > + {entry.icon} + {entry.title} + {entry.subtitle && ( + + {entry.subtitle} + + )} + + {entry.category} + + + ))} + + )} + {/* Recent (frecency-ranked, only when input is empty) */} {!search && recentItems.length > 0 && ( <> @@ -1263,10 +1464,10 @@ export function CommandPalette() { )} {/* Project Navigation (shown first when on a project page) */} - {searchResults.projectNav.length > 0 && currentProject && ( + {!search && browseResults.projectNav.length > 0 && currentProject && ( <> - {searchResults.projectNav.map((item) => ( + {browseResults.projectNav.map((item) => ( 0 && ( + {!search && browseResults.navigation.length > 0 && ( <> - {searchResults.navigation.map((item) => ( + {browseResults.navigation.map((item) => ( 0 && ( + {!search && browseResults.settings.length > 0 && ( <> - {searchResults.settings.map((item) => ( + {browseResults.settings.map((item) => ( 0 && ( + {!search && browseResults.observe.length > 0 && ( <> - {searchResults.observe.map((item) => ( + {browseResults.observe.map((item) => ( 0 && ( + {!search && browseResults.plugins.length > 0 && ( <> - {searchResults.plugins.map((item) => ( + {browseResults.plugins.map((item) => ( 0 && ( + {!search && browseResults.account.length > 0 && ( <> - {searchResults.account.map((item) => ( + {browseResults.account.map((item) => ( 0 && ( + {!search && browseResults.skills.length > 0 && ( <> - {searchResults.skills.slice(0, 10).map((skill) => ( + {browseResults.skills.slice(0, 10).map((skill) => ( @@ -1424,10 +1623,10 @@ export function CommandPalette() { )} {/* MCP Servers */} - {searchResults.mcpServers.length > 0 && ( + {!search && browseResults.mcpServers.length > 0 && ( <> - {searchResults.mcpServers.slice(0, 10).map((mcp) => ( + {browseResults.mcpServers.slice(0, 10).map((mcp) => ( @@ -1453,9 +1652,9 @@ export function CommandPalette() { {!search && projectResultsGroup} {/* Actions */} - {searchResults.actions.length > 0 && ( + {!search && browseResults.actions.length > 0 && ( - {searchResults.actions.map((action) => ( + {browseResults.actions.map((action) => ( diff --git a/web/src/components/ui/resizable.tsx b/web/src/components/ui/resizable.tsx new file mode 100644 index 000000000..e7ab393d4 --- /dev/null +++ b/web/src/components/ui/resizable.tsx @@ -0,0 +1,64 @@ +import { GripVertical } from 'lucide-react' +import { Group, Panel, Separator } from 'react-resizable-panels' +import { cn } from '@/lib/utils' + +/** + * shadcn-style wrapper over react-resizable-panels. + * + * Note this targets the v4 API (`Group` / `Panel` / `Separator`), not the + * `PanelGroup` / `PanelResizeHandle` names used by the published shadcn + * snippet — those were renamed in v4, so copy/pasting that snippet fails. + * Sizes follow the v4 convention: numbers are pixels, strings are percentages. + */ +function ResizablePanelGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +const ResizablePanel = Panel + +/** + * `withHandle` draws the visible grip. Without it the separator is still + * draggable, just invisible until hover — fine between two panes that already + * have a border, but a grip is clearer when they don't. + */ +function ResizableHandle({ + withHandle, + className, + ...props +}: React.ComponentProps & { withHandle?: boolean }) { + return ( + + {withHandle && ( +
+ +
+ )} +
+ ) +} + +export { ResizablePanelGroup, ResizablePanel, ResizableHandle } diff --git a/web/src/pages/ServiceDataBrowser.tsx b/web/src/pages/ServiceDataBrowser.tsx index 6979668b1..468a4b649 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 @@ -1246,10 +1311,10 @@ export function ServiceDataBrowser() { !hasLoadedChildren if (isLeafContainer) { - // Update URL params - use replace to avoid page reload - setSearchParams({ path: node.path }, { replace: true }) - setPage(1) - commitActiveTab({ path: node.path, entity: undefined, page: 1 }) + // navigateTo (not a bare setSearchParams) so the previous container's + // sort column and filter don't follow us to a container that has no + // such field. + navigateTo(node.path) // Don't expand in tree, just select it // The main content area will show the entities table via ContainerEntitiesView @@ -1292,9 +1357,7 @@ export function ServiceDataBrowser() { } } else { // Different container - select it and expand if not already expanded - setSearchParams({ path: node.path }, { replace: true }) - setPage(1) - commitActiveTab({ path: node.path, entity: undefined, page: 1 }) + navigateTo(node.path) // If not currently expanded, expand it if (!isCurrentlyExpanded) { @@ -1322,17 +1385,13 @@ export function ServiceDataBrowser() { } } } else if (node.type === 'entity') { - // Update URL params for entity selection - use replace to avoid page reload + // Switching tables must drop the outgoing table's sort column and + // filter: they name fields the incoming table may not have, and the + // query then sorts/filters on something that doesn't exist. navigateTo + // resets sort/filter/page for the new target; a bare setSearchParams + // only moved the selection and left both behind. const parentPath = node.path.split('/').slice(0, -1).join('/') - setSearchParams( - { - path: parentPath, - entity: node.name, - }, - { replace: true } - ) - setPage(1) - commitActiveTab({ path: parentPath, entity: node.name, page: 1 }) + navigateTo(parentPath, node.name) // Close sidebar on mobile when selecting an entity if (window.innerWidth < 768) { @@ -1796,6 +1855,8 @@ export function ServiceDataBrowser() { variant="ghost" size="icon" className="md:hidden" + aria-label="Toggle containers sidebar" + aria-expanded={isSidebarOpen} onClick={() => setIsSidebarOpen(!isSidebarOpen)} > @@ -1859,21 +1920,21 @@ export function ServiceDataBrowser() { {/* Main content area with sidebar */} -
- {/* Sidebar - Tree View */} + {renderShell( + /* Sidebar - Tree View. On desktop the width comes from the resizable + panel, so the drawer/translate classes only apply on mobile. */
{/* Flush rail rather than a card: the tree is primary navigation, not a standalone object, and a card here only added a border, @@ -1960,25 +2021,27 @@ export function ServiceDataBrowser() {
- - - {/* Overlay for mobile when sidebar is open */} - {isSidebarOpen && ( + , + /* Overlay for mobile when the drawer is open */ + isSidebarOpen ? (
setIsSidebarOpen(false)} /> - )} - - {/* Main content. + ) : null, + /* Main content. `min-h-0` rather than a hardcoded `calc(100vh - 180px)`: the magic number assumed a fixed header height, so it drifted whenever the header wrapped (long service name, mobile) and left the pane either clipped or overflowing the viewport. With min-h-0 the flex child can shrink below its content, which is what lets the inner `overflow-y-auto` own the scroll — and lets the tree rail scroll - independently of it. */} -
+ independently of it. */ +
-
+ )} (null) usePageTitle('Teams') @@ -233,6 +233,19 @@ export function Teams() { setBreadcrumbs([{ label: 'Teams' }]) }, [setBreadcrumbs]) + // The URL owns the dialog, so the command palette (and any deep link) can + // open it with `?new=1` — there is no /teams/new route, creation is a + // dialog. Deriving it beats mirroring the param into state via an effect: + // no cascading render, and it still reacts when the palette navigates here + // while this page is already mounted. + const createOpen = searchParams.get('new') === '1' + const setCreateOpen = (open: boolean) => { + const next = new URLSearchParams(searchParams) + if (open) next.set('new', '1') + else next.delete('new') + setSearchParams(next, { replace: !open }) + } + const { data, isLoading, isError, error } = useQuery( listTeamsOptions({ query: { page: 1, page_size: 100 } }) ) From a4bd4e949dcb5911707ffdf70f320a7612fa2891 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Thu, 6 Aug 2026 12:01:44 +0200 Subject: [PATCH 3/6] fix(web): show unified-trace projects as a colour legend, not a slug per span MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-span project badge truncated to ~"galachain-gat…" on every row while still costing ~88px of the name column, which on narrow viewports is most of the space the span name has. Each row now carries only the colour dot, and a legend above the waterfall decodes the colours with the full slug; the name stays reachable via the dot's tooltip and aria-label. CrossProjectTraceDetail already had a legend. TraceDetail's inline unified view (?view=unified) did not — it tagged every span but never said what the colours meant, so that view gets one too. Legend entries drop the badge's 88px cap, since truncating the thing that decodes the dots defeats it. --- web/src/components/traces/ProjectBadge.tsx | 56 +++++++++++++++++++++- web/src/pages/CrossProjectTraceDetail.tsx | 27 ++++------- web/src/pages/TraceDetail.tsx | 14 ++++-- 3 files changed, 76 insertions(+), 21 deletions(-) diff --git a/web/src/components/traces/ProjectBadge.tsx b/web/src/components/traces/ProjectBadge.tsx index 12da12b67..d8888b680 100644 --- a/web/src/components/traces/ProjectBadge.tsx +++ b/web/src/components/traces/ProjectBadge.tsx @@ -21,7 +21,7 @@ export function projectColor(projectId: number): string { return PROJECT_COLORS[Math.abs(projectId) % PROJECT_COLORS.length] } -/** A colour-matched project badge used in the legend and on each span row. */ +/** A colour-matched project badge used in the legend and in detail panels. */ export function ProjectBadge({ projectId, name, @@ -49,3 +49,57 @@ export function ProjectBadge({ ) } + +/** + * Just the colour, for per-span use in the waterfall. + * + * Span rows are the one place the full badge does not pay for itself: the name + * column is already competing with indentation and the span name, so a slug + * like `galachain-gateway` truncates to `galachain-gat…` on every row and + * still costs ~88px. The colour carries the identity and `ProjectLegend` + * decodes it once at the top; the name stays reachable via the tooltip. + */ +export function ProjectDot({ + projectId, + name, + className, +}: { + projectId: number + name: string + className?: string +}) { + return ( + + ) +} + +/** Decodes the per-span dot colours. Required wherever `ProjectDot` is used. */ +export function ProjectLegend({ + projects, + className, +}: { + projects: Array<{ project_id: number; project_name: string }> + className?: string +}) { + if (projects.length === 0) return null + return ( +
+ Projects: + {projects.map((p) => ( + + ))} +
+ ) +} diff --git a/web/src/pages/CrossProjectTraceDetail.tsx b/web/src/pages/CrossProjectTraceDetail.tsx index 21aac3b8a..ac1220a80 100644 --- a/web/src/pages/CrossProjectTraceDetail.tsx +++ b/web/src/pages/CrossProjectTraceDetail.tsx @@ -18,7 +18,11 @@ import { kindLabel, statusIcon, } from '@/components/traces/SpanWaterfall' -import { ProjectBadge } from '@/components/traces/ProjectBadge' +import { + ProjectBadge, + ProjectDot, + ProjectLegend, +} from '@/components/traces/ProjectBadge' import { buildSpanTree, flattenTree } from '@/utils/spanTree' import { usePageTitle } from '@/hooks/usePageTitle' import { cn } from '@/lib/utils' @@ -158,12 +162,10 @@ export default function CrossProjectTraceDetail() { [selectedSpanId, flatSpans] ) + // Dot, not badge: the legend above decodes the colour, so each row keeps its + // width for the span name instead of repeating a truncated slug. const renderRowBadge = (span: SpanRecord) => ( - + ) if (isPending) { @@ -325,17 +327,8 @@ export default function CrossProjectTraceDetail() {
)} - {/* Project legend */} -
- Projects: - {data.projects.map((p) => ( - - ))} -
+ {/* Project legend — decodes the per-span dots in the waterfall below. */} + {/* Waterfall + selected-span detail */}
( - ) : undefined, @@ -769,6 +773,10 @@ export default function TraceDetail({ project }: TraceDetailProps) { /> )} + {/* Decodes the per-span dots. Only the unified view colours spans by + project, so the legend appears with it. */} + {usingUnified && } + {/* AI conversation jump — when this trace has GenAI (LLM) spans, the prompts/responses read far better in the dedicated AI view than in the raw waterfall below. One click, trace pre-selected. */} From 2d52d09ec1c91f04956274066de26ad025d6b1ad Mon Sep 17 00:00:00 2001 From: David Viejo Date: Thu, 6 Aug 2026 12:15:04 +0200 Subject: [PATCH 4/6] fix(web): make Back work on deep-linked pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit navigate(-1) silently does nothing when a page was opened directly — a shared URL, a new tab, a bookmark — because there is no earlier entry in the history session to pop. The user clicks Back and simply stays put, with no feedback. This is the common case for trace detail, which is exactly the kind of page people paste to each other. Adds useGoBack(fallback): react-router records its position in the session as window.history.state.idx, so when that is 0 (or null, on the first entry) there is nothing of ours behind us and we navigate to the fallback instead — which is where the button's label claims it goes anyway. In-app navigation is unchanged and still pops history. Applied to all 19 call sites across 8 files, each with the parent it belongs to: trace detail -> the project's trace list, the global unified trace -> a contributing project's trace list, alert/dashboard/metric-alert forms -> their lists, cron job -> the project's cron jobs, funnel -> the project's funnels, IP geolocation -> proxy logs. The form ones also covered the post-save navigate(-1), which stranded the user on a saved form. --- web/src/components/funnel/FunnelDetail.tsx | 6 ++-- .../project/settings/CronJobDetail.tsx | 7 ++-- web/src/hooks/useGoBack.ts | 36 +++++++++++++++++++ web/src/pages/AlertRuleForm.tsx | 15 ++++---- web/src/pages/CrossProjectTraceDetail.tsx | 19 +++++++--- web/src/pages/DashboardBuilder.tsx | 8 +++-- web/src/pages/IpGeolocationDetail.tsx | 7 ++-- web/src/pages/MetricAlertForm.tsx | 8 +++-- web/src/pages/TraceDetail.tsx | 8 +++-- 9 files changed, 84 insertions(+), 30 deletions(-) create mode 100644 web/src/hooks/useGoBack.ts diff --git a/web/src/components/funnel/FunnelDetail.tsx b/web/src/components/funnel/FunnelDetail.tsx index 2465a825c..c6827a828 100644 --- a/web/src/components/funnel/FunnelDetail.tsx +++ b/web/src/components/funnel/FunnelDetail.tsx @@ -21,7 +21,7 @@ import { format, subDays } from 'date-fns' import { ArrowLeft, Calendar as CalendarIcon } from 'lucide-react' import * as React from 'react' import { DateRange } from 'react-day-picker' -import { useNavigate } from 'react-router' +import { useGoBack } from '@/hooks/useGoBack' import { FunnelVisualization } from './FunnelVisualization' interface FunnelDetailProps { @@ -30,7 +30,7 @@ interface FunnelDetailProps { } export function FunnelDetail({ project, funnelId }: FunnelDetailProps) { - const navigate = useNavigate() + const goBack = useGoBack(`/projects/${project.slug}/analytics/funnels`) const [dateRange, setDateRange] = React.useState({ from: subDays(new Date(), 30), to: new Date(), @@ -59,7 +59,7 @@ export function FunnelDetail({ project, funnelId }: FunnelDetailProps) {
-
diff --git a/web/src/components/project/settings/CronJobDetail.tsx b/web/src/components/project/settings/CronJobDetail.tsx index 62c661237..d52b93566 100644 --- a/web/src/components/project/settings/CronJobDetail.tsx +++ b/web/src/components/project/settings/CronJobDetail.tsx @@ -21,7 +21,8 @@ import { TableHeader, TableRow, } from '@/components/ui/table' -import { useNavigate, useParams } from 'react-router' +import { useParams } from 'react-router' +import { useGoBack } from '@/hooks/useGoBack' import { format } from 'date-fns' interface CronJobDetailProps { @@ -29,7 +30,7 @@ interface CronJobDetailProps { } export function CronJobDetail({ project }: CronJobDetailProps) { - const navigate = useNavigate() + const goBack = useGoBack(`/projects/${project.slug}/settings/cron-jobs`) const { environmentId, cronId } = useParams<{ environmentId: string cronId: string @@ -63,7 +64,7 @@ export function CronJobDetail({ project }: CronJobDetailProps) { return (
-
diff --git a/web/src/hooks/useGoBack.ts b/web/src/hooks/useGoBack.ts new file mode 100644 index 000000000..b0cbc27f3 --- /dev/null +++ b/web/src/hooks/useGoBack.ts @@ -0,0 +1,36 @@ +import { useCallback } from 'react' +import { useNavigate } from 'react-router' + +/** + * Back navigation that still works on a deep link. + * + * `navigate(-1)` silently does nothing when the page was opened directly — a + * shared URL, a new tab, a bookmark — because there is no earlier entry in + * this history session to pop. The user clicks "Back" and stays exactly where + * they were, with no feedback. Self-hosted users hit this on every link + * someone pastes them. + * + * React Router records its position in the history session as + * `window.history.state.idx` (it is `0`, or `null` on the very first entry, + * when there is nothing of ours behind us). When there is nothing to pop, go + * to `fallback` — which is where the button's label claims it goes anyway. + * + * @param fallback Absolute path to use when there's no history to go back to. + * + * @example + * const goBack = useGoBack(`/projects/${project.slug}/traces`) + * + */ +export function useGoBack(fallback: string) { + const navigate = useNavigate() + + return useCallback(() => { + const idx = (window.history.state as { idx?: number | null } | null)?.idx + if (typeof idx === 'number' && idx > 0) { + navigate(-1) + return + } + // `replace` so the dead-end entry doesn't linger in the stack. + navigate(fallback, { replace: true }) + }, [navigate, fallback]) +} diff --git a/web/src/pages/AlertRuleForm.tsx b/web/src/pages/AlertRuleForm.tsx index 603cd12b1..bddb10baf 100644 --- a/web/src/pages/AlertRuleForm.tsx +++ b/web/src/pages/AlertRuleForm.tsx @@ -36,7 +36,8 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { ArrowLeft } from 'lucide-react' import { useMemo } from 'react' import { useForm } from 'react-hook-form' -import { useNavigate, useParams } from 'react-router' +import { useParams } from 'react-router' +import { useGoBack } from '@/hooks/useGoBack' import { toast } from 'sonner' import { z } from 'zod' @@ -82,9 +83,9 @@ interface AlertRuleFormProps { } export function AlertRuleForm({ projectId }: AlertRuleFormProps) { - const navigate = useNavigate() const queryClient = useQueryClient() - const { ruleId } = useParams() + const { ruleId, slug } = useParams() + const goBack = useGoBack(`/projects/${slug}/errors/alert-rules`) const isEditing = !!ruleId const { data: existingRule, isLoading: ruleLoading } = useQuery({ @@ -137,7 +138,7 @@ export function AlertRuleForm({ projectId }: AlertRuleFormProps) { onSuccess: () => { toast.success('Alert rule created') queryClient.invalidateQueries({ predicate: (query) => (query.queryKey[0] as Record)?._id === 'listAlertRules' }) - navigate(-1) + goBack() }, }) @@ -147,7 +148,7 @@ export function AlertRuleForm({ projectId }: AlertRuleFormProps) { onSuccess: () => { toast.success('Alert rule updated') queryClient.invalidateQueries({ predicate: (query) => (query.queryKey[0] as Record)?._id === 'listAlertRules' }) - navigate(-1) + goBack() }, }) @@ -202,7 +203,7 @@ export function AlertRuleForm({ projectId }: AlertRuleFormProps) { return (
-
@@ -455,7 +456,7 @@ export function AlertRuleForm({ projectId }: AlertRuleFormProps) { ? 'Update Rule' : 'Create Rule'} -
diff --git a/web/src/pages/CrossProjectTraceDetail.tsx b/web/src/pages/CrossProjectTraceDetail.tsx index ac1220a80..09fec9bc4 100644 --- a/web/src/pages/CrossProjectTraceDetail.tsx +++ b/web/src/pages/CrossProjectTraceDetail.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from 'react' -import { Link, useNavigate, useParams } from 'react-router' +import { Link, useParams } from 'react-router' +import { useGoBack } from '@/hooks/useGoBack' import { useQuery } from '@tanstack/react-query' import { getUnifiedTraceOptions } from '@/api/client/@tanstack/react-query.gen' import type { @@ -117,7 +118,6 @@ function UnifiedSpanDetail({ export default function CrossProjectTraceDetail() { const { traceId } = useParams() - const navigate = useNavigate() const { data, isPending, isError, error } = useQuery({ ...getUnifiedTraceOptions({ path: { trace_id: traceId || '' } }), @@ -135,6 +135,15 @@ export default function CrossProjectTraceDetail() { const projectName = (span: SpanRecord) => projectById.get(span.project_id)?.project_name ?? `Project ${span.project_id}` + // This view is global, so there is no single list it belongs to. The first + // contributing project's trace list is the closest thing; before the trace + // loads (and in the error state) fall back to the project list. + const goBack = useGoBack( + data?.projects[0] + ? `/projects/${data.projects[0].project_slug}/traces` + : '/projects' + ) + const spans: SpanRecord[] = useMemo( () => (data?.spans ?? []).map((a) => a.span), [data] @@ -192,7 +201,7 @@ export default function CrossProjectTraceDetail() {
@@ -345,7 +347,7 @@ export default function DashboardBuilder({ project }: DashboardBuilderProps) { 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 (
-
@@ -1323,7 +1325,7 @@ function AlertFormBody({ project, isEditing, id, existing }: AlertFormBodyProps) ? 'Save changes' : 'Create alert'} -
diff --git a/web/src/pages/TraceDetail.tsx b/web/src/pages/TraceDetail.tsx index 6e7bde896..c88748e41 100644 --- a/web/src/pages/TraceDetail.tsx +++ b/web/src/pages/TraceDetail.tsx @@ -65,6 +65,7 @@ import { } from 'lucide-react' import { useCallback, useMemo, type ReactNode } from 'react' import { Link, useNavigate, useParams, useSearchParams } from 'react-router' +import { useGoBack } from '@/hooks/useGoBack' interface TraceDetailProps { project: ProjectResponse @@ -476,6 +477,7 @@ function CrossProjectBar({ export default function TraceDetail({ project }: TraceDetailProps) { const { traceId } = useParams() const navigate = useNavigate() + const goBack = useGoBack(`/projects/${project.slug}/traces`) const { data, isLoading, isFetching, error, refetch } = useQuery({ ...getTraceOptions({ @@ -673,7 +675,7 @@ export default function TraceDetail({ project }: TraceDetailProps) {