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:
@@ -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",
|
||||
|
||||
Generated
+31
@@ -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
|
||||
|
||||
@@ -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())}`;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user