wulf-pulse/components/navigation/command-palette.tsx
lorentz a0894fe946 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>
2026-05-03 10:11:08 -04:00

298 lines
11 KiB
TypeScript

/* 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>
);
}