From 29267534da527344550fff7c6d312c7e6722316d Mon Sep 17 00:00:00 2001 From: Max Soloviov Date: Fri, 19 Dec 2025 14:32:01 +0200 Subject: [PATCH 1/5] issues/155/Settings icon not visible on dark theme --- app/components/aside.tsx | 6 ++---- app/components/icons.tsx | 4 ++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/app/components/aside.tsx b/app/components/aside.tsx index d2a7309f..e78e95fd 100644 --- a/app/components/aside.tsx +++ b/app/components/aside.tsx @@ -52,10 +52,8 @@ export const Aside: React.FC = () => { = ({ size = 24, width, height, ...pr Date: Fri, 19 Dec 2025 15:19:02 +0200 Subject: [PATCH 2/5] issues/109/Server: each report contain - options --- app/components/reports-table.tsx | 50 ++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/app/components/reports-table.tsx b/app/components/reports-table.tsx index 5b8d5f3b..180b2ecc 100644 --- a/app/components/reports-table.tsx +++ b/app/components/reports-table.tsx @@ -55,6 +55,17 @@ const coreFields = [ 'errors', ]; +const formatMetadataValue = (value: any): string => { + if (value === null || value === undefined) { + return String(value); + } + if (typeof value === 'object') { + return JSON.stringify(value); + } + + return String(value); +}; + const getMetadataItems = (item: ReportHistory) => { const metadata: Array<{ key: string; value: any; icon?: React.ReactNode }> = []; @@ -77,6 +88,10 @@ const getMetadataItems = (item: ReportHistory) => { // Add any other metadata fields Object.entries(itemWithMetadata).forEach(([key, value]) => { if (!coreFields.includes(key) && !['environment', 'workingDir', 'branch'].includes(key)) { + // Skip empty objects + if (value !== null && typeof value === 'object' && Object.keys(value).length === 0) { + return; + } metadata.push({ key, value }); } }); @@ -145,6 +160,7 @@ export default function ReportsTable({ onChange }: Readonly) }, [project, total, rowsPerPage]); error && toast.error(error.message); + console.log('reports', reports); return ( <> @@ -206,21 +222,25 @@ export default function ReportsTable({ onChange }: Readonly) {/* Metadata chips below title */}
- {getMetadataItems(item).map(({ key, value, icon }, index) => ( - - - {key === 'branch' || key === 'workingDir' ? value : `${key}: ${value}`} - - - ))} + {getMetadataItems(item).map(({ key, value, icon }, index) => { + const formattedValue = formatMetadataValue(value); + const displayValue = + key === 'branch' || key === 'workingDir' ? formattedValue : `${key}: ${formattedValue}`; + + return ( + + {displayValue} + + ); + })}
From f8b41c1025239583a6c103f81be07b894c6448c2 Mon Sep 17 00:00:00 2001 From: Max Soloviov Date: Fri, 19 Dec 2025 15:26:38 +0200 Subject: [PATCH 3/5] issues/110/mass delete reports from UI --- app/components/delete-report-button.tsx | 97 +++++++++++++------------ app/components/reports-table.tsx | 31 +++++++- app/components/reports.tsx | 34 ++++++++- 3 files changed, 109 insertions(+), 53 deletions(-) diff --git a/app/components/delete-report-button.tsx b/app/components/delete-report-button.tsx index 6657166a..624596c5 100644 --- a/app/components/delete-report-button.tsx +++ b/app/components/delete-report-button.tsx @@ -9,12 +9,16 @@ import { DeleteIcon } from '@/app/components/icons'; import { invalidateCache } from '@/app/lib/query-cache'; interface DeleteProjectButtonProps { - reportId: string; + reportId?: string; + reportIds?: string[]; onDeleted: () => void; + label?: string; } -export default function DeleteReportButton({ reportId, onDeleted }: DeleteProjectButtonProps) { +export default function DeleteReportButton({ reportId, reportIds, onDeleted, label }: DeleteProjectButtonProps) { const queryClient = useQueryClient(); + const ids = reportIds ?? (reportId ? [reportId] : []); + const { mutate: deleteReport, isPending, @@ -23,18 +27,18 @@ export default function DeleteReportButton({ reportId, onDeleted }: DeleteProjec method: 'DELETE', onSuccess: () => { invalidateCache(queryClient, { queryKeys: ['/api/info'], predicate: '/api/report' }); - toast.success(`report "${reportId}" deleted`); + toast.success(`report${ids.length > 1 ? 's' : ''} deleted`); }, }); const { isOpen, onOpen, onOpenChange } = useDisclosure(); const DeleteReport = async () => { - if (!reportId) { + if (!ids.length) { return; } - deleteReport({ body: { reportsIds: [reportId] } }); + deleteReport({ body: { reportsIds: ids } }); onDeleted?.(); }; @@ -42,47 +46,46 @@ export default function DeleteReportButton({ reportId, onDeleted }: DeleteProjec error && toast.error(error.message); return ( - !!reportId && ( - <> - - - - {(onClose) => ( - <> - Are you sure? - -

This will permanently delete your report

-
- - - - - - )} -
-
- - ) + <> + + + + {(onClose) => ( + <> + Are you sure? + +

This will permanently delete your report{ids.length > 1 ? 's' : ''}.

+
+ + + + + + )} +
+
+ ); } diff --git a/app/components/reports-table.tsx b/app/components/reports-table.tsx index 180b2ecc..6fc2e055 100644 --- a/app/components/reports-table.tsx +++ b/app/components/reports-table.tsx @@ -13,6 +13,7 @@ import { Pagination, LinkIcon, Chip, + type Selection, } from '@heroui/react'; import Link from 'next/link'; import { keepPreviousData } from '@tanstack/react-query'; @@ -101,9 +102,12 @@ const getMetadataItems = (item: ReportHistory) => { interface ReportsTableProps { onChange: () => void; + selected?: string[]; + onSelect?: (reports: ReportHistory[]) => void; + onDeleted?: () => void; } -export default function ReportsTable({ onChange }: Readonly) { +export default function ReportsTable({ onChange, selected, onSelect, onDeleted }: Readonly) { const reportListEndpoint = '/api/report/list'; const [project, setProject] = useState(defaultProjectName); const [search, setSearch] = useState(''); @@ -130,11 +134,29 @@ export default function ReportsTable({ onChange }: Readonly) const { reports, total } = reportResponse ?? {}; - const onDeleted = () => { + const handleDeleted = () => { + onDeleted?.(); onChange?.(); refetch(); }; + const onChangeSelect = (keys: Selection) => { + if (keys === 'all') { + const all = reports ?? []; + + onSelect?.(all); + } + + if (typeof keys === 'string') { + return; + } + + const selectedKeys = Array.from(keys); + const selectedReports = reports?.filter((r) => selectedKeys.includes(r.reportID)) ?? []; + + onSelect?.(selectedReports); + }; + const onPageChange = useCallback( (page: number) => { setPage(page); @@ -195,6 +217,9 @@ export default function ReportsTable({ onChange }: Readonly) tr: 'border-b-1 rounded-0', }} radius="none" + selectedKeys={selected} + selectionMode="multiple" + onSelectionChange={onChangeSelect} > {(column) => ( @@ -256,7 +281,7 @@ export default function ReportsTable({ onChange }: Readonly) Open report - + diff --git a/app/components/reports.tsx b/app/components/reports.tsx index 528cd8e2..83dcfd94 100644 --- a/app/components/reports.tsx +++ b/app/components/reports.tsx @@ -1,18 +1,46 @@ +'use client'; + +import { useState } from 'react'; + import ReportsTable from '@/app/components/reports-table'; import { title } from '@/app/components/primitives'; +import DeleteReportButton from '@/app/components/delete-report-button'; +import { type ReportHistory } from '@/app/lib/storage'; interface ReportsProps { onChange: () => void; } export default function Reports({ onChange }: ReportsProps) { + const [selectedReports, setSelectedReports] = useState([]); + + const selectedReportIds = selectedReports.map((r) => r.reportID); + + const onListUpdate = () => { + setSelectedReports([]); + onChange?.(); + }; + return ( <> -
-

Reports

+
+
+

Reports

+
+
+ {selectedReports.length > 0 && ( +
Reports selected: {selectedReports.length}
+ )} + +

- + ); } From beeb1ad1d19628dd5920f7bb3c0900d4385af422 Mon Sep 17 00:00:00 2001 From: Max Soloviov Date: Sun, 21 Dec 2025 17:08:03 +0200 Subject: [PATCH 4/5] issues/111/filter by date+time range for reports/results --- app/api/report/list/route.ts | 6 +- app/api/result/list/route.ts | 6 +- app/components/date-range-picker.tsx | 108 ++++++++++++++++++++ app/components/reports-table.tsx | 21 +++- app/components/results-table.tsx | 20 +++- app/components/table-pagination-options.tsx | 17 +++ app/lib/service/index.ts | 31 ++++++ app/lib/storage/fs.ts | 38 +++++++ app/lib/storage/s3.ts | 38 +++++++ app/lib/storage/types.ts | 4 + package-lock.json | 70 +++++++------ package.json | 5 +- 12 files changed, 328 insertions(+), 36 deletions(-) create mode 100644 app/components/date-range-picker.tsx diff --git a/app/api/report/list/route.ts b/app/api/report/list/route.ts index ad216c87..ae2b1327 100644 --- a/app/api/report/list/route.ts +++ b/app/api/report/list/route.ts @@ -11,8 +11,12 @@ export async function GET(request: NextRequest) { const pagination = parseFromRequest(searchParams); const project = searchParams.get('project') ?? ''; const search = searchParams.get('search') ?? ''; + const dateFrom = searchParams.get('dateFrom') ?? undefined; + const dateTo = searchParams.get('dateTo') ?? undefined; - const { result: reports, error } = await withError(service.getReports({ pagination, project, search })); + const { result: reports, error } = await withError( + service.getReports({ pagination, project, search, dateFrom, dateTo }), + ); if (error) { return new Response(error.message, { status: 400 }); diff --git a/app/api/result/list/route.ts b/app/api/result/list/route.ts index 24515fb7..2af78e0d 100644 --- a/app/api/result/list/route.ts +++ b/app/api/result/list/route.ts @@ -12,8 +12,12 @@ export async function GET(request: NextRequest) { const project = searchParams.get('project') ?? ''; const tags = searchParams.get('tags')?.split(',').filter(Boolean) ?? []; const search = searchParams.get('search') ?? ''; + const dateFrom = searchParams.get('dateFrom') ?? undefined; + const dateTo = searchParams.get('dateTo') ?? undefined; - const { result, error } = await withError(service.getResults({ pagination, project, tags, search })); + const { result, error } = await withError( + service.getResults({ pagination, project, tags, search, dateFrom, dateTo }), + ); if (error) { return new Response(error.message, { status: 400 }); diff --git a/app/components/date-range-picker.tsx b/app/components/date-range-picker.tsx new file mode 100644 index 00000000..f2be3a49 --- /dev/null +++ b/app/components/date-range-picker.tsx @@ -0,0 +1,108 @@ +'use client'; + +import { DateRangePicker as HeroUIDateRangePicker } from '@heroui/react'; +import { useCallback, useMemo } from 'react'; +import { CalendarDateTime } from '@internationalized/date'; +import { I18nProvider } from '@react-aria/i18n'; + +interface DateRangePickerProps { + dateFrom?: string; + dateTo?: string; + label?: string; + onDateFromChange?: (date: string) => void; + onDateToChange?: (date: string) => void; +} + +export default function DateRangePicker({ + dateFrom, + dateTo, + label = 'Date Range', + onDateFromChange, + onDateToChange, +}: Readonly) { + // Convert ISO strings to CalendarDateTime for HeroUI DateRangePicker (includes time fields) + const defaultValue = useMemo(() => { + if (!dateFrom || !dateTo) return undefined; + + try { + // Parse ISO strings and convert to CalendarDateTime (includes time) + const startDate = new Date(dateFrom); + const endDate = new Date(dateTo); + + // Create CalendarDateTime objects with time + const start = new CalendarDateTime( + startDate.getFullYear(), + startDate.getMonth() + 1, + startDate.getDate(), + startDate.getHours(), + startDate.getMinutes(), + ); + const end = new CalendarDateTime( + endDate.getFullYear(), + endDate.getMonth() + 1, + endDate.getDate(), + endDate.getHours(), + endDate.getMinutes(), + ); + + return { start, end }; + } catch { + return undefined; + } + }, [dateFrom, dateTo]); + + const handleChange = useCallback( + (range: { start: any; end: any } | null) => { + if (!range) { + onDateFromChange?.(''); + onDateToChange?.(''); + + return; + } + + if (range.start && onDateFromChange) { + // Convert CalendarDateTime to ISO string + const year = range.start.year; + const month = String(range.start.month).padStart(2, '0'); + const day = String(range.start.day).padStart(2, '0'); + const hour = String(range.start.hour).padStart(2, '0'); + const minute = String(range.start.minute).padStart(2, '0'); + const isoString = `${year}-${month}-${day}T${hour}:${minute}:00.000Z`; + + onDateFromChange(isoString); + } else if (!range.start && onDateFromChange) { + onDateFromChange(''); + } + + if (range.end && onDateToChange) { + // Convert CalendarDateTime to ISO string + const year = range.end.year; + const month = String(range.end.month).padStart(2, '0'); + const day = String(range.end.day).padStart(2, '0'); + const hour = String(range.end.hour).padStart(2, '0'); + const minute = String(range.end.minute).padStart(2, '0'); + const isoString = `${year}-${month}-${day}T${hour}:${minute}:00.000Z`; + + onDateToChange(isoString); + } else if (!range.end && onDateToChange) { + onDateToChange(''); + } + }, + [onDateFromChange, onDateToChange], + ); + + return ( + + + + ); +} diff --git a/app/components/reports-table.tsx b/app/components/reports-table.tsx index 6fc2e055..c6289087 100644 --- a/app/components/reports-table.tsx +++ b/app/components/reports-table.tsx @@ -111,6 +111,8 @@ export default function ReportsTable({ onChange, selected, onSelect, onDeleted } const reportListEndpoint = '/api/report/list'; const [project, setProject] = useState(defaultProjectName); const [search, setSearch] = useState(''); + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); const [page, setPage] = useState(1); const [rowsPerPage, setRowsPerPage] = useState(10); @@ -119,6 +121,8 @@ export default function ReportsTable({ onChange, selected, onSelect, onDeleted } offset: ((page - 1) * rowsPerPage).toString(), project, ...(search.trim() && { search: search.trim() }), + ...(dateFrom && { dateFrom }), + ...(dateTo && { dateTo }), }); const { @@ -128,7 +132,7 @@ export default function ReportsTable({ onChange, selected, onSelect, onDeleted } error, refetch, } = useQuery(withQueryParams(reportListEndpoint, getQueryParams()), { - dependencies: [project, search, rowsPerPage, page], + dependencies: [project, search, dateFrom, dateTo, rowsPerPage, page], placeholderData: keepPreviousData, }); @@ -177,6 +181,16 @@ export default function ReportsTable({ onChange, selected, onSelect, onDeleted } setPage(1); }, []); + const onDateFromChange = useCallback((date: string) => { + setDateFrom(date); + setPage(1); + }, []); + + const onDateToChange = useCallback((date: string) => { + setDateTo(date); + setPage(1); + }, []); + const pages = useMemo(() => { return total ? Math.ceil(total / rowsPerPage) : 0; }, [project, total, rowsPerPage]); @@ -187,11 +201,16 @@ export default function ReportsTable({ onChange, selected, onSelect, onDeleted } return ( <> diff --git a/app/components/results-table.tsx b/app/components/results-table.tsx index 33692d29..d1be3f25 100644 --- a/app/components/results-table.tsx +++ b/app/components/results-table.tsx @@ -50,6 +50,8 @@ export default function ResultsTable({ onSelect, onDeleted, selected }: Readonly const [project, setProject] = useState(defaultProjectName); const [selectedTags, setSelectedTags] = useState([]); const [search, setSearch] = useState(''); + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); const [page, setPage] = useState(1); const [rowsPerPage, setRowsPerPage] = useState(10); @@ -59,6 +61,8 @@ export default function ResultsTable({ onSelect, onDeleted, selected }: Readonly project, ...(selectedTags.length > 0 && { tags: selectedTags.join(',') }), ...(search.trim() && { search: search.trim() }), + ...(dateFrom && { dateFrom }), + ...(dateTo && { dateTo }), }); const { @@ -68,7 +72,7 @@ export default function ResultsTable({ onSelect, onDeleted, selected }: Readonly error, refetch, } = useQuery(withQueryParams(resultListEndpoint, getQueryParams()), { - dependencies: [project, selectedTags, search, rowsPerPage, page], + dependencies: [project, selectedTags, search, dateFrom, dateTo, rowsPerPage, page], placeholderData: keepPreviousData, }); @@ -104,6 +108,16 @@ export default function ResultsTable({ onSelect, onDeleted, selected }: Readonly setPage(1); }, []); + const onDateFromChange = useCallback((date: string) => { + setDateFrom(date); + setPage(1); + }, []); + + const onDateToChange = useCallback((date: string) => { + setDateTo(date); + setPage(1); + }, []); + const pages = useMemo(() => { return total ? Math.ceil(total / rowsPerPage) : 0; }, [project, total, rowsPerPage]); @@ -139,6 +153,10 @@ export default function ResultsTable({ onSelect, onDeleted, selected }: Readonly onProjectChange={onProjectChange} onSearchChange={onSearchChange} onTagsChange={onTagsChange} + onDateFromChange={onDateFromChange} + onDateToChange={onDateToChange} + dateFrom={dateFrom} + dateTo={dateTo} /> void; onSearchChange?: (search: string) => void; onTagsChange?: (tags: string[]) => void; + onDateFromChange?: (date: string) => void; + onDateToChange?: (date: string) => void; + dateFrom?: string; + dateTo?: string; rowPerPageOptions?: number[]; entity: 'report' | 'result'; } @@ -29,6 +34,10 @@ export default function TablePaginationOptions({ onProjectChange, onSearchChange, onTagsChange, + onDateFromChange, + onDateToChange, + dateFrom, + dateTo, }: TablePaginationRowProps) { const rowPerPageItems = rowPerPageOptions ?? defaultRowPerPageOptions; @@ -55,6 +64,14 @@ export default function TablePaginationOptions({ /> {entity === 'result' && } + {(onDateFromChange || onDateToChange) && ( + + )}