Skip to content

feat: add reusable global miner search - #973

Merged
mcharles-square merged 16 commits into
mainfrom
feat/809-global-miner-search
Sep 14, 2026
Merged

mcharles-square merged 16 commits into
mainfrom
feat/809-global-miner-search

Conversation

@mcharles-square

Copy link
Copy Markdown
Collaborator

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 substring ILIKE predicate 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 allDevices selection.

Diagrams

flowchart LR
  A["Operator types search"] --> B["Debounced miner search input"]
  B --> C["MinerListFilter.search_query"]
  C --> D["Fleet management RPC"]
  D --> E["SQL search predicate"]
  E --> F["Paginated rows and matching totals"]
Loading
sequenceDiagram
  participant O as Operator
  participant UI as Miner list or picker
  participant API as Fleet API
  participant DB as Miner store
  O->>UI: Type partial identifier
  UI->>UI: Wait 250 ms
  UI->>API: List request with search_query
  API->>DB: Apply search AND existing filters
  DB-->>API: Rows, count, and cursor
  API-->>UI: Matching page
  UI-->>O: Display matching miners
Loading

Areas of the code involved

Area / package / file What changed Why it matters for review
proto/fleetmanagement Added MinerListFilter.search_query. Defines the API contract shared by list and bulk-selection flows.
server/internal/domain/fleetmanagement Parses, trims, and bounds the query. Establishes the request-validation boundary.
server/internal/domain/stores/sqlstores Adds escaped, parameterized multi-column search and routes search queries through dynamic counts. Ensures substring matching, pagination, totals, and org scoping remain consistent.
client/src/protoFleet/components Added reusable debounced search input and integrated it into MinerSelectionList. Covers rack, group, schedule, alert, and curtailment miner pickers.
client/src/protoFleet/features/fleetManagement Added All Miners URL state, saved-view persistence, and bulk-action copy handling. Keeps search shareable and prevents filtered selections from being described as fleet-wide.
client/src/shared/components/Search Added configurable IDs and labels. Allows multiple miner search controls without duplicate DOM IDs.
Generated protobuf files Regenerated client and server bindings — generated, skip. Required output for the proto contract change.

Key technical decisions & trade-offs

  • Extend the existing MinerListFilter instead of adding a search RPC, so pagination, counts, exports, and selection flows share one server-side implementation.
  • Use parameterized literal ILIKE matching instead of client-side filtering, so searches work across pages and do not expose cross-organization data.
  • Disable modal Select All during a search instead of broadening the generic selector to the whole fleet; explicit row selection remains available.
  • Start without trigram indexes because the expected 3,000+ fleet size is compatible with the existing filtered scan; query plans can justify a later index migration.

Testing & validation

  • just gen
  • Targeted Go parser and SQL filter tests passed.
  • Targeted Vitest tests passed: 84 tests.
  • npm run build:protoFleet passed.
  • TypeScript compilation and targeted ESLint passed.
  • Fleet miner list/action tests passed: 138 tests, 1 skipped.
  • Database integration tests could not run because the local Postgres instance rejected the configured fleet user password.

@github-actions github-actions Bot added the review-policy: needs-review Managed by the Review Policy workflow. label Aug 26, 2026
@github-actions github-actions Bot added javascript Pull requests that update javascript code client server shared labels Aug 26, 2026
@mcharles-square
mcharles-square requested a balanced review from Copilot August 26, 2026 17:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 initialValue change. 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 whenever initialValue changes 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.

Comment thread client/src/protoFleet/components/MinerSearchInput.tsx
mcharles-square added a commit that referenced this pull request Aug 26, 2026
…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.
@mcharles-square
mcharles-square force-pushed the feat/809-global-miner-search branch from f55db6d to 659fcbe Compare August 26, 2026 21:00
@github-actions

github-actions Bot commented Aug 26, 2026 •

Copy link
Copy Markdown

🔐 Codex Security Review

Note: This is an automated security-focused code review generated by Codex.
It should be used as a supplementary check alongside human review.
False positives are possible - use your judgment.

Scope summary

  • Reviewed pull request diff only (87bed2f37552c53a6a558c4723c4ed18b641d97f...4c707e762ee58dd2070f735589b3c11c841bcbd3, exact PR three-dot diff)
  • Model: gpt-5.6-sol

💡 Click "edited" above to see previous reviews for this PR.


Review Summary

Overall Risk: HIGH

Findings

[HIGH] Automated review incomplete

  • Category: Other
  • Description: The automated review produced no usable result for 87bed2f37552c53a6a558c4723c4ed18b641d97f...4c707e762ee58dd2070f735589b3c11c841bcbd3 (workflow run 34886886502; reason: codex-job-timeout, elapsed: unknown, budget: 9 minutes).
  • Impact: The pull request has not received complete automated security, correctness, and reliability analysis.
  • Recommendation: Require human review before merging. Do not treat this result as approval-free or low risk.

Notes

Human review is required because the bounded automated review was incomplete.


Generated by Codex Security Review |
Triggered by: @mcharles-square |
Review workflow run

mcharles-square added a commit that referenced this pull request Sep 4, 2026
…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.
@mcharles-square
mcharles-square force-pushed the feat/809-global-miner-search branch from aeeb399 to 8dbb6f4 Compare September 4, 2026 22:30
mcharles-square and others added 9 commits September 14, 2026 15:31
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.
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>
Render the global miner search as an icon until requested, then autofocus the field and reflow filters and actions responsively. Keep picker search persistent, clear active queries immediately, and cover the expanded and collapsed states in tests and Storybook.

Co-authored-by: Jared Marrz <jmarr@squareup.com>
Signed-off-by: Jared Marrz <jmarr@squareup.com>
The committed bindings kept protoc-gen-go's raw import order, so CI's
`just gen` (which ends in `goimports -w .`) regenerated a different file and
failed the generated-code check.
@mcharles-square
mcharles-square force-pushed the feat/809-global-miner-search branch from 8dbb6f4 to e0a643e Compare September 14, 2026 13:42
…all filter

canSelectAll already withdraws select-all for any non-empty search before it
consults hasUnsupportedAllSelectionFilter, so the search clause inside that
helper could never decide anything. Record why the gate belongs at the call
site instead: a substring match is unrepresentable for every caller, not only
the ones that opt into disableFilteredSelectAll.
…back

The name branch reuses the display-name sort expression, so an unnamed miner
is matched on "<manufacturer> <model>" — searching "S21" finds unnamed S21s.
The field contract listed only the literal columns.
The List filter row is padded on its left edge only, since until now
nothing in it was full-width — content-sized pills never reached the
right edge, so the missing right inset was invisible. The expanded
search field is `phone:w-full`, so it ran flush to the viewport while
every sibling stayed inset.

Mirror the page inset with the same `--list-padding-phone` variable
List already publishes for its left padding, and give the MinerList
stories the Fleet page's layout props so the story reproduces the
page's padding instead of rendering edge-to-edge.
The collapsible-search story failed on every open. The field autofocuses
on expand, and Input hides its label on focus with `visibility: hidden`.
Browsers keep a hidden `<label for>` as the accessible name, but Testing
Library's name computation drops hidden labels, so the play function's
role-and-name query found no textbox the moment the field opened. jsdom
never applies Tailwind, which is why the equivalent unit tests pass.

Query through the label association instead, which does not consult
visibility, scoped to `input` so the collapsed toggle's identical
aria-label cannot match.
@mcharles-square
mcharles-square marked this pull request as ready for review September 14, 2026 15:21
@mcharles-square
mcharles-square requested a review from a team as a code owner September 14, 2026 15:21
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-09-14T19:50:30.384126Z da4d37e New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5460d37859

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread server/internal/domain/stores/sqlstores/device_filters.go
- Search's toolbar variant is fixed at control height (h-8, the same as a
  compact Button). With padding it measured 34px, so swapping the 32px
  toggle for the field nudged the whole row and everything below it by 2px.
- List's leading slot becomes trailingFilterControls. The miner search now
  sits at the trailing edge of the filter group and expands into the gap
  before the right-aligned actions, so no sibling moves on activation. On
  phones it still wraps onto its own full-width line.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 07a1fbea43

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread client/src/protoFleet/components/MinerSearchInput.tsx Outdated
The typed query only reaches the URL, and with it the selection scope
key, after the 250 ms debounce. An all-mode selection made before typing
therefore stayed armed while the field already showed a narrower query,
and a bulk action clicked in that window targeted the whole prior scope.
The list now clears its selection synchronously via onQueryInput whenever
the typed query differs from the applied one.

MinerSearchInput also reported "expanded" for a query supplied by
navigation without tracking that it had done so, so removing the query
collapsed the field without ever reporting the collapse and the miner
list kept its phone-width full-row wrapper. Expansion transitions are now
derived from the combined toggle-or-value state and deduplicated, so the
parent hears both directions.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4c707e762e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread client/src/shared/components/Search/Search.tsx
Comment thread client/src/protoFleet/components/MinerSelectionList.tsx
Comment thread client/src/protoFleet/components/MinerSearchInput.tsx
@github-actions github-actions Bot added review-policy: human-approved Managed by the Review Policy workflow. and removed review-policy: needs-review Managed by the Review Policy workflow. labels Sep 14, 2026
…on view switch

The miner picker replaced its whole list with a spinner whenever a
request was loading with nothing to show, which after a zero-result
search unmounted the search field on the next refinement: focus was lost
and keystrokes typed during the reload were dropped. The spinner now
renders in the list's empty-state row so the header stays mounted.

In the fleet table a query still waiting on the debounce could land on a
saved view applied within that window, because an unchanged applied
search never cancelled the timer. The search input is now keyed on the
view id so a view switch remounts it and discards the pending query.
@github-actions github-actions Bot added review-policy: needs-review Managed by the Review Policy workflow. and removed review-policy: human-approved Managed by the Review Policy workflow. labels Sep 14, 2026
@mcharles-square
mcharles-square enabled auto-merge (squash) September 14, 2026 19:46
@mcharles-square
mcharles-square merged commit 376b12a into main Sep 14, 2026
75 of 76 checks passed
@mcharles-square
mcharles-square deleted the feat/809-global-miner-search branch September 14, 2026 19:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

client javascript Pull requests that update javascript code review-policy: needs-review Managed by the Review Policy workflow. server shared

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add search/filter functionality to the All Miners table (and other miner-selection surfaces)

4 participants