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,
}},
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/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.
);
}
/**
- * 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 (
-