feat(nav): global ⌘K command palette

Adds a global command launcher reachable from anywhere via ⌘K (Mac) /
Ctrl+K (Win), or "/" when no input is focused.  Three sections:

- **Navigation** — every primary route from the top-bar nav, plus the
  full Admin sub-menu, role-gated against the session.
- **Recent activity** — last 5 audits and last 5 device observations,
  lazy-loaded once on first open from /api/dashboard/overview.
- **Companies** — fuzzy search against the active customer list from
  /api/companies, kicks in once the user types 2+ characters.
  Selecting a company deep-links to /configuration-items?company=<id>.

Top-bar exposes a small "Search · ⌘K" pill on md+ for discoverability,
sized to fit between the Status indicator and the Theme toggle.

Implementation:
- shadcn `command` primitive (uses cmdk under the hood); declined the
  bundled dialog overwrite to keep our existing dialog.tsx.
- CommandPalette mounted once in app/layout.tsx so it lives outside the
  AppNavigation re-renders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lorentz 2026-05-03 10:11:08 -04:00
parent 3fa41c25a3
commit a0894fe946
7 changed files with 521 additions and 0 deletions

View file

@ -315,6 +315,16 @@ export function AppNavigation() {
{/* Right Side Actions */}
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={() => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }))}
className="hidden md:inline-flex h-9 items-center gap-2 rounded-md border bg-muted/40 px-2.5 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
aria-label="Open command palette"
>
<Search className="h-3.5 w-3.5" />
<span className="hidden lg:inline">Search</span>
<kbd className="num text-[10px] bg-background border rounded-sm px-1 py-px">K</kbd>
</button>
<StatusIndicator />
<ThemeToggle />
<UserMenu />

View file

@ -0,0 +1,298 @@
/* CommandPalette — global ⌘K / Ctrl+K launcher.
*
* Three sections:
* Navigation every primary route from the top-bar nav,
* plus admin shortcuts.
* Recent activity last 5 analyses + last 5 device observations,
* pulled lazily from /api/dashboard/overview.
* Companies fuzzy search against /api/companies (kicks in
* once the user types a query).
*
* Mounted in the root layout so it's available on every page. */
'use client';
import { useEffect, useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useSession } from '@/lib/auth-client';
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from '@/components/ui/command';
import {
LayoutDashboard,
Server,
Activity,
HardDrive,
Sparkles,
Brain,
Search,
AlertTriangle,
Database,
GitCompare,
Plug,
Power,
Users,
Workflow,
Settings,
ShieldCheck,
TrendingUp,
Building2,
Clock,
} from 'lucide-react';
// ── Static navigation entries ────────────────────────────────────────
interface CommandRoute {
href: string;
label: string;
hint?: string;
icon: React.ElementType;
/** Comma-separated extra keywords to match. */
keywords?: string;
superAdminOnly?: boolean;
}
const ROUTES: CommandRoute[] = [
// Primary
{ href: '/', label: 'Dashboard', icon: LayoutDashboard, keywords: 'home ops operations today kpi' },
{ href: '/status', label: 'System status', icon: Activity, keywords: 'health integrations workers sync uptime' },
{ href: '/configuration-items', label: 'Configuration items', icon: Server, keywords: 'ci devices assets' },
{ href: '/backup-status', label: 'Backup status', icon: HardDrive, keywords: 'veeam rpo' },
{ href: '/veeam-comparison', label: 'RPO comparison', icon: GitCompare, keywords: 'pulse veeam datto rmm' },
{ href: '/veeam-analysis', label: 'Backup ticket analysis', icon: Brain, keywords: 'veeam category resolution' },
// Engagement (super-admin)
{ href: '/engagement', label: 'Engagement overview', icon: TrendingUp, superAdminOnly: true, keywords: 'hours teams email' },
{ href: '/engagement/profile', label: 'Engagement · employee profile', icon: TrendingUp, superAdminOnly: true, keywords: 'employee resource' },
// Analyzer
{ href: '/analyzer/tickets', label: 'Analyzer · browse tickets', icon: Search, keywords: 'tickets analyzer browse' },
{ href: '/analyzer/reports', label: 'Analyzer · aggregate reports', icon: Sparkles, keywords: 'aggregate report' },
{ href: '/analyzer/queue', label: 'Analyzer · needs review', icon: Sparkles, keywords: 'human review queue' },
{ href: '/analyzer/itglue/applications', label: 'IT Glue · applications', icon: Database, keywords: 'flexible asset' },
{ href: '/analyzer/itglue/configurations', label: 'IT Glue · configurations', icon: Database, keywords: 'configuration' },
// Admin (super-admin)
{ href: '/admin', label: 'Admin home', icon: Activity, superAdminOnly: true, keywords: 'admin dashboard' },
{ href: '/admin/integrations', label: 'Admin · integrations', icon: Power, superAdminOnly: true, keywords: 'toggle disable enable s1 sentinelone datto itglue' },
{ href: '/admin/sync', label: 'Admin · sync schedules', icon: Clock, superAdminOnly: true, keywords: 'cron schedules autotask veeam' },
{ href: '/admin/workflow', label: 'Admin · workflow rules', icon: Workflow, superAdminOnly: true, keywords: 'classifier ai prompt' },
{ href: '/admin/rmm-overshell', label: 'Admin · RMM Overshell', icon: Activity, superAdminOnly: true, keywords: 'datto execute script' },
{ href: '/admin/itglue-writes', label: 'Admin · IT Glue write log', icon: Database, superAdminOnly: true, keywords: 'audit revert' },
{ href: '/admin/device-link-conflicts', label: 'Admin · device-link conflicts', icon: AlertTriangle, superAdminOnly: true, keywords: 'mapping conflicts xref' },
{ href: '/admin/users', label: 'Admin · users & roles', icon: Users, superAdminOnly: true, keywords: 'invite role permission' },
// Account
{ href: '/settings', label: 'Settings', icon: Settings, keywords: 'profile account' },
{ href: '/settings/security', label: 'Security · 2FA & sessions', icon: ShieldCheck, keywords: 'two factor totp logout' },
];
interface OverviewResponse {
observations: Array<{ id: string; kind: string; hostname: string | null; companyName: string | null }>;
audits: Array<{ id: string; hostname: string | null; companyName: string | null }>;
}
interface CompanyHit {
id: number;
name: string;
}
interface CompaniesApiRow {
id: number;
companyName: string;
}
// ── Component ────────────────────────────────────────────────────────
export function CommandPalette() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [recents, setRecents] = useState<OverviewResponse | null>(null);
const [allCompanies, setAllCompanies] = useState<CompanyHit[] | null>(null);
const [companiesLoading, setCompaniesLoading] = useState(false);
const router = useRouter();
const { data: session } = useSession();
const isSuperAdmin = (session?.user as { role?: string } | undefined)?.role === 'super-admin';
// Bind ⌘K / Ctrl+K (and / when nothing else is focused).
useEffect(() => {
function onKey(e: KeyboardEvent) {
const isCmdK = (e.key === 'k' || e.key === 'K') && (e.metaKey || e.ctrlKey);
const isSlash =
e.key === '/' &&
!(document.activeElement instanceof HTMLInputElement) &&
!(document.activeElement instanceof HTMLTextAreaElement) &&
!(document.activeElement as HTMLElement | null)?.isContentEditable;
if (isCmdK || isSlash) {
e.preventDefault();
setOpen((v) => !v);
}
}
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, []);
// Lazy-load recent activity once on first open.
useEffect(() => {
if (!open || recents) return;
void fetch('/api/dashboard/overview', { cache: 'no-store' })
.then((r) => (r.ok ? r.json() : null))
.then((j: OverviewResponse | null) => {
if (j) setRecents(j);
})
.catch(() => {
/* ignore */
});
}, [open, recents]);
// Companies are loaded once on first open (the endpoint returns the
// full active customer list — small enough to filter client-side).
useEffect(() => {
if (!open || allCompanies !== null) return;
setCompaniesLoading(true);
void fetch('/api/companies', { cache: 'no-store' })
.then((r) => (r.ok ? r.json() : { companies: [] }))
.then((j: { companies?: CompaniesApiRow[] }) => {
const items: CompanyHit[] = (j.companies ?? []).map((c) => ({
id: c.id,
name: c.companyName,
}));
setAllCompanies(items);
})
.catch(() => setAllCompanies([]))
.finally(() => setCompaniesLoading(false));
}, [open, allCompanies]);
const companyMatches = (() => {
const q = query.trim().toLowerCase();
if (q.length < 2 || !allCompanies) return [];
return allCompanies
.filter((c) => c.name.toLowerCase().includes(q))
.slice(0, 8);
})();
const go = useCallback(
(href: string) => {
setOpen(false);
router.push(href);
},
[router],
);
const visibleRoutes = ROUTES.filter((r) => !r.superAdminOnly || isSuperAdmin);
return (
<CommandDialog
open={open}
onOpenChange={setOpen}
title="Search Pulse"
description="Jump to any page, recent ticket, or company."
>
<CommandInput
placeholder="Search pages, tickets, companies…"
value={query}
onValueChange={setQuery}
/>
<CommandList>
<CommandEmpty>No results.</CommandEmpty>
<CommandGroup heading="Navigation">
{visibleRoutes.map((r) => (
<CommandItem
key={r.href}
value={`${r.label} ${r.keywords ?? ''}`}
onSelect={() => go(r.href)}
>
<r.icon className="h-4 w-4" />
<span>{r.label}</span>
{r.hint && <CommandShortcut>{r.hint}</CommandShortcut>}
</CommandItem>
))}
</CommandGroup>
{recents && recents.audits.length > 0 && (
<>
<CommandSeparator />
<CommandGroup heading="Recent audits">
{recents.audits.slice(0, 5).map((a) => (
<CommandItem
key={a.id}
value={`audit ${a.hostname ?? ''} ${a.companyName ?? ''}`}
onSelect={() => go(`/analyzer/itglue/configurations`)}
>
<Sparkles className="h-4 w-4" />
<span className="truncate">
{a.hostname ?? '(unanchored)'}
{a.companyName && (
<span className="text-muted-foreground"> · {a.companyName}</span>
)}
</span>
</CommandItem>
))}
</CommandGroup>
</>
)}
{recents && recents.observations.length > 0 && (
<>
<CommandSeparator />
<CommandGroup heading="Recent observations">
{recents.observations.slice(0, 5).map((o) => (
<CommandItem
key={o.id}
value={`observation ${o.hostname ?? ''} ${o.companyName ?? ''} ${o.kind}`}
onSelect={() => go('/configuration-items')}
>
<Activity className="h-4 w-4" />
<span className="truncate">
{o.hostname ?? '(unanchored)'}
{o.companyName && (
<span className="text-muted-foreground"> · {o.companyName}</span>
)}
</span>
</CommandItem>
))}
</CommandGroup>
</>
)}
{companyMatches.length > 0 && (
<>
<CommandSeparator />
<CommandGroup heading="Companies">
{companyMatches.map((c) => (
<CommandItem
key={c.id}
value={`company ${c.name}`}
onSelect={() =>
go(
`/configuration-items?company=${encodeURIComponent(String(c.id))}`,
)
}
>
<Building2 className="h-4 w-4" />
<span className="truncate">{c.name}</span>
</CommandItem>
))}
</CommandGroup>
</>
)}
<CommandSeparator />
<CommandGroup heading="Tip">
<CommandItem disabled value="hint">
<Plug className="h-4 w-4" />
<span className="text-muted-foreground">Press</span>
<CommandShortcut> K</CommandShortcut>
<span className="text-muted-foreground">or</span>
<CommandShortcut>/</CommandShortcut>
<span className="text-muted-foreground">to open this panel anywhere.</span>
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
);
}

184
components/ui/command.tsx Normal file
View file

@ -0,0 +1,184 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}