Skip to content

fix(ui): give the pending devices count a page to land on - #7048

Closed
otavio wants to merge 7 commits into
masterfrom
fix/pending-registrations-list
Closed

fix(ui): give the pending devices count a page to land on#7048
otavio wants to merge 7 commits into
masterfrom
fix/pending-registrations-list

Conversation

@otavio

@otavio otavio commented Sep 5, 2026

Copy link
Copy Markdown
Member

What

Adds a review queue at /pending-devices listing every device waiting on a decision across all
install keys, and points the dashboard card at it. The card's count and the list are now the same
number from the same table.

Why

The Dashboard's Pending Devices card linked to /devices?status=pending. The devices list hides
every status but accepted and an effect forced the parameter back, so the click landed on the
accepted list with no error and no explanation — a real count with no destination.

Acceptance had moved to the install keys area, which left no list of what was waiting: a pending
registration was reachable only by opening each key's activity in turn and scanning it.

Closes #7043

Changes

  • new page: lists by device status rather than by key, which is what lets it agree with the
    dashboard count. Each row names the key that enrolled it, so the context acceptance needs
    survives the change of axis. Route is /pending-devices, not /devices/pending, because
    NavLink matches by prefix — nested, the sidebar would light up two entries at once, and an
    exact match on Devices would stop it highlighting on the device detail and add-device pages.
  • links: dashboard card and the AddDevice callout. The latter also called the destination a
    tab, which has not existed since acceptance moved.
  • devices list: the forced status left the pending and rejected column sets, their tabs and
    the accept/reject/remove dialog unreachable, and the pending set duplicated the new queue line
    for line. Removed; the tab now links out instead of switching status.
  • pending count: excluded containers, so the count matches the list it links to — the list
    injects platform ne connector unconditionally. Only the pending count moved; the registered
    count answers the same question for licence and firewall evaluation.
  • wizard: the first-run screen keys off those counts, so a namespace holding only a pending
    container would have been offered a first device it already had.
  • docs: reviewing a pending enrollment described the key's activity page as where it happens
    "rather than in the device list"; the two are now alternatives.

Testing

The count-versus-list agreement rests on matching the list's operator exactly. The stats predicate
uses <> rather than IS DISTINCT FROM deliberately: platform is nullable and the list's ne
filter emits <>, so a null-safe comparison here would count a row the list does not show and
restore the disagreement. TestPgStore/StatsStore covers a pending container; the NULL case is not
expressible through the fixture and rests on that reasoning.

Worth probing: a namespace whose only entity is a pending container should still get the dashboard,
not the wizard.

DeviceDetails paired useInstallKeys with resolveEnrollmentSource and repeated the page size
the lookup depends on. A second caller was about to repeat both, so the pair moves behind
useEnrollmentSource and the page size stops being a literal each caller has to agree on.

The lookup is client-side over a single page of keys, so a namespace past that page resolves
its tail to null rather than to a name. That limit is why the hook's doc comment states it:
it is not visible from the call site, and a caller that assumes otherwise renders a dash.

KeyValueChip moves to components/common for the same reason — a second page tree needs it,
and reaching across into pages/install-keys/ to get it is not a dependency worth having.
Acceptance moved to the install keys area, which left no list of what is waiting: a pending
registration was reachable only by opening each key's activity in turn and scanning it. The
dashboard still counted them, so the count named work with nowhere to go.

The queue lists by device status rather than by key, which is what lets it agree with the
dashboard count — both now answer the same question of the same table. Each row names the
key that enrolled it, so the context acceptance needs survives the change of axis.

The route is /pending-devices rather than /devices/pending because NavLink matches by prefix:
nested under /devices, the sidebar would light up both entries at once, and giving Devices an
exact match instead would stop it highlighting on the device detail and add-device pages.
Both links pointed at /devices?status=pending. The devices list hides every status but
accepted and forces the parameter back, so the click landed on the accepted list with no
error and no explanation — the count on the card was real, the destination was not.

AddDevice's link also called the destination a tab. There has not been one since acceptance
moved to the install keys area, so the word was describing the page it failed to reach.

Fixes: #7043
An effect forced the status back to accepted on every render, so the pending and rejected
column sets, their tabs, the status constraint and the handler that switched between them
could not be reached. The accept, reject and remove actions went with them: nothing could
open the confirmation dialog, so the dialog and the two hooks behind it were dead as well.

Keeping them was not free. The pending column set duplicated, line for line, the one the new
review queue renders, so the same markup existed twice with only one copy reachable.

The tab that used to switch status now links out: to the queue for what is waiting, and to
install keys for how devices get admitted. The list is the accepted fleet and says so.
The count fed a dashboard card whose only destination was the device list, and that list
excludes connectors. A pending container was therefore counted and then absent from the page
the count sends you to, which is the one thing a count next to a link must not do.

The comparison is <> rather than IS DISTINCT FROM, and the difference matters: the device
list builds its filter from the ne operator, which emits <>, and platform is nullable. A
null-safe comparison here would count a row the list does not show, restoring the disagreement
this removes. Matching the list exactly is the point, quirk included.

Only the pending count moves. The registered count answers the same question for licence and
firewall evaluation, so narrowing it would move a limit rather than a label.

Pending containers keep their own path: the containers list still has a tab for them.
…container

The wizard shows when the device counts are all zero. Pending stopped counting containers one
commit ago, and registered and rejected still do, so the counts can now read empty for a
namespace whose only entity is a pending container — and the dashboard would offer to connect
a first device to someone who already has one waiting.

Containers are asked for only once the counts already say empty, so the common path keeps the
single stats request it had.
Reviewing a pending enrollment described the key's activity page as the place it happens,
"rather than in the device list". That was true when it was written and stops being true one
commit earlier: the queue lists every device waiting on a decision across all keys, so the
two are now alternatives rather than one correct answer.

Troubleshooting still told readers to check a Pending tab, which has not existed since
acceptance moved to install keys.
@otavio
otavio requested review from a team as code owners September 5, 2026 18:59
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

Claude finished @otavio's task in 4m 12s —— View job


Code Review Complete

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

A few things I specifically verified, since they're the subtle load-bearing parts of this change:

  • Count ↔ list agreement. The new platform <> 'connector' filter in buildPendingDevicesQuery (server/api/store/pg/stats.go:102) matches the list exactly: /api/devices unconditionally appends a platform ne connector property filter (server/api/routes/device.go:79-98), and ne compiles to ? <> ? (filters.go:288, asserted at filters_test.go:85). A NULL-platform pending row is excluded by both sides, so the deliberate choice of <> over IS DISTINCT FROM is correct — and in practice platform is persisted as "", never NULL.
  • Dashboard hook order. useContainers({ enabled: looksEmpty }) is called unconditionally before both early returns, with the statsLoading guard relocated into the combined check — no rules-of-hooks violation, and perPage: 1 still yields the true totalCount (from the header, not array length).
  • Permission gating & XSS. Accept/reject stay wrapped in RestrictedAction (device:accept/device:reject); the new route sits under the same ProtectedRoute/NamespaceGuard guards; all device fields render as escaped JSX and the install-key id is encodeURIComponent'd.
  • Tests are distinguishing. The new Go stats test, the Dashboard pending-container test, and the Devices "always asks for accepted" test each fail against origin/master, so they genuinely gate the new behavior.

--- · fix/pending-registrations-list

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

Documentation preview: https://189ece20.shellhub-docs.pages.dev

@otavio

otavio commented Sep 5, 2026

Copy link
Copy Markdown
Member Author

QA validation — run against a live instance

Exercised this branch (251bba1dc) end-to-end against a running dev stack (v0.27.0-rc.8, UI bind-mounted
from the branch so the browser drove branch source), in both community and enterprise editions. Every
feature was reached through its real entry point — sidebar, dashboard card, devices list, add-device page —
rather than by calling endpoints directly.

28 checks executed: 28 pass, 0 fail.

The server fix does what it claims

Seeded a namespace with 4 pending devices, one of them platform='connector':

GET /api/statspending_devices 3
GET /api/devices?status=pendingX-Total-Count 3
GET /api/devices?status=pending&connector=true 1
pre-fix query (status='pending' alone) on the same rows 4

So the count and the list it is read alongside disagreed before and agree now. Still consistent at 13/13
after the fixture grew.

Review queue

  • Reached by clicking the sidebar entry; lists status=pending, sorted created_at desc.
  • Pagination: 13 pending → 10 rows on page 1, 3 on page 2, URL ?page=2, request
    page=2&per_page=10&status=pending&sort_by=created_at&order_by=desc.
  • Install Key column verified on all four branches: key name linking to
    /install-keys/:id/activity, Pairing code, Tenant-only registration, and for a null digest.
  • Accept moved ci-runner-01 out of the queue (3→2) and into the accepted devices list; Reject moved
    legacy-box out (2→1). Confirmed at the database level, and pending_devices dropped accordingly while
    the container stayed excluded.
  • Row click → /devices/:uid. Empty state renders "No devices are waiting for review".

The review queue listing pending devices with the install key that enrolled each one

Page 2 of the same queue, and the empty state in a namespace with nothing pending:

Page two of the pending queue showing the remaining three devices
The pending queue empty state reading No devices are waiting for review

The welcome-screen guard, both directions

namespace contents result
containerns stats all zero, one pending container dashboard renders (not the wizard), after exactly one GET /api/containers?page=1&per_page=1
emptyns genuinely empty wizard renders
dev stats non-empty zero container requests — the common path keeps its single stats call

Dashboard rendering for a namespace whose only entity is a pending container, with all device counts at zero
The welcome screen rendering for a genuinely empty namespace

Also verified

/devices?status=pending no longer filters — the Accepted tab stays selected and the request goes out as
status=accepted; and the accepted list keeps its columns after a device is accepted from the queue:

The devices list at the status equals pending URL, still showing the Accepted tab
The devices list showing the newly accepted device with online dot, SSHID and connect column

The add-device page's link into the queue, on a non-codeless install method:

The add device page showing the sentence linking to the pending list

Devices list no longer status-filters (?status=pending is inert, requests status=accepted) and its
accepted columns are intact; the Pending / Install Keys links; the add-device "pending list" link (via a
non-codeless method); DeviceDetails enrollment source through the new hook; install-keys pages after the
KeyValueChip move; docs build green with the shot registered at /pending-devices (and the committed PNG
is a genuine capture of the new page, not stale); OpenAPI spec still validates and the client regenerates.

/admin/api/stats — verified on an enterprise stack

Brought the stack up in enterprise mode (cloud master compiles clean against this branch with
-tags enterprise) and hit the admin endpoint, which aggregates instance-wide across every namespace:

pending rows instance-wide (2 namespaces, 2 of them containers) 15
pre-fix query (status='pending' alone) 15
post-fix query (excluding connector) 13
GET /admin/api/statspending_devices 13

So the admin surface carries the same correction, not just the namespace-scoped one. No license was needed:
internal/admin/routes/routes.go lists StatsURL under licmiddleware.SkipLicense, and the sibling
/admin/api/auth/token returns 402 on the same unlicensed instance, which confirms the exemption is what
let stats through.

Install-key lifecycle, device removal, live SSH, and the repo's own Vitest/Go suites were out of scope
for this run.

@gustavosbarreto

Copy link
Copy Markdown
Member

#7070 merged and closed #7043, taking a different route: instead of a page listing pending devices across keys, the install key list now says how many devices each key has waiting, in the usage column, and the dashboard card lands there.

Two things from this PR that were right and that #7070 did not carry:

The dead code. 4b0b34300 is the only change that fixed the issue from the inside, and it is still needed: visibleTabs keeps only the accepted tab and an effect forces the parameter back, so the pending and rejected column sets, their tabs and the accept/reject/remove dialog remain unreachable on master. Reopened as #7071, keeping your commit and authorship, with the pending tab pointing at the install keys list rather than the page this PR added.

The used_times observation. Chasing the count in the usage column turned up that the counter was only incremented where the server decided admission by itself, so usage_limit was never enforced for a manual-mode key. That became #7068 and #7069, which merged just before #7070.

@otavio
otavio deleted the fix/pending-registrations-list branch September 10, 2026 18:15
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