diff --git a/web/app/(app)/dashboard/(components)/api-keys.tsx b/web/app/(app)/dashboard/(components)/api-keys.tsx index 687fae8..4ff4c0e 100644 --- a/web/app/(app)/dashboard/(components)/api-keys.tsx +++ b/web/app/(app)/dashboard/(components)/api-keys.tsx @@ -29,6 +29,7 @@ import { } from '@/lib/api' import { Skeleton } from '@/components/ui/skeleton' import EmptyState from '@/components/shared/empty-state' +import ErrorState from '@/components/shared/error-state' import RelativeTime from '@/components/shared/relative-time' import GenerateApiKey, { type GenerateApiKeyHandle, @@ -60,7 +61,12 @@ export default function ApiKeys() { const { toast } = useToast() - const { isPending, error, data: apiKeys } = useApiKeys('active') + const { + isPending, + error, + data: apiKeys, + refetch, + } = useApiKeys('active') const { data: revokedKeysData, isPending: isRevokedPending } = useApiKeys( 'revoked', @@ -185,9 +191,12 @@ export default function ApiKeys() { )} {error && ( -
-
Error: {error.message}
-
+ refetch()} + /> )} {!isPending && !error && apiKeys?.length === 0 && ( diff --git a/web/app/(app)/dashboard/(components)/device-list.tsx b/web/app/(app)/dashboard/(components)/device-list.tsx index 909b6e3..8644323 100644 --- a/web/app/(app)/dashboard/(components)/device-list.tsx +++ b/web/app/(app)/dashboard/(components)/device-list.tsx @@ -17,6 +17,7 @@ import { useToast } from '@/hooks/use-toast' import { Routes } from '@/config/routes' import { useDeleteDevice, useDevices, useSubscription } from '@/lib/api' import EmptyState from '@/components/shared/empty-state' +import ErrorState from '@/components/shared/error-state' import RelativeTime from '@/components/shared/relative-time' import { useRef, useState } from 'react' import { @@ -45,7 +46,6 @@ import { type DeviceRow = DeviceVersionCandidate & { createdAt: string - status?: string enabled?: boolean } @@ -54,7 +54,7 @@ export default function DeviceList() { const [devicePendingDelete, setDevicePendingDelete] = useState(null) const { toast } = useToast() - const { isPending, error, data: devices } = useDevices() + const { isPending, error, data: devices, refetch } = useDevices() const { data: currentSubscription } = useSubscription() @@ -189,9 +189,12 @@ export default function DeviceList() { )} {error && ( -
-
Error: {error.message}
-
+ refetch()} + /> )} {!isPending && !error && devices?.length === 0 && ( @@ -220,10 +223,12 @@ export default function DeviceList() { Update available )} + {/* Colour and text now come from the same field. The + variant used to key off device.status, which the + API never sends, so an enabled device was styled + identically to a disabled one. */} {device.enabled ? 'Enabled' : 'Disabled'} diff --git a/web/app/(app)/dashboard/(components)/webhooks/webhooks-section.tsx b/web/app/(app)/dashboard/(components)/webhooks/webhooks-section.tsx index 990176f..1d9b0af 100644 --- a/web/app/(app)/dashboard/(components)/webhooks/webhooks-section.tsx +++ b/web/app/(app)/dashboard/(components)/webhooks/webhooks-section.tsx @@ -1,7 +1,8 @@ 'use client' import { Button } from '@/components/ui/button' -import { PlusCircle } from 'lucide-react' +import { PlusCircle, Webhook } from 'lucide-react' +import ErrorState from '@/components/shared/error-state' import { useState } from 'react' import { WebhookData } from '@/lib/types' import { WebhookCard } from './webhook-card' @@ -43,6 +44,7 @@ export default function WebhooksSection() { data: webhooks, isLoading, error, + refetch, } = useQuery({ queryKey: ['webhooks'], queryFn: () => @@ -103,9 +105,12 @@ export default function WebhooksSection() { ) : error ? ( -
- Error: {error.message} -
+ refetch()} + /> ) : webhooks?.data?.length > 0 ? (
{webhooks.data.map((webhook) => ( diff --git a/web/components/shared/error-state.tsx b/web/components/shared/error-state.tsx new file mode 100644 index 0000000..52bf582 --- /dev/null +++ b/web/components/shared/error-state.tsx @@ -0,0 +1,50 @@ +'use client' + +import type { ComponentType } from 'react' +import { AlertCircle, RefreshCw } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { formatError } from '@/lib/utils/errorHandler' + +type ErrorStateProps = { + error: unknown + title?: string + onRetry?: () => void + icon?: ComponentType<{ className?: string }> +} + +/** + * Shared error placeholder, the counterpart to EmptyState. + * + * Several sections rendered a bare `Error: {error.message}` with no styling + * and no way to recover, which put raw transport strings like "Request failed + * with status code 500" in front of users. formatError already knows how to + * turn an axios rejection into something readable, including rate limits, so + * this routes everything through it. + */ +export default function ErrorState({ + error, + title = 'Something went wrong', + onRetry, + icon: Icon = AlertCircle, +}: ErrorStateProps) { + const { message } = formatError(error) + + return ( +
+
+ +
+

{title}

+

{message}

+ {onRetry && ( + + )} +
+ ) +} diff --git a/web/e2e/dashboard.spec.ts b/web/e2e/dashboard.spec.ts index a6da5d7..980d1e2 100644 --- a/web/e2e/dashboard.spec.ts +++ b/web/e2e/dashboard.spec.ts @@ -100,6 +100,48 @@ test.describe('dashboard (mocked API, no real backend)', () => { ).toBeVisible() }) + // These sections used to render a bare "Error: Request failed with status + // code 500" with no styling and no way to recover. + test('a failed devices request explains itself and offers a retry', async ({ + page, + context, + }) => { + await authenticate(context) + await mockApi(page) + + // Held failing rather than failing once: react-query retries a failed + // query several times before surfacing an error, so a single failure would + // recover on its own and the error state would never render. + let failDevices = true + await page.route('**/api/v1/gateway/devices', (route) => { + if (route.request().method() !== 'GET' || !failDevices) + return route.fallback() + return route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ message: 'Devices are temporarily unavailable' }), + }) + }) + + await page.goto('/dashboard') + + // Generous: the retries back off before the error is shown. + await expect(page.getByText("Couldn't load your devices")).toBeVisible({ + timeout: 30000, + }) + // The server's message, not the axios transport string. + await expect( + page.getByText('Devices are temporarily unavailable') + ).toBeVisible() + await expect(page.getByText(/Request failed with status code/)).toHaveCount( + 0 + ) + + failDevices = false + await page.getByRole('button', { name: 'Try again' }).click() + await expect(page.getByText("Couldn't load your devices")).toHaveCount(0) + }) + test('leads with real quota usage, not invented trends', async ({ page, context, diff --git a/web/lib/api/types.ts b/web/lib/api/types.ts index d222e9a..a2be5d6 100644 --- a/web/lib/api/types.ts +++ b/web/lib/api/types.ts @@ -26,9 +26,14 @@ export interface Device { brand?: string model?: string enabled?: boolean - status?: string - // No batteryLevel or signal here: neither the device schema nor the Android - // app reports them, so the UI can only ever invent those values. + // No `status` field: the Device schema has none and the API never sends one, + // so `device.status === 'online'` was always false and every device rendered + // with the muted "inactive" badge even while enabled and working. + // + // The device does report real telemetry through its heartbeat (battery, + // network type, last heartbeat, SIM details) which the API stores and + // returns. It is deliberately not modelled here yet: surfacing it is a + // feature, not a fix, and belongs in its own change. appVersionCode?: number createdAt?: string }