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.
)} 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" />
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/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. 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;