filters and error handling

This commit is contained in:
Reece
2026-03-24 15:52:42 +00:00
parent 2139edc157
commit fac51bc339
5 changed files with 236 additions and 11 deletions
@@ -9,6 +9,7 @@ import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
@@ -63,7 +64,7 @@ public class PipelineController {
MultipartFile[] files = request.getFileInput();
String jsonString = request.getJson();
if (files == null) {
return null;
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
PipelineConfig config = objectMapper.readValue(jsonString, PipelineConfig.class);
log.info("Received POST request to /handleData with {} files", files.length);
@@ -80,7 +81,7 @@ public class PipelineController {
try {
List<Resource> inputFiles = processor.generateInputFiles(files);
if (inputFiles == null || inputFiles.isEmpty()) {
return null;
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
PipelineResult result = processor.runPipelineAgainstFiles(inputFiles, config);
List<Resource> outputFiles = result.getOutputFiles();
@@ -102,7 +103,7 @@ public class PipelineController {
throw e;
}
} else if (outputFiles == null) {
return null;
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
// Multiple files: stream into a zip TempFile
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
@@ -137,7 +138,7 @@ public class PipelineController {
}
} catch (Exception e) {
log.error("Error handling data: ", e);
return null;
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}
@@ -20,6 +20,8 @@ interface CardExpansionModalProps {
children: React.ReactNode;
/** Footer content */
footer?: React.ReactNode;
/** Non-scrolling toolbar rendered between the header and the list */
toolbar?: React.ReactNode;
}
const MODAL_W_REM = 56;
@@ -39,6 +41,7 @@ export function CardExpansionModal({
labelPlural,
children,
footer,
toolbar,
}: CardExpansionModalProps) {
const [viewportW, setViewportW] = useState(window.innerWidth);
const [viewportH, setViewportH] = useState(window.innerHeight);
@@ -160,6 +163,11 @@ export function CardExpansionModal({
{/* Body — only mount once open */}
{showBody && (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, backgroundColor: 'var(--bg-toolbar)' }}>
{toolbar && (
<div style={{ flexShrink: 0, borderBottom: '0.0625rem solid var(--border-subtle)' }}>
{toolbar}
</div>
)}
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
<div style={{ padding: '0.75rem 1rem', backgroundColor: 'var(--bg-toolbar)' }}>
{children}
@@ -1,4 +1,4 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import {
Box,
Text,
@@ -7,12 +7,15 @@ import {
Button,
ScrollArea,
Loader,
TextInput,
Select,
} from '@mantine/core';
import { useCardModalAnimation } from '@app/hooks/useCardModalAnimation';
import { CardExpansionModal } from '@app/components/smartFolders/CardExpansionModal';
import { StatCard } from '@app/components/smartFolders/StatCard';
import { useTranslation } from 'react-i18next';
import SearchIcon from '@mui/icons-material/Search';
import DownloadIcon from '@mui/icons-material/Download';
import HistoryIcon from '@mui/icons-material/History';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
@@ -43,6 +46,59 @@ import { SmartFolderHomePage, humaniseOp } from '@app/components/smartFolders/Sm
import { useNavigationActions } from '@app/contexts/NavigationContext';
import { FilePreviewModal } from '@app/components/smartFolders/FilePreviewModal';
const SORT_OPTIONS = [
{ value: 'newest', label: 'Newest' },
{ value: 'oldest', label: 'Oldest' },
{ value: 'name-asc', label: 'A → Z' },
{ value: 'name-desc', label: 'Z → A' },
];
const ACTIVITY_STATUS_OPTIONS = [
{ value: 'all', label: 'All' },
{ value: 'processed', label: 'Done' },
{ value: 'processing', label: 'Active' },
{ value: 'error', label: 'Failed' },
{ value: 'pending', label: 'Pending' },
];
function FilterSortBar({
search,
onSearchChange,
sort,
onSortChange,
extra,
}: {
search: string;
onSearchChange: (v: string) => void;
sort: string;
onSortChange: (v: string) => void;
extra?: React.ReactNode;
}) {
return (
<Box style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', padding: '0.5rem 0.75rem' }}>
<TextInput
size="xs"
placeholder="Filter…"
value={search}
onChange={e => onSearchChange(e.currentTarget.value)}
leftSection={<SearchIcon style={{ fontSize: '0.875rem' }} />}
style={{ flex: 1 }}
styles={{ input: { fontSize: '0.75rem' } }}
/>
{extra}
<Select
size="xs"
value={sort}
onChange={v => v && onSortChange(v)}
data={SORT_OPTIONS}
style={{ width: '6.5rem' }}
styles={{ input: { fontSize: '0.75rem' } }}
comboboxProps={{ withinPortal: true }}
/>
</Box>
);
}
interface SmartFolderWorkbenchViewProps {
data: { folderId: string | null; pendingFileId?: string; pendingFileIds?: string[] };
}
@@ -100,6 +156,17 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
const [inputFiles, setInputFiles] = useState<StirlingFile[]>([]);
const [previewFileId, setPreviewFileId] = useState<FileId | null>(null);
const [previewFileName, setPreviewFileName] = useState('');
// Filter / sort state
const [activitySearch, setActivitySearch] = useState('');
const [activitySort, setActivitySort] = useState('newest');
const [activityStatusFilter, setActivityStatusFilter] = useState('all');
const [inputSearch, setInputSearch] = useState('');
const [inputSort, setInputSort] = useState('newest');
const [outputSearch, setOutputSearch] = useState('');
const [outputSort, setOutputSort] = useState('newest');
const [failedSearch, setFailedSearch] = useState('');
const [failedSort, setFailedSort] = useState('newest');
const [automation, setAutomation] = useState<AutomationConfig | null>(null);
const { phase: inputModalPhase, cardRect: inputCardRect, textExpanded: inputTextExpanded, openModal: openInputModal, closeModal: closeInputModal } = useCardModalAnimation();
const { phase: outputModalPhase, cardRect: outputCardRect, textExpanded: outputTextExpanded, openModal: openOutputModal, closeModal: closeOutputModal } = useCardModalAnimation();
@@ -273,6 +340,81 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
}
}, [folder, updateFolder, fileIds, folderRecord, inputFiles, runPipeline]);
// ── Filtered + sorted lists — all before early returns ──
const filteredActivityIds = useMemo(() => {
const q = activitySearch.toLowerCase();
let items = fileIds.map(id => {
const meta = folderRecord?.files[id];
const name = meta?.name ?? inputFiles.find(f => f.fileId === id)?.name ?? id;
const status = meta?.status ?? 'pending';
const time = meta?.processedAt
? new Date(meta.processedAt).getTime()
: meta?.addedAt ? new Date(meta.addedAt).getTime() : 0;
return { id, name, status, time };
});
if (q) items = items.filter(i => i.name.toLowerCase().includes(q));
if (activityStatusFilter !== 'all') items = items.filter(i => i.status === activityStatusFilter);
if (activitySort === 'newest') items = [...items].reverse();
else if (activitySort === 'name-asc') items = [...items].sort((a, b) => a.name.localeCompare(b.name));
else if (activitySort === 'name-desc') items = [...items].sort((a, b) => b.name.localeCompare(a.name));
// 'oldest' keeps the original order (insertion order = oldest first)
return items.map(i => i.id);
}, [fileIds, folderRecord, inputFiles, activitySearch, activityStatusFilter, activitySort]);
const filteredInputFiles = useMemo(() => {
const q = inputSearch.toLowerCase();
let files = q ? inputFiles.filter(f => f.name.toLowerCase().includes(q)) : [...inputFiles];
if (inputSort === 'newest') files.sort((a, b) => {
const ta = folderRecord?.files[a.fileId]?.addedAt ? new Date(folderRecord.files[a.fileId].addedAt!).getTime() : a.lastModified;
const tb = folderRecord?.files[b.fileId]?.addedAt ? new Date(folderRecord.files[b.fileId].addedAt!).getTime() : b.lastModified;
return tb - ta;
});
else if (inputSort === 'oldest') files.sort((a, b) => {
const ta = folderRecord?.files[a.fileId]?.addedAt ? new Date(folderRecord.files[a.fileId].addedAt!).getTime() : a.lastModified;
const tb = folderRecord?.files[b.fileId]?.addedAt ? new Date(folderRecord.files[b.fileId].addedAt!).getTime() : b.lastModified;
return ta - tb;
});
else if (inputSort === 'name-asc') files.sort((a, b) => a.name.localeCompare(b.name));
else if (inputSort === 'name-desc') files.sort((a, b) => b.name.localeCompare(a.name));
return files;
}, [inputFiles, inputSearch, inputSort, folderRecord]);
const filteredOutputFiles = useMemo(() => {
const q = outputSearch.toLowerCase();
let files = q ? outputFiles.filter(f => f.name.toLowerCase().includes(q)) : [...outputFiles];
const getTime = (f: StirlingFile) => {
const processedAt = Object.values(folderRecord?.files ?? {}).find(m =>
m.displayFileIds?.includes(f.fileId) || m.displayFileId === f.fileId
)?.processedAt;
return processedAt ? new Date(processedAt).getTime() : f.lastModified;
};
if (outputSort === 'newest') files.sort((a, b) => getTime(b) - getTime(a));
else if (outputSort === 'oldest') files.sort((a, b) => getTime(a) - getTime(b));
else if (outputSort === 'name-asc') files.sort((a, b) => a.name.localeCompare(b.name));
else if (outputSort === 'name-desc') files.sort((a, b) => b.name.localeCompare(a.name));
return files;
}, [outputFiles, outputSearch, outputSort, folderRecord]);
const filteredFailedIds = useMemo(() => {
const q = failedSearch.toLowerCase();
const allFailedIds = fileIds.filter(id => folderRecord?.files[id]?.status === 'error');
let items = allFailedIds.map(id => {
const meta = folderRecord?.files[id];
const name = meta?.name ?? inputFiles.find(f => f.fileId === id)?.name ?? id;
const time = meta?.lastFailedAt ? new Date(meta.lastFailedAt).getTime() : 0;
const attempts = meta?.failedAttempts ?? 0;
return { id, name, time, attempts };
});
if (q) items = items.filter(i => i.name.toLowerCase().includes(q));
if (failedSort === 'newest') items = [...items].sort((a, b) => b.time - a.time);
else if (failedSort === 'oldest') items = [...items].sort((a, b) => a.time - b.time);
else if (failedSort === 'name-asc') items = [...items].sort((a, b) => a.name.localeCompare(b.name));
else if (failedSort === 'name-desc') items = [...items].sort((a, b) => b.name.localeCompare(a.name));
return items.map(i => i.id);
}, [fileIds, folderRecord, inputFiles, failedSearch, failedSort]);
// Early returns — after all hooks
if (!folderId) return <SmartFolderHomePage />;
const FolderIcon = folder
@@ -535,15 +677,40 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
</Text>
</Box>
{/* Activity filter/sort toolbar */}
{fileIds.length > 0 && (
<FilterSortBar
search={activitySearch}
onSearchChange={setActivitySearch}
sort={activitySort}
onSortChange={setActivitySort}
extra={
<Select
size="xs"
value={activityStatusFilter}
onChange={v => v && setActivityStatusFilter(v)}
data={ACTIVITY_STATUS_OPTIONS}
style={{ width: '5.5rem' }}
styles={{ input: { fontSize: '0.75rem' } }}
comboboxProps={{ withinPortal: true }}
/>
}
/>
)}
<ScrollArea style={{ flex: 1 }}>
<Stack gap="xs" px="md" pb="md">
{fileIds.length === 0 ? (
<Text size="xs" c="dimmed" ta="center" py="lg">
{t('smartFolders.workbench.noActivity', 'No activity yet — drop a PDF to start')}
</Text>
) : filteredActivityIds.length === 0 ? (
<Text size="xs" c="dimmed" ta="center" py="lg">
{t('smartFolders.workbench.noActivityMatch', 'No matching activity')}
</Text>
) : (
<>
{[...fileIds].reverse().map((fileId) => {
{filteredActivityIds.map((fileId) => {
const meta = folderRecord?.files[fileId];
const status = meta?.status ?? 'pending';
const run = recentRuns.find(r => r.inputFileId === fileId);
@@ -624,6 +791,14 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
count={inputFiles.length}
labelSingular={t('smartFolders.workbench.inputFile', 'input file')}
labelPlural={t('smartFolders.workbench.inputFiles', 'input files')}
toolbar={
<FilterSortBar
search={inputSearch}
onSearchChange={setInputSearch}
sort={inputSort}
onSortChange={setInputSort}
/>
}
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0.375rem 0.625rem', borderRadius: '0.25rem', display: 'inline-flex', alignItems: 'center', gap: '0.375rem', fontSize: '0.875rem', color: 'var(--mantine-color-dimmed)' }}
@@ -640,7 +815,11 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
<Text size="sm" c="dimmed" ta="center" py="xl">
{t('smartFolders.workbench.noInputFiles', 'No input files stored yet')}
</Text>
) : inputFiles.map((file) => {
) : filteredInputFiles.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
{t('smartFolders.workbench.noMatch', 'No matching files')}
</Text>
) : filteredInputFiles.map((file) => {
const inputMeta = folderRecord?.files[file.fileId];
return (
<Box
@@ -682,6 +861,14 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
count={outputFiles.length}
labelSingular={t('smartFolders.workbench.outputFile', 'output file')}
labelPlural={t('smartFolders.workbench.outputFiles', 'output files')}
toolbar={
<FilterSortBar
search={outputSearch}
onSearchChange={setOutputSearch}
sort={outputSort}
onSortChange={setOutputSort}
/>
}
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0.375rem 0.625rem', borderRadius: '0.25rem', display: 'inline-flex', alignItems: 'center', gap: '0.375rem', fontSize: '0.875rem', color: 'var(--mantine-color-dimmed)' }}
@@ -698,7 +885,11 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
<Text size="sm" c="dimmed" ta="center" py="xl">
{t('smartFolders.workbench.noOutputFiles', 'No output files stored yet')}
</Text>
) : outputFiles.map((file) => {
) : filteredOutputFiles.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
{t('smartFolders.workbench.noMatch', 'No matching files')}
</Text>
) : filteredOutputFiles.map((file) => {
const processedAt = Object.values(folderRecord?.files ?? {}).find(m =>
m.displayFileIds?.includes(file.fileId) || m.displayFileId === file.fileId
)?.processedAt;
@@ -741,6 +932,14 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
count={failedFileIds.length}
labelSingular={t('smartFolders.workbench.fileFailedToProcess', 'file failed to process')}
labelPlural={t('smartFolders.workbench.filesFailedToProcess', 'files failed to process')}
toolbar={
<FilterSortBar
search={failedSearch}
onSearchChange={setFailedSearch}
sort={failedSort}
onSortChange={setFailedSort}
/>
}
footer={
<Group justify="flex-end">
<Button size="sm" color="red"
@@ -772,7 +971,11 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps
}
>
<Stack gap="0.5rem">
{failedFileIds.map((fileId) => {
{filteredFailedIds.length === 0 && failedFileIds.length > 0 ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
{t('smartFolders.workbench.noMatch', 'No matching files')}
</Text>
) : filteredFailedIds.map((fileId) => {
const meta = folderRecord?.files[fileId];
const inputFile = inputFiles.find(f => f.fileId === fileId);
const filename = meta?.name ?? inputFile?.name ?? fileId;
@@ -229,7 +229,7 @@ function FileRow({
<ActionIcon
data-no-select
size="sm"
variant="light"
variant="subtle"
color="blue"
style={{
flexShrink: 0,
@@ -255,7 +255,7 @@ function FileRow({
<ActionIcon
data-no-select
size="sm"
variant="light"
variant="subtle"
color="blue"
style={{
flexShrink: 0,
@@ -205,6 +205,19 @@ export function useFolderAutomation(toolRegistry: Partial<ToolRegistry>) {
notifySW({ type: 'SCHEDULE_RETRY' });
}
} finally {
// Safety net: run BEFORE releasing the lock so no concurrent runPipeline call
// can start, set status back to 'processing', and cause a false-positive reset.
try {
const record = await folderStorage.getFolderData(folder.id);
if (record?.files[inputFileId]?.status === 'processing') {
await folderStorage.updateFileMetadata(folder.id, inputFileId, {
status: 'error',
errorMessage: 'Processing failed unexpectedly',
});
}
} catch {
// Best-effort — ignore if IDB itself is failing
}
processingRef.current.delete(inputFileId);
}
},