Files
fleet/frontend/components/CommandPalette/CommandPalette.tsx
T
Nico b10642be3c 44954 custom host vitals (#49334)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #44954 

# Checklist for submitter

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added Custom Host Vitals management (create/edit/delete, search) with
copyable variable tokens and per-host values.
* Show Custom Host Vitals on host details, including role-based editing.
* Enabled Custom Host Vitals in host-vitals labels and the activity
feed.
* Extended GitOps to manage global Custom Host Vitals declaratively
(including dry-run behavior).
* Split Controls → Variables into Global Variables and Custom Host
Vitals, including routing updates.

* **Bug Fixes**
* Improved validation and expansion of Custom Host Vital references
across scripts, profiles, installers, and deployments, with clearer
failures when values are missing or invalid.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-15 19:35:27 -03:00

1012 lines
36 KiB
TypeScript

import React, {
useContext,
useEffect,
useLayoutEffect,
useMemo,
useState,
useCallback,
useRef,
} from "react";
import { Command, useCommandState } from "cmdk";
import {
Title as DialogTitle,
Description as DialogDescription,
} from "@radix-ui/react-dialog";
import { browserHistory } from "react-router";
import { AppContext } from "context/app";
import {
APP_CONTEXT_ALL_TEAMS_ID,
APP_CONTEXT_NO_TEAM_ID,
} from "interfaces/team";
import Icon from "components/Icon";
import { isDarkMode, setThemeMode } from "utilities/theme";
import paths from "router/paths";
import {
ICommandItem,
ICommandSubItem,
GROUPS,
buildPaletteItems,
buildFleetSwitchUrl,
computeBestMatch,
pathSupportsAllFleets,
pathSupportsUnassigned,
} from "./helpers";
import FleetPicker from "./components/FleetPicker";
import HostPicker from "./components/HostPicker";
import SoftwarePicker from "./components/SoftwarePicker";
import ReportPicker from "./components/ReportPicker";
import PolicyPicker from "./components/PolicyPicker";
import HighlightedLabel from "./components/HighlightedLabel";
import UprightEmoji from "./components/UprightEmoji";
import { isPreFilteredResult } from "./components/constants";
const baseClass = "command-palette";
// Subscribes to cmdk's selected value via its internal store. We can't
// rely on `Command.Dialog`'s `onValueChange` prop for this: in cmdk@1.1.1
// that callback only fires when `value` is also controlled, and the
// palette runs cmdk uncontrolled so arrow-key highlight changes never
// reach React without this bridge. Rendered inside `Command.Dialog` so
// the `useCommandState` context is available.
const HighlightSubscriber = ({
onChange,
}: {
onChange: (value: string) => void;
}) => {
const value = useCommandState((state) => state.value);
useEffect(() => {
onChange(value ?? "");
}, [value, onChange]);
return null;
};
// cmdk treats `value` as the row's identity *and* as the substring it
// filters against. Two rows with the same value collide. Including the
// item's id guarantees uniqueness without losing the user-typeable
// label/keyword content cmdk needs for its filter pass.
//
// Hoisted to module scope so callers can use these from memoization
// safely (stable identity across renders).
const getUniqueItemValue = (item: ICommandItem) => {
const parts = [item.id, item.label, ...(item.keywords ?? [])];
item.subItems?.forEach((sub) => {
parts.push(sub.label, ...(sub.keywords ?? []));
});
return parts.join(" ");
};
const getUniqueSubItemValue = (sub: ICommandSubItem) => {
return [sub.id, sub.label, ...(sub.keywords ?? [])].join(" ");
};
type Page =
| "root"
| "switch-fleet"
| "view-host"
| "view-software"
| "view-software-library"
| "view-report"
| "view-policy";
const CommandPalette = (): JSX.Element | null => {
const [open, setOpen] = useState(false);
const [page, setPage] = useState<Page>("root");
const [search, setSearch] = useState("");
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const inputRef = useRef<HTMLInputElement>(null);
const {
availableTeams,
currentTeam,
setCurrentTeam,
isGlobalAdmin,
isGlobalMaintainer,
isAnyTeamAdmin,
isAnyTeamMaintainer,
isTeamAdmin,
isTeamMaintainer,
isGlobalTechnician,
isAnyTeamTechnician,
isObserverPlus,
isAnyTeamObserverPlus,
isGlobalObserver,
isTeamObserver,
isPremiumTier,
isMacMdmEnabledAndConfigured,
isWindowsMdmEnabledAndConfigured,
isAndroidMdmEnabledAndConfigured,
isVppEnabled,
config,
isNoAccess,
} = useContext(AppContext);
const isTechnician = isGlobalTechnician || isAnyTeamTechnician;
const canAccessControls =
isGlobalAdmin ||
isGlobalMaintainer ||
isAnyTeamAdmin ||
isAnyTeamMaintainer ||
isTechnician;
const canWrite =
isGlobalAdmin ||
isGlobalMaintainer ||
isAnyTeamAdmin ||
isAnyTeamMaintainer ||
isTechnician;
// The Variables section (and its Global variables / Custom host vitals
// sub-tabs) is hidden from technicians in the Controls sub-nav — they only
// get OS settings and Scripts (see ManageControlsPage). canAccessControls
// includes technicians, so use this narrower flag for Variables.
const canAccessVariables =
isGlobalAdmin ||
isGlobalMaintainer ||
isAnyTeamAdmin ||
isAnyTeamMaintainer;
// Custom variables are admin-tier global config (mirrors Variables.tsx
// `canEdit`). Team admins/maintainers/technicians lack the role even
// though they have `canWrite`, so the destination page would render
// a read-only view — gate the palette entry accordingly.
const canEditCustomVariable = !!isGlobalAdmin || !!isGlobalMaintainer;
// Mirrors SoftwarePage.tsx canAddSoftware. Note isTeamAdmin /
// isTeamMaintainer here are scoped to currentTeam by AppContext — a
// user who is admin of Team A but observer of Team B (currently
// selected) correctly evaluates to false. canWrite would have
// accepted them via isAnyTeamAdmin.
const canAddSoftware =
!!isGlobalAdmin ||
!!isGlobalMaintainer ||
!!isTeamAdmin ||
!!isTeamMaintainer;
// Admin/maintainer-only Controls sub-items (Certificates, Passwords, Host
// names). Technicians can reach Controls (canAccessControls) but not these,
// so gate them on the positive admin/maintainer role rather than
// `!isTechnician`.
const isAdminOrMaintainer =
isGlobalAdmin ||
isGlobalMaintainer ||
isAnyTeamAdmin ||
isAnyTeamMaintainer;
// Observer+ users can run live queries even though they can't write.
const canRunLiveReport =
canWrite || !!isObserverPlus || !!isAnyTeamObserverPlus;
// Used by ReportPicker to decide whether to render the "Observers can
// run" affordance on a report — that hint is meant for non-observers
// (it advertises which reports they can hand off), so suppress for
// observers viewing their own scope.
const isViewerObserverInScope = !!isGlobalObserver || !!isTeamObserver;
// Primo Mode is a single-fleet premium installation. The fleet switcher
// should be hidden, fleet creation disabled, and All-fleets-only commands
// need to surface for the user's single fleet.
const isPrimoMode = !!config?.partnerships?.enable_primo;
// Track theme as reactive state so the toggle-dark-mode item's label
// updates if the theme flips externally (system theme media query,
// another tab, sibling component). utilities/theme dispatches a
// `fleet-theme-change` window event on every change.
const [isDarkModeActive, setIsDarkModeActive] = useState(isDarkMode);
useEffect(() => {
const onThemeChange = (e: Event) => {
const detail = (e as CustomEvent<{ dark: boolean }>).detail;
setIsDarkModeActive(!!detail?.dark);
};
window.addEventListener("fleet-theme-change", onThemeChange);
return () =>
window.removeEventListener("fleet-theme-change", onThemeChange);
}, []);
// Policy automations: mirrors ManagePoliciesPage.canEditAutomationsSettings.
// Maintainers can add/delete policies but the in-page Automations button
// is hidden for them, and the deep-link useEffect re-checks the same
// gate — so the palette item must match, otherwise it's a dead link for
// maintainers. isTeamAdmin is scoped to currentTeam by AppContext, so a
// user who's team admin of A but viewing B correctly won't see this.
const canManagePolicyAutomations = isGlobalAdmin || isTeamAdmin;
// Software automations require global admin (all fleets view)
const canManageSoftwareAutomations = isGlobalAdmin;
// Report automations: mirror ManageQueriesPage's `canManageAutomations`
// exactly (isGlobalAdmin || isTeamAdmin) — the palette item points at
// the current team via withTeamId, so the destination's gate evaluates
// against the same team. Using isAnyTeamAdmin here would surface the
// command for users who are admin of *some* team but observer of the
// current one — they'd land on Reports and see no button.
const canManageReportAutomations = isGlobalAdmin || isTeamAdmin;
const canAccessSettings = isGlobalAdmin;
// Whether a specific team is selected (not "All teams")
const hasTeamSelected = currentTeam && currentTeam.id > 0;
const isUnassigned = currentTeam?.id === 0;
// Append fleet_id to a path so navigation preserves the current team context.
// Includes Unassigned (id 0) so navigation doesn't drop the no-team context.
const withTeamId = useCallback(
(path: string) => {
if (!hasTeamSelected && !isUnassigned) {
return path;
}
const separator = path.includes("?") ? "&" : "?";
return `${path}${separator}fleet_id=${currentTeam?.id}`;
},
[hasTeamSelected, isUnassigned, currentTeam?.id]
);
// Reset page and search when dialog opens/closes
useEffect(() => {
if (!open) {
setPage("root");
setSearch("");
setExpandedItems(new Set());
}
}, [open]);
const canSwitchFleet =
isPremiumTier &&
!isPrimoMode &&
!!availableTeams &&
availableTeams.length > 1;
// Display label for the fleet switcher button — falls back to the
// "All fleets" sentinel when no specific team is selected. Used in
// both the visible button text and the aria-label.
const fleetSwitcherLabel = currentTeam?.name || "All fleets";
// Detect macOS so we can render the Cmd glyph (⌘) vs. "Ctrl" inline on
// the fleet-switcher shortcut. navigator.platform is deprecated but
// still the most reliable cross-browser signal for this binary check.
const isMacPlatform =
typeof navigator !== "undefined" &&
/Mac|iPhone|iPad|iPod/i.test(navigator.platform);
const pickerPagePlaceholders: Partial<Record<Page, string>> = {
"switch-fleet": "Search a fleet...",
"view-host": "Search hosts...",
"view-software": "Search software inventory...",
"view-software-library": "Search software library...",
"view-report": "Search reports...",
"view-policy": "Search policies...",
};
const pickerPagePlaceholder = pickerPagePlaceholders[page];
// Toggle open on Cmd+K / Ctrl+K; jump to switch-fleet on Cmd+Shift+F.
// Focus is handled by the [open, page] effect below — don't rAF here, the
// input ref isn't set until Radix's portal mounts.
// Skip registration entirely for no-access users so we don't intercept
// (and preventDefault) keyboard shortcuts for a palette they can't open.
useEffect(() => {
if (isNoAccess) return undefined;
const onKeyDown = (e: KeyboardEvent) => {
// Require the platform-native modifier: Cmd on macOS, Ctrl elsewhere.
// Accepting either on both platforms hijacks native shortcuts —
// e.g. Ctrl+K readline kill-line in text fields on macOS, and
// Ctrl+Shift+F (find in files / system shortcut) on macOS.
const correctModifier = isMacPlatform ? e.metaKey : e.ctrlKey;
if (!correctModifier) return;
// Normalize once so Caps Lock (or shift layouts) don't miss.
const key = e.key.toLowerCase();
if (key === "k") {
e.preventDefault();
setOpen((prev) => !prev);
} else if (e.shiftKey && key === "f" && canSwitchFleet) {
e.preventDefault();
setOpen(true);
setSearch("");
setPage("switch-fleet");
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [canSwitchFleet, isNoAccess, isMacPlatform]);
const navigate = useCallback((path: string) => {
setOpen(false);
browserHistory.push(path);
}, []);
const goToPage = useCallback((newPage: Page) => {
setSearch("");
setPage(newPage);
}, []);
const goBack = useCallback(() => {
setSearch("");
setPage("root");
}, []);
// Focus the input whenever the dialog is open or the page changes. Runs
// after the portal has mounted the input, unlike rAF in event handlers
// (which fires before Radix's first commit when opening from closed).
useEffect(() => {
if (open) {
inputRef.current?.focus();
}
}, [open, page]);
// Backspace on empty input returns to root from a picker page.
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (page !== "root" && e.key === "Backspace" && !search) {
e.preventDefault();
goBack();
}
},
[page, search, goBack]
);
// Intercept Escape on a picker page so it returns to root instead of
// closing the dialog. cmdk 1.1.1's Command.Dialog doesn't forward
// `onEscapeKeyDown` to Radix's Dialog.Content, so we can't override
// the close intent via props.
//
// Approach: a capture-phase document listener that calls
// `stopImmediatePropagation` on Escape from a picker page. This prevents
// both Radix's DismissableLayer ESC handler AND any sibling listeners
// from firing on this event — the dialog never learns about the press,
// so it doesn't close. `useLayoutEffect` is intentional: it attaches
// before any `useEffect` in deeper Radix components, guaranteeing
// priority in the capture phase regardless of mount order. Click-
// outside still closes the palette outright via onOpenChange.
const pageRef = useRef(page);
pageRef.current = page;
useLayoutEffect(() => {
if (!open || isNoAccess) {
return undefined;
}
const onDocKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && pageRef.current !== "root") {
e.preventDefault();
e.stopImmediatePropagation();
goBack();
}
};
document.addEventListener("keydown", onDocKey, true);
return () => document.removeEventListener("keydown", onDocKey, true);
}, [open, goBack, isNoAccess]);
const handleOpenChange = useCallback((nextOpen: boolean) => {
setOpen(nextOpen);
}, []);
const handleSwitchFleet = useCallback(
(fleetId: number) => {
const selected = availableTeams?.find((t) => t.id === fleetId);
if (selected) {
setCurrentTeam(selected);
}
const { pathname, search: currentSearch } = window.location;
browserHistory.push(
buildFleetSwitchUrl({ pathname, currentSearch, fleetId })
);
// Return to root so the palette stays open on the main view.
setSearch("");
setPage("root");
},
[availableTeams, setCurrentTeam]
);
const toggleExpanded = useCallback((id: string) => {
setExpandedItems((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
// Memoize the item array on the values buildPaletteItems actually
// consumes. Inline onToggle/onView callbacks are intentionally excluded
// from deps — they only call already-stable setters/useCallback'd
// goToPage, so they don't change semantically across renders.
const items = useMemo(
() =>
buildPaletteItems({
search,
currentTeam,
availableTeams,
config,
canAccessControls,
canAccessVariables,
canWrite,
canRunLiveReport,
canAccessSettings,
canManagePolicyAutomations,
canManageSoftwareAutomations,
canManageReportAutomations,
canEditCustomVariable,
canAddSoftware,
isAdminOrMaintainer,
isTechnician,
isPremiumTier,
isPrimoMode,
isDarkMode: isDarkModeActive,
isMacMdmEnabledAndConfigured,
isWindowsMdmEnabledAndConfigured,
isAndroidMdmEnabledAndConfigured,
isVppEnabled,
hasTeamSelected,
withTeamId,
onToggleDarkMode: () => {
setThemeMode(isDarkModeActive ? "light" : "dark");
setOpen(false);
},
onViewHost: () => goToPage("view-host"),
onViewSoftware: () => goToPage("view-software"),
onViewSoftwareLibrary: () => goToPage("view-software-library"),
onViewReport: () => goToPage("view-report"),
onViewPolicy: () => goToPage("view-policy"),
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
search,
currentTeam,
availableTeams,
config,
canAccessControls,
canAccessVariables,
canWrite,
canRunLiveReport,
canAccessSettings,
canManagePolicyAutomations,
canManageSoftwareAutomations,
canManageReportAutomations,
canEditCustomVariable,
canAddSoftware,
isAdminOrMaintainer,
isTechnician,
isPremiumTier,
isPrimoMode,
isDarkModeActive,
isMacMdmEnabledAndConfigured,
isWindowsMdmEnabledAndConfigured,
isAndroidMdmEnabledAndConfigured,
isVppEnabled,
hasTeamSelected,
withTeamId,
goToPage,
]
);
const groupedItems = useMemo(
() =>
items.reduce<Record<string, ICommandItem[]>>((acc, item) => {
if (!acc[item.group]) {
acc[item.group] = [];
}
acc[item.group].push(item);
return acc;
}, {}),
[items]
);
const isSearching = search.length > 0;
const searchLower = search.toLowerCase().trim();
// Map cmdk values (normalized) to parent item IDs for auto-expand on keyboard nav
const valueToParentId = useMemo(() => {
const map = new Map<string, string>();
items.forEach((item) => {
if (item.subItems?.length) {
map.set(getUniqueItemValue(item).toLowerCase().trim(), item.id);
item.subItems.forEach((sub) => {
map.set(getUniqueSubItemValue(sub).toLowerCase().trim(), item.id);
});
}
});
return map;
}, [items]);
// Track whether the most recent input was a keystroke or pointer move.
// Hovering changes cmdk's selected value (selection-follows-pointer),
// which would otherwise pop sub-items open every time the mouse passes
// a parent row. We still want auto-expand on arrow-key navigation, so
// gate it on this ref instead of turning off pointer selection
// entirely (which would also kill the hover highlight).
const lastInputSourceRef = useRef<"keyboard" | "pointer">("keyboard");
useEffect(() => {
if (!open) return undefined;
const onKey = () => {
lastInputSourceRef.current = "keyboard";
};
const onPointer = () => {
lastInputSourceRef.current = "pointer";
};
document.addEventListener("keydown", onKey, true);
document.addEventListener("pointermove", onPointer, true);
return () => {
document.removeEventListener("keydown", onKey, true);
document.removeEventListener("pointermove", onPointer, true);
};
}, [open]);
// Auto expand/collapse sub-items as the user arrows through items.
// Normalize to match how valueToParentId stores its keys — cmdk
// hands back the raw `value` prop (preserves casing/whitespace).
const handleHighlightChange = useCallback(
(value: string) => {
if (isSearching) return;
if (lastInputSourceRef.current !== "keyboard") return;
const parentId = valueToParentId.get(value.toLowerCase().trim());
// Bail out when the target set is equivalent to the current one
// (arrowing within the same parent's sub-items, or moving between
// two non-parent rows). Without this, every arrow press allocates
// a new Set and forces a re-render even when no expansion state
// actually changes.
setExpandedItems((prev) => {
if (parentId) {
if (prev.size === 1 && prev.has(parentId)) return prev;
return new Set([parentId]);
}
if (prev.size === 0) return prev;
return new Set();
});
},
[valueToParentId, isSearching]
);
// Find Best matches — items and sub-items scored by how strongly their
// label or keywords match the query. Implementation is in helpers.ts
// (so the scoring tiers are unit-testable independent of cmdk).
const bestMatchItems = useMemo(() => computeBestMatch(items, searchLower), [
items,
searchLower,
]);
// Set of IDs already shown in Best match — used to dedupe from regular
// groups and inside expanded sub-item lists.
const bestMatchIds = useMemo(() => {
const ids = new Set<string>();
bestMatchItems.forEach(({ item, sub }) => ids.add((sub ?? item).id));
return ids;
}, [bestMatchItems]);
const renderItem = (item: ICommandItem) => {
const isExpanded = expandedItems.has(item.id);
// Sub-items promoted into Best match are hidden here so they don't
// render twice. The parent stays visible because its own promotion
// is handled one level up (in renderRootPage, via groupedItems
// filtering).
const visibleSubItems = item.subItems?.filter(
(sub) => !bestMatchIds.has(sub.id)
);
const hasSubItems = !!visibleSubItems && visibleSubItems.length > 0;
return (
<React.Fragment key={item.id}>
<Command.Item
value={getUniqueItemValue(item)}
onSelect={() => {
if (item.onAction) {
item.onAction();
return;
}
if (item.path) navigate(item.path);
}}
className={`${baseClass}__item`}
>
<div className={`${baseClass}__item-left`}>
<span className={`${baseClass}__item-label`}>{item.label}</span>
{hasSubItems && !isSearching && (
<button
type="button"
tabIndex={-1}
className={`${baseClass}__item-more ${
isExpanded ? `${baseClass}__item-more--expanded` : ""
}`}
onClick={(e) => {
e.stopPropagation();
toggleExpanded(item.id);
}}
onPointerDown={(e) => e.preventDefault()}
>
<Icon
name="chevron-down"
size="small"
color="ui-fleet-black-50"
/>
</button>
)}
{item.opensPickerPage && (
<span aria-hidden className={`${baseClass}__item-more`}>
<Icon
name="chevron-right"
size="small"
color="ui-fleet-black-50"
/>
</span>
)}
</div>
{item.teamName && (
<span className={`${baseClass}__item-fleet`}>
<UprightEmoji text={item.teamName} />
</span>
)}
</Command.Item>
{/* Render sub-items when expanded (browsing) or always when searching */}
{hasSubItems &&
(isExpanded || isSearching) &&
visibleSubItems &&
visibleSubItems.map((sub) => (
<Command.Item
key={sub.id}
value={getUniqueSubItemValue(sub)}
onSelect={() => navigate(sub.path)}
className={`${baseClass}__item ${baseClass}__item--sub`}
>
<span className={`${baseClass}__item-label`}>{sub.label}</span>
</Command.Item>
))}
</React.Fragment>
);
};
const renderRootPage = () => (
<>
{/* Top-ranked items render without a heading — visually they're
just the most relevant matches, separated from the rest by a
rule. The BEST_MATCH prefix on the value is a cmdk filter-bypass
token (see filter prop) — it forces the item past substring
filtering regardless of score. */}
{bestMatchItems.length > 0 && (
<>
<Command.Group className={`${baseClass}__group`}>
{bestMatchItems.map(({ item, sub }) => {
const target = sub || item;
// Sub-items get the same indented styling they have in
// their regular group, so users can tell at a glance which
// results are nested.
const itemClass = sub
? `${baseClass}__item ${baseClass}__item--sub`
: `${baseClass}__item`;
return (
<Command.Item
key={`best-${target.id}`}
// cmdk treats value as the row's identity. Include
// target.id so two promoted items with the same label
// (e.g., the "Users" Settings page and the "Users"
// setup-experience sub-item) don't collide.
value={`BEST_MATCH ${target.id} ${target.label}`}
onSelect={() => {
// Sub-items navigate to their own path — never route
// through the parent's onAction. Otherwise a future
// sub-item under an action-backed parent (e.g., a
// child of "View host") would open the parent flow
// instead of the sub-item destination.
if (sub) {
navigate(sub.path);
return;
}
if (item.onAction) {
item.onAction();
return;
}
if (item.path) navigate(item.path);
}}
className={itemClass}
>
<div className={`${baseClass}__item-left`}>
<span className={`${baseClass}__item-label`}>
<HighlightedLabel text={target.label} query={search} />
</span>
{/* Render the picker-page chevron for items that open
a picker (View host, View software, etc.). The
chevron only belongs to parent items — sub-items
navigate directly. */}
{!sub && item.opensPickerPage && (
<span aria-hidden className={`${baseClass}__item-more`}>
<Icon
name="chevron-right"
size="small"
color="ui-fleet-black-50"
/>
</span>
)}
</div>
{/* Team-context chip on items whose navigation switches
the user's current fleet (e.g., add-report on All
fleets shows "All fleets"). */}
{!sub && item.teamName && (
<span className={`${baseClass}__item-fleet`}>
<UprightEmoji text={item.teamName} />
</span>
)}
{/* Parent label as a context chip on promoted sub-items
— disambiguates rows that share a label (e.g., two
"Run script" sub-items, one under Setup experience
and one under Manage policy automations). */}
{sub && (
<span className={`${baseClass}__item-meta`}>
{item.label}
</span>
)}
</Command.Item>
);
})}
</Command.Group>
<Command.Separator className={`${baseClass}__separator`} />
</>
)}
{GROUPS.map((group) => {
const groupItems = groupedItems[group];
if (!groupItems?.length) {
return null;
}
// Drop items already shown in Best match so the user doesn't see
// the same row twice. Items with only a sub-item promoted stay —
// renderItem hides the promoted sub-item inline.
const visibleGroupItems = groupItems.filter(
(item) => !bestMatchIds.has(item.id)
);
if (!visibleGroupItems.length) {
return null;
}
return (
<Command.Group
key={group}
heading={group}
className={`${baseClass}__group`}
>
{visibleGroupItems.map(renderItem)}
</Command.Group>
);
})}
</>
);
const handleSelectHost = useCallback((hostId: number) => {
// Match ManageHostsPage.handleRowSelect — navigate without fleet_id so
// we don't switch the user's current team context. The host details
// page reads the host's team from the host record itself.
setOpen(false);
browserHistory.push(paths.HOST_DETAILS(hostId));
}, []);
const handleSelectSoftware = useCallback(
(softwareId: number) => {
setOpen(false);
const basePath = paths.SOFTWARE_TITLE_DETAILS(String(softwareId));
// Software titles are team-scoped; the destination page reads fleet_id
// from the URL. Pass the user's current team so we don't drop them on
// "All fleets" view by accident. Unassigned (id 0) is preserved too.
if (currentTeam && currentTeam.id !== APP_CONTEXT_ALL_TEAMS_ID) {
browserHistory.push(`${basePath}?fleet_id=${currentTeam.id}`);
} else {
browserHistory.push(basePath);
}
},
[currentTeam]
);
const handleSelectReport = useCallback(
(reportId: number) => {
setOpen(false);
const basePath = paths.REPORT_DETAILS(reportId);
if (currentTeam && currentTeam.id !== APP_CONTEXT_ALL_TEAMS_ID) {
browserHistory.push(`${basePath}?fleet_id=${currentTeam.id}`);
} else {
browserHistory.push(basePath);
}
},
[currentTeam]
);
const handleSelectPolicy = useCallback(
(policyId: number) => {
setOpen(false);
const basePath = paths.POLICY_DETAILS(policyId);
if (currentTeam && currentTeam.id !== APP_CONTEXT_ALL_TEAMS_ID) {
browserHistory.push(`${basePath}?fleet_id=${currentTeam.id}`);
} else {
browserHistory.push(basePath);
}
},
[currentTeam]
);
if (isNoAccess) {
return null;
}
return (
<Command.Dialog
open={open}
onOpenChange={handleOpenChange}
label="Command palette"
className={baseClass}
overlayClassName={`${baseClass}__overlay`}
contentClassName={`${baseClass}__content`}
filter={(value, searchTerm) => {
// Best match items bypass cmdk's substring filter — they're already
// scored against the query, and we want them shown regardless.
if (value.startsWith("BEST_MATCH ")) {
return 1;
}
// Picker results are pre-filtered by the server; show everything.
if (isPreFilteredResult(value)) {
return 1;
}
// Default cmdk filtering
if (value.toLowerCase().includes(searchTerm.toLowerCase())) {
return 1;
}
return 0;
}}
>
<HighlightSubscriber onChange={handleHighlightChange} />
{/* cmdk's Dialog wraps Radix Dialog.Content, which requires a Title and
a Description for screen reader accessibility — without these, Radix
logs a console error/warning on every open. Both are rendered
visually hidden so the palette UI stays unchanged. */}
<DialogTitle className="sr-only">Command palette</DialogTitle>
<DialogDescription className="sr-only">
Search for a page, command, or resource across Fleet.
</DialogDescription>
<div className={`${baseClass}__input-wrapper`}>
{page !== "root" && (
// tabIndex=-1 so Radix's open-autofocus skips the back button
// and lands on Command.Input — Backspace and Escape already
// cover keyboard "go back" so we lose nothing.
<button
type="button"
aria-label="Back"
tabIndex={-1}
className={`${baseClass}__back-button`}
onClick={goBack}
>
<Icon name="arrow-left" color="ui-fleet-black-75" />
</button>
)}
<Command.Input
ref={inputRef}
className={`${baseClass}__input`}
placeholder={
pickerPagePlaceholder ?? "Search for a page or command..."
}
value={search}
onValueChange={setSearch}
onKeyDown={onKeyDown}
/>
{page === "root" && canSwitchFleet && (
<button
type="button"
// Locks the accessible name to the team name so the kbd
// shortcut pills (aria-hidden) can't pollute it later if
// the markup changes.
aria-label={`Switch fleet (currently ${fleetSwitcherLabel})`}
className={`${baseClass}__fleet-switcher`}
onClick={() => goToPage("switch-fleet")}
onKeyDown={(e) => {
// Stop Enter from bubbling to cmdk-root, which would
// activate whichever list item is currently highlighted.
// The button's native Enter still triggers the click above.
if (e.key === "Enter") {
e.stopPropagation();
}
}}
>
<span className={`${baseClass}__fleet-switcher-label`}>
{fleetSwitcherLabel}
</span>
<span
aria-hidden
className={`${baseClass}__fleet-switcher-shortcut`}
>
<kbd className={`${baseClass}__shortcut-key`}>
{isMacPlatform ? "⌘" : "Ctrl"}
</kbd>
<kbd className={`${baseClass}__shortcut-key`}></kbd>
<kbd className={`${baseClass}__shortcut-key`}>F</kbd>
</span>
</button>
)}
{page !== "root" && <kbd className={`${baseClass}__esc-hint`}>ESC</kbd>}
</div>
{/* Announce picker-page transitions to screen readers — the placeholder
text changes but isn't reliably announced on its own. Strip the
trailing ellipsis so the announcement isn't verbalized as
"dot dot dot" by some screen readers. */}
<div role="status" aria-live="polite" className="sr-only">
{page === "root"
? ""
: pickerPagePlaceholder?.replace(/\.{3}$/, "") ?? ""}
</div>
<Command.List className={`${baseClass}__list`}>
{/* Picker pages render their own contextual empty state, so only show
cmdk's generic Empty on the root page. */}
{page === "root" && (
<Command.Empty className={`${baseClass}__empty`}>
No results found.
</Command.Empty>
)}
{page === "root" && renderRootPage()}
{page === "switch-fleet" && (
<FleetPicker
// Drop "All fleets" and "Unassigned" on pages whose useTeamIdParam
// config rejects them (e.g. Dashboard hides Unassigned; the Fleet
// → Users/Options/Settings admin pages hide All). Otherwise the
// option appears valid but selecting it triggers a redirect-to-
// default and silently reverts. Read pathname at render — the
// palette can't be navigated away from without closing, so the
// value is stable per session.
availableTeams={availableTeams?.filter((t) => {
if (
t.id === APP_CONTEXT_NO_TEAM_ID &&
!pathSupportsUnassigned(window.location.pathname)
) {
return false;
}
if (
t.id === APP_CONTEXT_ALL_TEAMS_ID &&
!pathSupportsAllFleets(window.location.pathname)
) {
return false;
}
return true;
})}
currentTeam={currentTeam}
search={search}
onSelect={handleSwitchFleet}
/>
)}
{page === "view-host" && (
<HostPicker
search={search}
showTeamColumn={!!isPremiumTier && !isPrimoMode}
onSelect={handleSelectHost}
/>
)}
{page === "view-software" && (
<SoftwarePicker
search={search}
currentTeam={currentTeam}
onSelect={handleSelectSoftware}
/>
)}
{page === "view-software-library" && (
<SoftwarePicker
search={search}
currentTeam={currentTeam}
scope="library"
onSelect={handleSelectSoftware}
/>
)}
{page === "view-report" && (
<ReportPicker
search={search}
currentTeam={currentTeam}
isViewerObserver={isViewerObserverInScope}
onSelect={handleSelectReport}
/>
)}
{page === "view-policy" && (
<PolicyPicker
search={search}
currentTeam={currentTeam}
isPremiumTier={!!isPremiumTier}
onSelect={handleSelectPolicy}
/>
)}
</Command.List>
</Command.Dialog>
);
};
export default CommandPalette;