Improve admin diagnostics controls

- Add a client uploads toggle with status refresh and feedback
- Replace native date filters with calendar and time pickers
- Improve responsive filter layout and test upload settings
This commit is contained in:
Quick104
2026-07-22 13:13:52 -04:00
parent 0a914441ea
commit 4e42cb3053
8 changed files with 515 additions and 37 deletions
+1
View File
@@ -43,6 +43,7 @@
"pdfjs-dist": "^5.7.284",
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-day-picker": "^10.0.1",
"react-dom": "^19.0.0",
"react-qr-code": "^2.0.18",
"react-router": "^7.15.1",
+31
View File
@@ -100,6 +100,9 @@ importers:
react:
specifier: ^19.0.0
version: 19.2.4
react-day-picker:
specifier: ^10.0.1
version: 10.0.1(@types/react@19.2.14)(react@19.2.4)
react-dom:
specifier: ^19.0.0
version: 19.2.4(react@19.2.4)
@@ -375,6 +378,9 @@ packages:
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
'@date-fns/tz@1.5.0':
resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==}
'@dnd-kit/accessibility@3.1.1':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
@@ -2014,6 +2020,9 @@ packages:
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
date-fns@4.4.0:
resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -2704,6 +2713,16 @@ packages:
'@types/react-dom':
optional: true
react-day-picker@10.0.1:
resolution: {integrity: sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==}
engines: {node: '>=18'}
peerDependencies:
'@types/react': '>=16.8.0'
react: '>=16.8.0'
peerDependenciesMeta:
'@types/react':
optional: true
react-dom@19.2.4:
resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
peerDependencies:
@@ -3335,6 +3354,8 @@ snapshots:
'@csstools/css-tokenizer@4.0.0': {}
'@date-fns/tz@1.5.0': {}
'@dnd-kit/accessibility@3.1.1(react@19.2.4)':
dependencies:
react: 19.2.4
@@ -4890,6 +4911,8 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
date-fns@4.4.0: {}
debug@4.4.3:
dependencies:
ms: 2.1.3
@@ -5508,6 +5531,14 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
react-day-picker@10.0.1(@types/react@19.2.14)(react@19.2.4):
dependencies:
'@date-fns/tz': 1.5.0
date-fns: 4.4.0
react: 19.2.4
optionalDependencies:
'@types/react': 19.2.14
react-dom@19.2.4(react@19.2.4):
dependencies:
react: 19.2.4
+104
View File
@@ -0,0 +1,104 @@
import { useState } from "react";
import { CalendarIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { formatDateTime } from "@/lib/datetime";
import { cn } from "@/lib/utils";
/**
* Calendar + time-of-day picker that speaks the same `YYYY-MM-DDTHH:mm` local
* string format as a native `datetime-local` input, so it can drop into
* existing filter state without conversions.
*/
export function DateTimePicker({
id,
value,
onChange,
placeholder = "Any time",
className,
}: {
id?: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
}) {
const [open, setOpen] = useState(false);
const selected = parseLocalDateTime(value);
const time = selected ? value.slice(11, 16) : "";
function handleSelectDay(day: Date | undefined) {
if (!day) {
onChange("");
return;
}
onChange(`${toLocalDateString(day)}T${time || "00:00"}`);
}
function handleTimeChange(nextTime: string) {
const day = selected ?? new Date();
onChange(`${toLocalDateString(day)}T${nextTime || "00:00"}`);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
id={id}
type="button"
variant="outline"
className={cn(
"w-full justify-start bg-transparent px-3 font-normal",
!selected && "text-muted-foreground",
className,
)}
>
<CalendarIcon className="size-4 opacity-60" aria-hidden="true" />
<span className="truncate">
{selected ? formatDateTime(selected, { seconds: false }) : placeholder}
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={selected}
defaultMonth={selected}
onSelect={handleSelectDay}
/>
<div className="flex items-center gap-2 border-t p-3">
<Input
type="time"
aria-label="Time of day"
value={time}
onChange={(event) => handleTimeChange(event.target.value)}
className="h-8"
/>
<Button
type="button"
variant="ghost"
size="sm"
disabled={!selected}
onClick={() => onChange("")}
>
Clear
</Button>
</div>
</PopoverContent>
</Popover>
);
}
function parseLocalDateTime(value: string) {
if (!value) return undefined;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? undefined : date;
}
function toLocalDateString(day: Date) {
const pad = (part: number) => String(part).padStart(2, "0");
return `${day.getFullYear()}-${pad(day.getMonth() + 1)}-${pad(day.getDate())}`;
}
+67
View File
@@ -0,0 +1,67 @@
import type { ComponentProps } from "react";
import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp } from "lucide-react";
import { DayPicker } from "react-day-picker";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: ComponentProps<typeof DayPicker>) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "relative flex flex-col gap-4 sm:flex-row",
month: "flex w-full flex-col gap-4",
month_caption: "flex h-7 items-center justify-center",
caption_label: "text-sm font-medium",
nav: "absolute inset-x-0 top-0 flex items-center justify-between",
button_previous: cn(
buttonVariants({ variant: "outline" }),
"size-7 bg-transparent p-0 opacity-60 hover:opacity-100",
),
button_next: cn(
buttonVariants({ variant: "outline" }),
"size-7 bg-transparent p-0 opacity-60 hover:opacity-100",
),
month_grid: "w-full border-collapse",
weekdays: "flex",
weekday: "text-muted-foreground w-8 text-[0.8rem] font-normal",
week: "mt-2 flex w-full",
day: "relative size-8 p-0 text-center text-sm",
day_button: cn(
buttonVariants({ variant: "ghost" }),
"size-8 p-0 font-normal aria-selected:opacity-100",
),
selected:
"rounded-md [&>button]:bg-primary [&>button]:text-primary-foreground [&>button]:hover:bg-primary [&>button]:hover:text-primary-foreground",
today: "rounded-md [&>button:not([aria-selected])]:bg-accent",
outside: "text-muted-foreground",
disabled: "text-muted-foreground opacity-50",
hidden: "invisible",
...classNames,
}}
components={{
Chevron: ({ orientation, className: chevronClassName }) => {
const Icon =
orientation === "left"
? ChevronLeft
: orientation === "right"
? ChevronRight
: orientation === "up"
? ChevronUp
: ChevronDown;
return <Icon className={cn("size-4", chevronClassName)} aria-hidden="true" />;
},
}}
{...props}
/>
);
}
export { Calendar };
@@ -1,17 +1,30 @@
import { createElement, type ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DiagnosticReport } from "@/api/types";
const mocks = vi.hoisted(() => ({
api: vi.fn(),
apiResponse: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}));
vi.mock("@/api/client", () => ({
api: vi.fn(),
api: mocks.api,
apiResponse: mocks.apiResponse,
}));
import { downloadDiagnosticReport } from "./diagnostics";
vi.mock("sonner", () => ({
toast: {
error: mocks.toastError,
success: mocks.toastSuccess,
},
}));
import { downloadDiagnosticReport, useUpdateDiagnosticsUploadsEnabled } from "./diagnostics";
const report = {
id: "83fd3186-bd4f-42e1-8285-58107c503685",
@@ -20,7 +33,10 @@ const report = {
describe("downloadDiagnosticReport", () => {
beforeEach(() => {
mocks.api.mockReset();
mocks.apiResponse.mockReset();
mocks.toastError.mockReset();
mocks.toastSuccess.mockReset();
});
it("streams the bundle through the proxy download path", async () => {
@@ -62,3 +78,68 @@ describe("downloadDiagnosticReport", () => {
);
});
});
describe("useUpdateDiagnosticsUploadsEnabled", () => {
beforeEach(() => {
mocks.api.mockReset();
mocks.apiResponse.mockReset();
mocks.toastError.mockReset();
mocks.toastSuccess.mockReset();
});
function createWrapper(queryClient: QueryClient) {
return function Wrapper({ children }: { children: ReactNode }) {
return createElement(QueryClientProvider, { client: queryClient }, children);
};
}
it.each([
[true, "true", "Client diagnostic uploads enabled"],
[false, "false", "Client diagnostic uploads disabled"],
] as const)("persists %s and refreshes diagnostics status", async (enabled, value, message) => {
const queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
});
const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries");
mocks.api.mockResolvedValue({ key: "diagnostics.uploads_enabled", value });
const { result } = renderHook(() => useUpdateDiagnosticsUploadsEnabled(), {
wrapper: createWrapper(queryClient),
});
await act(async () => {
await result.current.mutateAsync(enabled);
});
expect(mocks.api).toHaveBeenCalledWith("/admin/settings/diagnostics.uploads_enabled", {
method: "PUT",
body: JSON.stringify({ value }),
});
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["diagnostics", "status"] });
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["admin", "serverSettings"] });
expect(mocks.toastSuccess).toHaveBeenCalledWith(message);
});
it("surfaces storage validation errors", async () => {
const queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
});
const error = new Error("diagnostics uploads require configured private object storage");
mocks.api.mockRejectedValue(error);
const { result } = renderHook(() => useUpdateDiagnosticsUploadsEnabled(), {
wrapper: createWrapper(queryClient),
});
let caught: unknown;
await act(async () => {
try {
await result.current.mutateAsync(true);
} catch (mutationError) {
caught = mutationError;
}
});
expect(caught).toBe(error);
expect(mocks.toastError).toHaveBeenCalledWith(error.message);
expect(mocks.toastSuccess).not.toHaveBeenCalled();
});
});
@@ -3,6 +3,7 @@ import { toast } from "sonner";
import { api, apiResponse } from "@/api/client";
import type {
AdminSettingUpdateResponse,
DiagnosticReport,
DiagnosticReportListResponse,
DiagnosticReportSummary,
@@ -38,6 +39,31 @@ export function useDiagnosticsStatus() {
});
}
export function useUpdateDiagnosticsUploadsEnabled() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (enabled: boolean) =>
api<AdminSettingUpdateResponse>("/admin/settings/diagnostics.uploads_enabled", {
method: "PUT",
body: JSON.stringify({ value: enabled ? "true" : "false" }),
}),
onSuccess: async (_result, enabled) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: adminKeys.diagnosticStatus() }),
queryClient.invalidateQueries({ queryKey: adminKeys.serverSettings() }),
]);
toast.success(
enabled ? "Client diagnostic uploads enabled" : "Client diagnostic uploads disabled",
);
},
onError: (error) => {
toast.error(
error instanceof Error ? error.message : "Failed to update client diagnostic uploads",
);
},
});
}
export function useDiagnosticReports(params: AdminDiagnosticsQuery) {
const query = toQueryString(params);
return useQuery({
+133
View File
@@ -0,0 +1,133 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DiagnosticAvailabilityStatus, DiagnosticStatus } from "@/api/types";
const mocks = vi.hoisted(() => ({
mutateUploadsEnabled: vi.fn(),
useDiagnosticsStatus: vi.fn(),
useUpdateDiagnosticsUploadsEnabled: vi.fn(),
}));
vi.mock("@/hooks/useDateTimeFormat", () => ({
useDateTimeFormat: vi.fn(),
}));
vi.mock("@/hooks/queries/admin/diagnostics", () => ({
downloadDiagnosticReport: vi.fn(),
useDeleteDiagnosticReport: () => ({ isPending: false, mutate: vi.fn() }),
useDiagnosticReport: () => ({ data: undefined, isError: false, isLoading: false }),
useDiagnosticReports: () => ({
data: { reports: [] },
isError: false,
isFetching: false,
isLoading: false,
}),
useDiagnosticsStatus: () => mocks.useDiagnosticsStatus(),
useUpdateDiagnosticsUploadsEnabled: () => mocks.useUpdateDiagnosticsUploadsEnabled(),
}));
import AdminDiagnostics from "./AdminDiagnostics";
function diagnosticStatus(status: DiagnosticAvailabilityStatus): DiagnosticStatus {
return {
status,
server_instance_id: "server-id",
accepted_schema_versions: [1],
max_bundle_bytes: 10 * 1024 * 1024,
max_manifest_bytes: 64 * 1024,
retention_days: 30,
consent_notice_version: 1,
};
}
function renderPage() {
return render(
<MemoryRouter>
<AdminDiagnostics />
</MemoryRouter>,
);
}
describe("AdminDiagnostics uploads toggle", () => {
beforeEach(() => {
mocks.mutateUploadsEnabled.mockReset();
mocks.useDiagnosticsStatus.mockReset();
mocks.useUpdateDiagnosticsUploadsEnabled.mockReset();
mocks.useDiagnosticsStatus.mockReturnValue({
data: diagnosticStatus("disabled"),
isError: false,
isLoading: false,
});
mocks.useUpdateDiagnosticsUploadsEnabled.mockReturnValue({
isPending: false,
mutate: mocks.mutateUploadsEnabled,
});
});
it("renders disabled status off and submits enablement without changing optimistically", async () => {
const user = userEvent.setup();
renderPage();
const toggle = screen.getByRole("switch", { name: "Client uploads" });
expect(toggle).not.toBeChecked();
expect(screen.getByText(/Use the Client uploads toggle above to enable them/)).toBeVisible();
await user.click(toggle);
expect(mocks.mutateUploadsEnabled).toHaveBeenCalledWith(true);
expect(toggle).not.toBeChecked();
});
it("renders available status on and submits disablement", async () => {
mocks.useDiagnosticsStatus.mockReturnValue({
data: diagnosticStatus("available"),
isError: false,
isLoading: false,
});
const user = userEvent.setup();
renderPage();
const toggle = screen.getByRole("switch", { name: "Client uploads" });
expect(toggle).toBeChecked();
await user.click(toggle);
expect(mocks.mutateUploadsEnabled).toHaveBeenCalledWith(false);
});
it("keeps storage-unavailable status on while showing the warning", () => {
mocks.useDiagnosticsStatus.mockReturnValue({
data: diagnosticStatus("storage_unavailable"),
isError: false,
isLoading: false,
});
renderPage();
expect(screen.getByRole("switch", { name: "Client uploads" })).toBeChecked();
expect(screen.getByText(/Client diagnostic storage is currently unavailable/)).toBeVisible();
});
it("disables the toggle while an update is pending", () => {
mocks.useUpdateDiagnosticsUploadsEnabled.mockReturnValue({
isPending: true,
mutate: mocks.mutateUploadsEnabled,
});
renderPage();
expect(screen.getByRole("switch", { name: "Client uploads" })).toBeDisabled();
});
it("disables the toggle when status is unavailable", () => {
mocks.useDiagnosticsStatus.mockReturnValue({
data: undefined,
isError: true,
isLoading: false,
});
renderPage();
expect(screen.getByRole("switch", { name: "Client uploads" })).toBeDisabled();
});
});
+70 -35
View File
@@ -1,11 +1,12 @@
import { useEffect, useMemo, useState } from "react";
import type { FormEvent, KeyboardEvent } from "react";
import type { FormEvent, KeyboardEvent, ReactNode } from "react";
import { Link, useSearchParams } from "react-router";
import { Bug, Download, ExternalLink, FilterX, Trash2, TriangleAlert } from "lucide-react";
import { toast } from "sonner";
import type { DiagnosticReport, DiagnosticReportState, DiagnosticReportSummary } from "@/api/types";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { DateTimePicker } from "@/components/DateTimePicker";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -32,6 +33,8 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { useDateTimeFormat } from "@/hooks/useDateTimeFormat";
import {
downloadDiagnosticReport,
@@ -39,6 +42,7 @@ import {
useDiagnosticReport,
useDiagnosticReports,
useDiagnosticsStatus,
useUpdateDiagnosticsUploadsEnabled,
} from "@/hooks/queries/admin/diagnostics";
import { formatDateTime as formatPreferredDateTime } from "@/lib/datetime";
@@ -95,10 +99,12 @@ export default function AdminDiagnostics() {
}, [activeCursor, searchParams]);
const status = useDiagnosticsStatus();
const updateUploadsEnabled = useUpdateDiagnosticsUploadsEnabled();
const reports = useDiagnosticReports(query);
const selectedReport = useDiagnosticReport(selectedID);
const deleteReport = useDeleteDiagnosticReport();
const hasAppliedFilters = FILTER_KEYS.some((key) => searchParams.has(key));
const uploadsEnabled = status.data !== undefined && status.data.status !== "disabled";
function setFilter<Key extends keyof FilterDraft>(key: Key, value: FilterDraft[Key]) {
setFilters((current) => ({ ...current, [key]: value }));
@@ -170,12 +176,25 @@ export default function AdminDiagnostics() {
Review client crash reports, device context, and correlated playback sessions.
</p>
</div>
<div className="text-right">
<div className="text-muted-foreground text-xs font-medium tracking-[0.2em] uppercase">
Retention
<div className="flex items-center gap-5 sm:gap-6">
<div className="flex items-center gap-2">
<Label htmlFor="diagnostics-uploads-enabled" className="text-muted-foreground text-sm">
Client uploads
</Label>
<Switch
id="diagnostics-uploads-enabled"
checked={uploadsEnabled}
onCheckedChange={(enabled) => updateUploadsEnabled.mutate(enabled)}
disabled={!status.data || status.isError || updateUploadsEnabled.isPending}
/>
</div>
<div className="text-sm">
{status.data ? `${status.data.retention_days} days` : "Loading..."}
<div className="text-right">
<div className="text-muted-foreground text-xs font-medium tracking-[0.2em] uppercase">
Retention
</div>
<div className="text-sm">
{status.data ? `${status.data.retention_days} days` : "Loading..."}
</div>
</div>
</div>
</div>
@@ -185,24 +204,22 @@ export default function AdminDiagnostics() {
)}
<form
className="surface-panel-subtle grid gap-3 rounded-2xl p-4 md:grid-cols-2 xl:grid-cols-[110px_150px_170px_1fr_1fr_180px_auto] xl:items-end"
className="surface-panel-subtle flex flex-wrap items-end gap-x-3 gap-y-4 rounded-2xl p-4"
onSubmit={applyFilters}
>
<div className="space-y-2">
<Label htmlFor="diagnostics-user">User</Label>
<FilterField label="User" htmlFor="diagnostics-user" className="w-24">
<Input
id="diagnostics-user"
type="number"
min={1}
step={1}
inputMode="numeric"
placeholder="42"
placeholder="Any"
value={filters.userID}
onChange={(event) => setFilter("userID", event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="diagnostics-platform">Platform</Label>
</FilterField>
<FilterField label="Platform" htmlFor="diagnostics-platform" className="min-w-36 flex-1">
<Select value={filters.platform} onValueChange={(value) => setFilter("platform", value)}>
<SelectTrigger id="diagnostics-platform" className="w-full">
<SelectValue />
@@ -215,9 +232,8 @@ export default function AdminDiagnostics() {
<SelectItem value="tvos">tvOS</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="diagnostics-type">Report type</Label>
</FilterField>
<FilterField label="Report type" htmlFor="diagnostics-type" className="min-w-36 flex-1">
<Select
value={filters.reportType}
onValueChange={(value) => setFilter("reportType", value)}
@@ -235,36 +251,31 @@ export default function AdminDiagnostics() {
<SelectItem value="manual">Manual</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="diagnostics-from">From</Label>
<Input
</FilterField>
<FilterField label="From" htmlFor="diagnostics-from" className="min-w-44 flex-1">
<DateTimePicker
id="diagnostics-from"
type="datetime-local"
value={filters.from}
onChange={(event) => setFilter("from", event.target.value)}
onChange={(value) => setFilter("from", value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="diagnostics-to">To</Label>
<Input
</FilterField>
<FilterField label="To" htmlFor="diagnostics-to" className="min-w-44 flex-1">
<DateTimePicker
id="diagnostics-to"
type="datetime-local"
value={filters.to}
onChange={(event) => setFilter("to", event.target.value)}
onChange={(value) => setFilter("to", value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="diagnostics-short-id">Short ID</Label>
</FilterField>
<FilterField label="Short ID" htmlFor="diagnostics-short-id" className="min-w-32 flex-1">
<Input
id="diagnostics-short-id"
className="font-mono text-xs uppercase"
className="font-mono uppercase placeholder:normal-case"
placeholder="Exact ID"
value={filters.shortID}
onChange={(event) => setFilter("shortID", event.target.value)}
/>
</div>
<div className="flex gap-2 md:col-span-2 xl:col-span-1">
</FilterField>
<div className="ml-auto flex gap-2">
<Button type="submit">Apply</Button>
<Button
type="button"
@@ -413,6 +424,30 @@ export default function AdminDiagnostics() {
);
}
function FilterField({
label,
htmlFor,
className,
children,
}: {
label: string;
htmlFor: string;
className?: string;
children: ReactNode;
}) {
return (
<div className={cn("flex flex-col gap-1.5", className)}>
<Label
htmlFor={htmlFor}
className="text-muted-foreground text-[11px] font-medium tracking-wide uppercase"
>
{label}
</Label>
{children}
</div>
);
}
function DiagnosticReportRow({
report,
onSelect,
@@ -576,7 +611,7 @@ function DiagnosticReportDetail({
function FeatureStatusBanner({ status }: { status: "disabled" | "storage_unavailable" }) {
const message =
status === "disabled"
? "Client diagnostic uploads are currently disabled. Reports from when the feature was enabled may still be available below."
? "Client diagnostic uploads are currently disabled. Use the Client uploads toggle above to enable them. Reports from when the feature was enabled may still be available below."
: "Client diagnostic storage is currently unavailable. Existing report metadata may still be available below.";
return (
<div className="border-border bg-muted/30 text-muted-foreground flex items-start gap-3 rounded-xl border px-4 py-3 text-sm">