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/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/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/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/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/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/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/components/traces/ProjectBadge.tsx b/web/src/components/traces/ProjectBadge.tsx index 12da12b67..45bf1a365 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,61 @@ 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/components/ui/resizable.tsx b/web/src/components/ui/resizable.tsx new file mode 100644 index 000000000..9d90786e0 --- /dev/null +++ b/web/src/components/ui/resizable.tsx @@ -0,0 +1,65 @@ +import type { ComponentProps } from 'react' +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 +}: 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 +}: ComponentProps & { withHandle?: boolean }) { + return ( + + {withHandle && ( +
+ +
+ )} +
+ ) +} + +export { ResizablePanelGroup, ResizablePanel, ResizableHandle } 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/hooks/useKeyboardShortcut.ts b/web/src/hooks/useKeyboardShortcut.ts index d428bc4c6..78f311e17 100644 --- a/web/src/hooks/useKeyboardShortcut.ts +++ b/web/src/hooks/useKeyboardShortcut.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' import { useNavigate } from 'react-router' interface KeyboardShortcutOptions { @@ -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 @@ -40,6 +53,16 @@ export function useKeyboardShortcut({ }: KeyboardShortcutOptions) { const navigate = useNavigate() + // Callers pass an inline arrow (`onClick={() => setOpen(true)}`), so a new + // identity every render. Keeping it in a ref stops the effect from tearing + // the keydown listener down and re-adding it on each render. The write is + // in an effect, not in render — mutating a ref during render is unsafe + // under concurrent rendering. + const callbackRef = useRef(callback) + useEffect(() => { + callbackRef.current = callback + }, [callback]) + useEffect(() => { if (!enabled) return @@ -51,9 +74,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 && @@ -62,8 +89,8 @@ export function useKeyboardShortcut({ ) { e.preventDefault() - if (callback) { - callback() + if (callbackRef.current) { + callbackRef.current() } else if (path) { navigate(path) } @@ -72,5 +99,5 @@ export function useKeyboardShortcut({ document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) - }, [key, path, callback, enabled, navigate]) + }, [key, path, enabled, navigate]) } 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 21aac3b8a..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 { @@ -18,7 +19,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' @@ -113,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 || '' } }), @@ -131,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] @@ -158,12 +171,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) { @@ -190,7 +201,7 @@ 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 */}
-
@@ -345,7 +347,7 @@ export default function DashboardBuilder({ project }: DashboardBuilderProps) { 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/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/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/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() {
{/* 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 +2027,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') @@ -232,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 } }) ) @@ -260,10 +274,11 @@ export function Teams() { should reach. Projects with no grants stay open to everyone.

- + setCreateOpen(true)} + label="Create team" + className="self-start" + /> diff --git a/web/src/pages/TraceDetail.tsx b/web/src/pages/TraceDetail.tsx index dda105688..c88748e41 100644 --- a/web/src/pages/TraceDetail.tsx +++ b/web/src/pages/TraceDetail.tsx @@ -44,7 +44,10 @@ import { serviceColor, statusIcon, } from '@/components/traces/SpanWaterfall' -import { ProjectBadge } from '@/components/traces/ProjectBadge' +import { + ProjectDot, + ProjectLegend, +} from '@/components/traces/ProjectBadge' import { TraceStatBadges } from '@/components/traces/TraceStatBadges' import { buildSpanTree, flattenTree } from '@/utils/spanTree' import type { SpanTreeNode } from '@/utils/spanTree' @@ -62,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 @@ -473,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({ @@ -586,15 +591,16 @@ export default function TraceDetail({ project }: TraceDetailProps) { traceEnd, traceDuration, correlatedLogs, + // Dot, not badge: `ProjectLegend` below decodes the colour once, so the + // name column isn't spending ~88px per row on a truncated slug. renderRowBadge: usingUnified ? (span) => ( - ) : undefined, @@ -669,7 +675,7 @@ export default function TraceDetail({ project }: TraceDetailProps) {