feat: add reusable global miner search - #973
Conversation
There was a problem hiding this comment.
Pull request overview
Adds reusable, debounced global miner search across fleet tables and selection flows, backed by server-side filtering.
Changes:
- Adds validated multi-field substring search to the protobuf API and SQL store.
- Integrates search with URLs, saved views, counts, exports, and bulk actions.
- Adds reusable search UI and coverage for server/client behavior.
Reviewed changes
Copilot reviewed 22 out of 24 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
server/internal/domain/stores/sqlstores/device.go |
Routes searched counts through dynamic queries. |
server/internal/domain/stores/sqlstores/device_filters.go |
Builds escaped multi-field search predicates. |
server/internal/domain/stores/sqlstores/device_filters_test.go |
Tests search SQL and dynamic routing. |
server/internal/domain/stores/sqlstores/device_filters_integration_test.go |
Tests database search and count consistency. |
server/internal/domain/stores/interfaces/device.go |
Adds search to the domain filter. |
server/internal/domain/fleetmanagement/service.go |
Parses and bounds search input. |
server/internal/domain/fleetmanagement/parse_filter_test.go |
Tests search parsing and limits. |
proto/fleetmanagement/v1/fleetmanagement.proto |
Adds the search API field. |
client/src/shared/components/Search/Search.tsx |
Makes search IDs, labels, and sanitization configurable. |
client/src/protoFleet/features/fleetManagement/views/viewSummary.ts |
Summarizes saved search filters. |
client/src/protoFleet/features/fleetManagement/views/savedViews.ts |
Persists search in saved views. |
client/src/protoFleet/features/fleetManagement/utils/fleetVisiblePairingFilter.ts |
Preserves search through pairing filters. |
client/src/protoFleet/features/fleetManagement/utils/fleetVisiblePairingFilter.test.ts |
Tests search preservation. |
client/src/protoFleet/features/fleetManagement/utils/filterUrlParams.ts |
Encodes and parses search URLs. |
client/src/protoFleet/features/fleetManagement/utils/filterUrlParams.test.ts |
Tests search URL handling. |
client/src/protoFleet/features/fleetManagement/components/MinerList/MinerList.tsx |
Adds search to the fleet table. |
client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/useMinerActions.tsx |
Treats search as an active filter. |
client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/useMinerActions.test.tsx |
Tests searched bulk-action copy. |
client/src/protoFleet/components/MinerSelectionList.tsx |
Adds search to miner pickers. |
client/src/protoFleet/components/MinerSelectionList.test.tsx |
Tests picker search and select-all behavior. |
client/src/protoFleet/components/MinerSearchInput.tsx |
Implements reusable debounced search. |
client/src/protoFleet/components/MinerSearchInput.test.tsx |
Tests debounce and input normalization. |
client/src/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb.ts |
Regenerates the TypeScript protobuf binding. |
Suppressed comments (2)
client/src/protoFleet/components/MinerSearchInput.tsx:56
- This cleanup only handles unmounts, so a pending query survives an external
initialValuechange. For example, typing and then clearing filters, navigating back, or applying a saved view within 250 ms re-seeds the displayed value, but the stale timer subsequently fires and restores the old search. Cancel the pending timeout wheneverinitialValuechanges as well.
// Unmount-only: keying this on `onQueryChange` would cancel a pending search
// whenever an unrelated navigation changed the callback's identity.
useEffect(
() => () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
},
[],
);
client/src/protoFleet/components/MinerSearchInput.tsx:65
- The API contract rejects search queries longer than 255 Unicode code points, but this input permits and emits arbitrary lengths. Entering 256+ characters therefore guarantees every list/count request will fail and surfaces only the generic “Failed to load miners” toast. Apply the same bound in the input sanitizer so invalid queries cannot be sent.
sanitize={trimLeadingWhitespace}
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| const handleChange = useCallback((value: string) => { | ||
| if (timeoutRef.current) clearTimeout(timeoutRef.current); | ||
| timeoutRef.current = setTimeout(() => { | ||
| onQueryChangeRef.current(value); | ||
| }, SEARCH_DEBOUNCE_MS); |
The all-mode bulk-action filter dropped the new searchQuery field, so "Select all" under a search sent unpair/rename/worker-name/reparent at the whole fleet while the confirmation copy claimed a filtered scope. Also from review of the global miner search change: - keep a pending search debounce alive across onQueryChange identity churn - bound search_query by runes, matching the proto's max_len contract - reuse the name sort expression so search-by-name and sort-by-name agree - stop the search integration test from nulling NOT NULL mac_address
…icate Three call sites that pair a count with a row set each carried their own copy of the same ten-term boolean, and every new filter dimension had to be added to all three in lockstep. The copies agreed, but nothing kept them agreeing; the search field in the previous commit had to be added to each one by hand. Collapse them onto minerFilterParams.requiresDynamicQuery(). Cover every dimension with a unit test, and assert end-to-end that a search-scoped list total, state breakdown, and model-group counts all agree.
The debounced handler emitted value.trim(), the miner list persisted that to the URL, and it came straight back as initialValue. Search and Input both re-seed their displayed value from that prop, so the trimmed echo overwrote the field while it still had focus. Typing "rack ", pausing past the 250ms debounce, then typing "7" produced "rack7": the persisted value came back without the space and the next keystroke landed against the trimmed text. Any multi-word query typed at human speed was affected. Emit the query as typed and leave trimming to the consumers, which already do it — the server trims search_query, the miner list trims when reading the URL param, and the selection list trims before testing for an active search. Leading whitespace is dropped via Input's sanitize hook, which applies it to the displayed text and the emitted value together so the two cannot disagree; trailing whitespace stays typable.
Search's compact mode is not a size modifier — it drops the border, the focus ring and the clear button, and shrinks the field to 24px. Both real usages had opted into it, so the miner list rendered 50%-opacity label text on the page background next to bordered secondary buttons, with nothing to signal it was a field at all. Replace the compact boolean with an explicit variant: compact bare, no container — only for callers supplying their own toolbar bordered at control height, with a clear button default the 56px field used in modals, with the Cmd-K hint toolbar reuses the bare input and owns the container, so the field matches the height of the compact buttons beside it rather than the 56px modal field. It supplies its own clear button because Input renders one only at the default height, and returns focus to the field after clearing. Also widen the wrapper from w-24 to w-full on mobile: 96px fit about eight characters.
…ounce MinerSelectionList treats an active search and an all-mode selection as mutually exclusive: canSelectAll requires an empty searchQuery, and an effect force-clears allSelected when that stops holding. Both read the applied filter, which the search input updates only after its 250ms debounce, so the invariant had a 250ms hole. Consumers call getSelection() straight from their submit handler with no second confirmation, so a keystroke followed by a click inside that window committed all-mode with a searchQuery the filter still reported as empty. Add a synchronous onQueryInput alongside the debounced onQueryChange and gate select-all on both the applied and the pending query. Gating on both also covers the reverse race, where clearing the field would otherwise re-offer select-all 250ms before the list stopped being filtered. Reported by Copilot on #973. The miner list is unaffected: it supports all-mode with a search by design, reads currentFilter when the action is confirmed rather than when it is opened, and resets the selection when the URL filter changes.
f55db6d to
659fcbe
Compare
🔐 Codex Security Review
Review SummaryOverall Risk: HIGH Findings[HIGH] Automated review incomplete
NotesHuman review is required because the bounded automated review was incomplete. Generated by Codex Security Review | |
Disambiguate ProtoOS and single-miner log search selectors from the new accessible clear button. Keep stale debounced queries from surviving external value resets, and cap typed queries at the API's 255-Unicode-code-point boundary. Co-authored-by: Jared Marrz <jmarr@squareup.com> Signed-off-by: Jared Marrz <jmarr@squareup.com>
Reviewable diff: +185/-39 across 13 files (excludes generated, test, and story files).
Summary
Fleet operators can now find miners globally from one debounced search field across name, device identifier, serial number, MAC address, IP address, and worker name. The search is available on the fleet miner table and reusable miner-selection flows, while preserving URL filters, saved views, pagination, totals, and safe selection behavior. Closes #809.
How it works
The client debounces input for 250 ms and adds the query to the existing
MinerListFilter. The Fleet service trims and bounds the query, then the SQL store applies a parameterized, literal substringILIKEpredicate across the searchable miner fields. The same filtered query drives paginated rows, totals, state counts, model groups, exports, and filtered identifier resolution.In selection modals, the shared miner list sends the query through the same server-side filter. Modal Select All is disabled while searching so a search cannot be accidentally represented as an organization-wide
allDevicesselection.Diagrams
Areas of the code involved
proto/fleetmanagementMinerListFilter.search_query.server/internal/domain/fleetmanagementserver/internal/domain/stores/sqlstoresclient/src/protoFleet/componentsMinerSelectionList.client/src/protoFleet/features/fleetManagementclient/src/shared/components/SearchKey technical decisions & trade-offs
MinerListFilterinstead of adding a search RPC, so pagination, counts, exports, and selection flows share one server-side implementation.ILIKEmatching instead of client-side filtering, so searches work across pages and do not expose cross-organization data.Testing & validation
just gennpm run build:protoFleetpassed.fleetuser password.