Skip to content

feat: say which install key has devices waiting for a decision - #7070

Merged
gustavosbarreto merged 4 commits into
masterfrom
feat/install-key-pending-count
Sep 10, 2026
Merged

feat: say which install key has devices waiting for a decision#7070
gustavosbarreto merged 4 commits into
masterfrom
feat/install-key-pending-count

Conversation

@gustavosbarreto

Copy link
Copy Markdown
Member

The Dashboard's Pending Devices card pointed at /devices?status=pending, a URL the devices list undoes: it hides every status tab but accepted and an effect forces the parameter back. The count was real, the destination was not.

Acceptance lives in the install keys area, so that list is where the card should land. It just had nothing to say. With two devices sitting pending, both keys read 0 / ∞, because used_times only counts devices already accepted. The one page where a pending registration is reviewed gave no sign that anything was waiting, and finding one meant opening each key's activity in turn.

So the list now says it. The install key payload carries pending_devices, and the usage cell shows it: a device awaiting a decision is a claim on the same allowance an accepted one spends, so that column is the only place able to say that accepting the queue would exhaust the key. While devices wait they take the cell's headline and the spend moves below the bar. On a key with a real limit the bar draws the projection, and hatches when accepting everything waiting would pass the limit.

The count is over devices with status pending rather than history events without a verdict, because an event outlives the device it registered and a removed pending device would keep inflating it. Counting devices also makes the sum across a namespace's keys equal the pending_devices figure the stats endpoint reports, so the card and the page agree.

The Dashboard card and the callout under the install command both point at the install keys list now, and the docs stop describing a Pending tab that has not existed since acceptance moved.

Supersedes #7048, which answered the same issue with a dedicated /pending-devices page and a sidebar entry.

Closes #7043
Closes #7048

@gustavosbarreto
gustavosbarreto requested review from a team as code owners September 10, 2026 15:18
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown

Claude finished @gustavosbarreto's task in 6m 18s —— View job


Code Review Complete

Reviewed 16 files across code quality, security, testing, language patterns, and architecture. 1 inline issue posted (1 low).

Additional notes

Two areas were investigated in depth and cleared, worth noting for the author:

  • pending_devices "omitted from a single-key read" — both openapi/spec/components/schemas/installKey.yaml and the models.InstallKey.PendingDevices doc comment describe a single-key read that omits the field. There is no single-key read endpoint in the community API (ListInstallKeys is the only path that serializes models.InstallKey; Create uses a separate responses.CreateInstallKey). So the description is harmless but slightly aspirational — no actual contract mismatch, and the field is correctly always-present on the list response. Not a code change; flagging only so the wording is intentional.
  • Cross-repo (cloud) — the added PendingDevices field is purely additive. Cloud has no separate install-key store or serialization; it reuses the community Pg store, so pending_devices populates identically in both editions. The store.Store interface signature is unchanged, so the generated cloud mock is unaffected. No breaking change.

Security (SQL injection / cross-tenant scoping of the new subquery), the new Go store test, the UI test wiring, and the bun ColumnExpr/scanonly + React divide-by-zero paths were all verified clean.


To request another review round, comment /review.
· feat/install-key-pending-count

@github-actions

Copy link
Copy Markdown

Documentation preview: https://fb8d944a.shellhub-docs.pages.dev

Comment thread server/api/store/pg/install-key.go Outdated
Comment on lines +78 to +79
// Counts devices rather than history events without a verdict: an event outlives the device it
// registered, so a removed pending device would keep inflating the count.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This explanatory comment on an unexported const goes against the repo's code-style rule (.claude/rules/code-style.md): "Write no comments", with only two exceptions — a doc comment on an exported declaration, and a suppression's reason. pendingDevicesExpr is unexported, so neither applies. The rule specifically calls out this case: rationale like "an ordering invariant" or "why" a design choice was made "belongs in the commit message ... Write it in the commit message, in full."

The rationale here (counting devices vs. history events so a removed pending device doesn't keep inflating the count) is worth keeping — it's already captured well in the PR body, so moving it to the commit message and dropping the code comment satisfies the convention.

Fix this →

@otavio

otavio commented Sep 10, 2026

Copy link
Copy Markdown
Member

A few things to fix before merge:

Correctness

  • used_times is incremented only at auto-accept (services/enrollment.go:127) and the
    webhook callback (services/install-key.go:512). The console accept path
    (UpdateDeviceStatus) never increments it and never checks usage_limit, so the
    projection bar and the · N over warning describe a limit that path does not enforce.
  • enrollmentInstallKey (services/auth.go:183-191) can return "" and install_key_id
    is nullzero, so such a pending device is counted by the stats endpoint and by no key —
    the sum-equals-stats claim has a hole.
  • useAcceptDevicePairing (hooks/useDeviceCode.ts:27) accepts a pending device but does
    not invalidate installKeyList; the count goes stale on the pairing path.
  • pending_devices has no omitempty, so the schema's "omitted from a single-key read" and
    the model's "List only" are not true as written.

Standards

  • The comment on pendingDevicesExpr (store/pg/install-key.go:78-85) is on an unexported
    declaration and its text is already verbatim in commit 00db2554; same for the base.css
    comment. code-style.md allows only doc comments on exported symbols and suppression
    reasons.
  • install_key_tests.go:97-101 uses raw "pending"/"accepted"/"rejected"; every other
    caller of WithDeviceStatus uses models.DeviceStatus*.
  • device-not-appearing.mdx — the body now points at Install Keys, but the heading is still
    ## Is it in Pending?.

Worth a look

  • devices(install_key_id, namespace_id, status) is unindexed, so the correlated count runs
    per key per page.
  • The pending arithmetic sits inline in UsageMeter while its siblings live behind
    helpers.ts, which is why the new test has to assert through a rendered row.

@gustavosbarreto
gustavosbarreto force-pushed the feat/install-key-pending-count branch from aa7e195 to d803804 Compare September 10, 2026 17:05
@gustavosbarreto

gustavosbarreto commented Sep 10, 2026

Copy link
Copy Markdown
Member Author

Pushed. What changed, and what did not.

enrollmentInstallKey can return "". Real, and it breaks the sum-equals-stats claim as written. It does not happen today: every namespace gets a legacy key at creation and migration 013 backfilled the existing ones. But install_key_id is nullable and the claim was stated as an invariant, so the commit message now scopes it to every device that has a key and names the gap.

useAcceptDevicePairing not invalidating installKeyList. Added. The pairing accept admits a device without going through the device mutations, so the count went stale on that path.

pending_devices wording. There is no single-key read that serializes models.InstallKey, so the sentence described a contract that does not exist. Dropped from the schema description and the model doc.

Missing index. Migration 026 adds a partial index on devices (namespace_id, install_key_id) WHERE status = 'pending'. Partial because that is the only status the count reads, and pending is a small, short-lived slice of a namespace's devices. It does not reintroduce what migration 020 removed: that was a presence heartbeat writing last_seen/disconnected_at, where an indexed column changing disqualifies HOT. Neither column is in this index, and status changes only on a decision.

Arithmetic inline in UsageMeter. Moved to getWaitingInfo in helpers.ts, with an it.each over five limit/spent/waiting combinations including the exact-at-limit boundary. It was also why the other test had to assert through a rendered row.

The comments. Both removed. Why the count is over devices rather than over history events is in the server commit message; why the hatch takes currentColor is in the UI one. What stays in the diff is five doc comments on exported declarations, each with an identical sibling in its own file.

The migration keeps its comment, matching every other migration in that directory, 020 included. Say the word and it moves to the commit message too.

used_times and the projection. That is #7069. The counter is incremented only at auto-accept and the webhook callback, and the console accept path does not enforce usage_limit; #7068 tracks it, #7069 fixes it. The sequencing point is right: the projection and the · N over warning are only truthful once #7069 lands, so it should merge first.

Raw status strings. Changed to models.DeviceStatus*. For the record, storetest is split on this: 32 raw against 29 typed.

device-not-appearing.mdx heading. Now "Is it waiting to be accepted?".

@github-actions

Copy link
Copy Markdown

Documentation preview: https://03cece93.shellhub-docs.pages.dev

The install key list carries pending_devices.

It counts devices with status pending rather than history events without
a verdict, because an event outlives the device it registered: a pending
device that was later removed would keep inflating the count. Counting
devices also makes the sum across a namespace's keys match the
pending_devices the stats endpoint reports, for every device that has a
key -- install_key_id is nullable, so a device without one is counted by
the stats endpoint and by no key.

The count is a correlated subquery per key, so migration 026 indexes the
devices it reads.
A waiting device is a claim on the allowance an accepted one spends, so
it belongs in the usage cell. While devices wait they take the headline
and the spend moves below the bar. Where the key has a limit, the bar
draws what accepting the queue would spend, hatched once that would pass
the limit -- the hatch takes currentColor so the caller picks the hue
rather than base.css deciding it.

Every path that decides a device now invalidates the keys list: accept,
reject and remove, and the pairing-code accept, which admits a device
without going through the device mutations.
Both pointed at /devices?status=pending, which the devices list forces
back to accepted. They now point at the install keys list, where the
decision is made.
The Pending tab it described has not existed since acceptance moved to
the install keys area.
@gustavosbarreto
gustavosbarreto force-pushed the feat/install-key-pending-count branch from d803804 to ebcfc26 Compare September 10, 2026 17:11
@github-actions

Copy link
Copy Markdown

Documentation preview: https://1427b2d5.shellhub-docs.pages.dev

@gustavosbarreto

Copy link
Copy Markdown
Member Author

/review

@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown

Claude finished @gustavosbarreto's task in 6m 54s —— View job


Code Review Complete

Reviewed 21 files across code quality, security, testing, language patterns, and architecture — no new issues found. The code looks good as-is.

Notes for the author (not blocking)

  • Prior feedback resolved. The comment on the unexported pendingDevicesExpr const is gone; the retained comment on the exported entity.InstallKey.PendingDevices field states a real scanonly invariant and is fine under the doc-comment exception.
  • SQL scoping verified clean. pendingDevicesExpr is a static const (no interpolation), and the count is doubly bounded: the subquery correlates d.namespace_id = install_key.namespace_id, and the outer InstallKeyList applies the tenant scope via applyScopedOptions. No cross-tenant leak.
  • ColumnExpr("install_key.*") + subquery is correct, not redundant — the first ColumnExpr suppresses bun's auto column list, so install_key.* is needed to keep the real columns while adding the computed pending_devices. ScanAndCount builds a separate count wrapper, so no double-counting.
  • No divide-by-zero in UsageMeter. Bar early-returns for unlimited keys (usage_limit === 0), so (waiting / usage.limit) is only reached when limit >= 1.
  • Cross-repo (cloud) is safe. The PendingDevices field is purely additive; cloud reuses the community Pg store and the store.Store signature is unchanged, so the generated mock is unaffected. No single-key endpoint serializes models.InstallKey, so the missing omitempty exposes nothing unexpected.
  • Tests check out. The Go store test exercises pending-only counting and cross-key isolation deterministically; the UI getWaitingInfo it.each arithmetic is correct across all five cases including the exact-at-limit boundary.
  • One minor, non-blocking UX edge: when a key is already at 100% of its limit and still has pending devices (e.g. 1/1 used · 1 over), the oversubscription hatch in the bar collapses to zero width since there's no room left to draw it. The · N over text below still conveys the overflow, so nothing is lost — flagging only in case the visual was meant to appear there too.

To request another review round, comment /review.
· feat/install-key-pending-count

@gustavosbarreto
gustavosbarreto merged commit 6e1199d into master Sep 10, 2026
43 checks passed
@gustavosbarreto
gustavosbarreto deleted the feat/install-key-pending-count branch September 10, 2026 17:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dashboard's Pending Devices card links to a page that redirects away

2 participants