From d9733b680d784611cbd76c369dfcaa80339c2373 Mon Sep 17 00:00:00 2001 From: "Luis Gustavo S. Barreto" Date: Thu, 10 Sep 2026 14:10:38 -0300 Subject: [PATCH 1/4] feat(server): count each install key's devices awaiting a decision 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. --- .../spec/components/schemas/installKey.yaml | 5 ++ pkg/models/install_key.go | 2 + server/api/store/pg/entity/install-key.go | 3 + server/api/store/pg/install-key.go | 9 +++ ...devices_pending_by_install_key.tx.down.sql | 1 + ...6_devices_pending_by_install_key.tx.up.sql | 15 +++++ server/api/store/storetest/helpers.go | 7 +++ .../api/store/storetest/install_key_tests.go | 55 +++++++++++++++++++ server/api/store/storetest/registry.go | 1 + 9 files changed, 98 insertions(+) create mode 100644 server/api/store/pg/migrations/026_devices_pending_by_install_key.tx.down.sql create mode 100644 server/api/store/pg/migrations/026_devices_pending_by_install_key.tx.up.sql diff --git a/openapi/spec/components/schemas/installKey.yaml b/openapi/spec/components/schemas/installKey.yaml index edb3a077261..fe3829a24a6 100644 --- a/openapi/spec/components/schemas/installKey.yaml +++ b/openapi/spec/components/schemas/installKey.yaml @@ -63,6 +63,11 @@ properties: type: integer description: How many devices have registered with the key. example: 3 + pending_devices: + type: integer + description: How many devices the key registered that are still awaiting a decision. + minimum: 0 + example: 1 last_used_at: type: string format: date-time diff --git a/pkg/models/install_key.go b/pkg/models/install_key.go index 0649a0fbffb..a0ce766baa5 100644 --- a/pkg/models/install_key.go +++ b/pkg/models/install_key.go @@ -106,6 +106,8 @@ type InstallKey struct { UsageLimit int `json:"usage_limit"` // UsedTimes is how many devices have enrolled with the key. UsedTimes int `json:"used_times"` + // PendingDevices counts the key's devices still awaiting a decision. + PendingDevices int `json:"pending_devices"` // LastUsedAt is when a device last enrolled with the key. LastUsedAt *time.Time `json:"last_used_at"` // Ephemeral marks devices enrolled with the key for automatic removal once offline past diff --git a/server/api/store/pg/entity/install-key.go b/server/api/store/pg/entity/install-key.go index 3a51109bcf5..db8b96ac1bc 100644 --- a/server/api/store/pg/entity/install-key.go +++ b/server/api/store/pg/entity/install-key.go @@ -37,6 +37,8 @@ type InstallKey struct { CreatedAt time.Time `bun:"created_at"` UpdatedAt time.Time `bun:"updated_at"` ExpiresAt *time.Time `bun:"expires_at,nullzero"` + // PendingDevices is counted by InstallKeyList; it is not a stored column. + PendingDevices int `bun:"pending_devices,scanonly"` } // InstallKeyFromModel projects an install key into its row form. @@ -100,6 +102,7 @@ func InstallKeyToModel(entity *InstallKey) *models.InstallKey { Reusable: entity.Reusable, UsageLimit: entity.UsageLimit, UsedTimes: entity.UsedTimes, + PendingDevices: entity.PendingDevices, LastUsedAt: entity.LastUsedAt, Ephemeral: entity.Ephemeral, EphemeralTimeout: entity.EphemeralTimeout, diff --git a/server/api/store/pg/install-key.go b/server/api/store/pg/install-key.go index 787954db67e..808e1aada90 100644 --- a/server/api/store/pg/install-key.go +++ b/server/api/store/pg/install-key.go @@ -75,6 +75,13 @@ func (pg *Pg) InstallKeyConflicts(ctx context.Context, sc scope.Scope, target *m return conflicts, len(conflicts) > 0, nil } +const pendingDevicesExpr = `( + SELECT COUNT(*) FROM devices d + WHERE d.install_key_id = install_key.key_digest + AND d.namespace_id = install_key.namespace_id + AND d.status = 'pending' +) AS pending_devices` + // InstallKeyList implements [store.InstallKeyStore]. func (pg *Pg) InstallKeyList(ctx context.Context, sc scope.Scope, opts ...store.QueryOption) ([]models.InstallKey, int, error) { db := pg.GetConnection(ctx) @@ -83,6 +90,8 @@ func (pg *Pg) InstallKeyList(ctx context.Context, sc scope.Scope, opts ...store. query := db.NewSelect(). Model(&entities). + ColumnExpr("install_key.*"). + ColumnExpr(pendingDevicesExpr). OrderExpr("(type = 'user') ASC, (type = 'pairing') ASC") var err error query, err = applyScopedOptions(ctx, query, sc, opts...) diff --git a/server/api/store/pg/migrations/026_devices_pending_by_install_key.tx.down.sql b/server/api/store/pg/migrations/026_devices_pending_by_install_key.tx.down.sql new file mode 100644 index 00000000000..9a17abd8a7e --- /dev/null +++ b/server/api/store/pg/migrations/026_devices_pending_by_install_key.tx.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS devices_pending_by_install_key; diff --git a/server/api/store/pg/migrations/026_devices_pending_by_install_key.tx.up.sql b/server/api/store/pg/migrations/026_devices_pending_by_install_key.tx.up.sql new file mode 100644 index 00000000000..5be12a0aad2 --- /dev/null +++ b/server/api/store/pg/migrations/026_devices_pending_by_install_key.tx.up.sql @@ -0,0 +1,15 @@ +-- Partial on pending: it is the only status the install key list's count reads, and pending is a +-- small, short-lived slice of a namespace's devices. Migration 020 dropped the indexes on +-- last_seen and disconnected_at because a presence heartbeat writes them and an indexed column +-- changing disqualifies HOT; neither is in this index, and status changes only on a decision. +-- +-- lock_timeout because migrations run inline at startup before the listener binds, and a lock +-- queued behind a long snapshot would stall the boot. SET LOCAL is enough: bun runs a .tx. file +-- in one transaction. +SET LOCAL lock_timeout = '60s'; + +--bun:split + +CREATE INDEX IF NOT EXISTS devices_pending_by_install_key + ON devices USING btree (namespace_id, install_key_id) + WHERE status = 'pending'; diff --git a/server/api/store/storetest/helpers.go b/server/api/store/storetest/helpers.go index 7a9289de159..58bbd9b7e9e 100644 --- a/server/api/store/storetest/helpers.go +++ b/server/api/store/storetest/helpers.go @@ -187,6 +187,13 @@ func WithDeviceStatus(status models.DeviceStatus) DeviceOption { } } +// WithDeviceInstallKey attributes the device to the install key with the given digest +func WithDeviceInstallKey(digest string) DeviceOption { + return func(d *models.Device) { + d.InstallKeyID = digest + } +} + // WithDevicePublicKey sets the device public key func WithDevicePublicKey(publicKey string) DeviceOption { return func(d *models.Device) { diff --git a/server/api/store/storetest/install_key_tests.go b/server/api/store/storetest/install_key_tests.go index 490125867fe..b00f77f3f6c 100644 --- a/server/api/store/storetest/install_key_tests.go +++ b/server/api/store/storetest/install_key_tests.go @@ -66,3 +66,58 @@ func (s *Suite) TestInstallKeyModeRoundTrip(t *testing.T) { assert.Empty(t, got.WebhookURL) }) } + +// TestInstallKeyListPendingDevices verifies the per-key count of enrollments awaiting a decision, +// which is what tells the keys list which key has something to review. +func (s *Suite) TestInstallKeyListPendingDevices(t *testing.T) { + ctx := context.Background() + st := s.provider.Store() + + require.NoError(t, s.provider.CleanDatabase(t)) + tenantID := s.CreateNamespace(t) + + const owner = "00000000-0000-4000-0000-000000000009" + + waitingDigest := "3333333333333333333333333333333333333333333333333333333333333333" + _, err := st.InstallKeyCreate(ctx, &models.InstallKey{ + ID: waitingDigest, + Name: "waiting", + TenantID: tenantID, + Mode: models.InstallKeyModeManual, + Reusable: true, + Tags: []string{}, + CreatedBy: owner, + }) + require.NoError(t, err) + + settledDigest := "4444444444444444444444444444444444444444444444444444444444444444" + _, err = st.InstallKeyCreate(ctx, &models.InstallKey{ + ID: settledDigest, + Name: "settled", + TenantID: tenantID, + Mode: models.InstallKeyModeAutomatic, + Reusable: true, + Tags: []string{}, + CreatedBy: owner, + }) + require.NoError(t, err) + + s.CreateDevice(t, WithTenantID(tenantID), WithDeviceInstallKey(waitingDigest), WithDeviceStatus(models.DeviceStatusPending)) + s.CreateDevice(t, WithTenantID(tenantID), WithDeviceInstallKey(waitingDigest), WithDeviceStatus(models.DeviceStatusPending)) + s.CreateDevice(t, WithTenantID(tenantID), WithDeviceInstallKey(waitingDigest), WithDeviceStatus(models.DeviceStatusAccepted)) + s.CreateDevice(t, WithTenantID(tenantID), WithDeviceInstallKey(settledDigest), WithDeviceStatus(models.DeviceStatusAccepted)) + s.CreateDevice(t, WithTenantID(tenantID), WithDeviceInstallKey(settledDigest), WithDeviceStatus(models.DeviceStatusRejected)) + + t.Run("counts only the devices a key still owes a decision", func(t *testing.T) { + installKeys, _, err := st.InstallKeyList(ctx, scope.MustBounded(tenantID)) + require.NoError(t, err) + + counts := make(map[string]int, len(installKeys)) + for _, key := range installKeys { + counts[key.Name] = key.PendingDevices + } + + assert.Equal(t, 2, counts["waiting"]) + assert.Equal(t, 0, counts["settled"]) + }) +} diff --git a/server/api/store/storetest/registry.go b/server/api/store/storetest/registry.go index 544701a0dc1..e5440bf916c 100644 --- a/server/api/store/storetest/registry.go +++ b/server/api/store/storetest/registry.go @@ -97,6 +97,7 @@ var Groups = []Group{ }}, {Name: "InstallKeyStore", Tests: []TestFunc{ (*Suite).TestInstallKeyModeRoundTrip, + (*Suite).TestInstallKeyListPendingDevices, (*Suite).TestInstallKeyEventCreate, (*Suite).TestInstallKeyEventList, }}, From 414484bd7d6f7a6ce545cbed20475407e1213c99 Mon Sep 17 00:00:00 2001 From: "Luis Gustavo S. Barreto" Date: Thu, 10 Sep 2026 14:10:38 -0300 Subject: [PATCH 2/4] feat(ui): show devices awaiting a decision in the key's usage column 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. --- ui/apps/console/src/hooks/useDeviceCode.ts | 1 + .../console/src/hooks/useDeviceMutations.ts | 5 +- .../pages/install-keys/InstallKeysTable.tsx | 3 +- .../src/pages/install-keys/UsageMeter.tsx | 102 +++++++++++++++--- .../__tests__/InstallKeys.test.tsx | 76 +++++++++++++ .../install-keys/__tests__/helpers.test.ts | 24 +++++ .../console/src/pages/install-keys/helpers.ts | 22 ++++ ui/packages/design-system/css/base.css | 8 ++ 8 files changed, 222 insertions(+), 19 deletions(-) create mode 100644 ui/apps/console/src/pages/install-keys/__tests__/InstallKeys.test.tsx diff --git a/ui/apps/console/src/hooks/useDeviceCode.ts b/ui/apps/console/src/hooks/useDeviceCode.ts index bd290bd7936..1c9d5f660fc 100644 --- a/ui/apps/console/src/hooks/useDeviceCode.ts +++ b/ui/apps/console/src/hooks/useDeviceCode.ts @@ -30,6 +30,7 @@ export function useAcceptDevicePairing() { "getDevice", "getStatusDevices", "getStats", + "installKeyList", ); return useMutation({ ...acceptDevicePairingMutation(), diff --git a/ui/apps/console/src/hooks/useDeviceMutations.ts b/ui/apps/console/src/hooks/useDeviceMutations.ts index 693c987380b..04bd0ab871b 100644 --- a/ui/apps/console/src/hooks/useDeviceMutations.ts +++ b/ui/apps/console/src/hooks/useDeviceMutations.ts @@ -15,7 +15,7 @@ import { useInvalidateByIds } from "./useInvalidateQueries"; /** * Accepts a pending device. The counts change with it, so the stats query is refreshed as well - * as the lists. + * as the lists — including the install keys list, which carries each key's pending count. */ export function useAcceptDevice() { const invalidate = useInvalidateByIds( @@ -23,6 +23,7 @@ export function useAcceptDevice() { "getDevice", "getStatusDevices", "getStats", + "installKeyList", ); return useMutation({ ...acceptDeviceMutation(), @@ -39,6 +40,7 @@ export function useRejectDevice() { "getDevices", "getDevice", "getStatusDevices", + "installKeyList", ); return useMutation({ ...updateDeviceStatusMutation(), @@ -55,6 +57,7 @@ export function useRemoveDevice() { "getDevice", "getStatusDevices", "getStats", + "installKeyList", ); return useMutation({ ...deleteDeviceMutation(), diff --git a/ui/apps/console/src/pages/install-keys/InstallKeysTable.tsx b/ui/apps/console/src/pages/install-keys/InstallKeysTable.tsx index 33749b138e3..28d1a9e7eb6 100644 --- a/ui/apps/console/src/pages/install-keys/InstallKeysTable.tsx +++ b/ui/apps/console/src/pages/install-keys/InstallKeysTable.tsx @@ -167,7 +167,8 @@ export default function InstallKeysTable({ }, { key: "usage", - header: "Usage limit", + header: "Usage", + headerClassName: "w-40", render: (key) => , }, { diff --git a/ui/apps/console/src/pages/install-keys/UsageMeter.tsx b/ui/apps/console/src/pages/install-keys/UsageMeter.tsx index 5ec4770e433..c50a1f50041 100644 --- a/ui/apps/console/src/pages/install-keys/UsageMeter.tsx +++ b/ui/apps/console/src/pages/install-keys/UsageMeter.tsx @@ -1,6 +1,13 @@ +import { ClockIcon } from "@heroicons/react/24/outline"; import { cn } from "@shellhub/design-system/cn"; import { type InstallKey } from "@/client"; -import { getKeyBlockers, getUsageInfo, type UsageInfo } from "./helpers"; +import StatusChip from "./StatusChip"; +import { + getKeyBlockers, + getUsageInfo, + getWaitingInfo, + type UsageInfo, +} from "./helpers"; function formatLabel(usage: UsageInfo): string { const cap = usage.kind === "unlimited" ? "∞" : usage.limit; @@ -11,10 +18,14 @@ function Bar({ usage, dimmed, reached, + waiting, + oversubscribed, }: { usage: UsageInfo; dimmed: boolean; reached: boolean; + waiting: number; + oversubscribed: boolean; }) { if (usage.kind === "unlimited") { return ( @@ -30,21 +41,47 @@ function Bar({ ? "bg-text-muted/40" : "bg-primary"; - const width = Math.max(usage.ratio * 100, usage.used > 0 ? 6 : 0) + "%"; + const usedWidth = Math.max(usage.ratio * 100, usage.used > 0 ? 6 : 0); + + if (waiting === 0) { + return ( +
+
+
+ ); + } + + const waitingWidth = Math.min(100 - usedWidth, (waiting / usage.limit) * 100); return ( -
+
+
); } /** - * How much of an install key's allowance is spent. An unlimited key shows a count rather than a - * bar, since there is nothing to fill. + * How much of an install key's allowance is spent, and how much of it is claimed by devices still + * awaiting a decision. An unlimited key shows a count rather than a bar, since there is nothing + * to fill. */ export default function UsageMeter({ installKey, @@ -56,18 +93,49 @@ export default function UsageMeter({ const usage = getUsageInfo(installKey); const { inert, overused, revoked, disabled } = getKeyBlockers(installKey); const reached = overused && !revoked && !disabled; + const { waiting, beyondLimit, oversubscribed } = getWaitingInfo(installKey); return ( -
-
- {formatLabel(usage)} -
- +
+ {waiting > 0 ? ( +
+ +
+ ) : ( +
+ {formatLabel(usage)} +
+ )} + + {waiting > 0 && ( +
+ {formatLabel(usage)} used + {oversubscribed && ` · ${beyondLimit} over`} +
+ )}
); } diff --git a/ui/apps/console/src/pages/install-keys/__tests__/InstallKeys.test.tsx b/ui/apps/console/src/pages/install-keys/__tests__/InstallKeys.test.tsx new file mode 100644 index 00000000000..957323e0f87 --- /dev/null +++ b/ui/apps/console/src/pages/install-keys/__tests__/InstallKeys.test.tsx @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import { createTestWrapper } from "@/tests/wrapper"; +import { mockInstallKey } from "@/tests/factories"; +import { paginatedResponse } from "@/tests/sdk"; +import { seedAuthStore } from "@/tests/seedAuthStore"; +import InstallKeys from "../index"; + +const sdk = vi.hoisted(() => + mockSdkGen({ + installKeyList: vi.fn(), + }), +); + +function renderPage() { + return render(, { + wrapper: createTestWrapper({ initialEntries: ["/install-keys"] }), + }); +} + +async function keyRow(name: string) { + return within(await screen.findByRole("row", { name: new RegExp(name) })); +} + +beforeEach(() => { + vi.clearAllMocks(); + seedAuthStore(); + sdk.installKeyList.mockResolvedValue( + paginatedResponse([ + mockInstallKey({ + id: "waiting-digest", + name: "fleet-key", + pending_devices: 2, + }), + mockInstallKey({ + id: "settled-digest", + name: "auto-key", + mode: "automatic", + pending_devices: 0, + }), + mockInstallKey({ + id: "capped-digest", + name: "edge-fleet", + usage_limit: 4, + used_times: 3, + pending_devices: 3, + }), + ]), + ); +}); + +describe("Install keys", () => { + it("says how many devices a key has waiting for a decision", async () => { + renderPage(); + + expect((await keyRow("fleet-key")).getByText("2 waiting")).toBeInTheDocument(); + }); + + it("says nothing about waiting when a key has nothing pending", async () => { + renderPage(); + + expect((await keyRow("auto-key")).queryByText(/waiting/)).not.toBeInTheDocument(); + }); + + it("keeps the spend readable while devices wait", async () => { + renderPage(); + + expect((await keyRow("fleet-key")).getByText(/0 \/ ∞ used/)).toBeInTheDocument(); + }); + + it("warns when accepting everything waiting would pass the key's limit", async () => { + renderPage(); + + expect((await keyRow("edge-fleet")).getByText(/3 \/ 4 used · 2 over/)).toBeInTheDocument(); + }); +}); diff --git a/ui/apps/console/src/pages/install-keys/__tests__/helpers.test.ts b/ui/apps/console/src/pages/install-keys/__tests__/helpers.test.ts index a3f4e4370f7..69fc59b5024 100644 --- a/ui/apps/console/src/pages/install-keys/__tests__/helpers.test.ts +++ b/ui/apps/console/src/pages/install-keys/__tests__/helpers.test.ts @@ -4,6 +4,7 @@ import { getExpiryInfo, getKeyBlockers, getUsageInfo, + getWaitingInfo, installKeyDisplayName, isPairingKey, isWebhookUrl, @@ -237,3 +238,26 @@ describe("enrollment source", () => { expect(installKeyDisplayName(real)).toBe("fleet"); }); }); + +describe("getWaitingInfo", () => { + it.each([ + { usage_limit: 0, used_times: 40, pending_devices: 3, beyondLimit: 0 }, + { usage_limit: 5, used_times: 1, pending_devices: 2, beyondLimit: 0 }, + { usage_limit: 5, used_times: 3, pending_devices: 2, beyondLimit: 0 }, + { usage_limit: 4, used_times: 3, pending_devices: 3, beyondLimit: 2 }, + { usage_limit: 1, used_times: 1, pending_devices: 1, beyondLimit: 1 }, + ])( + "leaves $beyondLimit of $pending_devices waiting beyond a limit of $usage_limit already $used_times spent", + ({ beyondLimit, ...rest }) => { + const info = getWaitingInfo(key(rest)); + + expect(info.waiting).toBe(rest.pending_devices); + expect(info.beyondLimit).toBe(beyondLimit); + expect(info.oversubscribed).toBe(beyondLimit > 0); + }, + ); + + it("counts nothing waiting when the API omits the field", () => { + expect(getWaitingInfo(key({})).waiting).toBe(0); + }); +}); diff --git a/ui/apps/console/src/pages/install-keys/helpers.ts b/ui/apps/console/src/pages/install-keys/helpers.ts index 67223c421e0..37a12019390 100644 --- a/ui/apps/console/src/pages/install-keys/helpers.ts +++ b/ui/apps/console/src/pages/install-keys/helpers.ts @@ -191,6 +191,28 @@ export interface UsageInfo { exhausted: boolean; } +/** + * The devices a key registered that still await a decision, and how many of them its allowance + * cannot take: accepting the queue spends the same budget an accepted device spent, so a key can + * hold more waiting devices than it can still admit. + */ +export interface WaitingInfo { + waiting: number; + beyondLimit: number; + oversubscribed: boolean; +} + +/** Reads a key's waiting queue against its remaining allowance. */ +export function getWaitingInfo(key: InstallKey): WaitingInfo { + const waiting = key.pending_devices ?? 0; + const beyondLimit = + key.usage_limit > 0 + ? Math.max(0, key.used_times + waiting - key.usage_limit) + : 0; + + return { waiting, beyondLimit, oversubscribed: beyondLimit > 0 }; +} + /** * Decode a key's enrollment budget for the usage meter. `usage_limit` is the * source of truth the API derives reusability from: 0 unlimited, 1 single-use, diff --git a/ui/packages/design-system/css/base.css b/ui/packages/design-system/css/base.css index 2237ba1a907..7d43e1d1a59 100644 --- a/ui/packages/design-system/css/base.css +++ b/ui/packages/design-system/css/base.css @@ -222,6 +222,14 @@ ); } + .usage-oversubscribed { + background-image: repeating-linear-gradient( + -45deg, + currentColor 0 4px, + transparent 4px 8px + ); + } + /* Page transition fade */ .page-enter { animation: pageEnter 1s ease-out; From 8e950ca924da3ea9e360336d73784b3c086aa98e Mon Sep 17 00:00:00 2001 From: "Luis Gustavo S. Barreto" Date: Thu, 10 Sep 2026 14:10:38 -0300 Subject: [PATCH 3/4] fix(ui): send the pending-device links to a page that exists 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. --- ui/apps/console/src/pages/AddDevice.tsx | 15 ++++++--------- ui/apps/console/src/pages/Dashboard.tsx | 10 ++-------- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/ui/apps/console/src/pages/AddDevice.tsx b/ui/apps/console/src/pages/AddDevice.tsx index 170248461e7..b438ac3b6bc 100644 --- a/ui/apps/console/src/pages/AddDevice.tsx +++ b/ui/apps/console/src/pages/AddDevice.tsx @@ -28,11 +28,7 @@ import NumericInput from "@/components/common/fields/NumericInput"; import RadioCard from "@/components/common/fields/RadioCard"; import RadioGroupField from "@/components/common/fields/RadioGroupField"; import { LABEL_BASE } from "@/utils/styles"; -import { - Button, - Card, - WindowChrome, -} from "@shellhub/design-system/primitives"; +import { Button, Card, WindowChrome } from "@shellhub/design-system/primitives"; import { cn } from "@shellhub/design-system/cn"; const INITIAL_VISIBLE = 3; @@ -527,14 +523,15 @@ export default function AddDevice() {
- After installing, your device will appear in the{" "} + After installing, your device waits for a decision on the{" "} - Pending tab + install key {" "} - and must be accepted before you can connect to it. + that registered it, and must be accepted before you can connect to + it.
)} diff --git a/ui/apps/console/src/pages/Dashboard.tsx b/ui/apps/console/src/pages/Dashboard.tsx index 7c291339935..42fb4c73e95 100644 --- a/ui/apps/console/src/pages/Dashboard.tsx +++ b/ui/apps/console/src/pages/Dashboard.tsx @@ -1,4 +1,3 @@ -import { useNavigate } from "react-router-dom"; import { ClockIcon, Squares2X2Icon, @@ -24,7 +23,6 @@ export default function Dashboard() { const tenantId = useAuthStore((s) => s.tenant) ?? ""; const { namespace: currentNamespace } = useNamespace(tenantId); const { stats, isLoading: statsLoading, error: statsError } = useStats(); - const navigate = useNavigate(); if (statsLoading) return null; @@ -32,10 +30,6 @@ export default function Dashboard() { return ; } - const goToPending = () => { - void navigate("/devices?status=pending"); - }; - return (
} title="Pending Devices" value={stats?.pending_devices ?? "--"} - linkLabel="View pending" - onClick={goToPending} + linkLabel="Review pending" + linkTo="/install-keys" accent="text-accent-yellow" />
From ebcfc2673a11babd76eb6a0bceb2b7db787d0d02 Mon Sep 17 00:00:00 2001 From: "Luis Gustavo S. Barreto" Date: Thu, 10 Sep 2026 14:10:38 -0300 Subject: [PATCH 4/4] docs: point a waiting device at the install keys list The Pending tab it described has not existed since acceptance moved to the install keys area. --- .../docs/src/pages/manage/devices/device-not-appearing.mdx | 7 ++++--- ui/apps/docs/src/pages/manage/devices/install-keys.mdx | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/ui/apps/docs/src/pages/manage/devices/device-not-appearing.mdx b/ui/apps/docs/src/pages/manage/devices/device-not-appearing.mdx index c711feae7d3..1c588599654 100644 --- a/ui/apps/docs/src/pages/manage/devices/device-not-appearing.mdx +++ b/ui/apps/docs/src/pages/manage/devices/device-not-appearing.mdx @@ -11,10 +11,11 @@ import Callout from "../../../components/Callout.astro"; The agent is installed and the device is not where you expect. Work down this page in order — it goes from most to least common. -## Is it in Pending? +## Is it waiting to be accepted? -Check the **Pending** tab before anything else. A device enrolled with only a tenant ID lands -there and waits to be accepted. It is not broken; it is queued. +Check **Install Keys** before anything else. A device enrolled with only a tenant ID waits for a +decision on the key that registered it, and each key's row says how many are waiting. It is not +broken; it is queued. Accept it and it comes online. To stop needing to, use an [install key](/manage/devices/install-keys). See diff --git a/ui/apps/docs/src/pages/manage/devices/install-keys.mdx b/ui/apps/docs/src/pages/manage/devices/install-keys.mdx index ece5367c107..5e6647b1f53 100644 --- a/ui/apps/docs/src/pages/manage/devices/install-keys.mdx +++ b/ui/apps/docs/src/pages/manage/devices/install-keys.mdx @@ -124,7 +124,8 @@ ran. ## Reviewing a pending enrollment A key in manual mode, and a key whose webhook deferred, leaves devices waiting. They are reviewed -on the key's activity page rather than in the device list. +on the key's activity page rather than in the device list. The keys list marks every key that has +devices waiting, and the dashboard's pending count links to it.