diff --git a/src/main/java/io/hyperfoil/tools/h5m/rest/FolderResource.java b/src/main/java/io/hyperfoil/tools/h5m/rest/FolderResource.java index e71e31a4..6ba7c1d3 100644 --- a/src/main/java/io/hyperfoil/tools/h5m/rest/FolderResource.java +++ b/src/main/java/io/hyperfoil/tools/h5m/rest/FolderResource.java @@ -1,6 +1,7 @@ package io.hyperfoil.tools.h5m.rest; import io.hyperfoil.tools.jjq.value.JqValue; +import io.hyperfoil.tools.jjq.value.JqValues; import io.hyperfoil.tools.h5m.api.Folder; import io.hyperfoil.tools.h5m.api.FolderSummary; import io.hyperfoil.tools.h5m.api.RecalculationStatus; @@ -8,24 +9,39 @@ import io.hyperfoil.tools.h5m.api.svc.ValueServiceInterface; import io.hyperfoil.tools.h5m.svc.RecalculationService; import io.hyperfoil.tools.h5m.svc.RecalculationTracker; +import io.quarkus.runtime.configuration.MemorySize; import io.quarkus.security.Authenticated; import jakarta.annotation.security.PermitAll; import jakarta.inject.Inject; import jakarta.validation.constraints.NotNull; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; +import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.openapi.annotations.Operation; -import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; import org.eclipse.microprofile.openapi.annotations.tags.Tag; +import org.jboss.resteasy.reactive.RestForm; +import org.jboss.resteasy.reactive.multipart.FileUpload; +import java.io.IOException; +import java.net.URL; +import java.net.URLConnection; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Stream; + +import static jakarta.ws.rs.core.MediaType.MULTIPART_FORM_DATA; @Path("/api/folder") @Produces(MediaType.APPLICATION_JSON) @Tag(name = "Folder", description = "Manage folders for uploaded data") public class FolderResource { + @ConfigProperty(name = "quarkus.http.limits.max-body-size") + MemorySize maxBodySize; + @Inject FolderServiceInterface folderService; @@ -83,15 +99,60 @@ public void deleteFolder(@PathParam("id") long id) { @POST @Path("{id}/upload") + @Consumes(MULTIPART_FORM_DATA) @Authenticated @Operation(description = "Upload JSON data to a folder. Returns immediately with an uploadId.") + @APIResponse(responseCode = "200", description = "Upload successful, returns uploadId") + @APIResponse(responseCode = "400", description = "Request received but content is not valid JSON or URL scheme is not http/https") public long upload( @PathParam("id") long id, - JqValue data) { - if (data == null) { - throw new BadRequestException("Missing request body"); + @RestForm("raw") String raw, + @RestForm("url") URL url, + @RestForm("file") FileUpload file) { + + if (Stream.of(url, raw == null || raw.isBlank() ? null : raw, file).filter(Objects::nonNull).count() != 1) { + throw new BadRequestException("Provide exactly one of 'file', 'raw', or 'url'"); + } + + byte[] bytes; + + try { + if (url != null) { + if (!Set.of("http", "https").contains(url.getProtocol())) { + throw new BadRequestException("Only http/https URLs are allowed"); + } + URLConnection connection = url.openConnection(); + connection.setConnectTimeout(5000); + connection.setReadTimeout(30000); + int readLimit = (int) Math.min(maxBodySize.asLongValue() + 1, Integer.MAX_VALUE); + try (var inputStream = connection.getInputStream()) { + bytes = inputStream.readNBytes(readLimit); + } + if (bytes.length > maxBodySize.asLongValue()) { + throw new BadRequestException("Content at '" + url + "' exceeds the maximum upload size"); + } + } else if (file != null) { + if (file.size() > maxBodySize.asLongValue()) { + throw new BadRequestException("Uploaded file exceeds the maximum upload size"); + } + bytes = java.nio.file.Files.readAllBytes(file.uploadedFile()); + } else { + if (raw.length() > maxBodySize.asLongValue()) { + throw new BadRequestException("Raw JSON exceeds the maximum upload size"); + } + bytes = raw.getBytes(java.nio.charset.StandardCharsets.UTF_8); + } + } catch (BadRequestException e) { + throw e; + } catch (IOException e) { + throw new BadRequestException("Failed to read upload data: " + e.getMessage()); + } + + try { + return folderService.upload(id, JqValues.parse(bytes)).uploadId; + } catch (Exception e) { + throw new BadRequestException("Invalid JSON: " + e.getMessage()); } - return folderService.upload(id, data).uploadId; } @GET diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index c7ac187e..5ad08c46 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -56,6 +56,10 @@ quarkus.banner.enabled=false %dev.quarkus.http.cors.methods=GET,POST,PUT,DELETE,OPTIONS %dev.quarkus.http.cors.headers=content-type,authorization +quarkus.http.limits.max-body-size=200M + +quarkus.package.jar.type=uber-jar +quarkus.package.jar.add-runner-suffix=false quarkus.package.jar.type=fast-jar # open api diff --git a/src/main/webui/src/app/components/DataTab.tsx b/src/main/webui/src/app/components/DataTab.tsx index d446d800..64e361ef 100644 --- a/src/main/webui/src/app/components/DataTab.tsx +++ b/src/main/webui/src/app/components/DataTab.tsx @@ -1,5 +1,6 @@ import type { View, ViewComponent } from '@client/types.gen.ts'; +import { UploadDataModal } from '@app/components/UploadDataModal'; import { ViewConfigModal } from '@app/components/ViewConfigModal'; import { Button, @@ -7,7 +8,13 @@ import { Dropdown, ErrorBoundary, InlineLoading, + Pagination, SkeletonText, + StructuredListBody, + StructuredListCell, + StructuredListHead, + StructuredListRow, + StructuredListWrapper, Table, TableBody, TableCell, @@ -91,6 +98,10 @@ export const DataTab = ({ folderId, groupId }: { folderId: number; groupId: numb const [configModalOpen, setConfigModalOpen] = useState(false); const [editingView, setEditingView] = useState(null); const [modalKey, setModalKey] = useState(0); + const [uploadModalOpen, setUploadModalOpen] = useState(false); + const [recentUploads, setRecentUploads] = useState>([]); + const [uploadsPage, setUploadsPage] = useState(1); + const [uploadsPageSize, setUploadsPageSize] = useState(10); const selectedView = useMemo((): View | null => { if (!views || views.length === 0) return null; @@ -107,7 +118,78 @@ export const DataTab = ({ folderId, groupId }: { folderId: number; groupId: numb }, [views, selectedViewId]); if (viewsLoading) return ; - if (!views || views.length === 0) return

No views configured

; + + const uploadButton = ( + + ); + + const uploadModal = ( + }> + + setUploadModalOpen(false)} + folderId={folderId} + onUploadSuccess={(fileName, uploadId) => { + setRecentUploads((prev) => [{ fileName, uploadId, uploadedAt: new Date() }, ...prev]); + setUploadsPage(1); + }} + /> + + + ); + + const uploadsStart = (uploadsPage - 1) * uploadsPageSize; + const pagedUploads = recentUploads.slice(uploadsStart, uploadsStart + uploadsPageSize); + + const recentUploadsList = recentUploads.length > 0 ? ( +
+ + + + Upload file + Upload ID + Uploaded at + + + + {pagedUploads.map((item, i) => ( + + {item.fileName} + + + #{item.uploadId} + + + {item.uploadedAt.toLocaleString()} + + ))} + + + { + setUploadsPage(page); + setUploadsPageSize(pageSize); + }} + size="sm" + /> +
+ ) : null; + + if (!views || views.length === 0) return ( + <> +
{uploadButton}
+ {recentUploadsList} +

No views configured

+ {uploadModal} + + ); const dropdownItems = views.map((v: View) => ({ id: String(v.id), @@ -117,6 +199,7 @@ export const DataTab = ({ folderId, groupId }: { folderId: number; groupId: numb return (
+ {uploadButton}
+ {recentUploadsList} {selectedView && (!selectedView.components || selectedView.components.length === 0) && (

This view has no columns configured. Click Configure to select which nodes to display. @@ -174,6 +258,7 @@ export const DataTab = ({ folderId, groupId }: { folderId: number; groupId: numb /> + {uploadModal}

); }; diff --git a/src/main/webui/src/app/components/UploadDataModal.css b/src/main/webui/src/app/components/UploadDataModal.css new file mode 100644 index 00000000..7a3c47a0 --- /dev/null +++ b/src/main/webui/src/app/components/UploadDataModal.css @@ -0,0 +1,21 @@ +.upload-drop-zone { + display: flex; + justify-content: center; +} + +.upload-file-list { + border-top: 1px solid var(--cds-border-subtle-01); + padding-top: var(--cds-spacing-04); + margin-top: var(--cds-spacing-05); +} + +.upload-file-list__count { + margin: 0 0 var(--cds-spacing-03); + font-size: 0.75rem; + color: var(--cds-text-secondary); +} + +.upload-paste-json { + font-family: var(--cds-code-01-font-family, monospace); + font-size: 0.8125rem; +} diff --git a/src/main/webui/src/app/components/UploadDataModal.tsx b/src/main/webui/src/app/components/UploadDataModal.tsx new file mode 100644 index 00000000..346b46f3 --- /dev/null +++ b/src/main/webui/src/app/components/UploadDataModal.tsx @@ -0,0 +1,270 @@ +import { + Button, + ComposedModal, + FileUploaderDropContainer, + FileUploaderItem, + InlineLoading, + ModalBody, + ModalFooter, + ModalHeader, + Tab, + TabList, + TabPanel, + TabPanels, + Tabs, + TextArea, + TextInput, +} from '@carbon/react'; +import type { UploadData } from '@client/types.gen.ts'; +import { uploadMutation } from '@client/@tanstack/react-query.gen.ts'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useRef, useState } from 'react'; +import { useNotification } from '@app/context/useNotification.tsx'; +import './UploadDataModal.css'; + +interface UploadDataModalProps { + open: boolean; + onClose: () => void; + folderId: number; + onUploadSuccess: (fileName: string, uploadId: number) => void; +} + +const TAB_FILE = 0; +const TAB_FOLDER = 1; +const TAB_PASTE = 2; +const TAB_URL = 3; + +type FileStatus = 'pending' | 'success' | 'error'; + +export const UploadDataModal = ({ open, onClose, folderId, onUploadSuccess }: UploadDataModalProps) => { + const queryClient = useQueryClient(); + const [activeTab, setActiveTab] = useState(TAB_FILE); + const [selectedFiles, setSelectedFiles] = useState([]); + const [currentIndex, setCurrentIndex] = useState(null); + const [currentStatus, setCurrentStatus] = useState(null); + const folderInputRef = useRef(null); + const [pasteText, setPasteText] = useState(''); + const [pasteError, setPasteError] = useState(null); + const [urlInput, setUrlInput] = useState(''); + + const notifications = useNotification(); + const upload = useMutation(uploadMutation()); + + const isUploading = currentStatus === 'pending'; + + function addFiles(incoming: File[]) { + const jsonFiles = incoming.filter((f) => f.name.endsWith('.json')); + if (jsonFiles.length === 0) { + notifications.warning('No .json files found.'); + return; + } + setSelectedFiles((prev) => { + const existingNames = new Set(prev.map((f) => f.name)); + const newFiles = jsonFiles.filter((f) => !existingNames.has(f.name)); + return [...prev, ...newFiles]; + }); + } + + function handleClose() { + setActiveTab(TAB_FILE); + setSelectedFiles([]); + setCurrentIndex(null); + setCurrentStatus(null); + setPasteText(''); + setPasteError(null); + setUrlInput(''); + upload.reset(); + onClose(); + } + + async function handleUpload() { + const items: Array<{ label: string; body: UploadData['body'] }> = []; + + if (activeTab === TAB_FILE || activeTab === TAB_FOLDER) { + for (const file of selectedFiles) items.push({ label: file.name, body: { file } }); + } else if (activeTab === TAB_PASTE && pasteError === null) { + const label = pasteText.trim().substring(0, 50) + (pasteText.trim().length >= 50 ? '...' : ''); + items.push({ label, body: { raw: pasteText.trim() } }); + } else if (activeTab === TAB_URL) { + items.push({ label: urlInput.trim(), body: { url: urlInput.trim() } }); + } + + if (items.length === 0) return; + + let successCount = 0; + + for (let i = 0; i < items.length; i++) { + const item = items[i]!; + setCurrentIndex(i); + setCurrentStatus('pending'); + + try { + const uploadId = await upload.mutateAsync({ path: { id: folderId }, body: item.body }); + void queryClient.invalidateQueries(); + onUploadSuccess(item.label, uploadId); + setCurrentStatus('success'); + successCount++; + } catch (e: unknown) { + setCurrentStatus('error'); + notifications.handleError(item.label, e); + } + } + + if (successCount > 0) { + notifications.success( + items.length > 1 + ? `${successCount} file${successCount > 1 ? 's' : ''} uploaded successfully` + : `'${items[0]!.label}' uploaded successfully` + ); + setTimeout(handleClose, 500); + } + } + + const canUpload = + !isUploading && + !upload.isPending && + (activeTab === TAB_FILE || activeTab === TAB_FOLDER + ? selectedFiles.length > 0 && currentIndex === null + : activeTab === TAB_PASTE + ? pasteText.trim().length > 0 && pasteError === null + : urlInput.trim().length > 0); + + return ( + + + + { + setActiveTab(selectedIndex); + }}> + + File Upload + Folder Upload + Paste JSON + URL + + + + +
+ addFiles(addedFiles)} + /> +
+ {selectedFiles.length > 0 && ( +
+

+ {selectedFiles.length} file{selectedFiles.length > 1 ? 's' : ''} selected +

+ {selectedFiles.map((file, idx) => ( + setSelectedFiles((prev) => prev.filter((_, i) => i !== idx))} + /> + ))} +
+ )} +
+ + +
+ + { addFiles(Array.from(e.target.files ?? [])); e.target.value = ''; }} + /> +
+ {selectedFiles.length > 0 && ( +
+

+ {selectedFiles.length} file{selectedFiles.length > 1 ? 's' : ''} selected +

+ {selectedFiles.map((file, idx) => ( + setSelectedFiles((prev) => prev.filter((_, i) => i !== idx))} + /> + ))} +
+ )} +
+ + +