mirror of
https://github.com/vernu/textbee.git
synced 2026-08-02 18:38:41 -04:00
fix: drop the phantom device status and share one error state
The Device type declared a `status` field that the schema does not have
and the API never sends, so `device.status === 'online'` was always
false. The badge took its colour from that phantom field and its text
from `enabled`, so an enabled, working device was styled exactly like a
disabled one. Both now come from `enabled`.
Devices, API keys and webhooks each rendered a bare unstyled
"Error: {error.message}" with no way to recover, putting raw transport
strings like "Request failed with status code 500" in front of users.
formatError already handles axios rejections and rate limits and was
used by none of them.
Adds components/shared/error-state.tsx as the counterpart to the
existing EmptyState, routing all three through formatError and offering
a retry.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 && (
|
||||
<div className='flex justify-center items-center h-full'>
|
||||
<div>Error: {error.message}</div>
|
||||
</div>
|
||||
<ErrorState
|
||||
error={error}
|
||||
title="Couldn't load your API keys"
|
||||
icon={Key}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isPending && !error && apiKeys?.length === 0 && (
|
||||
|
||||
@@ -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<DeviceRow | null>(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 && (
|
||||
<div className='flex justify-center items-center h-full'>
|
||||
<div>Error: {error.message}</div>
|
||||
</div>
|
||||
<ErrorState
|
||||
error={error}
|
||||
title="Couldn't load your devices"
|
||||
icon={Smartphone}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isPending && !error && devices?.length === 0 && (
|
||||
@@ -220,10 +223,12 @@ export default function DeviceList() {
|
||||
Update available
|
||||
</Badge>
|
||||
)}
|
||||
{/* 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. */}
|
||||
<Badge
|
||||
variant={
|
||||
device.status === 'online' ? 'default' : 'secondary'
|
||||
}
|
||||
variant={device.enabled ? 'default' : 'secondary'}
|
||||
className='text-xs'
|
||||
>
|
||||
{device.enabled ? 'Enabled' : 'Disabled'}
|
||||
|
||||
@@ -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() {
|
||||
<WebhookRowSkeleton />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='rounded-lg border border-destructive/50 p-4 text-destructive'>
|
||||
Error: {error.message}
|
||||
</div>
|
||||
<ErrorState
|
||||
error={error}
|
||||
title="Couldn't load your webhooks"
|
||||
icon={Webhook}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
) : webhooks?.data?.length > 0 ? (
|
||||
<div className='rounded-md border divide-y bg-card overflow-hidden'>
|
||||
{webhooks.data.map((webhook) => (
|
||||
|
||||
50
web/components/shared/error-state.tsx
Normal file
50
web/components/shared/error-state.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
role='alert'
|
||||
className='flex flex-col items-center justify-center gap-2 py-12 text-center'
|
||||
>
|
||||
<div className='rounded-full bg-destructive/10 p-3'>
|
||||
<Icon className='h-6 w-6 text-destructive' />
|
||||
</div>
|
||||
<p className='text-sm font-medium text-foreground'>{title}</p>
|
||||
<p className='max-w-sm text-xs text-muted-foreground'>{message}</p>
|
||||
{onRetry && (
|
||||
<Button variant='outline' size='sm' className='mt-2' onClick={onRetry}>
|
||||
<RefreshCw className='mr-1.5 h-3.5 w-3.5' />
|
||||
Try again
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user