-
Notifications
You must be signed in to change notification settings - Fork 15
Command Palette Developer Guide
How the βK launcher is built: one global injection point, one client file, and one search endpoint that fans out across seven modules while never returning a row the analyst couldn't already see. Shipped as #932.
The user-facing page is The command palette.
Colour key: β¨οΈ client Β· π¨ CSS Β· π API Β· π§© injection Β· π₯οΈ module deep-link Β· π docs
| π¨ | File | What it does |
|---|---|---|
| β¨οΈ | assets/js/command-palette.js |
All of the client side. Builds the overlay, the βK/Ctrl-K trigger, keyboard navigation, client-side filtering of modules + actions, and the debounced entity search |
| π¨ | assets/css/command-palette.css |
The overlay, styled entirely from theme.css tokens so it follows light/dark |
| π | api/system/global_search.php |
The aggregator: one request fans out to tickets, changes, problems, knowledge, contracts, assets and CMDB, each gated and scoped |
| π§© | includes/waffle-menu.php |
Injection point β renderWaffleMenuJS() emits the assets, window.CP_BASE and the visibility-filtered window.CP_MODULES, so the palette loads on every analyst page |
| π₯οΈ | asset-management/index.php |
Gained a ?asset_id= deep link so asset results can open the asset itself (see Β§5) |
| π |
CHANGELOG.local.md, README.md, this wiki |
logged as #932 |
Note what is not in that list: no new table, no migration, no db_verify change. The palette is read-only β it navigates and searches existing data and stores nothing.
There is no per-page wiring. renderWaffleMenuJS() in includes/waffle-menu.php already runs on every analyst page (it's how toast.js and confirm.js get everywhere), so the palette rides along with it:
<link rel="stylesheet" href="<?php echo BASE_URL; ?>assets/css/command-palette.css?v=1">
<script>
window.CP_BASE = <?php echo json_encode(BASE_URL); ?>;
window.CP_MODULES = <?php echo json_encode($cpModules, JSON_UNESCAPED_SLASHES); ?>;
</script>
<script src="<?php echo BASE_URL; ?>assets/js/command-palette.js?v=2"></script>Two things worth knowing:
π
$modulesis a top-level global in this file, butrenderWaffleMenuJS()is a function β so it needsglobal $modules;. Miss that and$cpModulesbuilds from an undefined variable and the palette ships with an empty module list: it opens, but "Go to" is blank. This bit on the first build.
CP_MODULES is filtered server-side with the exact rule the waffle panel uses β so the palette can never offer a destination the launcher wouldn't:
foreach ($modules as $cpKey => $cpMod) {
if ($cpKey === 'system') {
if (!sessionIsAdmin()) continue; // System is admin-only
} elseif ($cpAllowed !== null && !in_array($cpKey, $cpAllowed)) {
continue; // everything else honours allowed_modules
}
$cpModules[] = ['key' => $cpKey, 'name' => $cpMod['name'],
'path' => $cpMod['path'], 'icon' => $cpMod['icon']];
}The module display names are already localised ($modules resolves them through t()), so the palette is translated for free.
Cache-buster: the manual ?v= convention applies. command-palette.js is at ?v=2 β bump it on every edit or browsers run the stale file. The CSS is at ?v=1.
command-palette.js is a single IIFE, guarded against double-inclusion by window.__cmdpInit. It reads the two globals the page injected (CP_BASE, CP_MODULES) and owns everything else.
Three kinds of row, one render path. render(serverResults) builds the list from, in order:
-
Go to β
CP_MODULES, filtered client-side by a substring match with a small prefix boost (score()), sotasranks Tasks first. -
Actions β a static
COMMANDSarray (Toggle dark mode,Sign out), same matcher. -
Search results β the server response, grouped by
typein a fixed order.
Each rendered row pushes an activate thunk into a parallel pending[] array; after innerHTML, the rows are wired to their thunk by index. A nav row navigates (location.href = BASE + path), an action row runs its run(), a result row navigates to BASE + result.url.
Keyboard model (onKeydown): β/β move activeIx with wrap, Enter fires the active thunk, Esc closes. Escape also calls stopPropagation() so the page's own global Escape handler (which closes the waffle panel) doesn't also fire.
The search is debounced and race-guarded:
function doSearch(q) {
var seq = ++searchSeq; // monotonic ticket
fetch(BASE + 'api/system/global_search.php?q=' + encodeURIComponent(q), β¦)
.then(function (data) {
if (seq !== searchSeq) return; // a newer query superseded this one
if (input.value.trim() !== q) return;
render(data && data.success ? data.results : []);
});
}searchSeq means a slow response for ser can never overwrite the results for serv typed a moment later β the classic autocomplete bug. Client-side rows (modules, actions) render instantly on every keystroke; only the entity search waits 180 ms and hits the network.
π The search min-length lives in two places on purpose. The client skips the fetch below two characters, and the endpoint returns
[]below two characters. The client rule saves a request; the server rule is the one that actually holds, because the endpoint is a URL anyone signed-in can call directly.
To add a new result type to the client you touch four small maps: ICONS, TYPE_LABEL, pluralType(), and the group-order array in render(). That's it β the rendering itself is type-agnostic.
api/system/global_search.php is the whole backend. It follows the standard read-endpoint preamble (session_start(['read_and_close' => true]), auth check, Content-Type: application/json) and then fans out. Every source passes two gates before it contributes a single row:
$allowed = $_SESSION['allowed_modules'] ?? null; // null = unrestricted
$can = function (string $key) use ($allowed): bool {
return $allowed === null || in_array($key, $allowed, true);
};This mirrors the CP_MODULES filter, so what you can search matches what you can navigate to. Reads aren't capability-gated in FreeITSM β module membership is the right gate here, not a Layer-2 capability.
Each source runs through its own module's tenancy filter, exactly as that module's own list endpoint does β never a generic one, because the meaning of NULL differs by table:
| Source |
$can() key |
Scope helper | Table & match |
|---|---|---|---|
| Tickets | tickets |
ticketTenantFilter($conn, $id, 't') |
tickets β ticket_number / subject, deleted_datetime IS NULL
|
| Changes | changes |
activeTenantFilter($conn, $id, 'c') |
changes β title, or id from a CHG-#### (see below) |
| Problems | problems |
ticketTenantFilter($conn, $id, 'p') |
problems β problem_number / title
|
| Knowledge | knowledge |
knowledgeTenantFilter($conn, $id, 'a') |
knowledge_articles β title, non-archived |
| Contracts | contracts |
none |
contracts β contract_number / title
|
| Assets | assets |
activeTenantFilter($conn, $id, 'a') |
assets β hostname / service_tag
|
| CMDB | cmdb |
activeTenantFilter($conn, $id, 'o') |
cmdb_objects β name
|
π Knowledge uses
knowledgeTenantFilter, notactiveTenantFilter. For Knowledge aNULLtenant means "shared with every company" β the opposite of tickets/assets, whereNULLmeans "unassigned, owned by Default". Feed Knowledge through the wrong filter and every shared article silently vanishes from a non-Default company. See Multi-Tenancy: worked examples.
π Contracts has no
tenant_idcolumn β it's install-wide β so there's deliberately no scope filter to apply. Don't add one reflexively; check the table.
if ($can('changes')) {
try {
[$tSql, $tArgs] = activeTenantFilter($conn, $analystId, 'c');
β¦
} catch (Exception $e) { /* table not ready β no change results */ }
}On a part-migrated install (one that hasn't run Database Verify) a missing table or column throws. Wrapping each source means that source simply contributes nothing, rather than a missing problems table failing the entire search. It's the same defensive posture the tenancy helpers themselves take.
Changes have no stored reference β CHG-0042 is derived from the id at display time. So searching "CHG-42" or "42" must match changes.id = 42:
$digits = preg_replace('/\D+/', '', $q); // "CHG-42" β "42"
$idClause = $digits !== '' ? ' OR c.id = ?' : '';
$sql = "SELECT c.id, c.title FROM changes c
WHERE (c.title LIKE ?" . $idClause . ")" . $tSql . " β¦";and the row's subtitle is re-derived with sprintf('CHG-%04d', $id) so the palette shows the reference the analyst knows.
Each source caps at $perType = 6 and the query is %β¦% on a 2-char minimum. Results are a flat list of { type, module, id, title, subtitle, url }; the client does the grouping.
A palette result is only useful if it opens the record. Six of the seven modules already supported opening one record by URL, so their result url just points at the existing route:
| Module | URL |
|---|---|
| Tickets | tickets/?ticket_id=<id> |
| Changes | change-management/?change_id=<id> |
| Problems | problem-management/?problem_id=<id> |
| Knowledge | knowledge/?article=<id> |
| Contracts | contracts/view.php?id=<id> |
| CMDB | cmdb/object.php?id=<id> |
Assets were the exception β the module selected a record via an in-page selectAsset(id) click with no URL routing at all. Rather than link to a bare list, the fix was a small deep link in asset-management/index.php:
Promise.all([ loadAssets(), loadAssetTypesForDropdown(), β¦ ]).then(function () {
var aid = new URLSearchParams(window.location.search).get('asset_id');
if (aid) { var n = parseInt(aid, 10); if (n) selectAsset(n); }
});The Promise.all matters: selectAsset() renders the Type/Status/Location dropdowns from lookups that load separately, so the deep link waits for them β otherwise a deep-linked asset paints with empty dropdowns on first load.
php -l proves nothing (a fatal is served as HTTP 200), and grepping rendered HTML for a function name proves less. What was actually done:
-
Server side, against a forged admin session (
session_startfile withanalyst_id): confirmed the page emitsCP_MODULESpopulated with all 21 modules β the check that caught the missingglobal $modules, which would otherwise have shipped an empty launcher. -
The endpoint hit directly for a spread of queries, asserting all seven types come back with correct
urlandsubtitleβCHG-0008 β change-management/?change_id=8,PRB-00024 β problem-management/?problem_id=24,CON-2023-005 β contracts/view.php?id=3, and so on. Searching a reference (CHG-β¦,PRB) was checked as well as searching a title. -
The 2-char floor confirmed to return
[]at the endpoint, not just in the client. -
The client in headless Chrome: a real
Ctrl+KKeyboardEventdispatched atdocumentopened the palette; asserted the empty-query view lists modules + actions, that typingtasfilters to Tasks, that ArrowDown moves the active row, and that Escape closes it β with anerrorlistener proving no exceptions were thrown on load.
-
A new search source. Add a gated
try/catchblock toglobal_search.phpreturning{ type, module, id, title, subtitle, url }, using that module's own tenancy filter and a real deep-link URL (add one to the module first if it has none, as assets needed). Then teach the client four maps (Β§3). Check the multi-tenancy meaning ofNULLfor that table before choosing the filter β it is the easiest thing to get wrong. -
A new global action. Push to the
COMMANDSarray incommand-palette.js: alabel, somekeywords, and arun(). -
Contextual actions (assign-to-me / close the ticket you're looking at) are the intended next step. They differ from today's global actions in that they depend on page context, so they'd be injected per-page (or read from the URL/DOM) rather than hard-coded in
COMMANDS, and would run through the same ticket service the inbox uses β never a second write path.
- The command palette β the analyst-facing page
- Module Access Control β the first of the two gates
- Multi-Tenancy: worked examples β why Knowledge scopes differently
- Theming & Dark Mode β the tokens the overlay is built from
- Internationalisation β why the module names come through already translated
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)