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>
298 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|