From 4e42cb30534fd9384c60c911cff58ae0318f87ba Mon Sep 17 00:00:00 2001
From: Quick104 <31828688+Quick104@users.noreply.github.com>
Date: Wed, 22 Jul 2026 13:13:52 -0400
Subject: [PATCH] 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
---
web/package.json | 1 +
web/pnpm-lock.yaml | 31 ++++
web/src/components/DateTimePicker.tsx | 104 ++++++++++++++
web/src/components/ui/calendar.tsx | 67 +++++++++
.../hooks/queries/admin/diagnostics.test.ts | 85 ++++++++++-
web/src/hooks/queries/admin/diagnostics.ts | 26 ++++
web/src/pages/AdminDiagnostics.test.tsx | 133 ++++++++++++++++++
web/src/pages/AdminDiagnostics.tsx | 105 +++++++++-----
8 files changed, 515 insertions(+), 37 deletions(-)
create mode 100644 web/src/components/DateTimePicker.tsx
create mode 100644 web/src/components/ui/calendar.tsx
create mode 100644 web/src/pages/AdminDiagnostics.test.tsx
diff --git a/web/package.json b/web/package.json
index b5e9362a..b5b4b23c 100644
--- a/web/package.json
+++ b/web/package.json
@@ -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",
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index 62c13ffb..ffc874fd 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -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
diff --git a/web/src/components/DateTimePicker.tsx b/web/src/components/DateTimePicker.tsx
new file mode 100644
index 00000000..5b3c7af0
--- /dev/null
+++ b/web/src/components/DateTimePicker.tsx
@@ -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 (
+
+
+
+
+
+
+
+ handleTimeChange(event.target.value)}
+ className="h-8"
+ />
+
+
+
+
+ );
+}
+
+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())}`;
+}
diff --git a/web/src/components/ui/calendar.tsx b/web/src/components/ui/calendar.tsx
new file mode 100644
index 00000000..b2d3d753
--- /dev/null
+++ b/web/src/components/ui/calendar.tsx
@@ -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) {
+ return (
+ 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 ;
+ },
+ }}
+ {...props}
+ />
+ );
+}
+
+export { Calendar };
diff --git a/web/src/hooks/queries/admin/diagnostics.test.ts b/web/src/hooks/queries/admin/diagnostics.test.ts
index 9af2572b..214daa38 100644
--- a/web/src/hooks/queries/admin/diagnostics.test.ts
+++ b/web/src/hooks/queries/admin/diagnostics.test.ts
@@ -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();
+ });
+});
diff --git a/web/src/hooks/queries/admin/diagnostics.ts b/web/src/hooks/queries/admin/diagnostics.ts
index 7d3b26d0..535d8a2a 100644
--- a/web/src/hooks/queries/admin/diagnostics.ts
+++ b/web/src/hooks/queries/admin/diagnostics.ts
@@ -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("/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({
diff --git a/web/src/pages/AdminDiagnostics.test.tsx b/web/src/pages/AdminDiagnostics.test.tsx
new file mode 100644
index 00000000..d1ad76ad
--- /dev/null
+++ b/web/src/pages/AdminDiagnostics.test.tsx
@@ -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(
+
+
+ ,
+ );
+}
+
+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();
+ });
+});
diff --git a/web/src/pages/AdminDiagnostics.tsx b/web/src/pages/AdminDiagnostics.tsx
index b681c576..bf06a8f7 100644
--- a/web/src/pages/AdminDiagnostics.tsx
+++ b/web/src/pages/AdminDiagnostics.tsx
@@ -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: 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.
-
-
- Retention
+
+
+
+ updateUploadsEnabled.mutate(enabled)}
+ disabled={!status.data || status.isError || updateUploadsEnabled.isPending}
+ />
-
- {status.data ? `${status.data.retention_days} days` : "Loading..."}
+
+
+ Retention
+
+
+ {status.data ? `${status.data.retention_days} days` : "Loading..."}
+
@@ -185,24 +204,22 @@ export default function AdminDiagnostics() {
)}