Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions openapi/spec/components/schemas/installKey.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions pkg/models/install_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions server/api/store/pg/entity/install-key.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions server/api/store/pg/install-key.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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...)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP INDEX IF EXISTS devices_pending_by_install_key;
Original file line number Diff line number Diff line change
@@ -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';
7 changes: 7 additions & 0 deletions server/api/store/storetest/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
55 changes: 55 additions & 0 deletions server/api/store/storetest/install_key_tests.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
})
}
1 change: 1 addition & 0 deletions server/api/store/storetest/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ var Groups = []Group{
}},
{Name: "InstallKeyStore", Tests: []TestFunc{
(*Suite).TestInstallKeyModeRoundTrip,
(*Suite).TestInstallKeyListPendingDevices,
(*Suite).TestInstallKeyEventCreate,
(*Suite).TestInstallKeyEventList,
}},
Expand Down
1 change: 1 addition & 0 deletions ui/apps/console/src/hooks/useDeviceCode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export function useAcceptDevicePairing() {
"getDevice",
"getStatusDevices",
"getStats",
"installKeyList",
);
return useMutation({
...acceptDevicePairingMutation(),
Expand Down
5 changes: 4 additions & 1 deletion ui/apps/console/src/hooks/useDeviceMutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,15 @@ 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(
"getDevices",
"getDevice",
"getStatusDevices",
"getStats",
"installKeyList",
);
return useMutation({
...acceptDeviceMutation(),
Expand All @@ -39,6 +40,7 @@ export function useRejectDevice() {
"getDevices",
"getDevice",
"getStatusDevices",
"installKeyList",
);
return useMutation({
...updateDeviceStatusMutation(),
Expand All @@ -55,6 +57,7 @@ export function useRemoveDevice() {
"getDevice",
"getStatusDevices",
"getStats",
"installKeyList",
);
return useMutation({
...deleteDeviceMutation(),
Expand Down
15 changes: 6 additions & 9 deletions ui/apps/console/src/pages/AddDevice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -527,14 +523,15 @@ export default function AddDevice() {
<div className="flex items-start gap-3 bg-primary/[0.04] border border-primary/15 rounded-xl px-4 py-3.5 mb-6">
<InformationCircleIcon className="w-4 h-4 text-primary shrink-0 mt-0.5" />
<div className="text-xs text-text-secondary leading-relaxed">
After installing, your device will appear in the{" "}
After installing, your device waits for a decision on the{" "}
<Link
to="/devices?status=pending"
to="/install-keys"
className="text-primary font-medium hover:text-primary/80 transition-colors"
>
Pending tab
install key
</Link>{" "}
and must be accepted before you can connect to it.
that registered it, and must be accepted before you can connect to
it.
</div>
</div>
)}
Expand Down
10 changes: 2 additions & 8 deletions ui/apps/console/src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { useNavigate } from "react-router-dom";
import {
ClockIcon,
Squares2X2Icon,
Expand All @@ -24,18 +23,13 @@ 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;

if (!statsError && stats && !hasAnyDevices(stats) && currentNamespace) {
return <WelcomeScreen namespaceName={currentNamespace.name} />;
}

const goToPending = () => {
void navigate("/devices?status=pending");
};

return (
<div>
<PageHeader
Expand Down Expand Up @@ -88,8 +82,8 @@ export default function Dashboard() {
icon={<ClockIcon className="w-7 h-7" />}
title="Pending Devices"
value={stats?.pending_devices ?? "--"}
linkLabel="View pending"
onClick={goToPending}
linkLabel="Review pending"
linkTo="/install-keys"
accent="text-accent-yellow"
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,8 @@ export default function InstallKeysTable({
},
{
key: "usage",
header: "Usage limit",
header: "Usage",
headerClassName: "w-40",
render: (key) => <UsageMeter installKey={key} muted />,
},
{
Expand Down
Loading
Loading