From b2960513186c1950539aadd468ef6c2fa2691767 Mon Sep 17 00:00:00 2001 From: klpoland Date: Wed, 6 May 2026 11:54:24 -0400 Subject: [PATCH 1/7] first pass code migration --- .../js/captures/CapturesTableManager.js | 217 ++ .../captures/FileListCapturesTableManager.js | 397 ++++ .../js/captures/FileListPageController.js | 620 ++++++ .../_FileListCapturesTableManager.body.js | 387 ++++ .../captures/_FileListPageController.body.js | 610 ++++++ .../js/captures/_frag_captures_table.txt | 204 ++ .../static/js/constants/FileListConfig.js | 24 + .../static/js/core/BrowserSupport.js | 77 + .../static/js/core/ComponentUtils.js | 33 + .../static/js/core/DropdownUtils.js | 52 + .../sds_gateway/static/js/core/FormatUtils.js | 109 + .../static/js/core/GatewayChrome.js | 59 + .../static/js/core/ModalManager.js | 969 +++++++++ .../static/js/core/NotificationUtils.js | 87 + .../static/js/core/PaginationManager.js | 78 + .../static/js/core/TableManager.js | 167 ++ .../static/js/core/_frag_escape.txt | 6 + .../static/js/core/_frag_format.txt | 95 + .../static/js/core/_frag_modal.txt | 956 +++++++++ .../static/js/core/_frag_pagination.txt | 65 + .../static/js/core/_frag_table.txt | 154 ++ .../static/js/deprecated/components.js | 1855 +++++++++++++++++ .../static/js/deprecated/file-list.js | 1117 ++++++++++ .../static/js/deprecated/file-manager.js | 1625 +++++++++++++++ .../file_list_upload_capture_modal.js | 1075 ++++++++++ .../static/js/deprecated/files-ui.js | 857 ++++++++ .../static/js/deprecated/files-upload.js | 763 +++++++ gateway/sds_gateway/static/js/file-list.js | 1109 +--------- gateway/sds_gateway/static/js/file-manager.js | 169 +- .../js/search/DebouncedSearchManager.js | 110 + .../static/js/search/FilterManager.js | 177 ++ .../static/js/search/_frag_filter.txt | 164 ++ .../static/js/search/_frag_search.txt | 96 + .../static/js/upload/Blake3FileHandler.js | 182 ++ .../static/js/upload/CaptureTypeSelector.js | 192 ++ .../static/js/upload/FileDropManager.js | 173 ++ .../js/upload/FileListUploadCaptureModal.js | 1076 ++++++++++ .../static/js/upload/FileUploadHandler.js | 230 ++ .../static/js/upload/FilesPageInitializer.js | 242 +++ .../static/js/upload/FilesUploadModal.js | 563 +++++ .../js/upload/UploadCaptureModalController.js | 39 + .../static/js/upload/_blake3_class.txt | 169 ++ .../sds_gateway/static/js/upload/_cap_sel.txt | 183 ++ .../static/js/upload/_file_upload_handler.txt | 221 ++ .../static/js/upload/_files_modal_class.txt | 550 +++++ .../static/js/upload/_files_page_init.txt | 233 +++ .../static/js/upload/_files_upload_dom.txt | 29 + .../templates/users/file_list.html | 19 +- .../sds_gateway/templates/users/files.html | 20 +- 49 files changed, 17333 insertions(+), 1271 deletions(-) create mode 100644 gateway/sds_gateway/static/js/captures/CapturesTableManager.js create mode 100644 gateway/sds_gateway/static/js/captures/FileListCapturesTableManager.js create mode 100644 gateway/sds_gateway/static/js/captures/FileListPageController.js create mode 100644 gateway/sds_gateway/static/js/captures/_FileListCapturesTableManager.body.js create mode 100644 gateway/sds_gateway/static/js/captures/_FileListPageController.body.js create mode 100644 gateway/sds_gateway/static/js/captures/_frag_captures_table.txt create mode 100644 gateway/sds_gateway/static/js/constants/FileListConfig.js create mode 100644 gateway/sds_gateway/static/js/core/BrowserSupport.js create mode 100644 gateway/sds_gateway/static/js/core/ComponentUtils.js create mode 100644 gateway/sds_gateway/static/js/core/DropdownUtils.js create mode 100644 gateway/sds_gateway/static/js/core/FormatUtils.js create mode 100644 gateway/sds_gateway/static/js/core/GatewayChrome.js create mode 100644 gateway/sds_gateway/static/js/core/ModalManager.js create mode 100644 gateway/sds_gateway/static/js/core/NotificationUtils.js create mode 100644 gateway/sds_gateway/static/js/core/PaginationManager.js create mode 100644 gateway/sds_gateway/static/js/core/TableManager.js create mode 100644 gateway/sds_gateway/static/js/core/_frag_escape.txt create mode 100644 gateway/sds_gateway/static/js/core/_frag_format.txt create mode 100644 gateway/sds_gateway/static/js/core/_frag_modal.txt create mode 100644 gateway/sds_gateway/static/js/core/_frag_pagination.txt create mode 100644 gateway/sds_gateway/static/js/core/_frag_table.txt create mode 100644 gateway/sds_gateway/static/js/deprecated/components.js create mode 100644 gateway/sds_gateway/static/js/deprecated/file-list.js create mode 100644 gateway/sds_gateway/static/js/deprecated/file-manager.js create mode 100644 gateway/sds_gateway/static/js/deprecated/file_list_upload_capture_modal.js create mode 100644 gateway/sds_gateway/static/js/deprecated/files-ui.js create mode 100644 gateway/sds_gateway/static/js/deprecated/files-upload.js create mode 100644 gateway/sds_gateway/static/js/search/DebouncedSearchManager.js create mode 100644 gateway/sds_gateway/static/js/search/FilterManager.js create mode 100644 gateway/sds_gateway/static/js/search/_frag_filter.txt create mode 100644 gateway/sds_gateway/static/js/search/_frag_search.txt create mode 100644 gateway/sds_gateway/static/js/upload/Blake3FileHandler.js create mode 100644 gateway/sds_gateway/static/js/upload/CaptureTypeSelector.js create mode 100644 gateway/sds_gateway/static/js/upload/FileDropManager.js create mode 100644 gateway/sds_gateway/static/js/upload/FileListUploadCaptureModal.js create mode 100644 gateway/sds_gateway/static/js/upload/FileUploadHandler.js create mode 100644 gateway/sds_gateway/static/js/upload/FilesPageInitializer.js create mode 100644 gateway/sds_gateway/static/js/upload/FilesUploadModal.js create mode 100644 gateway/sds_gateway/static/js/upload/UploadCaptureModalController.js create mode 100644 gateway/sds_gateway/static/js/upload/_blake3_class.txt create mode 100644 gateway/sds_gateway/static/js/upload/_cap_sel.txt create mode 100644 gateway/sds_gateway/static/js/upload/_file_upload_handler.txt create mode 100644 gateway/sds_gateway/static/js/upload/_files_modal_class.txt create mode 100644 gateway/sds_gateway/static/js/upload/_files_page_init.txt create mode 100644 gateway/sds_gateway/static/js/upload/_files_upload_dom.txt diff --git a/gateway/sds_gateway/static/js/captures/CapturesTableManager.js b/gateway/sds_gateway/static/js/captures/CapturesTableManager.js new file mode 100644 index 000000000..ce2a6f4be --- /dev/null +++ b/gateway/sds_gateway/static/js/captures/CapturesTableManager.js @@ -0,0 +1,217 @@ +/** + * Capture table: delegation, modal integration. + * Migrated from deprecated/components.js. + */ + +class CapturesTableManager extends TableManager { + constructor(config) { + super(config); + this.modalHandler = config.modalHandler; + this.tableContainerSelector = config.tableContainerSelector; + this.eventDelegationHandler = null; + this.initializeEventDelegation(); + } + + /** + * Initialize event delegation for better performance and memory management + */ + initializeEventDelegation() { + // Remove existing handler if it exists + if (this.eventDelegationHandler) { + document.removeEventListener("click", this.eventDelegationHandler); + } + + // Create single persistent event handler using delegation + this.eventDelegationHandler = (e) => { + // Ignore Bootstrap dropdown toggles + if ( + e.target.matches('[data-bs-toggle="dropdown"]') || + e.target.closest('[data-bs-toggle="dropdown"]') + ) { + return; + } + + // Handle capture details button clicks from actions dropdown + if ( + e.target.matches(".capture-details-btn") || + e.target.closest(".capture-details-btn") + ) { + e.preventDefault(); + const button = e.target.matches(".capture-details-btn") + ? e.target + : e.target.closest(".capture-details-btn"); + this.openCaptureModal(button); + return; + } + + // Handle capture link clicks + if ( + e.target.matches(".capture-link") || + e.target.closest(".capture-link") + ) { + e.preventDefault(); + const link = e.target.matches(".capture-link") + ? e.target + : e.target.closest(".capture-link"); + this.openCaptureModal(link); + return; + } + + // Handle view button clicks + if ( + e.target.matches(".view-capture-btn") || + e.target.closest(".view-capture-btn") + ) { + e.preventDefault(); + const button = e.target.matches(".view-capture-btn") + ? e.target + : e.target.closest(".view-capture-btn"); + this.openCaptureModal(button); + return; + } + }; + + // Add the persistent event listener + document.addEventListener("click", this.eventDelegationHandler); + } + + renderRow(capture, index) { + // Sanitize all data before rendering + const safeData = { + uuid: ComponentUtils.escapeHtml(capture.uuid || ""), + channel: ComponentUtils.escapeHtml(capture.channel || ""), + scanGroup: ComponentUtils.escapeHtml(capture.scan_group || ""), + captureType: ComponentUtils.escapeHtml(capture.capture_type || ""), + captureTypeDisplay: ComponentUtils.escapeHtml( + capture.capture_type_display || "", + ), + topLevelDir: ComponentUtils.escapeHtml(capture.top_level_dir || ""), + indexName: ComponentUtils.escapeHtml(capture.index_name || ""), + owner: ComponentUtils.escapeHtml(capture.owner || ""), + origin: ComponentUtils.escapeHtml(capture.origin || ""), + dataset: ComponentUtils.escapeHtml(capture.dataset || ""), + createdAt: ComponentUtils.escapeHtml(capture.created_at || ""), + updatedAt: ComponentUtils.escapeHtml(capture.updated_at || ""), + isPublic: ComponentUtils.escapeHtml(capture.is_public || ""), + isDeleted: ComponentUtils.escapeHtml(capture.is_deleted || ""), + centerFrequencyGhz: ComponentUtils.escapeHtml( + capture.center_frequency_ghz || "", + ), + }; + + // Handle composite vs single capture display + let channelDisplay = safeData.channel; + let typeDisplay = safeData.captureTypeDisplay || safeData.captureType; + + if (capture.is_multi_channel) { + // For composite captures, show all channels + if (capture.channels && Array.isArray(capture.channels)) { + channelDisplay = capture.channels + .map((ch) => ComponentUtils.escapeHtml(ch.channel || ch)) + .join(", "); + } + // Use capture_type_display if available, otherwise fall back to captureType + typeDisplay = capture.capture_type_display || safeData.captureType; + } + + return ` + + + + ${safeData.uuid} + + + ${channelDisplay} + + ${ComponentUtils.formatDateForModal(capture.capture?.created_at || capture.created_at)} + + ${typeDisplay} + ${capture.files_count || "0"} + ${capture.center_frequency_ghz ? `${capture.center_frequency_ghz.toFixed(3)} GHz` : "-"} + ${capture.sample_rate_mhz ? `${capture.sample_rate_mhz.toFixed(1)} MHz` : "-"} + + `; + } + + /** + * Attach row click handlers - now uses event delegation + */ + attachRowClickHandlers() { + // Event delegation is handled in initializeEventDelegation() + // This method is kept for compatibility but doesn't need to do anything + } + + /** + * Open capture modal with XSS protection + */ + openCaptureModal(linkElement) { + if (this.modalHandler) { + this.modalHandler.openCaptureModal(linkElement); + } + } + + /** + * Get CSRF token for API requests + */ + getCSRFToken() { + const token = document.querySelector("[name=csrfmiddlewaretoken]"); + return token ? token.value : ""; + } + + /** + * Open a custom modal + */ + openCustomModal(modalId) { + const modal = document.getElementById(modalId); + if (modal) { + modal.style.display = "block"; + document.body.style.overflow = "hidden"; + } + } + + /** + * Close a custom modal + */ + closeCustomModal(modalId) { + const modal = document.getElementById(modalId); + if (modal) { + modal.style.display = "none"; + document.body.style.overflow = "auto"; + } + } + + /** + * Cleanup method for proper resource management + */ + destroy() { + if (this.eventDelegationHandler) { + document.removeEventListener("click", this.eventDelegationHandler); + this.eventDelegationHandler = null; + } + } +} + +if (typeof window !== "undefined") { + window.CapturesTableManager = CapturesTableManager; +} +if (typeof module !== "undefined" && module.exports) { + module.exports = { CapturesTableManager }; +} + diff --git a/gateway/sds_gateway/static/js/captures/FileListCapturesTableManager.js b/gateway/sds_gateway/static/js/captures/FileListCapturesTableManager.js new file mode 100644 index 000000000..fe990e327 --- /dev/null +++ b/gateway/sds_gateway/static/js/captures/FileListCapturesTableManager.js @@ -0,0 +1,397 @@ +/** + * File list capture table with selection and row rendering. + * Migrated from deprecated/file-list.js. + */ + +class FileListCapturesTableManager extends CapturesTableManager { + /** + * UUIDs selected for quick-add / bulk actions. Class field initializes as soon as + * the instance exists (after super()), so renderRow never runs before this exists. + */ + selectedCaptureIds = new Set(); + + constructor(options) { + super(options); + this.resultsCountElement = document.getElementById(options.resultsCountId); + this.searchButton = document.getElementById("search-btn"); + this.searchButtonContent = document.getElementById("search-btn-content"); + this.searchButtonLoading = document.getElementById("search-btn-loading"); + this.onSelectionChange = options.onSelectionChange ?? null; + this.setupSelectionCheckboxHandler(); + this.setupRowClickSelection(); + } + + _notifySelectionChange() { + if (typeof this.onSelectionChange === "function") { + this.onSelectionChange(); + } + } + + /** + * Override showLoading to toggle button contents instead of showing separate indicator + */ + showLoading() { + if (this.searchButton) { + this.searchButton.disabled = true; + if (this.searchButtonContent) + this.searchButtonContent.classList.add("d-none"); + if (this.searchButtonLoading) + this.searchButtonLoading.classList.remove("d-none"); + } + } + + /** + * Override hideLoading to restore button contents + */ + hideLoading() { + if (this.searchButton) { + this.searchButton.disabled = false; + if (this.searchButtonContent) + this.searchButtonContent.classList.remove("d-none"); + if (this.searchButtonLoading) + this.searchButtonLoading.classList.add("d-none"); + } + } + + /** + * Delegated handler for selection checkboxes: keep selectedCaptureIds in sync + */ + setupSelectionCheckboxHandler() { + this._checkboxChangeHandler = (e) => { + if (!e.target.matches(".capture-select-checkbox")) return; + const uuid = e.target.getAttribute("data-capture-uuid"); + if (!uuid) return; + if (e.target.checked) { + this.selectedCaptureIds.add(uuid); + } else { + this.selectedCaptureIds.delete(uuid); + } + this._notifySelectionChange(); + }; + document.addEventListener("change", this._checkboxChangeHandler); + } + + /** + * When selection mode is active, clicking a row toggles its selection (instead of opening the modal). + * Uses capture phase so we run before the row's click handler. + */ + setupRowClickSelection() { + const table = document.getElementById(this.tableId); + if (!table) return; + this._rowClickTable = table; + + this._rowClickHandler = (e) => { + if (!table.classList.contains("selection-mode-active")) return; + if ( + e.target.closest( + "button, a, [data-bs-toggle='dropdown'], .capture-select-checkbox", + ) + ) + return; + const row = e.target.closest("tr"); + if (!row) return; + const checkbox = row.querySelector(".capture-select-checkbox"); + if (!checkbox) return; + const uuid = checkbox.getAttribute("data-capture-uuid"); + if (!uuid) return; + + if (this.selectedCaptureIds.has(uuid)) { + this.selectedCaptureIds.delete(uuid); + checkbox.checked = false; + } else { + this.selectedCaptureIds.add(uuid); + checkbox.checked = true; + } + this._notifySelectionChange(); + e.preventDefault(); + e.stopPropagation(); + }; + + table.addEventListener("click", this._rowClickHandler, true); + } + + destroy() { + if (this._checkboxChangeHandler) { + document.removeEventListener("change", this._checkboxChangeHandler); + this._checkboxChangeHandler = null; + } + if (this._rowClickHandler && this._rowClickTable) { + this._rowClickTable.removeEventListener( + "click", + this._rowClickHandler, + true, + ); + this._rowClickHandler = null; + this._rowClickTable = null; + } + super.destroy(); + } + + /** + * Initialize dropdowns with body container for proper positioning + */ + initializeDropdowns() { + if (window.DropdownUtils) { + window.DropdownUtils.initIconDropdowns(document); + } + } + + /** + * Update table with new data + */ + updateTable(captures, hasResults) { + this.selectedCaptureIds ??= new Set(); + const tbody = this.tbody ?? this.table?.querySelector("tbody"); + if (!tbody) return; + + // Update results count + this.updateResultsCount(captures, hasResults); + + if (!hasResults || captures.length === 0) { + tbody.innerHTML = ` + + + No captures found matching your search criteria. + + + `; + this._notifySelectionChange(); + return; + } + + // Build table HTML efficiently + const tableHTML = captures + .map((capture, index) => this.renderRow(capture, index)) + .join(""); + tbody.innerHTML = tableHTML; + + // Initialize dropdowns after table is updated + this.initializeDropdowns(); + this._notifySelectionChange(); + } + + /** + * Update results count display + */ + updateResultsCount(captures, hasResults) { + if (this.resultsCountElement) { + const count = hasResults && captures ? captures.length : 0; + const pluralSuffix = count === 1 ? "" : "s"; + this.resultsCountElement.textContent = `${count} capture${pluralSuffix} found`; + } + } + + /** + * Render individual table row with XSS protection + * Overrides the base class method to include file-specific columns + */ + renderRow(capture) { + this.selectedCaptureIds ??= new Set(); + // Sanitize all data before rendering + const safeData = { + uuid: ComponentUtils.escapeHtml(capture.uuid || ""), + name: ComponentUtils.escapeHtml(capture.name || ""), + channel: ComponentUtils.escapeHtml(capture.channel || ""), + scanGroup: ComponentUtils.escapeHtml(capture.scan_group || ""), + captureType: ComponentUtils.escapeHtml(capture.capture_type || ""), + captureTypeDisplay: ComponentUtils.escapeHtml( + capture.capture_type_display || "", + ), + topLevelDir: ComponentUtils.escapeHtml(capture.top_level_dir || ""), + indexName: ComponentUtils.escapeHtml(capture.index_name || ""), + owner: ComponentUtils.escapeHtml(capture.owner || ""), + origin: ComponentUtils.escapeHtml(capture.origin || ""), + dataset: ComponentUtils.escapeHtml(capture.dataset || ""), + createdAt: ComponentUtils.escapeHtml(capture.created_at || ""), + updatedAt: ComponentUtils.escapeHtml(capture.updated_at || ""), + isPublic: ComponentUtils.escapeHtml(capture.is_public || ""), + isDeleted: ComponentUtils.escapeHtml(capture.is_deleted || ""), + centerFrequencyGhz: ComponentUtils.escapeHtml( + capture.center_frequency_ghz || "", + ), + lengthOfCaptureMs: capture.length_of_capture_ms ?? 0, + fileCadenceMs: capture.file_cadence_ms ?? 1000, + perDataFileSize: capture.per_data_file_size ?? 0, + totalSize: capture.total_file_size ?? 0, + dataFilesCount: capture.data_files_count ?? 0, + dataFilesTotalSize: capture.data_files_total_size ?? 0, + totalFilesCount: capture.files.length ?? 0, + captureStartEpochSec: capture.capture_start_epoch_sec ?? 0, + }; + + let typeDisplay = safeData.captureTypeDisplay || safeData.captureType; + + if (capture.is_multi_channel) { + typeDisplay = capture.capture_type_display || safeData.captureType; + } + + // Display name with fallback to "Unnamed Capture" + const nameDisplay = safeData.name || "Unnamed Capture"; + + // Format created date to match template format + let createdDate = "-"; + if (capture.created_at) { + const date = new Date(capture.created_at); + if (!Number.isNaN(date.getTime())) { + const dateStr = date.toISOString().split("T")[0]; // YYYY-MM-DD + const timeStr = date.toLocaleTimeString("en-US", { + hour12: false, + timeZoneName: "short", + }); // HH:mm:ss TZ + createdDate = ` +
+ ${dateStr} + ${timeStr} +
+ `; + } + } + + // Check if shared (for shared icon) + const isShared = capture.is_shared_with_me || false; + const sharedIcon = isShared + ? ` + + ` + : ""; + + // Check if owner (for conditional actions and selection — only owned captures are selectable) + const isOwner = capture.is_owner === true; + + const checked = this.selectedCaptureIds.has(capture.uuid) ? " checked" : ""; + const selectCell = isOwner + ? `` + : ''; + return ` + + ${selectCell} + + + ${nameDisplay} + + ${sharedIcon} + + ${safeData.topLevelDir || "-"} + ${typeDisplay} + ${createdDate} + + + + + `; + } +} + +if (typeof module !== "undefined" && module.exports) { + module.exports = { FileListCapturesTableManager }; +} + diff --git a/gateway/sds_gateway/static/js/captures/FileListPageController.js b/gateway/sds_gateway/static/js/captures/FileListPageController.js new file mode 100644 index 000000000..59da83f79 --- /dev/null +++ b/gateway/sds_gateway/static/js/captures/FileListPageController.js @@ -0,0 +1,620 @@ +/** + * Orchestrates capture list page (file list). + * Migrated from deprecated/file-list.js (FileListController). + */ + +class FileListPageController { + constructor() { + this.userInteractedWithFrequency = false; + this.urlParams = new URLSearchParams(window.location.search); + this.currentSortBy = + this.urlParams.get("sort_by") || window.FileListConfig.DEFAULT_SORT_BY; + this.currentSortOrder = + this.urlParams.get("sort_order") || window.FileListConfig.DEFAULT_SORT_ORDER; + + // Cache DOM elements + this.cacheElements(); + + // Initialize components + this.initializeComponents(); + + // Initialize functionality + this.initializeEventHandlers(); + this.initializeFromURL(); + + // Initial setup + this.updateSortIcons(); + this.tableManager.attachRowClickHandlers(); + + // Initialize dropdowns for any existing static dropdowns + this.initializeDropdowns(); + } + + /** + * Cache frequently accessed DOM elements + */ + cacheElements() { + this.elements = { + searchInput: document.getElementById(window.FileListConfig.ELEMENT_IDS.SEARCH_INPUT), + startDate: document.getElementById(window.FileListConfig.ELEMENT_IDS.START_DATE), + endDate: document.getElementById(window.FileListConfig.ELEMENT_IDS.END_DATE), + centerFreqMin: document.getElementById( + window.FileListConfig.ELEMENT_IDS.CENTER_FREQ_MIN, + ), + centerFreqMax: document.getElementById( + window.FileListConfig.ELEMENT_IDS.CENTER_FREQ_MAX, + ), + applyFilters: document.getElementById(window.FileListConfig.ELEMENT_IDS.APPLY_FILTERS), + clearFilters: document.getElementById(window.FileListConfig.ELEMENT_IDS.CLEAR_FILTERS), + itemsPerPage: document.getElementById(window.FileListConfig.ELEMENT_IDS.ITEMS_PER_PAGE), + sortableHeaders: document.querySelectorAll("th.sortable"), + frequencyButton: document.querySelector( + '[data-bs-target="#collapseFrequency"]', + ), + frequencyCollapse: document.getElementById("collapseFrequency"), + dateButton: document.querySelector('[data-bs-target="#collapseDate"]'), + dateCollapse: document.getElementById("collapseDate"), + }; + } + + /** + * Initialize component managers + */ + initializeComponents() { + this.modalManager = new ModalManager({ + modalId: "capture-modal", + modalBodyId: "capture-modal-body", + modalTitleId: "capture-modal-label", + }); + + this.tableManager = new FileListCapturesTableManager({ + tableId: "captures-table", + tableContainerSelector: ".table-responsive", + resultsCountId: "results-count", + modalHandler: this.modalManager, + onSelectionChange: () => this.syncBulkAddToDatasetButton(), + }); + + this.searchManager = new SearchManager({ + searchInputId: window.FileListConfig.ELEMENT_IDS.SEARCH_INPUT, + searchButtonId: "search-btn", + clearButtonId: "reset-search-btn", + searchFormId: "search-form", + onSearchStart: () => this.tableManager.showLoading(), + onSearch: (query, signal) => this.performSearch(signal), + debounceDelay: window.FileListConfig.DEBOUNCE_DELAY, + }); + + this.paginationManager = new PaginationManager({ + containerId: "captures-pagination", + onPageChange: (page) => this.handlePageChange(page), + }); + } + + /** + * Initialize all event handlers + */ + initializeEventHandlers() { + this.initializeTableSorting(); + this.initializeAccordions(); + this.initializeFrequencyHandling(); + this.initializeItemsPerPageHandler(); + this.initializeAddToDatasetButton(); + } + + /** + * Selection mode: one button to enter; when on, show Cancel and Add + */ + initializeAddToDatasetButton() { + const mainBtn = document.getElementById("add-captures-to-dataset-btn"); + const table = document.getElementById("captures-table"); + const modeButtonsWrap = document.getElementById( + "add-to-dataset-mode-buttons", + ); + const cancelBtn = document.getElementById("add-to-dataset-cancel-btn"); + const addBtn = document.getElementById("add-to-dataset-add-btn"); + if (!mainBtn || !table) return; + + const enterSelectionMode = () => { + table.classList.add("selection-mode-active"); + mainBtn.classList.add("d-none"); + mainBtn.setAttribute("aria-pressed", "true"); + if (modeButtonsWrap) modeButtonsWrap.classList.remove("d-none"); + this.syncBulkAddToDatasetButton(); + }; + + mainBtn.addEventListener("click", enterSelectionMode); + + if (cancelBtn) { + cancelBtn.addEventListener("click", () => this.exitSelectionMode()); + } + + if (addBtn) { + addBtn.addEventListener("click", () => { + const ids = Array.from(this.tableManager?.selectedCaptureIds ?? []); + if (ids.length === 0) { + if (window.showAlert) { + window.showAlert( + "Select at least one capture before adding to a dataset.", + "warning", + ); + } + return; + } + const modal = document.getElementById("quickAddToDatasetModal"); + if (modal) { + modal.dataset.captureUuids = JSON.stringify(ids); + const bsModal = bootstrap.Modal.getOrCreateInstance(modal); + bsModal.show(); + } + }); + } + } + + /** + * Exit bulk-add selection mode: hide the mode controls, uncheck all selected + * captures, and clear the selection set. + */ + exitSelectionMode() { + const mainBtn = document.getElementById("add-captures-to-dataset-btn"); + const table = document.getElementById("captures-table"); + const modeButtonsWrap = document.getElementById( + "add-to-dataset-mode-buttons", + ); + table?.classList.remove("selection-mode-active"); + mainBtn?.classList.remove("d-none"); + mainBtn?.setAttribute("aria-pressed", "false"); + modeButtonsWrap?.classList.add("d-none"); + + // Uncheck all visible checkboxes and clear the tracked set + if (this.tableManager) { + for (const uuid of this.tableManager.selectedCaptureIds) { + const cb = document.querySelector( + `.capture-select-checkbox[data-capture-uuid="${uuid}"]`, + ); + if (cb) cb.checked = false; + } + this.tableManager.selectedCaptureIds.clear(); + this.syncBulkAddToDatasetButton(); + } + } + + /** + * While selection mode is active, disable bulk "Add" until at least one capture is selected. + */ + syncBulkAddToDatasetButton() { + const addBtn = document.getElementById("add-to-dataset-add-btn"); + const table = document.getElementById("captures-table"); + if (!addBtn || !table?.classList.contains("selection-mode-active")) { + return; + } + const n = this.tableManager?.selectedCaptureIds?.size ?? 0; + addBtn.disabled = n === 0; + addBtn.title = + n === 0 + ? "Select at least one capture to add to a dataset" + : "Add selected captures to a dataset"; + addBtn.setAttribute( + "aria-label", + n === 0 + ? "Add to dataset — select at least one capture first" + : `Add ${n} selected capture${n === 1 ? "" : "s"} to a dataset`, + ); + } + + /** + * Initialize values from URL parameters + */ + initializeFromURL() { + // Set initial date values from URL + if (this.urlParams.get("date_start") && this.elements.startDate) { + this.elements.startDate.value = this.urlParams.get("date_start"); + } + if (this.urlParams.get("date_end") && this.elements.endDate) { + this.elements.endDate.value = this.urlParams.get("date_end"); + } + + // Set frequency values if they exist in URL + this.initializeFrequencyFromURL(); + } + + /** + * Handle page change events + */ + handlePageChange(page) { + const urlParams = new URLSearchParams(window.location.search); + urlParams.set("page", page.toString()); + window.location.search = urlParams.toString(); + } + + /** + * Build search parameters from form inputs + */ + buildSearchParams() { + const searchParams = new URLSearchParams(); + + const searchQuery = this.elements.searchInput?.value.trim() || ""; + const startDate = this.elements.startDate?.value || ""; + let endDate = this.elements.endDate?.value || ""; + + // If end date is set, include the full day + if (endDate) { + endDate = `${endDate}T23:59:59`; + } + + // Add search parameters + if (searchQuery) searchParams.set("search", searchQuery); + if (startDate) searchParams.set("date_start", startDate); + if (endDate) searchParams.set("date_end", endDate); + + // Only add frequency parameters if user has explicitly interacted + if (this.userInteractedWithFrequency) { + if (this.elements.centerFreqMin?.value) { + searchParams.set("min_freq", this.elements.centerFreqMin.value); + } + if (this.elements.centerFreqMax?.value) { + searchParams.set("max_freq", this.elements.centerFreqMax.value); + } + } + + searchParams.set("sort_by", this.currentSortBy); + searchParams.set("sort_order", this.currentSortOrder); + + return searchParams; + } + + /** + * Execute search API call + */ + async executeSearch(searchParams, signal) { + const apiUrl = `${window.location.pathname.replace(/\/$/, "")}/api/?${searchParams.toString()}`; + + const response = await fetch(apiUrl, { + method: "GET", + headers: { + Accept: "application/json", + }, + credentials: "same-origin", + signal: signal, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const text = await response.text(); + try { + return JSON.parse(text); + } catch { + throw new Error("Invalid JSON response from server"); + } + } + + /** + * Update UI with search results + */ + updateUI(data) { + if (data.error) { + throw new Error(`Server error: ${data.error}`); + } + + this.tableManager.updateTable(data.captures || [], data.has_results); + } + + /** + * Update browser history without page refresh + */ + updateBrowserHistory(searchParams) { + const newUrl = `${window.location.pathname}?${searchParams.toString()}`; + window.history.pushState({}, "", newUrl); + } + + /** + * Main search function - now broken down into smaller methods + */ + async performSearch(signal) { + try { + const startTime = Date.now(); + this.tableManager.showLoading(); + + const searchParams = this.buildSearchParams(); + const data = await this.executeSearch(searchParams, signal); + + // Ensure minimum loading time is displayed + const elapsedTime = Date.now() - startTime; + if (elapsedTime < window.FileListConfig.MIN_LOADING_TIME) { + await new Promise((resolve) => + setTimeout(resolve, window.FileListConfig.MIN_LOADING_TIME - elapsedTime), + ); + } + + this.updateUI(data); + this.updateBrowserHistory(searchParams); + } catch (error) { + // Don't show error if request was aborted (user issued a new search) + if (error.name === "AbortError") { + console.log("Previous search request was cancelled"); + return; + } + + console.error("Search error:", error); + this.tableManager.showError(`Search failed: ${error.message}`); + } finally { + this.tableManager.hideLoading(); + } + } + + /** + * Initialize table sorting functionality + */ + initializeTableSorting() { + if (!this.elements.sortableHeaders) return; + + for (const header of this.elements.sortableHeaders) { + header.style.cursor = "pointer"; + header.addEventListener("click", () => this.handleSort(header)); + } + } + + /** + * Handle sort click events + */ + handleSort(header) { + try { + const sortField = header.getAttribute("data-sort"); + const currentSort = this.urlParams.get("sort_by"); + const currentOrder = this.urlParams.get("sort_order") || "desc"; + + // Determine new sort order + let newOrder = "asc"; + if (currentSort === sortField && currentOrder === "asc") { + newOrder = "desc"; + } + + // Build new URL with sort parameters + const urlParams = new URLSearchParams(window.location.search); + urlParams.set("sort_by", sortField); + urlParams.set("sort_order", newOrder); + urlParams.set("page", "1"); + + // Navigate to sorted results + window.location.search = urlParams.toString(); + } catch (error) { + console.error("Error handling sort:", error); + } + } + + /** + * Update sort icons to show current sort state + */ + updateSortIcons() { + if (!this.elements.sortableHeaders) return; + + const currentSort = this.urlParams.get("sort_by") || window.FileListConfig.DEFAULT_SORT_BY; + const currentOrder = this.urlParams.get("sort_order") || "desc"; + + for (const header of this.elements.sortableHeaders) { + const sortField = header.getAttribute("data-sort"); + const icon = header.querySelector(".sort-icon"); + + if (icon) { + // Reset classes + icon.className = "bi sort-icon"; + + if (currentSort === sortField) { + // Add active class and appropriate direction icon + icon.classList.add("active"); + if (currentOrder === "desc") { + icon.classList.add("bi-caret-down-fill"); + } else { + icon.classList.add("bi-caret-up-fill"); + } + } else { + // Inactive columns get default down arrow + icon.classList.add("bi-caret-down-fill"); + } + } + } + } + + /** + * Initialize accordion behavior + */ + initializeAccordions() { + // Frequency filter accordion + if (this.elements.frequencyButton && this.elements.frequencyCollapse) { + this.elements.frequencyButton.addEventListener("click", (e) => { + e.preventDefault(); + this.toggleAccordion( + this.elements.frequencyButton, + this.elements.frequencyCollapse, + ); + }); + } + + // Date filter accordion + if (this.elements.dateButton && this.elements.dateCollapse) { + this.elements.dateButton.addEventListener("click", (e) => { + e.preventDefault(); + this.toggleAccordion( + this.elements.dateButton, + this.elements.dateCollapse, + ); + }); + } + } + + /** + * Helper function to toggle accordion state + */ + toggleAccordion(button, collapse) { + const isCollapsed = button.classList.contains("collapsed"); + + if (isCollapsed) { + button.classList.remove("collapsed"); + button.setAttribute("aria-expanded", "true"); + collapse.classList.add("show"); + } else { + button.classList.add("collapsed"); + button.setAttribute("aria-expanded", "false"); + collapse.classList.remove("show"); + } + } + + /** + * Initialize frequency handling + */ + initializeFrequencyHandling() { + // Add event listeners to track user interaction with frequency inputs + if (this.elements.centerFreqMin) { + this.elements.centerFreqMin.addEventListener("change", () => { + this.userInteractedWithFrequency = true; + }); + } + + if (this.elements.centerFreqMax) { + this.elements.centerFreqMax.addEventListener("change", () => { + this.userInteractedWithFrequency = true; + }); + } + + // Apply filters button + if (this.elements.applyFilters) { + this.elements.applyFilters.addEventListener("click", (e) => { + e.preventDefault(); + this.performSearch(); + }); + } + + // Clear filters button + if (this.elements.clearFilters) { + this.elements.clearFilters.addEventListener("click", (e) => { + e.preventDefault(); + this.clearAllFilters(); + }); + } + } + + /** + * Clear all filter inputs + */ + clearAllFilters() { + // Get current URL parameters + const urlParams = new URLSearchParams(window.location.search); + const currentSearch = urlParams.get("search"); + + // Reset all filter inputs except search + if (this.elements.startDate) this.elements.startDate.value = ""; + if (this.elements.endDate) this.elements.endDate.value = ""; + if (this.elements.centerFreqMin) this.elements.centerFreqMin.value = ""; + if (this.elements.centerFreqMax) this.elements.centerFreqMax.value = ""; + + // Reset interaction tracking + this.userInteractedWithFrequency = false; + + // Reset frequency slider if it exists + const frequencyRangeSlider = document.getElementById( + "frequency-range-slider", + ); + if (frequencyRangeSlider?.noUiSlider) { + frequencyRangeSlider.noUiSlider.set([0, 10]); + } + + // Also reset the display values + const lowerValue = document.getElementById("frequency-range-lower"); + const upperValue = document.getElementById("frequency-range-upper"); + if (lowerValue) lowerValue.textContent = "0 GHz"; + if (upperValue) upperValue.textContent = "10 GHz"; + + // Create new URL parameters with only search and sort parameters preserved + const newParams = new URLSearchParams(); + if (currentSearch) { + newParams.set("search", currentSearch); + } + newParams.set("sort_by", this.currentSortBy); + newParams.set("sort_order", this.currentSortOrder); + + // Update URL and trigger search + window.history.pushState( + {}, + "", + `${window.location.pathname}?${newParams.toString()}`, + ); + this.performSearch(); + } + + /** + * Initialize items per page handler + */ + initializeItemsPerPageHandler() { + if (this.elements.itemsPerPage) { + this.elements.itemsPerPage.addEventListener("change", (e) => { + const urlParams = new URLSearchParams(window.location.search); + urlParams.set("items_per_page", e.target.value); + urlParams.set("page", "1"); + window.location.search = urlParams.toString(); + }); + } + } + + /** + * Initialize frequency range from URL parameters + */ + initializeFrequencyFromURL() { + if (!this.elements.centerFreqMin || !this.elements.centerFreqMax) return; + + const minFreq = Number.parseFloat(this.urlParams.get("min_freq")); + const maxFreq = Number.parseFloat(this.urlParams.get("max_freq")); + + if (!Number.isNaN(minFreq)) { + this.elements.centerFreqMin.value = minFreq; + this.userInteractedWithFrequency = true; + } + if (!Number.isNaN(maxFreq)) { + this.elements.centerFreqMax.value = maxFreq; + this.userInteractedWithFrequency = true; + } + + // Update noUiSlider if it exists + if (this.userInteractedWithFrequency) { + this.initializeFrequencySlider(); + } + } + + initializeFrequencySlider() { + try { + const minFreq = Number.parseFloat(this.urlParams.get("min_freq")); + const maxFreq = Number.parseFloat(this.urlParams.get("max_freq")); + const frequencyRangeSlider = document.getElementById( + "frequency-range-slider", + ); + if (frequencyRangeSlider?.noUiSlider) { + const currentValues = frequencyRangeSlider.noUiSlider.get(); + const newMin = !Number.isNaN(minFreq) + ? minFreq + : Number.parseFloat(currentValues[0]); + const newMax = !Number.isNaN(maxFreq) + ? maxFreq + : Number.parseFloat(currentValues[1]); + + frequencyRangeSlider.noUiSlider.set([newMin, newMax]); + } + } catch (error) { + console.error("Error initializing frequency slider:", error); + } + } + + /** + * Initialize dropdowns with body container for proper positioning + */ + initializeDropdowns() { + if (window.DropdownUtils) { + window.DropdownUtils.initIconDropdowns(document); + } + } +} + +if (typeof module !== "undefined" && module.exports) { + module.exports = { FileListPageController }; +} + diff --git a/gateway/sds_gateway/static/js/captures/_FileListCapturesTableManager.body.js b/gateway/sds_gateway/static/js/captures/_FileListCapturesTableManager.body.js new file mode 100644 index 000000000..7e874dc4b --- /dev/null +++ b/gateway/sds_gateway/static/js/captures/_FileListCapturesTableManager.body.js @@ -0,0 +1,387 @@ +class FileListCapturesTableManager extends CapturesTableManager { + /** + * UUIDs selected for quick-add / bulk actions. Class field initializes as soon as + * the instance exists (after super()), so renderRow never runs before this exists. + */ + selectedCaptureIds = new Set(); + + constructor(options) { + super(options); + this.resultsCountElement = document.getElementById(options.resultsCountId); + this.searchButton = document.getElementById("search-btn"); + this.searchButtonContent = document.getElementById("search-btn-content"); + this.searchButtonLoading = document.getElementById("search-btn-loading"); + this.onSelectionChange = options.onSelectionChange ?? null; + this.setupSelectionCheckboxHandler(); + this.setupRowClickSelection(); + } + + _notifySelectionChange() { + if (typeof this.onSelectionChange === "function") { + this.onSelectionChange(); + } + } + + /** + * Override showLoading to toggle button contents instead of showing separate indicator + */ + showLoading() { + if (this.searchButton) { + this.searchButton.disabled = true; + if (this.searchButtonContent) + this.searchButtonContent.classList.add("d-none"); + if (this.searchButtonLoading) + this.searchButtonLoading.classList.remove("d-none"); + } + } + + /** + * Override hideLoading to restore button contents + */ + hideLoading() { + if (this.searchButton) { + this.searchButton.disabled = false; + if (this.searchButtonContent) + this.searchButtonContent.classList.remove("d-none"); + if (this.searchButtonLoading) + this.searchButtonLoading.classList.add("d-none"); + } + } + + /** + * Delegated handler for selection checkboxes: keep selectedCaptureIds in sync + */ + setupSelectionCheckboxHandler() { + this._checkboxChangeHandler = (e) => { + if (!e.target.matches(".capture-select-checkbox")) return; + const uuid = e.target.getAttribute("data-capture-uuid"); + if (!uuid) return; + if (e.target.checked) { + this.selectedCaptureIds.add(uuid); + } else { + this.selectedCaptureIds.delete(uuid); + } + this._notifySelectionChange(); + }; + document.addEventListener("change", this._checkboxChangeHandler); + } + + /** + * When selection mode is active, clicking a row toggles its selection (instead of opening the modal). + * Uses capture phase so we run before the row's click handler. + */ + setupRowClickSelection() { + const table = document.getElementById(this.tableId); + if (!table) return; + this._rowClickTable = table; + + this._rowClickHandler = (e) => { + if (!table.classList.contains("selection-mode-active")) return; + if ( + e.target.closest( + "button, a, [data-bs-toggle='dropdown'], .capture-select-checkbox", + ) + ) + return; + const row = e.target.closest("tr"); + if (!row) return; + const checkbox = row.querySelector(".capture-select-checkbox"); + if (!checkbox) return; + const uuid = checkbox.getAttribute("data-capture-uuid"); + if (!uuid) return; + + if (this.selectedCaptureIds.has(uuid)) { + this.selectedCaptureIds.delete(uuid); + checkbox.checked = false; + } else { + this.selectedCaptureIds.add(uuid); + checkbox.checked = true; + } + this._notifySelectionChange(); + e.preventDefault(); + e.stopPropagation(); + }; + + table.addEventListener("click", this._rowClickHandler, true); + } + + destroy() { + if (this._checkboxChangeHandler) { + document.removeEventListener("change", this._checkboxChangeHandler); + this._checkboxChangeHandler = null; + } + if (this._rowClickHandler && this._rowClickTable) { + this._rowClickTable.removeEventListener( + "click", + this._rowClickHandler, + true, + ); + this._rowClickHandler = null; + this._rowClickTable = null; + } + super.destroy(); + } + + /** + * Initialize dropdowns with body container for proper positioning + */ + initializeDropdowns() { + if (window.DropdownUtils) { + window.DropdownUtils.initIconDropdowns(document); + } + } + + /** + * Update table with new data + */ + updateTable(captures, hasResults) { + this.selectedCaptureIds ??= new Set(); + const tbody = this.tbody ?? this.table?.querySelector("tbody"); + if (!tbody) return; + + // Update results count + this.updateResultsCount(captures, hasResults); + + if (!hasResults || captures.length === 0) { + tbody.innerHTML = ` + + + No captures found matching your search criteria. + + + `; + this._notifySelectionChange(); + return; + } + + // Build table HTML efficiently + const tableHTML = captures + .map((capture, index) => this.renderRow(capture, index)) + .join(""); + tbody.innerHTML = tableHTML; + + // Initialize dropdowns after table is updated + this.initializeDropdowns(); + this._notifySelectionChange(); + } + + /** + * Update results count display + */ + updateResultsCount(captures, hasResults) { + if (this.resultsCountElement) { + const count = hasResults && captures ? captures.length : 0; + const pluralSuffix = count === 1 ? "" : "s"; + this.resultsCountElement.textContent = `${count} capture${pluralSuffix} found`; + } + } + + /** + * Render individual table row with XSS protection + * Overrides the base class method to include file-specific columns + */ + renderRow(capture) { + this.selectedCaptureIds ??= new Set(); + // Sanitize all data before rendering + const safeData = { + uuid: ComponentUtils.escapeHtml(capture.uuid || ""), + name: ComponentUtils.escapeHtml(capture.name || ""), + channel: ComponentUtils.escapeHtml(capture.channel || ""), + scanGroup: ComponentUtils.escapeHtml(capture.scan_group || ""), + captureType: ComponentUtils.escapeHtml(capture.capture_type || ""), + captureTypeDisplay: ComponentUtils.escapeHtml( + capture.capture_type_display || "", + ), + topLevelDir: ComponentUtils.escapeHtml(capture.top_level_dir || ""), + indexName: ComponentUtils.escapeHtml(capture.index_name || ""), + owner: ComponentUtils.escapeHtml(capture.owner || ""), + origin: ComponentUtils.escapeHtml(capture.origin || ""), + dataset: ComponentUtils.escapeHtml(capture.dataset || ""), + createdAt: ComponentUtils.escapeHtml(capture.created_at || ""), + updatedAt: ComponentUtils.escapeHtml(capture.updated_at || ""), + isPublic: ComponentUtils.escapeHtml(capture.is_public || ""), + isDeleted: ComponentUtils.escapeHtml(capture.is_deleted || ""), + centerFrequencyGhz: ComponentUtils.escapeHtml( + capture.center_frequency_ghz || "", + ), + lengthOfCaptureMs: capture.length_of_capture_ms ?? 0, + fileCadenceMs: capture.file_cadence_ms ?? 1000, + perDataFileSize: capture.per_data_file_size ?? 0, + totalSize: capture.total_file_size ?? 0, + dataFilesCount: capture.data_files_count ?? 0, + dataFilesTotalSize: capture.data_files_total_size ?? 0, + totalFilesCount: capture.files.length ?? 0, + captureStartEpochSec: capture.capture_start_epoch_sec ?? 0, + }; + + let typeDisplay = safeData.captureTypeDisplay || safeData.captureType; + + if (capture.is_multi_channel) { + typeDisplay = capture.capture_type_display || safeData.captureType; + } + + // Display name with fallback to "Unnamed Capture" + const nameDisplay = safeData.name || "Unnamed Capture"; + + // Format created date to match template format + let createdDate = "-"; + if (capture.created_at) { + const date = new Date(capture.created_at); + if (!Number.isNaN(date.getTime())) { + const dateStr = date.toISOString().split("T")[0]; // YYYY-MM-DD + const timeStr = date.toLocaleTimeString("en-US", { + hour12: false, + timeZoneName: "short", + }); // HH:mm:ss TZ + createdDate = ` +
+ ${dateStr} + ${timeStr} +
+ `; + } + } + + // Check if shared (for shared icon) + const isShared = capture.is_shared_with_me || false; + const sharedIcon = isShared + ? ` + + ` + : ""; + + // Check if owner (for conditional actions and selection — only owned captures are selectable) + const isOwner = capture.is_owner === true; + + const checked = this.selectedCaptureIds.has(capture.uuid) ? " checked" : ""; + const selectCell = isOwner + ? `` + : ''; + return ` + + ${selectCell} + + + ${nameDisplay} + + ${sharedIcon} + + ${safeData.topLevelDir || "-"} + ${typeDisplay} + ${createdDate} + + + + + `; + } +} diff --git a/gateway/sds_gateway/static/js/captures/_FileListPageController.body.js b/gateway/sds_gateway/static/js/captures/_FileListPageController.body.js new file mode 100644 index 000000000..235e61a5b --- /dev/null +++ b/gateway/sds_gateway/static/js/captures/_FileListPageController.body.js @@ -0,0 +1,610 @@ +class FileListPageController { + constructor() { + this.userInteractedWithFrequency = false; + this.urlParams = new URLSearchParams(window.location.search); + this.currentSortBy = + this.urlParams.get("sort_by") || window.FileListConfig.DEFAULT_SORT_BY; + this.currentSortOrder = + this.urlParams.get("sort_order") || window.FileListConfig.DEFAULT_SORT_ORDER; + + // Cache DOM elements + this.cacheElements(); + + // Initialize components + this.initializeComponents(); + + // Initialize functionality + this.initializeEventHandlers(); + this.initializeFromURL(); + + // Initial setup + this.updateSortIcons(); + this.tableManager.attachRowClickHandlers(); + + // Initialize dropdowns for any existing static dropdowns + this.initializeDropdowns(); + } + + /** + * Cache frequently accessed DOM elements + */ + cacheElements() { + this.elements = { + searchInput: document.getElementById(window.FileListConfig.ELEMENT_IDS.SEARCH_INPUT), + startDate: document.getElementById(window.FileListConfig.ELEMENT_IDS.START_DATE), + endDate: document.getElementById(window.FileListConfig.ELEMENT_IDS.END_DATE), + centerFreqMin: document.getElementById( + window.FileListConfig.ELEMENT_IDS.CENTER_FREQ_MIN, + ), + centerFreqMax: document.getElementById( + window.FileListConfig.ELEMENT_IDS.CENTER_FREQ_MAX, + ), + applyFilters: document.getElementById(window.FileListConfig.ELEMENT_IDS.APPLY_FILTERS), + clearFilters: document.getElementById(window.FileListConfig.ELEMENT_IDS.CLEAR_FILTERS), + itemsPerPage: document.getElementById(window.FileListConfig.ELEMENT_IDS.ITEMS_PER_PAGE), + sortableHeaders: document.querySelectorAll("th.sortable"), + frequencyButton: document.querySelector( + '[data-bs-target="#collapseFrequency"]', + ), + frequencyCollapse: document.getElementById("collapseFrequency"), + dateButton: document.querySelector('[data-bs-target="#collapseDate"]'), + dateCollapse: document.getElementById("collapseDate"), + }; + } + + /** + * Initialize component managers + */ + initializeComponents() { + this.modalManager = new ModalManager({ + modalId: "capture-modal", + modalBodyId: "capture-modal-body", + modalTitleId: "capture-modal-label", + }); + + this.tableManager = new FileListCapturesTableManager({ + tableId: "captures-table", + tableContainerSelector: ".table-responsive", + resultsCountId: "results-count", + modalHandler: this.modalManager, + onSelectionChange: () => this.syncBulkAddToDatasetButton(), + }); + + this.searchManager = new SearchManager({ + searchInputId: window.FileListConfig.ELEMENT_IDS.SEARCH_INPUT, + searchButtonId: "search-btn", + clearButtonId: "reset-search-btn", + searchFormId: "search-form", + onSearchStart: () => this.tableManager.showLoading(), + onSearch: (query, signal) => this.performSearch(signal), + debounceDelay: window.FileListConfig.DEBOUNCE_DELAY, + }); + + this.paginationManager = new PaginationManager({ + containerId: "captures-pagination", + onPageChange: (page) => this.handlePageChange(page), + }); + } + + /** + * Initialize all event handlers + */ + initializeEventHandlers() { + this.initializeTableSorting(); + this.initializeAccordions(); + this.initializeFrequencyHandling(); + this.initializeItemsPerPageHandler(); + this.initializeAddToDatasetButton(); + } + + /** + * Selection mode: one button to enter; when on, show Cancel and Add + */ + initializeAddToDatasetButton() { + const mainBtn = document.getElementById("add-captures-to-dataset-btn"); + const table = document.getElementById("captures-table"); + const modeButtonsWrap = document.getElementById( + "add-to-dataset-mode-buttons", + ); + const cancelBtn = document.getElementById("add-to-dataset-cancel-btn"); + const addBtn = document.getElementById("add-to-dataset-add-btn"); + if (!mainBtn || !table) return; + + const enterSelectionMode = () => { + table.classList.add("selection-mode-active"); + mainBtn.classList.add("d-none"); + mainBtn.setAttribute("aria-pressed", "true"); + if (modeButtonsWrap) modeButtonsWrap.classList.remove("d-none"); + this.syncBulkAddToDatasetButton(); + }; + + mainBtn.addEventListener("click", enterSelectionMode); + + if (cancelBtn) { + cancelBtn.addEventListener("click", () => this.exitSelectionMode()); + } + + if (addBtn) { + addBtn.addEventListener("click", () => { + const ids = Array.from(this.tableManager?.selectedCaptureIds ?? []); + if (ids.length === 0) { + if (window.showAlert) { + window.showAlert( + "Select at least one capture before adding to a dataset.", + "warning", + ); + } + return; + } + const modal = document.getElementById("quickAddToDatasetModal"); + if (modal) { + modal.dataset.captureUuids = JSON.stringify(ids); + const bsModal = bootstrap.Modal.getOrCreateInstance(modal); + bsModal.show(); + } + }); + } + } + + /** + * Exit bulk-add selection mode: hide the mode controls, uncheck all selected + * captures, and clear the selection set. + */ + exitSelectionMode() { + const mainBtn = document.getElementById("add-captures-to-dataset-btn"); + const table = document.getElementById("captures-table"); + const modeButtonsWrap = document.getElementById( + "add-to-dataset-mode-buttons", + ); + table?.classList.remove("selection-mode-active"); + mainBtn?.classList.remove("d-none"); + mainBtn?.setAttribute("aria-pressed", "false"); + modeButtonsWrap?.classList.add("d-none"); + + // Uncheck all visible checkboxes and clear the tracked set + if (this.tableManager) { + for (const uuid of this.tableManager.selectedCaptureIds) { + const cb = document.querySelector( + `.capture-select-checkbox[data-capture-uuid="${uuid}"]`, + ); + if (cb) cb.checked = false; + } + this.tableManager.selectedCaptureIds.clear(); + this.syncBulkAddToDatasetButton(); + } + } + + /** + * While selection mode is active, disable bulk "Add" until at least one capture is selected. + */ + syncBulkAddToDatasetButton() { + const addBtn = document.getElementById("add-to-dataset-add-btn"); + const table = document.getElementById("captures-table"); + if (!addBtn || !table?.classList.contains("selection-mode-active")) { + return; + } + const n = this.tableManager?.selectedCaptureIds?.size ?? 0; + addBtn.disabled = n === 0; + addBtn.title = + n === 0 + ? "Select at least one capture to add to a dataset" + : "Add selected captures to a dataset"; + addBtn.setAttribute( + "aria-label", + n === 0 + ? "Add to dataset — select at least one capture first" + : `Add ${n} selected capture${n === 1 ? "" : "s"} to a dataset`, + ); + } + + /** + * Initialize values from URL parameters + */ + initializeFromURL() { + // Set initial date values from URL + if (this.urlParams.get("date_start") && this.elements.startDate) { + this.elements.startDate.value = this.urlParams.get("date_start"); + } + if (this.urlParams.get("date_end") && this.elements.endDate) { + this.elements.endDate.value = this.urlParams.get("date_end"); + } + + // Set frequency values if they exist in URL + this.initializeFrequencyFromURL(); + } + + /** + * Handle page change events + */ + handlePageChange(page) { + const urlParams = new URLSearchParams(window.location.search); + urlParams.set("page", page.toString()); + window.location.search = urlParams.toString(); + } + + /** + * Build search parameters from form inputs + */ + buildSearchParams() { + const searchParams = new URLSearchParams(); + + const searchQuery = this.elements.searchInput?.value.trim() || ""; + const startDate = this.elements.startDate?.value || ""; + let endDate = this.elements.endDate?.value || ""; + + // If end date is set, include the full day + if (endDate) { + endDate = `${endDate}T23:59:59`; + } + + // Add search parameters + if (searchQuery) searchParams.set("search", searchQuery); + if (startDate) searchParams.set("date_start", startDate); + if (endDate) searchParams.set("date_end", endDate); + + // Only add frequency parameters if user has explicitly interacted + if (this.userInteractedWithFrequency) { + if (this.elements.centerFreqMin?.value) { + searchParams.set("min_freq", this.elements.centerFreqMin.value); + } + if (this.elements.centerFreqMax?.value) { + searchParams.set("max_freq", this.elements.centerFreqMax.value); + } + } + + searchParams.set("sort_by", this.currentSortBy); + searchParams.set("sort_order", this.currentSortOrder); + + return searchParams; + } + + /** + * Execute search API call + */ + async executeSearch(searchParams, signal) { + const apiUrl = `${window.location.pathname.replace(/\/$/, "")}/api/?${searchParams.toString()}`; + + const response = await fetch(apiUrl, { + method: "GET", + headers: { + Accept: "application/json", + }, + credentials: "same-origin", + signal: signal, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const text = await response.text(); + try { + return JSON.parse(text); + } catch { + throw new Error("Invalid JSON response from server"); + } + } + + /** + * Update UI with search results + */ + updateUI(data) { + if (data.error) { + throw new Error(`Server error: ${data.error}`); + } + + this.tableManager.updateTable(data.captures || [], data.has_results); + } + + /** + * Update browser history without page refresh + */ + updateBrowserHistory(searchParams) { + const newUrl = `${window.location.pathname}?${searchParams.toString()}`; + window.history.pushState({}, "", newUrl); + } + + /** + * Main search function - now broken down into smaller methods + */ + async performSearch(signal) { + try { + const startTime = Date.now(); + this.tableManager.showLoading(); + + const searchParams = this.buildSearchParams(); + const data = await this.executeSearch(searchParams, signal); + + // Ensure minimum loading time is displayed + const elapsedTime = Date.now() - startTime; + if (elapsedTime < window.FileListConfig.MIN_LOADING_TIME) { + await new Promise((resolve) => + setTimeout(resolve, window.FileListConfig.MIN_LOADING_TIME - elapsedTime), + ); + } + + this.updateUI(data); + this.updateBrowserHistory(searchParams); + } catch (error) { + // Don't show error if request was aborted (user issued a new search) + if (error.name === "AbortError") { + console.log("Previous search request was cancelled"); + return; + } + + console.error("Search error:", error); + this.tableManager.showError(`Search failed: ${error.message}`); + } finally { + this.tableManager.hideLoading(); + } + } + + /** + * Initialize table sorting functionality + */ + initializeTableSorting() { + if (!this.elements.sortableHeaders) return; + + for (const header of this.elements.sortableHeaders) { + header.style.cursor = "pointer"; + header.addEventListener("click", () => this.handleSort(header)); + } + } + + /** + * Handle sort click events + */ + handleSort(header) { + try { + const sortField = header.getAttribute("data-sort"); + const currentSort = this.urlParams.get("sort_by"); + const currentOrder = this.urlParams.get("sort_order") || "desc"; + + // Determine new sort order + let newOrder = "asc"; + if (currentSort === sortField && currentOrder === "asc") { + newOrder = "desc"; + } + + // Build new URL with sort parameters + const urlParams = new URLSearchParams(window.location.search); + urlParams.set("sort_by", sortField); + urlParams.set("sort_order", newOrder); + urlParams.set("page", "1"); + + // Navigate to sorted results + window.location.search = urlParams.toString(); + } catch (error) { + console.error("Error handling sort:", error); + } + } + + /** + * Update sort icons to show current sort state + */ + updateSortIcons() { + if (!this.elements.sortableHeaders) return; + + const currentSort = this.urlParams.get("sort_by") || window.FileListConfig.DEFAULT_SORT_BY; + const currentOrder = this.urlParams.get("sort_order") || "desc"; + + for (const header of this.elements.sortableHeaders) { + const sortField = header.getAttribute("data-sort"); + const icon = header.querySelector(".sort-icon"); + + if (icon) { + // Reset classes + icon.className = "bi sort-icon"; + + if (currentSort === sortField) { + // Add active class and appropriate direction icon + icon.classList.add("active"); + if (currentOrder === "desc") { + icon.classList.add("bi-caret-down-fill"); + } else { + icon.classList.add("bi-caret-up-fill"); + } + } else { + // Inactive columns get default down arrow + icon.classList.add("bi-caret-down-fill"); + } + } + } + } + + /** + * Initialize accordion behavior + */ + initializeAccordions() { + // Frequency filter accordion + if (this.elements.frequencyButton && this.elements.frequencyCollapse) { + this.elements.frequencyButton.addEventListener("click", (e) => { + e.preventDefault(); + this.toggleAccordion( + this.elements.frequencyButton, + this.elements.frequencyCollapse, + ); + }); + } + + // Date filter accordion + if (this.elements.dateButton && this.elements.dateCollapse) { + this.elements.dateButton.addEventListener("click", (e) => { + e.preventDefault(); + this.toggleAccordion( + this.elements.dateButton, + this.elements.dateCollapse, + ); + }); + } + } + + /** + * Helper function to toggle accordion state + */ + toggleAccordion(button, collapse) { + const isCollapsed = button.classList.contains("collapsed"); + + if (isCollapsed) { + button.classList.remove("collapsed"); + button.setAttribute("aria-expanded", "true"); + collapse.classList.add("show"); + } else { + button.classList.add("collapsed"); + button.setAttribute("aria-expanded", "false"); + collapse.classList.remove("show"); + } + } + + /** + * Initialize frequency handling + */ + initializeFrequencyHandling() { + // Add event listeners to track user interaction with frequency inputs + if (this.elements.centerFreqMin) { + this.elements.centerFreqMin.addEventListener("change", () => { + this.userInteractedWithFrequency = true; + }); + } + + if (this.elements.centerFreqMax) { + this.elements.centerFreqMax.addEventListener("change", () => { + this.userInteractedWithFrequency = true; + }); + } + + // Apply filters button + if (this.elements.applyFilters) { + this.elements.applyFilters.addEventListener("click", (e) => { + e.preventDefault(); + this.performSearch(); + }); + } + + // Clear filters button + if (this.elements.clearFilters) { + this.elements.clearFilters.addEventListener("click", (e) => { + e.preventDefault(); + this.clearAllFilters(); + }); + } + } + + /** + * Clear all filter inputs + */ + clearAllFilters() { + // Get current URL parameters + const urlParams = new URLSearchParams(window.location.search); + const currentSearch = urlParams.get("search"); + + // Reset all filter inputs except search + if (this.elements.startDate) this.elements.startDate.value = ""; + if (this.elements.endDate) this.elements.endDate.value = ""; + if (this.elements.centerFreqMin) this.elements.centerFreqMin.value = ""; + if (this.elements.centerFreqMax) this.elements.centerFreqMax.value = ""; + + // Reset interaction tracking + this.userInteractedWithFrequency = false; + + // Reset frequency slider if it exists + const frequencyRangeSlider = document.getElementById( + "frequency-range-slider", + ); + if (frequencyRangeSlider?.noUiSlider) { + frequencyRangeSlider.noUiSlider.set([0, 10]); + } + + // Also reset the display values + const lowerValue = document.getElementById("frequency-range-lower"); + const upperValue = document.getElementById("frequency-range-upper"); + if (lowerValue) lowerValue.textContent = "0 GHz"; + if (upperValue) upperValue.textContent = "10 GHz"; + + // Create new URL parameters with only search and sort parameters preserved + const newParams = new URLSearchParams(); + if (currentSearch) { + newParams.set("search", currentSearch); + } + newParams.set("sort_by", this.currentSortBy); + newParams.set("sort_order", this.currentSortOrder); + + // Update URL and trigger search + window.history.pushState( + {}, + "", + `${window.location.pathname}?${newParams.toString()}`, + ); + this.performSearch(); + } + + /** + * Initialize items per page handler + */ + initializeItemsPerPageHandler() { + if (this.elements.itemsPerPage) { + this.elements.itemsPerPage.addEventListener("change", (e) => { + const urlParams = new URLSearchParams(window.location.search); + urlParams.set("items_per_page", e.target.value); + urlParams.set("page", "1"); + window.location.search = urlParams.toString(); + }); + } + } + + /** + * Initialize frequency range from URL parameters + */ + initializeFrequencyFromURL() { + if (!this.elements.centerFreqMin || !this.elements.centerFreqMax) return; + + const minFreq = Number.parseFloat(this.urlParams.get("min_freq")); + const maxFreq = Number.parseFloat(this.urlParams.get("max_freq")); + + if (!Number.isNaN(minFreq)) { + this.elements.centerFreqMin.value = minFreq; + this.userInteractedWithFrequency = true; + } + if (!Number.isNaN(maxFreq)) { + this.elements.centerFreqMax.value = maxFreq; + this.userInteractedWithFrequency = true; + } + + // Update noUiSlider if it exists + if (this.userInteractedWithFrequency) { + this.initializeFrequencySlider(); + } + } + + initializeFrequencySlider() { + try { + const minFreq = Number.parseFloat(this.urlParams.get("min_freq")); + const maxFreq = Number.parseFloat(this.urlParams.get("max_freq")); + const frequencyRangeSlider = document.getElementById( + "frequency-range-slider", + ); + if (frequencyRangeSlider?.noUiSlider) { + const currentValues = frequencyRangeSlider.noUiSlider.get(); + const newMin = !Number.isNaN(minFreq) + ? minFreq + : Number.parseFloat(currentValues[0]); + const newMax = !Number.isNaN(maxFreq) + ? maxFreq + : Number.parseFloat(currentValues[1]); + + frequencyRangeSlider.noUiSlider.set([newMin, newMax]); + } + } catch (error) { + console.error("Error initializing frequency slider:", error); + } + } + + /** + * Initialize dropdowns with body container for proper positioning + */ + initializeDropdowns() { + if (window.DropdownUtils) { + window.DropdownUtils.initIconDropdowns(document); + } + } +} diff --git a/gateway/sds_gateway/static/js/captures/_frag_captures_table.txt b/gateway/sds_gateway/static/js/captures/_frag_captures_table.txt new file mode 100644 index 000000000..e703a860e --- /dev/null +++ b/gateway/sds_gateway/static/js/captures/_frag_captures_table.txt @@ -0,0 +1,204 @@ +class CapturesTableManager extends TableManager { + constructor(config) { + super(config); + this.modalHandler = config.modalHandler; + this.tableContainerSelector = config.tableContainerSelector; + this.eventDelegationHandler = null; + this.initializeEventDelegation(); + } + + /** + * Initialize event delegation for better performance and memory management + */ + initializeEventDelegation() { + // Remove existing handler if it exists + if (this.eventDelegationHandler) { + document.removeEventListener("click", this.eventDelegationHandler); + } + + // Create single persistent event handler using delegation + this.eventDelegationHandler = (e) => { + // Ignore Bootstrap dropdown toggles + if ( + e.target.matches('[data-bs-toggle="dropdown"]') || + e.target.closest('[data-bs-toggle="dropdown"]') + ) { + return; + } + + // Handle capture details button clicks from actions dropdown + if ( + e.target.matches(".capture-details-btn") || + e.target.closest(".capture-details-btn") + ) { + e.preventDefault(); + const button = e.target.matches(".capture-details-btn") + ? e.target + : e.target.closest(".capture-details-btn"); + this.openCaptureModal(button); + return; + } + + // Handle capture link clicks + if ( + e.target.matches(".capture-link") || + e.target.closest(".capture-link") + ) { + e.preventDefault(); + const link = e.target.matches(".capture-link") + ? e.target + : e.target.closest(".capture-link"); + this.openCaptureModal(link); + return; + } + + // Handle view button clicks + if ( + e.target.matches(".view-capture-btn") || + e.target.closest(".view-capture-btn") + ) { + e.preventDefault(); + const button = e.target.matches(".view-capture-btn") + ? e.target + : e.target.closest(".view-capture-btn"); + this.openCaptureModal(button); + return; + } + }; + + // Add the persistent event listener + document.addEventListener("click", this.eventDelegationHandler); + } + + renderRow(capture, index) { + // Sanitize all data before rendering + const safeData = { + uuid: ComponentUtils.escapeHtml(capture.uuid || ""), + channel: ComponentUtils.escapeHtml(capture.channel || ""), + scanGroup: ComponentUtils.escapeHtml(capture.scan_group || ""), + captureType: ComponentUtils.escapeHtml(capture.capture_type || ""), + captureTypeDisplay: ComponentUtils.escapeHtml( + capture.capture_type_display || "", + ), + topLevelDir: ComponentUtils.escapeHtml(capture.top_level_dir || ""), + indexName: ComponentUtils.escapeHtml(capture.index_name || ""), + owner: ComponentUtils.escapeHtml(capture.owner || ""), + origin: ComponentUtils.escapeHtml(capture.origin || ""), + dataset: ComponentUtils.escapeHtml(capture.dataset || ""), + createdAt: ComponentUtils.escapeHtml(capture.created_at || ""), + updatedAt: ComponentUtils.escapeHtml(capture.updated_at || ""), + isPublic: ComponentUtils.escapeHtml(capture.is_public || ""), + isDeleted: ComponentUtils.escapeHtml(capture.is_deleted || ""), + centerFrequencyGhz: ComponentUtils.escapeHtml( + capture.center_frequency_ghz || "", + ), + }; + + // Handle composite vs single capture display + let channelDisplay = safeData.channel; + let typeDisplay = safeData.captureTypeDisplay || safeData.captureType; + + if (capture.is_multi_channel) { + // For composite captures, show all channels + if (capture.channels && Array.isArray(capture.channels)) { + channelDisplay = capture.channels + .map((ch) => ComponentUtils.escapeHtml(ch.channel || ch)) + .join(", "); + } + // Use capture_type_display if available, otherwise fall back to captureType + typeDisplay = capture.capture_type_display || safeData.captureType; + } + + return ` + + + + ${safeData.uuid} + + + ${channelDisplay} + + ${ComponentUtils.formatDateForModal(capture.capture?.created_at || capture.created_at)} + + ${typeDisplay} + ${capture.files_count || "0"} + ${capture.center_frequency_ghz ? `${capture.center_frequency_ghz.toFixed(3)} GHz` : "-"} + ${capture.sample_rate_mhz ? `${capture.sample_rate_mhz.toFixed(1)} MHz` : "-"} + + `; + } + + /** + * Attach row click handlers - now uses event delegation + */ + attachRowClickHandlers() { + // Event delegation is handled in initializeEventDelegation() + // This method is kept for compatibility but doesn't need to do anything + } + + /** + * Open capture modal with XSS protection + */ + openCaptureModal(linkElement) { + if (this.modalHandler) { + this.modalHandler.openCaptureModal(linkElement); + } + } + + /** + * Get CSRF token for API requests + */ + getCSRFToken() { + const token = document.querySelector("[name=csrfmiddlewaretoken]"); + return token ? token.value : ""; + } + + /** + * Open a custom modal + */ + openCustomModal(modalId) { + const modal = document.getElementById(modalId); + if (modal) { + modal.style.display = "block"; + document.body.style.overflow = "hidden"; + } + } + + /** + * Close a custom modal + */ + closeCustomModal(modalId) { + const modal = document.getElementById(modalId); + if (modal) { + modal.style.display = "none"; + document.body.style.overflow = "auto"; + } + } + + /** + * Cleanup method for proper resource management + */ + destroy() { + if (this.eventDelegationHandler) { + document.removeEventListener("click", this.eventDelegationHandler); + this.eventDelegationHandler = null; + } + } +} diff --git a/gateway/sds_gateway/static/js/constants/FileListConfig.js b/gateway/sds_gateway/static/js/constants/FileListConfig.js new file mode 100644 index 000000000..2f03900d9 --- /dev/null +++ b/gateway/sds_gateway/static/js/constants/FileListConfig.js @@ -0,0 +1,24 @@ +/** + * File / capture list page constants. + * Migrated from deprecated/file-list.js (CONFIG). + */ +window.FileListConfig = { + DEBOUNCE_DELAY: 500, + DEFAULT_SORT_BY: "created_at", + DEFAULT_SORT_ORDER: "desc", + MIN_LOADING_TIME: 500, + ELEMENT_IDS: { + SEARCH_INPUT: "search-input", + START_DATE: "start_date", + END_DATE: "end_date", + CENTER_FREQ_MIN: "centerFreqMinInput", + CENTER_FREQ_MAX: "centerFreqMaxInput", + APPLY_FILTERS: "apply-filters-btn", + CLEAR_FILTERS: "clear-filters-btn", + ITEMS_PER_PAGE: "items-per-page", + }, +}; + +if (typeof module !== "undefined" && module.exports) { + module.exports = { FileListConfig: window.FileListConfig }; +} diff --git a/gateway/sds_gateway/static/js/core/BrowserSupport.js b/gateway/sds_gateway/static/js/core/BrowserSupport.js new file mode 100644 index 000000000..ba00e6d43 --- /dev/null +++ b/gateway/sds_gateway/static/js/core/BrowserSupport.js @@ -0,0 +1,77 @@ +/** + * Browser feature checks for gateway UI. + * Merged from deprecated/files-ui.js (BrowserCompatibility) and + * deprecated/file-manager.js (checkBrowserSupport). + */ +const BrowserSupport = { + checkRequiredFeatures() { + const requiredFeatures = { + "DOM API": "document" in window && "addEventListener" in document, + "Console API": "console" in window && "log" in console, + Map: "Map" in window, + Set: "Set" in window, + "Template Literals": (() => { + try { + const test = `test${1}`; + return test === "test1"; + } catch { + return false; + } + })(), + }; + + const missingFeatures = Object.entries(requiredFeatures) + .filter(([name, supported]) => !supported) + .map(([name]) => name); + + if (missingFeatures.length > 0) { + console.warn("Missing browser features:", missingFeatures); + return false; + } + + return true; + }, + + checkBootstrapSupport() { + return ( + "bootstrap" in window || + typeof bootstrap !== "undefined" || + document.querySelector("[data-bs-toggle]") !== null + ); + }, + + /** + * File manager / upload oriented checks. + * @returns {boolean} + */ + checkBrowserSupport() { + const requiredFeatures = { + "File API": "File" in window, + FileReader: "FileReader" in window, + FormData: "FormData" in window, + "Fetch API": "fetch" in window, + Promise: "Promise" in window, + Map: "Map" in window, + Set: "Set" in window, + }; + + const missingFeatures = Object.entries(requiredFeatures) + .filter(([name, supported]) => !supported) + .map(([name]) => name); + + if (missingFeatures.length > 0) { + console.warn("Missing browser features:", missingFeatures); + return false; + } + + return true; + }, +}; + +if (typeof window !== "undefined") { + window.BrowserSupport = BrowserSupport; +} + +if (typeof module !== "undefined" && module.exports) { + module.exports = { BrowserSupport }; +} diff --git a/gateway/sds_gateway/static/js/core/ComponentUtils.js b/gateway/sds_gateway/static/js/core/ComponentUtils.js new file mode 100644 index 000000000..54ad149a9 --- /dev/null +++ b/gateway/sds_gateway/static/js/core/ComponentUtils.js @@ -0,0 +1,33 @@ +/** + * Legacy HTML escaping and format delegates (prefer Django fragments long-term). + * Migrated from deprecated/components.js. + * Depends on window.FormatUtils (load FormatUtils.js first). + */ +const ComponentUtils = { + escapeHtml(text) { + if (!text) return ""; + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + }, + + formatDate(dateString) { + return window.FormatUtils.formatDate(dateString); + }, + + formatDateForModal(dateString) { + return window.FormatUtils.formatDateForModal(dateString); + }, + + formatDateSimple(dateString) { + return window.FormatUtils.formatDateSimple(dateString); + }, +}; + +if (typeof window !== "undefined") { + window.ComponentUtils = ComponentUtils; +} + +if (typeof module !== "undefined" && module.exports) { + module.exports = { ComponentUtils }; +} diff --git a/gateway/sds_gateway/static/js/core/DropdownUtils.js b/gateway/sds_gateway/static/js/core/DropdownUtils.js new file mode 100644 index 000000000..b91f6a122 --- /dev/null +++ b/gateway/sds_gateway/static/js/core/DropdownUtils.js @@ -0,0 +1,52 @@ +/** + * Bootstrap dropdown initialization for icon action menus (body container). + * Migrated from deprecated/file-list.js. + */ +const DropdownUtils = { + /** + * @param {ParentNode} [root] + */ + initIconDropdowns(root = document) { + const dropdownButtons = root.querySelectorAll(".btn-icon-dropdown"); + + if (dropdownButtons.length === 0) { + return; + } + + for (const toggle of dropdownButtons) { + if (toggle._dropdown) { + continue; + } + + toggle._dropdown = new bootstrap.Dropdown(toggle, { + container: "body", + boundary: "viewport", + popperConfig: { + modifiers: [ + { + name: "preventOverflow", + options: { + boundary: "viewport", + }, + }, + ], + }, + }); + + toggle.addEventListener("show.bs.dropdown", () => { + const dropdownMenu = toggle.nextElementSibling; + if (dropdownMenu?.classList.contains("dropdown-menu")) { + document.body.appendChild(dropdownMenu); + } + }); + } + }, +}; + +if (typeof window !== "undefined") { + window.DropdownUtils = DropdownUtils; +} + +if (typeof module !== "undefined" && module.exports) { + module.exports = { DropdownUtils }; +} diff --git a/gateway/sds_gateway/static/js/core/FormatUtils.js b/gateway/sds_gateway/static/js/core/FormatUtils.js new file mode 100644 index 000000000..4b1e5db23 --- /dev/null +++ b/gateway/sds_gateway/static/js/core/FormatUtils.js @@ -0,0 +1,109 @@ +/** + * Date and display formatting helpers. + * Migrated from deprecated/components.js (ComponentUtils). + */ +const FormatUtils = { + formatDate(dateString) { + if (!dateString) return "
-
"; + + let date; + + // Try to parse the date string + if (typeof dateString === "string") { + // Handle different date formats + if (dateString.includes("T")) { + // ISO format: 2023-12-25T14:30:45.123Z + date = new Date(dateString); + } else if (dateString.includes("/") && dateString.includes(":")) { + // Already formatted: 12/25/2023 2:30:45 PM + date = new Date(dateString); + } else { + // Try to parse as-is + date = new Date(dateString); + } + } else { + date = new Date(dateString); + } + + if (!date || Number.isNaN(date.getTime())) { + return "
-
"; + } + + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const year = date.getFullYear(); + const hours = date.getHours(); + const minutes = String(date.getMinutes()).padStart(2, "0"); + const seconds = String(date.getSeconds()).padStart(2, "0"); + const ampm = hours >= 12 ? "PM" : "AM"; + const displayHours = hours % 12 || 12; + + return `
${month}/${day}/${year}
${displayHours}:${minutes}:${seconds} ${ampm}`; + }, + + /** + * Format date for modal display in the same style as dataset table + * @param {string} dateString - ISO date string + * @returns {string} Formatted date HTML + */ + formatDateForModal(dateString) { + if (!dateString || dateString === "None") { + return "N/A"; + } + + try { + const date = new Date(dateString); + if (Number.isNaN(date.getTime())) { + return "N/A"; + } + + // Format date as YYYY-MM-DD + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const dateFormatted = `${year}-${month}-${day}`; + + // Format time as HH:MM:SS T + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + const seconds = String(date.getSeconds()).padStart(2, "0"); + const timezone = date + .toLocaleTimeString("en-US", { timeZoneName: "short" }) + .split(" ")[1]; + const timeFormatted = `${hours}:${minutes}:${seconds} ${timezone}`; + + return `${dateFormatted}${timeFormatted}`; + } catch (error) { + console.error("Error formatting capture date:", error); + return "N/A"; + } + }, + + /** + * Formats date for display (simple version) + * @param {string} dateString - ISO date string + * @returns {string} Formatted date + */ + formatDateSimple(dateString) { + try { + const date = new Date(dateString); + return date.toString() !== "Invalid Date" + ? date.toLocaleDateString("en-US", { + month: "2-digit", + day: "2-digit", + year: "numeric", + }) + : ""; + } catch (e) { + return ""; + } + }, +}; + +if (typeof window !== "undefined") { + window.FormatUtils = FormatUtils; +} +if (typeof module !== "undefined" && module.exports) { + module.exports = { FormatUtils }; +} + diff --git a/gateway/sds_gateway/static/js/core/GatewayChrome.js b/gateway/sds_gateway/static/js/core/GatewayChrome.js new file mode 100644 index 000000000..e39ea3acc --- /dev/null +++ b/gateway/sds_gateway/static/js/core/GatewayChrome.js @@ -0,0 +1,59 @@ +/** + * Global styles and visualization triggers. + * Migrated from deprecated/components.js (tail section). + */ +const style = document.createElement("style"); +style.textContent = ` + .edit-name-btn:hover i, + .save-name-btn:hover i { + color: white !important; + } + + /* Hide native clear button in Chrome */ + input[type="search"]::-webkit-search-cancel-button { + -webkit-appearance: none; + display: none; + } +`; +document.head.appendChild(style); + +document.addEventListener("DOMContentLoaded", () => { + if (!window.components) { + const domUtils = window.DOMUtils ? new window.DOMUtils() : null; + window.components = { + showError(message) { + if (domUtils) { + domUtils.showAlert(message, "error"); + } else { + console.error(message); + } + }, + showSuccess(message) { + if (domUtils) { + domUtils.showAlert(message, "success"); + } else { + console.log(message); + } + }, + }; + } + + if (window.VisualizationModal) { + window.visualizationModalInstance = new window.VisualizationModal(); + } + + document.addEventListener("click", (e) => { + if (e.target.closest(".visualization-trigger-btn")) { + const button = e.target.closest(".visualization-trigger-btn"); + const captureUuid = button.getAttribute("data-capture-uuid"); + const captureType = button.getAttribute("data-capture-type"); + + if (captureUuid && captureType && window.visualizationModalInstance) { + window.visualizationModalInstance.openWithCaptureData( + captureUuid, + captureType, + ); + } + } + }); +}); diff --git a/gateway/sds_gateway/static/js/core/ModalManager.js b/gateway/sds_gateway/static/js/core/ModalManager.js new file mode 100644 index 000000000..4daf0ecd6 --- /dev/null +++ b/gateway/sds_gateway/static/js/core/ModalManager.js @@ -0,0 +1,969 @@ +/** + * Capture detail modal and related API calls. + * Migrated from deprecated/components.js. + */ + +class ModalManager { + constructor(config) { + this.modalId = config.modalId; + this.modal = document.getElementById(this.modalId); + this.modalTitle = this.modal?.querySelector(".modal-title"); + this.modalBody = this.modal?.querySelector(".modal-body"); + + if (this.modal && window.bootstrap) { + this.bootstrapModal = new bootstrap.Modal(this.modal); + } + } + + show(title, content) { + if (!this.modal) return; + + if (this.modalTitle) { + this.modalTitle.textContent = title; + } + + if (this.modalBody) { + this.modalBody.innerHTML = content; + } + + if (this.bootstrapModal) { + this.bootstrapModal.show(); + } + } + + hide() { + if (this.bootstrapModal) { + this.bootstrapModal.hide(); + } + } + + openCaptureModal(linkElement) { + if (!linkElement) return; + + try { + // Reset visualize button to hidden state + const visualizeBtn = document.getElementById("visualize-btn"); + if (visualizeBtn) { + visualizeBtn.classList.add("d-none"); + } + + // Get all data attributes from the link with sanitization + const data = { + uuid: ComponentUtils.escapeHtml( + linkElement.getAttribute("data-uuid") || "", + ), + name: ComponentUtils.escapeHtml( + linkElement.getAttribute("data-name") || "", + ), + channel: ComponentUtils.escapeHtml( + linkElement.getAttribute("data-channel") || "", + ), + scanGroup: ComponentUtils.escapeHtml( + linkElement.getAttribute("data-scan-group") || "", + ), + captureType: ComponentUtils.escapeHtml( + linkElement.getAttribute("data-capture-type") || "", + ), + topLevelDir: ComponentUtils.escapeHtml( + linkElement.getAttribute("data-top-level-dir") || "", + ), + owner: ComponentUtils.escapeHtml( + linkElement.getAttribute("data-owner") || "", + ), + origin: ComponentUtils.escapeHtml( + linkElement.getAttribute("data-origin") || "", + ), + dataset: ComponentUtils.escapeHtml( + linkElement.getAttribute("data-dataset") || "", + ), + createdAt: linkElement.getAttribute("data-created-at") || "", + updatedAt: linkElement.getAttribute("data-updated-at") || "", + isPublic: linkElement.getAttribute("data-is-public") || "", + centerFrequencyGhz: + linkElement.getAttribute("data-center-frequency-ghz") || "", + isMultiChannel: linkElement.getAttribute("data-is-multi-channel") || "", + channels: linkElement.getAttribute("data-channels") || "", + }; + + // Parse owner field safely + const ownerDisplay = data.owner + ? data.owner.split("'").find((part) => part.includes("@")) || "N/A" + : "N/A"; + + // Check if this is a composite capture + const isComposite = + data.isMultiChannel === "True" || data.isMultiChannel === "true"; + + let modalContent = ` +
+
+
+ Basic Information +
+
+
+ +
+ + + + +
+
Click the edit button to modify the capture name
+
+
+
+

+ Capture Type: + ${data.captureType || "N/A"} +

+

+ Origin: + ${data.origin || "N/A"} +

+
+
+

+ Owner: + ${ownerDisplay} +

+
+
+ `; + + // Handle composite vs single capture display + if (isComposite) { + modalContent += ` +
+ Channels: + ${data.channel || "N/A"} +
+ `; + } else { + modalContent += ` +
+ Channel: + ${data.channel || "N/A"} +
+ `; + } + + modalContent += ` +
+
+
+
+ Technical Details +
+
+
+
+

+ Scan Group: + ${data.scanGroup || "N/A"} +

+

+ Dataset: + ${data.dataset || "N/A"} +

+

+ Is Public: + ${data.isPublic === "True" ? "Yes" : "No"} +

+
+
+

+ Top Level Directory: + ${data.topLevelDir || "N/A"} +

+

+ Center Frequency: + + ${data.centerFrequencyGhz && data.centerFrequencyGhz !== "None" ? `${Number.parseFloat(data.centerFrequencyGhz).toFixed(3)} GHz` : "N/A"} + +

+
+
+
+
+
+
+ Timestamps +
+
+
+
+

+ Created At: +
+ + ${ComponentUtils.formatDateForModal(data.createdAt)} + +

+
+
+

+ Updated At: +
+ + ${ComponentUtils.formatDateForModal(data.updatedAt)} + +

+
+
+
+ +
+
+
+ Loading files... +
+ Loading files... +
+
+ `; + + // Add composite-specific information if available + if (isComposite && data.channels) { + try { + // Convert Python dict syntax to valid JSON + let channelsData; + if (typeof data.channels === "string") { + // Handle Python dict syntax: {'key': 'value'} -> {"key": "value"} + const pythonDict = data.channels + .replace(/'/g, '"') // Replace single quotes with double quotes + .replace(/True/g, "true") // Replace Python True with JSON true + .replace(/False/g, "false") // Replace Python False with JSON false + .replace(/None/g, "null"); // Replace Python None with JSON null + + channelsData = JSON.parse(pythonDict); + } else { + channelsData = data.channels; + } + + if (Array.isArray(channelsData) && channelsData.length > 0) { + modalContent += ` +
+
Channel Details
+
+ `; + + for (let i = 0; i < channelsData.length; i++) { + const channel = channelsData[i]; + const channelId = `channel-${i}`; + + // Format channel metadata as key-value pairs + let metadataDisplay = "N/A"; + if ( + channel.channel_metadata && + typeof channel.channel_metadata === "object" + ) { + const metadata = channel.channel_metadata; + const metadataItems = []; + + // Helper function to format values dynamically + const formatValue = (value, fieldName = "") => { + if (value === null || value === undefined) { + return "N/A"; + } + + if (typeof value === "boolean") { + return value ? "Yes" : "No"; + } + + // Handle string representations of booleans + if (typeof value === "string") { + if (value.toLowerCase() === "true") { + return "Yes"; + } + if (value.toLowerCase() === "false") { + return "No"; + } + } + + if (typeof value === "number") { + const absValue = Math.abs(value); + const valueStr = value.toString(); + const timeIndicators = [ + "computer_time", + "start_bound", + "end_bound", + "init_utc_timestamp", + ]; + // Only format as timestamp if the field name contains "time" + if ( + timeIndicators.includes(fieldName.toLowerCase()) && + valueStr.length >= 10 && + valueStr.length <= 13 + ) { + // Convert to milliseconds if it's in seconds + const timestamp = + valueStr.length === 10 ? value * 1000 : value; + return new Date(timestamp).toLocaleString(); + } + + // Only format for Giga (1e9) and Mega (1e6) ranges + if (absValue >= 1e9) { + return `${(value / 1e9).toFixed(3)} GHz`; + } + if (absValue >= 1e6) { + return `${(value / 1e6).toFixed(1)} MHz`; + } + return value.toString(); + } + + if (Array.isArray(value)) { + return value + .map((item) => formatValue(item, fieldName)) + .join(", "); + } + + if (typeof value === "object") { + return JSON.stringify(value); + } + + return String(value); + }; + + // Helper function to format field names + const formatFieldName = (fieldName) => { + return fieldName + .replace(/_/g, " ") + .replace(/\b\w/g, (l) => l.toUpperCase()); + }; + + // Loop through all metadata fields + if (Object.keys(metadata).length > 0) { + for (const [key, value] of Object.entries(metadata)) { + if (value !== undefined && value !== null) { + const formattedValue = formatValue(value, key); + const formattedKey = formatFieldName(key); + metadataItems.push( + `${formattedKey}: ${formattedValue}`, + ); + } + } + } else { + metadataItems.push("No metadata available"); + } + + if (metadataItems.length > 0) { + metadataDisplay = metadataItems.join("
"); + } + } + + modalContent += ` +
+

+ +

+
+
+
+ ${metadataDisplay} +
+
+
+
+ `; + } + + modalContent += ` +
+
+ `; + } + } catch (e) { + console.error("Could not parse channels data for modal:", e); + console.error( + "Raw channels data that failed to parse:", + data.channels, + ); + + // Show a fallback message in the modal + modalContent += ` +
+
Channel Details
+
+ + Unable to display channel details due to data format issues. +
Raw data: ${ComponentUtils.escapeHtml(String(data.channels).substring(0, 100))}... +
+
+ `; + } + } + + const title = data.name + ? data.name + : data.topLevelDir || "Unnamed Capture"; + this.show(title, modalContent); + + // Store capture data for later use + this.currentCaptureData = data; + + // Setup name editing handlers after modal content is loaded + this.setupNameEditingHandlers(); + + // Setup visualize button for Digital RF captures + this.setupVisualizeButton(data); + + // Load and display files for this capture + this.loadCaptureFiles(data.uuid); + } catch (error) { + console.error("Error opening capture modal:", error); + this.show("Error", "Error displaying capture details"); + } + } + + /** + * Setup visualize button for Digital RF captures + */ + setupVisualizeButton(captureData) { + const visualizeBtn = document.getElementById("visualize-btn"); + if (!visualizeBtn) return; + + // Show button only for Digital RF captures + if (captureData.captureType === "drf") { + visualizeBtn.classList.remove("d-none"); + + // Set up click handler to open visualization modal + visualizeBtn.onclick = () => { + // Use the VisualizationModal instance to open with capture data + if (window.visualizationModalInstance) { + window.visualizationModalInstance.openWithCaptureData( + captureData.uuid, + captureData.captureType, + ); + } + }; + } else { + visualizeBtn.classList.add("d-none"); + } + } + + /** + * Setup handlers for name editing functionality + */ + setupNameEditingHandlers() { + const nameInput = document.getElementById("capture-name-input"); + const editBtn = document.getElementById("edit-name-btn"); + const saveBtn = document.getElementById("save-name-btn"); + const cancelBtn = document.getElementById("cancel-name-btn"); + + if (!nameInput || !editBtn || !saveBtn || !cancelBtn) return; + + // Initially disable the input + nameInput.disabled = true; + let originalName = nameInput.value; + let isEditing = false; + + const startEditing = () => { + nameInput.disabled = false; + nameInput.focus(); + nameInput.select(); + editBtn.classList.add("d-none"); + saveBtn.classList.remove("d-none"); + cancelBtn.classList.remove("d-none"); + isEditing = true; + }; + + const stopEditing = () => { + nameInput.disabled = true; + editBtn.classList.remove("d-none"); + saveBtn.classList.add("d-none"); + cancelBtn.classList.add("d-none"); + isEditing = false; + }; + + const cancelEditing = () => { + nameInput.value = originalName; + stopEditing(); + }; + + // Edit button handler + editBtn.addEventListener("click", () => { + if (!isEditing) { + startEditing(); + } + }); + + // Cancel button handler + cancelBtn.addEventListener("click", cancelEditing); + + // Save button handler + saveBtn.addEventListener("click", async () => { + const newName = nameInput.value.trim(); + const uuid = nameInput.getAttribute("data-uuid"); + + if (!uuid) { + console.error("No UUID found for capture"); + return; + } + + // Disable buttons during save + editBtn.disabled = true; + saveBtn.disabled = true; + cancelBtn.disabled = true; + saveBtn.innerHTML = + ''; + + try { + await this.updateCaptureName(uuid, newName); + + // Success - update UI + originalName = newName; + stopEditing(); + + // Update the table display + this.updateTableNameDisplay(uuid, newName); + + // Update modal title using stored capture data + if (this.modalTitle && this.currentCaptureData) { + this.currentCaptureData.name = newName; + this.modalTitle.textContent = + newName || this.currentCaptureData.topLevelDir || "Unnamed Capture"; + } + + // Show success message + this.showSuccessMessage("Capture name updated successfully!"); + } catch (error) { + console.error("Error updating capture name:", error); + this.showErrorMessage( + "Failed to update capture name. Please try again.", + ); + // Revert to original name + nameInput.value = originalName; + } finally { + // Re-enable buttons and restore icons + editBtn.disabled = false; + saveBtn.disabled = false; + cancelBtn.disabled = false; + saveBtn.innerHTML = ''; + } + }); + + // Handle Enter key to save + nameInput.addEventListener("keypress", (e) => { + if (e.key === "Enter" && !nameInput.disabled) { + saveBtn.click(); + } + }); + + // Handle Escape key to cancel + nameInput.addEventListener("keydown", (e) => { + if (e.key === "Escape" && !nameInput.disabled) { + cancelEditing(); + } + }); + } + + /** + * Update capture name via API + */ + async updateCaptureName(uuid, newName) { + const response = await fetch(`/api/v1/assets/captures/${uuid}/`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": this.getCSRFToken(), + }, + body: JSON.stringify({ name: newName }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || "Failed to update capture name"); + } + + return response.json(); + } + + /** + * Update the table display with the new name + */ + updateTableNameDisplay(uuid, newName) { + // Find all elements with this UUID and update their display + const captureLinks = document.querySelectorAll(`[data-uuid="${uuid}"]`); + + for (const link of captureLinks) { + // Update data attribute + link.dataset.name = newName; + + // Update display text if it's a capture link + if (link.classList.contains("capture-link")) { + link.textContent = newName || "Unnamed Capture"; + link.setAttribute( + "aria-label", + `View details for capture ${newName || uuid}`, + ); + link.setAttribute("title", `View capture details: ${newName || uuid}`); + } + } + } + + /** + * Clear existing alert messages from the modal + */ + clearAlerts() { + const modalBody = document.getElementById("capture-modal-body"); + if (modalBody) { + const existingAlerts = modalBody.querySelectorAll(".alert"); + for (const alert of existingAlerts) { + alert.remove(); + } + } + } + + /** + * Show success message + */ + showSuccessMessage(message) { + // Clear existing alerts first + this.clearAlerts(); + + // Create a temporary alert + const alert = document.createElement("div"); + alert.className = "alert alert-success alert-dismissible fade show"; + alert.innerHTML = ` + ${message} + + `; + + // Insert at the top of the modal body + const modalBody = document.getElementById("capture-modal-body"); + if (modalBody) { + modalBody.insertBefore(alert, modalBody.firstChild); + + // Auto-dismiss after 3 seconds + setTimeout(() => { + if (alert.parentNode) { + alert.remove(); + } + }, 3000); + } + } + + /** + * Show error message + */ + showErrorMessage(message) { + // Clear existing alerts first + this.clearAlerts(); + + // Create a temporary alert + const alert = document.createElement("div"); + alert.className = "alert alert-danger alert-dismissible fade show"; + alert.innerHTML = ` + ${message} + + `; + + // Insert at the top of the modal body + const modalBody = document.getElementById("capture-modal-body"); + if (modalBody) { + modalBody.insertBefore(alert, modalBody.firstChild); + + // Auto-dismiss after 5 seconds + setTimeout(() => { + if (alert.parentNode) { + alert.remove(); + } + }, 5000); + } + } + + /** + * Load and display files associated with the capture + */ + async loadCaptureFiles(captureUuid) { + try { + const response = await fetch(`/api/v1/assets/captures/${captureUuid}/`, { + method: "GET", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": this.getCSRFToken(), + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const captureData = await response.json(); + console.log("Raw capture data:", captureData); + + const files = captureData.files || []; + const filesCount = captureData.files_count || 0; + const totalSize = captureData.total_file_size || 0; + + console.log("Files info:", { + filesCount, + totalSize, + numFiles: files.length, + }); + + // Update files section with simple summary + const filesSection = document.getElementById("files-section-placeholder"); + if (filesSection) { + filesSection.innerHTML = ` +
+
+
+ Files Summary +
+
+
+

+ Number of Files: + ${filesCount} +

+
+
+

+ Total Size: + ${window.DOMUtils.formatFileSize(totalSize)} +

+
+
+ `; + } + } catch (error) { + console.error("Error loading capture files:", error); + const filesSection = document.getElementById("files-section-placeholder"); + if (filesSection) { + filesSection.innerHTML = ` +
+ + Error loading files information +
+ `; + } + } + } + + /** + * Format file metadata for display + */ + formatFileMetadata(file) { + const metadata = []; + + // Primary file information - most useful for users + if (file.size) { + metadata.push( + `Size: ${window.DOMUtils.formatFileSize(file.size)} (${file.size.toLocaleString()} bytes)`, + ); + } + + if (file.media_type) { + metadata.push( + `Media Type: ${ComponentUtils.escapeHtml(file.media_type)}`, + ); + } + + if (file.created_at) { + metadata.push(`Created: ${file.created_at}`); + } + + if (file.updated_at) { + metadata.push(`Updated: ${file.updated_at}`); + } + + // File properties and attributes + if (file.name) { + metadata.push( + `Name: ${ComponentUtils.escapeHtml(file.name)}`, + ); + } + + if (file.directory || file.relative_path) { + metadata.push( + `Directory: ${ComponentUtils.escapeHtml(file.directory || file.relative_path)}`, + ); + } + + // Removed permissions display + // if (file.permissions) { + // metadata.push(`Permissions: ${ComponentUtils.escapeHtml(file.permissions)}`); + // } + + if (file.owner?.username) { + metadata.push( + `Owner: ${ComponentUtils.escapeHtml(file.owner.username)}`, + ); + } + + if (file.expiration_date) { + metadata.push( + `Expires: ${new Date(file.expiration_date).toLocaleDateString()}`, + ); + } + + if (file.bucket_name) { + metadata.push( + `Storage Bucket: ${ComponentUtils.escapeHtml(file.bucket_name)}`, + ); + } + + // Removed checksum display + // if (file.sum_blake3) { + // metadata.push(`Checksum: ${ComponentUtils.escapeHtml(file.sum_blake3)}`); + // } + + // Associated resources + // TODO: Refactor this to handle multiple associations + if (file.capture?.name) { + metadata.push( + `Associated Capture: ${ComponentUtils.escapeHtml(file.capture.name)}`, + ); + } + + if (file.dataset?.name) { + metadata.push( + `Associated Dataset: ${ComponentUtils.escapeHtml(file.dataset.name)}`, + ); + } + + // Additional metadata if available + if (file.metadata && typeof file.metadata === "object") { + for (const [key, value] of Object.entries(file.metadata)) { + if (value !== null && value !== undefined) { + const formattedKey = key + .replace(/_/g, " ") + .replace(/\b\w/g, (l) => l.toUpperCase()); + let formattedValue; + + // Format different types of values + if (typeof value === "boolean") { + formattedValue = value ? "Yes" : "No"; + } else if (typeof value === "number") { + formattedValue = value.toLocaleString(); + } else if (typeof value === "object") { + formattedValue = `${JSON.stringify(value, null, 2)}`; + } else { + formattedValue = ComponentUtils.escapeHtml(String(value)); + } + + metadata.push(`${formattedKey}: ${formattedValue}`); + } + } + } + + if (metadata.length === 0) { + return '

No metadata available for this file.

'; + } + + return `
${metadata.join("
")}
`; + } + + /** + * Get CSRF token for API requests + */ + getCSRFToken() { + const token = document.querySelector("[name=csrfmiddlewaretoken]"); + return token ? token.value : ""; + } + + /** + * Load and display file metadata for a specific file in the modal + */ + async loadFileMetadata(fileUuid, fileName) { + const fileMetadataSection = document.getElementById( + `file-metadata-${fileUuid}`, + ); + const metadataContent = + fileMetadataSection?.querySelector(".metadata-content"); + + if (!fileMetadataSection || !metadataContent) return; + + // Toggle visibility + if (fileMetadataSection.style.display === "none") { + fileMetadataSection.style.display = "block"; + + // Check if metadata is already loaded + if (metadataContent.innerHTML.includes("Click to load metadata...")) { + // Show loading state + metadataContent.innerHTML = ` +
+
+ Loading... +
+ Loading metadata... +
+ `; + + try { + const response = await fetch(`/api/v1/assets/files/${fileUuid}/`, { + method: "GET", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": this.getCSRFToken(), + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const fileData = await response.json(); + + // Format and display the metadata + const formattedMetadata = this.formatFileMetadata(fileData); + metadataContent.innerHTML = formattedMetadata; + } catch (error) { + console.error("Error loading file metadata:", error); + metadataContent.innerHTML = ` +
+ + Failed to load metadata for ${ComponentUtils.escapeHtml(fileName)}. +
Error: ${ComponentUtils.escapeHtml(error.message)} +
+ `; + } + } + } else { + fileMetadataSection.style.display = "none"; + } + } +} + +if (typeof window !== "undefined") { + window.ModalManager = ModalManager; +} +if (typeof module !== "undefined" && module.exports) { + module.exports = { ModalManager }; +} + diff --git a/gateway/sds_gateway/static/js/core/NotificationUtils.js b/gateway/sds_gateway/static/js/core/NotificationUtils.js new file mode 100644 index 000000000..a4f6c7619 --- /dev/null +++ b/gateway/sds_gateway/static/js/core/NotificationUtils.js @@ -0,0 +1,87 @@ +/** + * User notifications with de-duplication (files UI, uploads, etc.). + * Migrated from deprecated/files-ui.js (ErrorHandler). + */ +const NotificationUtils = { + shownMessages: new Set(), + + showError(message, context = "", error = null) { + const messageKey = `${context}:${message}`; + + if (this.shownMessages.has(messageKey)) { + if (error) { + console.error(`FilesUI Error [${context}]:`, { + message: error.message, + stack: error.stack, + userMessage: message, + timestamp: new Date().toISOString(), + userAgent: navigator.userAgent, + }); + } else { + console.warn(`FilesUI Warning [${context}]:`, message); + } + return; + } + + this.shownMessages.add(messageKey); + + if (error) { + console.error(`FilesUI Error [${context}]:`, { + message: error.message, + stack: error.stack, + userMessage: message, + timestamp: new Date().toISOString(), + userAgent: navigator.userAgent, + }); + } else { + console.warn(`FilesUI Warning [${context}]:`, message); + } + + if (window.components?.showError) { + window.components.showError(message); + } else { + this.showFallbackError(message); + } + }, + + showFallbackError(message) { + const errorContainer = document.querySelector( + ".error-container, .alert-container, .files-container", + ); + if (errorContainer) { + const errorDiv = document.createElement("div"); + errorDiv.className = "alert alert-danger alert-dismissible fade show"; + errorDiv.innerHTML = ` + ${message} + + `; + errorContainer.insertBefore(errorDiv, errorContainer.firstChild); + } + }, + + getUserFriendlyErrorMessage(error, context = "") { + void context; + if (!error) return "An unexpected error occurred"; + + if (error.name === "TypeError" && error.message.includes("Cannot read")) { + return "Configuration error: Some components are not properly loaded"; + } + if (error.name === "ReferenceError") { + return "Component error: Required functionality is not available"; + } + + return error.message || "An unexpected error occurred"; + }, +}; + +/** @deprecated Use NotificationUtils */ +const ErrorHandler = NotificationUtils; + +if (typeof window !== "undefined") { + window.NotificationUtils = NotificationUtils; + window.ErrorHandler = ErrorHandler; +} + +if (typeof module !== "undefined" && module.exports) { + module.exports = { NotificationUtils, ErrorHandler }; +} diff --git a/gateway/sds_gateway/static/js/core/PaginationManager.js b/gateway/sds_gateway/static/js/core/PaginationManager.js new file mode 100644 index 000000000..22c07fe9a --- /dev/null +++ b/gateway/sds_gateway/static/js/core/PaginationManager.js @@ -0,0 +1,78 @@ +/** + * Pagination controls. + * Migrated from deprecated/components.js. + */ + +class PaginationManager { + constructor(config) { + this.containerId = config.containerId; + this.container = document.getElementById(this.containerId); + this.onPageChange = config.onPageChange; + } + + update(pagination) { + if (!this.container || !pagination) return; + + this.container.innerHTML = ""; + + if (pagination.num_pages <= 1) return; + + const ul = document.createElement("ul"); + ul.className = "pagination justify-content-center"; + + // Previous button + if (pagination.has_previous) { + ul.innerHTML += ` +
  • + + + +
  • + `; + } + + // Page numbers + const startPage = Math.max(1, pagination.number - 2); + const endPage = Math.min(pagination.num_pages, pagination.number + 2); + + for (let i = startPage; i <= endPage; i++) { + ul.innerHTML += ` +
  • + ${i} +
  • + `; + } + + // Next button + if (pagination.has_next) { + ul.innerHTML += ` +
  • + + + +
  • + `; + } + + this.container.appendChild(ul); + + // Add click handlers + const links = ul.querySelectorAll("a.page-link"); + for (const link of links) { + link.addEventListener("click", (e) => { + e.preventDefault(); + const page = Number.parseInt(e.target.dataset.page); + if (page && this.onPageChange) { + this.onPageChange(page); + } + }); + } + } + +if (typeof window !== "undefined") { + window.PaginationManager = PaginationManager; +} +if (typeof module !== "undefined" && module.exports) { + module.exports = { PaginationManager }; +} + diff --git a/gateway/sds_gateway/static/js/core/TableManager.js b/gateway/sds_gateway/static/js/core/TableManager.js new file mode 100644 index 000000000..f8afde55c --- /dev/null +++ b/gateway/sds_gateway/static/js/core/TableManager.js @@ -0,0 +1,167 @@ +/** + * Generic table operations. + * Migrated from deprecated/components.js. + */ + +class TableManager { + constructor(config) { + this.tableId = config.tableId; + this.table = document.getElementById(this.tableId); + this.tbody = this.table?.querySelector("tbody"); + this.loadingIndicator = document.getElementById(config.loadingIndicatorId); + this.paginationContainer = document.getElementById( + config.paginationContainerId, + ); + this.currentSort = { by: "created_at", order: "desc" }; + this.onRowClick = config.onRowClick; + + this.initializeSorting(); + } + + initializeSorting() { + if (!this.table) return; + + const sortableHeaders = this.table.querySelectorAll("th.sortable"); + for (const header of sortableHeaders) { + header.style.cursor = "pointer"; + header.addEventListener("click", () => { + this.handleSort(header); + }); + } + + this.updateSortIcons(); + } + + handleSort(header) { + const field = header.getAttribute("data-sort"); + const currentSort = new URLSearchParams(window.location.search).get( + "sort_by", + ); + const currentOrder = + new URLSearchParams(window.location.search).get("sort_order") || "desc"; + + let newOrder = "asc"; + if (currentSort === field && currentOrder === "asc") { + newOrder = "desc"; + } + + this.currentSort = { by: field, order: newOrder }; + this.updateURL({ sort_by: field, sort_order: newOrder, page: "1" }); + } + + updateSortIcons() { + const urlParams = new URLSearchParams(window.location.search); + const currentSort = urlParams.get("sort_by") || "created_at"; + const currentOrder = urlParams.get("sort_order") || "desc"; + + const sortableHeaders = this.table?.querySelectorAll("th.sortable"); + for (const header of sortableHeaders || []) { + const icon = header.querySelector(".sort-icon"); + const field = header.getAttribute("data-sort"); + + if (icon) { + // Reset classes + icon.className = "bi sort-icon"; + + if (field === currentSort) { + // Add active class and appropriate direction icon + icon.classList.add("active"); + icon.classList.add( + currentOrder === "asc" ? "bi-caret-up-fill" : "bi-caret-down-fill", + ); + } else { + // Inactive columns get default down arrow + icon.classList.add("bi-caret-down-fill"); + } + } + } + } + + showLoading() { + if (this.loadingIndicator) { + this.loadingIndicator.classList.remove("d-none"); + } + } + + hideLoading() { + if (this.loadingIndicator) { + this.loadingIndicator.classList.add("d-none"); + } + } + + showError(message) { + const tbody = document.querySelector("tbody"); + if (tbody) { + tbody.innerHTML = ` + + + ${ComponentUtils.escapeHtml(message)} +
    Try refreshing the page or contact support if the problem persists. + + + `; + } + } + + updateTable(data, hasResults) { + if (!this.tbody) return; + + if (!hasResults || !data || data.length === 0) { + this.tbody.innerHTML = ` + + No results found. + + `; + return; + } + + this.tbody.innerHTML = data + .map((item, index) => this.renderRow(item, index)) + .join(""); + this.attachRowClickHandlers(); + } + + renderRow(item, index) { + // This should be overridden by specific implementations + return `Override renderRow method`; + } + + attachRowClickHandlers() { + if (!this.onRowClick) return; + + const rows = this.tbody?.querySelectorAll('tr[data-clickable="true"]'); + for (const row of rows || []) { + row.addEventListener("click", (e) => { + if ( + e.target.closest( + "button, a, .capture-select-checkbox, .capture-select-column", + ) + ) + return; // Don't trigger on buttons/links/selection + this.onRowClick(row); + }); + } + } + + updateURL(params) { + const urlParams = new URLSearchParams(window.location.search); + for (const [key, value] of Object.entries(params)) { + if (value) { + urlParams.set(key, value); + } else { + urlParams.delete(key); + } + } + + const newUrl = `${window.location.pathname}?${urlParams.toString()}`; + window.history.pushState({}, "", newUrl); + } +} + +if (typeof window !== "undefined") { + window.TableManager = TableManager; +} +if (typeof module !== "undefined" && module.exports) { + module.exports = { TableManager }; +} + diff --git a/gateway/sds_gateway/static/js/core/_frag_escape.txt b/gateway/sds_gateway/static/js/core/_frag_escape.txt new file mode 100644 index 000000000..d13b72e37 --- /dev/null +++ b/gateway/sds_gateway/static/js/core/_frag_escape.txt @@ -0,0 +1,6 @@ + escapeHtml(text) { + if (!text) return ""; + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + }, diff --git a/gateway/sds_gateway/static/js/core/_frag_format.txt b/gateway/sds_gateway/static/js/core/_frag_format.txt new file mode 100644 index 000000000..5cf251a77 --- /dev/null +++ b/gateway/sds_gateway/static/js/core/_frag_format.txt @@ -0,0 +1,95 @@ + formatDate(dateString) { + if (!dateString) return "
    -
    "; + + let date; + + // Try to parse the date string + if (typeof dateString === "string") { + // Handle different date formats + if (dateString.includes("T")) { + // ISO format: 2023-12-25T14:30:45.123Z + date = new Date(dateString); + } else if (dateString.includes("/") && dateString.includes(":")) { + // Already formatted: 12/25/2023 2:30:45 PM + date = new Date(dateString); + } else { + // Try to parse as-is + date = new Date(dateString); + } + } else { + date = new Date(dateString); + } + + if (!date || Number.isNaN(date.getTime())) { + return "
    -
    "; + } + + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const year = date.getFullYear(); + const hours = date.getHours(); + const minutes = String(date.getMinutes()).padStart(2, "0"); + const seconds = String(date.getSeconds()).padStart(2, "0"); + const ampm = hours >= 12 ? "PM" : "AM"; + const displayHours = hours % 12 || 12; + + return `
    ${month}/${day}/${year}
    ${displayHours}:${minutes}:${seconds} ${ampm}`; + }, + + /** + * Format date for modal display in the same style as dataset table + * @param {string} dateString - ISO date string + * @returns {string} Formatted date HTML + */ + formatDateForModal(dateString) { + if (!dateString || dateString === "None") { + return "N/A"; + } + + try { + const date = new Date(dateString); + if (Number.isNaN(date.getTime())) { + return "N/A"; + } + + // Format date as YYYY-MM-DD + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const dateFormatted = `${year}-${month}-${day}`; + + // Format time as HH:MM:SS T + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + const seconds = String(date.getSeconds()).padStart(2, "0"); + const timezone = date + .toLocaleTimeString("en-US", { timeZoneName: "short" }) + .split(" ")[1]; + const timeFormatted = `${hours}:${minutes}:${seconds} ${timezone}`; + + return `${dateFormatted}${timeFormatted}`; + } catch (error) { + console.error("Error formatting capture date:", error); + return "N/A"; + } + }, + + /** + * Formats date for display (simple version) + * @param {string} dateString - ISO date string + * @returns {string} Formatted date + */ + formatDateSimple(dateString) { + try { + const date = new Date(dateString); + return date.toString() !== "Invalid Date" + ? date.toLocaleDateString("en-US", { + month: "2-digit", + day: "2-digit", + year: "numeric", + }) + : ""; + } catch (e) { + return ""; + } + }, diff --git a/gateway/sds_gateway/static/js/core/_frag_modal.txt b/gateway/sds_gateway/static/js/core/_frag_modal.txt new file mode 100644 index 000000000..f20f280fa --- /dev/null +++ b/gateway/sds_gateway/static/js/core/_frag_modal.txt @@ -0,0 +1,956 @@ +class ModalManager { + constructor(config) { + this.modalId = config.modalId; + this.modal = document.getElementById(this.modalId); + this.modalTitle = this.modal?.querySelector(".modal-title"); + this.modalBody = this.modal?.querySelector(".modal-body"); + + if (this.modal && window.bootstrap) { + this.bootstrapModal = new bootstrap.Modal(this.modal); + } + } + + show(title, content) { + if (!this.modal) return; + + if (this.modalTitle) { + this.modalTitle.textContent = title; + } + + if (this.modalBody) { + this.modalBody.innerHTML = content; + } + + if (this.bootstrapModal) { + this.bootstrapModal.show(); + } + } + + hide() { + if (this.bootstrapModal) { + this.bootstrapModal.hide(); + } + } + + openCaptureModal(linkElement) { + if (!linkElement) return; + + try { + // Reset visualize button to hidden state + const visualizeBtn = document.getElementById("visualize-btn"); + if (visualizeBtn) { + visualizeBtn.classList.add("d-none"); + } + + // Get all data attributes from the link with sanitization + const data = { + uuid: ComponentUtils.escapeHtml( + linkElement.getAttribute("data-uuid") || "", + ), + name: ComponentUtils.escapeHtml( + linkElement.getAttribute("data-name") || "", + ), + channel: ComponentUtils.escapeHtml( + linkElement.getAttribute("data-channel") || "", + ), + scanGroup: ComponentUtils.escapeHtml( + linkElement.getAttribute("data-scan-group") || "", + ), + captureType: ComponentUtils.escapeHtml( + linkElement.getAttribute("data-capture-type") || "", + ), + topLevelDir: ComponentUtils.escapeHtml( + linkElement.getAttribute("data-top-level-dir") || "", + ), + owner: ComponentUtils.escapeHtml( + linkElement.getAttribute("data-owner") || "", + ), + origin: ComponentUtils.escapeHtml( + linkElement.getAttribute("data-origin") || "", + ), + dataset: ComponentUtils.escapeHtml( + linkElement.getAttribute("data-dataset") || "", + ), + createdAt: linkElement.getAttribute("data-created-at") || "", + updatedAt: linkElement.getAttribute("data-updated-at") || "", + isPublic: linkElement.getAttribute("data-is-public") || "", + centerFrequencyGhz: + linkElement.getAttribute("data-center-frequency-ghz") || "", + isMultiChannel: linkElement.getAttribute("data-is-multi-channel") || "", + channels: linkElement.getAttribute("data-channels") || "", + }; + + // Parse owner field safely + const ownerDisplay = data.owner + ? data.owner.split("'").find((part) => part.includes("@")) || "N/A" + : "N/A"; + + // Check if this is a composite capture + const isComposite = + data.isMultiChannel === "True" || data.isMultiChannel === "true"; + + let modalContent = ` +
    +
    +
    + Basic Information +
    +
    +
    + +
    + + + + +
    +
    Click the edit button to modify the capture name
    +
    +
    +
    +

    + Capture Type: + ${data.captureType || "N/A"} +

    +

    + Origin: + ${data.origin || "N/A"} +

    +
    +
    +

    + Owner: + ${ownerDisplay} +

    +
    +
    + `; + + // Handle composite vs single capture display + if (isComposite) { + modalContent += ` +
    + Channels: + ${data.channel || "N/A"} +
    + `; + } else { + modalContent += ` +
    + Channel: + ${data.channel || "N/A"} +
    + `; + } + + modalContent += ` +
    +
    +
    +
    + Technical Details +
    +
    +
    +
    +

    + Scan Group: + ${data.scanGroup || "N/A"} +

    +

    + Dataset: + ${data.dataset || "N/A"} +

    +

    + Is Public: + ${data.isPublic === "True" ? "Yes" : "No"} +

    +
    +
    +

    + Top Level Directory: + ${data.topLevelDir || "N/A"} +

    +

    + Center Frequency: + + ${data.centerFrequencyGhz && data.centerFrequencyGhz !== "None" ? `${Number.parseFloat(data.centerFrequencyGhz).toFixed(3)} GHz` : "N/A"} + +

    +
    +
    +
    +
    +
    +
    + Timestamps +
    +
    +
    +
    +

    + Created At: +
    + + ${ComponentUtils.formatDateForModal(data.createdAt)} + +

    +
    +
    +

    + Updated At: +
    + + ${ComponentUtils.formatDateForModal(data.updatedAt)} + +

    +
    +
    +
    + +
    +
    +
    + Loading files... +
    + Loading files... +
    +
    + `; + + // Add composite-specific information if available + if (isComposite && data.channels) { + try { + // Convert Python dict syntax to valid JSON + let channelsData; + if (typeof data.channels === "string") { + // Handle Python dict syntax: {'key': 'value'} -> {"key": "value"} + const pythonDict = data.channels + .replace(/'/g, '"') // Replace single quotes with double quotes + .replace(/True/g, "true") // Replace Python True with JSON true + .replace(/False/g, "false") // Replace Python False with JSON false + .replace(/None/g, "null"); // Replace Python None with JSON null + + channelsData = JSON.parse(pythonDict); + } else { + channelsData = data.channels; + } + + if (Array.isArray(channelsData) && channelsData.length > 0) { + modalContent += ` +
    +
    Channel Details
    +
    + `; + + for (let i = 0; i < channelsData.length; i++) { + const channel = channelsData[i]; + const channelId = `channel-${i}`; + + // Format channel metadata as key-value pairs + let metadataDisplay = "N/A"; + if ( + channel.channel_metadata && + typeof channel.channel_metadata === "object" + ) { + const metadata = channel.channel_metadata; + const metadataItems = []; + + // Helper function to format values dynamically + const formatValue = (value, fieldName = "") => { + if (value === null || value === undefined) { + return "N/A"; + } + + if (typeof value === "boolean") { + return value ? "Yes" : "No"; + } + + // Handle string representations of booleans + if (typeof value === "string") { + if (value.toLowerCase() === "true") { + return "Yes"; + } + if (value.toLowerCase() === "false") { + return "No"; + } + } + + if (typeof value === "number") { + const absValue = Math.abs(value); + const valueStr = value.toString(); + const timeIndicators = [ + "computer_time", + "start_bound", + "end_bound", + "init_utc_timestamp", + ]; + // Only format as timestamp if the field name contains "time" + if ( + timeIndicators.includes(fieldName.toLowerCase()) && + valueStr.length >= 10 && + valueStr.length <= 13 + ) { + // Convert to milliseconds if it's in seconds + const timestamp = + valueStr.length === 10 ? value * 1000 : value; + return new Date(timestamp).toLocaleString(); + } + + // Only format for Giga (1e9) and Mega (1e6) ranges + if (absValue >= 1e9) { + return `${(value / 1e9).toFixed(3)} GHz`; + } + if (absValue >= 1e6) { + return `${(value / 1e6).toFixed(1)} MHz`; + } + return value.toString(); + } + + if (Array.isArray(value)) { + return value + .map((item) => formatValue(item, fieldName)) + .join(", "); + } + + if (typeof value === "object") { + return JSON.stringify(value); + } + + return String(value); + }; + + // Helper function to format field names + const formatFieldName = (fieldName) => { + return fieldName + .replace(/_/g, " ") + .replace(/\b\w/g, (l) => l.toUpperCase()); + }; + + // Loop through all metadata fields + if (Object.keys(metadata).length > 0) { + for (const [key, value] of Object.entries(metadata)) { + if (value !== undefined && value !== null) { + const formattedValue = formatValue(value, key); + const formattedKey = formatFieldName(key); + metadataItems.push( + `${formattedKey}: ${formattedValue}`, + ); + } + } + } else { + metadataItems.push("No metadata available"); + } + + if (metadataItems.length > 0) { + metadataDisplay = metadataItems.join("
    "); + } + } + + modalContent += ` +
    +

    + +

    +
    +
    +
    + ${metadataDisplay} +
    +
    +
    +
    + `; + } + + modalContent += ` +
    +
    + `; + } + } catch (e) { + console.error("Could not parse channels data for modal:", e); + console.error( + "Raw channels data that failed to parse:", + data.channels, + ); + + // Show a fallback message in the modal + modalContent += ` +
    +
    Channel Details
    +
    + + Unable to display channel details due to data format issues. +
    Raw data: ${ComponentUtils.escapeHtml(String(data.channels).substring(0, 100))}... +
    +
    + `; + } + } + + const title = data.name + ? data.name + : data.topLevelDir || "Unnamed Capture"; + this.show(title, modalContent); + + // Store capture data for later use + this.currentCaptureData = data; + + // Setup name editing handlers after modal content is loaded + this.setupNameEditingHandlers(); + + // Setup visualize button for Digital RF captures + this.setupVisualizeButton(data); + + // Load and display files for this capture + this.loadCaptureFiles(data.uuid); + } catch (error) { + console.error("Error opening capture modal:", error); + this.show("Error", "Error displaying capture details"); + } + } + + /** + * Setup visualize button for Digital RF captures + */ + setupVisualizeButton(captureData) { + const visualizeBtn = document.getElementById("visualize-btn"); + if (!visualizeBtn) return; + + // Show button only for Digital RF captures + if (captureData.captureType === "drf") { + visualizeBtn.classList.remove("d-none"); + + // Set up click handler to open visualization modal + visualizeBtn.onclick = () => { + // Use the VisualizationModal instance to open with capture data + if (window.visualizationModalInstance) { + window.visualizationModalInstance.openWithCaptureData( + captureData.uuid, + captureData.captureType, + ); + } + }; + } else { + visualizeBtn.classList.add("d-none"); + } + } + + /** + * Setup handlers for name editing functionality + */ + setupNameEditingHandlers() { + const nameInput = document.getElementById("capture-name-input"); + const editBtn = document.getElementById("edit-name-btn"); + const saveBtn = document.getElementById("save-name-btn"); + const cancelBtn = document.getElementById("cancel-name-btn"); + + if (!nameInput || !editBtn || !saveBtn || !cancelBtn) return; + + // Initially disable the input + nameInput.disabled = true; + let originalName = nameInput.value; + let isEditing = false; + + const startEditing = () => { + nameInput.disabled = false; + nameInput.focus(); + nameInput.select(); + editBtn.classList.add("d-none"); + saveBtn.classList.remove("d-none"); + cancelBtn.classList.remove("d-none"); + isEditing = true; + }; + + const stopEditing = () => { + nameInput.disabled = true; + editBtn.classList.remove("d-none"); + saveBtn.classList.add("d-none"); + cancelBtn.classList.add("d-none"); + isEditing = false; + }; + + const cancelEditing = () => { + nameInput.value = originalName; + stopEditing(); + }; + + // Edit button handler + editBtn.addEventListener("click", () => { + if (!isEditing) { + startEditing(); + } + }); + + // Cancel button handler + cancelBtn.addEventListener("click", cancelEditing); + + // Save button handler + saveBtn.addEventListener("click", async () => { + const newName = nameInput.value.trim(); + const uuid = nameInput.getAttribute("data-uuid"); + + if (!uuid) { + console.error("No UUID found for capture"); + return; + } + + // Disable buttons during save + editBtn.disabled = true; + saveBtn.disabled = true; + cancelBtn.disabled = true; + saveBtn.innerHTML = + ''; + + try { + await this.updateCaptureName(uuid, newName); + + // Success - update UI + originalName = newName; + stopEditing(); + + // Update the table display + this.updateTableNameDisplay(uuid, newName); + + // Update modal title using stored capture data + if (this.modalTitle && this.currentCaptureData) { + this.currentCaptureData.name = newName; + this.modalTitle.textContent = + newName || this.currentCaptureData.topLevelDir || "Unnamed Capture"; + } + + // Show success message + this.showSuccessMessage("Capture name updated successfully!"); + } catch (error) { + console.error("Error updating capture name:", error); + this.showErrorMessage( + "Failed to update capture name. Please try again.", + ); + // Revert to original name + nameInput.value = originalName; + } finally { + // Re-enable buttons and restore icons + editBtn.disabled = false; + saveBtn.disabled = false; + cancelBtn.disabled = false; + saveBtn.innerHTML = ''; + } + }); + + // Handle Enter key to save + nameInput.addEventListener("keypress", (e) => { + if (e.key === "Enter" && !nameInput.disabled) { + saveBtn.click(); + } + }); + + // Handle Escape key to cancel + nameInput.addEventListener("keydown", (e) => { + if (e.key === "Escape" && !nameInput.disabled) { + cancelEditing(); + } + }); + } + + /** + * Update capture name via API + */ + async updateCaptureName(uuid, newName) { + const response = await fetch(`/api/v1/assets/captures/${uuid}/`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": this.getCSRFToken(), + }, + body: JSON.stringify({ name: newName }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || "Failed to update capture name"); + } + + return response.json(); + } + + /** + * Update the table display with the new name + */ + updateTableNameDisplay(uuid, newName) { + // Find all elements with this UUID and update their display + const captureLinks = document.querySelectorAll(`[data-uuid="${uuid}"]`); + + for (const link of captureLinks) { + // Update data attribute + link.dataset.name = newName; + + // Update display text if it's a capture link + if (link.classList.contains("capture-link")) { + link.textContent = newName || "Unnamed Capture"; + link.setAttribute( + "aria-label", + `View details for capture ${newName || uuid}`, + ); + link.setAttribute("title", `View capture details: ${newName || uuid}`); + } + } + } + + /** + * Clear existing alert messages from the modal + */ + clearAlerts() { + const modalBody = document.getElementById("capture-modal-body"); + if (modalBody) { + const existingAlerts = modalBody.querySelectorAll(".alert"); + for (const alert of existingAlerts) { + alert.remove(); + } + } + } + + /** + * Show success message + */ + showSuccessMessage(message) { + // Clear existing alerts first + this.clearAlerts(); + + // Create a temporary alert + const alert = document.createElement("div"); + alert.className = "alert alert-success alert-dismissible fade show"; + alert.innerHTML = ` + ${message} + + `; + + // Insert at the top of the modal body + const modalBody = document.getElementById("capture-modal-body"); + if (modalBody) { + modalBody.insertBefore(alert, modalBody.firstChild); + + // Auto-dismiss after 3 seconds + setTimeout(() => { + if (alert.parentNode) { + alert.remove(); + } + }, 3000); + } + } + + /** + * Show error message + */ + showErrorMessage(message) { + // Clear existing alerts first + this.clearAlerts(); + + // Create a temporary alert + const alert = document.createElement("div"); + alert.className = "alert alert-danger alert-dismissible fade show"; + alert.innerHTML = ` + ${message} + + `; + + // Insert at the top of the modal body + const modalBody = document.getElementById("capture-modal-body"); + if (modalBody) { + modalBody.insertBefore(alert, modalBody.firstChild); + + // Auto-dismiss after 5 seconds + setTimeout(() => { + if (alert.parentNode) { + alert.remove(); + } + }, 5000); + } + } + + /** + * Load and display files associated with the capture + */ + async loadCaptureFiles(captureUuid) { + try { + const response = await fetch(`/api/v1/assets/captures/${captureUuid}/`, { + method: "GET", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": this.getCSRFToken(), + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const captureData = await response.json(); + console.log("Raw capture data:", captureData); + + const files = captureData.files || []; + const filesCount = captureData.files_count || 0; + const totalSize = captureData.total_file_size || 0; + + console.log("Files info:", { + filesCount, + totalSize, + numFiles: files.length, + }); + + // Update files section with simple summary + const filesSection = document.getElementById("files-section-placeholder"); + if (filesSection) { + filesSection.innerHTML = ` +
    +
    +
    + Files Summary +
    +
    +
    +

    + Number of Files: + ${filesCount} +

    +
    +
    +

    + Total Size: + ${window.DOMUtils.formatFileSize(totalSize)} +

    +
    +
    + `; + } + } catch (error) { + console.error("Error loading capture files:", error); + const filesSection = document.getElementById("files-section-placeholder"); + if (filesSection) { + filesSection.innerHTML = ` +
    + + Error loading files information +
    + `; + } + } + } + + /** + * Format file metadata for display + */ + formatFileMetadata(file) { + const metadata = []; + + // Primary file information - most useful for users + if (file.size) { + metadata.push( + `Size: ${window.DOMUtils.formatFileSize(file.size)} (${file.size.toLocaleString()} bytes)`, + ); + } + + if (file.media_type) { + metadata.push( + `Media Type: ${ComponentUtils.escapeHtml(file.media_type)}`, + ); + } + + if (file.created_at) { + metadata.push(`Created: ${file.created_at}`); + } + + if (file.updated_at) { + metadata.push(`Updated: ${file.updated_at}`); + } + + // File properties and attributes + if (file.name) { + metadata.push( + `Name: ${ComponentUtils.escapeHtml(file.name)}`, + ); + } + + if (file.directory || file.relative_path) { + metadata.push( + `Directory: ${ComponentUtils.escapeHtml(file.directory || file.relative_path)}`, + ); + } + + // Removed permissions display + // if (file.permissions) { + // metadata.push(`Permissions: ${ComponentUtils.escapeHtml(file.permissions)}`); + // } + + if (file.owner?.username) { + metadata.push( + `Owner: ${ComponentUtils.escapeHtml(file.owner.username)}`, + ); + } + + if (file.expiration_date) { + metadata.push( + `Expires: ${new Date(file.expiration_date).toLocaleDateString()}`, + ); + } + + if (file.bucket_name) { + metadata.push( + `Storage Bucket: ${ComponentUtils.escapeHtml(file.bucket_name)}`, + ); + } + + // Removed checksum display + // if (file.sum_blake3) { + // metadata.push(`Checksum: ${ComponentUtils.escapeHtml(file.sum_blake3)}`); + // } + + // Associated resources + // TODO: Refactor this to handle multiple associations + if (file.capture?.name) { + metadata.push( + `Associated Capture: ${ComponentUtils.escapeHtml(file.capture.name)}`, + ); + } + + if (file.dataset?.name) { + metadata.push( + `Associated Dataset: ${ComponentUtils.escapeHtml(file.dataset.name)}`, + ); + } + + // Additional metadata if available + if (file.metadata && typeof file.metadata === "object") { + for (const [key, value] of Object.entries(file.metadata)) { + if (value !== null && value !== undefined) { + const formattedKey = key + .replace(/_/g, " ") + .replace(/\b\w/g, (l) => l.toUpperCase()); + let formattedValue; + + // Format different types of values + if (typeof value === "boolean") { + formattedValue = value ? "Yes" : "No"; + } else if (typeof value === "number") { + formattedValue = value.toLocaleString(); + } else if (typeof value === "object") { + formattedValue = `${JSON.stringify(value, null, 2)}`; + } else { + formattedValue = ComponentUtils.escapeHtml(String(value)); + } + + metadata.push(`${formattedKey}: ${formattedValue}`); + } + } + } + + if (metadata.length === 0) { + return '

    No metadata available for this file.

    '; + } + + return `
    ${metadata.join("
    ")}
    `; + } + + /** + * Get CSRF token for API requests + */ + getCSRFToken() { + const token = document.querySelector("[name=csrfmiddlewaretoken]"); + return token ? token.value : ""; + } + + /** + * Load and display file metadata for a specific file in the modal + */ + async loadFileMetadata(fileUuid, fileName) { + const fileMetadataSection = document.getElementById( + `file-metadata-${fileUuid}`, + ); + const metadataContent = + fileMetadataSection?.querySelector(".metadata-content"); + + if (!fileMetadataSection || !metadataContent) return; + + // Toggle visibility + if (fileMetadataSection.style.display === "none") { + fileMetadataSection.style.display = "block"; + + // Check if metadata is already loaded + if (metadataContent.innerHTML.includes("Click to load metadata...")) { + // Show loading state + metadataContent.innerHTML = ` +
    +
    + Loading... +
    + Loading metadata... +
    + `; + + try { + const response = await fetch(`/api/v1/assets/files/${fileUuid}/`, { + method: "GET", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": this.getCSRFToken(), + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const fileData = await response.json(); + + // Format and display the metadata + const formattedMetadata = this.formatFileMetadata(fileData); + metadataContent.innerHTML = formattedMetadata; + } catch (error) { + console.error("Error loading file metadata:", error); + metadataContent.innerHTML = ` +
    + + Failed to load metadata for ${ComponentUtils.escapeHtml(fileName)}. +
    Error: ${ComponentUtils.escapeHtml(error.message)} +
    + `; + } + } + } else { + fileMetadataSection.style.display = "none"; + } + } +} diff --git a/gateway/sds_gateway/static/js/core/_frag_pagination.txt b/gateway/sds_gateway/static/js/core/_frag_pagination.txt new file mode 100644 index 000000000..a29e8775d --- /dev/null +++ b/gateway/sds_gateway/static/js/core/_frag_pagination.txt @@ -0,0 +1,65 @@ +class PaginationManager { + constructor(config) { + this.containerId = config.containerId; + this.container = document.getElementById(this.containerId); + this.onPageChange = config.onPageChange; + } + + update(pagination) { + if (!this.container || !pagination) return; + + this.container.innerHTML = ""; + + if (pagination.num_pages <= 1) return; + + const ul = document.createElement("ul"); + ul.className = "pagination justify-content-center"; + + // Previous button + if (pagination.has_previous) { + ul.innerHTML += ` +
  • + + + +
  • + `; + } + + // Page numbers + const startPage = Math.max(1, pagination.number - 2); + const endPage = Math.min(pagination.num_pages, pagination.number + 2); + + for (let i = startPage; i <= endPage; i++) { + ul.innerHTML += ` +
  • + ${i} +
  • + `; + } + + // Next button + if (pagination.has_next) { + ul.innerHTML += ` +
  • + + + +
  • + `; + } + + this.container.appendChild(ul); + + // Add click handlers + const links = ul.querySelectorAll("a.page-link"); + for (const link of links) { + link.addEventListener("click", (e) => { + e.preventDefault(); + const page = Number.parseInt(e.target.dataset.page); + if (page && this.onPageChange) { + this.onPageChange(page); + } + }); + } + } diff --git a/gateway/sds_gateway/static/js/core/_frag_table.txt b/gateway/sds_gateway/static/js/core/_frag_table.txt new file mode 100644 index 000000000..b8405aa6b --- /dev/null +++ b/gateway/sds_gateway/static/js/core/_frag_table.txt @@ -0,0 +1,154 @@ +class TableManager { + constructor(config) { + this.tableId = config.tableId; + this.table = document.getElementById(this.tableId); + this.tbody = this.table?.querySelector("tbody"); + this.loadingIndicator = document.getElementById(config.loadingIndicatorId); + this.paginationContainer = document.getElementById( + config.paginationContainerId, + ); + this.currentSort = { by: "created_at", order: "desc" }; + this.onRowClick = config.onRowClick; + + this.initializeSorting(); + } + + initializeSorting() { + if (!this.table) return; + + const sortableHeaders = this.table.querySelectorAll("th.sortable"); + for (const header of sortableHeaders) { + header.style.cursor = "pointer"; + header.addEventListener("click", () => { + this.handleSort(header); + }); + } + + this.updateSortIcons(); + } + + handleSort(header) { + const field = header.getAttribute("data-sort"); + const currentSort = new URLSearchParams(window.location.search).get( + "sort_by", + ); + const currentOrder = + new URLSearchParams(window.location.search).get("sort_order") || "desc"; + + let newOrder = "asc"; + if (currentSort === field && currentOrder === "asc") { + newOrder = "desc"; + } + + this.currentSort = { by: field, order: newOrder }; + this.updateURL({ sort_by: field, sort_order: newOrder, page: "1" }); + } + + updateSortIcons() { + const urlParams = new URLSearchParams(window.location.search); + const currentSort = urlParams.get("sort_by") || "created_at"; + const currentOrder = urlParams.get("sort_order") || "desc"; + + const sortableHeaders = this.table?.querySelectorAll("th.sortable"); + for (const header of sortableHeaders || []) { + const icon = header.querySelector(".sort-icon"); + const field = header.getAttribute("data-sort"); + + if (icon) { + // Reset classes + icon.className = "bi sort-icon"; + + if (field === currentSort) { + // Add active class and appropriate direction icon + icon.classList.add("active"); + icon.classList.add( + currentOrder === "asc" ? "bi-caret-up-fill" : "bi-caret-down-fill", + ); + } else { + // Inactive columns get default down arrow + icon.classList.add("bi-caret-down-fill"); + } + } + } + } + + showLoading() { + if (this.loadingIndicator) { + this.loadingIndicator.classList.remove("d-none"); + } + } + + hideLoading() { + if (this.loadingIndicator) { + this.loadingIndicator.classList.add("d-none"); + } + } + + showError(message) { + const tbody = document.querySelector("tbody"); + if (tbody) { + tbody.innerHTML = ` + + + ${ComponentUtils.escapeHtml(message)} +
    Try refreshing the page or contact support if the problem persists. + + + `; + } + } + + updateTable(data, hasResults) { + if (!this.tbody) return; + + if (!hasResults || !data || data.length === 0) { + this.tbody.innerHTML = ` + + No results found. + + `; + return; + } + + this.tbody.innerHTML = data + .map((item, index) => this.renderRow(item, index)) + .join(""); + this.attachRowClickHandlers(); + } + + renderRow(item, index) { + // This should be overridden by specific implementations + return `Override renderRow method`; + } + + attachRowClickHandlers() { + if (!this.onRowClick) return; + + const rows = this.tbody?.querySelectorAll('tr[data-clickable="true"]'); + for (const row of rows || []) { + row.addEventListener("click", (e) => { + if ( + e.target.closest( + "button, a, .capture-select-checkbox, .capture-select-column", + ) + ) + return; // Don't trigger on buttons/links/selection + this.onRowClick(row); + }); + } + } + + updateURL(params) { + const urlParams = new URLSearchParams(window.location.search); + for (const [key, value] of Object.entries(params)) { + if (value) { + urlParams.set(key, value); + } else { + urlParams.delete(key); + } + } + + const newUrl = `${window.location.pathname}?${urlParams.toString()}`; + window.history.pushState({}, "", newUrl); + } +} diff --git a/gateway/sds_gateway/static/js/deprecated/components.js b/gateway/sds_gateway/static/js/deprecated/components.js new file mode 100644 index 000000000..edc931686 --- /dev/null +++ b/gateway/sds_gateway/static/js/deprecated/components.js @@ -0,0 +1,1855 @@ +/* Reusable Components for SDS Gateway + +* NOTE: This file is here because the functions have NOT +* been refactored to be placed in the new JS structure. + +* TODO: Refactor the rest of the methods to be placed in the new JS structure. +* And deprecate this file. +*/ + +/** + * Utility functions for security and common operations + */ +const ComponentUtils = { + /** + * Escapes HTML to prevent XSS attacks + * @param {string} text - Text to escape + * @returns {string} Escaped HTML + */ + escapeHtml(text) { + if (!text) return ""; + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + }, + + /** + * Formats date for display with date and time on separate lines + * @param {string} dateString - ISO date string or formatted date string + * @returns {string} Formatted date with HTML structure + */ + formatDate(dateString) { + if (!dateString) return "
    -
    "; + + let date; + + // Try to parse the date string + if (typeof dateString === "string") { + // Handle different date formats + if (dateString.includes("T")) { + // ISO format: 2023-12-25T14:30:45.123Z + date = new Date(dateString); + } else if (dateString.includes("/") && dateString.includes(":")) { + // Already formatted: 12/25/2023 2:30:45 PM + date = new Date(dateString); + } else { + // Try to parse as-is + date = new Date(dateString); + } + } else { + date = new Date(dateString); + } + + if (!date || Number.isNaN(date.getTime())) { + return "
    -
    "; + } + + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const year = date.getFullYear(); + const hours = date.getHours(); + const minutes = String(date.getMinutes()).padStart(2, "0"); + const seconds = String(date.getSeconds()).padStart(2, "0"); + const ampm = hours >= 12 ? "PM" : "AM"; + const displayHours = hours % 12 || 12; + + return `
    ${month}/${day}/${year}
    ${displayHours}:${minutes}:${seconds} ${ampm}`; + }, + + /** + * Format date for modal display in the same style as dataset table + * @param {string} dateString - ISO date string + * @returns {string} Formatted date HTML + */ + formatDateForModal(dateString) { + if (!dateString || dateString === "None") { + return "N/A"; + } + + try { + const date = new Date(dateString); + if (Number.isNaN(date.getTime())) { + return "N/A"; + } + + // Format date as YYYY-MM-DD + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const dateFormatted = `${year}-${month}-${day}`; + + // Format time as HH:MM:SS T + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + const seconds = String(date.getSeconds()).padStart(2, "0"); + const timezone = date + .toLocaleTimeString("en-US", { timeZoneName: "short" }) + .split(" ")[1]; + const timeFormatted = `${hours}:${minutes}:${seconds} ${timezone}`; + + return `${dateFormatted}${timeFormatted}`; + } catch (error) { + console.error("Error formatting capture date:", error); + return "N/A"; + } + }, + + /** + * Formats date for display (simple version) + * @param {string} dateString - ISO date string + * @returns {string} Formatted date + */ + formatDateSimple(dateString) { + try { + const date = new Date(dateString); + return date.toString() !== "Invalid Date" + ? date.toLocaleDateString("en-US", { + month: "2-digit", + day: "2-digit", + year: "numeric", + }) + : ""; + } catch (e) { + return ""; + } + }, +}; + +/** + * TableManager - Handles table operations like sorting, pagination, and updates + */ +class TableManager { + constructor(config) { + this.tableId = config.tableId; + this.table = document.getElementById(this.tableId); + this.tbody = this.table?.querySelector("tbody"); + this.loadingIndicator = document.getElementById(config.loadingIndicatorId); + this.paginationContainer = document.getElementById( + config.paginationContainerId, + ); + this.currentSort = { by: "created_at", order: "desc" }; + this.onRowClick = config.onRowClick; + + this.initializeSorting(); + } + + initializeSorting() { + if (!this.table) return; + + const sortableHeaders = this.table.querySelectorAll("th.sortable"); + for (const header of sortableHeaders) { + header.style.cursor = "pointer"; + header.addEventListener("click", () => { + this.handleSort(header); + }); + } + + this.updateSortIcons(); + } + + handleSort(header) { + const field = header.getAttribute("data-sort"); + const currentSort = new URLSearchParams(window.location.search).get( + "sort_by", + ); + const currentOrder = + new URLSearchParams(window.location.search).get("sort_order") || "desc"; + + let newOrder = "asc"; + if (currentSort === field && currentOrder === "asc") { + newOrder = "desc"; + } + + this.currentSort = { by: field, order: newOrder }; + this.updateURL({ sort_by: field, sort_order: newOrder, page: "1" }); + } + + updateSortIcons() { + const urlParams = new URLSearchParams(window.location.search); + const currentSort = urlParams.get("sort_by") || "created_at"; + const currentOrder = urlParams.get("sort_order") || "desc"; + + const sortableHeaders = this.table?.querySelectorAll("th.sortable"); + for (const header of sortableHeaders || []) { + const icon = header.querySelector(".sort-icon"); + const field = header.getAttribute("data-sort"); + + if (icon) { + // Reset classes + icon.className = "bi sort-icon"; + + if (field === currentSort) { + // Add active class and appropriate direction icon + icon.classList.add("active"); + icon.classList.add( + currentOrder === "asc" ? "bi-caret-up-fill" : "bi-caret-down-fill", + ); + } else { + // Inactive columns get default down arrow + icon.classList.add("bi-caret-down-fill"); + } + } + } + } + + showLoading() { + if (this.loadingIndicator) { + this.loadingIndicator.classList.remove("d-none"); + } + } + + hideLoading() { + if (this.loadingIndicator) { + this.loadingIndicator.classList.add("d-none"); + } + } + + showError(message) { + const tbody = document.querySelector("tbody"); + if (tbody) { + tbody.innerHTML = ` + + + ${ComponentUtils.escapeHtml(message)} +
    Try refreshing the page or contact support if the problem persists. + + + `; + } + } + + updateTable(data, hasResults) { + if (!this.tbody) return; + + if (!hasResults || !data || data.length === 0) { + this.tbody.innerHTML = ` + + No results found. + + `; + return; + } + + this.tbody.innerHTML = data + .map((item, index) => this.renderRow(item, index)) + .join(""); + this.attachRowClickHandlers(); + } + + renderRow(item, index) { + // This should be overridden by specific implementations + return `Override renderRow method`; + } + + attachRowClickHandlers() { + if (!this.onRowClick) return; + + const rows = this.tbody?.querySelectorAll('tr[data-clickable="true"]'); + for (const row of rows || []) { + row.addEventListener("click", (e) => { + if ( + e.target.closest( + "button, a, .capture-select-checkbox, .capture-select-column", + ) + ) + return; // Don't trigger on buttons/links/selection + this.onRowClick(row); + }); + } + } + + updateURL(params) { + const urlParams = new URLSearchParams(window.location.search); + for (const [key, value] of Object.entries(params)) { + if (value) { + urlParams.set(key, value); + } else { + urlParams.delete(key); + } + } + + const newUrl = `${window.location.pathname}?${urlParams.toString()}`; + window.history.pushState({}, "", newUrl); + } +} + +/** + * CapturesTableManager - Specific implementation for captures table + */ +class CapturesTableManager extends TableManager { + constructor(config) { + super(config); + this.modalHandler = config.modalHandler; + this.tableContainerSelector = config.tableContainerSelector; + this.eventDelegationHandler = null; + this.initializeEventDelegation(); + } + + /** + * Initialize event delegation for better performance and memory management + */ + initializeEventDelegation() { + // Remove existing handler if it exists + if (this.eventDelegationHandler) { + document.removeEventListener("click", this.eventDelegationHandler); + } + + // Create single persistent event handler using delegation + this.eventDelegationHandler = (e) => { + // Ignore Bootstrap dropdown toggles + if ( + e.target.matches('[data-bs-toggle="dropdown"]') || + e.target.closest('[data-bs-toggle="dropdown"]') + ) { + return; + } + + // Handle capture details button clicks from actions dropdown + if ( + e.target.matches(".capture-details-btn") || + e.target.closest(".capture-details-btn") + ) { + e.preventDefault(); + const button = e.target.matches(".capture-details-btn") + ? e.target + : e.target.closest(".capture-details-btn"); + this.openCaptureModal(button); + return; + } + + // Handle capture link clicks + if ( + e.target.matches(".capture-link") || + e.target.closest(".capture-link") + ) { + e.preventDefault(); + const link = e.target.matches(".capture-link") + ? e.target + : e.target.closest(".capture-link"); + this.openCaptureModal(link); + return; + } + + // Handle view button clicks + if ( + e.target.matches(".view-capture-btn") || + e.target.closest(".view-capture-btn") + ) { + e.preventDefault(); + const button = e.target.matches(".view-capture-btn") + ? e.target + : e.target.closest(".view-capture-btn"); + this.openCaptureModal(button); + return; + } + }; + + // Add the persistent event listener + document.addEventListener("click", this.eventDelegationHandler); + } + + renderRow(capture, index) { + // Sanitize all data before rendering + const safeData = { + uuid: ComponentUtils.escapeHtml(capture.uuid || ""), + channel: ComponentUtils.escapeHtml(capture.channel || ""), + scanGroup: ComponentUtils.escapeHtml(capture.scan_group || ""), + captureType: ComponentUtils.escapeHtml(capture.capture_type || ""), + captureTypeDisplay: ComponentUtils.escapeHtml( + capture.capture_type_display || "", + ), + topLevelDir: ComponentUtils.escapeHtml(capture.top_level_dir || ""), + indexName: ComponentUtils.escapeHtml(capture.index_name || ""), + owner: ComponentUtils.escapeHtml(capture.owner || ""), + origin: ComponentUtils.escapeHtml(capture.origin || ""), + dataset: ComponentUtils.escapeHtml(capture.dataset || ""), + createdAt: ComponentUtils.escapeHtml(capture.created_at || ""), + updatedAt: ComponentUtils.escapeHtml(capture.updated_at || ""), + isPublic: ComponentUtils.escapeHtml(capture.is_public || ""), + isDeleted: ComponentUtils.escapeHtml(capture.is_deleted || ""), + centerFrequencyGhz: ComponentUtils.escapeHtml( + capture.center_frequency_ghz || "", + ), + }; + + // Handle composite vs single capture display + let channelDisplay = safeData.channel; + let typeDisplay = safeData.captureTypeDisplay || safeData.captureType; + + if (capture.is_multi_channel) { + // For composite captures, show all channels + if (capture.channels && Array.isArray(capture.channels)) { + channelDisplay = capture.channels + .map((ch) => ComponentUtils.escapeHtml(ch.channel || ch)) + .join(", "); + } + // Use capture_type_display if available, otherwise fall back to captureType + typeDisplay = capture.capture_type_display || safeData.captureType; + } + + return ` + + + + ${safeData.uuid} + + + ${channelDisplay} + + ${ComponentUtils.formatDateForModal(capture.capture?.created_at || capture.created_at)} + + ${typeDisplay} + ${capture.files_count || "0"} + ${capture.center_frequency_ghz ? `${capture.center_frequency_ghz.toFixed(3)} GHz` : "-"} + ${capture.sample_rate_mhz ? `${capture.sample_rate_mhz.toFixed(1)} MHz` : "-"} + + `; + } + + /** + * Attach row click handlers - now uses event delegation + */ + attachRowClickHandlers() { + // Event delegation is handled in initializeEventDelegation() + // This method is kept for compatibility but doesn't need to do anything + } + + /** + * Open capture modal with XSS protection + */ + openCaptureModal(linkElement) { + if (this.modalHandler) { + this.modalHandler.openCaptureModal(linkElement); + } + } + + /** + * Get CSRF token for API requests + */ + getCSRFToken() { + const token = document.querySelector("[name=csrfmiddlewaretoken]"); + return token ? token.value : ""; + } + + /** + * Open a custom modal + */ + openCustomModal(modalId) { + const modal = document.getElementById(modalId); + if (modal) { + modal.style.display = "block"; + document.body.style.overflow = "hidden"; + } + } + + /** + * Close a custom modal + */ + closeCustomModal(modalId) { + const modal = document.getElementById(modalId); + if (modal) { + modal.style.display = "none"; + document.body.style.overflow = "auto"; + } + } + + /** + * Cleanup method for proper resource management + */ + destroy() { + if (this.eventDelegationHandler) { + document.removeEventListener("click", this.eventDelegationHandler); + this.eventDelegationHandler = null; + } + } +} + +/** + * FilterManager - Handles form-based filtering with URL state management + */ +class FilterManager { + constructor(config) { + this.formId = config.formId; + this.form = document.getElementById(this.formId); + this.applyButton = document.getElementById(config.applyButtonId); + this.clearButton = document.getElementById(config.clearButtonId); + this.onFilterChange = config.onFilterChange; + this.searchInputId = config.searchInputId || "search-input"; + + this.initializeEventListeners(); + this.loadFromURL(); + } + + initializeEventListeners() { + if (this.applyButton) { + this.applyButton.addEventListener("click", (e) => { + e.preventDefault(); + this.applyFilters(); + }); + } + + if (this.clearButton) { + this.clearButton.addEventListener("click", (e) => { + e.preventDefault(); + this.clearFilters(); + }); + } + + // Auto-apply on form submission + if (this.form) { + this.form.addEventListener("submit", (e) => { + e.preventDefault(); + this.applyFilters(); + }); + } + } + + getFilterValues() { + if (!this.form) return {}; + + const formData = new FormData(this.form); + const filters = {}; + + for (const [key, value] of formData.entries()) { + if (value && value.trim() !== "") { + filters[key] = value.trim(); + } + } + + return filters; + } + + applyFilters() { + const filters = this.getFilterValues(); + this.updateURL(filters); + + if (this.onFilterChange) { + this.onFilterChange(filters); + } + } + + clearFilters() { + if (!this.form) return; + + // Get all form inputs except the search input + const inputs = this.form.querySelectorAll("input, select, textarea"); + for (const input of inputs) { + // Skip the search input + if (input.id === this.searchInputId) { + continue; + } + + // Clear other inputs + if (input.type === "checkbox" || input.type === "radio") { + input.checked = false; + } else { + input.value = ""; + } + } + + // Get current URL parameters + const urlParams = new URLSearchParams(window.location.search); + const searchValue = urlParams.get("search"); + const sortBy = urlParams.get("sort_by") || "created_at"; + const sortOrder = urlParams.get("sort_order") || "desc"; + + // Clear all parameters except search and sort + urlParams.forEach((_, key) => { + if (key !== "search" && key !== "sort_by" && key !== "sort_order") { + urlParams.delete(key); + } + }); + + // Ensure sort parameters are set + urlParams.set("sort_by", sortBy); + urlParams.set("sort_order", sortOrder); + urlParams.set("page", "1"); + + // Update URL + const newUrl = `${window.location.pathname}?${urlParams.toString()}`; + window.history.pushState({}, "", newUrl); + + // Trigger filter change callback + if (this.onFilterChange) { + const filters = { + sort_by: sortBy, + sort_order: sortOrder, + }; + if (searchValue) { + filters.search = searchValue; + } + this.onFilterChange(filters); + } + } + + loadFromURL() { + if (!this.form) return; + + const urlParams = new URLSearchParams(window.location.search); + const inputs = this.form.querySelectorAll("input, select, textarea"); + + for (const input of inputs) { + const value = urlParams.get(input.name); + if (value !== null) { + if (input.type === "checkbox" || input.type === "radio") { + input.checked = value === "true" || value === input.value; + } else { + input.value = value; + } + } + } + } + + updateURL(filters) { + const urlParams = new URLSearchParams(window.location.search); + + // Preserve search parameter if it exists + const searchValue = urlParams.get("search"); + + // Remove old filter parameters + const formData = new FormData(this.form || document.createElement("form")); + for (const key of formData.keys()) { + urlParams.delete(key); + } + + // Add new filter parameters + for (const [key, value] of Object.entries(filters)) { + if (value) { + urlParams.set(key, value); + } + } + + // Restore search parameter if it existed + if (searchValue) { + urlParams.set("search", searchValue); + } + + // Reset to first page when filters change + urlParams.set("page", "1"); + + const newUrl = `${window.location.pathname}?${urlParams.toString()}`; + window.history.pushState({}, "", newUrl); + } +} + +/** + * SearchManager - Handles search functionality with debouncing and request cancellation + */ +class SearchManager { + constructor(config) { + this.searchInput = document.getElementById(config.searchInputId); + this.searchButton = document.getElementById(config.searchButtonId); + this.clearButton = document.getElementById("clear-search-btn"); + this.onSearch = config.onSearch; + this.onSearchStart = config.onSearchStart; + this.debounceDelay = config.debounceDelay || 500; + this.debounceTimer = null; + this.abortController = new AbortController(); + + this.initializeEventListeners(); + this.updateClearButtonVisibility(); + } + + initializeEventListeners() { + if (this.searchInput) { + this.searchInput.addEventListener("input", () => { + this.debounceSearch(); + this.updateClearButtonVisibility(); + }); + + this.searchInput.addEventListener("keypress", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + this.debounceSearch(); + } + }); + } + + if (this.searchButton) { + this.searchButton.addEventListener("click", (e) => { + e.preventDefault(); + this.debounceSearch(); + }); + } + + if (this.clearButton) { + this.clearButton.addEventListener("click", (e) => { + e.preventDefault(); + this.clearSearch(); + }); + } + } + + updateClearButtonVisibility() { + if (this.clearButton) { + this.clearButton.style.display = this.searchInput?.value + ? "block" + : "none"; + } + } + + debounceSearch() { + // Show loading indicator immediately for visual confirmation + if (this.onSearchStart) { + this.onSearchStart(); + } + + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + + this.debounceTimer = setTimeout(() => { + this.performSearch(); + }, this.debounceDelay); + } + + performSearch() { + // Cancel any previous request and create a new abort controller + this.abortController.abort(); + this.abortController = new AbortController(); + + const query = this.searchInput?.value || ""; + + if (this.onSearch) { + this.onSearch(query, this.abortController.signal); + } + } + + clearSearch() { + if (this.searchInput) { + this.searchInput.value = ""; + this.updateClearButtonVisibility(); + } + + this.debounceSearch(); + } + + /** + * Get the current abort signal for fetch requests + */ + getAbortSignal() { + return this.abortController.signal; + } +} + +/** + * ModalManager - Handles modal operations + */ +class ModalManager { + constructor(config) { + this.modalId = config.modalId; + this.modal = document.getElementById(this.modalId); + this.modalTitle = this.modal?.querySelector(".modal-title"); + this.modalBody = this.modal?.querySelector(".modal-body"); + + if (this.modal && window.bootstrap) { + this.bootstrapModal = new bootstrap.Modal(this.modal); + } + } + + show(title, content) { + if (!this.modal) return; + + if (this.modalTitle) { + this.modalTitle.textContent = title; + } + + if (this.modalBody) { + this.modalBody.innerHTML = content; + } + + if (this.bootstrapModal) { + this.bootstrapModal.show(); + } + } + + hide() { + if (this.bootstrapModal) { + this.bootstrapModal.hide(); + } + } + + openCaptureModal(linkElement) { + if (!linkElement) return; + + try { + // Reset visualize button to hidden state + const visualizeBtn = document.getElementById("visualize-btn"); + if (visualizeBtn) { + visualizeBtn.classList.add("d-none"); + } + + // Get all data attributes from the link with sanitization + const data = { + uuid: ComponentUtils.escapeHtml( + linkElement.getAttribute("data-uuid") || "", + ), + name: ComponentUtils.escapeHtml( + linkElement.getAttribute("data-name") || "", + ), + channel: ComponentUtils.escapeHtml( + linkElement.getAttribute("data-channel") || "", + ), + scanGroup: ComponentUtils.escapeHtml( + linkElement.getAttribute("data-scan-group") || "", + ), + captureType: ComponentUtils.escapeHtml( + linkElement.getAttribute("data-capture-type") || "", + ), + topLevelDir: ComponentUtils.escapeHtml( + linkElement.getAttribute("data-top-level-dir") || "", + ), + owner: ComponentUtils.escapeHtml( + linkElement.getAttribute("data-owner") || "", + ), + origin: ComponentUtils.escapeHtml( + linkElement.getAttribute("data-origin") || "", + ), + dataset: ComponentUtils.escapeHtml( + linkElement.getAttribute("data-dataset") || "", + ), + createdAt: linkElement.getAttribute("data-created-at") || "", + updatedAt: linkElement.getAttribute("data-updated-at") || "", + isPublic: linkElement.getAttribute("data-is-public") || "", + centerFrequencyGhz: + linkElement.getAttribute("data-center-frequency-ghz") || "", + isMultiChannel: linkElement.getAttribute("data-is-multi-channel") || "", + channels: linkElement.getAttribute("data-channels") || "", + }; + + // Parse owner field safely + const ownerDisplay = data.owner + ? data.owner.split("'").find((part) => part.includes("@")) || "N/A" + : "N/A"; + + // Check if this is a composite capture + const isComposite = + data.isMultiChannel === "True" || data.isMultiChannel === "true"; + + let modalContent = ` +
    +
    +
    + Basic Information +
    +
    +
    + +
    + + + + +
    +
    Click the edit button to modify the capture name
    +
    +
    +
    +

    + Capture Type: + ${data.captureType || "N/A"} +

    +

    + Origin: + ${data.origin || "N/A"} +

    +
    +
    +

    + Owner: + ${ownerDisplay} +

    +
    +
    + `; + + // Handle composite vs single capture display + if (isComposite) { + modalContent += ` +
    + Channels: + ${data.channel || "N/A"} +
    + `; + } else { + modalContent += ` +
    + Channel: + ${data.channel || "N/A"} +
    + `; + } + + modalContent += ` +
    +
    +
    +
    + Technical Details +
    +
    +
    +
    +

    + Scan Group: + ${data.scanGroup || "N/A"} +

    +

    + Dataset: + ${data.dataset || "N/A"} +

    +

    + Is Public: + ${data.isPublic === "True" ? "Yes" : "No"} +

    +
    +
    +

    + Top Level Directory: + ${data.topLevelDir || "N/A"} +

    +

    + Center Frequency: + + ${data.centerFrequencyGhz && data.centerFrequencyGhz !== "None" ? `${Number.parseFloat(data.centerFrequencyGhz).toFixed(3)} GHz` : "N/A"} + +

    +
    +
    +
    +
    +
    +
    + Timestamps +
    +
    +
    +
    +

    + Created At: +
    + + ${ComponentUtils.formatDateForModal(data.createdAt)} + +

    +
    +
    +

    + Updated At: +
    + + ${ComponentUtils.formatDateForModal(data.updatedAt)} + +

    +
    +
    +
    + +
    +
    +
    + Loading files... +
    + Loading files... +
    +
    + `; + + // Add composite-specific information if available + if (isComposite && data.channels) { + try { + // Convert Python dict syntax to valid JSON + let channelsData; + if (typeof data.channels === "string") { + // Handle Python dict syntax: {'key': 'value'} -> {"key": "value"} + const pythonDict = data.channels + .replace(/'/g, '"') // Replace single quotes with double quotes + .replace(/True/g, "true") // Replace Python True with JSON true + .replace(/False/g, "false") // Replace Python False with JSON false + .replace(/None/g, "null"); // Replace Python None with JSON null + + channelsData = JSON.parse(pythonDict); + } else { + channelsData = data.channels; + } + + if (Array.isArray(channelsData) && channelsData.length > 0) { + modalContent += ` +
    +
    Channel Details
    +
    + `; + + for (let i = 0; i < channelsData.length; i++) { + const channel = channelsData[i]; + const channelId = `channel-${i}`; + + // Format channel metadata as key-value pairs + let metadataDisplay = "N/A"; + if ( + channel.channel_metadata && + typeof channel.channel_metadata === "object" + ) { + const metadata = channel.channel_metadata; + const metadataItems = []; + + // Helper function to format values dynamically + const formatValue = (value, fieldName = "") => { + if (value === null || value === undefined) { + return "N/A"; + } + + if (typeof value === "boolean") { + return value ? "Yes" : "No"; + } + + // Handle string representations of booleans + if (typeof value === "string") { + if (value.toLowerCase() === "true") { + return "Yes"; + } + if (value.toLowerCase() === "false") { + return "No"; + } + } + + if (typeof value === "number") { + const absValue = Math.abs(value); + const valueStr = value.toString(); + const timeIndicators = [ + "computer_time", + "start_bound", + "end_bound", + "init_utc_timestamp", + ]; + // Only format as timestamp if the field name contains "time" + if ( + timeIndicators.includes(fieldName.toLowerCase()) && + valueStr.length >= 10 && + valueStr.length <= 13 + ) { + // Convert to milliseconds if it's in seconds + const timestamp = + valueStr.length === 10 ? value * 1000 : value; + return new Date(timestamp).toLocaleString(); + } + + // Only format for Giga (1e9) and Mega (1e6) ranges + if (absValue >= 1e9) { + return `${(value / 1e9).toFixed(3)} GHz`; + } + if (absValue >= 1e6) { + return `${(value / 1e6).toFixed(1)} MHz`; + } + return value.toString(); + } + + if (Array.isArray(value)) { + return value + .map((item) => formatValue(item, fieldName)) + .join(", "); + } + + if (typeof value === "object") { + return JSON.stringify(value); + } + + return String(value); + }; + + // Helper function to format field names + const formatFieldName = (fieldName) => { + return fieldName + .replace(/_/g, " ") + .replace(/\b\w/g, (l) => l.toUpperCase()); + }; + + // Loop through all metadata fields + if (Object.keys(metadata).length > 0) { + for (const [key, value] of Object.entries(metadata)) { + if (value !== undefined && value !== null) { + const formattedValue = formatValue(value, key); + const formattedKey = formatFieldName(key); + metadataItems.push( + `${formattedKey}: ${formattedValue}`, + ); + } + } + } else { + metadataItems.push("No metadata available"); + } + + if (metadataItems.length > 0) { + metadataDisplay = metadataItems.join("
    "); + } + } + + modalContent += ` +
    +

    + +

    +
    +
    +
    + ${metadataDisplay} +
    +
    +
    +
    + `; + } + + modalContent += ` +
    +
    + `; + } + } catch (e) { + console.error("Could not parse channels data for modal:", e); + console.error( + "Raw channels data that failed to parse:", + data.channels, + ); + + // Show a fallback message in the modal + modalContent += ` +
    +
    Channel Details
    +
    + + Unable to display channel details due to data format issues. +
    Raw data: ${ComponentUtils.escapeHtml(String(data.channels).substring(0, 100))}... +
    +
    + `; + } + } + + const title = data.name + ? data.name + : data.topLevelDir || "Unnamed Capture"; + this.show(title, modalContent); + + // Store capture data for later use + this.currentCaptureData = data; + + // Setup name editing handlers after modal content is loaded + this.setupNameEditingHandlers(); + + // Setup visualize button for Digital RF captures + this.setupVisualizeButton(data); + + // Load and display files for this capture + this.loadCaptureFiles(data.uuid); + } catch (error) { + console.error("Error opening capture modal:", error); + this.show("Error", "Error displaying capture details"); + } + } + + /** + * Setup visualize button for Digital RF captures + */ + setupVisualizeButton(captureData) { + const visualizeBtn = document.getElementById("visualize-btn"); + if (!visualizeBtn) return; + + // Show button only for Digital RF captures + if (captureData.captureType === "drf") { + visualizeBtn.classList.remove("d-none"); + + // Set up click handler to open visualization modal + visualizeBtn.onclick = () => { + // Use the VisualizationModal instance to open with capture data + if (window.visualizationModalInstance) { + window.visualizationModalInstance.openWithCaptureData( + captureData.uuid, + captureData.captureType, + ); + } + }; + } else { + visualizeBtn.classList.add("d-none"); + } + } + + /** + * Setup handlers for name editing functionality + */ + setupNameEditingHandlers() { + const nameInput = document.getElementById("capture-name-input"); + const editBtn = document.getElementById("edit-name-btn"); + const saveBtn = document.getElementById("save-name-btn"); + const cancelBtn = document.getElementById("cancel-name-btn"); + + if (!nameInput || !editBtn || !saveBtn || !cancelBtn) return; + + // Initially disable the input + nameInput.disabled = true; + let originalName = nameInput.value; + let isEditing = false; + + const startEditing = () => { + nameInput.disabled = false; + nameInput.focus(); + nameInput.select(); + editBtn.classList.add("d-none"); + saveBtn.classList.remove("d-none"); + cancelBtn.classList.remove("d-none"); + isEditing = true; + }; + + const stopEditing = () => { + nameInput.disabled = true; + editBtn.classList.remove("d-none"); + saveBtn.classList.add("d-none"); + cancelBtn.classList.add("d-none"); + isEditing = false; + }; + + const cancelEditing = () => { + nameInput.value = originalName; + stopEditing(); + }; + + // Edit button handler + editBtn.addEventListener("click", () => { + if (!isEditing) { + startEditing(); + } + }); + + // Cancel button handler + cancelBtn.addEventListener("click", cancelEditing); + + // Save button handler + saveBtn.addEventListener("click", async () => { + const newName = nameInput.value.trim(); + const uuid = nameInput.getAttribute("data-uuid"); + + if (!uuid) { + console.error("No UUID found for capture"); + return; + } + + // Disable buttons during save + editBtn.disabled = true; + saveBtn.disabled = true; + cancelBtn.disabled = true; + saveBtn.innerHTML = + ''; + + try { + await this.updateCaptureName(uuid, newName); + + // Success - update UI + originalName = newName; + stopEditing(); + + // Update the table display + this.updateTableNameDisplay(uuid, newName); + + // Update modal title using stored capture data + if (this.modalTitle && this.currentCaptureData) { + this.currentCaptureData.name = newName; + this.modalTitle.textContent = + newName || this.currentCaptureData.topLevelDir || "Unnamed Capture"; + } + + // Show success message + this.showSuccessMessage("Capture name updated successfully!"); + } catch (error) { + console.error("Error updating capture name:", error); + this.showErrorMessage( + "Failed to update capture name. Please try again.", + ); + // Revert to original name + nameInput.value = originalName; + } finally { + // Re-enable buttons and restore icons + editBtn.disabled = false; + saveBtn.disabled = false; + cancelBtn.disabled = false; + saveBtn.innerHTML = ''; + } + }); + + // Handle Enter key to save + nameInput.addEventListener("keypress", (e) => { + if (e.key === "Enter" && !nameInput.disabled) { + saveBtn.click(); + } + }); + + // Handle Escape key to cancel + nameInput.addEventListener("keydown", (e) => { + if (e.key === "Escape" && !nameInput.disabled) { + cancelEditing(); + } + }); + } + + /** + * Update capture name via API + */ + async updateCaptureName(uuid, newName) { + const response = await fetch(`/api/v1/assets/captures/${uuid}/`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": this.getCSRFToken(), + }, + body: JSON.stringify({ name: newName }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || "Failed to update capture name"); + } + + return response.json(); + } + + /** + * Update the table display with the new name + */ + updateTableNameDisplay(uuid, newName) { + // Find all elements with this UUID and update their display + const captureLinks = document.querySelectorAll(`[data-uuid="${uuid}"]`); + + for (const link of captureLinks) { + // Update data attribute + link.dataset.name = newName; + + // Update display text if it's a capture link + if (link.classList.contains("capture-link")) { + link.textContent = newName || "Unnamed Capture"; + link.setAttribute( + "aria-label", + `View details for capture ${newName || uuid}`, + ); + link.setAttribute("title", `View capture details: ${newName || uuid}`); + } + } + } + + /** + * Clear existing alert messages from the modal + */ + clearAlerts() { + const modalBody = document.getElementById("capture-modal-body"); + if (modalBody) { + const existingAlerts = modalBody.querySelectorAll(".alert"); + for (const alert of existingAlerts) { + alert.remove(); + } + } + } + + /** + * Show success message + */ + showSuccessMessage(message) { + // Clear existing alerts first + this.clearAlerts(); + + // Create a temporary alert + const alert = document.createElement("div"); + alert.className = "alert alert-success alert-dismissible fade show"; + alert.innerHTML = ` + ${message} + + `; + + // Insert at the top of the modal body + const modalBody = document.getElementById("capture-modal-body"); + if (modalBody) { + modalBody.insertBefore(alert, modalBody.firstChild); + + // Auto-dismiss after 3 seconds + setTimeout(() => { + if (alert.parentNode) { + alert.remove(); + } + }, 3000); + } + } + + /** + * Show error message + */ + showErrorMessage(message) { + // Clear existing alerts first + this.clearAlerts(); + + // Create a temporary alert + const alert = document.createElement("div"); + alert.className = "alert alert-danger alert-dismissible fade show"; + alert.innerHTML = ` + ${message} + + `; + + // Insert at the top of the modal body + const modalBody = document.getElementById("capture-modal-body"); + if (modalBody) { + modalBody.insertBefore(alert, modalBody.firstChild); + + // Auto-dismiss after 5 seconds + setTimeout(() => { + if (alert.parentNode) { + alert.remove(); + } + }, 5000); + } + } + + /** + * Load and display files associated with the capture + */ + async loadCaptureFiles(captureUuid) { + try { + const response = await fetch(`/api/v1/assets/captures/${captureUuid}/`, { + method: "GET", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": this.getCSRFToken(), + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const captureData = await response.json(); + console.log("Raw capture data:", captureData); + + const files = captureData.files || []; + const filesCount = captureData.files_count || 0; + const totalSize = captureData.total_file_size || 0; + + console.log("Files info:", { + filesCount, + totalSize, + numFiles: files.length, + }); + + // Update files section with simple summary + const filesSection = document.getElementById("files-section-placeholder"); + if (filesSection) { + filesSection.innerHTML = ` +
    +
    +
    + Files Summary +
    +
    +
    +

    + Number of Files: + ${filesCount} +

    +
    +
    +

    + Total Size: + ${window.DOMUtils.formatFileSize(totalSize)} +

    +
    +
    + `; + } + } catch (error) { + console.error("Error loading capture files:", error); + const filesSection = document.getElementById("files-section-placeholder"); + if (filesSection) { + filesSection.innerHTML = ` +
    + + Error loading files information +
    + `; + } + } + } + + /** + * Format file metadata for display + */ + formatFileMetadata(file) { + const metadata = []; + + // Primary file information - most useful for users + if (file.size) { + metadata.push( + `Size: ${window.DOMUtils.formatFileSize(file.size)} (${file.size.toLocaleString()} bytes)`, + ); + } + + if (file.media_type) { + metadata.push( + `Media Type: ${ComponentUtils.escapeHtml(file.media_type)}`, + ); + } + + if (file.created_at) { + metadata.push(`Created: ${file.created_at}`); + } + + if (file.updated_at) { + metadata.push(`Updated: ${file.updated_at}`); + } + + // File properties and attributes + if (file.name) { + metadata.push( + `Name: ${ComponentUtils.escapeHtml(file.name)}`, + ); + } + + if (file.directory || file.relative_path) { + metadata.push( + `Directory: ${ComponentUtils.escapeHtml(file.directory || file.relative_path)}`, + ); + } + + // Removed permissions display + // if (file.permissions) { + // metadata.push(`Permissions: ${ComponentUtils.escapeHtml(file.permissions)}`); + // } + + if (file.owner?.username) { + metadata.push( + `Owner: ${ComponentUtils.escapeHtml(file.owner.username)}`, + ); + } + + if (file.expiration_date) { + metadata.push( + `Expires: ${new Date(file.expiration_date).toLocaleDateString()}`, + ); + } + + if (file.bucket_name) { + metadata.push( + `Storage Bucket: ${ComponentUtils.escapeHtml(file.bucket_name)}`, + ); + } + + // Removed checksum display + // if (file.sum_blake3) { + // metadata.push(`Checksum: ${ComponentUtils.escapeHtml(file.sum_blake3)}`); + // } + + // Associated resources + // TODO: Refactor this to handle multiple associations + if (file.capture?.name) { + metadata.push( + `Associated Capture: ${ComponentUtils.escapeHtml(file.capture.name)}`, + ); + } + + if (file.dataset?.name) { + metadata.push( + `Associated Dataset: ${ComponentUtils.escapeHtml(file.dataset.name)}`, + ); + } + + // Additional metadata if available + if (file.metadata && typeof file.metadata === "object") { + for (const [key, value] of Object.entries(file.metadata)) { + if (value !== null && value !== undefined) { + const formattedKey = key + .replace(/_/g, " ") + .replace(/\b\w/g, (l) => l.toUpperCase()); + let formattedValue; + + // Format different types of values + if (typeof value === "boolean") { + formattedValue = value ? "Yes" : "No"; + } else if (typeof value === "number") { + formattedValue = value.toLocaleString(); + } else if (typeof value === "object") { + formattedValue = `${JSON.stringify(value, null, 2)}`; + } else { + formattedValue = ComponentUtils.escapeHtml(String(value)); + } + + metadata.push(`${formattedKey}: ${formattedValue}`); + } + } + } + + if (metadata.length === 0) { + return '

    No metadata available for this file.

    '; + } + + return `
    ${metadata.join("
    ")}
    `; + } + + /** + * Get CSRF token for API requests + */ + getCSRFToken() { + const token = document.querySelector("[name=csrfmiddlewaretoken]"); + return token ? token.value : ""; + } + + /** + * Load and display file metadata for a specific file in the modal + */ + async loadFileMetadata(fileUuid, fileName) { + const fileMetadataSection = document.getElementById( + `file-metadata-${fileUuid}`, + ); + const metadataContent = + fileMetadataSection?.querySelector(".metadata-content"); + + if (!fileMetadataSection || !metadataContent) return; + + // Toggle visibility + if (fileMetadataSection.style.display === "none") { + fileMetadataSection.style.display = "block"; + + // Check if metadata is already loaded + if (metadataContent.innerHTML.includes("Click to load metadata...")) { + // Show loading state + metadataContent.innerHTML = ` +
    +
    + Loading... +
    + Loading metadata... +
    + `; + + try { + const response = await fetch(`/api/v1/assets/files/${fileUuid}/`, { + method: "GET", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": this.getCSRFToken(), + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const fileData = await response.json(); + + // Format and display the metadata + const formattedMetadata = this.formatFileMetadata(fileData); + metadataContent.innerHTML = formattedMetadata; + } catch (error) { + console.error("Error loading file metadata:", error); + metadataContent.innerHTML = ` +
    + + Failed to load metadata for ${ComponentUtils.escapeHtml(fileName)}. +
    Error: ${ComponentUtils.escapeHtml(error.message)} +
    + `; + } + } + } else { + fileMetadataSection.style.display = "none"; + } + } +} + +/** + * PaginationManager - Handles pagination controls + */ +class PaginationManager { + constructor(config) { + this.containerId = config.containerId; + this.container = document.getElementById(this.containerId); + this.onPageChange = config.onPageChange; + } + + update(pagination) { + if (!this.container || !pagination) return; + + this.container.innerHTML = ""; + + if (pagination.num_pages <= 1) return; + + const ul = document.createElement("ul"); + ul.className = "pagination justify-content-center"; + + // Previous button + if (pagination.has_previous) { + ul.innerHTML += ` +
  • + + + +
  • + `; + } + + // Page numbers + const startPage = Math.max(1, pagination.number - 2); + const endPage = Math.min(pagination.num_pages, pagination.number + 2); + + for (let i = startPage; i <= endPage; i++) { + ul.innerHTML += ` +
  • + ${i} +
  • + `; + } + + // Next button + if (pagination.has_next) { + ul.innerHTML += ` +
  • + + + +
  • + `; + } + + this.container.appendChild(ul); + + // Add click handlers + const links = ul.querySelectorAll("a.page-link"); + for (const link of links) { + link.addEventListener("click", (e) => { + e.preventDefault(); + const page = Number.parseInt(e.target.dataset.page); + if (page && this.onPageChange) { + this.onPageChange(page); + } + }); + } + } +} + +// Make classes available globally +window.ComponentUtils = ComponentUtils; +window.TableManager = TableManager; +window.CapturesTableManager = CapturesTableManager; +window.FilterManager = FilterManager; +window.SearchManager = SearchManager; +window.ModalManager = ModalManager; +window.PaginationManager = PaginationManager; + +// Export classes for module use +if (typeof module !== "undefined" && module.exports) { + module.exports = { + ComponentUtils, + TableManager, + CapturesTableManager, + FilterManager, + SearchManager, + ModalManager, + PaginationManager, + }; +} + +// Add custom styles +const style = document.createElement("style"); +style.textContent = ` + .edit-name-btn:hover i, + .save-name-btn:hover i { + color: white !important; + } + + /* Hide native clear button in Chrome */ + input[type="search"]::-webkit-search-cancel-button { + -webkit-appearance: none; + display: none; + } +`; +document.head.appendChild(style); + +// Global event listener for visualization trigger buttons +document.addEventListener("DOMContentLoaded", () => { + // Initialize VisualizationModal instance if available + if (window.VisualizationModal) { + window.visualizationModalInstance = new window.VisualizationModal(); + } + + // Handle clicks on visualization trigger buttons + document.addEventListener("click", (e) => { + if (e.target.closest(".visualization-trigger-btn")) { + const button = e.target.closest(".visualization-trigger-btn"); + const captureUuid = button.getAttribute("data-capture-uuid"); + const captureType = button.getAttribute("data-capture-type"); + + if (captureUuid && captureType) { + // Use the VisualizationModal instance to open with capture data + if (window.visualizationModalInstance) { + window.visualizationModalInstance.openWithCaptureData( + captureUuid, + captureType, + ); + } + } + } + }); +}); diff --git a/gateway/sds_gateway/static/js/deprecated/file-list.js b/gateway/sds_gateway/static/js/deprecated/file-list.js new file mode 100644 index 000000000..9a99a0140 --- /dev/null +++ b/gateway/sds_gateway/static/js/deprecated/file-list.js @@ -0,0 +1,1117 @@ +/** + * TODO: This file has a lot of redundancy with manager files + * And needs to be deprecated. and have its functionality migrated + * to the new JS structure. + */ + +/* File List Page JavaScript - Refactored to use Components */ + +/** + * Configuration constants + */ +const CONFIG = { + DEBOUNCE_DELAY: 500, + DEFAULT_SORT_BY: "created_at", + DEFAULT_SORT_ORDER: "desc", + MIN_LOADING_TIME: 500, // Minimum milliseconds to display loading indicator + ELEMENT_IDS: { + SEARCH_INPUT: "search-input", + START_DATE: "start_date", + END_DATE: "end_date", + CENTER_FREQ_MIN: "centerFreqMinInput", + CENTER_FREQ_MAX: "centerFreqMaxInput", + APPLY_FILTERS: "apply-filters-btn", + CLEAR_FILTERS: "clear-filters-btn", + ITEMS_PER_PAGE: "items-per-page", + }, +}; + +/** + * Main controller class for the file list page + */ +class FileListController { + constructor() { + this.userInteractedWithFrequency = false; + this.urlParams = new URLSearchParams(window.location.search); + this.currentSortBy = + this.urlParams.get("sort_by") || CONFIG.DEFAULT_SORT_BY; + this.currentSortOrder = + this.urlParams.get("sort_order") || CONFIG.DEFAULT_SORT_ORDER; + + // Cache DOM elements + this.cacheElements(); + + // Initialize components + this.initializeComponents(); + + // Initialize functionality + this.initializeEventHandlers(); + this.initializeFromURL(); + + // Initial setup + this.updateSortIcons(); + this.tableManager.attachRowClickHandlers(); + + // Initialize dropdowns for any existing static dropdowns + this.initializeDropdowns(); + } + + /** + * Cache frequently accessed DOM elements + */ + cacheElements() { + this.elements = { + searchInput: document.getElementById(CONFIG.ELEMENT_IDS.SEARCH_INPUT), + startDate: document.getElementById(CONFIG.ELEMENT_IDS.START_DATE), + endDate: document.getElementById(CONFIG.ELEMENT_IDS.END_DATE), + centerFreqMin: document.getElementById( + CONFIG.ELEMENT_IDS.CENTER_FREQ_MIN, + ), + centerFreqMax: document.getElementById( + CONFIG.ELEMENT_IDS.CENTER_FREQ_MAX, + ), + applyFilters: document.getElementById(CONFIG.ELEMENT_IDS.APPLY_FILTERS), + clearFilters: document.getElementById(CONFIG.ELEMENT_IDS.CLEAR_FILTERS), + itemsPerPage: document.getElementById(CONFIG.ELEMENT_IDS.ITEMS_PER_PAGE), + sortableHeaders: document.querySelectorAll("th.sortable"), + frequencyButton: document.querySelector( + '[data-bs-target="#collapseFrequency"]', + ), + frequencyCollapse: document.getElementById("collapseFrequency"), + dateButton: document.querySelector('[data-bs-target="#collapseDate"]'), + dateCollapse: document.getElementById("collapseDate"), + }; + } + + /** + * Initialize component managers + */ + initializeComponents() { + this.modalManager = new ModalManager({ + modalId: "capture-modal", + modalBodyId: "capture-modal-body", + modalTitleId: "capture-modal-label", + }); + + this.tableManager = new FileListCapturesTableManager({ + tableId: "captures-table", + tableContainerSelector: ".table-responsive", + resultsCountId: "results-count", + modalHandler: this.modalManager, + onSelectionChange: () => this.syncBulkAddToDatasetButton(), + }); + + this.searchManager = new SearchManager({ + searchInputId: CONFIG.ELEMENT_IDS.SEARCH_INPUT, + searchButtonId: "search-btn", + clearButtonId: "reset-search-btn", + searchFormId: "search-form", + onSearchStart: () => this.tableManager.showLoading(), + onSearch: (query, signal) => this.performSearch(signal), + debounceDelay: CONFIG.DEBOUNCE_DELAY, + }); + + this.paginationManager = new PaginationManager({ + containerId: "captures-pagination", + onPageChange: (page) => this.handlePageChange(page), + }); + } + + /** + * Initialize all event handlers + */ + initializeEventHandlers() { + this.initializeTableSorting(); + this.initializeAccordions(); + this.initializeFrequencyHandling(); + this.initializeItemsPerPageHandler(); + this.initializeAddToDatasetButton(); + } + + /** + * Selection mode: one button to enter; when on, show Cancel and Add + */ + initializeAddToDatasetButton() { + const mainBtn = document.getElementById("add-captures-to-dataset-btn"); + const table = document.getElementById("captures-table"); + const modeButtonsWrap = document.getElementById( + "add-to-dataset-mode-buttons", + ); + const cancelBtn = document.getElementById("add-to-dataset-cancel-btn"); + const addBtn = document.getElementById("add-to-dataset-add-btn"); + if (!mainBtn || !table) return; + + const enterSelectionMode = () => { + table.classList.add("selection-mode-active"); + mainBtn.classList.add("d-none"); + mainBtn.setAttribute("aria-pressed", "true"); + if (modeButtonsWrap) modeButtonsWrap.classList.remove("d-none"); + this.syncBulkAddToDatasetButton(); + }; + + mainBtn.addEventListener("click", enterSelectionMode); + + if (cancelBtn) { + cancelBtn.addEventListener("click", () => this.exitSelectionMode()); + } + + if (addBtn) { + addBtn.addEventListener("click", () => { + const ids = Array.from(this.tableManager?.selectedCaptureIds ?? []); + if (ids.length === 0) { + if (window.showAlert) { + window.showAlert( + "Select at least one capture before adding to a dataset.", + "warning", + ); + } + return; + } + const modal = document.getElementById("quickAddToDatasetModal"); + if (modal) { + modal.dataset.captureUuids = JSON.stringify(ids); + const bsModal = bootstrap.Modal.getOrCreateInstance(modal); + bsModal.show(); + } + }); + } + } + + /** + * Exit bulk-add selection mode: hide the mode controls, uncheck all selected + * captures, and clear the selection set. + */ + exitSelectionMode() { + const mainBtn = document.getElementById("add-captures-to-dataset-btn"); + const table = document.getElementById("captures-table"); + const modeButtonsWrap = document.getElementById( + "add-to-dataset-mode-buttons", + ); + table?.classList.remove("selection-mode-active"); + mainBtn?.classList.remove("d-none"); + mainBtn?.setAttribute("aria-pressed", "false"); + modeButtonsWrap?.classList.add("d-none"); + + // Uncheck all visible checkboxes and clear the tracked set + if (this.tableManager) { + for (const uuid of this.tableManager.selectedCaptureIds) { + const cb = document.querySelector( + `.capture-select-checkbox[data-capture-uuid="${uuid}"]`, + ); + if (cb) cb.checked = false; + } + this.tableManager.selectedCaptureIds.clear(); + this.syncBulkAddToDatasetButton(); + } + } + + /** + * While selection mode is active, disable bulk "Add" until at least one capture is selected. + */ + syncBulkAddToDatasetButton() { + const addBtn = document.getElementById("add-to-dataset-add-btn"); + const table = document.getElementById("captures-table"); + if (!addBtn || !table?.classList.contains("selection-mode-active")) { + return; + } + const n = this.tableManager?.selectedCaptureIds?.size ?? 0; + addBtn.disabled = n === 0; + addBtn.title = + n === 0 + ? "Select at least one capture to add to a dataset" + : "Add selected captures to a dataset"; + addBtn.setAttribute( + "aria-label", + n === 0 + ? "Add to dataset — select at least one capture first" + : `Add ${n} selected capture${n === 1 ? "" : "s"} to a dataset`, + ); + } + + /** + * Initialize values from URL parameters + */ + initializeFromURL() { + // Set initial date values from URL + if (this.urlParams.get("date_start") && this.elements.startDate) { + this.elements.startDate.value = this.urlParams.get("date_start"); + } + if (this.urlParams.get("date_end") && this.elements.endDate) { + this.elements.endDate.value = this.urlParams.get("date_end"); + } + + // Set frequency values if they exist in URL + this.initializeFrequencyFromURL(); + } + + /** + * Handle page change events + */ + handlePageChange(page) { + const urlParams = new URLSearchParams(window.location.search); + urlParams.set("page", page.toString()); + window.location.search = urlParams.toString(); + } + + /** + * Build search parameters from form inputs + */ + buildSearchParams() { + const searchParams = new URLSearchParams(); + + const searchQuery = this.elements.searchInput?.value.trim() || ""; + const startDate = this.elements.startDate?.value || ""; + let endDate = this.elements.endDate?.value || ""; + + // If end date is set, include the full day + if (endDate) { + endDate = `${endDate}T23:59:59`; + } + + // Add search parameters + if (searchQuery) searchParams.set("search", searchQuery); + if (startDate) searchParams.set("date_start", startDate); + if (endDate) searchParams.set("date_end", endDate); + + // Only add frequency parameters if user has explicitly interacted + if (this.userInteractedWithFrequency) { + if (this.elements.centerFreqMin?.value) { + searchParams.set("min_freq", this.elements.centerFreqMin.value); + } + if (this.elements.centerFreqMax?.value) { + searchParams.set("max_freq", this.elements.centerFreqMax.value); + } + } + + searchParams.set("sort_by", this.currentSortBy); + searchParams.set("sort_order", this.currentSortOrder); + + return searchParams; + } + + /** + * Execute search API call + */ + async executeSearch(searchParams, signal) { + const apiUrl = `${window.location.pathname.replace(/\/$/, "")}/api/?${searchParams.toString()}`; + + const response = await fetch(apiUrl, { + method: "GET", + headers: { + Accept: "application/json", + }, + credentials: "same-origin", + signal: signal, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const text = await response.text(); + try { + return JSON.parse(text); + } catch { + throw new Error("Invalid JSON response from server"); + } + } + + /** + * Update UI with search results + */ + updateUI(data) { + if (data.error) { + throw new Error(`Server error: ${data.error}`); + } + + this.tableManager.updateTable(data.captures || [], data.has_results); + } + + /** + * Update browser history without page refresh + */ + updateBrowserHistory(searchParams) { + const newUrl = `${window.location.pathname}?${searchParams.toString()}`; + window.history.pushState({}, "", newUrl); + } + + /** + * Main search function - now broken down into smaller methods + */ + async performSearch(signal) { + try { + const startTime = Date.now(); + this.tableManager.showLoading(); + + const searchParams = this.buildSearchParams(); + const data = await this.executeSearch(searchParams, signal); + + // Ensure minimum loading time is displayed + const elapsedTime = Date.now() - startTime; + if (elapsedTime < CONFIG.MIN_LOADING_TIME) { + await new Promise((resolve) => + setTimeout(resolve, CONFIG.MIN_LOADING_TIME - elapsedTime), + ); + } + + this.updateUI(data); + this.updateBrowserHistory(searchParams); + } catch (error) { + // Don't show error if request was aborted (user issued a new search) + if (error.name === "AbortError") { + console.log("Previous search request was cancelled"); + return; + } + + console.error("Search error:", error); + this.tableManager.showError(`Search failed: ${error.message}`); + } finally { + this.tableManager.hideLoading(); + } + } + + /** + * Initialize table sorting functionality + */ + initializeTableSorting() { + if (!this.elements.sortableHeaders) return; + + for (const header of this.elements.sortableHeaders) { + header.style.cursor = "pointer"; + header.addEventListener("click", () => this.handleSort(header)); + } + } + + /** + * Handle sort click events + */ + handleSort(header) { + try { + const sortField = header.getAttribute("data-sort"); + const currentSort = this.urlParams.get("sort_by"); + const currentOrder = this.urlParams.get("sort_order") || "desc"; + + // Determine new sort order + let newOrder = "asc"; + if (currentSort === sortField && currentOrder === "asc") { + newOrder = "desc"; + } + + // Build new URL with sort parameters + const urlParams = new URLSearchParams(window.location.search); + urlParams.set("sort_by", sortField); + urlParams.set("sort_order", newOrder); + urlParams.set("page", "1"); + + // Navigate to sorted results + window.location.search = urlParams.toString(); + } catch (error) { + console.error("Error handling sort:", error); + } + } + + /** + * Update sort icons to show current sort state + */ + updateSortIcons() { + if (!this.elements.sortableHeaders) return; + + const currentSort = this.urlParams.get("sort_by") || CONFIG.DEFAULT_SORT_BY; + const currentOrder = this.urlParams.get("sort_order") || "desc"; + + for (const header of this.elements.sortableHeaders) { + const sortField = header.getAttribute("data-sort"); + const icon = header.querySelector(".sort-icon"); + + if (icon) { + // Reset classes + icon.className = "bi sort-icon"; + + if (currentSort === sortField) { + // Add active class and appropriate direction icon + icon.classList.add("active"); + if (currentOrder === "desc") { + icon.classList.add("bi-caret-down-fill"); + } else { + icon.classList.add("bi-caret-up-fill"); + } + } else { + // Inactive columns get default down arrow + icon.classList.add("bi-caret-down-fill"); + } + } + } + } + + /** + * Initialize accordion behavior + */ + initializeAccordions() { + // Frequency filter accordion + if (this.elements.frequencyButton && this.elements.frequencyCollapse) { + this.elements.frequencyButton.addEventListener("click", (e) => { + e.preventDefault(); + this.toggleAccordion( + this.elements.frequencyButton, + this.elements.frequencyCollapse, + ); + }); + } + + // Date filter accordion + if (this.elements.dateButton && this.elements.dateCollapse) { + this.elements.dateButton.addEventListener("click", (e) => { + e.preventDefault(); + this.toggleAccordion( + this.elements.dateButton, + this.elements.dateCollapse, + ); + }); + } + } + + /** + * Helper function to toggle accordion state + */ + toggleAccordion(button, collapse) { + const isCollapsed = button.classList.contains("collapsed"); + + if (isCollapsed) { + button.classList.remove("collapsed"); + button.setAttribute("aria-expanded", "true"); + collapse.classList.add("show"); + } else { + button.classList.add("collapsed"); + button.setAttribute("aria-expanded", "false"); + collapse.classList.remove("show"); + } + } + + /** + * Initialize frequency handling + */ + initializeFrequencyHandling() { + // Add event listeners to track user interaction with frequency inputs + if (this.elements.centerFreqMin) { + this.elements.centerFreqMin.addEventListener("change", () => { + this.userInteractedWithFrequency = true; + }); + } + + if (this.elements.centerFreqMax) { + this.elements.centerFreqMax.addEventListener("change", () => { + this.userInteractedWithFrequency = true; + }); + } + + // Apply filters button + if (this.elements.applyFilters) { + this.elements.applyFilters.addEventListener("click", (e) => { + e.preventDefault(); + this.performSearch(); + }); + } + + // Clear filters button + if (this.elements.clearFilters) { + this.elements.clearFilters.addEventListener("click", (e) => { + e.preventDefault(); + this.clearAllFilters(); + }); + } + } + + /** + * Clear all filter inputs + */ + clearAllFilters() { + // Get current URL parameters + const urlParams = new URLSearchParams(window.location.search); + const currentSearch = urlParams.get("search"); + + // Reset all filter inputs except search + if (this.elements.startDate) this.elements.startDate.value = ""; + if (this.elements.endDate) this.elements.endDate.value = ""; + if (this.elements.centerFreqMin) this.elements.centerFreqMin.value = ""; + if (this.elements.centerFreqMax) this.elements.centerFreqMax.value = ""; + + // Reset interaction tracking + this.userInteractedWithFrequency = false; + + // Reset frequency slider if it exists + const frequencyRangeSlider = document.getElementById( + "frequency-range-slider", + ); + if (frequencyRangeSlider?.noUiSlider) { + frequencyRangeSlider.noUiSlider.set([0, 10]); + } + + // Also reset the display values + const lowerValue = document.getElementById("frequency-range-lower"); + const upperValue = document.getElementById("frequency-range-upper"); + if (lowerValue) lowerValue.textContent = "0 GHz"; + if (upperValue) upperValue.textContent = "10 GHz"; + + // Create new URL parameters with only search and sort parameters preserved + const newParams = new URLSearchParams(); + if (currentSearch) { + newParams.set("search", currentSearch); + } + newParams.set("sort_by", this.currentSortBy); + newParams.set("sort_order", this.currentSortOrder); + + // Update URL and trigger search + window.history.pushState( + {}, + "", + `${window.location.pathname}?${newParams.toString()}`, + ); + this.performSearch(); + } + + /** + * Initialize items per page handler + */ + initializeItemsPerPageHandler() { + if (this.elements.itemsPerPage) { + this.elements.itemsPerPage.addEventListener("change", (e) => { + const urlParams = new URLSearchParams(window.location.search); + urlParams.set("items_per_page", e.target.value); + urlParams.set("page", "1"); + window.location.search = urlParams.toString(); + }); + } + } + + /** + * Initialize frequency range from URL parameters + */ + initializeFrequencyFromURL() { + if (!this.elements.centerFreqMin || !this.elements.centerFreqMax) return; + + const minFreq = Number.parseFloat(this.urlParams.get("min_freq")); + const maxFreq = Number.parseFloat(this.urlParams.get("max_freq")); + + if (!Number.isNaN(minFreq)) { + this.elements.centerFreqMin.value = minFreq; + this.userInteractedWithFrequency = true; + } + if (!Number.isNaN(maxFreq)) { + this.elements.centerFreqMax.value = maxFreq; + this.userInteractedWithFrequency = true; + } + + // Update noUiSlider if it exists + if (this.userInteractedWithFrequency) { + this.initializeFrequencySlider(); + } + } + + initializeFrequencySlider() { + try { + const frequencyRangeSlider = document.getElementById( + "frequency-range-slider", + ); + if (frequencyRangeSlider?.noUiSlider) { + const currentValues = frequencyRangeSlider.noUiSlider.get(); + const newMin = !Number.isNaN(minFreq) + ? minFreq + : Number.parseFloat(currentValues[0]); + const newMax = !Number.isNaN(maxFreq) + ? maxFreq + : Number.parseFloat(currentValues[1]); + + frequencyRangeSlider.noUiSlider.set([newMin, newMax]); + } + } catch (error) { + console.error("Error initializing frequency slider:", error); + } + } + + /** + * Initialize dropdowns with body container for proper positioning + */ + initializeDropdowns() { + const dropdownButtons = document.querySelectorAll(".btn-icon-dropdown"); + + if (dropdownButtons.length === 0) { + return; + } + + for (const toggle of dropdownButtons) { + // Check if dropdown is already initialized + if (toggle._dropdown) { + continue; + } + + const _dropdown = new bootstrap.Dropdown(toggle, { + container: "body", + boundary: "viewport", + popperConfig: { + modifiers: [ + { + name: "preventOverflow", + options: { + boundary: "viewport", + }, + }, + ], + }, + }); + + // Manually move dropdown to body when shown + toggle.addEventListener("show.bs.dropdown", () => { + const dropdownMenu = toggle.nextElementSibling; + if (dropdownMenu?.classList.contains("dropdown-menu")) { + document.body.appendChild(dropdownMenu); + } + }); + } + } +} + +/** + * Enhanced CapturesTableManager for file list specific functionality + * Extends the base CapturesTableManager from components.js + */ +class FileListCapturesTableManager extends CapturesTableManager { + /** + * UUIDs selected for quick-add / bulk actions. Class field initializes as soon as + * the instance exists (after super()), so renderRow never runs before this exists. + */ + selectedCaptureIds = new Set(); + + constructor(options) { + super(options); + this.resultsCountElement = document.getElementById(options.resultsCountId); + this.searchButton = document.getElementById("search-btn"); + this.searchButtonContent = document.getElementById("search-btn-content"); + this.searchButtonLoading = document.getElementById("search-btn-loading"); + this.onSelectionChange = options.onSelectionChange ?? null; + this.setupSelectionCheckboxHandler(); + this.setupRowClickSelection(); + } + + _notifySelectionChange() { + if (typeof this.onSelectionChange === "function") { + this.onSelectionChange(); + } + } + + /** + * Override showLoading to toggle button contents instead of showing separate indicator + */ + showLoading() { + if (this.searchButton) { + this.searchButton.disabled = true; + if (this.searchButtonContent) + this.searchButtonContent.classList.add("d-none"); + if (this.searchButtonLoading) + this.searchButtonLoading.classList.remove("d-none"); + } + } + + /** + * Override hideLoading to restore button contents + */ + hideLoading() { + if (this.searchButton) { + this.searchButton.disabled = false; + if (this.searchButtonContent) + this.searchButtonContent.classList.remove("d-none"); + if (this.searchButtonLoading) + this.searchButtonLoading.classList.add("d-none"); + } + } + + /** + * Delegated handler for selection checkboxes: keep selectedCaptureIds in sync + */ + setupSelectionCheckboxHandler() { + this._checkboxChangeHandler = (e) => { + if (!e.target.matches(".capture-select-checkbox")) return; + const uuid = e.target.getAttribute("data-capture-uuid"); + if (!uuid) return; + if (e.target.checked) { + this.selectedCaptureIds.add(uuid); + } else { + this.selectedCaptureIds.delete(uuid); + } + this._notifySelectionChange(); + }; + document.addEventListener("change", this._checkboxChangeHandler); + } + + /** + * When selection mode is active, clicking a row toggles its selection (instead of opening the modal). + * Uses capture phase so we run before the row's click handler. + */ + setupRowClickSelection() { + const table = document.getElementById(this.tableId); + if (!table) return; + this._rowClickTable = table; + + this._rowClickHandler = (e) => { + if (!table.classList.contains("selection-mode-active")) return; + if ( + e.target.closest( + "button, a, [data-bs-toggle='dropdown'], .capture-select-checkbox", + ) + ) + return; + const row = e.target.closest("tr"); + if (!row) return; + const checkbox = row.querySelector(".capture-select-checkbox"); + if (!checkbox) return; + const uuid = checkbox.getAttribute("data-capture-uuid"); + if (!uuid) return; + + if (this.selectedCaptureIds.has(uuid)) { + this.selectedCaptureIds.delete(uuid); + checkbox.checked = false; + } else { + this.selectedCaptureIds.add(uuid); + checkbox.checked = true; + } + this._notifySelectionChange(); + e.preventDefault(); + e.stopPropagation(); + }; + + table.addEventListener("click", this._rowClickHandler, true); + } + + destroy() { + if (this._checkboxChangeHandler) { + document.removeEventListener("change", this._checkboxChangeHandler); + this._checkboxChangeHandler = null; + } + if (this._rowClickHandler && this._rowClickTable) { + this._rowClickTable.removeEventListener( + "click", + this._rowClickHandler, + true, + ); + this._rowClickHandler = null; + this._rowClickTable = null; + } + super.destroy(); + } + + /** + * Initialize dropdowns with body container for proper positioning + */ + initializeDropdowns() { + const dropdownButtons = document.querySelectorAll(".btn-icon-dropdown"); + + if (dropdownButtons.length === 0) { + return; + } + + for (const toggle of dropdownButtons) { + // Check if dropdown is already initialized + if (toggle._dropdown) { + continue; + } + + const _dropdown = new bootstrap.Dropdown(toggle, { + container: "body", + boundary: "viewport", + popperConfig: { + modifiers: [ + { + name: "preventOverflow", + options: { + boundary: "viewport", + }, + }, + ], + }, + }); + + // Manually move dropdown to body when shown + toggle.addEventListener("show.bs.dropdown", () => { + const dropdownMenu = toggle.nextElementSibling; + if (dropdownMenu?.classList.contains("dropdown-menu")) { + document.body.appendChild(dropdownMenu); + } + }); + } + } + + /** + * Update table with new data + */ + updateTable(captures, hasResults) { + this.selectedCaptureIds ??= new Set(); + const tbody = this.tbody ?? this.table?.querySelector("tbody"); + if (!tbody) return; + + // Update results count + this.updateResultsCount(captures, hasResults); + + if (!hasResults || captures.length === 0) { + tbody.innerHTML = ` + + + No captures found matching your search criteria. + + + `; + this._notifySelectionChange(); + return; + } + + // Build table HTML efficiently + const tableHTML = captures + .map((capture, index) => this.renderRow(capture, index)) + .join(""); + tbody.innerHTML = tableHTML; + + // Initialize dropdowns after table is updated + this.initializeDropdowns(); + this._notifySelectionChange(); + } + + /** + * Update results count display + */ + updateResultsCount(captures, hasResults) { + if (this.resultsCountElement) { + const count = hasResults && captures ? captures.length : 0; + const pluralSuffix = count === 1 ? "" : "s"; + this.resultsCountElement.textContent = `${count} capture${pluralSuffix} found`; + } + } + + /** + * Render individual table row with XSS protection + * Overrides the base class method to include file-specific columns + */ + renderRow(capture) { + this.selectedCaptureIds ??= new Set(); + // Sanitize all data before rendering + const safeData = { + uuid: ComponentUtils.escapeHtml(capture.uuid || ""), + name: ComponentUtils.escapeHtml(capture.name || ""), + channel: ComponentUtils.escapeHtml(capture.channel || ""), + scanGroup: ComponentUtils.escapeHtml(capture.scan_group || ""), + captureType: ComponentUtils.escapeHtml(capture.capture_type || ""), + captureTypeDisplay: ComponentUtils.escapeHtml( + capture.capture_type_display || "", + ), + topLevelDir: ComponentUtils.escapeHtml(capture.top_level_dir || ""), + indexName: ComponentUtils.escapeHtml(capture.index_name || ""), + owner: ComponentUtils.escapeHtml(capture.owner || ""), + origin: ComponentUtils.escapeHtml(capture.origin || ""), + dataset: ComponentUtils.escapeHtml(capture.dataset || ""), + createdAt: ComponentUtils.escapeHtml(capture.created_at || ""), + updatedAt: ComponentUtils.escapeHtml(capture.updated_at || ""), + isPublic: ComponentUtils.escapeHtml(capture.is_public || ""), + isDeleted: ComponentUtils.escapeHtml(capture.is_deleted || ""), + centerFrequencyGhz: ComponentUtils.escapeHtml( + capture.center_frequency_ghz || "", + ), + lengthOfCaptureMs: capture.length_of_capture_ms ?? 0, + fileCadenceMs: capture.file_cadence_ms ?? 1000, + perDataFileSize: capture.per_data_file_size ?? 0, + totalSize: capture.total_file_size ?? 0, + dataFilesCount: capture.data_files_count ?? 0, + dataFilesTotalSize: capture.data_files_total_size ?? 0, + totalFilesCount: capture.files.length ?? 0, + captureStartEpochSec: capture.capture_start_epoch_sec ?? 0, + }; + + let typeDisplay = safeData.captureTypeDisplay || safeData.captureType; + + if (capture.is_multi_channel) { + typeDisplay = capture.capture_type_display || safeData.captureType; + } + + // Display name with fallback to "Unnamed Capture" + const nameDisplay = safeData.name || "Unnamed Capture"; + + // Format created date to match template format + let createdDate = "-"; + if (capture.created_at) { + const date = new Date(capture.created_at); + if (!Number.isNaN(date.getTime())) { + const dateStr = date.toISOString().split("T")[0]; // YYYY-MM-DD + const timeStr = date.toLocaleTimeString("en-US", { + hour12: false, + timeZoneName: "short", + }); // HH:mm:ss TZ + createdDate = ` +
    + ${dateStr} + ${timeStr} +
    + `; + } + } + + // Check if shared (for shared icon) + const isShared = capture.is_shared_with_me || false; + const sharedIcon = isShared + ? ` + + ` + : ""; + + // Check if owner (for conditional actions and selection — only owned captures are selectable) + const isOwner = capture.is_owner === true; + + const checked = this.selectedCaptureIds.has(capture.uuid) ? " checked" : ""; + const selectCell = isOwner + ? `` + : ''; + return ` + + ${selectCell} + + + ${nameDisplay} + + ${sharedIcon} + + ${safeData.topLevelDir || "-"} + ${typeDisplay} + ${createdDate} + + + + + `; + } +} + +// Expose frequency slider initialization function globally for backward compatibility +window.initializeFrequencySlider = () => { + // This function is called from the template + if (window.fileListController) { + window.fileListController.initializeFrequencyFromURL(); + } +}; + +// Initialize the application when DOM is loaded +document.addEventListener("DOMContentLoaded", () => { + try { + window.fileListController = new FileListController(); + } catch (error) { + console.error("Error initializing file list controller:", error); + } +}); + +// Export for ES6 modules (Jest testing) - only if in module context +if (typeof module !== "undefined" && module.exports) { + module.exports = { FileListController, FileListCapturesTableManager }; +} diff --git a/gateway/sds_gateway/static/js/deprecated/file-manager.js b/gateway/sds_gateway/static/js/deprecated/file-manager.js new file mode 100644 index 000000000..cbe928cd4 --- /dev/null +++ b/gateway/sds_gateway/static/js/deprecated/file-manager.js @@ -0,0 +1,1625 @@ +class FileManager { + constructor() { + // Check browser compatibility before proceeding + if (!this.checkBrowserSupport()) { + this.showError( + "Your browser doesn't support required features. Please use a modern browser.", + null, + "browser-compatibility", + ); + return; + } + + this.droppedFiles = null; + this.boundHandlers = new Map(); // Track bound event handlers for cleanup + this.activeModals = new Set(); // Track active modals + + // Prevent browser from navigating away when user drags files over the whole window + this.addGlobalDropGuards(); + this.init(); + } + + addGlobalDropGuards() { + // Prevent browser navigation on any drop event + document.addEventListener( + "dragover", + (e) => { + e.preventDefault(); + }, + false, + ); + + document.addEventListener( + "drop", + (e) => { + e.preventDefault(); + e.stopPropagation(); + + // Always handle global drops for testing + this.handleGlobalDrop(e); + }, + false, + ); + } + + async handleGlobalDrop(e) { + const dt = e.dataTransfer; + if (!dt) { + console.warn("No dataTransfer in global drop"); + return; + } + + const files = await this.collectFilesFromDataTransfer(dt); + + if (!files.length) { + console.warn("No files collected from global drop"); + return; + } + + // Store the dropped files globally + window.selectedFiles = files; + + // Open the upload modal + const uploadModalEl = document.getElementById("uploadCaptureModal"); + if (!uploadModalEl) { + console.error("Upload modal element not found"); + return; + } + + const uploadModal = new bootstrap.Modal(uploadModalEl); + uploadModal.show(); + + // Wait a bit for modal to fully open, then trigger file selection + setTimeout(() => { + this.handleGlobalFilesInModal(files); + }, 200); + } + + handleGlobalFilesInModal(files) { + // Update the file input to show selected files + const fileInput = document.getElementById("captureFileInput"); + if (fileInput) { + // Create a new FileList-like object + const dataTransfer = new DataTransfer(); + for (const file of files) { + dataTransfer.items.add(file); + } + fileInput.files = dataTransfer.files; + } + + // Update the selected files display + this.handleFileSelection(files); + + // Make sure the selected files section is visible + const selectedFilesSection = document.getElementById("selectedFiles"); + if (selectedFilesSection) { + selectedFilesSection.classList.add("has-files"); + } + + // Update the file input label to show selected files + const fileInputLabel = fileInput?.nextElementSibling; + if (fileInputLabel?.classList.contains("form-control")) { + const fileNames = files + .map((f) => f.webkitRelativePath || f.name) + .join(", "); + fileInputLabel.textContent = fileNames || "No directory selected."; + } + } + + convertToFiles(itemsOrFiles) { + if (!itemsOrFiles) return []; + // DataTransferItemList detection: items have getAsFile() + const first = itemsOrFiles[0]; + if (first && typeof first.getAsFile === "function") { + return Array.from(itemsOrFiles) + .map((item) => item.getAsFile()) + .filter((f) => !!f); + } + return Array.from(itemsOrFiles); + } + + // Collect files from a directory or mixed drop using the File System API (Chrome/WebKit) + async collectFilesFromDataTransfer(dataTransfer) { + const items = Array.from(dataTransfer.items || []); + const supportsEntries = + items.length > 0 && typeof items[0].webkitGetAsEntry === "function"; + if (!supportsEntries) { + return this.convertToFiles( + dataTransfer.files?.length ? dataTransfer.files : dataTransfer.items, + ); + } + + const allFiles = []; + for (const item of items) { + if (item.kind !== "file") continue; + const entry = item.webkitGetAsEntry(); + if (!entry) continue; + const files = await this.traverseEntry(entry); + allFiles.push(...files); + } + return allFiles; + } + + async traverseEntry(entry) { + if (entry.isFile) { + return new Promise((resolve) => { + entry.file((file) => { + // Preserve folder structure on drop by injecting webkitRelativePath + try { + const relative = (entry.fullPath || file.name).replace(/^\//, ""); + Object.defineProperty(file, "webkitRelativePath", { + value: relative, + configurable: true, + }); + } catch (_) {} + resolve([file]); + }); + }); + } + + if (entry.isDirectory) { + const reader = entry.createReader(); + const entries = await this.readAllEntries(reader); + const nestedFiles = []; + for (const child of entries) { + const files = await this.traverseEntry(child); + nestedFiles.push(...files); + } + return nestedFiles; + } + + return []; + } + + readAllEntries(reader) { + return new Promise((resolve) => { + const entries = []; + const readChunk = () => { + reader.readEntries((results) => { + if (!results.length) { + resolve(entries); + return; + } + entries.push(...results); + readChunk(); + }); + }; + readChunk(); + }); + } + + stripHtml(html) { + if (!html) return ""; + const div = document.createElement("div"); + div.innerHTML = html; + return (div.textContent || div.innerText || "").trim(); + } + + init() { + // Get container and data + this.container = document.querySelector(".files-container"); + if (!this.container) { + this.showError("Files container not found", null, "initialization"); + return; + } + + // Get data attributes + this.currentDir = this.container.dataset.currentDir; + this.userEmail = this.container.dataset.userEmail; + + // Get all file cards for data + const fileCards = document.querySelectorAll(".file-card:not(.header)"); + const items = Array.from(fileCards).map((card) => ({ + type: card.dataset.type, + name: card.querySelector(".file-name").textContent, + path: card.dataset.path, + uuid: card.dataset.uuid, + is_capture: card.dataset.isCapture === "true", + + is_shared: card.dataset.isShared === "true", + capture_uuid: card.dataset.captureUuid, + description: card.dataset.description, + modified_at: card.querySelector(".file-meta").textContent.trim(), + shared_by: card.querySelector(".file-shared").textContent.trim(), + })); + + // Get dataset options + const datasetSelect = document.getElementById("datasetSelect"); + const datasets = datasetSelect + ? Array.from(datasetSelect.options) + .slice(1) + .map((opt) => ({ + name: opt.text, + uuid: opt.value, + })) + : []; + + // Initialize all handlers + this.initializeEventListeners(); + this.initializeUploadHandlers(); + this.initializeFileClicks(); + } + + initializeEventListeners() { + const fileCards = document.querySelectorAll(".file-card"); + + for (const card of fileCards) { + if (!card.classList.contains("header")) { + const type = card.dataset.type; + // Add click handlers to directories and files + card.addEventListener("click", (e) => + this.handleFileCardClick(e, card), + ); + // Basic keyboard accessibility + card.setAttribute("tabindex", "0"); + card.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + this.handleFileCardClick(e, card); + } + }); + + if (type === "directory") { + card.style.cursor = "pointer"; + card.classList.add("clickable-directory"); + } else if (type === "file") { + card.style.cursor = "pointer"; + card.classList.add("clickable-file"); + } + } + } + } + + initializeUploadHandlers() { + // Initialize capture upload + const captureElements = { + uploadZone: document.getElementById("uploadZone"), + fileInput: document.getElementById("captureFileInput"), + // browseButton is optional in Files modal styling + browseButton: document.querySelector( + "#uploadCaptureModal .browse-button", + ), + selectedFilesList: document.getElementById("selectedFilesList"), + uploadForm: document.getElementById("uploadCaptureForm"), + }; + + // Only require the essentials; browseButton may be missing + const essentials = [ + captureElements.uploadZone, + captureElements.fileInput, + captureElements.selectedFilesList, + captureElements.uploadForm, + ]; + // Skip initialization if we're on the files page which has its own custom handler + const isFilesPage = window.location.pathname.includes("/users/files/"); + if (essentials.every((el) => el) && !isFilesPage) { + this.initializeCaptureUpload(captureElements); + } + + // Initialize text file upload + const textUploadForm = document.getElementById("uploadFileForm"); + if (textUploadForm) { + this.initializeTextFileUpload(textUploadForm); + } + } + + initializeCaptureUpload(elements) { + const { + uploadZone, + fileInput, + browseButton, + selectedFilesList, + uploadForm, + } = elements; + + if (!uploadZone || !fileInput || !selectedFilesList || !uploadForm) { + this.showError( + "Upload elements not found", + null, + "upload-initialization", + ); + return; + } + + // Handle browse button click (if present) + if (browseButton) { + browseButton.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + fileInput.click(); + }); + } + + // Handle drag and drop + uploadZone.addEventListener("dragover", (e) => { + e.preventDefault(); + uploadZone.classList.add("drag-over"); + }); + + uploadZone.addEventListener("dragleave", () => { + uploadZone.classList.remove("drag-over"); + }); + + uploadZone.addEventListener("drop", async (e) => { + e.preventDefault(); + uploadZone.classList.remove("drag-over"); + const dt = e.dataTransfer; + if (dt) { + const files = await this.collectFilesFromDataTransfer(dt); + this.droppedFiles = files; + // Clear any existing input selection so we rely on dropped files on submit + try { + fileInput.value = ""; + } catch (_) {} + this.handleFileSelection(files); + } + }); + + // Handle file input change + fileInput.addEventListener("change", (e) => { + this.droppedFiles = null; // prefer explicit file input selection + this.handleFileSelection(this.convertToFiles(e.target.files)); + }); + + // Toggle DRF/RH input groups + const typeSelect = document.getElementById("captureTypeSelect"); + const channelGroup = document.getElementById("channelInputGroup"); + const scanGroup = document.getElementById("scanGroupInputGroup"); + const channelInput = document.getElementById("captureChannelsInput"); + + if (typeSelect) { + typeSelect.addEventListener("change", () => { + const v = typeSelect.value; + + // Use Bootstrap classes instead of inline styles + if (channelGroup) { + if (v === "drf") { + channelGroup.classList.remove("d-none"); + channelGroup.style.display = ""; + } else { + channelGroup.classList.add("d-none"); + } + } + + if (scanGroup) { + if (v === "rh") { + scanGroup.classList.remove("d-none"); + scanGroup.style.display = ""; + } else { + scanGroup.classList.add("d-none"); + } + } + + if (channelInput) { + if (v === "drf") { + channelInput.setAttribute("required", "required"); + } else { + channelInput.removeAttribute("required"); + } + } + }); + + // Trigger change event to set initial state + typeSelect.dispatchEvent(new Event("change")); + } + + // Check for globally dropped files when modal opens + if (window.selectedFiles?.length) { + this.handleFileSelection(window.selectedFiles); + } + + // Handle form submission + uploadForm.addEventListener("submit", async (e) => { + e.preventDefault(); + + const formData = new FormData(); + const submitBtn = uploadForm.querySelector('button[type="submit"]'); + const uploadText = submitBtn.querySelector(".upload-text"); + const uploadSpinner = submitBtn.querySelector(".upload-spinner"); + + try { + submitBtn.disabled = true; + uploadText.classList.add("d-none"); + uploadSpinner.classList.remove("d-none"); + + // Get CSRF token and add it when present + const csrfToken = this.getCsrfToken(); + if (csrfToken) { + formData.append("csrfmiddlewaretoken", csrfToken); + } + + // Add capture type and channels from the form + const captureType = document.getElementById("captureTypeSelect").value; + const channels = + document.getElementById("captureChannelsInput")?.value || ""; + const scanGroupVal = + document.getElementById("captureScanGroupInput")?.value || ""; + formData.append("capture_type", captureType); + formData.append("channels", channels); + if (captureType === "rh" && scanGroupVal) { + formData.append("scan_group", scanGroupVal); + } + + // Add files and their relative paths - check for globally dropped files first + const files = window.selectedFiles?.length + ? Array.from(window.selectedFiles) + : this.droppedFiles?.length + ? Array.from(this.droppedFiles) + : Array.from(fileInput.files); + + // Create an array of relative paths in the same order as files + const relativePaths = files.map( + (file) => file.webkitRelativePath || file.name, + ); + + // Add each file + for (const [index, file] of files.entries()) { + formData.append("files", file); + formData.append("relative_paths", relativePaths[index]); + } + + await this.handleUpload(formData, submitBtn, "uploadCaptureModal", { + files, + }); + } catch (error) { + const userMessage = this.getUserFriendlyErrorMessage( + error, + "capture-upload", + ); + this.showError( + `Upload failed: ${userMessage}`, + error, + "capture-upload", + ); + } finally { + submitBtn.disabled = false; + uploadText.classList.remove("d-none"); + uploadSpinner.classList.add("d-none"); + this.droppedFiles = null; + window.selectedFiles = null; // Clear global files after upload + } + }); + } + + initializeTextFileUpload(uploadForm) { + uploadForm.addEventListener("submit", async (e) => { + e.preventDefault(); + + const formData = new FormData(uploadForm); + const submitBtn = uploadForm.querySelector('button[type="submit"]'); + const uploadText = submitBtn.querySelector(".upload-text"); + const uploadSpinner = submitBtn.querySelector(".upload-spinner"); + + try { + submitBtn.disabled = true; + uploadText.classList.add("d-none"); + uploadSpinner.classList.remove("d-none"); + + // CSRF token attached in handleUpload + + await this.handleUpload(formData, submitBtn, "uploadFileModal"); + } catch (error) { + const userMessage = this.getUserFriendlyErrorMessage( + error, + "text-upload", + ); + this.showError(`Upload failed: ${userMessage}`, error, "text-upload"); + } finally { + submitBtn.disabled = false; + uploadText.classList.remove("d-none"); + uploadSpinner.classList.add("d-none"); + } + }); + } + + initializeFileClicks() { + // Wire up download confirmation for dataset and capture buttons + document.addEventListener("click", (e) => { + if ( + e.target.matches(".download-capture-btn") || + e.target.closest(".download-capture-btn") + ) { + e.preventDefault(); + e.stopPropagation(); + const btn = e.target.matches(".download-capture-btn") + ? e.target + : e.target.closest(".download-capture-btn"); + const captureUuid = btn.dataset.captureUuid; + const captureName = btn.dataset.captureName || captureUuid; + + // Validate UUID before proceeding + if (!this.isValidUuid(captureUuid)) { + console.warn("Invalid capture UUID:", captureUuid); + this.showError("Invalid capture identifier", null, "download"); + return; + } + + // Update modal text + const nameEl = document.getElementById("downloadCaptureName"); + if (nameEl) nameEl.textContent = captureName; + + // Show modal using helper method + this.openModal("downloadModal"); + + // Confirm handler + const confirmBtn = document.getElementById("confirmDownloadBtn"); + if (confirmBtn) { + const onConfirm = () => { + this.closeModal("downloadModal"); + + // Use unified download handler if available + if (window.components?.handleDownload) { + const dummyButton = document.createElement("button"); + dummyButton.style.display = "none"; + window.components.handleDownload( + "capture", + captureUuid, + dummyButton, + ); + } + }; + confirmBtn.addEventListener("click", onConfirm, { once: true }); + } + } + + if ( + e.target.matches(".download-dataset-btn") || + e.target.closest(".download-dataset-btn") + ) { + e.preventDefault(); + e.stopPropagation(); + const btn = e.target.matches(".download-dataset-btn") + ? e.target + : e.target.closest(".download-dataset-btn"); + const datasetUuid = btn.dataset.datasetUuid; + + // Validate UUID before proceeding + if (!this.isValidUuid(datasetUuid)) { + console.warn("Invalid dataset UUID:", datasetUuid); + this.showError("Invalid dataset identifier", null, "download"); + return; + } + // Show modal using helper method + this.openModal("downloadModal"); + const confirmBtn = document.getElementById("confirmDownloadBtn"); + if (confirmBtn) { + const onConfirm = () => { + this.closeModal("downloadModal"); + fetch( + `/users/download-item/dataset/${encodeURIComponent(datasetUuid)}/`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": this.getCsrfToken(), + }, + }, + ) + .then(async (r) => { + try { + return await r.json(); + } catch (_) { + return {}; + } + }) + .catch(() => {}); + }; + confirmBtn.addEventListener("click", onConfirm, { once: true }); + } + } + + // Single file direct download link (GET) + const fileDownloadLink = + e.target.closest( + 'a.dropdown-item[href^="/users/files/"][href$="/download/"]', + ) || + e.target.closest( + '.dropdown-menu a[href^="/users/files/"][href$="/download/"]', + ) || + e.target.closest('a[href^="/users/files/"][href$="/download/"]'); + if (fileDownloadLink) { + e.preventDefault(); + e.stopPropagation(); + const card = fileDownloadLink.closest(".file-card"); + const fileName = + card?.querySelector(".file-name")?.textContent?.trim() || "File"; + // Use helper method to show success message + this.showSuccessMessage(`Download starting: ${fileName}`); + const href = fileDownloadLink.getAttribute("href"); + try { + window.open(href, "_blank"); + } catch (_) { + window.location.href = href; + } + return; + } + }); + } + + async handleUpload(formData, submitBtn, modalId, options = {}) { + const uploadText = submitBtn.querySelector(".upload-text"); + const uploadSpinner = submitBtn.querySelector(".upload-spinner"); + + try { + // Update UI + submitBtn.disabled = true; + uploadText.classList.add("d-none"); + uploadSpinner.classList.remove("d-none"); + + // Make request with progress (XHR for upload progress events) + const response = await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", "/users/upload-files/"); + xhr.withCredentials = true; + xhr.setRequestHeader("X-CSRFToken", this.getCsrfToken()); + xhr.setRequestHeader("Accept", "application/json"); + + // Progress UI elements + smoothing state + const wrap = document.getElementById("captureUploadProgressWrap"); + const bar = document.getElementById("captureUploadProgressBar"); + const text = document.getElementById("captureUploadProgressText"); + if (wrap) wrap.classList.remove("d-none"); + if (bar) { + bar.classList.add("progress-bar-striped", "progress-bar-animated"); + bar.style.width = "100%"; + bar.setAttribute("aria-valuenow", "100"); + bar.textContent = ""; + } + if (text) text.textContent = "Uploading…"; + + xhr.upload.onprogress = () => { + // Keep indeterminate to match button spinner timing (no file count) + if (text) text.textContent = "Uploading…"; + }; + + xhr.onerror = () => reject(new Error("Network error during upload")); + xhr.upload.onloadstart = () => { + if (text) text.textContent = "Starting upload…"; + }; + xhr.upload.onloadend = () => { + if (bar) { + bar.classList.add("progress-bar-striped", "progress-bar-animated"); + bar.style.width = "100%"; + bar.setAttribute("aria-valuenow", "100"); + bar.textContent = ""; + } + if (text) text.textContent = "Processing on server…"; + }; + xhr.onload = () => { + // Build a Response-like object compatible with existing code + const status = xhr.status; + const headers = new Headers({ + "content-type": xhr.getResponseHeader("content-type") || "", + }); + const bodyText = xhr.responseText || ""; + const responseLike = { + ok: status >= 200 && status < 300, + status, + headers, + json: async () => { + try { + return JSON.parse(bodyText); + } catch { + return {}; + } + }, + text: async () => bodyText, + }; + resolve(responseLike); + }; + + xhr.send(formData); + }); + + let result = null; + let fallbackText = ""; + try { + const contentType = response.headers.get("content-type") || ""; + if (contentType.includes("application/json")) { + result = await response.json(); + } else { + fallbackText = await response.text(); + } + } catch (_) {} + + if (response.ok) { + // Build a concise success message for inline banner + let successMessage = "Upload complete."; + if (result && (result.files_uploaded || result.total_files)) { + const uploaded = result.files_uploaded ?? result.total_uploaded ?? 0; + const total = result.total_files ?? result.total_uploaded ?? 0; + successMessage = `Upload complete: ${uploaded} / ${total} file${total === 1 ? "" : "s"} uploaded.`; + if (Array.isArray(result.errors) && result.errors.length) { + successMessage += " Some items were skipped or failed."; + } + } + try { + sessionStorage.setItem( + "filesAlert", + JSON.stringify({ + message: successMessage, + type: "success", + }), + ); + } catch (_) {} + // Reload to show inline banner on main page + window.location.reload(); + } else { + let message = ""; + if (result && (result.detail || result.error || result.message)) { + message = result.detail || result.error || result.message; + } else if (fallbackText) { + message = this.stripHtml(fallbackText) + .split("\n") + .slice(0, 3) + .join(" "); + } + if (!message) message = `Upload failed (${response.status})`; + // Friendly mapping for common statuses + if (response.status === 409) { + message = + "Upload skipped: a file with the same checksum already exists. Use PATCH to replace, or change the file."; + } + throw new Error(message); + } + } catch (error) { + const userMessage = this.getUserFriendlyErrorMessage( + error, + "upload-handler", + ); + try { + sessionStorage.setItem( + "filesAlert", + JSON.stringify({ + message: `Upload failed: ${userMessage}`, + type: "error", + }), + ); + // Reload to display the banner via template startup script + window.location.reload(); + } catch (_) { + this.showError( + `Upload failed: ${userMessage}`, + error, + "upload-handler", + ); + } + } finally { + // Reset UI + submitBtn.disabled = false; + uploadText.classList.remove("d-none"); + uploadSpinner.classList.add("d-none"); + } + } + + showUploadSuccess(result, modalId) { + const resultModal = new bootstrap.Modal( + document.getElementById("uploadResultModal"), + ); + const resultBody = document.getElementById("uploadResultModalBody"); + + resultBody.innerHTML = ` +
    +
    Upload Complete!
    + ${ + result.files_uploaded + ? `

    Files uploaded: ${result.files_uploaded} / ${result.total_files}

    ` + : "

    File uploaded successfully!

    " + } + ${result.errors ? `

    Errors: ${result.errors.join("
    ")}

    ` : ""} +
    + `; + + // Close upload modal and show result (guard instance) + const uploadModalEl = document.getElementById(modalId); + const uploadModalInstance = uploadModalEl + ? bootstrap.Modal.getInstance(uploadModalEl) + : null; + if (uploadModalInstance) { + uploadModalInstance.hide(); + } + resultModal.show(); + } + + // File preview methods + async showTextFilePreview(fileUuid, fileName) { + try { + // Check if this is a file we should preview + if (!this.shouldPreviewFile(fileName)) { + this.showError("This file type cannot be previewed"); + return; + } + + const content = await this.fetchFileContent(fileUuid); + this.showPreviewModal(fileName, content); + } catch (error) { + if (error.message === "File too large to preview") { + this.showError( + "File is too large to preview. Please download it instead.", + error, + "file-preview", + ); + } else { + const userMessage = this.getUserFriendlyErrorMessage( + error, + "file-preview", + ); + this.showError(userMessage, error, "file-preview"); + } + } + } + + shouldPreviewFile(fileName) { + const extension = this.getFileExtension(fileName); + return this.isPreviewableFileType(extension); + } + + async fetchFileContent(fileUuid) { + const response = await fetch(`/users/files/${fileUuid}/content/`); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to fetch file content"); + } + + return response.text(); + } + + showPreviewModal(fileName, content) { + const modal = document.getElementById("filePreviewModal"); + const modalTitle = modal.querySelector(".modal-title"); + const previewContent = modal.querySelector(".preview-content"); + + // Enhanced accessibility + modal.setAttribute("aria-label", `Preview of ${fileName}`); + modal.setAttribute("aria-describedby", "preview-content"); + modal.setAttribute("role", "dialog"); + + modalTitle.textContent = fileName; + modalTitle.setAttribute("id", "preview-modal-title"); + + // Clear previous content + previewContent.innerHTML = ""; + previewContent.setAttribute("id", "preview-content"); + previewContent.setAttribute("aria-label", `Content of ${fileName}`); + + // Check if we should use syntax highlighting + if (this.shouldUseSyntaxHighlighting(fileName)) { + this.showSyntaxHighlightedContent(previewContent, content, fileName); + } else { + // Basic text display + const preElement = this.createElement("pre", "preview-text", content); + preElement.setAttribute("aria-label", `Text content of ${fileName}`); + previewContent.appendChild(preElement); + } + + new bootstrap.Modal(modal).show(); + } + + // Helper methods for syntax highlighting + getFileExtension(fileName) { + return fileName.split(".").pop().toLowerCase(); + } + + // Helper method to open modal with fallbacks + openModal(modalId) { + this.activeModals.add(modalId); + + if (window.components?.openCustomModal) { + window.components.openCustomModal(modalId); + } else if (typeof openCustomModal === "function") { + openCustomModal(modalId); + } else if (window.openCustomModal) { + window.openCustomModal(modalId); + } else { + const modal = document.getElementById(modalId); + if (modal) modal.style.display = "block"; + } + } + + // Helper method to close modal with fallbacks + closeModal(modalId) { + this.activeModals.delete(modalId); + + if (window.components?.closeCustomModal) { + window.components.closeCustomModal(modalId); + } else if (typeof closeCustomModal === "function") { + closeCustomModal(modalId); + } else if (window.closeCustomModal) { + window.closeCustomModal(modalId); + } else { + const modal = document.getElementById(modalId); + if (modal) modal.style.display = "none"; + } + } + + // Helper method to show success message with fallbacks + showSuccessMessage(message) { + if (window.components?.showSuccess) { + window.components.showSuccess(message); + } else { + const live = document.getElementById("aria-live-region"); + if (live) live.textContent = message; + } + } + + // Helper method to get CSRF token + getCsrfToken() { + return document.querySelector("[name=csrfmiddlewaretoken]")?.value || ""; + } + + // Helper method to check if file has extension + hasFileExtension(fileName) { + return /\.[^./]+$/.test(fileName); + } + + // Input validation methods + isValidUuid(uuid) { + if (!uuid || typeof uuid !== "string") return false; + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + return uuidRegex.test(uuid); + } + + isValidFileName(fileName) { + if (!fileName || typeof fileName !== "string") return false; + // Check for invalid characters and length + const invalidChars = /[<>:"/\\|?*]/; + return !invalidChars.test(fileName) && fileName.length <= 255; + } + + isValidPath(path) { + if (!path || typeof path !== "string") return false; + // Check for path traversal attempts and invalid characters + const invalidPathPatterns = /\.\.|[<>:"|?*]/; + return !invalidPathPatterns.test(path) && path.length <= 4096; + } + + sanitizeFileName(fileName) { + if (!fileName) return ""; + // Remove or replace invalid characters + return fileName.replace(/[<>:"/\\|?*]/g, "_"); + } + + // Helper method to create DOM element with attributes + createElement(tag, className, innerHTML) { + const element = document.createElement(tag); + if (className) element.className = className; + if (innerHTML) element.innerHTML = innerHTML; + return element; + } + + // Helper method to check if file type is previewable + isPreviewableFileType(extension) { + const nonPreviewableExtensions = [ + "7z", + "a", + "accdb", + "ai", + "avi", + "bak", + "bin", + "bz2", + "class", + "dll", + "dmg", + "doc", + "docx", + "ear", + "eps", + "exe", + "flv", + "gz", + "h5", + "hdf", + "hdf5", + "img", + "iso", + "jar", + "lib", + "log", + "mat", + "mdb", + "mov", + "mp3", + "mp4", + "nc", + "netcdf", + "obj", + "odp", + "ods", + "odt", + "o", + "out", + "pdf", + "pdb", + "pkg", + "ppt", + "pptx", + "psd", + "r", + "rar", + "rdata", + "rds", + "raw", + "rpm", + "sav", + "so", + "sqlite", + "svg", + "tar", + "temp", + "tmp", + "war", + "wmv", + "xls", + "xlsx", + "zip", + ]; + return !nonPreviewableExtensions.includes(extension); + } + + // Helper method to extract text from notebook cell source + extractCellSourceText(source) { + if (Array.isArray(source)) { + return source.join("") || ""; + } + if (typeof source === "string") { + return source; + } + return String(source || ""); + } + + // Helper method to extract text from notebook cell output + extractCellOutputText(output) { + if (output.output_type === "stream") { + return Array.isArray(output.text) + ? output.text.join("") + : String(output.text || ""); + } + if (output.output_type === "execute_result") { + return output.data?.["text/plain"] + ? Array.isArray(output.data["text/plain"]) + ? output.data["text/plain"].join("") + : String(output.data["text/plain"]) + : ""; + } + return ""; + } + + getLanguageFromExtension(extension) { + const languageMap = { + js: "javascript", + jsx: "javascript", + ts: "typescript", + tsx: "typescript", + py: "python", + pyw: "python", + ipynb: "json", // Jupyter notebooks are JSON + json: "json", + xml: "markup", + html: "markup", + htm: "markup", + css: "css", + scss: "css", + sass: "css", + sh: "bash", + bash: "bash", + zsh: "bash", + fish: "bash", + c: "c", + cpp: "cpp", + cc: "cpp", + cxx: "cpp", + h: "c", + hpp: "cpp", + java: "java", + php: "php", + rb: "ruby", + go: "go", + rs: "rust", + swift: "swift", + kt: "kotlin", + scala: "scala", + clj: "clojure", + hs: "haskell", + ml: "ocaml", + fs: "fsharp", + cs: "csharp", + vb: "vbnet", + sql: "sql", + r: "r", + m: "matlab", + pl: "perl", + tcl: "tcl", + lua: "lua", + vim: "vim", + yaml: "yaml", + yml: "yaml", + toml: "toml", + ini: "ini", + cfg: "ini", + conf: "ini", + md: "markdown", + markdown: "markdown", + txt: "text", + log: "text", + }; + return languageMap[extension] || "text"; + } + + shouldUseSyntaxHighlighting(fileName) { + const extension = this.getFileExtension(fileName); + const highlightableExtensions = [ + "js", + "jsx", + "ts", + "tsx", + "py", + "pyw", + "ipynb", + "json", + "xml", + "html", + "htm", + "css", + "scss", + "sass", + "sh", + "bash", + "zsh", + "fish", + "c", + "cpp", + "cc", + "cxx", + "h", + "hpp", + "java", + "php", + "rb", + "go", + "rs", + "swift", + "kt", + "scala", + "clj", + "hs", + "ml", + "fs", + "cs", + "vb", + "sql", + "r", + "m", + "pl", + "tcl", + "lua", + "vim", + "yaml", + "yml", + "toml", + "ini", + "cfg", + "conf", + "md", + "markdown", + ]; + return highlightableExtensions.includes(extension); + } + + showSyntaxHighlightedContent(container, content, fileName) { + const extension = this.getFileExtension(fileName); + const language = this.getLanguageFromExtension(extension); + + // Special handling for Jupyter notebooks + if (extension === "ipynb") { + this.showJupyterNotebookPreview(container, content, fileName); + return; + } + + // Create code element with language class + const codeElement = this.createElement("code", `language-${language}`); + codeElement.textContent = content; + + // Create pre element + const preElement = this.createElement("pre", "syntax-highlighted"); + preElement.appendChild(codeElement); + + // Add to container + container.appendChild(preElement); + + // Apply Prism.js highlighting + if (window.Prism) { + window.Prism.highlightElement(codeElement); + } + } + + showJupyterNotebookPreview(container, content, fileName) { + try { + // Parse the JSON content + const notebook = JSON.parse(content); + + // Create a container for the notebook preview + const notebookContainer = this.createElement( + "div", + "jupyter-notebook-preview", + ); + + // Add notebook metadata header + const header = this.createElement("div", "notebook-header"); + header.innerHTML = ` +
    + + ${notebook.metadata?.title || fileName} +
    +
    + ${notebook.metadata?.kernelspec?.display_name || "Python"} + ${notebook.cells?.length || 0} cells +
    + `; + notebookContainer.appendChild(header); + + // Process each cell + if (notebook.cells && Array.isArray(notebook.cells)) { + notebook.cells.forEach((cell, index) => { + const cellElement = this.createNotebookCell(cell, index); + notebookContainer.appendChild(cellElement); + }); + } + + container.appendChild(notebookContainer); + } catch (error) { + // Fallback to JSON display if parsing fails + console.warn( + "Failed to parse Jupyter notebook, falling back to JSON:", + error, + ); + this.showSyntaxHighlightedContent(container, content, "fallback.json"); + } + } + + createNotebookCell(cell, index) { + const cellContainer = this.createElement( + "div", + `notebook-cell ${cell.cell_type}`, + ); + const cellHeader = this.createElement("div", "cell-header"); + + let headerContent = ""; + if (cell.cell_type === "code") { + const execCount = + cell.execution_count !== null ? cell.execution_count : " "; + headerContent = ` + Code + In [${execCount}]: + `; + } else { + headerContent = `Markdown`; + } + + cellHeader.innerHTML = headerContent; + cellContainer.appendChild(cellHeader); + + // Cell content + const cellContent = this.createElement("div", "cell-content"); + + if (cell.cell_type === "code") { + // Code cell with syntax highlighting + const codeElement = this.createElement("code", "language-python"); + const sourceText = this.extractCellSourceText(cell.source); + codeElement.textContent = sourceText; + + const preElement = this.createElement("pre"); + preElement.appendChild(codeElement); + cellContent.appendChild(preElement); + + // Apply syntax highlighting + if (window.Prism) { + window.Prism.highlightElement(codeElement); + } + + // Add output if present + if (cell.outputs && cell.outputs.length > 0) { + const outputContainer = this.createElement("div", "cell-output"); + outputContainer.innerHTML = `Out [${cell.execution_count}]:`; + + for (const output of cell.outputs) { + const outputText = this.extractCellOutputText(output); + if (outputText) { + const outputElement = this.createElement( + "pre", + output.output_type === "stream" + ? "output-stream" + : "output-result", + ); + outputElement.textContent = outputText; + outputContainer.appendChild(outputElement); + } + } + + cellContent.appendChild(outputContainer); + } + } else { + // Markdown cell + const markdownElement = this.createElement("div", "markdown-content"); + const sourceText = this.extractCellSourceText(cell.source); + markdownElement.textContent = sourceText; + cellContent.appendChild(markdownElement); + } + + cellContainer.appendChild(cellContent); + return cellContainer; + } + + showError(message, error = null, context = "") { + // Log error details for debugging + if (error) { + console.error(`FileManager Error [${context}]:`, { + message: error.message, + stack: error.stack, + userMessage: message, + timestamp: new Date().toISOString(), + userAgent: navigator.userAgent, + }); + } else { + console.warn(`FileManager Warning [${context}]:`, message); + } + + // Show user-friendly error message + if (window.components?.showError) { + window.components.showError(message); + return; + } + const live = document.getElementById("aria-live-region"); + if (live) { + live.textContent = message; + return; + } + // Final fallback: inline banner near top + const container = + document.querySelector(".container-fluid") || document.body; + const div = this.createElement( + "div", + "alert alert-danger alert-dismissible fade show", + `${message}`, + ); + container.insertBefore(div, container.firstChild); + } + + // Enhanced error message formatting + getUserFriendlyErrorMessage(error, context = "") { + if (!error) return "An unexpected error occurred"; + + // Handle common error types + if (error.name === "NetworkError" || error.message.includes("fetch")) { + return "Network error: Please check your connection and try again"; + } + if (error.name === "TypeError" && error.message.includes("JSON")) { + return "Invalid response format: Please try again or contact support"; + } + if (error.message.includes("403") || error.message.includes("Forbidden")) { + return "Access denied: You don't have permission to perform this action"; + } + if (error.message.includes("404") || error.message.includes("Not Found")) { + return "Resource not found: The requested file or directory may have been moved or deleted"; + } + if ( + error.message.includes("500") || + error.message.includes("Internal Server Error") + ) { + return "Server error: Please try again later or contact support"; + } + + // Default user-friendly message + return error.message || "An unexpected error occurred"; + } + + escapeHtml(unsafe) { + return unsafe + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + // Memory management and cleanup + cleanup() { + // Remove all bound event handlers + for (const [element, handler] of this.boundHandlers) { + if (element?.removeEventListener) { + element.removeEventListener("click", handler); + } + } + this.boundHandlers.clear(); + + // Close all active modals + for (const modalId of this.activeModals) { + this.closeModal(modalId); + } + this.activeModals.clear(); + + // Clear file references + this.droppedFiles = null; + window.selectedFiles = null; + + console.log("FileManager cleanup completed"); + } + + // Browser compatibility check + checkBrowserSupport() { + const requiredFeatures = { + "File API": "File" in window, + FileReader: "FileReader" in window, + FormData: "FormData" in window, + "Fetch API": "fetch" in window, + Promise: "Promise" in window, + Map: "Map" in window, + Set: "Set" in window, + }; + + const missingFeatures = Object.entries(requiredFeatures) + .filter(([name, supported]) => !supported) + .map(([name]) => name); + + if (missingFeatures.length > 0) { + console.warn("Missing browser features:", missingFeatures); + return false; + } + + return true; + } + + // Track event handler for cleanup + bindEventHandler(element, event, handler) { + this.boundHandlers.set(element, handler); + element.addEventListener(event, handler); + } + + handleFileSelection(files) { + const selectedFilesList = document.getElementById("selectedFilesList"); + const selectedFiles = document.getElementById("selectedFiles"); + if (!selectedFilesList || !selectedFiles) return; + selectedFilesList.innerHTML = ""; + + const allFiles = Array.from(files || []); + // Filter out likely directory placeholders that some browsers expose on drop + const realFiles = allFiles.filter((f) => { + // Keep if size > 0 or has a known extension or MIME type + const hasExtension = this.hasFileExtension(f.name); + return f.size > 0 || hasExtension || (f.type && f.type.length > 0); + }); + + // If selection came from the file input (webkitdirectory browse), show all files. + // If it came from drag-and-drop, we may have limited UI space; still show all for clarity. + for (const file of realFiles) { + const li = this.createElement( + "li", + "", + ` + + ${file.webkitRelativePath || file.name} + `, + ); + selectedFilesList.appendChild(li); + } + + if (realFiles.length > 0) { + selectedFiles.classList.add("has-files"); + } else { + selectedFiles.classList.remove("has-files"); + } + } + + renderFileTree(node, container, path = "") { + for (const [name, value] of Object.entries(node)) { + let li; + if (value instanceof File) { + // Render file + li = this.createElement( + "li", + "", + ` + + ${name} + `, + ); + } else { + // Render directory + li = this.createElement( + "li", + "", + ` + + ${name} + + `, + ); + this.renderFileTree(value, li.querySelector("ul"), `${path + name}/`); + } + container.appendChild(li); + } + } + + handleFileCardClick(e, card) { + // Ignore clicks originating from the actions area (dropdown/buttons) + if (e.target.closest(".file-actions")) { + return; + } + + const type = card.dataset.type; + const path = card.dataset.path; + const uuid = card.dataset.uuid; + + if (type === "directory") { + this.handleDirectoryClick(path); + } else if (type === "dataset") { + this.handleDatasetClick(uuid); + } else if (type === "file") { + this.handleFileClick(card, uuid); + } + } + + handleDirectoryClick(path) { + if (path && this.isValidPath(path)) { + // Remove any duplicate slashes and ensure proper path format + const cleanPath = path.replace(/\/+/g, "/").replace(/\/$/, ""); + // Build the navigation URL + const navUrl = `/users/files/?dir=${encodeURIComponent(cleanPath)}`; + // Navigate to the directory using the dir query parameter + window.location.href = navUrl; + } else { + console.warn("Invalid directory path:", path); + this.showError("Invalid directory path", null, "navigation"); + } + } + + handleDatasetClick(uuid) { + if (uuid && this.isValidUuid(uuid)) { + const datasetUrl = `/users/files/?dir=/datasets/${encodeURIComponent(uuid)}`; + window.location.href = datasetUrl; + } else { + console.warn("Invalid dataset UUID:", uuid); + this.showError("Invalid dataset identifier", null, "navigation"); + } + } + + handleFileClick(card, uuid) { + if (uuid && this.isValidUuid(uuid)) { + // Prefer the exact text node for the filename and trim whitespace + const rawName = + card.querySelector(".file-name-text")?.textContent || + card.querySelector(".file-name")?.textContent || + ""; + const name = rawName.trim(); + + // Validate and sanitize filename + if (!this.isValidFileName(name)) { + console.warn("Invalid filename:", name); + this.showError("Invalid filename", null, "file-preview"); + return; + } + + const sanitizedName = this.sanitizeFileName(name); + const lower = sanitizedName.toLowerCase(); + + if (this.shouldPreviewFile(sanitizedName)) { + this.showTextFilePreview(uuid, sanitizedName); + } else if (lower.endsWith(".h5") || lower.endsWith(".hdf5")) { + // H5 files - no preview, no action + } else { + const detailUrl = `/users/file-detail/${uuid}/`; + window.location.href = detailUrl; + } + } else { + console.warn("Invalid file UUID:", uuid); + this.showError("Invalid file identifier", null, "file-preview"); + } + } +} + +// Initialize file manager when DOM is loaded +document.addEventListener("DOMContentLoaded", () => { + new FileManager(); +}); diff --git a/gateway/sds_gateway/static/js/deprecated/file_list_upload_capture_modal.js b/gateway/sds_gateway/static/js/deprecated/file_list_upload_capture_modal.js new file mode 100644 index 000000000..f8120cbbe --- /dev/null +++ b/gateway/sds_gateway/static/js/deprecated/file_list_upload_capture_modal.js @@ -0,0 +1,1075 @@ +/* Upload Capture Modal JavaScript */ + +document.addEventListener("DOMContentLoaded", () => { + // Upload Capture Modal JS + let isProcessing = false; // Flag to track if processing is active + // Reset cancellation state on page load + // Add page refresh/close confirmation + let uploadInProgress = false; + + // Clear any existing result modals on page load + const existingResultModal = document.getElementById("uploadResultModal"); + if (existingResultModal) { + const modalInstance = bootstrap.Modal.getInstance(existingResultModal); + if (modalInstance) { + modalInstance.hide(); + } + } + + // Clear any upload-related session storage + if (sessionStorage.getItem("uploadInProgress")) { + sessionStorage.removeItem("uploadInProgress"); + } + + // Handle beforeunload event (page refresh/close) + window.addEventListener("beforeunload", (e) => { + if ( + isProcessing || + uploadInProgress || + sessionStorage.getItem("uploadInProgress") + ) { + e.preventDefault(); + e.returnValue = + "Upload in progress will be aborted. Are you sure you want to leave?"; + return e.returnValue; + } + }); + + // Handle visibility change (tab close/minimize) + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden" && uploadInProgress) { + // Page hidden during upload + } + }); + + // Get button references + const uploadModal = document.getElementById("uploadCaptureModal"); + if (!uploadModal) { + console.warn("uploadCaptureModal not found"); + return; + } + + const cancelButton = uploadModal.querySelector(".btn-secondary"); + const closeButton = uploadModal.querySelector(".btn-close"); + const submitButton = document.getElementById("uploadSubmitBtn"); + + if (!cancelButton || !closeButton || !submitButton) { + console.warn("Required buttons not found in upload modal"); + return; + } + + // Store abort controller reference for cancellation + let currentAbortController = null; + + // Reset cancellation state when modal is opened + uploadModal.addEventListener("show.bs.modal", () => { + isProcessing = false; + currentAbortController = null; + }); + + // Reset cancellation state when files are selected + const fileInput = document.getElementById("captureFileInput"); + if (fileInput) { + fileInput.addEventListener("change", () => { + isProcessing = false; + currentAbortController = null; + }); + } + + // Reset cancellation state when modal is hidden + uploadModal.addEventListener("hidden.bs.modal", () => { + isProcessing = false; + currentAbortController = null; + }); + + // Handle cancel button click + let cancelRequested = false; + + // Helper function to handle cancellation logic + function handleCancellation(buttonType) { + if (isProcessing) { + // Cancel processing + cancelRequested = true; + // Abort current upload if controller exists + if (currentAbortController) { + currentAbortController.abort(); + } + // Update UI immediately based on button type + if (buttonType === "cancel") { + cancelButton.textContent = "Cancelling..."; + cancelButton.disabled = true; + } else if (buttonType === "close") { + closeButton.disabled = true; + closeButton.style.opacity = "0.5"; + } + // Update progress message + const progressMessage = document.getElementById("progressMessage"); + if (progressMessage) { + progressMessage.textContent = "Cancelling upload..."; + } + // Force UI reset after a short delay to ensure it happens + setTimeout(() => { + if (cancelRequested) { + resetUIState(); + } + }, 500); + } + // If not processing, let the normal button behavior handle it + } + + cancelButton.addEventListener("click", () => { + handleCancellation("cancel"); + }); + + // Handle close button (X) click - same logic as cancel button + closeButton.addEventListener("click", () => { + handleCancellation("close"); + }); + + // Helper function to check for large files + function checkForLargeFiles(files, cancelButton, submitButton) { + const progressSection = document.getElementById("checkingProgressSection"); + const LARGE_FILE_THRESHOLD = 512 * 1024 * 1024; // 512MB in bytes + const largeFiles = files.filter((file) => file.size > LARGE_FILE_THRESHOLD); + + if (largeFiles.length > 0) { + // Reset UI state + progressSection.style.display = "none"; + cancelButton.textContent = "Cancel"; + cancelButton.classList.remove("btn-warning"); + submitButton.disabled = false; + + // Create alert message + const largeFileNames = largeFiles.map((file) => file.name).join(", "); + const alertMessage = `Large files detected (over 512MB): ${largeFileNames}\n\nPlease:\n1. Skip these large files and upload the remaining files, or\n2. Use the SpectrumX SDK (https://pypi.org/project/spectrumx/) to upload large files and add them to your capture.\n\nLarge files may cause issues with the web interface.`; + + alert(alertMessage); + return true; // Indicates large files were found + } + return false; // No large files found + } + + // Helper function to check files for duplicates + async function checkFilesForDuplicates(files, cancelButton, submitButton) { + // Local progress bar variables + const progressSection = document.getElementById("checkingProgressSection"); + const progressBar = document.getElementById("checkingProgressBar"); + const progressText = document.getElementById("checkingProgressText"); + const progressMessage = document.getElementById("progressMessage"); + + // Show progress section + progressSection.style.display = "block"; + progressMessage.textContent = "Processing files for upload..."; + + // Update UI to show processing state + cancelButton.textContent = "Cancel Processing"; + submitButton.disabled = true; + + // Initialize variables for file checking + window.filesToSkip = new Set(); + window.fileCheckResults = new Map(); + + const totalFiles = files.length; + + // Get CSRF token + const getCSRFToken = () => { + const metaToken = document.querySelector('meta[name="csrf-token"]'); + if (metaToken) return metaToken.getAttribute("content"); + const inputToken = document.querySelector('[name="csrfmiddlewaretoken"]'); + if (inputToken) return inputToken.value; + const cookies = document.cookie.split(";"); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + if (cookie.startsWith("csrftoken=")) { + return cookie.substring("csrftoken=".length); + } + } + return ""; + }; + + const csrfToken = getCSRFToken(); + if (!csrfToken) { + throw new Error("CSRF token not found"); + } + + // Check each file for duplicates with progress + for (let i = 0; i < files.length; i++) { + const file = files[i]; + + // Update progress + const progress = Math.round(((i + 1) / totalFiles) * 100); + progressBar.style.width = `${progress}%`; + progressText.textContent = `${progress}%`; + + // Calculate BLAKE3 hash + const buffer = await file.arrayBuffer(); + const hasher = await hashwasm.createBLAKE3(); + hasher.init(); + hasher.update(new Uint8Array(buffer)); + const hashHex = hasher.digest("hex"); + + // Calculate directory path + let directory = "/"; + if (file.webkitRelativePath) { + const pathParts = file.webkitRelativePath.split("/"); + if (pathParts.length > 1) { + pathParts.pop(); + directory = `/${pathParts.join("/")}`; + } + } + + // Check if file exists + const checkData = { + directory: directory, + filename: file.name, + checksum: hashHex, + }; + + try { + const checkFileUrl = + document.querySelector("[data-check-file-url]")?.dataset + .checkFileUrl || "/users/check-file-exists/"; + const response = await fetch(checkFileUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": csrfToken, + }, + body: JSON.stringify(checkData), + }); + + const data = await response.json(); + + // Store the result + const fileKey = `${directory}/${file.name}`; + window.fileCheckResults.set(fileKey, { + file: file, + directory: directory, + filename: file.name, + checksum: hashHex, + data: data.data, + }); + + // Mark for skipping if file exists in tree + if (data.data && data.data.file_exists_in_tree === true) { + window.filesToSkip.add(fileKey); + } + } catch (error) { + // Error checking file + console.error("Error checking file:", error); + } + + // Check for cancellation after each file check + if (cancelRequested) { + break; + } + } + + progressSection.style.display = "none"; + + // Check if cancellation was requested during file checking + if (cancelRequested) { + // Small delay to ensure UI updates are visible + progressSection.style.display = "none"; + await new Promise((resolve) => setTimeout(resolve, 100)); + // Show alert for duplicate checking cancellation + alert("Processing cancelled. No files were uploaded."); + throw new Error("Upload cancelled by user"); + } + } + + // Helper function to handle skipped files upload + async function handleSkippedFilesUpload(allRelativePaths, abortController) { + // Create form data for skipped files case + const skippedFormData = new FormData(); + + // Always add all relative paths for capture creation + for (const path of allRelativePaths) { + skippedFormData.append("all_relative_paths", path); + } + + // Add other form fields + const captureType = document.getElementById("captureTypeSelect").value; + skippedFormData.append("capture_type", captureType); + + if (captureType === "drf") { + const channels = document.getElementById("captureChannelsInput").value; + skippedFormData.append("channels", channels); + } else if (captureType === "rh") { + const scanGroup = document.getElementById("captureScanGroupInput").value; + skippedFormData.append("scan_group", scanGroup); + } + + // Don't send chunk information for skipped files + // This ensures capture creation happens + + const uploadUrl = + document.querySelector("[data-upload-url]")?.dataset.uploadUrl || + "/users/upload-capture/"; + const getCSRFToken = () => { + const metaToken = document.querySelector('meta[name="csrf-token"]'); + if (metaToken) return metaToken.getAttribute("content"); + const inputToken = document.querySelector('[name="csrfmiddlewaretoken"]'); + if (inputToken) return inputToken.value; + const cookies = document.cookie.split(";"); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + if (cookie.startsWith("csrftoken=")) { + return cookie.substring("csrftoken=".length); + } + } + return ""; + }; + + const response = await fetch(uploadUrl, { + method: "POST", + headers: { + "X-CSRFToken": getCSRFToken(), + }, + body: skippedFormData, + signal: abortController.signal, + }); + + const result = await response.json(); + return result; + } + + // Helper function to calculate total chunks + function calculateTotalChunks(filesToUpload, chunkSizeBytes) { + let totalChunks = 0; + let tempChunkSize = 0; + let tempChunkFiles = 0; // Track number of files in current chunk (mirrors currentChunk.length) + + for (let i = 0; i < filesToUpload.length; i++) { + const file = filesToUpload[i]; + + // Check if this file would exceed the chunk limit (mirrors upload logic exactly) + if (tempChunkSize + file.size > chunkSizeBytes && tempChunkFiles > 0) { + // Current chunk would exceed size limit, start new chunk + totalChunks++; + tempChunkSize = 0; + tempChunkFiles = 0; + } + + // Now add the file to the current chunk + if (file.size > chunkSizeBytes) { + // Large file gets its own chunk + totalChunks++; + tempChunkSize = 0; + tempChunkFiles = 0; + } else { + // Add to current chunk + tempChunkSize += file.size; + tempChunkFiles++; + } + } + + // Add final chunk if there are remaining files + if (tempChunkSize > 0) { + totalChunks++; + } + + return totalChunks; + } + + // Function to upload files in chunks + async function uploadFilesInChunks( + filesToUpload, + relativePathsToUpload, + allRelativePaths, + totalFiles, + ) { + // Get progress elements locally + const progressBar = document.getElementById("checkingProgressBar"); + const progressText = document.getElementById("checkingProgressText"); + const progressMessage = document.getElementById("progressMessage"); + const progressSection = document.getElementById("checkingProgressSection"); + + // Show upload progress if there are files to upload + if (filesToUpload.length > 0) { + progressSection.style.display = "block"; + progressMessage.textContent = "Uploading files and creating captures..."; + progressBar.style.width = "0%"; + progressText.textContent = "0%"; + } + + // Create AbortController for upload + const abortController = new AbortController(); + currentAbortController = abortController; + + // Chunk size for file uploads (50 MB per chunk) + const CHUNK_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB in bytes + + let allResults = { + file_upload_status: "success", + saved_files_count: 0, + captures: [], + errors: [], + message: "", + }; + + // Special case: if all files are skipped, send a single request without chunking + if (filesToUpload.length === 0) { + allResults = await handleSkippedFilesUpload( + allRelativePaths, + abortController, + ); + } else { + // Upload files in chunks based on size (50MB per chunk) + let currentChunk = []; + let currentChunkPaths = []; + let currentChunkSize = 0; + let chunkNumber = 1; + let filesProcessed = 0; + + // Calculate total chunks first + const totalChunks = calculateTotalChunks(filesToUpload, CHUNK_SIZE_BYTES); + + // Now upload files in chunks + for (let i = 0; i < filesToUpload.length; i++) { + const file = filesToUpload[i]; + const filePath = relativePathsToUpload[i]; + + // Check if this file would exceed the 50MB limit + if ( + currentChunkSize + file.size > CHUNK_SIZE_BYTES && + currentChunk.length > 0 + ) { + // Upload current chunk before adding this file + await uploadChunk( + currentChunk, + currentChunkPaths, + chunkNumber, + totalChunks, + filesProcessed, + false, + allResults, + allRelativePaths, + totalFiles, + CHUNK_SIZE_BYTES, + ); + // Reset for next chunk + currentChunk = []; + currentChunkPaths = []; + currentChunkSize = 0; + chunkNumber++; + } + + // Add file to current chunk + currentChunk.push(file); + currentChunkPaths.push(filePath); + currentChunkSize += file.size; + filesProcessed++; + + // Check if this is the last file + if (i === filesToUpload.length - 1) { + // Upload final chunk + await uploadChunk( + currentChunk, + currentChunkPaths, + chunkNumber, + totalChunks, + filesProcessed, + true, + allResults, + allRelativePaths, + totalFiles, + CHUNK_SIZE_BYTES, + ); + } + + // Check if cancel was requested + if (cancelRequested) { + break; + } + } + } + + // Check if cancellation was requested during chunk upload + if (cancelRequested) { + // Small delay to ensure UI updates are visible + await new Promise((resolve) => setTimeout(resolve, 100)); + throw new Error("Upload cancelled by user"); + } + + // Check if upload was aborted due to errors + if (allResults.file_upload_status === "error") { + // Clear the reference since upload was aborted + currentAbortController = null; + // Show error results + showUploadResults(allResults, allResults.saved_files_count, totalFiles); + return allResults; // Exit early, don't continue with normal flow + } + + // Clear the reference since upload completed + currentAbortController = null; + return allResults; + } + + // Helper function to upload a chunk + async function uploadChunk( + chunk, + chunkPaths, + chunkNum, + totalChunks, + filesProcessed, + isFinalChunk, + allResults, + allRelativePaths, + totalFiles, + CHUNK_SIZE_BYTES, + ) { + // Get progress elements locally + const progressBar = document.getElementById("checkingProgressBar"); + const progressText = document.getElementById("checkingProgressText"); + const progressMessage = document.getElementById("progressMessage"); + + // Update progress + const progress = Math.round((filesProcessed / totalFiles) * 100); + progressBar.style.width = `${progress}%`; + progressText.textContent = `${progress}%`; + + if (chunk.length === 1 && chunk[0].size > CHUNK_SIZE_BYTES) { + // Large file upload + const file = chunk[0]; + progressMessage.textContent = `Uploading large file: ${file.name} (${(file.size / (1024 * 1024)).toFixed(1)} MB)...`; + } else { + // Normal chunk upload + progressMessage.textContent = `Uploading chunk ${chunkNum}/${totalChunks} (${filesProcessed} files processed)...`; + } + + // Create form data for this chunk + const chunkFormData = new FormData(); + + // Add files for this chunk + for (const file of chunk) { + chunkFormData.append("files", file); + } + + for (const path of chunkPaths) { + chunkFormData.append("relative_paths", path); + } + + // Always add all relative paths for capture creation + for (const path of allRelativePaths) { + chunkFormData.append("all_relative_paths", path); + } + + // Add other form fields + const captureType = document.getElementById("captureTypeSelect").value; + chunkFormData.append("capture_type", captureType); + + if (captureType === "drf") { + const channels = document.getElementById("captureChannelsInput").value; + chunkFormData.append("channels", channels); + } else if (captureType === "rh") { + const scanGroup = document.getElementById("captureScanGroupInput").value; + chunkFormData.append("scan_group", scanGroup); + } + + // Add chunk information + chunkFormData.append("is_chunk", "true"); + chunkFormData.append("chunk_number", chunkNum.toString()); + chunkFormData.append("total_chunks", totalChunks.toString()); + + // Check for cancellation before starting this chunk + if (cancelRequested) { + throw new Error("Upload cancelled by user"); + } + + // Upload this chunk with timeout (longer timeout for large files) + const controller = new AbortController(); + currentAbortController = controller; + + const MIN_AVG_UPLOAD_RATE = 100 * 1024; // 100 KB/s minimum upload rate + const MIN_TIMEOUT_MS = 30000; // Minimum 30 seconds timeout + const total_chunk_size_bytes = chunk.reduce( + (total, file) => total + file.size, + 0, + ); + const calculated_timeout = + (total_chunk_size_bytes / MIN_AVG_UPLOAD_RATE) * 1000; + const timeout = Math.max(calculated_timeout, MIN_TIMEOUT_MS); // Use at least 30 seconds + + const timeoutId = setTimeout(() => controller.abort(), timeout); + + let response; + let chunkResult; + + const getCSRFToken = () => { + const metaToken = document.querySelector('meta[name="csrf-token"]'); + if (metaToken) return metaToken.getAttribute("content"); + const inputToken = document.querySelector('[name="csrfmiddlewaretoken"]'); + if (inputToken) return inputToken.value; + const cookies = document.cookie.split(";"); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + if (cookie.startsWith("csrftoken=")) { + return cookie.substring("csrftoken=".length); + } + } + return ""; + }; + + const uploadUrl = + document.querySelector("[data-upload-url]")?.dataset.uploadUrl || + "/users/upload-capture/"; + + try { + response = await fetch(uploadUrl, { + method: "POST", + headers: { + "X-CSRFToken": getCSRFToken(), + }, + body: chunkFormData, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + chunkResult = await response.json(); + } catch (error) { + clearTimeout(timeoutId); + if (error.name === "AbortError") { + throw new Error("Upload timeout - connection may be lost"); + } + throw error; + } + + // Merge results + if (chunkResult.saved_files_count !== undefined) { + allResults.saved_files_count += chunkResult.saved_files_count; + } + + // Collect captures from the final chunk + if (chunkResult.captures && isFinalChunk) { + allResults.captures = allResults.captures.concat(chunkResult.captures); + } + + // Also collect any message from the final chunk + if (chunkResult.message && isFinalChunk) { + allResults.message = chunkResult.message; + } + + if (chunkResult.errors) { + allResults.errors = allResults.errors.concat(chunkResult.errors); + } + + // Check if any chunk failed + if (chunkResult.file_upload_status === "error") { + allResults.file_upload_status = "error"; + allResults.message = chunkResult.message || "Upload failed"; + const progressMessage = document.getElementById("progressMessage"); + if (progressMessage) { + progressMessage.textContent = + "Upload aborted due to errors. Please check the results."; + } + throw new Error(`Upload failed: ${chunkResult.message}`); + } + if (chunkResult.file_upload_status === "success") { + // Only update to success if this is the final chunk + if (isFinalChunk) { + allResults.file_upload_status = "success"; + } + } + } + + const uploadForm = document.getElementById("uploadCaptureForm"); + if (uploadForm) { + uploadForm.addEventListener("submit", async (e) => { + e.preventDefault(); + + // Set processing state + isProcessing = true; + uploadInProgress = true; + cancelRequested = false; // Reset cancel flag for new upload + sessionStorage.setItem("uploadInProgress", "true"); + + // Check if files are selected + if (!window.selectedFiles || window.selectedFiles.length === 0) { + alert("Please select files to upload."); + return; + } + + const files = window.selectedFiles; + + // Check for large files before duplicate checking + if (checkForLargeFiles(files, cancelButton, submitButton)) { + return; + } + + // Check files for duplicates + await checkFilesForDuplicates(files, cancelButton, submitButton); + + // Prepare files for upload (only non-skipped files) + const filesToUpload = []; + const relativePathsToUpload = []; + // Always collect all relative paths for capture creation, even for skipped files + const allRelativePaths = []; + let skippedFilesCount = 0; + + for (const file of files) { + let directory = "/"; + if (file.webkitRelativePath) { + const pathParts = file.webkitRelativePath.split("/"); + if (pathParts.length > 1) { + pathParts.pop(); + directory = `/${pathParts.join("/")}`; + } + } + + const fileKey = `${directory}/${file.name}`; + const relativePath = file.webkitRelativePath || file.name; + + // Add to all paths for capture creation + allRelativePaths.push(relativePath); + + // Only add to upload list if not skipped + if (!window.filesToSkip.has(fileKey)) { + filesToUpload.push(file); + relativePathsToUpload.push(relativePath); + } else { + skippedFilesCount++; + } + } + + // Upload files in chunks + try { + const uploadResults = await uploadFilesInChunks( + filesToUpload, + relativePathsToUpload, + allRelativePaths, + filesToUpload.length, + ); + + // Clear the reference since upload completed + currentAbortController = null; + + // Show results + showUploadResults( + uploadResults, + uploadResults.saved_files_count, + files.length, + skippedFilesCount, + ); + + // Don't auto-reload for successful uploads - let user close modal first + } catch (error) { + if (cancelRequested) { + // Check if this was cancelled during duplicate checking (no files uploaded yet) + if (!uploadInProgress) { + // Already showed alert in checkFilesForDuplicates function + // No need to reload since no files were uploaded + } else { + alert( + "Upload cancelled. Any files uploaded before cancellation have been saved.", + ); + // Reload page after cancellation + setTimeout(() => { + window.location.reload(); + }, 1000); + } + } else if (error.name === "AbortError") { + alert( + "Upload was interrupted. Any files uploaded before the interruption have been saved.", + ); + // Reload page after interruption + setTimeout(() => { + window.location.reload(); + }, 1000); + } else if ( + error.name === "TypeError" && + error.message.includes("fetch") + ) { + // Don't show alert for network errors after page refresh + if (uploadInProgress || sessionStorage.getItem("uploadInProgress")) { + // Suppressing network error alert during active upload + } else { + alert( + "Network error during upload. Please check your connection and try again.", + ); + } + } else { + alert(`Upload failed: ${error.message}`); + // Reload page after other errors + setTimeout(() => { + window.location.reload(); + }, 1000); + } + } finally { + // Clean up UI state - ensure this always runs + resetUIState(); + } + }); + } + + // Function to reset UI state + function resetUIState() { + // Reset submit button + if (submitButton) { + submitButton.disabled = false; + } + + // Hide progress section + const progressSection = document.getElementById("checkingProgressSection"); + if (progressSection) { + progressSection.style.display = "none"; + } + + // Reset cancel button + if (cancelButton) { + cancelButton.textContent = "Cancel"; + cancelButton.classList.remove("btn-warning"); + cancelButton.disabled = false; + } + + // Reset close button + if (closeButton) { + closeButton.disabled = false; + closeButton.style.opacity = "1"; + } + + // Reset progress elements + const progressBar = document.getElementById("checkingProgressBar"); + const progressText = document.getElementById("checkingProgressText"); + const progressMessage = document.getElementById("progressMessage"); + + if (progressBar) progressBar.style.width = "0%"; + if (progressText) progressText.textContent = "0%"; + if (progressMessage) progressMessage.textContent = ""; + + // Reset state flags + isProcessing = false; + uploadInProgress = false; + cancelRequested = false; + + // Clear session storage + sessionStorage.removeItem("uploadInProgress"); + + // Clear abort controller + currentAbortController = null; + } + + // Function to show upload results + function showUploadResults( + result, + uploadedCount, + totalCount, + skippedCount = 0, + ) { + // Check if page was refreshed during upload + if (!uploadInProgress && result.file_upload_status === "error") { + resetUIState(); // Ensure UI is reset even if modal is not shown + return; + } + + const modalBody = document.getElementById("uploadResultModalBody"); + const resultModalEl = document.getElementById("uploadResultModal"); + const modal = new bootstrap.Modal(resultModalEl); + + // Close the upload modal before showing result modal + const uploadModal = bootstrap.Modal.getInstance( + document.getElementById("uploadCaptureModal"), + ); + if (uploadModal) { + uploadModal.hide(); + } + + let msg = ""; + + if (result.file_upload_status === "success") { + // Use frontend accumulated count for accuracy, but include backend message for additional info + if (uploadedCount === 0 && totalCount > 0) { + // All files were skipped + msg = `Upload complete!
    All ${totalCount} files already existed on the server.`; + } else if (skippedCount > 0) { + // Some files were uploaded, some were skipped + msg = `Upload complete!
    Files uploaded: ${uploadedCount} / ${totalCount}`; + msg += `
    Files already exist: ${skippedCount}`; + } else { + // All files were uploaded (no skipped files) + msg = `Upload complete!
    Files uploaded: ${uploadedCount} / ${totalCount}`; + } + + if (result.captures && result.captures.length > 0) { + const uuids = result.captures + .map((uuid) => `
  • ${uuid}
  • `) + .join(""); + msg += `
    Created capture UUID(s):`; + } + + if (result.errors && result.errors.length > 0) { + const errs = result.errors.map((e) => `
  • ${e}
  • `).join(""); + msg += `
    Errors:`; + msg += "
    Please check details and upload again."; + } + } else { + // Upload failed - show error message and prompt to remove error files + msg = "Upload Failed
    "; + if (result.message) { + msg += `${result.message}

    `; + } + msg += "Please check file validity and try again."; + if (result.errors && result.errors.length > 0) { + const errs = result.errors.map((e) => `
  • ${e}
  • `).join(""); + msg += `

    Error Details:`; + } + } + + modalBody.innerHTML = msg; + modal.show(); + + // Add event listener to reload page when result modal is closed (only for successful uploads) + if (result.file_upload_status === "success") { + resultModalEl.addEventListener( + "hidden.bs.modal", + () => { + window.location.reload(); + }, + { once: true }, + ); // Only trigger once per modal instance + } + } +}); + +// Capture Type Selection JavaScript +document.addEventListener("DOMContentLoaded", () => { + const captureTypeSelect = document.getElementById("captureTypeSelect"); + const channelInputGroup = document.getElementById("channelInputGroup"); + const scanGroupInputGroup = document.getElementById("scanGroupInputGroup"); + const captureChannelsInput = document.getElementById("captureChannelsInput"); + const captureScanGroupInput = document.getElementById( + "captureScanGroupInput", + ); + + if (captureTypeSelect) { + captureTypeSelect.addEventListener("change", function () { + const selectedType = this.value; + + // Hide both input groups initially + if (channelInputGroup) + channelInputGroup.classList.add("hidden-input-group"); + if (scanGroupInputGroup) + scanGroupInputGroup.classList.add("hidden-input-group"); + + // Clear required attributes + if (captureChannelsInput) + captureChannelsInput.removeAttribute("required"); + if (captureScanGroupInput) + captureScanGroupInput.removeAttribute("required"); + + // Show appropriate input group based on selection + if (selectedType === "drf") { + if (channelInputGroup) + channelInputGroup.classList.remove("hidden-input-group"); + if (captureChannelsInput) + captureChannelsInput.setAttribute("required", "required"); + } else if (selectedType === "rh") { + if (scanGroupInputGroup) + scanGroupInputGroup.classList.remove("hidden-input-group"); + // scan_group is optional for RadioHound captures + } + }); + } + + // Reset form when modal is hidden + const uploadModal = document.getElementById("uploadCaptureModal"); + if (uploadModal) { + uploadModal.addEventListener("hidden.bs.modal", () => { + // Reset the form + const form = document.getElementById("uploadCaptureForm"); + if (form) { + form.reset(); + } + + // Hide input groups + if (channelInputGroup) + channelInputGroup.classList.add("hidden-input-group"); + if (scanGroupInputGroup) + scanGroupInputGroup.classList.add("hidden-input-group"); + + // Clear required attributes + if (captureChannelsInput) + captureChannelsInput.removeAttribute("required"); + if (captureScanGroupInput) + captureScanGroupInput.removeAttribute("required"); + + // Clear file check status + const checkStatusDiv = document.getElementById("fileCheckStatus"); + if (checkStatusDiv) { + checkStatusDiv.style.display = "none"; + } + + // Clear status alerts and file details button + const uploadModalBody = document.querySelector( + "#uploadCaptureModal .modal-body", + ); + if (uploadModalBody) { + const existingAlerts = uploadModalBody.querySelectorAll( + ".alert.alert-warning, .alert.alert-success", + ); + for (const alert of existingAlerts) { + if ( + alert.textContent.includes("will be skipped") || + alert.textContent.includes("will be uploaded") + ) { + alert.remove(); + } + } + + // Remove file details button + const detailsLink = uploadModalBody.querySelector( + "#viewFileDetailsLink", + ); + if (detailsLink) { + detailsLink.parentNode.remove(); + } + } + + // Clear global variables + if (window.filesToSkip) window.filesToSkip.clear(); + if (window.fileCheckResults) window.fileCheckResults.clear(); + }); + } +}); + +// BLAKE3 hash calculation for file deduplication +// Global variable to track files that should be skipped +window.filesToSkip = new Set(); +window.fileCheckResults = new Map(); // Store detailed results for each file + +document.addEventListener("DOMContentLoaded", () => { + const modal = document.getElementById("uploadCaptureModal"); + if (!modal) { + console.warn("uploadCaptureModal not found"); + return; + } + + modal.addEventListener("shown.bs.modal", () => { + const fileInput = document.getElementById("captureFileInput"); + if (!fileInput) { + console.warn("captureFileInput not found"); + return; + } + + // Remove any previous handler to avoid duplicates + fileInput.removeEventListener("change", window._blake3CaptureHandler); + + // Simple file handler that just stores the selected files + window._blake3CaptureHandler = async (event) => { + const files = event.target.files; + if (!files || files.length === 0) { + return; + } + + // Store the selected files for later processing + window.selectedFiles = Array.from(files); + }; + + fileInput.addEventListener("change", window._blake3CaptureHandler); + }); +}); diff --git a/gateway/sds_gateway/static/js/deprecated/files-ui.js b/gateway/sds_gateway/static/js/deprecated/files-ui.js new file mode 100644 index 000000000..c29548110 --- /dev/null +++ b/gateway/sds_gateway/static/js/deprecated/files-ui.js @@ -0,0 +1,857 @@ +/** + * Files UI Components + * Manages capture type selection and page initialization + */ + +// Error handling utilities +const ErrorHandler = { + shownMessages: new Set(), // Track shown messages to prevent duplicates + + showError(message, context = "", error = null) { + // Create a key for deduplication based on message and context + const messageKey = `${context}:${message}`; + + // Check if this exact message has already been shown + if (this.shownMessages.has(messageKey)) { + // Still log to console for debugging, but don't show UI message + if (error) { + console.error(`FilesUI Error [${context}]:`, { + message: error.message, + stack: error.stack, + userMessage: message, + timestamp: new Date().toISOString(), + userAgent: navigator.userAgent, + }); + } else { + console.warn(`FilesUI Warning [${context}]:`, message); + } + return; + } + + // Mark this message as shown + this.shownMessages.add(messageKey); + + // Log error details for debugging + if (error) { + console.error(`FilesUI Error [${context}]:`, { + message: error.message, + stack: error.stack, + userMessage: message, + timestamp: new Date().toISOString(), + userAgent: navigator.userAgent, + }); + } else { + console.warn(`FilesUI Warning [${context}]:`, message); + } + + // Show user-friendly error message + if (window.components?.showError) { + window.components.showError(message); + } else { + // Fallback: show in console and try to display on page + this.showFallbackError(message); + } + }, + + showFallbackError(message) { + // Try to find an error display area + const errorContainer = document.querySelector( + ".error-container, .alert-container, .files-container", + ); + if (errorContainer) { + const errorDiv = document.createElement("div"); + errorDiv.className = "alert alert-danger alert-dismissible fade show"; + errorDiv.innerHTML = ` + ${message} + + `; + errorContainer.insertBefore(errorDiv, errorContainer.firstChild); + } + }, + + getUserFriendlyErrorMessage(error, context = "") { + if (!error) return "An unexpected error occurred"; + + // Handle common error types + if (error.name === "TypeError" && error.message.includes("Cannot read")) { + return "Configuration error: Some components are not properly loaded"; + } + if (error.name === "ReferenceError") { + return "Component error: Required functionality is not available"; + } + + // Default user-friendly message + return error.message || "An unexpected error occurred"; + }, +}; + +// Browser compatibility checker +const BrowserCompatibility = { + checkRequiredFeatures() { + const requiredFeatures = { + "DOM API": "document" in window && "addEventListener" in document, + "Console API": "console" in window && "log" in console, + Map: "Map" in window, + Set: "Set" in window, + "Template Literals": (() => { + try { + // Test template literal support without eval + const test = `test${1}`; + return test === "test1"; + } catch { + return false; + } + })(), + }; + + const missingFeatures = Object.entries(requiredFeatures) + .filter(([name, supported]) => !supported) + .map(([name]) => name); + + if (missingFeatures.length > 0) { + console.warn("Missing browser features:", missingFeatures); + return false; + } + + return true; + }, + + checkBootstrapSupport() { + return ( + "bootstrap" in window || + typeof bootstrap !== "undefined" || + document.querySelector("[data-bs-toggle]") !== null + ); + }, +}; + +/** + * Capture Type Selection Handler + * Manages capture type dropdown and conditional form fields + */ +class CaptureTypeSelector { + constructor() { + this.boundHandlers = new Map(); // Track event handlers for cleanup + this.initializeElements(); + this.setupEventListeners(); + } + + initializeElements() { + this.captureTypeSelect = document.getElementById("captureTypeSelect"); + this.channelInputGroup = document.getElementById("channelInputGroup"); + this.scanGroupInputGroup = document.getElementById("scanGroupInputGroup"); + this.captureChannelsInput = document.getElementById("captureChannelsInput"); + this.captureScanGroupInput = document.getElementById( + "captureScanGroupInput", + ); + this.uploadModal = document.getElementById("uploadCaptureModal"); + + // Log which elements were found for debugging + console.log("CaptureTypeSelector elements found:", { + captureTypeSelect: !!this.captureTypeSelect, + channelInputGroup: !!this.channelInputGroup, + scanGroupInputGroup: !!this.scanGroupInputGroup, + captureChannelsInput: !!this.captureChannelsInput, + captureScanGroupInput: !!this.captureScanGroupInput, + uploadModal: !!this.uploadModal, + }); + } + + setupEventListeners() { + // Ensure boundHandlers is initialized + if (!this.boundHandlers) { + this.boundHandlers = new Map(); + } + + if (this.captureTypeSelect) { + const changeHandler = (e) => this.handleTypeChange(e); + this.boundHandlers.set(this.captureTypeSelect, changeHandler); + this.captureTypeSelect.addEventListener("change", changeHandler); + } + + if (this.uploadModal) { + const hiddenHandler = () => this.resetForm(); + this.boundHandlers.set(this.uploadModal, hiddenHandler); + this.uploadModal.addEventListener("hidden.bs.modal", hiddenHandler); + } + } + + handleTypeChange(event) { + const selectedType = event.target.value; + + // Validate capture type + if (!this.validateCaptureType(selectedType)) { + ErrorHandler.showError( + "Invalid capture type selected", + "capture-type-validation", + ); + return; + } + + // Hide both input groups initially + this.hideInputGroups(); + + // Clear required attributes + this.clearRequiredAttributes(); + + // Show appropriate input group based on selection + if (selectedType === "drf") { + this.showChannelInput(); + } else if (selectedType === "rh") { + this.showScanGroupInput(); + } + } + + hideInputGroups() { + if (this.channelInputGroup) { + this.channelInputGroup.classList.add("hidden-input-group"); + } + if (this.scanGroupInputGroup) { + this.scanGroupInputGroup.classList.add("hidden-input-group"); + } + } + + clearRequiredAttributes() { + if (this.captureChannelsInput) { + this.captureChannelsInput.removeAttribute("required"); + } + if (this.captureScanGroupInput) { + this.captureScanGroupInput.removeAttribute("required"); + } + } + + showChannelInput() { + if (this.channelInputGroup) { + this.channelInputGroup.classList.remove("hidden-input-group"); + } + if (this.captureChannelsInput) { + this.captureChannelsInput.setAttribute("required", "required"); + } + } + + showScanGroupInput() { + if (this.scanGroupInputGroup) { + this.scanGroupInputGroup.classList.remove("hidden-input-group"); + } + // scan_group is optional for RadioHound captures, so no required attribute + } + + // Input validation methods + validateCaptureType(type) { + const validTypes = ["drf", "rh"]; + return validTypes.includes(type); + } + + validateChannelInput(channels) { + if (!channels || typeof channels !== "string") return false; + // Basic validation for channel input (can be enhanced based on requirements) + return channels.trim().length > 0 && channels.length <= 1000; + } + + validateScanGroupInput(scanGroup) { + if (!scanGroup || typeof scanGroup !== "string") return false; + // Basic validation for scan group input + return scanGroup.trim().length > 0 && scanGroup.length <= 255; + } + + sanitizeInput(input) { + if (!input || typeof input !== "string") return ""; + // Remove potentially dangerous characters + return input.replace(/[<>:"/\\|?*]/g, "_").trim(); + } + + // Memory management and cleanup + cleanup() { + // Remove all bound event handlers + for (const [element, handler] of this.boundHandlers) { + if (element?.removeEventListener) { + element.removeEventListener("change", handler); + element.removeEventListener("hidden.bs.modal", handler); + } + } + this.boundHandlers.clear(); + console.log("CaptureTypeSelector cleanup completed"); + } + + resetForm() { + // Reset the form + const form = document.getElementById("uploadCaptureForm"); + if (form) { + form.reset(); + } + + // Hide input groups + this.hideInputGroups(); + + // Clear required attributes + this.clearRequiredAttributes(); + + // Clear global variables if they exist + this.cleanupGlobalState(); + } + + // Better global state management + cleanupGlobalState() { + const globalVars = ["filesToSkip", "fileCheckResults", "selectedFiles"]; + + for (const varName of globalVars) { + if (window[varName]) { + if (typeof window[varName].clear === "function") { + window[varName].clear(); + } else if (Array.isArray(window[varName])) { + window[varName].length = 0; + } else { + window[varName] = null; + } + console.log(`Cleaned up global variable: ${varName}`); + } + } + } +} + +/** + * Files Page Initialization + * Initializes modal managers, capture handlers, and user search components + */ +class FilesPageInitializer { + constructor() { + this.boundHandlers = new Map(); // Track event handlers for cleanup + this.activeHandlers = new Set(); // Track active component handlers + this.initializeComponents(); + } + + initializeComponents() { + try { + this.initializeModalManager(); + this.initializeCapturesTableManager(); + this.initializeUserSearchHandlers(); + } catch (error) { + ErrorHandler.showError( + "Failed to initialize page components", + "component-initialization", + error, + ); + } + } + + initializeModalManager() { + // Initialize ModalManager for capture modal + let modalManager = null; + try { + if (window.ModalManager) { + modalManager = new window.ModalManager({ + modalId: "capture-modal", + modalBodyId: "capture-modal-body", + modalTitleId: "capture-modal-label", + }); + + this.modalManager = modalManager; + console.log("ModalManager initialized successfully"); + } else { + ErrorHandler.showError( + "Modal functionality is not available. Some features may be limited.", + "modal-initialization", + ); + } + } catch (error) { + ErrorHandler.showError( + "Failed to initialize modal functionality", + "modal-initialization", + error, + ); + } + } + + initializeCapturesTableManager() { + // Initialize CapturesTableManager for capture edit/download functionality + try { + if (window.CapturesTableManager) { + window.capturesTableManager = new window.CapturesTableManager({ + modalHandler: this.modalManager, + }); + console.log("CapturesTableManager initialized successfully"); + } else { + ErrorHandler.showError( + "Table management functionality is not available. Some features may be limited.", + "table-initialization", + ); + } + } catch (error) { + ErrorHandler.showError( + "Failed to initialize table management functionality", + "table-initialization", + error, + ); + } + } + + initializeUserSearchHandlers() { + // Create a UserSearchHandler for each share modal + const shareModals = document.querySelectorAll(".modal[data-item-uuid]"); + + // Skip initialization if no share modals exist on this page + if (shareModals.length === 0) { + return; + } + + // Check if UserSearchHandler is available before trying to initialize + if (!window.UserSearchHandler) { + console.warn( + "UserSearchHandler not available. Share functionality will not work.", + ); + return; + } + + for (const modal of shareModals) { + this.setupUserSearchHandler(modal); + } + } + + setupUserSearchHandler(modal) { + try { + // Ensure boundHandlers and activeHandlers are initialized + if (!this.boundHandlers) { + this.boundHandlers = new Map(); + } + if (!this.activeHandlers) { + this.activeHandlers = new Set(); + } + + // Validate modal attributes + const itemUuid = modal.getAttribute("data-item-uuid"); + const itemType = modal.getAttribute("data-item-type"); + + if (!this.validateModalAttributes(itemUuid, itemType)) { + ErrorHandler.showError( + "Invalid modal configuration", + "user-search-setup", + ); + return; + } + + const handler = new window.UserSearchHandler(); + // Store the handler on the modal element + modal.userSearchHandler = handler; + this.activeHandlers.add(handler); + + // Create bound event handlers for cleanup + const showHandler = () => { + if (modal.userSearchHandler) { + modal.userSearchHandler.setItemInfo(itemUuid, itemType); + modal.userSearchHandler.init(); + } + }; + + const hideHandler = () => { + if (modal.userSearchHandler) { + modal.userSearchHandler.resetAll(); + } + }; + + // Store handlers for cleanup + this.boundHandlers.set(modal, { + show: showHandler, + hide: hideHandler, + }); + + // On modal show, set the item info and call init() + modal.addEventListener("show.bs.modal", showHandler); + + // On modal hide, reset all selections and entered data + modal.addEventListener("hidden.bs.modal", hideHandler); + + console.log(`UserSearchHandler initialized for ${itemType}: ${itemUuid}`); + } catch (error) { + ErrorHandler.showError( + "Failed to setup user search functionality", + "user-search-setup", + error, + ); + } + } + + /** + * Get initialized modal manager + * @returns {Object|null} - The modal manager instance + */ + getModalManager() { + return this.modalManager; + } + + /** + * Get captures table manager + * @returns {Object|null} - The captures table manager instance + */ + getCapturesTableManager() { + return window.capturesTableManager; + } + + // Validation methods + validateModalAttributes(uuid, type) { + if (!uuid || typeof uuid !== "string") { + console.warn("Invalid UUID in modal attributes:", uuid); + return false; + } + + if (!type || typeof type !== "string") { + console.warn("Invalid type in modal attributes:", type); + return false; + } + + // Validate UUID format (basic check) + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + if (!uuidRegex.test(uuid)) { + console.warn("Invalid UUID format in modal attributes:", uuid); + return false; + } + + // Validate type + const validTypes = ["capture", "dataset", "file"]; + if (!validTypes.includes(type)) { + console.warn("Invalid type in modal attributes:", type); + return false; + } + + return true; + } + + // Memory management and cleanup + cleanup() { + // Remove all bound event handlers + for (const [element, handlers] of this.boundHandlers) { + if (element?.removeEventListener) { + if (handlers.show) { + element.removeEventListener("show.bs.modal", handlers.show); + } + if (handlers.hide) { + element.removeEventListener("hidden.bs.modal", handlers.hide); + } + } + } + this.boundHandlers.clear(); + + // Cleanup active handlers + for (const handler of this.activeHandlers) { + if (handler && typeof handler.cleanup === "function") { + try { + handler.cleanup(); + } catch (error) { + console.warn("Error during handler cleanup:", error); + } + } + } + this.activeHandlers.clear(); + + console.log("FilesPageInitializer cleanup completed"); + } +} + +// Initialize when DOM is loaded +/** + * FileUploadHandler + * Handles individual file uploads (not capture uploads) + */ +class FileUploadHandler { + constructor() { + this.uploadForm = document.getElementById("uploadFileForm"); + this.fileInput = document.getElementById("fileInput"); + this.folderInput = document.getElementById("folderInput"); + this.submitBtn = document.getElementById("uploadFileSubmitBtn"); + this.clearBtn = document.getElementById("clearUploadBtn"); + this.uploadText = this.submitBtn?.querySelector(".upload-text"); + this.uploadSpinner = this.submitBtn?.querySelector(".upload-spinner"); + this.validationFeedback = document.getElementById( + "uploadValidationFeedback", + ); + + // Enable submit button when files or folders are selected + if (this.fileInput) { + this.fileInput.addEventListener("change", () => + this.updateSubmitButton(), + ); + } + if (this.folderInput) { + this.folderInput.addEventListener("change", () => + this.updateSubmitButton(), + ); + } + + // Clear button handler + if (this.clearBtn) { + this.clearBtn.addEventListener("click", () => this.clearModal()); + } + + if (this.uploadForm) { + this.uploadForm.addEventListener("submit", (e) => this.handleSubmit(e)); + } + } + + updateSubmitButton() { + if (this.submitBtn) { + const hasFiles = this.fileInput?.files.length > 0; + const hasFolders = this.folderInput?.files.length > 0; + this.submitBtn.disabled = !hasFiles && !hasFolders; + + // Hide validation feedback when files are selected + if (hasFiles || hasFolders) { + this.hideValidationFeedback(); + } + } + } + + showValidationFeedback() { + if (this.validationFeedback) { + this.validationFeedback.classList.add("d-block"); + } + // Add invalid styling to inputs + this.fileInput?.classList.add("is-invalid"); + this.folderInput?.classList.add("is-invalid"); + } + + hideValidationFeedback() { + if (this.validationFeedback) { + this.validationFeedback.classList.remove("d-block"); + } + // Remove invalid styling from inputs + this.fileInput?.classList.remove("is-invalid"); + this.folderInput?.classList.remove("is-invalid"); + } + + clearModal() { + // Reset form + if (this.uploadForm) { + this.uploadForm.reset(); + } + // Explicitly clear file inputs (form.reset() doesn't always clear file inputs) + if (this.fileInput) { + this.fileInput.value = ""; + } + if (this.folderInput) { + this.folderInput.value = ""; + } + // Hide validation feedback + this.hideValidationFeedback(); + // Update submit button state + this.updateSubmitButton(); + } + + async handleSubmit(event) { + event.preventDefault(); + + const files = Array.from(this.fileInput?.files || []); + const folderFiles = Array.from(this.folderInput?.files || []); + + if (files.length === 0 && folderFiles.length === 0) { + this.showValidationFeedback(); + return; + } + + this.setUploadingState(true); + + try { + // Debug: Check CSRF token availability + console.log("CSRF Token:", window.csrfToken); + console.log("Upload URL:", window.uploadFilesUrl); + + // Try multiple ways to get CSRF token + let csrfToken = window.csrfToken; + if (!csrfToken) { + // Fallback to DOM query + const csrfInput = document.querySelector("[name=csrfmiddlewaretoken]"); + csrfToken = csrfInput ? csrfInput.value : null; + } + + if (!csrfToken) { + throw new Error("CSRF token not found"); + } + + const formData = new FormData(); + const allFiles = [...files, ...folderFiles]; + const allRelativePaths = []; + + // Add all files to formData + for (const file of allFiles) { + formData.append("files", file); + // Use webkitRelativePath for folder files, filename for individual files + const relativePath = file.webkitRelativePath || file.name; + allRelativePaths.push(relativePath); + } + + // Add relative paths + for (const relativePath of allRelativePaths) { + formData.append("relative_paths", relativePath); + } + for (const relativePath of allRelativePaths) { + formData.append("all_relative_paths", relativePath); + } + + // Prevent capture creation when uploading files only + formData.append("capture_type", ""); + formData.append("channels", ""); + formData.append("scan_group", ""); + formData.append("csrfmiddlewaretoken", csrfToken); + + const response = await fetch(window.uploadFilesUrl, { + method: "POST", + body: formData, + headers: { + "X-CSRFToken": csrfToken, + }, + }); + + const result = await response.json(); + + if (response.ok) { + // Show success message + const fileCount = allFiles.length; + const successMsg = + fileCount === 1 + ? "1 file uploaded successfully!" + : `${fileCount} files uploaded successfully!`; + this.showResult("success", successMsg); + // Clear file inputs + this.clearModal(); + // Close modal + const modal = bootstrap.Modal.getInstance( + document.getElementById("uploadFileModal"), + ); + if (modal) modal.hide(); + // Refresh the file list + if (window.fileManager) { + window.fileManager.loadFiles(); + } + } else { + this.showResult( + "error", + result.error || "Upload failed. Please try again.", + ); + } + } catch (error) { + console.error("Upload error:", error); + this.showResult( + "error", + "Upload failed. Please check your connection and try again.", + ); + } finally { + this.setUploadingState(false); + } + } + + setUploadingState(uploading) { + if (this.submitBtn) { + this.submitBtn.disabled = uploading; + } + if (this.uploadText && this.uploadSpinner) { + this.uploadText.classList.toggle("d-none", uploading); + this.uploadSpinner.classList.toggle("d-none", !uploading); + } + } + + showResult(type, message) { + // Show result in the upload result modal + const resultModal = document.getElementById("uploadResultModal"); + const resultBody = document.getElementById("uploadResultModalBody"); + + if (resultModal && resultBody) { + resultBody.innerHTML = ` +
    + ${message} +
    + `; + const modal = new bootstrap.Modal(resultModal); + modal.show(); + } else { + // Fallback to alert + alert(message); + } + } + + cleanup() { + if (this.uploadForm) { + this.uploadForm.removeEventListener("submit", this.handleSubmit); + } + } +} + +document.addEventListener("DOMContentLoaded", () => { + // Check browser compatibility before proceeding + if (!BrowserCompatibility.checkRequiredFeatures()) { + ErrorHandler.showError( + "Your browser doesn't support required features. Please use a modern browser.", + "browser-compatibility", + ); + return; + } + + // Check Bootstrap support + if (!BrowserCompatibility.checkBootstrapSupport()) { + console.warn( + "Bootstrap not detected. Some UI features may not work properly.", + ); + } + + try { + // Check if we're on a page that needs these components + const needsCaptureSelector = + document.getElementById("captureTypeSelect") || + document.getElementById("uploadCaptureModal"); + const needsPageInitializer = + document.querySelector(".modal[data-item-uuid]") || + document.getElementById("capture-modal"); + const needsFileUploadHandler = + document.getElementById("uploadFileModal") || + document.getElementById("uploadFileForm"); + + // Initialize capture type selector only if needed + let captureSelector = null; + if (needsCaptureSelector) { + captureSelector = new CaptureTypeSelector(); + } + + // Initialize page components only if needed + let filesPageInitializer = null; + if (needsPageInitializer) { + filesPageInitializer = new FilesPageInitializer(); + window.filesPageInitializer = filesPageInitializer; + } + + // Initialize file upload handler only if needed + let fileUploadHandler = null; + if (needsFileUploadHandler) { + fileUploadHandler = new FileUploadHandler(); + window.fileUploadHandler = fileUploadHandler; + } + + // Store references for cleanup + window.filesUICleanup = () => { + if (captureSelector && typeof captureSelector.cleanup === "function") { + captureSelector.cleanup(); + } + if ( + filesPageInitializer && + typeof filesPageInitializer.cleanup === "function" + ) { + filesPageInitializer.cleanup(); + } + if ( + fileUploadHandler && + typeof fileUploadHandler.cleanup === "function" + ) { + fileUploadHandler.cleanup(); + } + }; + + console.log("Files UI initialized successfully", { + captureSelector: !!captureSelector, + pageInitializer: !!filesPageInitializer, + fileUploadHandler: !!fileUploadHandler, + }); + } catch (error) { + ErrorHandler.showError( + "Failed to initialize Files UI components", + "initialization", + error, + ); + } +}); diff --git a/gateway/sds_gateway/static/js/deprecated/files-upload.js b/gateway/sds_gateway/static/js/deprecated/files-upload.js new file mode 100644 index 000000000..43329c4e0 --- /dev/null +++ b/gateway/sds_gateway/static/js/deprecated/files-upload.js @@ -0,0 +1,763 @@ +/** + * Files Upload Handler + * Manages file upload functionality, BLAKE3 hashing, and progress tracking + */ + +/** + * BLAKE3 File Handler + * Manages file selection and BLAKE3 hash calculation for deduplication + */ +class Blake3FileHandler { + constructor() { + // Initialize global variables for file tracking + this.initializeGlobalVariables(); + this.setupEventListeners(); + } + + initializeGlobalVariables() { + // Global variables to track files that should be skipped + window.filesToSkip = new Set(); + window.fileCheckResults = new Map(); // Store detailed results for each file + } + + setupEventListeners() { + const modal = document.getElementById("uploadCaptureModal"); + if (!modal) { + console.warn("uploadCaptureModal not found"); + return; + } + + modal.addEventListener("shown.bs.modal", () => { + this.setupFileInputHandler(); + }); + } + + setupFileInputHandler() { + const fileInput = document.getElementById("captureFileInput"); + if (!fileInput) { + console.warn("captureFileInput not found"); + return; + } + + // Remove any previous handler to avoid duplicates + if (window._blake3CaptureHandler) { + fileInput.removeEventListener("change", window._blake3CaptureHandler); + } + + // Create file handler that stores selected files + window._blake3CaptureHandler = async (event) => { + await this.handleFileSelection(event); + }; + + fileInput.addEventListener("change", window._blake3CaptureHandler); + } + + async handleFileSelection(event) { + const files = event.target.files; + if (!files || files.length === 0) { + return; + } + + // Store the selected files for later processing + window.selectedFiles = Array.from(files); + + console.log(`Selected ${files.length} files for upload`); + } + + /** + * Calculate BLAKE3 hash for a file + * @param {File} file - The file to hash + * @returns {Promise} - The BLAKE3 hash in hex format + */ + async calculateBlake3Hash(file) { + try { + const buffer = await file.arrayBuffer(); + const hasher = await hashwasm.createBLAKE3(); + hasher.init(); + hasher.update(new Uint8Array(buffer)); + return hasher.digest("hex"); + } catch (error) { + console.error("Error calculating BLAKE3 hash:", error); + throw error; + } + } + + /** + * Get directory path from webkitRelativePath + * @param {File} file - The file to get directory for + * @returns {string} - The directory path + */ + getDirectoryPath(file) { + if (!file.webkitRelativePath) { + return "/"; + } + + const pathParts = file.webkitRelativePath.split("/"); + if (pathParts.length > 1) { + pathParts.pop(); // Remove filename + return `/${pathParts.join("/")}`; + } + + return "/"; + } + + /** + * Check if a file exists on the server + * @param {File} file - The file to check + * @param {string} hash - The BLAKE3 hash of the file + * @returns {Promise} - The server response + */ + async checkFileExists(file, hash) { + const directory = this.getDirectoryPath(file); + + const checkData = { + directory: directory, + filename: file.name, + checksum: hash, + }; + + try { + const response = await fetch(window.checkFileExistsUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": window.csrfToken, + }, + body: JSON.stringify(checkData), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return await response.json(); + } catch (error) { + console.error("Error checking file existence:", error); + throw error; + } + } + + /** + * Process a single file for duplicate checking + * @param {File} file - The file to process + * @returns {Promise} - Processing result + */ + async processFileForDuplicateCheck(file) { + try { + // Calculate hash + const hash = await this.calculateBlake3Hash(file); + + // Check if file exists + const checkResult = await this.checkFileExists(file, hash); + + // Store results + const directory = this.getDirectoryPath(file); + const fileKey = `${directory}/${file.name}`; + + const result = { + file: file, + directory: directory, + filename: file.name, + checksum: hash, + data: checkResult.data, + }; + + window.fileCheckResults.set(fileKey, result); + + // Mark for skipping if file exists + if (checkResult.data && checkResult.data.file_exists_in_tree === true) { + window.filesToSkip.add(fileKey); + } + + return result; + } catch (error) { + console.error("Error processing file for duplicate check:", error); + return null; + } + } +} + +/** + * Files Upload Modal Handler + * Manages file upload functionality, progress tracking, and chunked uploads + */ +class FilesUploadModal { + constructor() { + this.isProcessing = false; + this.uploadInProgress = false; + this.cancelRequested = false; + this.currentAbortController = null; + + this.initializeElements(); + this.setupEventListeners(); + this.clearExistingModals(); + } + + initializeElements() { + this.cancelButton = document.querySelector( + "#uploadCaptureModal .btn-secondary", + ); + this.submitButton = document.getElementById("uploadSubmitBtn"); + this.uploadModal = document.getElementById("uploadCaptureModal"); + this.fileInput = document.getElementById("captureFileInput"); + this.uploadForm = document.getElementById("uploadCaptureForm"); + } + + setupEventListeners() { + // Modal event listeners + if (this.uploadModal) { + this.uploadModal.addEventListener("show.bs.modal", () => + this.resetState(), + ); + this.uploadModal.addEventListener("hidden.bs.modal", () => + this.resetState(), + ); + } + + // File input change listener + if (this.fileInput) { + this.fileInput.addEventListener("change", () => this.resetState()); + } + + // Cancel button listener + if (this.cancelButton) { + this.cancelButton.addEventListener("click", () => this.handleCancel()); + } + + // Form submit listener + if (this.uploadForm) { + this.uploadForm.addEventListener("submit", (e) => this.handleSubmit(e)); + } + } + + clearExistingModals() { + const existingResultModal = document.getElementById("uploadResultModal"); + if (existingResultModal) { + const modalInstance = bootstrap.Modal.getInstance(existingResultModal); + if (modalInstance) { + modalInstance.hide(); + } + } + } + + resetState() { + this.isProcessing = false; + this.currentAbortController = null; + this.cancelRequested = false; + } + + handleCancel() { + if (this.isProcessing) { + this.cancelRequested = true; + + if (this.currentAbortController) { + this.currentAbortController.abort(); + } + + this.cancelButton.textContent = "Cancelling..."; + this.cancelButton.disabled = true; + + const progressMessage = document.getElementById("progressMessage"); + if (progressMessage) { + progressMessage.textContent = "Cancelling upload..."; + } + + setTimeout(() => { + if (this.cancelRequested) { + this.resetUIState(); + } + }, 500); + } + } + + async handleSubmit(e) { + e.preventDefault(); + + this.isProcessing = true; + this.uploadInProgress = true; + this.cancelRequested = false; + + // Check if files are selected + if (!window.selectedFiles || window.selectedFiles.length === 0) { + alert("Please select files to upload."); + return; + } + + try { + this.showProgressSection(); + await this.checkFilesForDuplicates(); + + if (this.cancelRequested) { + throw new Error("Upload cancelled by user"); + } + + await this.uploadFiles(); + } catch (error) { + this.handleError(error); + } finally { + this.resetUIState(); + } + } + + showProgressSection() { + const progressSection = document.getElementById("checkingProgressSection"); + const progressMessage = document.getElementById("progressMessage"); + + if (progressSection) { + progressSection.style.display = "block"; + } + if (progressMessage) { + progressMessage.textContent = "Checking files for duplicates..."; + } + + this.cancelButton.textContent = "Cancel Processing"; + this.cancelButton.classList.add("btn-warning"); + this.submitButton.disabled = true; + } + + async checkFilesForDuplicates() { + window.filesToSkip = new Set(); + window.fileCheckResults = new Map(); + const files = window.selectedFiles; + const totalFiles = files.length; + + const progressBar = document.getElementById("checkingProgressBar"); + const progressText = document.getElementById("checkingProgressText"); + + for (let i = 0; i < files.length; i++) { + if (this.cancelRequested) break; + + const file = files[i]; + const progress = Math.round(((i + 1) / totalFiles) * 100); + + if (progressBar) progressBar.style.width = `${progress}%`; + if (progressText) progressText.textContent = `${progress}%`; + + await this.processFile(file); + } + + if (this.cancelRequested) { + throw new Error("Upload cancelled by user"); + } + } + + async processFile(file) { + // Calculate BLAKE3 hash + const buffer = await file.arrayBuffer(); + const hasher = await hashwasm.createBLAKE3(); + hasher.init(); + hasher.update(new Uint8Array(buffer)); + const hashHex = hasher.digest("hex"); + + // Calculate directory path + let directory = "/"; + if (file.webkitRelativePath) { + const pathParts = file.webkitRelativePath.split("/"); + if (pathParts.length > 1) { + pathParts.pop(); + directory = `/${pathParts.join("/")}`; + } + } + + // Check if file exists + const checkData = { + directory: directory, + filename: file.name, + checksum: hashHex, + }; + + try { + const response = await fetch(window.checkFileExistsUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": window.csrfToken, + }, + body: JSON.stringify(checkData), + }); + const data = await response.json(); + + const fileKey = `${directory}/${file.name}`; + window.fileCheckResults.set(fileKey, { + file: file, + directory: directory, + filename: file.name, + checksum: hashHex, + data: data.data, + }); + + if (data.data && data.data.file_exists_in_tree === true) { + window.filesToSkip.add(fileKey); + } + } catch (error) { + console.error("Error checking file:", error); + } + } + + async uploadFiles() { + const progressMessage = document.getElementById("progressMessage"); + const progressSection = document.getElementById("checkingProgressSection"); + const progressBar = document.getElementById("checkingProgressBar"); + const progressText = document.getElementById("checkingProgressText"); + + if (progressMessage) { + progressMessage.textContent = "Uploading files and creating captures..."; + } + if (progressBar) progressBar.style.width = "0%"; + if (progressText) progressText.textContent = "0%"; + + const files = window.selectedFiles; + const filesToUpload = []; + const relativePathsToUpload = []; + const allRelativePaths = []; + + // Process files for upload + for (const file of files) { + let directory = "/"; + if (file.webkitRelativePath) { + const pathParts = file.webkitRelativePath.split("/"); + if (pathParts.length > 1) { + pathParts.pop(); + directory = `/${pathParts.join("/")}`; + } + } + const fileKey = `${directory}/${file.name}`; + const relativePath = file.webkitRelativePath || file.name; + + console.debug( + `Processing file: ${file.name}, webkitRelativePath: '${file.webkitRelativePath}', relativePath: '${relativePath}', directory: '${directory}'`, + ); + allRelativePaths.push(relativePath); + + if (!window.filesToSkip.has(fileKey)) { + filesToUpload.push(file); + relativePathsToUpload.push(relativePath); + } + } + + console.debug( + "All relative paths being sent:", + allRelativePaths.slice(0, 5), + ); + console.debug( + "Relative paths to upload:", + relativePathsToUpload.slice(0, 5), + ); + + if (filesToUpload.length > 0 && progressSection) { + progressSection.style.display = "block"; + } + + this.currentAbortController = new AbortController(); + + let result; + if (filesToUpload.length === 0) { + result = await this.uploadSkippedFiles(allRelativePaths); + } else { + result = await this.uploadFilesInChunks( + filesToUpload, + relativePathsToUpload, + allRelativePaths, + ); + } + + this.currentAbortController = null; + this.showUploadResults(result, result.saved_files_count, files.length); + } + + async uploadSkippedFiles(allRelativePaths) { + const formData = new FormData(); + + console.debug( + "uploadSkippedFiles - allRelativePaths:", + allRelativePaths.slice(0, 5), + ); + for (const path of allRelativePaths) { + formData.append("all_relative_paths", path); + } + + this.addCaptureTypeData(formData); + formData.append("csrfmiddlewaretoken", window.csrfToken); + + const response = await fetch(window.uploadFilesUrl, { + method: "POST", + body: formData, + headers: { + "X-CSRFToken": window.csrfToken, + }, + signal: this.currentAbortController.signal, + }); + + return await response.json(); + } + + async uploadFilesInChunks( + filesToUpload, + relativePathsToUpload, + allRelativePaths, + ) { + const CHUNK_SIZE = 5; + const totalFiles = filesToUpload.length; + const progressBar = document.getElementById("checkingProgressBar"); + const progressText = document.getElementById("checkingProgressText"); + const progressMessage = document.getElementById("progressMessage"); + + const allResults = { + file_upload_status: "success", + saved_files_count: 0, + captures: [], + errors: [], + message: "", + }; + + for (let i = 0; i < filesToUpload.length; i += CHUNK_SIZE) { + if (this.cancelRequested) break; + + const chunk = filesToUpload.slice(i, i + CHUNK_SIZE); + const chunkPaths = relativePathsToUpload.slice(i, i + CHUNK_SIZE); + + const totalChunks = Math.ceil(filesToUpload.length / CHUNK_SIZE); + const currentChunk = Math.floor(i / CHUNK_SIZE) + 1; + const isFinalChunk = currentChunk === totalChunks; + + // Update progress + const progress = Math.round(((i + chunk.length) / totalFiles) * 100); + if (progressBar) progressBar.style.width = `${progress}%`; + if (progressText) progressText.textContent = `${progress}%`; + if (progressMessage) { + progressMessage.textContent = `Uploading files ${i + 1}-${Math.min(i + CHUNK_SIZE, totalFiles)} of ${totalFiles} (chunk ${currentChunk}/${totalChunks})...`; + } + + const chunkResult = await this.uploadChunk( + chunk, + chunkPaths, + allRelativePaths, + currentChunk, + totalChunks, + ); + + // Merge results + if (chunkResult.saved_files_count !== undefined) { + allResults.saved_files_count += chunkResult.saved_files_count; + } + if (chunkResult.captures && isFinalChunk) { + allResults.captures = allResults.captures.concat(chunkResult.captures); + } + if (chunkResult.errors) { + allResults.errors = allResults.errors.concat(chunkResult.errors); + } + + if (chunkResult.file_upload_status === "error") { + allResults.file_upload_status = "error"; + allResults.message = chunkResult.message || "Upload failed"; + break; + } + + if (chunkResult.file_upload_status === "success" && isFinalChunk) { + allResults.file_upload_status = "success"; + } + } + + if (this.cancelRequested) { + throw new Error("Upload cancelled by user"); + } + + return allResults; + } + + async uploadChunk( + chunk, + chunkPaths, + allRelativePaths, + currentChunk, + totalChunks, + ) { + const formData = new FormData(); + + console.debug( + `uploadChunk ${currentChunk}/${totalChunks} - chunkPaths:`, + chunkPaths, + ); + console.debug( + `uploadChunk ${currentChunk}/${totalChunks} - allRelativePaths (first 5):`, + allRelativePaths.slice(0, 5), + ); + + for (const file of chunk) { + formData.append("files", file); + } + for (const path of chunkPaths) { + formData.append("relative_paths", path); + } + for (const path of allRelativePaths) { + formData.append("all_relative_paths", path); + } + + this.addCaptureTypeData(formData); + formData.append("csrfmiddlewaretoken", window.csrfToken); + + formData.append("is_chunk", "true"); + formData.append("chunk_number", currentChunk.toString()); + formData.append("total_chunks", totalChunks.toString()); + + const controller = new AbortController(); + this.currentAbortController = controller; + const timeoutId = setTimeout(() => controller.abort(), 300000); + + try { + const response = await fetch(window.uploadFilesUrl, { + method: "POST", + body: formData, + headers: { + "X-CSRFToken": window.csrfToken, + }, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return await response.json(); + } catch (error) { + clearTimeout(timeoutId); + if (error.name === "AbortError") { + throw new Error("Upload timeout - connection may be lost"); + } + throw error; + } + } + + addCaptureTypeData(formData) { + const captureType = document.getElementById("captureTypeSelect").value; + formData.append("capture_type", captureType); + + if (captureType === "drf") { + const channels = document.getElementById("captureChannelsInput").value; + formData.append("channels", channels); + } else if (captureType === "rh") { + const scanGroup = document.getElementById("captureScanGroupInput").value; + formData.append("scan_group", scanGroup); + } + } + + handleError(error) { + if (this.cancelRequested) { + alert( + "Upload cancelled. Any files uploaded before cancellation have been saved.", + ); + setTimeout(() => window.location.reload(), 1000); + } else if (error.name === "AbortError") { + alert( + "Upload was interrupted. Any files uploaded before the interruption have been saved.", + ); + setTimeout(() => window.location.reload(), 1000); + } else if (error.name === "TypeError" && error.message.includes("fetch")) { + alert( + "Network error during upload. Please check your connection and try again.", + ); + } else { + alert(`Upload failed: ${error.message}`); + setTimeout(() => window.location.reload(), 1000); + } + } + + resetUIState() { + this.submitButton.disabled = false; + + const progressSection = document.getElementById("checkingProgressSection"); + if (progressSection) { + progressSection.style.display = "none"; + } + + this.cancelButton.textContent = "Cancel"; + this.cancelButton.classList.remove("btn-warning"); + this.cancelButton.disabled = false; + + const progressBar = document.getElementById("checkingProgressBar"); + const progressText = document.getElementById("checkingProgressText"); + const progressMessage = document.getElementById("progressMessage"); + + if (progressBar) progressBar.style.width = "0%"; + if (progressText) progressText.textContent = "0%"; + if (progressMessage) progressMessage.textContent = ""; + + this.isProcessing = false; + this.uploadInProgress = false; + this.cancelRequested = false; + this.currentAbortController = null; + } + + showUploadResults(result, uploadedCount, totalCount) { + const uploadModal = bootstrap.Modal.getInstance(this.uploadModal); + if (uploadModal) { + uploadModal.hide(); + } + + if (result.file_upload_status === "success") { + const uploaded = uploadedCount ?? 0; + const message = `Upload complete: ${uploaded} / ${totalCount} file${totalCount === 1 ? "" : "s"} uploaded.`; + try { + sessionStorage.setItem( + "filesAlert", + JSON.stringify({ message: message, type: "success" }), + ); + } catch (_) {} + setTimeout(() => window.location.reload(), 500); + } else { + this.showErrorModal(result); + } + } + + showErrorModal(result) { + const modalBody = document.getElementById("uploadResultModalBody"); + const resultModalEl = document.getElementById("uploadResultModal"); + const modal = new bootstrap.Modal(resultModalEl); + + let msg = "Upload Failed
    "; + if (result.message) { + msg += `${result.message}

    `; + } + msg += "Please remove the problematic files and try again."; + + if (result.errors && result.errors.length > 0) { + const errs = result.errors.map((e) => `
  • ${e}
  • `).join(""); + msg += `

    Error Details:
      ${errs}
    `; + } + + modalBody.innerHTML = msg; + modal.show(); + } +} + +// Initialize when DOM is loaded +document.addEventListener("DOMContentLoaded", () => { + // Set up session storage alert handling + const key = "filesAlert"; + const stored = sessionStorage.getItem(key); + if (stored) { + try { + const data = JSON.parse(stored); + if ( + window.components && + typeof window.components.showError === "function" && + data?.type === "error" + ) { + window.components.showError(data.message || "An error occurred."); + } else if ( + window.components && + typeof window.components.showSuccess === "function" && + data?.type === "success" + ) { + window.components.showSuccess(data.message || "Success"); + } + } catch (e) {} + sessionStorage.removeItem(key); + } + + // Initialize BLAKE3 handler first, then upload modal + new Blake3FileHandler(); + new FilesUploadModal(); +}); diff --git a/gateway/sds_gateway/static/js/file-list.js b/gateway/sds_gateway/static/js/file-list.js index 9a99a0140..6e8ba3dd2 100644 --- a/gateway/sds_gateway/static/js/file-list.js +++ b/gateway/sds_gateway/static/js/file-list.js @@ -1,1117 +1,26 @@ /** - * TODO: This file has a lot of redundancy with manager files - * And needs to be deprecated. and have its functionality migrated - * to the new JS structure. + * Capture list page entrypoint. + * Controllers: captures/FileListPageController.js, captures/FileListCapturesTableManager.js. + * Full legacy copy: static/js/deprecated/file-list.js */ - -/* File List Page JavaScript - Refactored to use Components */ - -/** - * Configuration constants - */ -const CONFIG = { - DEBOUNCE_DELAY: 500, - DEFAULT_SORT_BY: "created_at", - DEFAULT_SORT_ORDER: "desc", - MIN_LOADING_TIME: 500, // Minimum milliseconds to display loading indicator - ELEMENT_IDS: { - SEARCH_INPUT: "search-input", - START_DATE: "start_date", - END_DATE: "end_date", - CENTER_FREQ_MIN: "centerFreqMinInput", - CENTER_FREQ_MAX: "centerFreqMaxInput", - APPLY_FILTERS: "apply-filters-btn", - CLEAR_FILTERS: "clear-filters-btn", - ITEMS_PER_PAGE: "items-per-page", - }, -}; - -/** - * Main controller class for the file list page - */ -class FileListController { - constructor() { - this.userInteractedWithFrequency = false; - this.urlParams = new URLSearchParams(window.location.search); - this.currentSortBy = - this.urlParams.get("sort_by") || CONFIG.DEFAULT_SORT_BY; - this.currentSortOrder = - this.urlParams.get("sort_order") || CONFIG.DEFAULT_SORT_ORDER; - - // Cache DOM elements - this.cacheElements(); - - // Initialize components - this.initializeComponents(); - - // Initialize functionality - this.initializeEventHandlers(); - this.initializeFromURL(); - - // Initial setup - this.updateSortIcons(); - this.tableManager.attachRowClickHandlers(); - - // Initialize dropdowns for any existing static dropdowns - this.initializeDropdowns(); - } - - /** - * Cache frequently accessed DOM elements - */ - cacheElements() { - this.elements = { - searchInput: document.getElementById(CONFIG.ELEMENT_IDS.SEARCH_INPUT), - startDate: document.getElementById(CONFIG.ELEMENT_IDS.START_DATE), - endDate: document.getElementById(CONFIG.ELEMENT_IDS.END_DATE), - centerFreqMin: document.getElementById( - CONFIG.ELEMENT_IDS.CENTER_FREQ_MIN, - ), - centerFreqMax: document.getElementById( - CONFIG.ELEMENT_IDS.CENTER_FREQ_MAX, - ), - applyFilters: document.getElementById(CONFIG.ELEMENT_IDS.APPLY_FILTERS), - clearFilters: document.getElementById(CONFIG.ELEMENT_IDS.CLEAR_FILTERS), - itemsPerPage: document.getElementById(CONFIG.ELEMENT_IDS.ITEMS_PER_PAGE), - sortableHeaders: document.querySelectorAll("th.sortable"), - frequencyButton: document.querySelector( - '[data-bs-target="#collapseFrequency"]', - ), - frequencyCollapse: document.getElementById("collapseFrequency"), - dateButton: document.querySelector('[data-bs-target="#collapseDate"]'), - dateCollapse: document.getElementById("collapseDate"), - }; - } - - /** - * Initialize component managers - */ - initializeComponents() { - this.modalManager = new ModalManager({ - modalId: "capture-modal", - modalBodyId: "capture-modal-body", - modalTitleId: "capture-modal-label", - }); - - this.tableManager = new FileListCapturesTableManager({ - tableId: "captures-table", - tableContainerSelector: ".table-responsive", - resultsCountId: "results-count", - modalHandler: this.modalManager, - onSelectionChange: () => this.syncBulkAddToDatasetButton(), - }); - - this.searchManager = new SearchManager({ - searchInputId: CONFIG.ELEMENT_IDS.SEARCH_INPUT, - searchButtonId: "search-btn", - clearButtonId: "reset-search-btn", - searchFormId: "search-form", - onSearchStart: () => this.tableManager.showLoading(), - onSearch: (query, signal) => this.performSearch(signal), - debounceDelay: CONFIG.DEBOUNCE_DELAY, - }); - - this.paginationManager = new PaginationManager({ - containerId: "captures-pagination", - onPageChange: (page) => this.handlePageChange(page), - }); - } - - /** - * Initialize all event handlers - */ - initializeEventHandlers() { - this.initializeTableSorting(); - this.initializeAccordions(); - this.initializeFrequencyHandling(); - this.initializeItemsPerPageHandler(); - this.initializeAddToDatasetButton(); - } - - /** - * Selection mode: one button to enter; when on, show Cancel and Add - */ - initializeAddToDatasetButton() { - const mainBtn = document.getElementById("add-captures-to-dataset-btn"); - const table = document.getElementById("captures-table"); - const modeButtonsWrap = document.getElementById( - "add-to-dataset-mode-buttons", - ); - const cancelBtn = document.getElementById("add-to-dataset-cancel-btn"); - const addBtn = document.getElementById("add-to-dataset-add-btn"); - if (!mainBtn || !table) return; - - const enterSelectionMode = () => { - table.classList.add("selection-mode-active"); - mainBtn.classList.add("d-none"); - mainBtn.setAttribute("aria-pressed", "true"); - if (modeButtonsWrap) modeButtonsWrap.classList.remove("d-none"); - this.syncBulkAddToDatasetButton(); - }; - - mainBtn.addEventListener("click", enterSelectionMode); - - if (cancelBtn) { - cancelBtn.addEventListener("click", () => this.exitSelectionMode()); - } - - if (addBtn) { - addBtn.addEventListener("click", () => { - const ids = Array.from(this.tableManager?.selectedCaptureIds ?? []); - if (ids.length === 0) { - if (window.showAlert) { - window.showAlert( - "Select at least one capture before adding to a dataset.", - "warning", - ); - } - return; - } - const modal = document.getElementById("quickAddToDatasetModal"); - if (modal) { - modal.dataset.captureUuids = JSON.stringify(ids); - const bsModal = bootstrap.Modal.getOrCreateInstance(modal); - bsModal.show(); - } - }); - } - } - - /** - * Exit bulk-add selection mode: hide the mode controls, uncheck all selected - * captures, and clear the selection set. - */ - exitSelectionMode() { - const mainBtn = document.getElementById("add-captures-to-dataset-btn"); - const table = document.getElementById("captures-table"); - const modeButtonsWrap = document.getElementById( - "add-to-dataset-mode-buttons", - ); - table?.classList.remove("selection-mode-active"); - mainBtn?.classList.remove("d-none"); - mainBtn?.setAttribute("aria-pressed", "false"); - modeButtonsWrap?.classList.add("d-none"); - - // Uncheck all visible checkboxes and clear the tracked set - if (this.tableManager) { - for (const uuid of this.tableManager.selectedCaptureIds) { - const cb = document.querySelector( - `.capture-select-checkbox[data-capture-uuid="${uuid}"]`, - ); - if (cb) cb.checked = false; - } - this.tableManager.selectedCaptureIds.clear(); - this.syncBulkAddToDatasetButton(); - } - } - - /** - * While selection mode is active, disable bulk "Add" until at least one capture is selected. - */ - syncBulkAddToDatasetButton() { - const addBtn = document.getElementById("add-to-dataset-add-btn"); - const table = document.getElementById("captures-table"); - if (!addBtn || !table?.classList.contains("selection-mode-active")) { - return; - } - const n = this.tableManager?.selectedCaptureIds?.size ?? 0; - addBtn.disabled = n === 0; - addBtn.title = - n === 0 - ? "Select at least one capture to add to a dataset" - : "Add selected captures to a dataset"; - addBtn.setAttribute( - "aria-label", - n === 0 - ? "Add to dataset — select at least one capture first" - : `Add ${n} selected capture${n === 1 ? "" : "s"} to a dataset`, - ); - } - - /** - * Initialize values from URL parameters - */ - initializeFromURL() { - // Set initial date values from URL - if (this.urlParams.get("date_start") && this.elements.startDate) { - this.elements.startDate.value = this.urlParams.get("date_start"); - } - if (this.urlParams.get("date_end") && this.elements.endDate) { - this.elements.endDate.value = this.urlParams.get("date_end"); - } - - // Set frequency values if they exist in URL - this.initializeFrequencyFromURL(); - } - - /** - * Handle page change events - */ - handlePageChange(page) { - const urlParams = new URLSearchParams(window.location.search); - urlParams.set("page", page.toString()); - window.location.search = urlParams.toString(); - } - - /** - * Build search parameters from form inputs - */ - buildSearchParams() { - const searchParams = new URLSearchParams(); - - const searchQuery = this.elements.searchInput?.value.trim() || ""; - const startDate = this.elements.startDate?.value || ""; - let endDate = this.elements.endDate?.value || ""; - - // If end date is set, include the full day - if (endDate) { - endDate = `${endDate}T23:59:59`; - } - - // Add search parameters - if (searchQuery) searchParams.set("search", searchQuery); - if (startDate) searchParams.set("date_start", startDate); - if (endDate) searchParams.set("date_end", endDate); - - // Only add frequency parameters if user has explicitly interacted - if (this.userInteractedWithFrequency) { - if (this.elements.centerFreqMin?.value) { - searchParams.set("min_freq", this.elements.centerFreqMin.value); - } - if (this.elements.centerFreqMax?.value) { - searchParams.set("max_freq", this.elements.centerFreqMax.value); - } - } - - searchParams.set("sort_by", this.currentSortBy); - searchParams.set("sort_order", this.currentSortOrder); - - return searchParams; - } - - /** - * Execute search API call - */ - async executeSearch(searchParams, signal) { - const apiUrl = `${window.location.pathname.replace(/\/$/, "")}/api/?${searchParams.toString()}`; - - const response = await fetch(apiUrl, { - method: "GET", - headers: { - Accept: "application/json", - }, - credentials: "same-origin", - signal: signal, - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const text = await response.text(); - try { - return JSON.parse(text); - } catch { - throw new Error("Invalid JSON response from server"); - } - } - - /** - * Update UI with search results - */ - updateUI(data) { - if (data.error) { - throw new Error(`Server error: ${data.error}`); - } - - this.tableManager.updateTable(data.captures || [], data.has_results); - } - - /** - * Update browser history without page refresh - */ - updateBrowserHistory(searchParams) { - const newUrl = `${window.location.pathname}?${searchParams.toString()}`; - window.history.pushState({}, "", newUrl); - } - - /** - * Main search function - now broken down into smaller methods - */ - async performSearch(signal) { - try { - const startTime = Date.now(); - this.tableManager.showLoading(); - - const searchParams = this.buildSearchParams(); - const data = await this.executeSearch(searchParams, signal); - - // Ensure minimum loading time is displayed - const elapsedTime = Date.now() - startTime; - if (elapsedTime < CONFIG.MIN_LOADING_TIME) { - await new Promise((resolve) => - setTimeout(resolve, CONFIG.MIN_LOADING_TIME - elapsedTime), - ); - } - - this.updateUI(data); - this.updateBrowserHistory(searchParams); - } catch (error) { - // Don't show error if request was aborted (user issued a new search) - if (error.name === "AbortError") { - console.log("Previous search request was cancelled"); - return; - } - - console.error("Search error:", error); - this.tableManager.showError(`Search failed: ${error.message}`); - } finally { - this.tableManager.hideLoading(); - } - } - - /** - * Initialize table sorting functionality - */ - initializeTableSorting() { - if (!this.elements.sortableHeaders) return; - - for (const header of this.elements.sortableHeaders) { - header.style.cursor = "pointer"; - header.addEventListener("click", () => this.handleSort(header)); - } - } - - /** - * Handle sort click events - */ - handleSort(header) { - try { - const sortField = header.getAttribute("data-sort"); - const currentSort = this.urlParams.get("sort_by"); - const currentOrder = this.urlParams.get("sort_order") || "desc"; - - // Determine new sort order - let newOrder = "asc"; - if (currentSort === sortField && currentOrder === "asc") { - newOrder = "desc"; - } - - // Build new URL with sort parameters - const urlParams = new URLSearchParams(window.location.search); - urlParams.set("sort_by", sortField); - urlParams.set("sort_order", newOrder); - urlParams.set("page", "1"); - - // Navigate to sorted results - window.location.search = urlParams.toString(); - } catch (error) { - console.error("Error handling sort:", error); - } - } - - /** - * Update sort icons to show current sort state - */ - updateSortIcons() { - if (!this.elements.sortableHeaders) return; - - const currentSort = this.urlParams.get("sort_by") || CONFIG.DEFAULT_SORT_BY; - const currentOrder = this.urlParams.get("sort_order") || "desc"; - - for (const header of this.elements.sortableHeaders) { - const sortField = header.getAttribute("data-sort"); - const icon = header.querySelector(".sort-icon"); - - if (icon) { - // Reset classes - icon.className = "bi sort-icon"; - - if (currentSort === sortField) { - // Add active class and appropriate direction icon - icon.classList.add("active"); - if (currentOrder === "desc") { - icon.classList.add("bi-caret-down-fill"); - } else { - icon.classList.add("bi-caret-up-fill"); - } - } else { - // Inactive columns get default down arrow - icon.classList.add("bi-caret-down-fill"); - } - } - } - } - - /** - * Initialize accordion behavior - */ - initializeAccordions() { - // Frequency filter accordion - if (this.elements.frequencyButton && this.elements.frequencyCollapse) { - this.elements.frequencyButton.addEventListener("click", (e) => { - e.preventDefault(); - this.toggleAccordion( - this.elements.frequencyButton, - this.elements.frequencyCollapse, - ); - }); - } - - // Date filter accordion - if (this.elements.dateButton && this.elements.dateCollapse) { - this.elements.dateButton.addEventListener("click", (e) => { - e.preventDefault(); - this.toggleAccordion( - this.elements.dateButton, - this.elements.dateCollapse, - ); - }); - } - } - - /** - * Helper function to toggle accordion state - */ - toggleAccordion(button, collapse) { - const isCollapsed = button.classList.contains("collapsed"); - - if (isCollapsed) { - button.classList.remove("collapsed"); - button.setAttribute("aria-expanded", "true"); - collapse.classList.add("show"); - } else { - button.classList.add("collapsed"); - button.setAttribute("aria-expanded", "false"); - collapse.classList.remove("show"); - } - } - - /** - * Initialize frequency handling - */ - initializeFrequencyHandling() { - // Add event listeners to track user interaction with frequency inputs - if (this.elements.centerFreqMin) { - this.elements.centerFreqMin.addEventListener("change", () => { - this.userInteractedWithFrequency = true; - }); - } - - if (this.elements.centerFreqMax) { - this.elements.centerFreqMax.addEventListener("change", () => { - this.userInteractedWithFrequency = true; - }); - } - - // Apply filters button - if (this.elements.applyFilters) { - this.elements.applyFilters.addEventListener("click", (e) => { - e.preventDefault(); - this.performSearch(); - }); - } - - // Clear filters button - if (this.elements.clearFilters) { - this.elements.clearFilters.addEventListener("click", (e) => { - e.preventDefault(); - this.clearAllFilters(); - }); - } - } - - /** - * Clear all filter inputs - */ - clearAllFilters() { - // Get current URL parameters - const urlParams = new URLSearchParams(window.location.search); - const currentSearch = urlParams.get("search"); - - // Reset all filter inputs except search - if (this.elements.startDate) this.elements.startDate.value = ""; - if (this.elements.endDate) this.elements.endDate.value = ""; - if (this.elements.centerFreqMin) this.elements.centerFreqMin.value = ""; - if (this.elements.centerFreqMax) this.elements.centerFreqMax.value = ""; - - // Reset interaction tracking - this.userInteractedWithFrequency = false; - - // Reset frequency slider if it exists - const frequencyRangeSlider = document.getElementById( - "frequency-range-slider", - ); - if (frequencyRangeSlider?.noUiSlider) { - frequencyRangeSlider.noUiSlider.set([0, 10]); - } - - // Also reset the display values - const lowerValue = document.getElementById("frequency-range-lower"); - const upperValue = document.getElementById("frequency-range-upper"); - if (lowerValue) lowerValue.textContent = "0 GHz"; - if (upperValue) upperValue.textContent = "10 GHz"; - - // Create new URL parameters with only search and sort parameters preserved - const newParams = new URLSearchParams(); - if (currentSearch) { - newParams.set("search", currentSearch); - } - newParams.set("sort_by", this.currentSortBy); - newParams.set("sort_order", this.currentSortOrder); - - // Update URL and trigger search - window.history.pushState( - {}, - "", - `${window.location.pathname}?${newParams.toString()}`, - ); - this.performSearch(); - } - - /** - * Initialize items per page handler - */ - initializeItemsPerPageHandler() { - if (this.elements.itemsPerPage) { - this.elements.itemsPerPage.addEventListener("change", (e) => { - const urlParams = new URLSearchParams(window.location.search); - urlParams.set("items_per_page", e.target.value); - urlParams.set("page", "1"); - window.location.search = urlParams.toString(); - }); - } - } - - /** - * Initialize frequency range from URL parameters - */ - initializeFrequencyFromURL() { - if (!this.elements.centerFreqMin || !this.elements.centerFreqMax) return; - - const minFreq = Number.parseFloat(this.urlParams.get("min_freq")); - const maxFreq = Number.parseFloat(this.urlParams.get("max_freq")); - - if (!Number.isNaN(minFreq)) { - this.elements.centerFreqMin.value = minFreq; - this.userInteractedWithFrequency = true; - } - if (!Number.isNaN(maxFreq)) { - this.elements.centerFreqMax.value = maxFreq; - this.userInteractedWithFrequency = true; - } - - // Update noUiSlider if it exists - if (this.userInteractedWithFrequency) { - this.initializeFrequencySlider(); - } - } - - initializeFrequencySlider() { - try { - const frequencyRangeSlider = document.getElementById( - "frequency-range-slider", - ); - if (frequencyRangeSlider?.noUiSlider) { - const currentValues = frequencyRangeSlider.noUiSlider.get(); - const newMin = !Number.isNaN(minFreq) - ? minFreq - : Number.parseFloat(currentValues[0]); - const newMax = !Number.isNaN(maxFreq) - ? maxFreq - : Number.parseFloat(currentValues[1]); - - frequencyRangeSlider.noUiSlider.set([newMin, newMax]); - } - } catch (error) { - console.error("Error initializing frequency slider:", error); - } - } - - /** - * Initialize dropdowns with body container for proper positioning - */ - initializeDropdowns() { - const dropdownButtons = document.querySelectorAll(".btn-icon-dropdown"); - - if (dropdownButtons.length === 0) { - return; - } - - for (const toggle of dropdownButtons) { - // Check if dropdown is already initialized - if (toggle._dropdown) { - continue; - } - - const _dropdown = new bootstrap.Dropdown(toggle, { - container: "body", - boundary: "viewport", - popperConfig: { - modifiers: [ - { - name: "preventOverflow", - options: { - boundary: "viewport", - }, - }, - ], - }, - }); - - // Manually move dropdown to body when shown - toggle.addEventListener("show.bs.dropdown", () => { - const dropdownMenu = toggle.nextElementSibling; - if (dropdownMenu?.classList.contains("dropdown-menu")) { - document.body.appendChild(dropdownMenu); - } - }); - } - } -} - -/** - * Enhanced CapturesTableManager for file list specific functionality - * Extends the base CapturesTableManager from components.js - */ -class FileListCapturesTableManager extends CapturesTableManager { - /** - * UUIDs selected for quick-add / bulk actions. Class field initializes as soon as - * the instance exists (after super()), so renderRow never runs before this exists. - */ - selectedCaptureIds = new Set(); - - constructor(options) { - super(options); - this.resultsCountElement = document.getElementById(options.resultsCountId); - this.searchButton = document.getElementById("search-btn"); - this.searchButtonContent = document.getElementById("search-btn-content"); - this.searchButtonLoading = document.getElementById("search-btn-loading"); - this.onSelectionChange = options.onSelectionChange ?? null; - this.setupSelectionCheckboxHandler(); - this.setupRowClickSelection(); - } - - _notifySelectionChange() { - if (typeof this.onSelectionChange === "function") { - this.onSelectionChange(); - } - } - - /** - * Override showLoading to toggle button contents instead of showing separate indicator - */ - showLoading() { - if (this.searchButton) { - this.searchButton.disabled = true; - if (this.searchButtonContent) - this.searchButtonContent.classList.add("d-none"); - if (this.searchButtonLoading) - this.searchButtonLoading.classList.remove("d-none"); - } - } - - /** - * Override hideLoading to restore button contents - */ - hideLoading() { - if (this.searchButton) { - this.searchButton.disabled = false; - if (this.searchButtonContent) - this.searchButtonContent.classList.remove("d-none"); - if (this.searchButtonLoading) - this.searchButtonLoading.classList.add("d-none"); - } - } - - /** - * Delegated handler for selection checkboxes: keep selectedCaptureIds in sync - */ - setupSelectionCheckboxHandler() { - this._checkboxChangeHandler = (e) => { - if (!e.target.matches(".capture-select-checkbox")) return; - const uuid = e.target.getAttribute("data-capture-uuid"); - if (!uuid) return; - if (e.target.checked) { - this.selectedCaptureIds.add(uuid); - } else { - this.selectedCaptureIds.delete(uuid); - } - this._notifySelectionChange(); - }; - document.addEventListener("change", this._checkboxChangeHandler); - } - - /** - * When selection mode is active, clicking a row toggles its selection (instead of opening the modal). - * Uses capture phase so we run before the row's click handler. - */ - setupRowClickSelection() { - const table = document.getElementById(this.tableId); - if (!table) return; - this._rowClickTable = table; - - this._rowClickHandler = (e) => { - if (!table.classList.contains("selection-mode-active")) return; - if ( - e.target.closest( - "button, a, [data-bs-toggle='dropdown'], .capture-select-checkbox", - ) - ) - return; - const row = e.target.closest("tr"); - if (!row) return; - const checkbox = row.querySelector(".capture-select-checkbox"); - if (!checkbox) return; - const uuid = checkbox.getAttribute("data-capture-uuid"); - if (!uuid) return; - - if (this.selectedCaptureIds.has(uuid)) { - this.selectedCaptureIds.delete(uuid); - checkbox.checked = false; - } else { - this.selectedCaptureIds.add(uuid); - checkbox.checked = true; - } - this._notifySelectionChange(); - e.preventDefault(); - e.stopPropagation(); - }; - - table.addEventListener("click", this._rowClickHandler, true); - } - - destroy() { - if (this._checkboxChangeHandler) { - document.removeEventListener("change", this._checkboxChangeHandler); - this._checkboxChangeHandler = null; - } - if (this._rowClickHandler && this._rowClickTable) { - this._rowClickTable.removeEventListener( - "click", - this._rowClickHandler, - true, - ); - this._rowClickHandler = null; - this._rowClickTable = null; - } - super.destroy(); - } - - /** - * Initialize dropdowns with body container for proper positioning - */ - initializeDropdowns() { - const dropdownButtons = document.querySelectorAll(".btn-icon-dropdown"); - - if (dropdownButtons.length === 0) { - return; - } - - for (const toggle of dropdownButtons) { - // Check if dropdown is already initialized - if (toggle._dropdown) { - continue; - } - - const _dropdown = new bootstrap.Dropdown(toggle, { - container: "body", - boundary: "viewport", - popperConfig: { - modifiers: [ - { - name: "preventOverflow", - options: { - boundary: "viewport", - }, - }, - ], - }, - }); - - // Manually move dropdown to body when shown - toggle.addEventListener("show.bs.dropdown", () => { - const dropdownMenu = toggle.nextElementSibling; - if (dropdownMenu?.classList.contains("dropdown-menu")) { - document.body.appendChild(dropdownMenu); - } - }); - } - } - - /** - * Update table with new data - */ - updateTable(captures, hasResults) { - this.selectedCaptureIds ??= new Set(); - const tbody = this.tbody ?? this.table?.querySelector("tbody"); - if (!tbody) return; - - // Update results count - this.updateResultsCount(captures, hasResults); - - if (!hasResults || captures.length === 0) { - tbody.innerHTML = ` - - - No captures found matching your search criteria. - - - `; - this._notifySelectionChange(); - return; - } - - // Build table HTML efficiently - const tableHTML = captures - .map((capture, index) => this.renderRow(capture, index)) - .join(""); - tbody.innerHTML = tableHTML; - - // Initialize dropdowns after table is updated - this.initializeDropdowns(); - this._notifySelectionChange(); - } - - /** - * Update results count display - */ - updateResultsCount(captures, hasResults) { - if (this.resultsCountElement) { - const count = hasResults && captures ? captures.length : 0; - const pluralSuffix = count === 1 ? "" : "s"; - this.resultsCountElement.textContent = `${count} capture${pluralSuffix} found`; - } - } - - /** - * Render individual table row with XSS protection - * Overrides the base class method to include file-specific columns - */ - renderRow(capture) { - this.selectedCaptureIds ??= new Set(); - // Sanitize all data before rendering - const safeData = { - uuid: ComponentUtils.escapeHtml(capture.uuid || ""), - name: ComponentUtils.escapeHtml(capture.name || ""), - channel: ComponentUtils.escapeHtml(capture.channel || ""), - scanGroup: ComponentUtils.escapeHtml(capture.scan_group || ""), - captureType: ComponentUtils.escapeHtml(capture.capture_type || ""), - captureTypeDisplay: ComponentUtils.escapeHtml( - capture.capture_type_display || "", - ), - topLevelDir: ComponentUtils.escapeHtml(capture.top_level_dir || ""), - indexName: ComponentUtils.escapeHtml(capture.index_name || ""), - owner: ComponentUtils.escapeHtml(capture.owner || ""), - origin: ComponentUtils.escapeHtml(capture.origin || ""), - dataset: ComponentUtils.escapeHtml(capture.dataset || ""), - createdAt: ComponentUtils.escapeHtml(capture.created_at || ""), - updatedAt: ComponentUtils.escapeHtml(capture.updated_at || ""), - isPublic: ComponentUtils.escapeHtml(capture.is_public || ""), - isDeleted: ComponentUtils.escapeHtml(capture.is_deleted || ""), - centerFrequencyGhz: ComponentUtils.escapeHtml( - capture.center_frequency_ghz || "", - ), - lengthOfCaptureMs: capture.length_of_capture_ms ?? 0, - fileCadenceMs: capture.file_cadence_ms ?? 1000, - perDataFileSize: capture.per_data_file_size ?? 0, - totalSize: capture.total_file_size ?? 0, - dataFilesCount: capture.data_files_count ?? 0, - dataFilesTotalSize: capture.data_files_total_size ?? 0, - totalFilesCount: capture.files.length ?? 0, - captureStartEpochSec: capture.capture_start_epoch_sec ?? 0, - }; - - let typeDisplay = safeData.captureTypeDisplay || safeData.captureType; - - if (capture.is_multi_channel) { - typeDisplay = capture.capture_type_display || safeData.captureType; - } - - // Display name with fallback to "Unnamed Capture" - const nameDisplay = safeData.name || "Unnamed Capture"; - - // Format created date to match template format - let createdDate = "-"; - if (capture.created_at) { - const date = new Date(capture.created_at); - if (!Number.isNaN(date.getTime())) { - const dateStr = date.toISOString().split("T")[0]; // YYYY-MM-DD - const timeStr = date.toLocaleTimeString("en-US", { - hour12: false, - timeZoneName: "short", - }); // HH:mm:ss TZ - createdDate = ` -
    - ${dateStr} - ${timeStr} -
    - `; - } - } - - // Check if shared (for shared icon) - const isShared = capture.is_shared_with_me || false; - const sharedIcon = isShared - ? ` - - ` - : ""; - - // Check if owner (for conditional actions and selection — only owned captures are selectable) - const isOwner = capture.is_owner === true; - - const checked = this.selectedCaptureIds.has(capture.uuid) ? " checked" : ""; - const selectCell = isOwner - ? `` - : ''; - return ` - - ${selectCell} - - - ${nameDisplay} - - ${sharedIcon} - - ${safeData.topLevelDir || "-"} - ${typeDisplay} - ${createdDate} - - - - - `; - } -} - -// Expose frequency slider initialization function globally for backward compatibility window.initializeFrequencySlider = () => { - // This function is called from the template if (window.fileListController) { window.fileListController.initializeFrequencyFromURL(); } }; -// Initialize the application when DOM is loaded document.addEventListener("DOMContentLoaded", () => { try { - window.fileListController = new FileListController(); + window.fileListController = new FileListPageController(); } catch (error) { console.error("Error initializing file list controller:", error); } }); -// Export for ES6 modules (Jest testing) - only if in module context if (typeof module !== "undefined" && module.exports) { - module.exports = { FileListController, FileListCapturesTableManager }; + const { FileListPageController } = require("./captures/FileListPageController.js"); + const { + FileListCapturesTableManager, + } = require("./captures/FileListCapturesTableManager.js"); + module.exports = { FileListController: FileListPageController, FileListCapturesTableManager }; } diff --git a/gateway/sds_gateway/static/js/file-manager.js b/gateway/sds_gateway/static/js/file-manager.js index cbe928cd4..1498e8c69 100644 --- a/gateway/sds_gateway/static/js/file-manager.js +++ b/gateway/sds_gateway/static/js/file-manager.js @@ -14,178 +14,17 @@ class FileManager { this.boundHandlers = new Map(); // Track bound event handlers for cleanup this.activeModals = new Set(); // Track active modals - // Prevent browser from navigating away when user drags files over the whole window - this.addGlobalDropGuards(); + this._fileDrop = new FileDropManager(this); + this._fileDrop.addGlobalDropGuards(); this.init(); } - addGlobalDropGuards() { - // Prevent browser navigation on any drop event - document.addEventListener( - "dragover", - (e) => { - e.preventDefault(); - }, - false, - ); - - document.addEventListener( - "drop", - (e) => { - e.preventDefault(); - e.stopPropagation(); - - // Always handle global drops for testing - this.handleGlobalDrop(e); - }, - false, - ); - } - - async handleGlobalDrop(e) { - const dt = e.dataTransfer; - if (!dt) { - console.warn("No dataTransfer in global drop"); - return; - } - - const files = await this.collectFilesFromDataTransfer(dt); - - if (!files.length) { - console.warn("No files collected from global drop"); - return; - } - - // Store the dropped files globally - window.selectedFiles = files; - - // Open the upload modal - const uploadModalEl = document.getElementById("uploadCaptureModal"); - if (!uploadModalEl) { - console.error("Upload modal element not found"); - return; - } - - const uploadModal = new bootstrap.Modal(uploadModalEl); - uploadModal.show(); - - // Wait a bit for modal to fully open, then trigger file selection - setTimeout(() => { - this.handleGlobalFilesInModal(files); - }, 200); - } - - handleGlobalFilesInModal(files) { - // Update the file input to show selected files - const fileInput = document.getElementById("captureFileInput"); - if (fileInput) { - // Create a new FileList-like object - const dataTransfer = new DataTransfer(); - for (const file of files) { - dataTransfer.items.add(file); - } - fileInput.files = dataTransfer.files; - } - - // Update the selected files display - this.handleFileSelection(files); - - // Make sure the selected files section is visible - const selectedFilesSection = document.getElementById("selectedFiles"); - if (selectedFilesSection) { - selectedFilesSection.classList.add("has-files"); - } - - // Update the file input label to show selected files - const fileInputLabel = fileInput?.nextElementSibling; - if (fileInputLabel?.classList.contains("form-control")) { - const fileNames = files - .map((f) => f.webkitRelativePath || f.name) - .join(", "); - fileInputLabel.textContent = fileNames || "No directory selected."; - } - } - convertToFiles(itemsOrFiles) { - if (!itemsOrFiles) return []; - // DataTransferItemList detection: items have getAsFile() - const first = itemsOrFiles[0]; - if (first && typeof first.getAsFile === "function") { - return Array.from(itemsOrFiles) - .map((item) => item.getAsFile()) - .filter((f) => !!f); - } - return Array.from(itemsOrFiles); + return this._fileDrop.convertToFiles(itemsOrFiles); } - // Collect files from a directory or mixed drop using the File System API (Chrome/WebKit) async collectFilesFromDataTransfer(dataTransfer) { - const items = Array.from(dataTransfer.items || []); - const supportsEntries = - items.length > 0 && typeof items[0].webkitGetAsEntry === "function"; - if (!supportsEntries) { - return this.convertToFiles( - dataTransfer.files?.length ? dataTransfer.files : dataTransfer.items, - ); - } - - const allFiles = []; - for (const item of items) { - if (item.kind !== "file") continue; - const entry = item.webkitGetAsEntry(); - if (!entry) continue; - const files = await this.traverseEntry(entry); - allFiles.push(...files); - } - return allFiles; - } - - async traverseEntry(entry) { - if (entry.isFile) { - return new Promise((resolve) => { - entry.file((file) => { - // Preserve folder structure on drop by injecting webkitRelativePath - try { - const relative = (entry.fullPath || file.name).replace(/^\//, ""); - Object.defineProperty(file, "webkitRelativePath", { - value: relative, - configurable: true, - }); - } catch (_) {} - resolve([file]); - }); - }); - } - - if (entry.isDirectory) { - const reader = entry.createReader(); - const entries = await this.readAllEntries(reader); - const nestedFiles = []; - for (const child of entries) { - const files = await this.traverseEntry(child); - nestedFiles.push(...files); - } - return nestedFiles; - } - - return []; - } - - readAllEntries(reader) { - return new Promise((resolve) => { - const entries = []; - const readChunk = () => { - reader.readEntries((results) => { - if (!results.length) { - resolve(entries); - return; - } - entries.push(...results); - readChunk(); - }); - }; - readChunk(); - }); + return this._fileDrop.collectFilesFromDataTransfer(dataTransfer); } stripHtml(html) { diff --git a/gateway/sds_gateway/static/js/search/DebouncedSearchManager.js b/gateway/sds_gateway/static/js/search/DebouncedSearchManager.js new file mode 100644 index 000000000..a40add3ac --- /dev/null +++ b/gateway/sds_gateway/static/js/search/DebouncedSearchManager.js @@ -0,0 +1,110 @@ +/** + * Debounced search with AbortController (historically named SearchManager on window). + * Migrated from deprecated/components.js. + */ + +class SearchManager { + constructor(config) { + this.searchInput = document.getElementById(config.searchInputId); + this.searchButton = document.getElementById(config.searchButtonId); + this.clearButton = document.getElementById("clear-search-btn"); + this.onSearch = config.onSearch; + this.onSearchStart = config.onSearchStart; + this.debounceDelay = config.debounceDelay || 500; + this.debounceTimer = null; + this.abortController = new AbortController(); + + this.initializeEventListeners(); + this.updateClearButtonVisibility(); + } + + initializeEventListeners() { + if (this.searchInput) { + this.searchInput.addEventListener("input", () => { + this.debounceSearch(); + this.updateClearButtonVisibility(); + }); + + this.searchInput.addEventListener("keypress", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + this.debounceSearch(); + } + }); + } + + if (this.searchButton) { + this.searchButton.addEventListener("click", (e) => { + e.preventDefault(); + this.debounceSearch(); + }); + } + + if (this.clearButton) { + this.clearButton.addEventListener("click", (e) => { + e.preventDefault(); + this.clearSearch(); + }); + } + } + + updateClearButtonVisibility() { + if (this.clearButton) { + this.clearButton.style.display = this.searchInput?.value + ? "block" + : "none"; + } + } + + debounceSearch() { + // Show loading indicator immediately for visual confirmation + if (this.onSearchStart) { + this.onSearchStart(); + } + + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + + this.debounceTimer = setTimeout(() => { + this.performSearch(); + }, this.debounceDelay); + } + + performSearch() { + // Cancel any previous request and create a new abort controller + this.abortController.abort(); + this.abortController = new AbortController(); + + const query = this.searchInput?.value || ""; + + if (this.onSearch) { + this.onSearch(query, this.abortController.signal); + } + } + + clearSearch() { + if (this.searchInput) { + this.searchInput.value = ""; + this.updateClearButtonVisibility(); + } + + this.debounceSearch(); + } + + /** + * Get the current abort signal for fetch requests + */ + getAbortSignal() { + return this.abortController.signal; + } +} + +if (typeof window !== "undefined") { + window.SearchManager = SearchManager; + window.DebouncedSearchManager = SearchManager; +} +if (typeof module !== "undefined" && module.exports) { + module.exports = { SearchManager, DebouncedSearchManager: SearchManager }; +} + diff --git a/gateway/sds_gateway/static/js/search/FilterManager.js b/gateway/sds_gateway/static/js/search/FilterManager.js new file mode 100644 index 000000000..78b4204c2 --- /dev/null +++ b/gateway/sds_gateway/static/js/search/FilterManager.js @@ -0,0 +1,177 @@ +/** + * Filter form state and URL sync. + * Migrated from deprecated/components.js. + */ + +class FilterManager { + constructor(config) { + this.formId = config.formId; + this.form = document.getElementById(this.formId); + this.applyButton = document.getElementById(config.applyButtonId); + this.clearButton = document.getElementById(config.clearButtonId); + this.onFilterChange = config.onFilterChange; + this.searchInputId = config.searchInputId || "search-input"; + + this.initializeEventListeners(); + this.loadFromURL(); + } + + initializeEventListeners() { + if (this.applyButton) { + this.applyButton.addEventListener("click", (e) => { + e.preventDefault(); + this.applyFilters(); + }); + } + + if (this.clearButton) { + this.clearButton.addEventListener("click", (e) => { + e.preventDefault(); + this.clearFilters(); + }); + } + + // Auto-apply on form submission + if (this.form) { + this.form.addEventListener("submit", (e) => { + e.preventDefault(); + this.applyFilters(); + }); + } + } + + getFilterValues() { + if (!this.form) return {}; + + const formData = new FormData(this.form); + const filters = {}; + + for (const [key, value] of formData.entries()) { + if (value && value.trim() !== "") { + filters[key] = value.trim(); + } + } + + return filters; + } + + applyFilters() { + const filters = this.getFilterValues(); + this.updateURL(filters); + + if (this.onFilterChange) { + this.onFilterChange(filters); + } + } + + clearFilters() { + if (!this.form) return; + + // Get all form inputs except the search input + const inputs = this.form.querySelectorAll("input, select, textarea"); + for (const input of inputs) { + // Skip the search input + if (input.id === this.searchInputId) { + continue; + } + + // Clear other inputs + if (input.type === "checkbox" || input.type === "radio") { + input.checked = false; + } else { + input.value = ""; + } + } + + // Get current URL parameters + const urlParams = new URLSearchParams(window.location.search); + const searchValue = urlParams.get("search"); + const sortBy = urlParams.get("sort_by") || "created_at"; + const sortOrder = urlParams.get("sort_order") || "desc"; + + // Clear all parameters except search and sort + urlParams.forEach((_, key) => { + if (key !== "search" && key !== "sort_by" && key !== "sort_order") { + urlParams.delete(key); + } + }); + + // Ensure sort parameters are set + urlParams.set("sort_by", sortBy); + urlParams.set("sort_order", sortOrder); + urlParams.set("page", "1"); + + // Update URL + const newUrl = `${window.location.pathname}?${urlParams.toString()}`; + window.history.pushState({}, "", newUrl); + + // Trigger filter change callback + if (this.onFilterChange) { + const filters = { + sort_by: sortBy, + sort_order: sortOrder, + }; + if (searchValue) { + filters.search = searchValue; + } + this.onFilterChange(filters); + } + } + + loadFromURL() { + if (!this.form) return; + + const urlParams = new URLSearchParams(window.location.search); + const inputs = this.form.querySelectorAll("input, select, textarea"); + + for (const input of inputs) { + const value = urlParams.get(input.name); + if (value !== null) { + if (input.type === "checkbox" || input.type === "radio") { + input.checked = value === "true" || value === input.value; + } else { + input.value = value; + } + } + } + } + + updateURL(filters) { + const urlParams = new URLSearchParams(window.location.search); + + // Preserve search parameter if it exists + const searchValue = urlParams.get("search"); + + // Remove old filter parameters + const formData = new FormData(this.form || document.createElement("form")); + for (const key of formData.keys()) { + urlParams.delete(key); + } + + // Add new filter parameters + for (const [key, value] of Object.entries(filters)) { + if (value) { + urlParams.set(key, value); + } + } + + // Restore search parameter if it existed + if (searchValue) { + urlParams.set("search", searchValue); + } + + // Reset to first page when filters change + urlParams.set("page", "1"); + + const newUrl = `${window.location.pathname}?${urlParams.toString()}`; + window.history.pushState({}, "", newUrl); + } +} + +if (typeof window !== "undefined") { + window.FilterManager = FilterManager; +} +if (typeof module !== "undefined" && module.exports) { + module.exports = { FilterManager }; +} + diff --git a/gateway/sds_gateway/static/js/search/_frag_filter.txt b/gateway/sds_gateway/static/js/search/_frag_filter.txt new file mode 100644 index 000000000..880e7e19e --- /dev/null +++ b/gateway/sds_gateway/static/js/search/_frag_filter.txt @@ -0,0 +1,164 @@ +class FilterManager { + constructor(config) { + this.formId = config.formId; + this.form = document.getElementById(this.formId); + this.applyButton = document.getElementById(config.applyButtonId); + this.clearButton = document.getElementById(config.clearButtonId); + this.onFilterChange = config.onFilterChange; + this.searchInputId = config.searchInputId || "search-input"; + + this.initializeEventListeners(); + this.loadFromURL(); + } + + initializeEventListeners() { + if (this.applyButton) { + this.applyButton.addEventListener("click", (e) => { + e.preventDefault(); + this.applyFilters(); + }); + } + + if (this.clearButton) { + this.clearButton.addEventListener("click", (e) => { + e.preventDefault(); + this.clearFilters(); + }); + } + + // Auto-apply on form submission + if (this.form) { + this.form.addEventListener("submit", (e) => { + e.preventDefault(); + this.applyFilters(); + }); + } + } + + getFilterValues() { + if (!this.form) return {}; + + const formData = new FormData(this.form); + const filters = {}; + + for (const [key, value] of formData.entries()) { + if (value && value.trim() !== "") { + filters[key] = value.trim(); + } + } + + return filters; + } + + applyFilters() { + const filters = this.getFilterValues(); + this.updateURL(filters); + + if (this.onFilterChange) { + this.onFilterChange(filters); + } + } + + clearFilters() { + if (!this.form) return; + + // Get all form inputs except the search input + const inputs = this.form.querySelectorAll("input, select, textarea"); + for (const input of inputs) { + // Skip the search input + if (input.id === this.searchInputId) { + continue; + } + + // Clear other inputs + if (input.type === "checkbox" || input.type === "radio") { + input.checked = false; + } else { + input.value = ""; + } + } + + // Get current URL parameters + const urlParams = new URLSearchParams(window.location.search); + const searchValue = urlParams.get("search"); + const sortBy = urlParams.get("sort_by") || "created_at"; + const sortOrder = urlParams.get("sort_order") || "desc"; + + // Clear all parameters except search and sort + urlParams.forEach((_, key) => { + if (key !== "search" && key !== "sort_by" && key !== "sort_order") { + urlParams.delete(key); + } + }); + + // Ensure sort parameters are set + urlParams.set("sort_by", sortBy); + urlParams.set("sort_order", sortOrder); + urlParams.set("page", "1"); + + // Update URL + const newUrl = `${window.location.pathname}?${urlParams.toString()}`; + window.history.pushState({}, "", newUrl); + + // Trigger filter change callback + if (this.onFilterChange) { + const filters = { + sort_by: sortBy, + sort_order: sortOrder, + }; + if (searchValue) { + filters.search = searchValue; + } + this.onFilterChange(filters); + } + } + + loadFromURL() { + if (!this.form) return; + + const urlParams = new URLSearchParams(window.location.search); + const inputs = this.form.querySelectorAll("input, select, textarea"); + + for (const input of inputs) { + const value = urlParams.get(input.name); + if (value !== null) { + if (input.type === "checkbox" || input.type === "radio") { + input.checked = value === "true" || value === input.value; + } else { + input.value = value; + } + } + } + } + + updateURL(filters) { + const urlParams = new URLSearchParams(window.location.search); + + // Preserve search parameter if it exists + const searchValue = urlParams.get("search"); + + // Remove old filter parameters + const formData = new FormData(this.form || document.createElement("form")); + for (const key of formData.keys()) { + urlParams.delete(key); + } + + // Add new filter parameters + for (const [key, value] of Object.entries(filters)) { + if (value) { + urlParams.set(key, value); + } + } + + // Restore search parameter if it existed + if (searchValue) { + urlParams.set("search", searchValue); + } + + // Reset to first page when filters change + urlParams.set("page", "1"); + + const newUrl = `${window.location.pathname}?${urlParams.toString()}`; + window.history.pushState({}, "", newUrl); + } +} diff --git a/gateway/sds_gateway/static/js/search/_frag_search.txt b/gateway/sds_gateway/static/js/search/_frag_search.txt new file mode 100644 index 000000000..fee873caf --- /dev/null +++ b/gateway/sds_gateway/static/js/search/_frag_search.txt @@ -0,0 +1,96 @@ +class SearchManager { + constructor(config) { + this.searchInput = document.getElementById(config.searchInputId); + this.searchButton = document.getElementById(config.searchButtonId); + this.clearButton = document.getElementById("clear-search-btn"); + this.onSearch = config.onSearch; + this.onSearchStart = config.onSearchStart; + this.debounceDelay = config.debounceDelay || 500; + this.debounceTimer = null; + this.abortController = new AbortController(); + + this.initializeEventListeners(); + this.updateClearButtonVisibility(); + } + + initializeEventListeners() { + if (this.searchInput) { + this.searchInput.addEventListener("input", () => { + this.debounceSearch(); + this.updateClearButtonVisibility(); + }); + + this.searchInput.addEventListener("keypress", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + this.debounceSearch(); + } + }); + } + + if (this.searchButton) { + this.searchButton.addEventListener("click", (e) => { + e.preventDefault(); + this.debounceSearch(); + }); + } + + if (this.clearButton) { + this.clearButton.addEventListener("click", (e) => { + e.preventDefault(); + this.clearSearch(); + }); + } + } + + updateClearButtonVisibility() { + if (this.clearButton) { + this.clearButton.style.display = this.searchInput?.value + ? "block" + : "none"; + } + } + + debounceSearch() { + // Show loading indicator immediately for visual confirmation + if (this.onSearchStart) { + this.onSearchStart(); + } + + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + + this.debounceTimer = setTimeout(() => { + this.performSearch(); + }, this.debounceDelay); + } + + performSearch() { + // Cancel any previous request and create a new abort controller + this.abortController.abort(); + this.abortController = new AbortController(); + + const query = this.searchInput?.value || ""; + + if (this.onSearch) { + this.onSearch(query, this.abortController.signal); + } + } + + clearSearch() { + if (this.searchInput) { + this.searchInput.value = ""; + this.updateClearButtonVisibility(); + } + + this.debounceSearch(); + } + + /** + * Get the current abort signal for fetch requests + */ + getAbortSignal() { + return this.abortController.signal; + } +} diff --git a/gateway/sds_gateway/static/js/upload/Blake3FileHandler.js b/gateway/sds_gateway/static/js/upload/Blake3FileHandler.js new file mode 100644 index 000000000..023a455dd --- /dev/null +++ b/gateway/sds_gateway/static/js/upload/Blake3FileHandler.js @@ -0,0 +1,182 @@ +/** + * BLAKE3 hashing and duplicate checks for capture uploads. + * Migrated from deprecated/files-upload.js. + */ + +class Blake3FileHandler { + constructor() { + // Initialize global variables for file tracking + this.initializeGlobalVariables(); + this.setupEventListeners(); + } + + initializeGlobalVariables() { + // Global variables to track files that should be skipped + window.filesToSkip = new Set(); + window.fileCheckResults = new Map(); // Store detailed results for each file + } + + setupEventListeners() { + const modal = document.getElementById("uploadCaptureModal"); + if (!modal) { + console.warn("uploadCaptureModal not found"); + return; + } + + modal.addEventListener("shown.bs.modal", () => { + this.setupFileInputHandler(); + }); + } + + setupFileInputHandler() { + const fileInput = document.getElementById("captureFileInput"); + if (!fileInput) { + console.warn("captureFileInput not found"); + return; + } + + // Remove any previous handler to avoid duplicates + if (window._blake3CaptureHandler) { + fileInput.removeEventListener("change", window._blake3CaptureHandler); + } + + // Create file handler that stores selected files + window._blake3CaptureHandler = async (event) => { + await this.handleFileSelection(event); + }; + + fileInput.addEventListener("change", window._blake3CaptureHandler); + } + + async handleFileSelection(event) { + const files = event.target.files; + if (!files || files.length === 0) { + return; + } + + // Store the selected files for later processing + window.selectedFiles = Array.from(files); + + console.log(`Selected ${files.length} files for upload`); + } + + /** + * Calculate BLAKE3 hash for a file + * @param {File} file - The file to hash + * @returns {Promise} - The BLAKE3 hash in hex format + */ + async calculateBlake3Hash(file) { + try { + const buffer = await file.arrayBuffer(); + const hasher = await hashwasm.createBLAKE3(); + hasher.init(); + hasher.update(new Uint8Array(buffer)); + return hasher.digest("hex"); + } catch (error) { + console.error("Error calculating BLAKE3 hash:", error); + throw error; + } + } + + /** + * Get directory path from webkitRelativePath + * @param {File} file - The file to get directory for + * @returns {string} - The directory path + */ + getDirectoryPath(file) { + if (!file.webkitRelativePath) { + return "/"; + } + + const pathParts = file.webkitRelativePath.split("/"); + if (pathParts.length > 1) { + pathParts.pop(); // Remove filename + return `/${pathParts.join("/")}`; + } + + return "/"; + } + + /** + * Check if a file exists on the server + * @param {File} file - The file to check + * @param {string} hash - The BLAKE3 hash of the file + * @returns {Promise} - The server response + */ + async checkFileExists(file, hash) { + const directory = this.getDirectoryPath(file); + + const checkData = { + directory: directory, + filename: file.name, + checksum: hash, + }; + + try { + const response = await fetch(window.checkFileExistsUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": window.csrfToken, + }, + body: JSON.stringify(checkData), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return await response.json(); + } catch (error) { + console.error("Error checking file existence:", error); + throw error; + } + } + + /** + * Process a single file for duplicate checking + * @param {File} file - The file to process + * @returns {Promise} - Processing result + */ + async processFileForDuplicateCheck(file) { + try { + // Calculate hash + const hash = await this.calculateBlake3Hash(file); + + // Check if file exists + const checkResult = await this.checkFileExists(file, hash); + + // Store results + const directory = this.getDirectoryPath(file); + const fileKey = `${directory}/${file.name}`; + + const result = { + file: file, + directory: directory, + filename: file.name, + checksum: hash, + data: checkResult.data, + }; + + window.fileCheckResults.set(fileKey, result); + + // Mark for skipping if file exists + if (checkResult.data && checkResult.data.file_exists_in_tree === true) { + window.filesToSkip.add(fileKey); + } + + return result; + } catch (error) { + console.error("Error processing file for duplicate check:", error); + return null; + } + } +} + +if (typeof window !== "undefined") { + window.Blake3FileHandler = Blake3FileHandler; +} +if (typeof module !== "undefined" && module.exports) { + module.exports = { Blake3FileHandler }; +} + diff --git a/gateway/sds_gateway/static/js/upload/CaptureTypeSelector.js b/gateway/sds_gateway/static/js/upload/CaptureTypeSelector.js new file mode 100644 index 000000000..2c4868c21 --- /dev/null +++ b/gateway/sds_gateway/static/js/upload/CaptureTypeSelector.js @@ -0,0 +1,192 @@ +/** Capture type dropdown for upload modal. Migrated from deprecated/files-ui.js */ +/** + * Capture Type Selection Handler + * Manages capture type dropdown and conditional form fields + */ +class CaptureTypeSelector { + constructor() { + this.boundHandlers = new Map(); // Track event handlers for cleanup + this.initializeElements(); + this.setupEventListeners(); + } + + initializeElements() { + this.captureTypeSelect = document.getElementById("captureTypeSelect"); + this.channelInputGroup = document.getElementById("channelInputGroup"); + this.scanGroupInputGroup = document.getElementById("scanGroupInputGroup"); + this.captureChannelsInput = document.getElementById("captureChannelsInput"); + this.captureScanGroupInput = document.getElementById( + "captureScanGroupInput", + ); + this.uploadModal = document.getElementById("uploadCaptureModal"); + + // Log which elements were found for debugging + console.log("CaptureTypeSelector elements found:", { + captureTypeSelect: !!this.captureTypeSelect, + channelInputGroup: !!this.channelInputGroup, + scanGroupInputGroup: !!this.scanGroupInputGroup, + captureChannelsInput: !!this.captureChannelsInput, + captureScanGroupInput: !!this.captureScanGroupInput, + uploadModal: !!this.uploadModal, + }); + } + + setupEventListeners() { + // Ensure boundHandlers is initialized + if (!this.boundHandlers) { + this.boundHandlers = new Map(); + } + + if (this.captureTypeSelect) { + const changeHandler = (e) => this.handleTypeChange(e); + this.boundHandlers.set(this.captureTypeSelect, changeHandler); + this.captureTypeSelect.addEventListener("change", changeHandler); + } + + if (this.uploadModal) { + const hiddenHandler = () => this.resetForm(); + this.boundHandlers.set(this.uploadModal, hiddenHandler); + this.uploadModal.addEventListener("hidden.bs.modal", hiddenHandler); + } + } + + handleTypeChange(event) { + const selectedType = event.target.value; + + // Validate capture type + if (!this.validateCaptureType(selectedType)) { + ErrorHandler.showError( + "Invalid capture type selected", + "capture-type-validation", + ); + return; + } + + // Hide both input groups initially + this.hideInputGroups(); + + // Clear required attributes + this.clearRequiredAttributes(); + + // Show appropriate input group based on selection + if (selectedType === "drf") { + this.showChannelInput(); + } else if (selectedType === "rh") { + this.showScanGroupInput(); + } + } + + hideInputGroups() { + if (this.channelInputGroup) { + this.channelInputGroup.classList.add("hidden-input-group"); + } + if (this.scanGroupInputGroup) { + this.scanGroupInputGroup.classList.add("hidden-input-group"); + } + } + + clearRequiredAttributes() { + if (this.captureChannelsInput) { + this.captureChannelsInput.removeAttribute("required"); + } + if (this.captureScanGroupInput) { + this.captureScanGroupInput.removeAttribute("required"); + } + } + + showChannelInput() { + if (this.channelInputGroup) { + this.channelInputGroup.classList.remove("hidden-input-group"); + } + if (this.captureChannelsInput) { + this.captureChannelsInput.setAttribute("required", "required"); + } + } + + showScanGroupInput() { + if (this.scanGroupInputGroup) { + this.scanGroupInputGroup.classList.remove("hidden-input-group"); + } + // scan_group is optional for RadioHound captures, so no required attribute + } + + // Input validation methods + validateCaptureType(type) { + const validTypes = ["drf", "rh"]; + return validTypes.includes(type); + } + + validateChannelInput(channels) { + if (!channels || typeof channels !== "string") return false; + // Basic validation for channel input (can be enhanced based on requirements) + return channels.trim().length > 0 && channels.length <= 1000; + } + + validateScanGroupInput(scanGroup) { + if (!scanGroup || typeof scanGroup !== "string") return false; + // Basic validation for scan group input + return scanGroup.trim().length > 0 && scanGroup.length <= 255; + } + + sanitizeInput(input) { + if (!input || typeof input !== "string") return ""; + // Remove potentially dangerous characters + return input.replace(/[<>:"/\\|?*]/g, "_").trim(); + } + + // Memory management and cleanup + cleanup() { + // Remove all bound event handlers + for (const [element, handler] of this.boundHandlers) { + if (element?.removeEventListener) { + element.removeEventListener("change", handler); + element.removeEventListener("hidden.bs.modal", handler); + } + } + this.boundHandlers.clear(); + console.log("CaptureTypeSelector cleanup completed"); + } + + resetForm() { + // Reset the form + const form = document.getElementById("uploadCaptureForm"); + if (form) { + form.reset(); + } + + // Hide input groups + this.hideInputGroups(); + + // Clear required attributes + this.clearRequiredAttributes(); + + // Clear global variables if they exist + this.cleanupGlobalState(); + } + + // Better global state management + cleanupGlobalState() { + const globalVars = ["filesToSkip", "fileCheckResults", "selectedFiles"]; + + for (const varName of globalVars) { + if (window[varName]) { + if (typeof window[varName].clear === "function") { + window[varName].clear(); + } else if (Array.isArray(window[varName])) { + window[varName].length = 0; + } else { + window[varName] = null; + } + console.log(`Cleaned up global variable: ${varName}`); + } + } + } +} + +if (typeof window !== "undefined") { + window.CaptureTypeSelector = CaptureTypeSelector; +} +if (typeof module !== "undefined" && module.exports) { + module.exports = { CaptureTypeSelector }; +} + diff --git a/gateway/sds_gateway/static/js/upload/FileDropManager.js b/gateway/sds_gateway/static/js/upload/FileDropManager.js new file mode 100644 index 000000000..d4bfc7a76 --- /dev/null +++ b/gateway/sds_gateway/static/js/upload/FileDropManager.js @@ -0,0 +1,173 @@ +/** + * Global drag/drop and DataTransfer directory traversal for capture uploads. + * Migrated from deprecated/file-manager.js. + */ +class FileDropManager { + /** @param {{ handleFileSelection: (files: File[]) => void }} host */ + constructor(host) { + this.host = host; + } + + addGlobalDropGuards() { + document.addEventListener( + "dragover", + (e) => { + e.preventDefault(); + }, + false, + ); + + document.addEventListener( + "drop", + (e) => { + e.preventDefault(); + e.stopPropagation(); + this.handleGlobalDrop(e); + }, + false, + ); + } + + async handleGlobalDrop(e) { + const dt = e.dataTransfer; + if (!dt) { + console.warn("No dataTransfer in global drop"); + return; + } + + const files = await this.collectFilesFromDataTransfer(dt); + + if (!files.length) { + console.warn("No files collected from global drop"); + return; + } + + window.selectedFiles = files; + + const uploadModalEl = document.getElementById("uploadCaptureModal"); + if (!uploadModalEl) { + console.error("Upload modal element not found"); + return; + } + + const uploadModal = new bootstrap.Modal(uploadModalEl); + uploadModal.show(); + + setTimeout(() => { + this.handleGlobalFilesInModal(files); + }, 200); + } + + handleGlobalFilesInModal(files) { + const fileInput = document.getElementById("captureFileInput"); + if (fileInput) { + const dataTransfer = new DataTransfer(); + for (const file of files) { + dataTransfer.items.add(file); + } + fileInput.files = dataTransfer.files; + } + + this.host.handleFileSelection(files); + + const selectedFilesSection = document.getElementById("selectedFiles"); + if (selectedFilesSection) { + selectedFilesSection.classList.add("has-files"); + } + + const fileInputLabel = fileInput?.nextElementSibling; + if (fileInputLabel?.classList.contains("form-control")) { + const fileNames = files + .map((f) => f.webkitRelativePath || f.name) + .join(", "); + fileInputLabel.textContent = fileNames || "No directory selected."; + } + } + + convertToFiles(itemsOrFiles) { + if (!itemsOrFiles) return []; + const first = itemsOrFiles[0]; + if (first && typeof first.getAsFile === "function") { + return Array.from(itemsOrFiles) + .map((item) => item.getAsFile()) + .filter((f) => !!f); + } + return Array.from(itemsOrFiles); + } + + async collectFilesFromDataTransfer(dataTransfer) { + const items = Array.from(dataTransfer.items || []); + const supportsEntries = + items.length > 0 && typeof items[0].webkitGetAsEntry === "function"; + if (!supportsEntries) { + return this.convertToFiles( + dataTransfer.files?.length ? dataTransfer.files : dataTransfer.items, + ); + } + + const allFiles = []; + for (const item of items) { + if (item.kind !== "file") continue; + const entry = item.webkitGetAsEntry(); + if (!entry) continue; + const files = await this.traverseEntry(entry); + allFiles.push(...files); + } + return allFiles; + } + + async traverseEntry(entry) { + if (entry.isFile) { + return new Promise((resolve) => { + entry.file((file) => { + try { + const relative = (entry.fullPath || file.name).replace(/^\//, ""); + Object.defineProperty(file, "webkitRelativePath", { + value: relative, + configurable: true, + }); + } catch (_) {} + resolve([file]); + }); + }); + } + + if (entry.isDirectory) { + const reader = entry.createReader(); + const entries = await this.readAllEntries(reader); + const nestedFiles = []; + for (const child of entries) { + const files = await this.traverseEntry(child); + nestedFiles.push(...files); + } + return nestedFiles; + } + + return []; + } + + readAllEntries(reader) { + return new Promise((resolve) => { + const entries = []; + const readChunk = () => { + reader.readEntries((results) => { + if (!results.length) { + resolve(entries); + return; + } + entries.push(...results); + readChunk(); + }); + }; + readChunk(); + }); + } +} + +if (typeof window !== "undefined") { + window.FileDropManager = FileDropManager; +} + +if (typeof module !== "undefined" && module.exports) { + module.exports = { FileDropManager }; +} diff --git a/gateway/sds_gateway/static/js/upload/FileListUploadCaptureModal.js b/gateway/sds_gateway/static/js/upload/FileListUploadCaptureModal.js new file mode 100644 index 000000000..a0839a805 --- /dev/null +++ b/gateway/sds_gateway/static/js/upload/FileListUploadCaptureModal.js @@ -0,0 +1,1076 @@ +/* Migrated from deprecated/file_list_upload_capture_modal.js — file list page upload flow. */ +/* Upload Capture Modal JavaScript */ + +document.addEventListener("DOMContentLoaded", () => { + // Upload Capture Modal JS + let isProcessing = false; // Flag to track if processing is active + // Reset cancellation state on page load + // Add page refresh/close confirmation + let uploadInProgress = false; + + // Clear any existing result modals on page load + const existingResultModal = document.getElementById("uploadResultModal"); + if (existingResultModal) { + const modalInstance = bootstrap.Modal.getInstance(existingResultModal); + if (modalInstance) { + modalInstance.hide(); + } + } + + // Clear any upload-related session storage + if (sessionStorage.getItem("uploadInProgress")) { + sessionStorage.removeItem("uploadInProgress"); + } + + // Handle beforeunload event (page refresh/close) + window.addEventListener("beforeunload", (e) => { + if ( + isProcessing || + uploadInProgress || + sessionStorage.getItem("uploadInProgress") + ) { + e.preventDefault(); + e.returnValue = + "Upload in progress will be aborted. Are you sure you want to leave?"; + return e.returnValue; + } + }); + + // Handle visibility change (tab close/minimize) + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden" && uploadInProgress) { + // Page hidden during upload + } + }); + + // Get button references + const uploadModal = document.getElementById("uploadCaptureModal"); + if (!uploadModal) { + console.warn("uploadCaptureModal not found"); + return; + } + + const cancelButton = uploadModal.querySelector(".btn-secondary"); + const closeButton = uploadModal.querySelector(".btn-close"); + const submitButton = document.getElementById("uploadSubmitBtn"); + + if (!cancelButton || !closeButton || !submitButton) { + console.warn("Required buttons not found in upload modal"); + return; + } + + // Store abort controller reference for cancellation + let currentAbortController = null; + + // Reset cancellation state when modal is opened + uploadModal.addEventListener("show.bs.modal", () => { + isProcessing = false; + currentAbortController = null; + }); + + // Reset cancellation state when files are selected + const fileInput = document.getElementById("captureFileInput"); + if (fileInput) { + fileInput.addEventListener("change", () => { + isProcessing = false; + currentAbortController = null; + }); + } + + // Reset cancellation state when modal is hidden + uploadModal.addEventListener("hidden.bs.modal", () => { + isProcessing = false; + currentAbortController = null; + }); + + // Handle cancel button click + let cancelRequested = false; + + // Helper function to handle cancellation logic + function handleCancellation(buttonType) { + if (isProcessing) { + // Cancel processing + cancelRequested = true; + // Abort current upload if controller exists + if (currentAbortController) { + currentAbortController.abort(); + } + // Update UI immediately based on button type + if (buttonType === "cancel") { + cancelButton.textContent = "Cancelling..."; + cancelButton.disabled = true; + } else if (buttonType === "close") { + closeButton.disabled = true; + closeButton.style.opacity = "0.5"; + } + // Update progress message + const progressMessage = document.getElementById("progressMessage"); + if (progressMessage) { + progressMessage.textContent = "Cancelling upload..."; + } + // Force UI reset after a short delay to ensure it happens + setTimeout(() => { + if (cancelRequested) { + resetUIState(); + } + }, 500); + } + // If not processing, let the normal button behavior handle it + } + + cancelButton.addEventListener("click", () => { + handleCancellation("cancel"); + }); + + // Handle close button (X) click - same logic as cancel button + closeButton.addEventListener("click", () => { + handleCancellation("close"); + }); + + // Helper function to check for large files + function checkForLargeFiles(files, cancelButton, submitButton) { + const progressSection = document.getElementById("checkingProgressSection"); + const LARGE_FILE_THRESHOLD = 512 * 1024 * 1024; // 512MB in bytes + const largeFiles = files.filter((file) => file.size > LARGE_FILE_THRESHOLD); + + if (largeFiles.length > 0) { + // Reset UI state + progressSection.style.display = "none"; + cancelButton.textContent = "Cancel"; + cancelButton.classList.remove("btn-warning"); + submitButton.disabled = false; + + // Create alert message + const largeFileNames = largeFiles.map((file) => file.name).join(", "); + const alertMessage = `Large files detected (over 512MB): ${largeFileNames}\n\nPlease:\n1. Skip these large files and upload the remaining files, or\n2. Use the SpectrumX SDK (https://pypi.org/project/spectrumx/) to upload large files and add them to your capture.\n\nLarge files may cause issues with the web interface.`; + + alert(alertMessage); + return true; // Indicates large files were found + } + return false; // No large files found + } + + // Helper function to check files for duplicates + async function checkFilesForDuplicates(files, cancelButton, submitButton) { + // Local progress bar variables + const progressSection = document.getElementById("checkingProgressSection"); + const progressBar = document.getElementById("checkingProgressBar"); + const progressText = document.getElementById("checkingProgressText"); + const progressMessage = document.getElementById("progressMessage"); + + // Show progress section + progressSection.style.display = "block"; + progressMessage.textContent = "Processing files for upload..."; + + // Update UI to show processing state + cancelButton.textContent = "Cancel Processing"; + submitButton.disabled = true; + + // Initialize variables for file checking + window.filesToSkip = new Set(); + window.fileCheckResults = new Map(); + + const totalFiles = files.length; + + // Get CSRF token + const getCSRFToken = () => { + const metaToken = document.querySelector('meta[name="csrf-token"]'); + if (metaToken) return metaToken.getAttribute("content"); + const inputToken = document.querySelector('[name="csrfmiddlewaretoken"]'); + if (inputToken) return inputToken.value; + const cookies = document.cookie.split(";"); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + if (cookie.startsWith("csrftoken=")) { + return cookie.substring("csrftoken=".length); + } + } + return ""; + }; + + const csrfToken = getCSRFToken(); + if (!csrfToken) { + throw new Error("CSRF token not found"); + } + + // Check each file for duplicates with progress + for (let i = 0; i < files.length; i++) { + const file = files[i]; + + // Update progress + const progress = Math.round(((i + 1) / totalFiles) * 100); + progressBar.style.width = `${progress}%`; + progressText.textContent = `${progress}%`; + + // Calculate BLAKE3 hash + const buffer = await file.arrayBuffer(); + const hasher = await hashwasm.createBLAKE3(); + hasher.init(); + hasher.update(new Uint8Array(buffer)); + const hashHex = hasher.digest("hex"); + + // Calculate directory path + let directory = "/"; + if (file.webkitRelativePath) { + const pathParts = file.webkitRelativePath.split("/"); + if (pathParts.length > 1) { + pathParts.pop(); + directory = `/${pathParts.join("/")}`; + } + } + + // Check if file exists + const checkData = { + directory: directory, + filename: file.name, + checksum: hashHex, + }; + + try { + const checkFileUrl = + document.querySelector("[data-check-file-url]")?.dataset + .checkFileUrl || "/users/check-file-exists/"; + const response = await fetch(checkFileUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": csrfToken, + }, + body: JSON.stringify(checkData), + }); + + const data = await response.json(); + + // Store the result + const fileKey = `${directory}/${file.name}`; + window.fileCheckResults.set(fileKey, { + file: file, + directory: directory, + filename: file.name, + checksum: hashHex, + data: data.data, + }); + + // Mark for skipping if file exists in tree + if (data.data && data.data.file_exists_in_tree === true) { + window.filesToSkip.add(fileKey); + } + } catch (error) { + // Error checking file + console.error("Error checking file:", error); + } + + // Check for cancellation after each file check + if (cancelRequested) { + break; + } + } + + progressSection.style.display = "none"; + + // Check if cancellation was requested during file checking + if (cancelRequested) { + // Small delay to ensure UI updates are visible + progressSection.style.display = "none"; + await new Promise((resolve) => setTimeout(resolve, 100)); + // Show alert for duplicate checking cancellation + alert("Processing cancelled. No files were uploaded."); + throw new Error("Upload cancelled by user"); + } + } + + // Helper function to handle skipped files upload + async function handleSkippedFilesUpload(allRelativePaths, abortController) { + // Create form data for skipped files case + const skippedFormData = new FormData(); + + // Always add all relative paths for capture creation + for (const path of allRelativePaths) { + skippedFormData.append("all_relative_paths", path); + } + + // Add other form fields + const captureType = document.getElementById("captureTypeSelect").value; + skippedFormData.append("capture_type", captureType); + + if (captureType === "drf") { + const channels = document.getElementById("captureChannelsInput").value; + skippedFormData.append("channels", channels); + } else if (captureType === "rh") { + const scanGroup = document.getElementById("captureScanGroupInput").value; + skippedFormData.append("scan_group", scanGroup); + } + + // Don't send chunk information for skipped files + // This ensures capture creation happens + + const uploadUrl = + document.querySelector("[data-upload-url]")?.dataset.uploadUrl || + "/users/upload-capture/"; + const getCSRFToken = () => { + const metaToken = document.querySelector('meta[name="csrf-token"]'); + if (metaToken) return metaToken.getAttribute("content"); + const inputToken = document.querySelector('[name="csrfmiddlewaretoken"]'); + if (inputToken) return inputToken.value; + const cookies = document.cookie.split(";"); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + if (cookie.startsWith("csrftoken=")) { + return cookie.substring("csrftoken=".length); + } + } + return ""; + }; + + const response = await fetch(uploadUrl, { + method: "POST", + headers: { + "X-CSRFToken": getCSRFToken(), + }, + body: skippedFormData, + signal: abortController.signal, + }); + + const result = await response.json(); + return result; + } + + // Helper function to calculate total chunks + function calculateTotalChunks(filesToUpload, chunkSizeBytes) { + let totalChunks = 0; + let tempChunkSize = 0; + let tempChunkFiles = 0; // Track number of files in current chunk (mirrors currentChunk.length) + + for (let i = 0; i < filesToUpload.length; i++) { + const file = filesToUpload[i]; + + // Check if this file would exceed the chunk limit (mirrors upload logic exactly) + if (tempChunkSize + file.size > chunkSizeBytes && tempChunkFiles > 0) { + // Current chunk would exceed size limit, start new chunk + totalChunks++; + tempChunkSize = 0; + tempChunkFiles = 0; + } + + // Now add the file to the current chunk + if (file.size > chunkSizeBytes) { + // Large file gets its own chunk + totalChunks++; + tempChunkSize = 0; + tempChunkFiles = 0; + } else { + // Add to current chunk + tempChunkSize += file.size; + tempChunkFiles++; + } + } + + // Add final chunk if there are remaining files + if (tempChunkSize > 0) { + totalChunks++; + } + + return totalChunks; + } + + // Function to upload files in chunks + async function uploadFilesInChunks( + filesToUpload, + relativePathsToUpload, + allRelativePaths, + totalFiles, + ) { + // Get progress elements locally + const progressBar = document.getElementById("checkingProgressBar"); + const progressText = document.getElementById("checkingProgressText"); + const progressMessage = document.getElementById("progressMessage"); + const progressSection = document.getElementById("checkingProgressSection"); + + // Show upload progress if there are files to upload + if (filesToUpload.length > 0) { + progressSection.style.display = "block"; + progressMessage.textContent = "Uploading files and creating captures..."; + progressBar.style.width = "0%"; + progressText.textContent = "0%"; + } + + // Create AbortController for upload + const abortController = new AbortController(); + currentAbortController = abortController; + + // Chunk size for file uploads (50 MB per chunk) + const CHUNK_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB in bytes + + let allResults = { + file_upload_status: "success", + saved_files_count: 0, + captures: [], + errors: [], + message: "", + }; + + // Special case: if all files are skipped, send a single request without chunking + if (filesToUpload.length === 0) { + allResults = await handleSkippedFilesUpload( + allRelativePaths, + abortController, + ); + } else { + // Upload files in chunks based on size (50MB per chunk) + let currentChunk = []; + let currentChunkPaths = []; + let currentChunkSize = 0; + let chunkNumber = 1; + let filesProcessed = 0; + + // Calculate total chunks first + const totalChunks = calculateTotalChunks(filesToUpload, CHUNK_SIZE_BYTES); + + // Now upload files in chunks + for (let i = 0; i < filesToUpload.length; i++) { + const file = filesToUpload[i]; + const filePath = relativePathsToUpload[i]; + + // Check if this file would exceed the 50MB limit + if ( + currentChunkSize + file.size > CHUNK_SIZE_BYTES && + currentChunk.length > 0 + ) { + // Upload current chunk before adding this file + await uploadChunk( + currentChunk, + currentChunkPaths, + chunkNumber, + totalChunks, + filesProcessed, + false, + allResults, + allRelativePaths, + totalFiles, + CHUNK_SIZE_BYTES, + ); + // Reset for next chunk + currentChunk = []; + currentChunkPaths = []; + currentChunkSize = 0; + chunkNumber++; + } + + // Add file to current chunk + currentChunk.push(file); + currentChunkPaths.push(filePath); + currentChunkSize += file.size; + filesProcessed++; + + // Check if this is the last file + if (i === filesToUpload.length - 1) { + // Upload final chunk + await uploadChunk( + currentChunk, + currentChunkPaths, + chunkNumber, + totalChunks, + filesProcessed, + true, + allResults, + allRelativePaths, + totalFiles, + CHUNK_SIZE_BYTES, + ); + } + + // Check if cancel was requested + if (cancelRequested) { + break; + } + } + } + + // Check if cancellation was requested during chunk upload + if (cancelRequested) { + // Small delay to ensure UI updates are visible + await new Promise((resolve) => setTimeout(resolve, 100)); + throw new Error("Upload cancelled by user"); + } + + // Check if upload was aborted due to errors + if (allResults.file_upload_status === "error") { + // Clear the reference since upload was aborted + currentAbortController = null; + // Show error results + showUploadResults(allResults, allResults.saved_files_count, totalFiles); + return allResults; // Exit early, don't continue with normal flow + } + + // Clear the reference since upload completed + currentAbortController = null; + return allResults; + } + + // Helper function to upload a chunk + async function uploadChunk( + chunk, + chunkPaths, + chunkNum, + totalChunks, + filesProcessed, + isFinalChunk, + allResults, + allRelativePaths, + totalFiles, + CHUNK_SIZE_BYTES, + ) { + // Get progress elements locally + const progressBar = document.getElementById("checkingProgressBar"); + const progressText = document.getElementById("checkingProgressText"); + const progressMessage = document.getElementById("progressMessage"); + + // Update progress + const progress = Math.round((filesProcessed / totalFiles) * 100); + progressBar.style.width = `${progress}%`; + progressText.textContent = `${progress}%`; + + if (chunk.length === 1 && chunk[0].size > CHUNK_SIZE_BYTES) { + // Large file upload + const file = chunk[0]; + progressMessage.textContent = `Uploading large file: ${file.name} (${(file.size / (1024 * 1024)).toFixed(1)} MB)...`; + } else { + // Normal chunk upload + progressMessage.textContent = `Uploading chunk ${chunkNum}/${totalChunks} (${filesProcessed} files processed)...`; + } + + // Create form data for this chunk + const chunkFormData = new FormData(); + + // Add files for this chunk + for (const file of chunk) { + chunkFormData.append("files", file); + } + + for (const path of chunkPaths) { + chunkFormData.append("relative_paths", path); + } + + // Always add all relative paths for capture creation + for (const path of allRelativePaths) { + chunkFormData.append("all_relative_paths", path); + } + + // Add other form fields + const captureType = document.getElementById("captureTypeSelect").value; + chunkFormData.append("capture_type", captureType); + + if (captureType === "drf") { + const channels = document.getElementById("captureChannelsInput").value; + chunkFormData.append("channels", channels); + } else if (captureType === "rh") { + const scanGroup = document.getElementById("captureScanGroupInput").value; + chunkFormData.append("scan_group", scanGroup); + } + + // Add chunk information + chunkFormData.append("is_chunk", "true"); + chunkFormData.append("chunk_number", chunkNum.toString()); + chunkFormData.append("total_chunks", totalChunks.toString()); + + // Check for cancellation before starting this chunk + if (cancelRequested) { + throw new Error("Upload cancelled by user"); + } + + // Upload this chunk with timeout (longer timeout for large files) + const controller = new AbortController(); + currentAbortController = controller; + + const MIN_AVG_UPLOAD_RATE = 100 * 1024; // 100 KB/s minimum upload rate + const MIN_TIMEOUT_MS = 30000; // Minimum 30 seconds timeout + const total_chunk_size_bytes = chunk.reduce( + (total, file) => total + file.size, + 0, + ); + const calculated_timeout = + (total_chunk_size_bytes / MIN_AVG_UPLOAD_RATE) * 1000; + const timeout = Math.max(calculated_timeout, MIN_TIMEOUT_MS); // Use at least 30 seconds + + const timeoutId = setTimeout(() => controller.abort(), timeout); + + let response; + let chunkResult; + + const getCSRFToken = () => { + const metaToken = document.querySelector('meta[name="csrf-token"]'); + if (metaToken) return metaToken.getAttribute("content"); + const inputToken = document.querySelector('[name="csrfmiddlewaretoken"]'); + if (inputToken) return inputToken.value; + const cookies = document.cookie.split(";"); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + if (cookie.startsWith("csrftoken=")) { + return cookie.substring("csrftoken=".length); + } + } + return ""; + }; + + const uploadUrl = + document.querySelector("[data-upload-url]")?.dataset.uploadUrl || + "/users/upload-capture/"; + + try { + response = await fetch(uploadUrl, { + method: "POST", + headers: { + "X-CSRFToken": getCSRFToken(), + }, + body: chunkFormData, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + chunkResult = await response.json(); + } catch (error) { + clearTimeout(timeoutId); + if (error.name === "AbortError") { + throw new Error("Upload timeout - connection may be lost"); + } + throw error; + } + + // Merge results + if (chunkResult.saved_files_count !== undefined) { + allResults.saved_files_count += chunkResult.saved_files_count; + } + + // Collect captures from the final chunk + if (chunkResult.captures && isFinalChunk) { + allResults.captures = allResults.captures.concat(chunkResult.captures); + } + + // Also collect any message from the final chunk + if (chunkResult.message && isFinalChunk) { + allResults.message = chunkResult.message; + } + + if (chunkResult.errors) { + allResults.errors = allResults.errors.concat(chunkResult.errors); + } + + // Check if any chunk failed + if (chunkResult.file_upload_status === "error") { + allResults.file_upload_status = "error"; + allResults.message = chunkResult.message || "Upload failed"; + const progressMessage = document.getElementById("progressMessage"); + if (progressMessage) { + progressMessage.textContent = + "Upload aborted due to errors. Please check the results."; + } + throw new Error(`Upload failed: ${chunkResult.message}`); + } + if (chunkResult.file_upload_status === "success") { + // Only update to success if this is the final chunk + if (isFinalChunk) { + allResults.file_upload_status = "success"; + } + } + } + + const uploadForm = document.getElementById("uploadCaptureForm"); + if (uploadForm) { + uploadForm.addEventListener("submit", async (e) => { + e.preventDefault(); + + // Set processing state + isProcessing = true; + uploadInProgress = true; + cancelRequested = false; // Reset cancel flag for new upload + sessionStorage.setItem("uploadInProgress", "true"); + + // Check if files are selected + if (!window.selectedFiles || window.selectedFiles.length === 0) { + alert("Please select files to upload."); + return; + } + + const files = window.selectedFiles; + + // Check for large files before duplicate checking + if (checkForLargeFiles(files, cancelButton, submitButton)) { + return; + } + + // Check files for duplicates + await checkFilesForDuplicates(files, cancelButton, submitButton); + + // Prepare files for upload (only non-skipped files) + const filesToUpload = []; + const relativePathsToUpload = []; + // Always collect all relative paths for capture creation, even for skipped files + const allRelativePaths = []; + let skippedFilesCount = 0; + + for (const file of files) { + let directory = "/"; + if (file.webkitRelativePath) { + const pathParts = file.webkitRelativePath.split("/"); + if (pathParts.length > 1) { + pathParts.pop(); + directory = `/${pathParts.join("/")}`; + } + } + + const fileKey = `${directory}/${file.name}`; + const relativePath = file.webkitRelativePath || file.name; + + // Add to all paths for capture creation + allRelativePaths.push(relativePath); + + // Only add to upload list if not skipped + if (!window.filesToSkip.has(fileKey)) { + filesToUpload.push(file); + relativePathsToUpload.push(relativePath); + } else { + skippedFilesCount++; + } + } + + // Upload files in chunks + try { + const uploadResults = await uploadFilesInChunks( + filesToUpload, + relativePathsToUpload, + allRelativePaths, + filesToUpload.length, + ); + + // Clear the reference since upload completed + currentAbortController = null; + + // Show results + showUploadResults( + uploadResults, + uploadResults.saved_files_count, + files.length, + skippedFilesCount, + ); + + // Don't auto-reload for successful uploads - let user close modal first + } catch (error) { + if (cancelRequested) { + // Check if this was cancelled during duplicate checking (no files uploaded yet) + if (!uploadInProgress) { + // Already showed alert in checkFilesForDuplicates function + // No need to reload since no files were uploaded + } else { + alert( + "Upload cancelled. Any files uploaded before cancellation have been saved.", + ); + // Reload page after cancellation + setTimeout(() => { + window.location.reload(); + }, 1000); + } + } else if (error.name === "AbortError") { + alert( + "Upload was interrupted. Any files uploaded before the interruption have been saved.", + ); + // Reload page after interruption + setTimeout(() => { + window.location.reload(); + }, 1000); + } else if ( + error.name === "TypeError" && + error.message.includes("fetch") + ) { + // Don't show alert for network errors after page refresh + if (uploadInProgress || sessionStorage.getItem("uploadInProgress")) { + // Suppressing network error alert during active upload + } else { + alert( + "Network error during upload. Please check your connection and try again.", + ); + } + } else { + alert(`Upload failed: ${error.message}`); + // Reload page after other errors + setTimeout(() => { + window.location.reload(); + }, 1000); + } + } finally { + // Clean up UI state - ensure this always runs + resetUIState(); + } + }); + } + + // Function to reset UI state + function resetUIState() { + // Reset submit button + if (submitButton) { + submitButton.disabled = false; + } + + // Hide progress section + const progressSection = document.getElementById("checkingProgressSection"); + if (progressSection) { + progressSection.style.display = "none"; + } + + // Reset cancel button + if (cancelButton) { + cancelButton.textContent = "Cancel"; + cancelButton.classList.remove("btn-warning"); + cancelButton.disabled = false; + } + + // Reset close button + if (closeButton) { + closeButton.disabled = false; + closeButton.style.opacity = "1"; + } + + // Reset progress elements + const progressBar = document.getElementById("checkingProgressBar"); + const progressText = document.getElementById("checkingProgressText"); + const progressMessage = document.getElementById("progressMessage"); + + if (progressBar) progressBar.style.width = "0%"; + if (progressText) progressText.textContent = "0%"; + if (progressMessage) progressMessage.textContent = ""; + + // Reset state flags + isProcessing = false; + uploadInProgress = false; + cancelRequested = false; + + // Clear session storage + sessionStorage.removeItem("uploadInProgress"); + + // Clear abort controller + currentAbortController = null; + } + + // Function to show upload results + function showUploadResults( + result, + uploadedCount, + totalCount, + skippedCount = 0, + ) { + // Check if page was refreshed during upload + if (!uploadInProgress && result.file_upload_status === "error") { + resetUIState(); // Ensure UI is reset even if modal is not shown + return; + } + + const modalBody = document.getElementById("uploadResultModalBody"); + const resultModalEl = document.getElementById("uploadResultModal"); + const modal = new bootstrap.Modal(resultModalEl); + + // Close the upload modal before showing result modal + const uploadModal = bootstrap.Modal.getInstance( + document.getElementById("uploadCaptureModal"), + ); + if (uploadModal) { + uploadModal.hide(); + } + + let msg = ""; + + if (result.file_upload_status === "success") { + // Use frontend accumulated count for accuracy, but include backend message for additional info + if (uploadedCount === 0 && totalCount > 0) { + // All files were skipped + msg = `Upload complete!
    All ${totalCount} files already existed on the server.`; + } else if (skippedCount > 0) { + // Some files were uploaded, some were skipped + msg = `Upload complete!
    Files uploaded: ${uploadedCount} / ${totalCount}`; + msg += `
    Files already exist: ${skippedCount}`; + } else { + // All files were uploaded (no skipped files) + msg = `Upload complete!
    Files uploaded: ${uploadedCount} / ${totalCount}`; + } + + if (result.captures && result.captures.length > 0) { + const uuids = result.captures + .map((uuid) => `
  • ${uuid}
  • `) + .join(""); + msg += `
    Created capture UUID(s):
      ${uuids}
    `; + } + + if (result.errors && result.errors.length > 0) { + const errs = result.errors.map((e) => `
  • ${e}
  • `).join(""); + msg += `
    Errors:
      ${errs}
    `; + msg += "
    Please check details and upload again."; + } + } else { + // Upload failed - show error message and prompt to remove error files + msg = "Upload Failed
    "; + if (result.message) { + msg += `${result.message}

    `; + } + msg += "Please check file validity and try again."; + if (result.errors && result.errors.length > 0) { + const errs = result.errors.map((e) => `
  • ${e}
  • `).join(""); + msg += `

    Error Details:
      ${errs}
    `; + } + } + + modalBody.innerHTML = msg; + modal.show(); + + // Add event listener to reload page when result modal is closed (only for successful uploads) + if (result.file_upload_status === "success") { + resultModalEl.addEventListener( + "hidden.bs.modal", + () => { + window.location.reload(); + }, + { once: true }, + ); // Only trigger once per modal instance + } + } +}); + +// Capture Type Selection JavaScript +document.addEventListener("DOMContentLoaded", () => { + const captureTypeSelect = document.getElementById("captureTypeSelect"); + const channelInputGroup = document.getElementById("channelInputGroup"); + const scanGroupInputGroup = document.getElementById("scanGroupInputGroup"); + const captureChannelsInput = document.getElementById("captureChannelsInput"); + const captureScanGroupInput = document.getElementById( + "captureScanGroupInput", + ); + + if (captureTypeSelect) { + captureTypeSelect.addEventListener("change", function () { + const selectedType = this.value; + + // Hide both input groups initially + if (channelInputGroup) + channelInputGroup.classList.add("hidden-input-group"); + if (scanGroupInputGroup) + scanGroupInputGroup.classList.add("hidden-input-group"); + + // Clear required attributes + if (captureChannelsInput) + captureChannelsInput.removeAttribute("required"); + if (captureScanGroupInput) + captureScanGroupInput.removeAttribute("required"); + + // Show appropriate input group based on selection + if (selectedType === "drf") { + if (channelInputGroup) + channelInputGroup.classList.remove("hidden-input-group"); + if (captureChannelsInput) + captureChannelsInput.setAttribute("required", "required"); + } else if (selectedType === "rh") { + if (scanGroupInputGroup) + scanGroupInputGroup.classList.remove("hidden-input-group"); + // scan_group is optional for RadioHound captures + } + }); + } + + // Reset form when modal is hidden + const uploadModal = document.getElementById("uploadCaptureModal"); + if (uploadModal) { + uploadModal.addEventListener("hidden.bs.modal", () => { + // Reset the form + const form = document.getElementById("uploadCaptureForm"); + if (form) { + form.reset(); + } + + // Hide input groups + if (channelInputGroup) + channelInputGroup.classList.add("hidden-input-group"); + if (scanGroupInputGroup) + scanGroupInputGroup.classList.add("hidden-input-group"); + + // Clear required attributes + if (captureChannelsInput) + captureChannelsInput.removeAttribute("required"); + if (captureScanGroupInput) + captureScanGroupInput.removeAttribute("required"); + + // Clear file check status + const checkStatusDiv = document.getElementById("fileCheckStatus"); + if (checkStatusDiv) { + checkStatusDiv.style.display = "none"; + } + + // Clear status alerts and file details button + const uploadModalBody = document.querySelector( + "#uploadCaptureModal .modal-body", + ); + if (uploadModalBody) { + const existingAlerts = uploadModalBody.querySelectorAll( + ".alert.alert-warning, .alert.alert-success", + ); + for (const alert of existingAlerts) { + if ( + alert.textContent.includes("will be skipped") || + alert.textContent.includes("will be uploaded") + ) { + alert.remove(); + } + } + + // Remove file details button + const detailsLink = uploadModalBody.querySelector( + "#viewFileDetailsLink", + ); + if (detailsLink) { + detailsLink.parentNode.remove(); + } + } + + // Clear global variables + if (window.filesToSkip) window.filesToSkip.clear(); + if (window.fileCheckResults) window.fileCheckResults.clear(); + }); + } +}); + +// BLAKE3 hash calculation for file deduplication +// Global variable to track files that should be skipped +window.filesToSkip = new Set(); +window.fileCheckResults = new Map(); // Store detailed results for each file + +document.addEventListener("DOMContentLoaded", () => { + const modal = document.getElementById("uploadCaptureModal"); + if (!modal) { + console.warn("uploadCaptureModal not found"); + return; + } + + modal.addEventListener("shown.bs.modal", () => { + const fileInput = document.getElementById("captureFileInput"); + if (!fileInput) { + console.warn("captureFileInput not found"); + return; + } + + // Remove any previous handler to avoid duplicates + fileInput.removeEventListener("change", window._blake3CaptureHandler); + + // Simple file handler that just stores the selected files + window._blake3CaptureHandler = async (event) => { + const files = event.target.files; + if (!files || files.length === 0) { + return; + } + + // Store the selected files for later processing + window.selectedFiles = Array.from(files); + }; + + fileInput.addEventListener("change", window._blake3CaptureHandler); + }); +}); diff --git a/gateway/sds_gateway/static/js/upload/FileUploadHandler.js b/gateway/sds_gateway/static/js/upload/FileUploadHandler.js new file mode 100644 index 000000000..74d31740d --- /dev/null +++ b/gateway/sds_gateway/static/js/upload/FileUploadHandler.js @@ -0,0 +1,230 @@ +/** Single-file upload modal (not capture batch). Migrated from deprecated/files-ui.js */ +class FileUploadHandler { + constructor() { + this.uploadForm = document.getElementById("uploadFileForm"); + this.fileInput = document.getElementById("fileInput"); + this.folderInput = document.getElementById("folderInput"); + this.submitBtn = document.getElementById("uploadFileSubmitBtn"); + this.clearBtn = document.getElementById("clearUploadBtn"); + this.uploadText = this.submitBtn?.querySelector(".upload-text"); + this.uploadSpinner = this.submitBtn?.querySelector(".upload-spinner"); + this.validationFeedback = document.getElementById( + "uploadValidationFeedback", + ); + + // Enable submit button when files or folders are selected + if (this.fileInput) { + this.fileInput.addEventListener("change", () => + this.updateSubmitButton(), + ); + } + if (this.folderInput) { + this.folderInput.addEventListener("change", () => + this.updateSubmitButton(), + ); + } + + // Clear button handler + if (this.clearBtn) { + this.clearBtn.addEventListener("click", () => this.clearModal()); + } + + if (this.uploadForm) { + this.uploadForm.addEventListener("submit", (e) => this.handleSubmit(e)); + } + } + + updateSubmitButton() { + if (this.submitBtn) { + const hasFiles = this.fileInput?.files.length > 0; + const hasFolders = this.folderInput?.files.length > 0; + this.submitBtn.disabled = !hasFiles && !hasFolders; + + // Hide validation feedback when files are selected + if (hasFiles || hasFolders) { + this.hideValidationFeedback(); + } + } + } + + showValidationFeedback() { + if (this.validationFeedback) { + this.validationFeedback.classList.add("d-block"); + } + // Add invalid styling to inputs + this.fileInput?.classList.add("is-invalid"); + this.folderInput?.classList.add("is-invalid"); + } + + hideValidationFeedback() { + if (this.validationFeedback) { + this.validationFeedback.classList.remove("d-block"); + } + // Remove invalid styling from inputs + this.fileInput?.classList.remove("is-invalid"); + this.folderInput?.classList.remove("is-invalid"); + } + + clearModal() { + // Reset form + if (this.uploadForm) { + this.uploadForm.reset(); + } + // Explicitly clear file inputs (form.reset() doesn't always clear file inputs) + if (this.fileInput) { + this.fileInput.value = ""; + } + if (this.folderInput) { + this.folderInput.value = ""; + } + // Hide validation feedback + this.hideValidationFeedback(); + // Update submit button state + this.updateSubmitButton(); + } + + async handleSubmit(event) { + event.preventDefault(); + + const files = Array.from(this.fileInput?.files || []); + const folderFiles = Array.from(this.folderInput?.files || []); + + if (files.length === 0 && folderFiles.length === 0) { + this.showValidationFeedback(); + return; + } + + this.setUploadingState(true); + + try { + // Debug: Check CSRF token availability + console.log("CSRF Token:", window.csrfToken); + console.log("Upload URL:", window.uploadFilesUrl); + + // Try multiple ways to get CSRF token + let csrfToken = window.csrfToken; + if (!csrfToken) { + // Fallback to DOM query + const csrfInput = document.querySelector("[name=csrfmiddlewaretoken]"); + csrfToken = csrfInput ? csrfInput.value : null; + } + + if (!csrfToken) { + throw new Error("CSRF token not found"); + } + + const formData = new FormData(); + const allFiles = [...files, ...folderFiles]; + const allRelativePaths = []; + + // Add all files to formData + for (const file of allFiles) { + formData.append("files", file); + // Use webkitRelativePath for folder files, filename for individual files + const relativePath = file.webkitRelativePath || file.name; + allRelativePaths.push(relativePath); + } + + // Add relative paths + for (const relativePath of allRelativePaths) { + formData.append("relative_paths", relativePath); + } + for (const relativePath of allRelativePaths) { + formData.append("all_relative_paths", relativePath); + } + + // Prevent capture creation when uploading files only + formData.append("capture_type", ""); + formData.append("channels", ""); + formData.append("scan_group", ""); + formData.append("csrfmiddlewaretoken", csrfToken); + + const response = await fetch(window.uploadFilesUrl, { + method: "POST", + body: formData, + headers: { + "X-CSRFToken": csrfToken, + }, + }); + + const result = await response.json(); + + if (response.ok) { + // Show success message + const fileCount = allFiles.length; + const successMsg = + fileCount === 1 + ? "1 file uploaded successfully!" + : `${fileCount} files uploaded successfully!`; + this.showResult("success", successMsg); + // Clear file inputs + this.clearModal(); + // Close modal + const modal = bootstrap.Modal.getInstance( + document.getElementById("uploadFileModal"), + ); + if (modal) modal.hide(); + // Refresh the file list + if (window.fileManager) { + window.fileManager.loadFiles(); + } + } else { + this.showResult( + "error", + result.error || "Upload failed. Please try again.", + ); + } + } catch (error) { + console.error("Upload error:", error); + this.showResult( + "error", + "Upload failed. Please check your connection and try again.", + ); + } finally { + this.setUploadingState(false); + } + } + + setUploadingState(uploading) { + if (this.submitBtn) { + this.submitBtn.disabled = uploading; + } + if (this.uploadText && this.uploadSpinner) { + this.uploadText.classList.toggle("d-none", uploading); + this.uploadSpinner.classList.toggle("d-none", !uploading); + } + } + + showResult(type, message) { + // Show result in the upload result modal + const resultModal = document.getElementById("uploadResultModal"); + const resultBody = document.getElementById("uploadResultModalBody"); + + if (resultModal && resultBody) { + resultBody.innerHTML = ` +
    + ${message} +
    + `; + const modal = new bootstrap.Modal(resultModal); + modal.show(); + } else { + // Fallback to alert + alert(message); + } + } + + cleanup() { + if (this.uploadForm) { + this.uploadForm.removeEventListener("submit", this.handleSubmit); + } + } +} + +if (typeof window !== "undefined") { + window.FileUploadHandler = FileUploadHandler; +} +if (typeof module !== "undefined" && module.exports) { + module.exports = { FileUploadHandler }; +} + diff --git a/gateway/sds_gateway/static/js/upload/FilesPageInitializer.js b/gateway/sds_gateway/static/js/upload/FilesPageInitializer.js new file mode 100644 index 000000000..c8c6cbcc5 --- /dev/null +++ b/gateway/sds_gateway/static/js/upload/FilesPageInitializer.js @@ -0,0 +1,242 @@ +/** Files page modal/table bootstrap. Migrated from deprecated/files-ui.js */ +class FilesPageInitializer { + constructor() { + this.boundHandlers = new Map(); // Track event handlers for cleanup + this.activeHandlers = new Set(); // Track active component handlers + this.initializeComponents(); + } + + initializeComponents() { + try { + this.initializeModalManager(); + this.initializeCapturesTableManager(); + this.initializeUserSearchHandlers(); + } catch (error) { + ErrorHandler.showError( + "Failed to initialize page components", + "component-initialization", + error, + ); + } + } + + initializeModalManager() { + // Initialize ModalManager for capture modal + let modalManager = null; + try { + if (window.ModalManager) { + modalManager = new window.ModalManager({ + modalId: "capture-modal", + modalBodyId: "capture-modal-body", + modalTitleId: "capture-modal-label", + }); + + this.modalManager = modalManager; + console.log("ModalManager initialized successfully"); + } else { + ErrorHandler.showError( + "Modal functionality is not available. Some features may be limited.", + "modal-initialization", + ); + } + } catch (error) { + ErrorHandler.showError( + "Failed to initialize modal functionality", + "modal-initialization", + error, + ); + } + } + + initializeCapturesTableManager() { + // Initialize CapturesTableManager for capture edit/download functionality + try { + if (window.CapturesTableManager) { + window.capturesTableManager = new window.CapturesTableManager({ + modalHandler: this.modalManager, + }); + console.log("CapturesTableManager initialized successfully"); + } else { + ErrorHandler.showError( + "Table management functionality is not available. Some features may be limited.", + "table-initialization", + ); + } + } catch (error) { + ErrorHandler.showError( + "Failed to initialize table management functionality", + "table-initialization", + error, + ); + } + } + + initializeUserSearchHandlers() { + // Create a UserSearchHandler for each share modal + const shareModals = document.querySelectorAll(".modal[data-item-uuid]"); + + // Skip initialization if no share modals exist on this page + if (shareModals.length === 0) { + return; + } + + // Check if UserSearchHandler is available before trying to initialize + if (!window.UserSearchHandler) { + console.warn( + "UserSearchHandler not available. Share functionality will not work.", + ); + return; + } + + for (const modal of shareModals) { + this.setupUserSearchHandler(modal); + } + } + + setupUserSearchHandler(modal) { + try { + // Ensure boundHandlers and activeHandlers are initialized + if (!this.boundHandlers) { + this.boundHandlers = new Map(); + } + if (!this.activeHandlers) { + this.activeHandlers = new Set(); + } + + // Validate modal attributes + const itemUuid = modal.getAttribute("data-item-uuid"); + const itemType = modal.getAttribute("data-item-type"); + + if (!this.validateModalAttributes(itemUuid, itemType)) { + ErrorHandler.showError( + "Invalid modal configuration", + "user-search-setup", + ); + return; + } + + const handler = new window.UserSearchHandler(); + // Store the handler on the modal element + modal.userSearchHandler = handler; + this.activeHandlers.add(handler); + + // Create bound event handlers for cleanup + const showHandler = () => { + if (modal.userSearchHandler) { + modal.userSearchHandler.setItemInfo(itemUuid, itemType); + modal.userSearchHandler.init(); + } + }; + + const hideHandler = () => { + if (modal.userSearchHandler) { + modal.userSearchHandler.resetAll(); + } + }; + + // Store handlers for cleanup + this.boundHandlers.set(modal, { + show: showHandler, + hide: hideHandler, + }); + + // On modal show, set the item info and call init() + modal.addEventListener("show.bs.modal", showHandler); + + // On modal hide, reset all selections and entered data + modal.addEventListener("hidden.bs.modal", hideHandler); + + console.log(`UserSearchHandler initialized for ${itemType}: ${itemUuid}`); + } catch (error) { + ErrorHandler.showError( + "Failed to setup user search functionality", + "user-search-setup", + error, + ); + } + } + + /** + * Get initialized modal manager + * @returns {Object|null} - The modal manager instance + */ + getModalManager() { + return this.modalManager; + } + + /** + * Get captures table manager + * @returns {Object|null} - The captures table manager instance + */ + getCapturesTableManager() { + return window.capturesTableManager; + } + + // Validation methods + validateModalAttributes(uuid, type) { + if (!uuid || typeof uuid !== "string") { + console.warn("Invalid UUID in modal attributes:", uuid); + return false; + } + + if (!type || typeof type !== "string") { + console.warn("Invalid type in modal attributes:", type); + return false; + } + + // Validate UUID format (basic check) + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + if (!uuidRegex.test(uuid)) { + console.warn("Invalid UUID format in modal attributes:", uuid); + return false; + } + + // Validate type + const validTypes = ["capture", "dataset", "file"]; + if (!validTypes.includes(type)) { + console.warn("Invalid type in modal attributes:", type); + return false; + } + + return true; + } + + // Memory management and cleanup + cleanup() { + // Remove all bound event handlers + for (const [element, handlers] of this.boundHandlers) { + if (element?.removeEventListener) { + if (handlers.show) { + element.removeEventListener("show.bs.modal", handlers.show); + } + if (handlers.hide) { + element.removeEventListener("hidden.bs.modal", handlers.hide); + } + } + } + this.boundHandlers.clear(); + + // Cleanup active handlers + for (const handler of this.activeHandlers) { + if (handler && typeof handler.cleanup === "function") { + try { + handler.cleanup(); + } catch (error) { + console.warn("Error during handler cleanup:", error); + } + } + } + this.activeHandlers.clear(); + + console.log("FilesPageInitializer cleanup completed"); + } +} + +if (typeof window !== "undefined") { + window.FilesPageInitializer = FilesPageInitializer; +} +if (typeof module !== "undefined" && module.exports) { + module.exports = { FilesPageInitializer }; +} + diff --git a/gateway/sds_gateway/static/js/upload/FilesUploadModal.js b/gateway/sds_gateway/static/js/upload/FilesUploadModal.js new file mode 100644 index 000000000..09085e5de --- /dev/null +++ b/gateway/sds_gateway/static/js/upload/FilesUploadModal.js @@ -0,0 +1,563 @@ +/** + * Capture upload modal: progress, chunk upload, results. + * Migrated from deprecated/files-upload.js (FilesUploadModal). + */ + +class FilesUploadModal { + constructor() { + this.isProcessing = false; + this.uploadInProgress = false; + this.cancelRequested = false; + this.currentAbortController = null; + + this.initializeElements(); + this.setupEventListeners(); + this.clearExistingModals(); + } + + initializeElements() { + this.cancelButton = document.querySelector( + "#uploadCaptureModal .btn-secondary", + ); + this.submitButton = document.getElementById("uploadSubmitBtn"); + this.uploadModal = document.getElementById("uploadCaptureModal"); + this.fileInput = document.getElementById("captureFileInput"); + this.uploadForm = document.getElementById("uploadCaptureForm"); + } + + setupEventListeners() { + // Modal event listeners + if (this.uploadModal) { + this.uploadModal.addEventListener("show.bs.modal", () => + this.resetState(), + ); + this.uploadModal.addEventListener("hidden.bs.modal", () => + this.resetState(), + ); + } + + // File input change listener + if (this.fileInput) { + this.fileInput.addEventListener("change", () => this.resetState()); + } + + // Cancel button listener + if (this.cancelButton) { + this.cancelButton.addEventListener("click", () => this.handleCancel()); + } + + // Form submit listener + if (this.uploadForm) { + this.uploadForm.addEventListener("submit", (e) => this.handleSubmit(e)); + } + } + + clearExistingModals() { + const existingResultModal = document.getElementById("uploadResultModal"); + if (existingResultModal) { + const modalInstance = bootstrap.Modal.getInstance(existingResultModal); + if (modalInstance) { + modalInstance.hide(); + } + } + } + + resetState() { + this.isProcessing = false; + this.currentAbortController = null; + this.cancelRequested = false; + } + + handleCancel() { + if (this.isProcessing) { + this.cancelRequested = true; + + if (this.currentAbortController) { + this.currentAbortController.abort(); + } + + this.cancelButton.textContent = "Cancelling..."; + this.cancelButton.disabled = true; + + const progressMessage = document.getElementById("progressMessage"); + if (progressMessage) { + progressMessage.textContent = "Cancelling upload..."; + } + + setTimeout(() => { + if (this.cancelRequested) { + this.resetUIState(); + } + }, 500); + } + } + + async handleSubmit(e) { + e.preventDefault(); + + this.isProcessing = true; + this.uploadInProgress = true; + this.cancelRequested = false; + + // Check if files are selected + if (!window.selectedFiles || window.selectedFiles.length === 0) { + alert("Please select files to upload."); + return; + } + + try { + this.showProgressSection(); + await this.checkFilesForDuplicates(); + + if (this.cancelRequested) { + throw new Error("Upload cancelled by user"); + } + + await this.uploadFiles(); + } catch (error) { + this.handleError(error); + } finally { + this.resetUIState(); + } + } + + showProgressSection() { + const progressSection = document.getElementById("checkingProgressSection"); + const progressMessage = document.getElementById("progressMessage"); + + if (progressSection) { + progressSection.style.display = "block"; + } + if (progressMessage) { + progressMessage.textContent = "Checking files for duplicates..."; + } + + this.cancelButton.textContent = "Cancel Processing"; + this.cancelButton.classList.add("btn-warning"); + this.submitButton.disabled = true; + } + + async checkFilesForDuplicates() { + window.filesToSkip = new Set(); + window.fileCheckResults = new Map(); + const files = window.selectedFiles; + const totalFiles = files.length; + + const progressBar = document.getElementById("checkingProgressBar"); + const progressText = document.getElementById("checkingProgressText"); + + for (let i = 0; i < files.length; i++) { + if (this.cancelRequested) break; + + const file = files[i]; + const progress = Math.round(((i + 1) / totalFiles) * 100); + + if (progressBar) progressBar.style.width = `${progress}%`; + if (progressText) progressText.textContent = `${progress}%`; + + await this.processFile(file); + } + + if (this.cancelRequested) { + throw new Error("Upload cancelled by user"); + } + } + + async processFile(file) { + // Calculate BLAKE3 hash + const buffer = await file.arrayBuffer(); + const hasher = await hashwasm.createBLAKE3(); + hasher.init(); + hasher.update(new Uint8Array(buffer)); + const hashHex = hasher.digest("hex"); + + // Calculate directory path + let directory = "/"; + if (file.webkitRelativePath) { + const pathParts = file.webkitRelativePath.split("/"); + if (pathParts.length > 1) { + pathParts.pop(); + directory = `/${pathParts.join("/")}`; + } + } + + // Check if file exists + const checkData = { + directory: directory, + filename: file.name, + checksum: hashHex, + }; + + try { + const response = await fetch(window.checkFileExistsUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": window.csrfToken, + }, + body: JSON.stringify(checkData), + }); + const data = await response.json(); + + const fileKey = `${directory}/${file.name}`; + window.fileCheckResults.set(fileKey, { + file: file, + directory: directory, + filename: file.name, + checksum: hashHex, + data: data.data, + }); + + if (data.data && data.data.file_exists_in_tree === true) { + window.filesToSkip.add(fileKey); + } + } catch (error) { + console.error("Error checking file:", error); + } + } + + async uploadFiles() { + const progressMessage = document.getElementById("progressMessage"); + const progressSection = document.getElementById("checkingProgressSection"); + const progressBar = document.getElementById("checkingProgressBar"); + const progressText = document.getElementById("checkingProgressText"); + + if (progressMessage) { + progressMessage.textContent = "Uploading files and creating captures..."; + } + if (progressBar) progressBar.style.width = "0%"; + if (progressText) progressText.textContent = "0%"; + + const files = window.selectedFiles; + const filesToUpload = []; + const relativePathsToUpload = []; + const allRelativePaths = []; + + // Process files for upload + for (const file of files) { + let directory = "/"; + if (file.webkitRelativePath) { + const pathParts = file.webkitRelativePath.split("/"); + if (pathParts.length > 1) { + pathParts.pop(); + directory = `/${pathParts.join("/")}`; + } + } + const fileKey = `${directory}/${file.name}`; + const relativePath = file.webkitRelativePath || file.name; + + console.debug( + `Processing file: ${file.name}, webkitRelativePath: '${file.webkitRelativePath}', relativePath: '${relativePath}', directory: '${directory}'`, + ); + allRelativePaths.push(relativePath); + + if (!window.filesToSkip.has(fileKey)) { + filesToUpload.push(file); + relativePathsToUpload.push(relativePath); + } + } + + console.debug( + "All relative paths being sent:", + allRelativePaths.slice(0, 5), + ); + console.debug( + "Relative paths to upload:", + relativePathsToUpload.slice(0, 5), + ); + + if (filesToUpload.length > 0 && progressSection) { + progressSection.style.display = "block"; + } + + this.currentAbortController = new AbortController(); + + let result; + if (filesToUpload.length === 0) { + result = await this.uploadSkippedFiles(allRelativePaths); + } else { + result = await this.uploadFilesInChunks( + filesToUpload, + relativePathsToUpload, + allRelativePaths, + ); + } + + this.currentAbortController = null; + this.showUploadResults(result, result.saved_files_count, files.length); + } + + async uploadSkippedFiles(allRelativePaths) { + const formData = new FormData(); + + console.debug( + "uploadSkippedFiles - allRelativePaths:", + allRelativePaths.slice(0, 5), + ); + for (const path of allRelativePaths) { + formData.append("all_relative_paths", path); + } + + this.addCaptureTypeData(formData); + formData.append("csrfmiddlewaretoken", window.csrfToken); + + const response = await fetch(window.uploadFilesUrl, { + method: "POST", + body: formData, + headers: { + "X-CSRFToken": window.csrfToken, + }, + signal: this.currentAbortController.signal, + }); + + return await response.json(); + } + + async uploadFilesInChunks( + filesToUpload, + relativePathsToUpload, + allRelativePaths, + ) { + const CHUNK_SIZE = 5; + const totalFiles = filesToUpload.length; + const progressBar = document.getElementById("checkingProgressBar"); + const progressText = document.getElementById("checkingProgressText"); + const progressMessage = document.getElementById("progressMessage"); + + const allResults = { + file_upload_status: "success", + saved_files_count: 0, + captures: [], + errors: [], + message: "", + }; + + for (let i = 0; i < filesToUpload.length; i += CHUNK_SIZE) { + if (this.cancelRequested) break; + + const chunk = filesToUpload.slice(i, i + CHUNK_SIZE); + const chunkPaths = relativePathsToUpload.slice(i, i + CHUNK_SIZE); + + const totalChunks = Math.ceil(filesToUpload.length / CHUNK_SIZE); + const currentChunk = Math.floor(i / CHUNK_SIZE) + 1; + const isFinalChunk = currentChunk === totalChunks; + + // Update progress + const progress = Math.round(((i + chunk.length) / totalFiles) * 100); + if (progressBar) progressBar.style.width = `${progress}%`; + if (progressText) progressText.textContent = `${progress}%`; + if (progressMessage) { + progressMessage.textContent = `Uploading files ${i + 1}-${Math.min(i + CHUNK_SIZE, totalFiles)} of ${totalFiles} (chunk ${currentChunk}/${totalChunks})...`; + } + + const chunkResult = await this.uploadChunk( + chunk, + chunkPaths, + allRelativePaths, + currentChunk, + totalChunks, + ); + + // Merge results + if (chunkResult.saved_files_count !== undefined) { + allResults.saved_files_count += chunkResult.saved_files_count; + } + if (chunkResult.captures && isFinalChunk) { + allResults.captures = allResults.captures.concat(chunkResult.captures); + } + if (chunkResult.errors) { + allResults.errors = allResults.errors.concat(chunkResult.errors); + } + + if (chunkResult.file_upload_status === "error") { + allResults.file_upload_status = "error"; + allResults.message = chunkResult.message || "Upload failed"; + break; + } + + if (chunkResult.file_upload_status === "success" && isFinalChunk) { + allResults.file_upload_status = "success"; + } + } + + if (this.cancelRequested) { + throw new Error("Upload cancelled by user"); + } + + return allResults; + } + + async uploadChunk( + chunk, + chunkPaths, + allRelativePaths, + currentChunk, + totalChunks, + ) { + const formData = new FormData(); + + console.debug( + `uploadChunk ${currentChunk}/${totalChunks} - chunkPaths:`, + chunkPaths, + ); + console.debug( + `uploadChunk ${currentChunk}/${totalChunks} - allRelativePaths (first 5):`, + allRelativePaths.slice(0, 5), + ); + + for (const file of chunk) { + formData.append("files", file); + } + for (const path of chunkPaths) { + formData.append("relative_paths", path); + } + for (const path of allRelativePaths) { + formData.append("all_relative_paths", path); + } + + this.addCaptureTypeData(formData); + formData.append("csrfmiddlewaretoken", window.csrfToken); + + formData.append("is_chunk", "true"); + formData.append("chunk_number", currentChunk.toString()); + formData.append("total_chunks", totalChunks.toString()); + + const controller = new AbortController(); + this.currentAbortController = controller; + const timeoutId = setTimeout(() => controller.abort(), 300000); + + try { + const response = await fetch(window.uploadFilesUrl, { + method: "POST", + body: formData, + headers: { + "X-CSRFToken": window.csrfToken, + }, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return await response.json(); + } catch (error) { + clearTimeout(timeoutId); + if (error.name === "AbortError") { + throw new Error("Upload timeout - connection may be lost"); + } + throw error; + } + } + + addCaptureTypeData(formData) { + const captureType = document.getElementById("captureTypeSelect").value; + formData.append("capture_type", captureType); + + if (captureType === "drf") { + const channels = document.getElementById("captureChannelsInput").value; + formData.append("channels", channels); + } else if (captureType === "rh") { + const scanGroup = document.getElementById("captureScanGroupInput").value; + formData.append("scan_group", scanGroup); + } + } + + handleError(error) { + if (this.cancelRequested) { + alert( + "Upload cancelled. Any files uploaded before cancellation have been saved.", + ); + setTimeout(() => window.location.reload(), 1000); + } else if (error.name === "AbortError") { + alert( + "Upload was interrupted. Any files uploaded before the interruption have been saved.", + ); + setTimeout(() => window.location.reload(), 1000); + } else if (error.name === "TypeError" && error.message.includes("fetch")) { + alert( + "Network error during upload. Please check your connection and try again.", + ); + } else { + alert(`Upload failed: ${error.message}`); + setTimeout(() => window.location.reload(), 1000); + } + } + + resetUIState() { + this.submitButton.disabled = false; + + const progressSection = document.getElementById("checkingProgressSection"); + if (progressSection) { + progressSection.style.display = "none"; + } + + this.cancelButton.textContent = "Cancel"; + this.cancelButton.classList.remove("btn-warning"); + this.cancelButton.disabled = false; + + const progressBar = document.getElementById("checkingProgressBar"); + const progressText = document.getElementById("checkingProgressText"); + const progressMessage = document.getElementById("progressMessage"); + + if (progressBar) progressBar.style.width = "0%"; + if (progressText) progressText.textContent = "0%"; + if (progressMessage) progressMessage.textContent = ""; + + this.isProcessing = false; + this.uploadInProgress = false; + this.cancelRequested = false; + this.currentAbortController = null; + } + + showUploadResults(result, uploadedCount, totalCount) { + const uploadModal = bootstrap.Modal.getInstance(this.uploadModal); + if (uploadModal) { + uploadModal.hide(); + } + + if (result.file_upload_status === "success") { + const uploaded = uploadedCount ?? 0; + const message = `Upload complete: ${uploaded} / ${totalCount} file${totalCount === 1 ? "" : "s"} uploaded.`; + try { + sessionStorage.setItem( + "filesAlert", + JSON.stringify({ message: message, type: "success" }), + ); + } catch (_) {} + setTimeout(() => window.location.reload(), 500); + } else { + this.showErrorModal(result); + } + } + + showErrorModal(result) { + const modalBody = document.getElementById("uploadResultModalBody"); + const resultModalEl = document.getElementById("uploadResultModal"); + const modal = new bootstrap.Modal(resultModalEl); + + let msg = "Upload Failed
    "; + if (result.message) { + msg += `${result.message}

    `; + } + msg += "Please remove the problematic files and try again."; + + if (result.errors && result.errors.length > 0) { + const errs = result.errors.map((e) => `
  • ${e}
  • `).join(""); + msg += `

    Error Details:
      ${errs}
    `; + } + + modalBody.innerHTML = msg; + modal.show(); + } +} + +if (typeof window !== "undefined") { + window.FilesUploadModal = FilesUploadModal; +} +if (typeof module !== "undefined" && module.exports) { + module.exports = { FilesUploadModal }; +} + diff --git a/gateway/sds_gateway/static/js/upload/UploadCaptureModalController.js b/gateway/sds_gateway/static/js/upload/UploadCaptureModalController.js new file mode 100644 index 000000000..9f58a1fd0 --- /dev/null +++ b/gateway/sds_gateway/static/js/upload/UploadCaptureModalController.js @@ -0,0 +1,39 @@ +/** + * Upload capture modal: submit, cancel/abort, progress, beforeunload guards. + * Placeholder: migrate from file_list_upload_capture_modal.js in a later step. + */ +class UploadCaptureModalController { + constructor() {} + + init() {} + + /** + * @param {File[]} files + * @param {HTMLElement} cancelButton + * @param {HTMLElement} submitButton + * @returns {boolean} true if large files blocked flow + */ + checkForLargeFiles(files, cancelButton, submitButton) { + void files; + void cancelButton; + void submitButton; + return false; + } + + resetUIState() {} + + /** + * @param {string} buttonType + */ + handleCancellation(buttonType) { + void buttonType; + } +} + +if (typeof window !== "undefined") { + window.UploadCaptureModalController = UploadCaptureModalController; +} + +if (typeof module !== "undefined" && module.exports) { + module.exports = { UploadCaptureModalController }; +} diff --git a/gateway/sds_gateway/static/js/upload/_blake3_class.txt b/gateway/sds_gateway/static/js/upload/_blake3_class.txt new file mode 100644 index 000000000..90c73ec4d --- /dev/null +++ b/gateway/sds_gateway/static/js/upload/_blake3_class.txt @@ -0,0 +1,169 @@ +class Blake3FileHandler { + constructor() { + // Initialize global variables for file tracking + this.initializeGlobalVariables(); + this.setupEventListeners(); + } + + initializeGlobalVariables() { + // Global variables to track files that should be skipped + window.filesToSkip = new Set(); + window.fileCheckResults = new Map(); // Store detailed results for each file + } + + setupEventListeners() { + const modal = document.getElementById("uploadCaptureModal"); + if (!modal) { + console.warn("uploadCaptureModal not found"); + return; + } + + modal.addEventListener("shown.bs.modal", () => { + this.setupFileInputHandler(); + }); + } + + setupFileInputHandler() { + const fileInput = document.getElementById("captureFileInput"); + if (!fileInput) { + console.warn("captureFileInput not found"); + return; + } + + // Remove any previous handler to avoid duplicates + if (window._blake3CaptureHandler) { + fileInput.removeEventListener("change", window._blake3CaptureHandler); + } + + // Create file handler that stores selected files + window._blake3CaptureHandler = async (event) => { + await this.handleFileSelection(event); + }; + + fileInput.addEventListener("change", window._blake3CaptureHandler); + } + + async handleFileSelection(event) { + const files = event.target.files; + if (!files || files.length === 0) { + return; + } + + // Store the selected files for later processing + window.selectedFiles = Array.from(files); + + console.log(`Selected ${files.length} files for upload`); + } + + /** + * Calculate BLAKE3 hash for a file + * @param {File} file - The file to hash + * @returns {Promise} - The BLAKE3 hash in hex format + */ + async calculateBlake3Hash(file) { + try { + const buffer = await file.arrayBuffer(); + const hasher = await hashwasm.createBLAKE3(); + hasher.init(); + hasher.update(new Uint8Array(buffer)); + return hasher.digest("hex"); + } catch (error) { + console.error("Error calculating BLAKE3 hash:", error); + throw error; + } + } + + /** + * Get directory path from webkitRelativePath + * @param {File} file - The file to get directory for + * @returns {string} - The directory path + */ + getDirectoryPath(file) { + if (!file.webkitRelativePath) { + return "/"; + } + + const pathParts = file.webkitRelativePath.split("/"); + if (pathParts.length > 1) { + pathParts.pop(); // Remove filename + return `/${pathParts.join("/")}`; + } + + return "/"; + } + + /** + * Check if a file exists on the server + * @param {File} file - The file to check + * @param {string} hash - The BLAKE3 hash of the file + * @returns {Promise} - The server response + */ + async checkFileExists(file, hash) { + const directory = this.getDirectoryPath(file); + + const checkData = { + directory: directory, + filename: file.name, + checksum: hash, + }; + + try { + const response = await fetch(window.checkFileExistsUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": window.csrfToken, + }, + body: JSON.stringify(checkData), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return await response.json(); + } catch (error) { + console.error("Error checking file existence:", error); + throw error; + } + } + + /** + * Process a single file for duplicate checking + * @param {File} file - The file to process + * @returns {Promise} - Processing result + */ + async processFileForDuplicateCheck(file) { + try { + // Calculate hash + const hash = await this.calculateBlake3Hash(file); + + // Check if file exists + const checkResult = await this.checkFileExists(file, hash); + + // Store results + const directory = this.getDirectoryPath(file); + const fileKey = `${directory}/${file.name}`; + + const result = { + file: file, + directory: directory, + filename: file.name, + checksum: hash, + data: checkResult.data, + }; + + window.fileCheckResults.set(fileKey, result); + + // Mark for skipping if file exists + if (checkResult.data && checkResult.data.file_exists_in_tree === true) { + window.filesToSkip.add(fileKey); + } + + return result; + } catch (error) { + console.error("Error processing file for duplicate check:", error); + return null; + } + } +} diff --git a/gateway/sds_gateway/static/js/upload/_cap_sel.txt b/gateway/sds_gateway/static/js/upload/_cap_sel.txt new file mode 100644 index 000000000..a3b46356c --- /dev/null +++ b/gateway/sds_gateway/static/js/upload/_cap_sel.txt @@ -0,0 +1,183 @@ +/** + * Capture Type Selection Handler + * Manages capture type dropdown and conditional form fields + */ +class CaptureTypeSelector { + constructor() { + this.boundHandlers = new Map(); // Track event handlers for cleanup + this.initializeElements(); + this.setupEventListeners(); + } + + initializeElements() { + this.captureTypeSelect = document.getElementById("captureTypeSelect"); + this.channelInputGroup = document.getElementById("channelInputGroup"); + this.scanGroupInputGroup = document.getElementById("scanGroupInputGroup"); + this.captureChannelsInput = document.getElementById("captureChannelsInput"); + this.captureScanGroupInput = document.getElementById( + "captureScanGroupInput", + ); + this.uploadModal = document.getElementById("uploadCaptureModal"); + + // Log which elements were found for debugging + console.log("CaptureTypeSelector elements found:", { + captureTypeSelect: !!this.captureTypeSelect, + channelInputGroup: !!this.channelInputGroup, + scanGroupInputGroup: !!this.scanGroupInputGroup, + captureChannelsInput: !!this.captureChannelsInput, + captureScanGroupInput: !!this.captureScanGroupInput, + uploadModal: !!this.uploadModal, + }); + } + + setupEventListeners() { + // Ensure boundHandlers is initialized + if (!this.boundHandlers) { + this.boundHandlers = new Map(); + } + + if (this.captureTypeSelect) { + const changeHandler = (e) => this.handleTypeChange(e); + this.boundHandlers.set(this.captureTypeSelect, changeHandler); + this.captureTypeSelect.addEventListener("change", changeHandler); + } + + if (this.uploadModal) { + const hiddenHandler = () => this.resetForm(); + this.boundHandlers.set(this.uploadModal, hiddenHandler); + this.uploadModal.addEventListener("hidden.bs.modal", hiddenHandler); + } + } + + handleTypeChange(event) { + const selectedType = event.target.value; + + // Validate capture type + if (!this.validateCaptureType(selectedType)) { + ErrorHandler.showError( + "Invalid capture type selected", + "capture-type-validation", + ); + return; + } + + // Hide both input groups initially + this.hideInputGroups(); + + // Clear required attributes + this.clearRequiredAttributes(); + + // Show appropriate input group based on selection + if (selectedType === "drf") { + this.showChannelInput(); + } else if (selectedType === "rh") { + this.showScanGroupInput(); + } + } + + hideInputGroups() { + if (this.channelInputGroup) { + this.channelInputGroup.classList.add("hidden-input-group"); + } + if (this.scanGroupInputGroup) { + this.scanGroupInputGroup.classList.add("hidden-input-group"); + } + } + + clearRequiredAttributes() { + if (this.captureChannelsInput) { + this.captureChannelsInput.removeAttribute("required"); + } + if (this.captureScanGroupInput) { + this.captureScanGroupInput.removeAttribute("required"); + } + } + + showChannelInput() { + if (this.channelInputGroup) { + this.channelInputGroup.classList.remove("hidden-input-group"); + } + if (this.captureChannelsInput) { + this.captureChannelsInput.setAttribute("required", "required"); + } + } + + showScanGroupInput() { + if (this.scanGroupInputGroup) { + this.scanGroupInputGroup.classList.remove("hidden-input-group"); + } + // scan_group is optional for RadioHound captures, so no required attribute + } + + // Input validation methods + validateCaptureType(type) { + const validTypes = ["drf", "rh"]; + return validTypes.includes(type); + } + + validateChannelInput(channels) { + if (!channels || typeof channels !== "string") return false; + // Basic validation for channel input (can be enhanced based on requirements) + return channels.trim().length > 0 && channels.length <= 1000; + } + + validateScanGroupInput(scanGroup) { + if (!scanGroup || typeof scanGroup !== "string") return false; + // Basic validation for scan group input + return scanGroup.trim().length > 0 && scanGroup.length <= 255; + } + + sanitizeInput(input) { + if (!input || typeof input !== "string") return ""; + // Remove potentially dangerous characters + return input.replace(/[<>:"/\\|?*]/g, "_").trim(); + } + + // Memory management and cleanup + cleanup() { + // Remove all bound event handlers + for (const [element, handler] of this.boundHandlers) { + if (element?.removeEventListener) { + element.removeEventListener("change", handler); + element.removeEventListener("hidden.bs.modal", handler); + } + } + this.boundHandlers.clear(); + console.log("CaptureTypeSelector cleanup completed"); + } + + resetForm() { + // Reset the form + const form = document.getElementById("uploadCaptureForm"); + if (form) { + form.reset(); + } + + // Hide input groups + this.hideInputGroups(); + + // Clear required attributes + this.clearRequiredAttributes(); + + // Clear global variables if they exist + this.cleanupGlobalState(); + } + + // Better global state management + cleanupGlobalState() { + const globalVars = ["filesToSkip", "fileCheckResults", "selectedFiles"]; + + for (const varName of globalVars) { + if (window[varName]) { + if (typeof window[varName].clear === "function") { + window[varName].clear(); + } else if (Array.isArray(window[varName])) { + window[varName].length = 0; + } else { + window[varName] = null; + } + console.log(`Cleaned up global variable: ${varName}`); + } + } + } +} diff --git a/gateway/sds_gateway/static/js/upload/_file_upload_handler.txt b/gateway/sds_gateway/static/js/upload/_file_upload_handler.txt new file mode 100644 index 000000000..b92c87f5d --- /dev/null +++ b/gateway/sds_gateway/static/js/upload/_file_upload_handler.txt @@ -0,0 +1,221 @@ +class FileUploadHandler { + constructor() { + this.uploadForm = document.getElementById("uploadFileForm"); + this.fileInput = document.getElementById("fileInput"); + this.folderInput = document.getElementById("folderInput"); + this.submitBtn = document.getElementById("uploadFileSubmitBtn"); + this.clearBtn = document.getElementById("clearUploadBtn"); + this.uploadText = this.submitBtn?.querySelector(".upload-text"); + this.uploadSpinner = this.submitBtn?.querySelector(".upload-spinner"); + this.validationFeedback = document.getElementById( + "uploadValidationFeedback", + ); + + // Enable submit button when files or folders are selected + if (this.fileInput) { + this.fileInput.addEventListener("change", () => + this.updateSubmitButton(), + ); + } + if (this.folderInput) { + this.folderInput.addEventListener("change", () => + this.updateSubmitButton(), + ); + } + + // Clear button handler + if (this.clearBtn) { + this.clearBtn.addEventListener("click", () => this.clearModal()); + } + + if (this.uploadForm) { + this.uploadForm.addEventListener("submit", (e) => this.handleSubmit(e)); + } + } + + updateSubmitButton() { + if (this.submitBtn) { + const hasFiles = this.fileInput?.files.length > 0; + const hasFolders = this.folderInput?.files.length > 0; + this.submitBtn.disabled = !hasFiles && !hasFolders; + + // Hide validation feedback when files are selected + if (hasFiles || hasFolders) { + this.hideValidationFeedback(); + } + } + } + + showValidationFeedback() { + if (this.validationFeedback) { + this.validationFeedback.classList.add("d-block"); + } + // Add invalid styling to inputs + this.fileInput?.classList.add("is-invalid"); + this.folderInput?.classList.add("is-invalid"); + } + + hideValidationFeedback() { + if (this.validationFeedback) { + this.validationFeedback.classList.remove("d-block"); + } + // Remove invalid styling from inputs + this.fileInput?.classList.remove("is-invalid"); + this.folderInput?.classList.remove("is-invalid"); + } + + clearModal() { + // Reset form + if (this.uploadForm) { + this.uploadForm.reset(); + } + // Explicitly clear file inputs (form.reset() doesn't always clear file inputs) + if (this.fileInput) { + this.fileInput.value = ""; + } + if (this.folderInput) { + this.folderInput.value = ""; + } + // Hide validation feedback + this.hideValidationFeedback(); + // Update submit button state + this.updateSubmitButton(); + } + + async handleSubmit(event) { + event.preventDefault(); + + const files = Array.from(this.fileInput?.files || []); + const folderFiles = Array.from(this.folderInput?.files || []); + + if (files.length === 0 && folderFiles.length === 0) { + this.showValidationFeedback(); + return; + } + + this.setUploadingState(true); + + try { + // Debug: Check CSRF token availability + console.log("CSRF Token:", window.csrfToken); + console.log("Upload URL:", window.uploadFilesUrl); + + // Try multiple ways to get CSRF token + let csrfToken = window.csrfToken; + if (!csrfToken) { + // Fallback to DOM query + const csrfInput = document.querySelector("[name=csrfmiddlewaretoken]"); + csrfToken = csrfInput ? csrfInput.value : null; + } + + if (!csrfToken) { + throw new Error("CSRF token not found"); + } + + const formData = new FormData(); + const allFiles = [...files, ...folderFiles]; + const allRelativePaths = []; + + // Add all files to formData + for (const file of allFiles) { + formData.append("files", file); + // Use webkitRelativePath for folder files, filename for individual files + const relativePath = file.webkitRelativePath || file.name; + allRelativePaths.push(relativePath); + } + + // Add relative paths + for (const relativePath of allRelativePaths) { + formData.append("relative_paths", relativePath); + } + for (const relativePath of allRelativePaths) { + formData.append("all_relative_paths", relativePath); + } + + // Prevent capture creation when uploading files only + formData.append("capture_type", ""); + formData.append("channels", ""); + formData.append("scan_group", ""); + formData.append("csrfmiddlewaretoken", csrfToken); + + const response = await fetch(window.uploadFilesUrl, { + method: "POST", + body: formData, + headers: { + "X-CSRFToken": csrfToken, + }, + }); + + const result = await response.json(); + + if (response.ok) { + // Show success message + const fileCount = allFiles.length; + const successMsg = + fileCount === 1 + ? "1 file uploaded successfully!" + : `${fileCount} files uploaded successfully!`; + this.showResult("success", successMsg); + // Clear file inputs + this.clearModal(); + // Close modal + const modal = bootstrap.Modal.getInstance( + document.getElementById("uploadFileModal"), + ); + if (modal) modal.hide(); + // Refresh the file list + if (window.fileManager) { + window.fileManager.loadFiles(); + } + } else { + this.showResult( + "error", + result.error || "Upload failed. Please try again.", + ); + } + } catch (error) { + console.error("Upload error:", error); + this.showResult( + "error", + "Upload failed. Please check your connection and try again.", + ); + } finally { + this.setUploadingState(false); + } + } + + setUploadingState(uploading) { + if (this.submitBtn) { + this.submitBtn.disabled = uploading; + } + if (this.uploadText && this.uploadSpinner) { + this.uploadText.classList.toggle("d-none", uploading); + this.uploadSpinner.classList.toggle("d-none", !uploading); + } + } + + showResult(type, message) { + // Show result in the upload result modal + const resultModal = document.getElementById("uploadResultModal"); + const resultBody = document.getElementById("uploadResultModalBody"); + + if (resultModal && resultBody) { + resultBody.innerHTML = ` +
    + ${message} +
    + `; + const modal = new bootstrap.Modal(resultModal); + modal.show(); + } else { + // Fallback to alert + alert(message); + } + } + + cleanup() { + if (this.uploadForm) { + this.uploadForm.removeEventListener("submit", this.handleSubmit); + } + } +} diff --git a/gateway/sds_gateway/static/js/upload/_files_modal_class.txt b/gateway/sds_gateway/static/js/upload/_files_modal_class.txt new file mode 100644 index 000000000..9c2077bd4 --- /dev/null +++ b/gateway/sds_gateway/static/js/upload/_files_modal_class.txt @@ -0,0 +1,550 @@ +class FilesUploadModal { + constructor() { + this.isProcessing = false; + this.uploadInProgress = false; + this.cancelRequested = false; + this.currentAbortController = null; + + this.initializeElements(); + this.setupEventListeners(); + this.clearExistingModals(); + } + + initializeElements() { + this.cancelButton = document.querySelector( + "#uploadCaptureModal .btn-secondary", + ); + this.submitButton = document.getElementById("uploadSubmitBtn"); + this.uploadModal = document.getElementById("uploadCaptureModal"); + this.fileInput = document.getElementById("captureFileInput"); + this.uploadForm = document.getElementById("uploadCaptureForm"); + } + + setupEventListeners() { + // Modal event listeners + if (this.uploadModal) { + this.uploadModal.addEventListener("show.bs.modal", () => + this.resetState(), + ); + this.uploadModal.addEventListener("hidden.bs.modal", () => + this.resetState(), + ); + } + + // File input change listener + if (this.fileInput) { + this.fileInput.addEventListener("change", () => this.resetState()); + } + + // Cancel button listener + if (this.cancelButton) { + this.cancelButton.addEventListener("click", () => this.handleCancel()); + } + + // Form submit listener + if (this.uploadForm) { + this.uploadForm.addEventListener("submit", (e) => this.handleSubmit(e)); + } + } + + clearExistingModals() { + const existingResultModal = document.getElementById("uploadResultModal"); + if (existingResultModal) { + const modalInstance = bootstrap.Modal.getInstance(existingResultModal); + if (modalInstance) { + modalInstance.hide(); + } + } + } + + resetState() { + this.isProcessing = false; + this.currentAbortController = null; + this.cancelRequested = false; + } + + handleCancel() { + if (this.isProcessing) { + this.cancelRequested = true; + + if (this.currentAbortController) { + this.currentAbortController.abort(); + } + + this.cancelButton.textContent = "Cancelling..."; + this.cancelButton.disabled = true; + + const progressMessage = document.getElementById("progressMessage"); + if (progressMessage) { + progressMessage.textContent = "Cancelling upload..."; + } + + setTimeout(() => { + if (this.cancelRequested) { + this.resetUIState(); + } + }, 500); + } + } + + async handleSubmit(e) { + e.preventDefault(); + + this.isProcessing = true; + this.uploadInProgress = true; + this.cancelRequested = false; + + // Check if files are selected + if (!window.selectedFiles || window.selectedFiles.length === 0) { + alert("Please select files to upload."); + return; + } + + try { + this.showProgressSection(); + await this.checkFilesForDuplicates(); + + if (this.cancelRequested) { + throw new Error("Upload cancelled by user"); + } + + await this.uploadFiles(); + } catch (error) { + this.handleError(error); + } finally { + this.resetUIState(); + } + } + + showProgressSection() { + const progressSection = document.getElementById("checkingProgressSection"); + const progressMessage = document.getElementById("progressMessage"); + + if (progressSection) { + progressSection.style.display = "block"; + } + if (progressMessage) { + progressMessage.textContent = "Checking files for duplicates..."; + } + + this.cancelButton.textContent = "Cancel Processing"; + this.cancelButton.classList.add("btn-warning"); + this.submitButton.disabled = true; + } + + async checkFilesForDuplicates() { + window.filesToSkip = new Set(); + window.fileCheckResults = new Map(); + const files = window.selectedFiles; + const totalFiles = files.length; + + const progressBar = document.getElementById("checkingProgressBar"); + const progressText = document.getElementById("checkingProgressText"); + + for (let i = 0; i < files.length; i++) { + if (this.cancelRequested) break; + + const file = files[i]; + const progress = Math.round(((i + 1) / totalFiles) * 100); + + if (progressBar) progressBar.style.width = `${progress}%`; + if (progressText) progressText.textContent = `${progress}%`; + + await this.processFile(file); + } + + if (this.cancelRequested) { + throw new Error("Upload cancelled by user"); + } + } + + async processFile(file) { + // Calculate BLAKE3 hash + const buffer = await file.arrayBuffer(); + const hasher = await hashwasm.createBLAKE3(); + hasher.init(); + hasher.update(new Uint8Array(buffer)); + const hashHex = hasher.digest("hex"); + + // Calculate directory path + let directory = "/"; + if (file.webkitRelativePath) { + const pathParts = file.webkitRelativePath.split("/"); + if (pathParts.length > 1) { + pathParts.pop(); + directory = `/${pathParts.join("/")}`; + } + } + + // Check if file exists + const checkData = { + directory: directory, + filename: file.name, + checksum: hashHex, + }; + + try { + const response = await fetch(window.checkFileExistsUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": window.csrfToken, + }, + body: JSON.stringify(checkData), + }); + const data = await response.json(); + + const fileKey = `${directory}/${file.name}`; + window.fileCheckResults.set(fileKey, { + file: file, + directory: directory, + filename: file.name, + checksum: hashHex, + data: data.data, + }); + + if (data.data && data.data.file_exists_in_tree === true) { + window.filesToSkip.add(fileKey); + } + } catch (error) { + console.error("Error checking file:", error); + } + } + + async uploadFiles() { + const progressMessage = document.getElementById("progressMessage"); + const progressSection = document.getElementById("checkingProgressSection"); + const progressBar = document.getElementById("checkingProgressBar"); + const progressText = document.getElementById("checkingProgressText"); + + if (progressMessage) { + progressMessage.textContent = "Uploading files and creating captures..."; + } + if (progressBar) progressBar.style.width = "0%"; + if (progressText) progressText.textContent = "0%"; + + const files = window.selectedFiles; + const filesToUpload = []; + const relativePathsToUpload = []; + const allRelativePaths = []; + + // Process files for upload + for (const file of files) { + let directory = "/"; + if (file.webkitRelativePath) { + const pathParts = file.webkitRelativePath.split("/"); + if (pathParts.length > 1) { + pathParts.pop(); + directory = `/${pathParts.join("/")}`; + } + } + const fileKey = `${directory}/${file.name}`; + const relativePath = file.webkitRelativePath || file.name; + + console.debug( + `Processing file: ${file.name}, webkitRelativePath: '${file.webkitRelativePath}', relativePath: '${relativePath}', directory: '${directory}'`, + ); + allRelativePaths.push(relativePath); + + if (!window.filesToSkip.has(fileKey)) { + filesToUpload.push(file); + relativePathsToUpload.push(relativePath); + } + } + + console.debug( + "All relative paths being sent:", + allRelativePaths.slice(0, 5), + ); + console.debug( + "Relative paths to upload:", + relativePathsToUpload.slice(0, 5), + ); + + if (filesToUpload.length > 0 && progressSection) { + progressSection.style.display = "block"; + } + + this.currentAbortController = new AbortController(); + + let result; + if (filesToUpload.length === 0) { + result = await this.uploadSkippedFiles(allRelativePaths); + } else { + result = await this.uploadFilesInChunks( + filesToUpload, + relativePathsToUpload, + allRelativePaths, + ); + } + + this.currentAbortController = null; + this.showUploadResults(result, result.saved_files_count, files.length); + } + + async uploadSkippedFiles(allRelativePaths) { + const formData = new FormData(); + + console.debug( + "uploadSkippedFiles - allRelativePaths:", + allRelativePaths.slice(0, 5), + ); + for (const path of allRelativePaths) { + formData.append("all_relative_paths", path); + } + + this.addCaptureTypeData(formData); + formData.append("csrfmiddlewaretoken", window.csrfToken); + + const response = await fetch(window.uploadFilesUrl, { + method: "POST", + body: formData, + headers: { + "X-CSRFToken": window.csrfToken, + }, + signal: this.currentAbortController.signal, + }); + + return await response.json(); + } + + async uploadFilesInChunks( + filesToUpload, + relativePathsToUpload, + allRelativePaths, + ) { + const CHUNK_SIZE = 5; + const totalFiles = filesToUpload.length; + const progressBar = document.getElementById("checkingProgressBar"); + const progressText = document.getElementById("checkingProgressText"); + const progressMessage = document.getElementById("progressMessage"); + + const allResults = { + file_upload_status: "success", + saved_files_count: 0, + captures: [], + errors: [], + message: "", + }; + + for (let i = 0; i < filesToUpload.length; i += CHUNK_SIZE) { + if (this.cancelRequested) break; + + const chunk = filesToUpload.slice(i, i + CHUNK_SIZE); + const chunkPaths = relativePathsToUpload.slice(i, i + CHUNK_SIZE); + + const totalChunks = Math.ceil(filesToUpload.length / CHUNK_SIZE); + const currentChunk = Math.floor(i / CHUNK_SIZE) + 1; + const isFinalChunk = currentChunk === totalChunks; + + // Update progress + const progress = Math.round(((i + chunk.length) / totalFiles) * 100); + if (progressBar) progressBar.style.width = `${progress}%`; + if (progressText) progressText.textContent = `${progress}%`; + if (progressMessage) { + progressMessage.textContent = `Uploading files ${i + 1}-${Math.min(i + CHUNK_SIZE, totalFiles)} of ${totalFiles} (chunk ${currentChunk}/${totalChunks})...`; + } + + const chunkResult = await this.uploadChunk( + chunk, + chunkPaths, + allRelativePaths, + currentChunk, + totalChunks, + ); + + // Merge results + if (chunkResult.saved_files_count !== undefined) { + allResults.saved_files_count += chunkResult.saved_files_count; + } + if (chunkResult.captures && isFinalChunk) { + allResults.captures = allResults.captures.concat(chunkResult.captures); + } + if (chunkResult.errors) { + allResults.errors = allResults.errors.concat(chunkResult.errors); + } + + if (chunkResult.file_upload_status === "error") { + allResults.file_upload_status = "error"; + allResults.message = chunkResult.message || "Upload failed"; + break; + } + + if (chunkResult.file_upload_status === "success" && isFinalChunk) { + allResults.file_upload_status = "success"; + } + } + + if (this.cancelRequested) { + throw new Error("Upload cancelled by user"); + } + + return allResults; + } + + async uploadChunk( + chunk, + chunkPaths, + allRelativePaths, + currentChunk, + totalChunks, + ) { + const formData = new FormData(); + + console.debug( + `uploadChunk ${currentChunk}/${totalChunks} - chunkPaths:`, + chunkPaths, + ); + console.debug( + `uploadChunk ${currentChunk}/${totalChunks} - allRelativePaths (first 5):`, + allRelativePaths.slice(0, 5), + ); + + for (const file of chunk) { + formData.append("files", file); + } + for (const path of chunkPaths) { + formData.append("relative_paths", path); + } + for (const path of allRelativePaths) { + formData.append("all_relative_paths", path); + } + + this.addCaptureTypeData(formData); + formData.append("csrfmiddlewaretoken", window.csrfToken); + + formData.append("is_chunk", "true"); + formData.append("chunk_number", currentChunk.toString()); + formData.append("total_chunks", totalChunks.toString()); + + const controller = new AbortController(); + this.currentAbortController = controller; + const timeoutId = setTimeout(() => controller.abort(), 300000); + + try { + const response = await fetch(window.uploadFilesUrl, { + method: "POST", + body: formData, + headers: { + "X-CSRFToken": window.csrfToken, + }, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return await response.json(); + } catch (error) { + clearTimeout(timeoutId); + if (error.name === "AbortError") { + throw new Error("Upload timeout - connection may be lost"); + } + throw error; + } + } + + addCaptureTypeData(formData) { + const captureType = document.getElementById("captureTypeSelect").value; + formData.append("capture_type", captureType); + + if (captureType === "drf") { + const channels = document.getElementById("captureChannelsInput").value; + formData.append("channels", channels); + } else if (captureType === "rh") { + const scanGroup = document.getElementById("captureScanGroupInput").value; + formData.append("scan_group", scanGroup); + } + } + + handleError(error) { + if (this.cancelRequested) { + alert( + "Upload cancelled. Any files uploaded before cancellation have been saved.", + ); + setTimeout(() => window.location.reload(), 1000); + } else if (error.name === "AbortError") { + alert( + "Upload was interrupted. Any files uploaded before the interruption have been saved.", + ); + setTimeout(() => window.location.reload(), 1000); + } else if (error.name === "TypeError" && error.message.includes("fetch")) { + alert( + "Network error during upload. Please check your connection and try again.", + ); + } else { + alert(`Upload failed: ${error.message}`); + setTimeout(() => window.location.reload(), 1000); + } + } + + resetUIState() { + this.submitButton.disabled = false; + + const progressSection = document.getElementById("checkingProgressSection"); + if (progressSection) { + progressSection.style.display = "none"; + } + + this.cancelButton.textContent = "Cancel"; + this.cancelButton.classList.remove("btn-warning"); + this.cancelButton.disabled = false; + + const progressBar = document.getElementById("checkingProgressBar"); + const progressText = document.getElementById("checkingProgressText"); + const progressMessage = document.getElementById("progressMessage"); + + if (progressBar) progressBar.style.width = "0%"; + if (progressText) progressText.textContent = "0%"; + if (progressMessage) progressMessage.textContent = ""; + + this.isProcessing = false; + this.uploadInProgress = false; + this.cancelRequested = false; + this.currentAbortController = null; + } + + showUploadResults(result, uploadedCount, totalCount) { + const uploadModal = bootstrap.Modal.getInstance(this.uploadModal); + if (uploadModal) { + uploadModal.hide(); + } + + if (result.file_upload_status === "success") { + const uploaded = uploadedCount ?? 0; + const message = `Upload complete: ${uploaded} / ${totalCount} file${totalCount === 1 ? "" : "s"} uploaded.`; + try { + sessionStorage.setItem( + "filesAlert", + JSON.stringify({ message: message, type: "success" }), + ); + } catch (_) {} + setTimeout(() => window.location.reload(), 500); + } else { + this.showErrorModal(result); + } + } + + showErrorModal(result) { + const modalBody = document.getElementById("uploadResultModalBody"); + const resultModalEl = document.getElementById("uploadResultModal"); + const modal = new bootstrap.Modal(resultModalEl); + + let msg = "Upload Failed
    "; + if (result.message) { + msg += `${result.message}

    `; + } + msg += "Please remove the problematic files and try again."; + + if (result.errors && result.errors.length > 0) { + const errs = result.errors.map((e) => `
  • ${e}
  • `).join(""); + msg += `

    Error Details:
      ${errs}
    `; + } + + modalBody.innerHTML = msg; + modal.show(); + } +} diff --git a/gateway/sds_gateway/static/js/upload/_files_page_init.txt b/gateway/sds_gateway/static/js/upload/_files_page_init.txt new file mode 100644 index 000000000..54fd5a524 --- /dev/null +++ b/gateway/sds_gateway/static/js/upload/_files_page_init.txt @@ -0,0 +1,233 @@ +class FilesPageInitializer { + constructor() { + this.boundHandlers = new Map(); // Track event handlers for cleanup + this.activeHandlers = new Set(); // Track active component handlers + this.initializeComponents(); + } + + initializeComponents() { + try { + this.initializeModalManager(); + this.initializeCapturesTableManager(); + this.initializeUserSearchHandlers(); + } catch (error) { + ErrorHandler.showError( + "Failed to initialize page components", + "component-initialization", + error, + ); + } + } + + initializeModalManager() { + // Initialize ModalManager for capture modal + let modalManager = null; + try { + if (window.ModalManager) { + modalManager = new window.ModalManager({ + modalId: "capture-modal", + modalBodyId: "capture-modal-body", + modalTitleId: "capture-modal-label", + }); + + this.modalManager = modalManager; + console.log("ModalManager initialized successfully"); + } else { + ErrorHandler.showError( + "Modal functionality is not available. Some features may be limited.", + "modal-initialization", + ); + } + } catch (error) { + ErrorHandler.showError( + "Failed to initialize modal functionality", + "modal-initialization", + error, + ); + } + } + + initializeCapturesTableManager() { + // Initialize CapturesTableManager for capture edit/download functionality + try { + if (window.CapturesTableManager) { + window.capturesTableManager = new window.CapturesTableManager({ + modalHandler: this.modalManager, + }); + console.log("CapturesTableManager initialized successfully"); + } else { + ErrorHandler.showError( + "Table management functionality is not available. Some features may be limited.", + "table-initialization", + ); + } + } catch (error) { + ErrorHandler.showError( + "Failed to initialize table management functionality", + "table-initialization", + error, + ); + } + } + + initializeUserSearchHandlers() { + // Create a UserSearchHandler for each share modal + const shareModals = document.querySelectorAll(".modal[data-item-uuid]"); + + // Skip initialization if no share modals exist on this page + if (shareModals.length === 0) { + return; + } + + // Check if UserSearchHandler is available before trying to initialize + if (!window.UserSearchHandler) { + console.warn( + "UserSearchHandler not available. Share functionality will not work.", + ); + return; + } + + for (const modal of shareModals) { + this.setupUserSearchHandler(modal); + } + } + + setupUserSearchHandler(modal) { + try { + // Ensure boundHandlers and activeHandlers are initialized + if (!this.boundHandlers) { + this.boundHandlers = new Map(); + } + if (!this.activeHandlers) { + this.activeHandlers = new Set(); + } + + // Validate modal attributes + const itemUuid = modal.getAttribute("data-item-uuid"); + const itemType = modal.getAttribute("data-item-type"); + + if (!this.validateModalAttributes(itemUuid, itemType)) { + ErrorHandler.showError( + "Invalid modal configuration", + "user-search-setup", + ); + return; + } + + const handler = new window.UserSearchHandler(); + // Store the handler on the modal element + modal.userSearchHandler = handler; + this.activeHandlers.add(handler); + + // Create bound event handlers for cleanup + const showHandler = () => { + if (modal.userSearchHandler) { + modal.userSearchHandler.setItemInfo(itemUuid, itemType); + modal.userSearchHandler.init(); + } + }; + + const hideHandler = () => { + if (modal.userSearchHandler) { + modal.userSearchHandler.resetAll(); + } + }; + + // Store handlers for cleanup + this.boundHandlers.set(modal, { + show: showHandler, + hide: hideHandler, + }); + + // On modal show, set the item info and call init() + modal.addEventListener("show.bs.modal", showHandler); + + // On modal hide, reset all selections and entered data + modal.addEventListener("hidden.bs.modal", hideHandler); + + console.log(`UserSearchHandler initialized for ${itemType}: ${itemUuid}`); + } catch (error) { + ErrorHandler.showError( + "Failed to setup user search functionality", + "user-search-setup", + error, + ); + } + } + + /** + * Get initialized modal manager + * @returns {Object|null} - The modal manager instance + */ + getModalManager() { + return this.modalManager; + } + + /** + * Get captures table manager + * @returns {Object|null} - The captures table manager instance + */ + getCapturesTableManager() { + return window.capturesTableManager; + } + + // Validation methods + validateModalAttributes(uuid, type) { + if (!uuid || typeof uuid !== "string") { + console.warn("Invalid UUID in modal attributes:", uuid); + return false; + } + + if (!type || typeof type !== "string") { + console.warn("Invalid type in modal attributes:", type); + return false; + } + + // Validate UUID format (basic check) + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + if (!uuidRegex.test(uuid)) { + console.warn("Invalid UUID format in modal attributes:", uuid); + return false; + } + + // Validate type + const validTypes = ["capture", "dataset", "file"]; + if (!validTypes.includes(type)) { + console.warn("Invalid type in modal attributes:", type); + return false; + } + + return true; + } + + // Memory management and cleanup + cleanup() { + // Remove all bound event handlers + for (const [element, handlers] of this.boundHandlers) { + if (element?.removeEventListener) { + if (handlers.show) { + element.removeEventListener("show.bs.modal", handlers.show); + } + if (handlers.hide) { + element.removeEventListener("hidden.bs.modal", handlers.hide); + } + } + } + this.boundHandlers.clear(); + + // Cleanup active handlers + for (const handler of this.activeHandlers) { + if (handler && typeof handler.cleanup === "function") { + try { + handler.cleanup(); + } catch (error) { + console.warn("Error during handler cleanup:", error); + } + } + } + this.activeHandlers.clear(); + + console.log("FilesPageInitializer cleanup completed"); + } +} diff --git a/gateway/sds_gateway/static/js/upload/_files_upload_dom.txt b/gateway/sds_gateway/static/js/upload/_files_upload_dom.txt new file mode 100644 index 000000000..5b91ac9cd --- /dev/null +++ b/gateway/sds_gateway/static/js/upload/_files_upload_dom.txt @@ -0,0 +1,29 @@ +// Initialize when DOM is loaded +document.addEventListener("DOMContentLoaded", () => { + // Set up session storage alert handling + const key = "filesAlert"; + const stored = sessionStorage.getItem(key); + if (stored) { + try { + const data = JSON.parse(stored); + if ( + window.components && + typeof window.components.showError === "function" && + data?.type === "error" + ) { + window.components.showError(data.message || "An error occurred."); + } else if ( + window.components && + typeof window.components.showSuccess === "function" && + data?.type === "success" + ) { + window.components.showSuccess(data.message || "Success"); + } + } catch (e) {} + sessionStorage.removeItem(key); + } + + // Initialize BLAKE3 handler first, then upload modal + new Blake3FileHandler(); + new FilesUploadModal(); +}); diff --git a/gateway/sds_gateway/templates/users/file_list.html b/gateway/sds_gateway/templates/users/file_list.html index bfd9e9ccb..b9bbb1128 100644 --- a/gateway/sds_gateway/templates/users/file_list.html +++ b/gateway/sds_gateway/templates/users/file_list.html @@ -365,13 +365,24 @@ src="{% static 'js/actions/DownloadActionManager.js' %}"> - - - + + + + + + + + + + + + + + - + {# djlint:off #} + {{ block.super }} + + + + + + + + + + + - {# Fallback modal helpers provided by components.js; inline duplicates removed #} + @@ -541,6 +552,11 @@ window.csrfToken = window.filesConfig.csrfToken; + + + + + {% endblock javascript %} From 68e7e9189c7eb8603540074f694701e070fb0aaa Mon Sep 17 00:00:00 2001 From: klpoland Date: Fri, 8 May 2026 12:13:49 -0400 Subject: [PATCH 2/7] remove fragments from migrating code --- .../js/captures/_frag_captures_table.txt | 204 ---- .../static/js/core/_frag_escape.txt | 6 - .../static/js/core/_frag_format.txt | 95 -- .../static/js/core/_frag_modal.txt | 956 ------------------ .../static/js/core/_frag_pagination.txt | 65 -- .../static/js/core/_frag_table.txt | 154 --- .../static/js/search/_frag_filter.txt | 164 --- .../static/js/search/_frag_search.txt | 96 -- .../static/js/upload/_blake3_class.txt | 169 ---- .../sds_gateway/static/js/upload/_cap_sel.txt | 183 ---- .../static/js/upload/_file_upload_handler.txt | 221 ---- .../static/js/upload/_files_modal_class.txt | 550 ---------- .../static/js/upload/_files_page_init.txt | 233 ----- .../static/js/upload/_files_upload_dom.txt | 29 - 14 files changed, 3125 deletions(-) delete mode 100644 gateway/sds_gateway/static/js/captures/_frag_captures_table.txt delete mode 100644 gateway/sds_gateway/static/js/core/_frag_escape.txt delete mode 100644 gateway/sds_gateway/static/js/core/_frag_format.txt delete mode 100644 gateway/sds_gateway/static/js/core/_frag_modal.txt delete mode 100644 gateway/sds_gateway/static/js/core/_frag_pagination.txt delete mode 100644 gateway/sds_gateway/static/js/core/_frag_table.txt delete mode 100644 gateway/sds_gateway/static/js/search/_frag_filter.txt delete mode 100644 gateway/sds_gateway/static/js/search/_frag_search.txt delete mode 100644 gateway/sds_gateway/static/js/upload/_blake3_class.txt delete mode 100644 gateway/sds_gateway/static/js/upload/_cap_sel.txt delete mode 100644 gateway/sds_gateway/static/js/upload/_file_upload_handler.txt delete mode 100644 gateway/sds_gateway/static/js/upload/_files_modal_class.txt delete mode 100644 gateway/sds_gateway/static/js/upload/_files_page_init.txt delete mode 100644 gateway/sds_gateway/static/js/upload/_files_upload_dom.txt diff --git a/gateway/sds_gateway/static/js/captures/_frag_captures_table.txt b/gateway/sds_gateway/static/js/captures/_frag_captures_table.txt deleted file mode 100644 index e703a860e..000000000 --- a/gateway/sds_gateway/static/js/captures/_frag_captures_table.txt +++ /dev/null @@ -1,204 +0,0 @@ -class CapturesTableManager extends TableManager { - constructor(config) { - super(config); - this.modalHandler = config.modalHandler; - this.tableContainerSelector = config.tableContainerSelector; - this.eventDelegationHandler = null; - this.initializeEventDelegation(); - } - - /** - * Initialize event delegation for better performance and memory management - */ - initializeEventDelegation() { - // Remove existing handler if it exists - if (this.eventDelegationHandler) { - document.removeEventListener("click", this.eventDelegationHandler); - } - - // Create single persistent event handler using delegation - this.eventDelegationHandler = (e) => { - // Ignore Bootstrap dropdown toggles - if ( - e.target.matches('[data-bs-toggle="dropdown"]') || - e.target.closest('[data-bs-toggle="dropdown"]') - ) { - return; - } - - // Handle capture details button clicks from actions dropdown - if ( - e.target.matches(".capture-details-btn") || - e.target.closest(".capture-details-btn") - ) { - e.preventDefault(); - const button = e.target.matches(".capture-details-btn") - ? e.target - : e.target.closest(".capture-details-btn"); - this.openCaptureModal(button); - return; - } - - // Handle capture link clicks - if ( - e.target.matches(".capture-link") || - e.target.closest(".capture-link") - ) { - e.preventDefault(); - const link = e.target.matches(".capture-link") - ? e.target - : e.target.closest(".capture-link"); - this.openCaptureModal(link); - return; - } - - // Handle view button clicks - if ( - e.target.matches(".view-capture-btn") || - e.target.closest(".view-capture-btn") - ) { - e.preventDefault(); - const button = e.target.matches(".view-capture-btn") - ? e.target - : e.target.closest(".view-capture-btn"); - this.openCaptureModal(button); - return; - } - }; - - // Add the persistent event listener - document.addEventListener("click", this.eventDelegationHandler); - } - - renderRow(capture, index) { - // Sanitize all data before rendering - const safeData = { - uuid: ComponentUtils.escapeHtml(capture.uuid || ""), - channel: ComponentUtils.escapeHtml(capture.channel || ""), - scanGroup: ComponentUtils.escapeHtml(capture.scan_group || ""), - captureType: ComponentUtils.escapeHtml(capture.capture_type || ""), - captureTypeDisplay: ComponentUtils.escapeHtml( - capture.capture_type_display || "", - ), - topLevelDir: ComponentUtils.escapeHtml(capture.top_level_dir || ""), - indexName: ComponentUtils.escapeHtml(capture.index_name || ""), - owner: ComponentUtils.escapeHtml(capture.owner || ""), - origin: ComponentUtils.escapeHtml(capture.origin || ""), - dataset: ComponentUtils.escapeHtml(capture.dataset || ""), - createdAt: ComponentUtils.escapeHtml(capture.created_at || ""), - updatedAt: ComponentUtils.escapeHtml(capture.updated_at || ""), - isPublic: ComponentUtils.escapeHtml(capture.is_public || ""), - isDeleted: ComponentUtils.escapeHtml(capture.is_deleted || ""), - centerFrequencyGhz: ComponentUtils.escapeHtml( - capture.center_frequency_ghz || "", - ), - }; - - // Handle composite vs single capture display - let channelDisplay = safeData.channel; - let typeDisplay = safeData.captureTypeDisplay || safeData.captureType; - - if (capture.is_multi_channel) { - // For composite captures, show all channels - if (capture.channels && Array.isArray(capture.channels)) { - channelDisplay = capture.channels - .map((ch) => ComponentUtils.escapeHtml(ch.channel || ch)) - .join(", "); - } - // Use capture_type_display if available, otherwise fall back to captureType - typeDisplay = capture.capture_type_display || safeData.captureType; - } - - return ` - - - - ${safeData.uuid} - - - ${channelDisplay} - - ${ComponentUtils.formatDateForModal(capture.capture?.created_at || capture.created_at)} - - ${typeDisplay} - ${capture.files_count || "0"} - ${capture.center_frequency_ghz ? `${capture.center_frequency_ghz.toFixed(3)} GHz` : "-"} - ${capture.sample_rate_mhz ? `${capture.sample_rate_mhz.toFixed(1)} MHz` : "-"} - - `; - } - - /** - * Attach row click handlers - now uses event delegation - */ - attachRowClickHandlers() { - // Event delegation is handled in initializeEventDelegation() - // This method is kept for compatibility but doesn't need to do anything - } - - /** - * Open capture modal with XSS protection - */ - openCaptureModal(linkElement) { - if (this.modalHandler) { - this.modalHandler.openCaptureModal(linkElement); - } - } - - /** - * Get CSRF token for API requests - */ - getCSRFToken() { - const token = document.querySelector("[name=csrfmiddlewaretoken]"); - return token ? token.value : ""; - } - - /** - * Open a custom modal - */ - openCustomModal(modalId) { - const modal = document.getElementById(modalId); - if (modal) { - modal.style.display = "block"; - document.body.style.overflow = "hidden"; - } - } - - /** - * Close a custom modal - */ - closeCustomModal(modalId) { - const modal = document.getElementById(modalId); - if (modal) { - modal.style.display = "none"; - document.body.style.overflow = "auto"; - } - } - - /** - * Cleanup method for proper resource management - */ - destroy() { - if (this.eventDelegationHandler) { - document.removeEventListener("click", this.eventDelegationHandler); - this.eventDelegationHandler = null; - } - } -} diff --git a/gateway/sds_gateway/static/js/core/_frag_escape.txt b/gateway/sds_gateway/static/js/core/_frag_escape.txt deleted file mode 100644 index d13b72e37..000000000 --- a/gateway/sds_gateway/static/js/core/_frag_escape.txt +++ /dev/null @@ -1,6 +0,0 @@ - escapeHtml(text) { - if (!text) return ""; - const div = document.createElement("div"); - div.textContent = text; - return div.innerHTML; - }, diff --git a/gateway/sds_gateway/static/js/core/_frag_format.txt b/gateway/sds_gateway/static/js/core/_frag_format.txt deleted file mode 100644 index 5cf251a77..000000000 --- a/gateway/sds_gateway/static/js/core/_frag_format.txt +++ /dev/null @@ -1,95 +0,0 @@ - formatDate(dateString) { - if (!dateString) return "
    -
    "; - - let date; - - // Try to parse the date string - if (typeof dateString === "string") { - // Handle different date formats - if (dateString.includes("T")) { - // ISO format: 2023-12-25T14:30:45.123Z - date = new Date(dateString); - } else if (dateString.includes("/") && dateString.includes(":")) { - // Already formatted: 12/25/2023 2:30:45 PM - date = new Date(dateString); - } else { - // Try to parse as-is - date = new Date(dateString); - } - } else { - date = new Date(dateString); - } - - if (!date || Number.isNaN(date.getTime())) { - return "
    -
    "; - } - - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - const year = date.getFullYear(); - const hours = date.getHours(); - const minutes = String(date.getMinutes()).padStart(2, "0"); - const seconds = String(date.getSeconds()).padStart(2, "0"); - const ampm = hours >= 12 ? "PM" : "AM"; - const displayHours = hours % 12 || 12; - - return `
    ${month}/${day}/${year}
    ${displayHours}:${minutes}:${seconds} ${ampm}`; - }, - - /** - * Format date for modal display in the same style as dataset table - * @param {string} dateString - ISO date string - * @returns {string} Formatted date HTML - */ - formatDateForModal(dateString) { - if (!dateString || dateString === "None") { - return "N/A"; - } - - try { - const date = new Date(dateString); - if (Number.isNaN(date.getTime())) { - return "N/A"; - } - - // Format date as YYYY-MM-DD - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - const dateFormatted = `${year}-${month}-${day}`; - - // Format time as HH:MM:SS T - const hours = String(date.getHours()).padStart(2, "0"); - const minutes = String(date.getMinutes()).padStart(2, "0"); - const seconds = String(date.getSeconds()).padStart(2, "0"); - const timezone = date - .toLocaleTimeString("en-US", { timeZoneName: "short" }) - .split(" ")[1]; - const timeFormatted = `${hours}:${minutes}:${seconds} ${timezone}`; - - return `${dateFormatted}${timeFormatted}`; - } catch (error) { - console.error("Error formatting capture date:", error); - return "N/A"; - } - }, - - /** - * Formats date for display (simple version) - * @param {string} dateString - ISO date string - * @returns {string} Formatted date - */ - formatDateSimple(dateString) { - try { - const date = new Date(dateString); - return date.toString() !== "Invalid Date" - ? date.toLocaleDateString("en-US", { - month: "2-digit", - day: "2-digit", - year: "numeric", - }) - : ""; - } catch (e) { - return ""; - } - }, diff --git a/gateway/sds_gateway/static/js/core/_frag_modal.txt b/gateway/sds_gateway/static/js/core/_frag_modal.txt deleted file mode 100644 index f20f280fa..000000000 --- a/gateway/sds_gateway/static/js/core/_frag_modal.txt +++ /dev/null @@ -1,956 +0,0 @@ -class ModalManager { - constructor(config) { - this.modalId = config.modalId; - this.modal = document.getElementById(this.modalId); - this.modalTitle = this.modal?.querySelector(".modal-title"); - this.modalBody = this.modal?.querySelector(".modal-body"); - - if (this.modal && window.bootstrap) { - this.bootstrapModal = new bootstrap.Modal(this.modal); - } - } - - show(title, content) { - if (!this.modal) return; - - if (this.modalTitle) { - this.modalTitle.textContent = title; - } - - if (this.modalBody) { - this.modalBody.innerHTML = content; - } - - if (this.bootstrapModal) { - this.bootstrapModal.show(); - } - } - - hide() { - if (this.bootstrapModal) { - this.bootstrapModal.hide(); - } - } - - openCaptureModal(linkElement) { - if (!linkElement) return; - - try { - // Reset visualize button to hidden state - const visualizeBtn = document.getElementById("visualize-btn"); - if (visualizeBtn) { - visualizeBtn.classList.add("d-none"); - } - - // Get all data attributes from the link with sanitization - const data = { - uuid: ComponentUtils.escapeHtml( - linkElement.getAttribute("data-uuid") || "", - ), - name: ComponentUtils.escapeHtml( - linkElement.getAttribute("data-name") || "", - ), - channel: ComponentUtils.escapeHtml( - linkElement.getAttribute("data-channel") || "", - ), - scanGroup: ComponentUtils.escapeHtml( - linkElement.getAttribute("data-scan-group") || "", - ), - captureType: ComponentUtils.escapeHtml( - linkElement.getAttribute("data-capture-type") || "", - ), - topLevelDir: ComponentUtils.escapeHtml( - linkElement.getAttribute("data-top-level-dir") || "", - ), - owner: ComponentUtils.escapeHtml( - linkElement.getAttribute("data-owner") || "", - ), - origin: ComponentUtils.escapeHtml( - linkElement.getAttribute("data-origin") || "", - ), - dataset: ComponentUtils.escapeHtml( - linkElement.getAttribute("data-dataset") || "", - ), - createdAt: linkElement.getAttribute("data-created-at") || "", - updatedAt: linkElement.getAttribute("data-updated-at") || "", - isPublic: linkElement.getAttribute("data-is-public") || "", - centerFrequencyGhz: - linkElement.getAttribute("data-center-frequency-ghz") || "", - isMultiChannel: linkElement.getAttribute("data-is-multi-channel") || "", - channels: linkElement.getAttribute("data-channels") || "", - }; - - // Parse owner field safely - const ownerDisplay = data.owner - ? data.owner.split("'").find((part) => part.includes("@")) || "N/A" - : "N/A"; - - // Check if this is a composite capture - const isComposite = - data.isMultiChannel === "True" || data.isMultiChannel === "true"; - - let modalContent = ` -
    -
    -
    - Basic Information -
    -
    -
    - -
    - - - - -
    -
    Click the edit button to modify the capture name
    -
    -
    -
    -

    - Capture Type: - ${data.captureType || "N/A"} -

    -

    - Origin: - ${data.origin || "N/A"} -

    -
    -
    -

    - Owner: - ${ownerDisplay} -

    -
    -
    - `; - - // Handle composite vs single capture display - if (isComposite) { - modalContent += ` -
    - Channels: - ${data.channel || "N/A"} -
    - `; - } else { - modalContent += ` -
    - Channel: - ${data.channel || "N/A"} -
    - `; - } - - modalContent += ` -
    -
    -
    -
    - Technical Details -
    -
    -
    -
    -

    - Scan Group: - ${data.scanGroup || "N/A"} -

    -

    - Dataset: - ${data.dataset || "N/A"} -

    -

    - Is Public: - ${data.isPublic === "True" ? "Yes" : "No"} -

    -
    -
    -

    - Top Level Directory: - ${data.topLevelDir || "N/A"} -

    -

    - Center Frequency: - - ${data.centerFrequencyGhz && data.centerFrequencyGhz !== "None" ? `${Number.parseFloat(data.centerFrequencyGhz).toFixed(3)} GHz` : "N/A"} - -

    -
    -
    -
    -
    -
    -
    - Timestamps -
    -
    -
    -
    -

    - Created At: -
    - - ${ComponentUtils.formatDateForModal(data.createdAt)} - -

    -
    -
    -

    - Updated At: -
    - - ${ComponentUtils.formatDateForModal(data.updatedAt)} - -

    -
    -
    -
    - -
    -
    -
    - Loading files... -
    - Loading files... -
    -
    - `; - - // Add composite-specific information if available - if (isComposite && data.channels) { - try { - // Convert Python dict syntax to valid JSON - let channelsData; - if (typeof data.channels === "string") { - // Handle Python dict syntax: {'key': 'value'} -> {"key": "value"} - const pythonDict = data.channels - .replace(/'/g, '"') // Replace single quotes with double quotes - .replace(/True/g, "true") // Replace Python True with JSON true - .replace(/False/g, "false") // Replace Python False with JSON false - .replace(/None/g, "null"); // Replace Python None with JSON null - - channelsData = JSON.parse(pythonDict); - } else { - channelsData = data.channels; - } - - if (Array.isArray(channelsData) && channelsData.length > 0) { - modalContent += ` -
    -
    Channel Details
    -
    - `; - - for (let i = 0; i < channelsData.length; i++) { - const channel = channelsData[i]; - const channelId = `channel-${i}`; - - // Format channel metadata as key-value pairs - let metadataDisplay = "N/A"; - if ( - channel.channel_metadata && - typeof channel.channel_metadata === "object" - ) { - const metadata = channel.channel_metadata; - const metadataItems = []; - - // Helper function to format values dynamically - const formatValue = (value, fieldName = "") => { - if (value === null || value === undefined) { - return "N/A"; - } - - if (typeof value === "boolean") { - return value ? "Yes" : "No"; - } - - // Handle string representations of booleans - if (typeof value === "string") { - if (value.toLowerCase() === "true") { - return "Yes"; - } - if (value.toLowerCase() === "false") { - return "No"; - } - } - - if (typeof value === "number") { - const absValue = Math.abs(value); - const valueStr = value.toString(); - const timeIndicators = [ - "computer_time", - "start_bound", - "end_bound", - "init_utc_timestamp", - ]; - // Only format as timestamp if the field name contains "time" - if ( - timeIndicators.includes(fieldName.toLowerCase()) && - valueStr.length >= 10 && - valueStr.length <= 13 - ) { - // Convert to milliseconds if it's in seconds - const timestamp = - valueStr.length === 10 ? value * 1000 : value; - return new Date(timestamp).toLocaleString(); - } - - // Only format for Giga (1e9) and Mega (1e6) ranges - if (absValue >= 1e9) { - return `${(value / 1e9).toFixed(3)} GHz`; - } - if (absValue >= 1e6) { - return `${(value / 1e6).toFixed(1)} MHz`; - } - return value.toString(); - } - - if (Array.isArray(value)) { - return value - .map((item) => formatValue(item, fieldName)) - .join(", "); - } - - if (typeof value === "object") { - return JSON.stringify(value); - } - - return String(value); - }; - - // Helper function to format field names - const formatFieldName = (fieldName) => { - return fieldName - .replace(/_/g, " ") - .replace(/\b\w/g, (l) => l.toUpperCase()); - }; - - // Loop through all metadata fields - if (Object.keys(metadata).length > 0) { - for (const [key, value] of Object.entries(metadata)) { - if (value !== undefined && value !== null) { - const formattedValue = formatValue(value, key); - const formattedKey = formatFieldName(key); - metadataItems.push( - `${formattedKey}: ${formattedValue}`, - ); - } - } - } else { - metadataItems.push("No metadata available"); - } - - if (metadataItems.length > 0) { - metadataDisplay = metadataItems.join("
    "); - } - } - - modalContent += ` -
    -

    - -

    -
    -
    -
    - ${metadataDisplay} -
    -
    -
    -
    - `; - } - - modalContent += ` -
    -
    - `; - } - } catch (e) { - console.error("Could not parse channels data for modal:", e); - console.error( - "Raw channels data that failed to parse:", - data.channels, - ); - - // Show a fallback message in the modal - modalContent += ` -
    -
    Channel Details
    -
    - - Unable to display channel details due to data format issues. -
    Raw data: ${ComponentUtils.escapeHtml(String(data.channels).substring(0, 100))}... -
    -
    - `; - } - } - - const title = data.name - ? data.name - : data.topLevelDir || "Unnamed Capture"; - this.show(title, modalContent); - - // Store capture data for later use - this.currentCaptureData = data; - - // Setup name editing handlers after modal content is loaded - this.setupNameEditingHandlers(); - - // Setup visualize button for Digital RF captures - this.setupVisualizeButton(data); - - // Load and display files for this capture - this.loadCaptureFiles(data.uuid); - } catch (error) { - console.error("Error opening capture modal:", error); - this.show("Error", "Error displaying capture details"); - } - } - - /** - * Setup visualize button for Digital RF captures - */ - setupVisualizeButton(captureData) { - const visualizeBtn = document.getElementById("visualize-btn"); - if (!visualizeBtn) return; - - // Show button only for Digital RF captures - if (captureData.captureType === "drf") { - visualizeBtn.classList.remove("d-none"); - - // Set up click handler to open visualization modal - visualizeBtn.onclick = () => { - // Use the VisualizationModal instance to open with capture data - if (window.visualizationModalInstance) { - window.visualizationModalInstance.openWithCaptureData( - captureData.uuid, - captureData.captureType, - ); - } - }; - } else { - visualizeBtn.classList.add("d-none"); - } - } - - /** - * Setup handlers for name editing functionality - */ - setupNameEditingHandlers() { - const nameInput = document.getElementById("capture-name-input"); - const editBtn = document.getElementById("edit-name-btn"); - const saveBtn = document.getElementById("save-name-btn"); - const cancelBtn = document.getElementById("cancel-name-btn"); - - if (!nameInput || !editBtn || !saveBtn || !cancelBtn) return; - - // Initially disable the input - nameInput.disabled = true; - let originalName = nameInput.value; - let isEditing = false; - - const startEditing = () => { - nameInput.disabled = false; - nameInput.focus(); - nameInput.select(); - editBtn.classList.add("d-none"); - saveBtn.classList.remove("d-none"); - cancelBtn.classList.remove("d-none"); - isEditing = true; - }; - - const stopEditing = () => { - nameInput.disabled = true; - editBtn.classList.remove("d-none"); - saveBtn.classList.add("d-none"); - cancelBtn.classList.add("d-none"); - isEditing = false; - }; - - const cancelEditing = () => { - nameInput.value = originalName; - stopEditing(); - }; - - // Edit button handler - editBtn.addEventListener("click", () => { - if (!isEditing) { - startEditing(); - } - }); - - // Cancel button handler - cancelBtn.addEventListener("click", cancelEditing); - - // Save button handler - saveBtn.addEventListener("click", async () => { - const newName = nameInput.value.trim(); - const uuid = nameInput.getAttribute("data-uuid"); - - if (!uuid) { - console.error("No UUID found for capture"); - return; - } - - // Disable buttons during save - editBtn.disabled = true; - saveBtn.disabled = true; - cancelBtn.disabled = true; - saveBtn.innerHTML = - ''; - - try { - await this.updateCaptureName(uuid, newName); - - // Success - update UI - originalName = newName; - stopEditing(); - - // Update the table display - this.updateTableNameDisplay(uuid, newName); - - // Update modal title using stored capture data - if (this.modalTitle && this.currentCaptureData) { - this.currentCaptureData.name = newName; - this.modalTitle.textContent = - newName || this.currentCaptureData.topLevelDir || "Unnamed Capture"; - } - - // Show success message - this.showSuccessMessage("Capture name updated successfully!"); - } catch (error) { - console.error("Error updating capture name:", error); - this.showErrorMessage( - "Failed to update capture name. Please try again.", - ); - // Revert to original name - nameInput.value = originalName; - } finally { - // Re-enable buttons and restore icons - editBtn.disabled = false; - saveBtn.disabled = false; - cancelBtn.disabled = false; - saveBtn.innerHTML = ''; - } - }); - - // Handle Enter key to save - nameInput.addEventListener("keypress", (e) => { - if (e.key === "Enter" && !nameInput.disabled) { - saveBtn.click(); - } - }); - - // Handle Escape key to cancel - nameInput.addEventListener("keydown", (e) => { - if (e.key === "Escape" && !nameInput.disabled) { - cancelEditing(); - } - }); - } - - /** - * Update capture name via API - */ - async updateCaptureName(uuid, newName) { - const response = await fetch(`/api/v1/assets/captures/${uuid}/`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - "X-CSRFToken": this.getCSRFToken(), - }, - body: JSON.stringify({ name: newName }), - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.detail || "Failed to update capture name"); - } - - return response.json(); - } - - /** - * Update the table display with the new name - */ - updateTableNameDisplay(uuid, newName) { - // Find all elements with this UUID and update their display - const captureLinks = document.querySelectorAll(`[data-uuid="${uuid}"]`); - - for (const link of captureLinks) { - // Update data attribute - link.dataset.name = newName; - - // Update display text if it's a capture link - if (link.classList.contains("capture-link")) { - link.textContent = newName || "Unnamed Capture"; - link.setAttribute( - "aria-label", - `View details for capture ${newName || uuid}`, - ); - link.setAttribute("title", `View capture details: ${newName || uuid}`); - } - } - } - - /** - * Clear existing alert messages from the modal - */ - clearAlerts() { - const modalBody = document.getElementById("capture-modal-body"); - if (modalBody) { - const existingAlerts = modalBody.querySelectorAll(".alert"); - for (const alert of existingAlerts) { - alert.remove(); - } - } - } - - /** - * Show success message - */ - showSuccessMessage(message) { - // Clear existing alerts first - this.clearAlerts(); - - // Create a temporary alert - const alert = document.createElement("div"); - alert.className = "alert alert-success alert-dismissible fade show"; - alert.innerHTML = ` - ${message} - - `; - - // Insert at the top of the modal body - const modalBody = document.getElementById("capture-modal-body"); - if (modalBody) { - modalBody.insertBefore(alert, modalBody.firstChild); - - // Auto-dismiss after 3 seconds - setTimeout(() => { - if (alert.parentNode) { - alert.remove(); - } - }, 3000); - } - } - - /** - * Show error message - */ - showErrorMessage(message) { - // Clear existing alerts first - this.clearAlerts(); - - // Create a temporary alert - const alert = document.createElement("div"); - alert.className = "alert alert-danger alert-dismissible fade show"; - alert.innerHTML = ` - ${message} - - `; - - // Insert at the top of the modal body - const modalBody = document.getElementById("capture-modal-body"); - if (modalBody) { - modalBody.insertBefore(alert, modalBody.firstChild); - - // Auto-dismiss after 5 seconds - setTimeout(() => { - if (alert.parentNode) { - alert.remove(); - } - }, 5000); - } - } - - /** - * Load and display files associated with the capture - */ - async loadCaptureFiles(captureUuid) { - try { - const response = await fetch(`/api/v1/assets/captures/${captureUuid}/`, { - method: "GET", - headers: { - "Content-Type": "application/json", - "X-CSRFToken": this.getCSRFToken(), - }, - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const captureData = await response.json(); - console.log("Raw capture data:", captureData); - - const files = captureData.files || []; - const filesCount = captureData.files_count || 0; - const totalSize = captureData.total_file_size || 0; - - console.log("Files info:", { - filesCount, - totalSize, - numFiles: files.length, - }); - - // Update files section with simple summary - const filesSection = document.getElementById("files-section-placeholder"); - if (filesSection) { - filesSection.innerHTML = ` -
    -
    -
    - Files Summary -
    -
    -
    -

    - Number of Files: - ${filesCount} -

    -
    -
    -

    - Total Size: - ${window.DOMUtils.formatFileSize(totalSize)} -

    -
    -
    - `; - } - } catch (error) { - console.error("Error loading capture files:", error); - const filesSection = document.getElementById("files-section-placeholder"); - if (filesSection) { - filesSection.innerHTML = ` -
    - - Error loading files information -
    - `; - } - } - } - - /** - * Format file metadata for display - */ - formatFileMetadata(file) { - const metadata = []; - - // Primary file information - most useful for users - if (file.size) { - metadata.push( - `Size: ${window.DOMUtils.formatFileSize(file.size)} (${file.size.toLocaleString()} bytes)`, - ); - } - - if (file.media_type) { - metadata.push( - `Media Type: ${ComponentUtils.escapeHtml(file.media_type)}`, - ); - } - - if (file.created_at) { - metadata.push(`Created: ${file.created_at}`); - } - - if (file.updated_at) { - metadata.push(`Updated: ${file.updated_at}`); - } - - // File properties and attributes - if (file.name) { - metadata.push( - `Name: ${ComponentUtils.escapeHtml(file.name)}`, - ); - } - - if (file.directory || file.relative_path) { - metadata.push( - `Directory: ${ComponentUtils.escapeHtml(file.directory || file.relative_path)}`, - ); - } - - // Removed permissions display - // if (file.permissions) { - // metadata.push(`Permissions: ${ComponentUtils.escapeHtml(file.permissions)}`); - // } - - if (file.owner?.username) { - metadata.push( - `Owner: ${ComponentUtils.escapeHtml(file.owner.username)}`, - ); - } - - if (file.expiration_date) { - metadata.push( - `Expires: ${new Date(file.expiration_date).toLocaleDateString()}`, - ); - } - - if (file.bucket_name) { - metadata.push( - `Storage Bucket: ${ComponentUtils.escapeHtml(file.bucket_name)}`, - ); - } - - // Removed checksum display - // if (file.sum_blake3) { - // metadata.push(`Checksum: ${ComponentUtils.escapeHtml(file.sum_blake3)}`); - // } - - // Associated resources - // TODO: Refactor this to handle multiple associations - if (file.capture?.name) { - metadata.push( - `Associated Capture: ${ComponentUtils.escapeHtml(file.capture.name)}`, - ); - } - - if (file.dataset?.name) { - metadata.push( - `Associated Dataset: ${ComponentUtils.escapeHtml(file.dataset.name)}`, - ); - } - - // Additional metadata if available - if (file.metadata && typeof file.metadata === "object") { - for (const [key, value] of Object.entries(file.metadata)) { - if (value !== null && value !== undefined) { - const formattedKey = key - .replace(/_/g, " ") - .replace(/\b\w/g, (l) => l.toUpperCase()); - let formattedValue; - - // Format different types of values - if (typeof value === "boolean") { - formattedValue = value ? "Yes" : "No"; - } else if (typeof value === "number") { - formattedValue = value.toLocaleString(); - } else if (typeof value === "object") { - formattedValue = `${JSON.stringify(value, null, 2)}`; - } else { - formattedValue = ComponentUtils.escapeHtml(String(value)); - } - - metadata.push(`${formattedKey}: ${formattedValue}`); - } - } - } - - if (metadata.length === 0) { - return '

    No metadata available for this file.

    '; - } - - return ``; - } - - /** - * Get CSRF token for API requests - */ - getCSRFToken() { - const token = document.querySelector("[name=csrfmiddlewaretoken]"); - return token ? token.value : ""; - } - - /** - * Load and display file metadata for a specific file in the modal - */ - async loadFileMetadata(fileUuid, fileName) { - const fileMetadataSection = document.getElementById( - `file-metadata-${fileUuid}`, - ); - const metadataContent = - fileMetadataSection?.querySelector(".metadata-content"); - - if (!fileMetadataSection || !metadataContent) return; - - // Toggle visibility - if (fileMetadataSection.style.display === "none") { - fileMetadataSection.style.display = "block"; - - // Check if metadata is already loaded - if (metadataContent.innerHTML.includes("Click to load metadata...")) { - // Show loading state - metadataContent.innerHTML = ` -
    -
    - Loading... -
    - Loading metadata... -
    - `; - - try { - const response = await fetch(`/api/v1/assets/files/${fileUuid}/`, { - method: "GET", - headers: { - "Content-Type": "application/json", - "X-CSRFToken": this.getCSRFToken(), - }, - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const fileData = await response.json(); - - // Format and display the metadata - const formattedMetadata = this.formatFileMetadata(fileData); - metadataContent.innerHTML = formattedMetadata; - } catch (error) { - console.error("Error loading file metadata:", error); - metadataContent.innerHTML = ` -
    - - Failed to load metadata for ${ComponentUtils.escapeHtml(fileName)}. -
    Error: ${ComponentUtils.escapeHtml(error.message)} -
    - `; - } - } - } else { - fileMetadataSection.style.display = "none"; - } - } -} diff --git a/gateway/sds_gateway/static/js/core/_frag_pagination.txt b/gateway/sds_gateway/static/js/core/_frag_pagination.txt deleted file mode 100644 index a29e8775d..000000000 --- a/gateway/sds_gateway/static/js/core/_frag_pagination.txt +++ /dev/null @@ -1,65 +0,0 @@ -class PaginationManager { - constructor(config) { - this.containerId = config.containerId; - this.container = document.getElementById(this.containerId); - this.onPageChange = config.onPageChange; - } - - update(pagination) { - if (!this.container || !pagination) return; - - this.container.innerHTML = ""; - - if (pagination.num_pages <= 1) return; - - const ul = document.createElement("ul"); - ul.className = "pagination justify-content-center"; - - // Previous button - if (pagination.has_previous) { - ul.innerHTML += ` -
  • - - - -
  • - `; - } - - // Page numbers - const startPage = Math.max(1, pagination.number - 2); - const endPage = Math.min(pagination.num_pages, pagination.number + 2); - - for (let i = startPage; i <= endPage; i++) { - ul.innerHTML += ` -
  • - ${i} -
  • - `; - } - - // Next button - if (pagination.has_next) { - ul.innerHTML += ` -
  • - - - -
  • - `; - } - - this.container.appendChild(ul); - - // Add click handlers - const links = ul.querySelectorAll("a.page-link"); - for (const link of links) { - link.addEventListener("click", (e) => { - e.preventDefault(); - const page = Number.parseInt(e.target.dataset.page); - if (page && this.onPageChange) { - this.onPageChange(page); - } - }); - } - } diff --git a/gateway/sds_gateway/static/js/core/_frag_table.txt b/gateway/sds_gateway/static/js/core/_frag_table.txt deleted file mode 100644 index b8405aa6b..000000000 --- a/gateway/sds_gateway/static/js/core/_frag_table.txt +++ /dev/null @@ -1,154 +0,0 @@ -class TableManager { - constructor(config) { - this.tableId = config.tableId; - this.table = document.getElementById(this.tableId); - this.tbody = this.table?.querySelector("tbody"); - this.loadingIndicator = document.getElementById(config.loadingIndicatorId); - this.paginationContainer = document.getElementById( - config.paginationContainerId, - ); - this.currentSort = { by: "created_at", order: "desc" }; - this.onRowClick = config.onRowClick; - - this.initializeSorting(); - } - - initializeSorting() { - if (!this.table) return; - - const sortableHeaders = this.table.querySelectorAll("th.sortable"); - for (const header of sortableHeaders) { - header.style.cursor = "pointer"; - header.addEventListener("click", () => { - this.handleSort(header); - }); - } - - this.updateSortIcons(); - } - - handleSort(header) { - const field = header.getAttribute("data-sort"); - const currentSort = new URLSearchParams(window.location.search).get( - "sort_by", - ); - const currentOrder = - new URLSearchParams(window.location.search).get("sort_order") || "desc"; - - let newOrder = "asc"; - if (currentSort === field && currentOrder === "asc") { - newOrder = "desc"; - } - - this.currentSort = { by: field, order: newOrder }; - this.updateURL({ sort_by: field, sort_order: newOrder, page: "1" }); - } - - updateSortIcons() { - const urlParams = new URLSearchParams(window.location.search); - const currentSort = urlParams.get("sort_by") || "created_at"; - const currentOrder = urlParams.get("sort_order") || "desc"; - - const sortableHeaders = this.table?.querySelectorAll("th.sortable"); - for (const header of sortableHeaders || []) { - const icon = header.querySelector(".sort-icon"); - const field = header.getAttribute("data-sort"); - - if (icon) { - // Reset classes - icon.className = "bi sort-icon"; - - if (field === currentSort) { - // Add active class and appropriate direction icon - icon.classList.add("active"); - icon.classList.add( - currentOrder === "asc" ? "bi-caret-up-fill" : "bi-caret-down-fill", - ); - } else { - // Inactive columns get default down arrow - icon.classList.add("bi-caret-down-fill"); - } - } - } - } - - showLoading() { - if (this.loadingIndicator) { - this.loadingIndicator.classList.remove("d-none"); - } - } - - hideLoading() { - if (this.loadingIndicator) { - this.loadingIndicator.classList.add("d-none"); - } - } - - showError(message) { - const tbody = document.querySelector("tbody"); - if (tbody) { - tbody.innerHTML = ` - - - ${ComponentUtils.escapeHtml(message)} -
    Try refreshing the page or contact support if the problem persists. - - - `; - } - } - - updateTable(data, hasResults) { - if (!this.tbody) return; - - if (!hasResults || !data || data.length === 0) { - this.tbody.innerHTML = ` - - No results found. - - `; - return; - } - - this.tbody.innerHTML = data - .map((item, index) => this.renderRow(item, index)) - .join(""); - this.attachRowClickHandlers(); - } - - renderRow(item, index) { - // This should be overridden by specific implementations - return `Override renderRow method`; - } - - attachRowClickHandlers() { - if (!this.onRowClick) return; - - const rows = this.tbody?.querySelectorAll('tr[data-clickable="true"]'); - for (const row of rows || []) { - row.addEventListener("click", (e) => { - if ( - e.target.closest( - "button, a, .capture-select-checkbox, .capture-select-column", - ) - ) - return; // Don't trigger on buttons/links/selection - this.onRowClick(row); - }); - } - } - - updateURL(params) { - const urlParams = new URLSearchParams(window.location.search); - for (const [key, value] of Object.entries(params)) { - if (value) { - urlParams.set(key, value); - } else { - urlParams.delete(key); - } - } - - const newUrl = `${window.location.pathname}?${urlParams.toString()}`; - window.history.pushState({}, "", newUrl); - } -} diff --git a/gateway/sds_gateway/static/js/search/_frag_filter.txt b/gateway/sds_gateway/static/js/search/_frag_filter.txt deleted file mode 100644 index 880e7e19e..000000000 --- a/gateway/sds_gateway/static/js/search/_frag_filter.txt +++ /dev/null @@ -1,164 +0,0 @@ -class FilterManager { - constructor(config) { - this.formId = config.formId; - this.form = document.getElementById(this.formId); - this.applyButton = document.getElementById(config.applyButtonId); - this.clearButton = document.getElementById(config.clearButtonId); - this.onFilterChange = config.onFilterChange; - this.searchInputId = config.searchInputId || "search-input"; - - this.initializeEventListeners(); - this.loadFromURL(); - } - - initializeEventListeners() { - if (this.applyButton) { - this.applyButton.addEventListener("click", (e) => { - e.preventDefault(); - this.applyFilters(); - }); - } - - if (this.clearButton) { - this.clearButton.addEventListener("click", (e) => { - e.preventDefault(); - this.clearFilters(); - }); - } - - // Auto-apply on form submission - if (this.form) { - this.form.addEventListener("submit", (e) => { - e.preventDefault(); - this.applyFilters(); - }); - } - } - - getFilterValues() { - if (!this.form) return {}; - - const formData = new FormData(this.form); - const filters = {}; - - for (const [key, value] of formData.entries()) { - if (value && value.trim() !== "") { - filters[key] = value.trim(); - } - } - - return filters; - } - - applyFilters() { - const filters = this.getFilterValues(); - this.updateURL(filters); - - if (this.onFilterChange) { - this.onFilterChange(filters); - } - } - - clearFilters() { - if (!this.form) return; - - // Get all form inputs except the search input - const inputs = this.form.querySelectorAll("input, select, textarea"); - for (const input of inputs) { - // Skip the search input - if (input.id === this.searchInputId) { - continue; - } - - // Clear other inputs - if (input.type === "checkbox" || input.type === "radio") { - input.checked = false; - } else { - input.value = ""; - } - } - - // Get current URL parameters - const urlParams = new URLSearchParams(window.location.search); - const searchValue = urlParams.get("search"); - const sortBy = urlParams.get("sort_by") || "created_at"; - const sortOrder = urlParams.get("sort_order") || "desc"; - - // Clear all parameters except search and sort - urlParams.forEach((_, key) => { - if (key !== "search" && key !== "sort_by" && key !== "sort_order") { - urlParams.delete(key); - } - }); - - // Ensure sort parameters are set - urlParams.set("sort_by", sortBy); - urlParams.set("sort_order", sortOrder); - urlParams.set("page", "1"); - - // Update URL - const newUrl = `${window.location.pathname}?${urlParams.toString()}`; - window.history.pushState({}, "", newUrl); - - // Trigger filter change callback - if (this.onFilterChange) { - const filters = { - sort_by: sortBy, - sort_order: sortOrder, - }; - if (searchValue) { - filters.search = searchValue; - } - this.onFilterChange(filters); - } - } - - loadFromURL() { - if (!this.form) return; - - const urlParams = new URLSearchParams(window.location.search); - const inputs = this.form.querySelectorAll("input, select, textarea"); - - for (const input of inputs) { - const value = urlParams.get(input.name); - if (value !== null) { - if (input.type === "checkbox" || input.type === "radio") { - input.checked = value === "true" || value === input.value; - } else { - input.value = value; - } - } - } - } - - updateURL(filters) { - const urlParams = new URLSearchParams(window.location.search); - - // Preserve search parameter if it exists - const searchValue = urlParams.get("search"); - - // Remove old filter parameters - const formData = new FormData(this.form || document.createElement("form")); - for (const key of formData.keys()) { - urlParams.delete(key); - } - - // Add new filter parameters - for (const [key, value] of Object.entries(filters)) { - if (value) { - urlParams.set(key, value); - } - } - - // Restore search parameter if it existed - if (searchValue) { - urlParams.set("search", searchValue); - } - - // Reset to first page when filters change - urlParams.set("page", "1"); - - const newUrl = `${window.location.pathname}?${urlParams.toString()}`; - window.history.pushState({}, "", newUrl); - } -} diff --git a/gateway/sds_gateway/static/js/search/_frag_search.txt b/gateway/sds_gateway/static/js/search/_frag_search.txt deleted file mode 100644 index fee873caf..000000000 --- a/gateway/sds_gateway/static/js/search/_frag_search.txt +++ /dev/null @@ -1,96 +0,0 @@ -class SearchManager { - constructor(config) { - this.searchInput = document.getElementById(config.searchInputId); - this.searchButton = document.getElementById(config.searchButtonId); - this.clearButton = document.getElementById("clear-search-btn"); - this.onSearch = config.onSearch; - this.onSearchStart = config.onSearchStart; - this.debounceDelay = config.debounceDelay || 500; - this.debounceTimer = null; - this.abortController = new AbortController(); - - this.initializeEventListeners(); - this.updateClearButtonVisibility(); - } - - initializeEventListeners() { - if (this.searchInput) { - this.searchInput.addEventListener("input", () => { - this.debounceSearch(); - this.updateClearButtonVisibility(); - }); - - this.searchInput.addEventListener("keypress", (e) => { - if (e.key === "Enter") { - e.preventDefault(); - this.debounceSearch(); - } - }); - } - - if (this.searchButton) { - this.searchButton.addEventListener("click", (e) => { - e.preventDefault(); - this.debounceSearch(); - }); - } - - if (this.clearButton) { - this.clearButton.addEventListener("click", (e) => { - e.preventDefault(); - this.clearSearch(); - }); - } - } - - updateClearButtonVisibility() { - if (this.clearButton) { - this.clearButton.style.display = this.searchInput?.value - ? "block" - : "none"; - } - } - - debounceSearch() { - // Show loading indicator immediately for visual confirmation - if (this.onSearchStart) { - this.onSearchStart(); - } - - if (this.debounceTimer) { - clearTimeout(this.debounceTimer); - } - - this.debounceTimer = setTimeout(() => { - this.performSearch(); - }, this.debounceDelay); - } - - performSearch() { - // Cancel any previous request and create a new abort controller - this.abortController.abort(); - this.abortController = new AbortController(); - - const query = this.searchInput?.value || ""; - - if (this.onSearch) { - this.onSearch(query, this.abortController.signal); - } - } - - clearSearch() { - if (this.searchInput) { - this.searchInput.value = ""; - this.updateClearButtonVisibility(); - } - - this.debounceSearch(); - } - - /** - * Get the current abort signal for fetch requests - */ - getAbortSignal() { - return this.abortController.signal; - } -} diff --git a/gateway/sds_gateway/static/js/upload/_blake3_class.txt b/gateway/sds_gateway/static/js/upload/_blake3_class.txt deleted file mode 100644 index 90c73ec4d..000000000 --- a/gateway/sds_gateway/static/js/upload/_blake3_class.txt +++ /dev/null @@ -1,169 +0,0 @@ -class Blake3FileHandler { - constructor() { - // Initialize global variables for file tracking - this.initializeGlobalVariables(); - this.setupEventListeners(); - } - - initializeGlobalVariables() { - // Global variables to track files that should be skipped - window.filesToSkip = new Set(); - window.fileCheckResults = new Map(); // Store detailed results for each file - } - - setupEventListeners() { - const modal = document.getElementById("uploadCaptureModal"); - if (!modal) { - console.warn("uploadCaptureModal not found"); - return; - } - - modal.addEventListener("shown.bs.modal", () => { - this.setupFileInputHandler(); - }); - } - - setupFileInputHandler() { - const fileInput = document.getElementById("captureFileInput"); - if (!fileInput) { - console.warn("captureFileInput not found"); - return; - } - - // Remove any previous handler to avoid duplicates - if (window._blake3CaptureHandler) { - fileInput.removeEventListener("change", window._blake3CaptureHandler); - } - - // Create file handler that stores selected files - window._blake3CaptureHandler = async (event) => { - await this.handleFileSelection(event); - }; - - fileInput.addEventListener("change", window._blake3CaptureHandler); - } - - async handleFileSelection(event) { - const files = event.target.files; - if (!files || files.length === 0) { - return; - } - - // Store the selected files for later processing - window.selectedFiles = Array.from(files); - - console.log(`Selected ${files.length} files for upload`); - } - - /** - * Calculate BLAKE3 hash for a file - * @param {File} file - The file to hash - * @returns {Promise} - The BLAKE3 hash in hex format - */ - async calculateBlake3Hash(file) { - try { - const buffer = await file.arrayBuffer(); - const hasher = await hashwasm.createBLAKE3(); - hasher.init(); - hasher.update(new Uint8Array(buffer)); - return hasher.digest("hex"); - } catch (error) { - console.error("Error calculating BLAKE3 hash:", error); - throw error; - } - } - - /** - * Get directory path from webkitRelativePath - * @param {File} file - The file to get directory for - * @returns {string} - The directory path - */ - getDirectoryPath(file) { - if (!file.webkitRelativePath) { - return "/"; - } - - const pathParts = file.webkitRelativePath.split("/"); - if (pathParts.length > 1) { - pathParts.pop(); // Remove filename - return `/${pathParts.join("/")}`; - } - - return "/"; - } - - /** - * Check if a file exists on the server - * @param {File} file - The file to check - * @param {string} hash - The BLAKE3 hash of the file - * @returns {Promise} - The server response - */ - async checkFileExists(file, hash) { - const directory = this.getDirectoryPath(file); - - const checkData = { - directory: directory, - filename: file.name, - checksum: hash, - }; - - try { - const response = await fetch(window.checkFileExistsUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-CSRFToken": window.csrfToken, - }, - body: JSON.stringify(checkData), - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - return await response.json(); - } catch (error) { - console.error("Error checking file existence:", error); - throw error; - } - } - - /** - * Process a single file for duplicate checking - * @param {File} file - The file to process - * @returns {Promise} - Processing result - */ - async processFileForDuplicateCheck(file) { - try { - // Calculate hash - const hash = await this.calculateBlake3Hash(file); - - // Check if file exists - const checkResult = await this.checkFileExists(file, hash); - - // Store results - const directory = this.getDirectoryPath(file); - const fileKey = `${directory}/${file.name}`; - - const result = { - file: file, - directory: directory, - filename: file.name, - checksum: hash, - data: checkResult.data, - }; - - window.fileCheckResults.set(fileKey, result); - - // Mark for skipping if file exists - if (checkResult.data && checkResult.data.file_exists_in_tree === true) { - window.filesToSkip.add(fileKey); - } - - return result; - } catch (error) { - console.error("Error processing file for duplicate check:", error); - return null; - } - } -} diff --git a/gateway/sds_gateway/static/js/upload/_cap_sel.txt b/gateway/sds_gateway/static/js/upload/_cap_sel.txt deleted file mode 100644 index a3b46356c..000000000 --- a/gateway/sds_gateway/static/js/upload/_cap_sel.txt +++ /dev/null @@ -1,183 +0,0 @@ -/** - * Capture Type Selection Handler - * Manages capture type dropdown and conditional form fields - */ -class CaptureTypeSelector { - constructor() { - this.boundHandlers = new Map(); // Track event handlers for cleanup - this.initializeElements(); - this.setupEventListeners(); - } - - initializeElements() { - this.captureTypeSelect = document.getElementById("captureTypeSelect"); - this.channelInputGroup = document.getElementById("channelInputGroup"); - this.scanGroupInputGroup = document.getElementById("scanGroupInputGroup"); - this.captureChannelsInput = document.getElementById("captureChannelsInput"); - this.captureScanGroupInput = document.getElementById( - "captureScanGroupInput", - ); - this.uploadModal = document.getElementById("uploadCaptureModal"); - - // Log which elements were found for debugging - console.log("CaptureTypeSelector elements found:", { - captureTypeSelect: !!this.captureTypeSelect, - channelInputGroup: !!this.channelInputGroup, - scanGroupInputGroup: !!this.scanGroupInputGroup, - captureChannelsInput: !!this.captureChannelsInput, - captureScanGroupInput: !!this.captureScanGroupInput, - uploadModal: !!this.uploadModal, - }); - } - - setupEventListeners() { - // Ensure boundHandlers is initialized - if (!this.boundHandlers) { - this.boundHandlers = new Map(); - } - - if (this.captureTypeSelect) { - const changeHandler = (e) => this.handleTypeChange(e); - this.boundHandlers.set(this.captureTypeSelect, changeHandler); - this.captureTypeSelect.addEventListener("change", changeHandler); - } - - if (this.uploadModal) { - const hiddenHandler = () => this.resetForm(); - this.boundHandlers.set(this.uploadModal, hiddenHandler); - this.uploadModal.addEventListener("hidden.bs.modal", hiddenHandler); - } - } - - handleTypeChange(event) { - const selectedType = event.target.value; - - // Validate capture type - if (!this.validateCaptureType(selectedType)) { - ErrorHandler.showError( - "Invalid capture type selected", - "capture-type-validation", - ); - return; - } - - // Hide both input groups initially - this.hideInputGroups(); - - // Clear required attributes - this.clearRequiredAttributes(); - - // Show appropriate input group based on selection - if (selectedType === "drf") { - this.showChannelInput(); - } else if (selectedType === "rh") { - this.showScanGroupInput(); - } - } - - hideInputGroups() { - if (this.channelInputGroup) { - this.channelInputGroup.classList.add("hidden-input-group"); - } - if (this.scanGroupInputGroup) { - this.scanGroupInputGroup.classList.add("hidden-input-group"); - } - } - - clearRequiredAttributes() { - if (this.captureChannelsInput) { - this.captureChannelsInput.removeAttribute("required"); - } - if (this.captureScanGroupInput) { - this.captureScanGroupInput.removeAttribute("required"); - } - } - - showChannelInput() { - if (this.channelInputGroup) { - this.channelInputGroup.classList.remove("hidden-input-group"); - } - if (this.captureChannelsInput) { - this.captureChannelsInput.setAttribute("required", "required"); - } - } - - showScanGroupInput() { - if (this.scanGroupInputGroup) { - this.scanGroupInputGroup.classList.remove("hidden-input-group"); - } - // scan_group is optional for RadioHound captures, so no required attribute - } - - // Input validation methods - validateCaptureType(type) { - const validTypes = ["drf", "rh"]; - return validTypes.includes(type); - } - - validateChannelInput(channels) { - if (!channels || typeof channels !== "string") return false; - // Basic validation for channel input (can be enhanced based on requirements) - return channels.trim().length > 0 && channels.length <= 1000; - } - - validateScanGroupInput(scanGroup) { - if (!scanGroup || typeof scanGroup !== "string") return false; - // Basic validation for scan group input - return scanGroup.trim().length > 0 && scanGroup.length <= 255; - } - - sanitizeInput(input) { - if (!input || typeof input !== "string") return ""; - // Remove potentially dangerous characters - return input.replace(/[<>:"/\\|?*]/g, "_").trim(); - } - - // Memory management and cleanup - cleanup() { - // Remove all bound event handlers - for (const [element, handler] of this.boundHandlers) { - if (element?.removeEventListener) { - element.removeEventListener("change", handler); - element.removeEventListener("hidden.bs.modal", handler); - } - } - this.boundHandlers.clear(); - console.log("CaptureTypeSelector cleanup completed"); - } - - resetForm() { - // Reset the form - const form = document.getElementById("uploadCaptureForm"); - if (form) { - form.reset(); - } - - // Hide input groups - this.hideInputGroups(); - - // Clear required attributes - this.clearRequiredAttributes(); - - // Clear global variables if they exist - this.cleanupGlobalState(); - } - - // Better global state management - cleanupGlobalState() { - const globalVars = ["filesToSkip", "fileCheckResults", "selectedFiles"]; - - for (const varName of globalVars) { - if (window[varName]) { - if (typeof window[varName].clear === "function") { - window[varName].clear(); - } else if (Array.isArray(window[varName])) { - window[varName].length = 0; - } else { - window[varName] = null; - } - console.log(`Cleaned up global variable: ${varName}`); - } - } - } -} diff --git a/gateway/sds_gateway/static/js/upload/_file_upload_handler.txt b/gateway/sds_gateway/static/js/upload/_file_upload_handler.txt deleted file mode 100644 index b92c87f5d..000000000 --- a/gateway/sds_gateway/static/js/upload/_file_upload_handler.txt +++ /dev/null @@ -1,221 +0,0 @@ -class FileUploadHandler { - constructor() { - this.uploadForm = document.getElementById("uploadFileForm"); - this.fileInput = document.getElementById("fileInput"); - this.folderInput = document.getElementById("folderInput"); - this.submitBtn = document.getElementById("uploadFileSubmitBtn"); - this.clearBtn = document.getElementById("clearUploadBtn"); - this.uploadText = this.submitBtn?.querySelector(".upload-text"); - this.uploadSpinner = this.submitBtn?.querySelector(".upload-spinner"); - this.validationFeedback = document.getElementById( - "uploadValidationFeedback", - ); - - // Enable submit button when files or folders are selected - if (this.fileInput) { - this.fileInput.addEventListener("change", () => - this.updateSubmitButton(), - ); - } - if (this.folderInput) { - this.folderInput.addEventListener("change", () => - this.updateSubmitButton(), - ); - } - - // Clear button handler - if (this.clearBtn) { - this.clearBtn.addEventListener("click", () => this.clearModal()); - } - - if (this.uploadForm) { - this.uploadForm.addEventListener("submit", (e) => this.handleSubmit(e)); - } - } - - updateSubmitButton() { - if (this.submitBtn) { - const hasFiles = this.fileInput?.files.length > 0; - const hasFolders = this.folderInput?.files.length > 0; - this.submitBtn.disabled = !hasFiles && !hasFolders; - - // Hide validation feedback when files are selected - if (hasFiles || hasFolders) { - this.hideValidationFeedback(); - } - } - } - - showValidationFeedback() { - if (this.validationFeedback) { - this.validationFeedback.classList.add("d-block"); - } - // Add invalid styling to inputs - this.fileInput?.classList.add("is-invalid"); - this.folderInput?.classList.add("is-invalid"); - } - - hideValidationFeedback() { - if (this.validationFeedback) { - this.validationFeedback.classList.remove("d-block"); - } - // Remove invalid styling from inputs - this.fileInput?.classList.remove("is-invalid"); - this.folderInput?.classList.remove("is-invalid"); - } - - clearModal() { - // Reset form - if (this.uploadForm) { - this.uploadForm.reset(); - } - // Explicitly clear file inputs (form.reset() doesn't always clear file inputs) - if (this.fileInput) { - this.fileInput.value = ""; - } - if (this.folderInput) { - this.folderInput.value = ""; - } - // Hide validation feedback - this.hideValidationFeedback(); - // Update submit button state - this.updateSubmitButton(); - } - - async handleSubmit(event) { - event.preventDefault(); - - const files = Array.from(this.fileInput?.files || []); - const folderFiles = Array.from(this.folderInput?.files || []); - - if (files.length === 0 && folderFiles.length === 0) { - this.showValidationFeedback(); - return; - } - - this.setUploadingState(true); - - try { - // Debug: Check CSRF token availability - console.log("CSRF Token:", window.csrfToken); - console.log("Upload URL:", window.uploadFilesUrl); - - // Try multiple ways to get CSRF token - let csrfToken = window.csrfToken; - if (!csrfToken) { - // Fallback to DOM query - const csrfInput = document.querySelector("[name=csrfmiddlewaretoken]"); - csrfToken = csrfInput ? csrfInput.value : null; - } - - if (!csrfToken) { - throw new Error("CSRF token not found"); - } - - const formData = new FormData(); - const allFiles = [...files, ...folderFiles]; - const allRelativePaths = []; - - // Add all files to formData - for (const file of allFiles) { - formData.append("files", file); - // Use webkitRelativePath for folder files, filename for individual files - const relativePath = file.webkitRelativePath || file.name; - allRelativePaths.push(relativePath); - } - - // Add relative paths - for (const relativePath of allRelativePaths) { - formData.append("relative_paths", relativePath); - } - for (const relativePath of allRelativePaths) { - formData.append("all_relative_paths", relativePath); - } - - // Prevent capture creation when uploading files only - formData.append("capture_type", ""); - formData.append("channels", ""); - formData.append("scan_group", ""); - formData.append("csrfmiddlewaretoken", csrfToken); - - const response = await fetch(window.uploadFilesUrl, { - method: "POST", - body: formData, - headers: { - "X-CSRFToken": csrfToken, - }, - }); - - const result = await response.json(); - - if (response.ok) { - // Show success message - const fileCount = allFiles.length; - const successMsg = - fileCount === 1 - ? "1 file uploaded successfully!" - : `${fileCount} files uploaded successfully!`; - this.showResult("success", successMsg); - // Clear file inputs - this.clearModal(); - // Close modal - const modal = bootstrap.Modal.getInstance( - document.getElementById("uploadFileModal"), - ); - if (modal) modal.hide(); - // Refresh the file list - if (window.fileManager) { - window.fileManager.loadFiles(); - } - } else { - this.showResult( - "error", - result.error || "Upload failed. Please try again.", - ); - } - } catch (error) { - console.error("Upload error:", error); - this.showResult( - "error", - "Upload failed. Please check your connection and try again.", - ); - } finally { - this.setUploadingState(false); - } - } - - setUploadingState(uploading) { - if (this.submitBtn) { - this.submitBtn.disabled = uploading; - } - if (this.uploadText && this.uploadSpinner) { - this.uploadText.classList.toggle("d-none", uploading); - this.uploadSpinner.classList.toggle("d-none", !uploading); - } - } - - showResult(type, message) { - // Show result in the upload result modal - const resultModal = document.getElementById("uploadResultModal"); - const resultBody = document.getElementById("uploadResultModalBody"); - - if (resultModal && resultBody) { - resultBody.innerHTML = ` -
    - ${message} -
    - `; - const modal = new bootstrap.Modal(resultModal); - modal.show(); - } else { - // Fallback to alert - alert(message); - } - } - - cleanup() { - if (this.uploadForm) { - this.uploadForm.removeEventListener("submit", this.handleSubmit); - } - } -} diff --git a/gateway/sds_gateway/static/js/upload/_files_modal_class.txt b/gateway/sds_gateway/static/js/upload/_files_modal_class.txt deleted file mode 100644 index 9c2077bd4..000000000 --- a/gateway/sds_gateway/static/js/upload/_files_modal_class.txt +++ /dev/null @@ -1,550 +0,0 @@ -class FilesUploadModal { - constructor() { - this.isProcessing = false; - this.uploadInProgress = false; - this.cancelRequested = false; - this.currentAbortController = null; - - this.initializeElements(); - this.setupEventListeners(); - this.clearExistingModals(); - } - - initializeElements() { - this.cancelButton = document.querySelector( - "#uploadCaptureModal .btn-secondary", - ); - this.submitButton = document.getElementById("uploadSubmitBtn"); - this.uploadModal = document.getElementById("uploadCaptureModal"); - this.fileInput = document.getElementById("captureFileInput"); - this.uploadForm = document.getElementById("uploadCaptureForm"); - } - - setupEventListeners() { - // Modal event listeners - if (this.uploadModal) { - this.uploadModal.addEventListener("show.bs.modal", () => - this.resetState(), - ); - this.uploadModal.addEventListener("hidden.bs.modal", () => - this.resetState(), - ); - } - - // File input change listener - if (this.fileInput) { - this.fileInput.addEventListener("change", () => this.resetState()); - } - - // Cancel button listener - if (this.cancelButton) { - this.cancelButton.addEventListener("click", () => this.handleCancel()); - } - - // Form submit listener - if (this.uploadForm) { - this.uploadForm.addEventListener("submit", (e) => this.handleSubmit(e)); - } - } - - clearExistingModals() { - const existingResultModal = document.getElementById("uploadResultModal"); - if (existingResultModal) { - const modalInstance = bootstrap.Modal.getInstance(existingResultModal); - if (modalInstance) { - modalInstance.hide(); - } - } - } - - resetState() { - this.isProcessing = false; - this.currentAbortController = null; - this.cancelRequested = false; - } - - handleCancel() { - if (this.isProcessing) { - this.cancelRequested = true; - - if (this.currentAbortController) { - this.currentAbortController.abort(); - } - - this.cancelButton.textContent = "Cancelling..."; - this.cancelButton.disabled = true; - - const progressMessage = document.getElementById("progressMessage"); - if (progressMessage) { - progressMessage.textContent = "Cancelling upload..."; - } - - setTimeout(() => { - if (this.cancelRequested) { - this.resetUIState(); - } - }, 500); - } - } - - async handleSubmit(e) { - e.preventDefault(); - - this.isProcessing = true; - this.uploadInProgress = true; - this.cancelRequested = false; - - // Check if files are selected - if (!window.selectedFiles || window.selectedFiles.length === 0) { - alert("Please select files to upload."); - return; - } - - try { - this.showProgressSection(); - await this.checkFilesForDuplicates(); - - if (this.cancelRequested) { - throw new Error("Upload cancelled by user"); - } - - await this.uploadFiles(); - } catch (error) { - this.handleError(error); - } finally { - this.resetUIState(); - } - } - - showProgressSection() { - const progressSection = document.getElementById("checkingProgressSection"); - const progressMessage = document.getElementById("progressMessage"); - - if (progressSection) { - progressSection.style.display = "block"; - } - if (progressMessage) { - progressMessage.textContent = "Checking files for duplicates..."; - } - - this.cancelButton.textContent = "Cancel Processing"; - this.cancelButton.classList.add("btn-warning"); - this.submitButton.disabled = true; - } - - async checkFilesForDuplicates() { - window.filesToSkip = new Set(); - window.fileCheckResults = new Map(); - const files = window.selectedFiles; - const totalFiles = files.length; - - const progressBar = document.getElementById("checkingProgressBar"); - const progressText = document.getElementById("checkingProgressText"); - - for (let i = 0; i < files.length; i++) { - if (this.cancelRequested) break; - - const file = files[i]; - const progress = Math.round(((i + 1) / totalFiles) * 100); - - if (progressBar) progressBar.style.width = `${progress}%`; - if (progressText) progressText.textContent = `${progress}%`; - - await this.processFile(file); - } - - if (this.cancelRequested) { - throw new Error("Upload cancelled by user"); - } - } - - async processFile(file) { - // Calculate BLAKE3 hash - const buffer = await file.arrayBuffer(); - const hasher = await hashwasm.createBLAKE3(); - hasher.init(); - hasher.update(new Uint8Array(buffer)); - const hashHex = hasher.digest("hex"); - - // Calculate directory path - let directory = "/"; - if (file.webkitRelativePath) { - const pathParts = file.webkitRelativePath.split("/"); - if (pathParts.length > 1) { - pathParts.pop(); - directory = `/${pathParts.join("/")}`; - } - } - - // Check if file exists - const checkData = { - directory: directory, - filename: file.name, - checksum: hashHex, - }; - - try { - const response = await fetch(window.checkFileExistsUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-CSRFToken": window.csrfToken, - }, - body: JSON.stringify(checkData), - }); - const data = await response.json(); - - const fileKey = `${directory}/${file.name}`; - window.fileCheckResults.set(fileKey, { - file: file, - directory: directory, - filename: file.name, - checksum: hashHex, - data: data.data, - }); - - if (data.data && data.data.file_exists_in_tree === true) { - window.filesToSkip.add(fileKey); - } - } catch (error) { - console.error("Error checking file:", error); - } - } - - async uploadFiles() { - const progressMessage = document.getElementById("progressMessage"); - const progressSection = document.getElementById("checkingProgressSection"); - const progressBar = document.getElementById("checkingProgressBar"); - const progressText = document.getElementById("checkingProgressText"); - - if (progressMessage) { - progressMessage.textContent = "Uploading files and creating captures..."; - } - if (progressBar) progressBar.style.width = "0%"; - if (progressText) progressText.textContent = "0%"; - - const files = window.selectedFiles; - const filesToUpload = []; - const relativePathsToUpload = []; - const allRelativePaths = []; - - // Process files for upload - for (const file of files) { - let directory = "/"; - if (file.webkitRelativePath) { - const pathParts = file.webkitRelativePath.split("/"); - if (pathParts.length > 1) { - pathParts.pop(); - directory = `/${pathParts.join("/")}`; - } - } - const fileKey = `${directory}/${file.name}`; - const relativePath = file.webkitRelativePath || file.name; - - console.debug( - `Processing file: ${file.name}, webkitRelativePath: '${file.webkitRelativePath}', relativePath: '${relativePath}', directory: '${directory}'`, - ); - allRelativePaths.push(relativePath); - - if (!window.filesToSkip.has(fileKey)) { - filesToUpload.push(file); - relativePathsToUpload.push(relativePath); - } - } - - console.debug( - "All relative paths being sent:", - allRelativePaths.slice(0, 5), - ); - console.debug( - "Relative paths to upload:", - relativePathsToUpload.slice(0, 5), - ); - - if (filesToUpload.length > 0 && progressSection) { - progressSection.style.display = "block"; - } - - this.currentAbortController = new AbortController(); - - let result; - if (filesToUpload.length === 0) { - result = await this.uploadSkippedFiles(allRelativePaths); - } else { - result = await this.uploadFilesInChunks( - filesToUpload, - relativePathsToUpload, - allRelativePaths, - ); - } - - this.currentAbortController = null; - this.showUploadResults(result, result.saved_files_count, files.length); - } - - async uploadSkippedFiles(allRelativePaths) { - const formData = new FormData(); - - console.debug( - "uploadSkippedFiles - allRelativePaths:", - allRelativePaths.slice(0, 5), - ); - for (const path of allRelativePaths) { - formData.append("all_relative_paths", path); - } - - this.addCaptureTypeData(formData); - formData.append("csrfmiddlewaretoken", window.csrfToken); - - const response = await fetch(window.uploadFilesUrl, { - method: "POST", - body: formData, - headers: { - "X-CSRFToken": window.csrfToken, - }, - signal: this.currentAbortController.signal, - }); - - return await response.json(); - } - - async uploadFilesInChunks( - filesToUpload, - relativePathsToUpload, - allRelativePaths, - ) { - const CHUNK_SIZE = 5; - const totalFiles = filesToUpload.length; - const progressBar = document.getElementById("checkingProgressBar"); - const progressText = document.getElementById("checkingProgressText"); - const progressMessage = document.getElementById("progressMessage"); - - const allResults = { - file_upload_status: "success", - saved_files_count: 0, - captures: [], - errors: [], - message: "", - }; - - for (let i = 0; i < filesToUpload.length; i += CHUNK_SIZE) { - if (this.cancelRequested) break; - - const chunk = filesToUpload.slice(i, i + CHUNK_SIZE); - const chunkPaths = relativePathsToUpload.slice(i, i + CHUNK_SIZE); - - const totalChunks = Math.ceil(filesToUpload.length / CHUNK_SIZE); - const currentChunk = Math.floor(i / CHUNK_SIZE) + 1; - const isFinalChunk = currentChunk === totalChunks; - - // Update progress - const progress = Math.round(((i + chunk.length) / totalFiles) * 100); - if (progressBar) progressBar.style.width = `${progress}%`; - if (progressText) progressText.textContent = `${progress}%`; - if (progressMessage) { - progressMessage.textContent = `Uploading files ${i + 1}-${Math.min(i + CHUNK_SIZE, totalFiles)} of ${totalFiles} (chunk ${currentChunk}/${totalChunks})...`; - } - - const chunkResult = await this.uploadChunk( - chunk, - chunkPaths, - allRelativePaths, - currentChunk, - totalChunks, - ); - - // Merge results - if (chunkResult.saved_files_count !== undefined) { - allResults.saved_files_count += chunkResult.saved_files_count; - } - if (chunkResult.captures && isFinalChunk) { - allResults.captures = allResults.captures.concat(chunkResult.captures); - } - if (chunkResult.errors) { - allResults.errors = allResults.errors.concat(chunkResult.errors); - } - - if (chunkResult.file_upload_status === "error") { - allResults.file_upload_status = "error"; - allResults.message = chunkResult.message || "Upload failed"; - break; - } - - if (chunkResult.file_upload_status === "success" && isFinalChunk) { - allResults.file_upload_status = "success"; - } - } - - if (this.cancelRequested) { - throw new Error("Upload cancelled by user"); - } - - return allResults; - } - - async uploadChunk( - chunk, - chunkPaths, - allRelativePaths, - currentChunk, - totalChunks, - ) { - const formData = new FormData(); - - console.debug( - `uploadChunk ${currentChunk}/${totalChunks} - chunkPaths:`, - chunkPaths, - ); - console.debug( - `uploadChunk ${currentChunk}/${totalChunks} - allRelativePaths (first 5):`, - allRelativePaths.slice(0, 5), - ); - - for (const file of chunk) { - formData.append("files", file); - } - for (const path of chunkPaths) { - formData.append("relative_paths", path); - } - for (const path of allRelativePaths) { - formData.append("all_relative_paths", path); - } - - this.addCaptureTypeData(formData); - formData.append("csrfmiddlewaretoken", window.csrfToken); - - formData.append("is_chunk", "true"); - formData.append("chunk_number", currentChunk.toString()); - formData.append("total_chunks", totalChunks.toString()); - - const controller = new AbortController(); - this.currentAbortController = controller; - const timeoutId = setTimeout(() => controller.abort(), 300000); - - try { - const response = await fetch(window.uploadFilesUrl, { - method: "POST", - body: formData, - headers: { - "X-CSRFToken": window.csrfToken, - }, - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - return await response.json(); - } catch (error) { - clearTimeout(timeoutId); - if (error.name === "AbortError") { - throw new Error("Upload timeout - connection may be lost"); - } - throw error; - } - } - - addCaptureTypeData(formData) { - const captureType = document.getElementById("captureTypeSelect").value; - formData.append("capture_type", captureType); - - if (captureType === "drf") { - const channels = document.getElementById("captureChannelsInput").value; - formData.append("channels", channels); - } else if (captureType === "rh") { - const scanGroup = document.getElementById("captureScanGroupInput").value; - formData.append("scan_group", scanGroup); - } - } - - handleError(error) { - if (this.cancelRequested) { - alert( - "Upload cancelled. Any files uploaded before cancellation have been saved.", - ); - setTimeout(() => window.location.reload(), 1000); - } else if (error.name === "AbortError") { - alert( - "Upload was interrupted. Any files uploaded before the interruption have been saved.", - ); - setTimeout(() => window.location.reload(), 1000); - } else if (error.name === "TypeError" && error.message.includes("fetch")) { - alert( - "Network error during upload. Please check your connection and try again.", - ); - } else { - alert(`Upload failed: ${error.message}`); - setTimeout(() => window.location.reload(), 1000); - } - } - - resetUIState() { - this.submitButton.disabled = false; - - const progressSection = document.getElementById("checkingProgressSection"); - if (progressSection) { - progressSection.style.display = "none"; - } - - this.cancelButton.textContent = "Cancel"; - this.cancelButton.classList.remove("btn-warning"); - this.cancelButton.disabled = false; - - const progressBar = document.getElementById("checkingProgressBar"); - const progressText = document.getElementById("checkingProgressText"); - const progressMessage = document.getElementById("progressMessage"); - - if (progressBar) progressBar.style.width = "0%"; - if (progressText) progressText.textContent = "0%"; - if (progressMessage) progressMessage.textContent = ""; - - this.isProcessing = false; - this.uploadInProgress = false; - this.cancelRequested = false; - this.currentAbortController = null; - } - - showUploadResults(result, uploadedCount, totalCount) { - const uploadModal = bootstrap.Modal.getInstance(this.uploadModal); - if (uploadModal) { - uploadModal.hide(); - } - - if (result.file_upload_status === "success") { - const uploaded = uploadedCount ?? 0; - const message = `Upload complete: ${uploaded} / ${totalCount} file${totalCount === 1 ? "" : "s"} uploaded.`; - try { - sessionStorage.setItem( - "filesAlert", - JSON.stringify({ message: message, type: "success" }), - ); - } catch (_) {} - setTimeout(() => window.location.reload(), 500); - } else { - this.showErrorModal(result); - } - } - - showErrorModal(result) { - const modalBody = document.getElementById("uploadResultModalBody"); - const resultModalEl = document.getElementById("uploadResultModal"); - const modal = new bootstrap.Modal(resultModalEl); - - let msg = "Upload Failed
    "; - if (result.message) { - msg += `${result.message}

    `; - } - msg += "Please remove the problematic files and try again."; - - if (result.errors && result.errors.length > 0) { - const errs = result.errors.map((e) => `
  • ${e}
  • `).join(""); - msg += `

    Error Details:
      ${errs}
    `; - } - - modalBody.innerHTML = msg; - modal.show(); - } -} diff --git a/gateway/sds_gateway/static/js/upload/_files_page_init.txt b/gateway/sds_gateway/static/js/upload/_files_page_init.txt deleted file mode 100644 index 54fd5a524..000000000 --- a/gateway/sds_gateway/static/js/upload/_files_page_init.txt +++ /dev/null @@ -1,233 +0,0 @@ -class FilesPageInitializer { - constructor() { - this.boundHandlers = new Map(); // Track event handlers for cleanup - this.activeHandlers = new Set(); // Track active component handlers - this.initializeComponents(); - } - - initializeComponents() { - try { - this.initializeModalManager(); - this.initializeCapturesTableManager(); - this.initializeUserSearchHandlers(); - } catch (error) { - ErrorHandler.showError( - "Failed to initialize page components", - "component-initialization", - error, - ); - } - } - - initializeModalManager() { - // Initialize ModalManager for capture modal - let modalManager = null; - try { - if (window.ModalManager) { - modalManager = new window.ModalManager({ - modalId: "capture-modal", - modalBodyId: "capture-modal-body", - modalTitleId: "capture-modal-label", - }); - - this.modalManager = modalManager; - console.log("ModalManager initialized successfully"); - } else { - ErrorHandler.showError( - "Modal functionality is not available. Some features may be limited.", - "modal-initialization", - ); - } - } catch (error) { - ErrorHandler.showError( - "Failed to initialize modal functionality", - "modal-initialization", - error, - ); - } - } - - initializeCapturesTableManager() { - // Initialize CapturesTableManager for capture edit/download functionality - try { - if (window.CapturesTableManager) { - window.capturesTableManager = new window.CapturesTableManager({ - modalHandler: this.modalManager, - }); - console.log("CapturesTableManager initialized successfully"); - } else { - ErrorHandler.showError( - "Table management functionality is not available. Some features may be limited.", - "table-initialization", - ); - } - } catch (error) { - ErrorHandler.showError( - "Failed to initialize table management functionality", - "table-initialization", - error, - ); - } - } - - initializeUserSearchHandlers() { - // Create a UserSearchHandler for each share modal - const shareModals = document.querySelectorAll(".modal[data-item-uuid]"); - - // Skip initialization if no share modals exist on this page - if (shareModals.length === 0) { - return; - } - - // Check if UserSearchHandler is available before trying to initialize - if (!window.UserSearchHandler) { - console.warn( - "UserSearchHandler not available. Share functionality will not work.", - ); - return; - } - - for (const modal of shareModals) { - this.setupUserSearchHandler(modal); - } - } - - setupUserSearchHandler(modal) { - try { - // Ensure boundHandlers and activeHandlers are initialized - if (!this.boundHandlers) { - this.boundHandlers = new Map(); - } - if (!this.activeHandlers) { - this.activeHandlers = new Set(); - } - - // Validate modal attributes - const itemUuid = modal.getAttribute("data-item-uuid"); - const itemType = modal.getAttribute("data-item-type"); - - if (!this.validateModalAttributes(itemUuid, itemType)) { - ErrorHandler.showError( - "Invalid modal configuration", - "user-search-setup", - ); - return; - } - - const handler = new window.UserSearchHandler(); - // Store the handler on the modal element - modal.userSearchHandler = handler; - this.activeHandlers.add(handler); - - // Create bound event handlers for cleanup - const showHandler = () => { - if (modal.userSearchHandler) { - modal.userSearchHandler.setItemInfo(itemUuid, itemType); - modal.userSearchHandler.init(); - } - }; - - const hideHandler = () => { - if (modal.userSearchHandler) { - modal.userSearchHandler.resetAll(); - } - }; - - // Store handlers for cleanup - this.boundHandlers.set(modal, { - show: showHandler, - hide: hideHandler, - }); - - // On modal show, set the item info and call init() - modal.addEventListener("show.bs.modal", showHandler); - - // On modal hide, reset all selections and entered data - modal.addEventListener("hidden.bs.modal", hideHandler); - - console.log(`UserSearchHandler initialized for ${itemType}: ${itemUuid}`); - } catch (error) { - ErrorHandler.showError( - "Failed to setup user search functionality", - "user-search-setup", - error, - ); - } - } - - /** - * Get initialized modal manager - * @returns {Object|null} - The modal manager instance - */ - getModalManager() { - return this.modalManager; - } - - /** - * Get captures table manager - * @returns {Object|null} - The captures table manager instance - */ - getCapturesTableManager() { - return window.capturesTableManager; - } - - // Validation methods - validateModalAttributes(uuid, type) { - if (!uuid || typeof uuid !== "string") { - console.warn("Invalid UUID in modal attributes:", uuid); - return false; - } - - if (!type || typeof type !== "string") { - console.warn("Invalid type in modal attributes:", type); - return false; - } - - // Validate UUID format (basic check) - const uuidRegex = - /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - if (!uuidRegex.test(uuid)) { - console.warn("Invalid UUID format in modal attributes:", uuid); - return false; - } - - // Validate type - const validTypes = ["capture", "dataset", "file"]; - if (!validTypes.includes(type)) { - console.warn("Invalid type in modal attributes:", type); - return false; - } - - return true; - } - - // Memory management and cleanup - cleanup() { - // Remove all bound event handlers - for (const [element, handlers] of this.boundHandlers) { - if (element?.removeEventListener) { - if (handlers.show) { - element.removeEventListener("show.bs.modal", handlers.show); - } - if (handlers.hide) { - element.removeEventListener("hidden.bs.modal", handlers.hide); - } - } - } - this.boundHandlers.clear(); - - // Cleanup active handlers - for (const handler of this.activeHandlers) { - if (handler && typeof handler.cleanup === "function") { - try { - handler.cleanup(); - } catch (error) { - console.warn("Error during handler cleanup:", error); - } - } - } - this.activeHandlers.clear(); - - console.log("FilesPageInitializer cleanup completed"); - } -} diff --git a/gateway/sds_gateway/static/js/upload/_files_upload_dom.txt b/gateway/sds_gateway/static/js/upload/_files_upload_dom.txt deleted file mode 100644 index 5b91ac9cd..000000000 --- a/gateway/sds_gateway/static/js/upload/_files_upload_dom.txt +++ /dev/null @@ -1,29 +0,0 @@ -// Initialize when DOM is loaded -document.addEventListener("DOMContentLoaded", () => { - // Set up session storage alert handling - const key = "filesAlert"; - const stored = sessionStorage.getItem(key); - if (stored) { - try { - const data = JSON.parse(stored); - if ( - window.components && - typeof window.components.showError === "function" && - data?.type === "error" - ) { - window.components.showError(data.message || "An error occurred."); - } else if ( - window.components && - typeof window.components.showSuccess === "function" && - data?.type === "success" - ) { - window.components.showSuccess(data.message || "Success"); - } - } catch (e) {} - sessionStorage.removeItem(key); - } - - // Initialize BLAKE3 handler first, then upload modal - new Blake3FileHandler(); - new FilesUploadModal(); -}); From 77925b3c13d9a5fbacc517ecdb169ae588645f3f Mon Sep 17 00:00:00 2001 From: klpoland Date: Fri, 8 May 2026 12:34:27 -0400 Subject: [PATCH 3/7] move deprecated files --- gateway/sds_gateway/static/js/components.js | 1855 ----------------- gateway/sds_gateway/static/js/file-list.js | 26 - gateway/sds_gateway/static/js/file-manager.js | 1464 ------------- .../js/file_list_upload_capture_modal.js | 1075 ---------- gateway/sds_gateway/static/js/files-ui.js | 857 -------- gateway/sds_gateway/static/js/files-upload.js | 763 ------- 6 files changed, 6040 deletions(-) delete mode 100644 gateway/sds_gateway/static/js/components.js delete mode 100644 gateway/sds_gateway/static/js/file-list.js delete mode 100644 gateway/sds_gateway/static/js/file-manager.js delete mode 100644 gateway/sds_gateway/static/js/file_list_upload_capture_modal.js delete mode 100644 gateway/sds_gateway/static/js/files-ui.js delete mode 100644 gateway/sds_gateway/static/js/files-upload.js diff --git a/gateway/sds_gateway/static/js/components.js b/gateway/sds_gateway/static/js/components.js deleted file mode 100644 index edc931686..000000000 --- a/gateway/sds_gateway/static/js/components.js +++ /dev/null @@ -1,1855 +0,0 @@ -/* Reusable Components for SDS Gateway - -* NOTE: This file is here because the functions have NOT -* been refactored to be placed in the new JS structure. - -* TODO: Refactor the rest of the methods to be placed in the new JS structure. -* And deprecate this file. -*/ - -/** - * Utility functions for security and common operations - */ -const ComponentUtils = { - /** - * Escapes HTML to prevent XSS attacks - * @param {string} text - Text to escape - * @returns {string} Escaped HTML - */ - escapeHtml(text) { - if (!text) return ""; - const div = document.createElement("div"); - div.textContent = text; - return div.innerHTML; - }, - - /** - * Formats date for display with date and time on separate lines - * @param {string} dateString - ISO date string or formatted date string - * @returns {string} Formatted date with HTML structure - */ - formatDate(dateString) { - if (!dateString) return "
    -
    "; - - let date; - - // Try to parse the date string - if (typeof dateString === "string") { - // Handle different date formats - if (dateString.includes("T")) { - // ISO format: 2023-12-25T14:30:45.123Z - date = new Date(dateString); - } else if (dateString.includes("/") && dateString.includes(":")) { - // Already formatted: 12/25/2023 2:30:45 PM - date = new Date(dateString); - } else { - // Try to parse as-is - date = new Date(dateString); - } - } else { - date = new Date(dateString); - } - - if (!date || Number.isNaN(date.getTime())) { - return "
    -
    "; - } - - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - const year = date.getFullYear(); - const hours = date.getHours(); - const minutes = String(date.getMinutes()).padStart(2, "0"); - const seconds = String(date.getSeconds()).padStart(2, "0"); - const ampm = hours >= 12 ? "PM" : "AM"; - const displayHours = hours % 12 || 12; - - return `
    ${month}/${day}/${year}
    ${displayHours}:${minutes}:${seconds} ${ampm}`; - }, - - /** - * Format date for modal display in the same style as dataset table - * @param {string} dateString - ISO date string - * @returns {string} Formatted date HTML - */ - formatDateForModal(dateString) { - if (!dateString || dateString === "None") { - return "N/A"; - } - - try { - const date = new Date(dateString); - if (Number.isNaN(date.getTime())) { - return "N/A"; - } - - // Format date as YYYY-MM-DD - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - const dateFormatted = `${year}-${month}-${day}`; - - // Format time as HH:MM:SS T - const hours = String(date.getHours()).padStart(2, "0"); - const minutes = String(date.getMinutes()).padStart(2, "0"); - const seconds = String(date.getSeconds()).padStart(2, "0"); - const timezone = date - .toLocaleTimeString("en-US", { timeZoneName: "short" }) - .split(" ")[1]; - const timeFormatted = `${hours}:${minutes}:${seconds} ${timezone}`; - - return `${dateFormatted}${timeFormatted}`; - } catch (error) { - console.error("Error formatting capture date:", error); - return "N/A"; - } - }, - - /** - * Formats date for display (simple version) - * @param {string} dateString - ISO date string - * @returns {string} Formatted date - */ - formatDateSimple(dateString) { - try { - const date = new Date(dateString); - return date.toString() !== "Invalid Date" - ? date.toLocaleDateString("en-US", { - month: "2-digit", - day: "2-digit", - year: "numeric", - }) - : ""; - } catch (e) { - return ""; - } - }, -}; - -/** - * TableManager - Handles table operations like sorting, pagination, and updates - */ -class TableManager { - constructor(config) { - this.tableId = config.tableId; - this.table = document.getElementById(this.tableId); - this.tbody = this.table?.querySelector("tbody"); - this.loadingIndicator = document.getElementById(config.loadingIndicatorId); - this.paginationContainer = document.getElementById( - config.paginationContainerId, - ); - this.currentSort = { by: "created_at", order: "desc" }; - this.onRowClick = config.onRowClick; - - this.initializeSorting(); - } - - initializeSorting() { - if (!this.table) return; - - const sortableHeaders = this.table.querySelectorAll("th.sortable"); - for (const header of sortableHeaders) { - header.style.cursor = "pointer"; - header.addEventListener("click", () => { - this.handleSort(header); - }); - } - - this.updateSortIcons(); - } - - handleSort(header) { - const field = header.getAttribute("data-sort"); - const currentSort = new URLSearchParams(window.location.search).get( - "sort_by", - ); - const currentOrder = - new URLSearchParams(window.location.search).get("sort_order") || "desc"; - - let newOrder = "asc"; - if (currentSort === field && currentOrder === "asc") { - newOrder = "desc"; - } - - this.currentSort = { by: field, order: newOrder }; - this.updateURL({ sort_by: field, sort_order: newOrder, page: "1" }); - } - - updateSortIcons() { - const urlParams = new URLSearchParams(window.location.search); - const currentSort = urlParams.get("sort_by") || "created_at"; - const currentOrder = urlParams.get("sort_order") || "desc"; - - const sortableHeaders = this.table?.querySelectorAll("th.sortable"); - for (const header of sortableHeaders || []) { - const icon = header.querySelector(".sort-icon"); - const field = header.getAttribute("data-sort"); - - if (icon) { - // Reset classes - icon.className = "bi sort-icon"; - - if (field === currentSort) { - // Add active class and appropriate direction icon - icon.classList.add("active"); - icon.classList.add( - currentOrder === "asc" ? "bi-caret-up-fill" : "bi-caret-down-fill", - ); - } else { - // Inactive columns get default down arrow - icon.classList.add("bi-caret-down-fill"); - } - } - } - } - - showLoading() { - if (this.loadingIndicator) { - this.loadingIndicator.classList.remove("d-none"); - } - } - - hideLoading() { - if (this.loadingIndicator) { - this.loadingIndicator.classList.add("d-none"); - } - } - - showError(message) { - const tbody = document.querySelector("tbody"); - if (tbody) { - tbody.innerHTML = ` - - - ${ComponentUtils.escapeHtml(message)} -
    Try refreshing the page or contact support if the problem persists. - - - `; - } - } - - updateTable(data, hasResults) { - if (!this.tbody) return; - - if (!hasResults || !data || data.length === 0) { - this.tbody.innerHTML = ` - - No results found. - - `; - return; - } - - this.tbody.innerHTML = data - .map((item, index) => this.renderRow(item, index)) - .join(""); - this.attachRowClickHandlers(); - } - - renderRow(item, index) { - // This should be overridden by specific implementations - return `Override renderRow method`; - } - - attachRowClickHandlers() { - if (!this.onRowClick) return; - - const rows = this.tbody?.querySelectorAll('tr[data-clickable="true"]'); - for (const row of rows || []) { - row.addEventListener("click", (e) => { - if ( - e.target.closest( - "button, a, .capture-select-checkbox, .capture-select-column", - ) - ) - return; // Don't trigger on buttons/links/selection - this.onRowClick(row); - }); - } - } - - updateURL(params) { - const urlParams = new URLSearchParams(window.location.search); - for (const [key, value] of Object.entries(params)) { - if (value) { - urlParams.set(key, value); - } else { - urlParams.delete(key); - } - } - - const newUrl = `${window.location.pathname}?${urlParams.toString()}`; - window.history.pushState({}, "", newUrl); - } -} - -/** - * CapturesTableManager - Specific implementation for captures table - */ -class CapturesTableManager extends TableManager { - constructor(config) { - super(config); - this.modalHandler = config.modalHandler; - this.tableContainerSelector = config.tableContainerSelector; - this.eventDelegationHandler = null; - this.initializeEventDelegation(); - } - - /** - * Initialize event delegation for better performance and memory management - */ - initializeEventDelegation() { - // Remove existing handler if it exists - if (this.eventDelegationHandler) { - document.removeEventListener("click", this.eventDelegationHandler); - } - - // Create single persistent event handler using delegation - this.eventDelegationHandler = (e) => { - // Ignore Bootstrap dropdown toggles - if ( - e.target.matches('[data-bs-toggle="dropdown"]') || - e.target.closest('[data-bs-toggle="dropdown"]') - ) { - return; - } - - // Handle capture details button clicks from actions dropdown - if ( - e.target.matches(".capture-details-btn") || - e.target.closest(".capture-details-btn") - ) { - e.preventDefault(); - const button = e.target.matches(".capture-details-btn") - ? e.target - : e.target.closest(".capture-details-btn"); - this.openCaptureModal(button); - return; - } - - // Handle capture link clicks - if ( - e.target.matches(".capture-link") || - e.target.closest(".capture-link") - ) { - e.preventDefault(); - const link = e.target.matches(".capture-link") - ? e.target - : e.target.closest(".capture-link"); - this.openCaptureModal(link); - return; - } - - // Handle view button clicks - if ( - e.target.matches(".view-capture-btn") || - e.target.closest(".view-capture-btn") - ) { - e.preventDefault(); - const button = e.target.matches(".view-capture-btn") - ? e.target - : e.target.closest(".view-capture-btn"); - this.openCaptureModal(button); - return; - } - }; - - // Add the persistent event listener - document.addEventListener("click", this.eventDelegationHandler); - } - - renderRow(capture, index) { - // Sanitize all data before rendering - const safeData = { - uuid: ComponentUtils.escapeHtml(capture.uuid || ""), - channel: ComponentUtils.escapeHtml(capture.channel || ""), - scanGroup: ComponentUtils.escapeHtml(capture.scan_group || ""), - captureType: ComponentUtils.escapeHtml(capture.capture_type || ""), - captureTypeDisplay: ComponentUtils.escapeHtml( - capture.capture_type_display || "", - ), - topLevelDir: ComponentUtils.escapeHtml(capture.top_level_dir || ""), - indexName: ComponentUtils.escapeHtml(capture.index_name || ""), - owner: ComponentUtils.escapeHtml(capture.owner || ""), - origin: ComponentUtils.escapeHtml(capture.origin || ""), - dataset: ComponentUtils.escapeHtml(capture.dataset || ""), - createdAt: ComponentUtils.escapeHtml(capture.created_at || ""), - updatedAt: ComponentUtils.escapeHtml(capture.updated_at || ""), - isPublic: ComponentUtils.escapeHtml(capture.is_public || ""), - isDeleted: ComponentUtils.escapeHtml(capture.is_deleted || ""), - centerFrequencyGhz: ComponentUtils.escapeHtml( - capture.center_frequency_ghz || "", - ), - }; - - // Handle composite vs single capture display - let channelDisplay = safeData.channel; - let typeDisplay = safeData.captureTypeDisplay || safeData.captureType; - - if (capture.is_multi_channel) { - // For composite captures, show all channels - if (capture.channels && Array.isArray(capture.channels)) { - channelDisplay = capture.channels - .map((ch) => ComponentUtils.escapeHtml(ch.channel || ch)) - .join(", "); - } - // Use capture_type_display if available, otherwise fall back to captureType - typeDisplay = capture.capture_type_display || safeData.captureType; - } - - return ` - - - - ${safeData.uuid} - - - ${channelDisplay} - - ${ComponentUtils.formatDateForModal(capture.capture?.created_at || capture.created_at)} - - ${typeDisplay} - ${capture.files_count || "0"} - ${capture.center_frequency_ghz ? `${capture.center_frequency_ghz.toFixed(3)} GHz` : "-"} - ${capture.sample_rate_mhz ? `${capture.sample_rate_mhz.toFixed(1)} MHz` : "-"} - - `; - } - - /** - * Attach row click handlers - now uses event delegation - */ - attachRowClickHandlers() { - // Event delegation is handled in initializeEventDelegation() - // This method is kept for compatibility but doesn't need to do anything - } - - /** - * Open capture modal with XSS protection - */ - openCaptureModal(linkElement) { - if (this.modalHandler) { - this.modalHandler.openCaptureModal(linkElement); - } - } - - /** - * Get CSRF token for API requests - */ - getCSRFToken() { - const token = document.querySelector("[name=csrfmiddlewaretoken]"); - return token ? token.value : ""; - } - - /** - * Open a custom modal - */ - openCustomModal(modalId) { - const modal = document.getElementById(modalId); - if (modal) { - modal.style.display = "block"; - document.body.style.overflow = "hidden"; - } - } - - /** - * Close a custom modal - */ - closeCustomModal(modalId) { - const modal = document.getElementById(modalId); - if (modal) { - modal.style.display = "none"; - document.body.style.overflow = "auto"; - } - } - - /** - * Cleanup method for proper resource management - */ - destroy() { - if (this.eventDelegationHandler) { - document.removeEventListener("click", this.eventDelegationHandler); - this.eventDelegationHandler = null; - } - } -} - -/** - * FilterManager - Handles form-based filtering with URL state management - */ -class FilterManager { - constructor(config) { - this.formId = config.formId; - this.form = document.getElementById(this.formId); - this.applyButton = document.getElementById(config.applyButtonId); - this.clearButton = document.getElementById(config.clearButtonId); - this.onFilterChange = config.onFilterChange; - this.searchInputId = config.searchInputId || "search-input"; - - this.initializeEventListeners(); - this.loadFromURL(); - } - - initializeEventListeners() { - if (this.applyButton) { - this.applyButton.addEventListener("click", (e) => { - e.preventDefault(); - this.applyFilters(); - }); - } - - if (this.clearButton) { - this.clearButton.addEventListener("click", (e) => { - e.preventDefault(); - this.clearFilters(); - }); - } - - // Auto-apply on form submission - if (this.form) { - this.form.addEventListener("submit", (e) => { - e.preventDefault(); - this.applyFilters(); - }); - } - } - - getFilterValues() { - if (!this.form) return {}; - - const formData = new FormData(this.form); - const filters = {}; - - for (const [key, value] of formData.entries()) { - if (value && value.trim() !== "") { - filters[key] = value.trim(); - } - } - - return filters; - } - - applyFilters() { - const filters = this.getFilterValues(); - this.updateURL(filters); - - if (this.onFilterChange) { - this.onFilterChange(filters); - } - } - - clearFilters() { - if (!this.form) return; - - // Get all form inputs except the search input - const inputs = this.form.querySelectorAll("input, select, textarea"); - for (const input of inputs) { - // Skip the search input - if (input.id === this.searchInputId) { - continue; - } - - // Clear other inputs - if (input.type === "checkbox" || input.type === "radio") { - input.checked = false; - } else { - input.value = ""; - } - } - - // Get current URL parameters - const urlParams = new URLSearchParams(window.location.search); - const searchValue = urlParams.get("search"); - const sortBy = urlParams.get("sort_by") || "created_at"; - const sortOrder = urlParams.get("sort_order") || "desc"; - - // Clear all parameters except search and sort - urlParams.forEach((_, key) => { - if (key !== "search" && key !== "sort_by" && key !== "sort_order") { - urlParams.delete(key); - } - }); - - // Ensure sort parameters are set - urlParams.set("sort_by", sortBy); - urlParams.set("sort_order", sortOrder); - urlParams.set("page", "1"); - - // Update URL - const newUrl = `${window.location.pathname}?${urlParams.toString()}`; - window.history.pushState({}, "", newUrl); - - // Trigger filter change callback - if (this.onFilterChange) { - const filters = { - sort_by: sortBy, - sort_order: sortOrder, - }; - if (searchValue) { - filters.search = searchValue; - } - this.onFilterChange(filters); - } - } - - loadFromURL() { - if (!this.form) return; - - const urlParams = new URLSearchParams(window.location.search); - const inputs = this.form.querySelectorAll("input, select, textarea"); - - for (const input of inputs) { - const value = urlParams.get(input.name); - if (value !== null) { - if (input.type === "checkbox" || input.type === "radio") { - input.checked = value === "true" || value === input.value; - } else { - input.value = value; - } - } - } - } - - updateURL(filters) { - const urlParams = new URLSearchParams(window.location.search); - - // Preserve search parameter if it exists - const searchValue = urlParams.get("search"); - - // Remove old filter parameters - const formData = new FormData(this.form || document.createElement("form")); - for (const key of formData.keys()) { - urlParams.delete(key); - } - - // Add new filter parameters - for (const [key, value] of Object.entries(filters)) { - if (value) { - urlParams.set(key, value); - } - } - - // Restore search parameter if it existed - if (searchValue) { - urlParams.set("search", searchValue); - } - - // Reset to first page when filters change - urlParams.set("page", "1"); - - const newUrl = `${window.location.pathname}?${urlParams.toString()}`; - window.history.pushState({}, "", newUrl); - } -} - -/** - * SearchManager - Handles search functionality with debouncing and request cancellation - */ -class SearchManager { - constructor(config) { - this.searchInput = document.getElementById(config.searchInputId); - this.searchButton = document.getElementById(config.searchButtonId); - this.clearButton = document.getElementById("clear-search-btn"); - this.onSearch = config.onSearch; - this.onSearchStart = config.onSearchStart; - this.debounceDelay = config.debounceDelay || 500; - this.debounceTimer = null; - this.abortController = new AbortController(); - - this.initializeEventListeners(); - this.updateClearButtonVisibility(); - } - - initializeEventListeners() { - if (this.searchInput) { - this.searchInput.addEventListener("input", () => { - this.debounceSearch(); - this.updateClearButtonVisibility(); - }); - - this.searchInput.addEventListener("keypress", (e) => { - if (e.key === "Enter") { - e.preventDefault(); - this.debounceSearch(); - } - }); - } - - if (this.searchButton) { - this.searchButton.addEventListener("click", (e) => { - e.preventDefault(); - this.debounceSearch(); - }); - } - - if (this.clearButton) { - this.clearButton.addEventListener("click", (e) => { - e.preventDefault(); - this.clearSearch(); - }); - } - } - - updateClearButtonVisibility() { - if (this.clearButton) { - this.clearButton.style.display = this.searchInput?.value - ? "block" - : "none"; - } - } - - debounceSearch() { - // Show loading indicator immediately for visual confirmation - if (this.onSearchStart) { - this.onSearchStart(); - } - - if (this.debounceTimer) { - clearTimeout(this.debounceTimer); - } - - this.debounceTimer = setTimeout(() => { - this.performSearch(); - }, this.debounceDelay); - } - - performSearch() { - // Cancel any previous request and create a new abort controller - this.abortController.abort(); - this.abortController = new AbortController(); - - const query = this.searchInput?.value || ""; - - if (this.onSearch) { - this.onSearch(query, this.abortController.signal); - } - } - - clearSearch() { - if (this.searchInput) { - this.searchInput.value = ""; - this.updateClearButtonVisibility(); - } - - this.debounceSearch(); - } - - /** - * Get the current abort signal for fetch requests - */ - getAbortSignal() { - return this.abortController.signal; - } -} - -/** - * ModalManager - Handles modal operations - */ -class ModalManager { - constructor(config) { - this.modalId = config.modalId; - this.modal = document.getElementById(this.modalId); - this.modalTitle = this.modal?.querySelector(".modal-title"); - this.modalBody = this.modal?.querySelector(".modal-body"); - - if (this.modal && window.bootstrap) { - this.bootstrapModal = new bootstrap.Modal(this.modal); - } - } - - show(title, content) { - if (!this.modal) return; - - if (this.modalTitle) { - this.modalTitle.textContent = title; - } - - if (this.modalBody) { - this.modalBody.innerHTML = content; - } - - if (this.bootstrapModal) { - this.bootstrapModal.show(); - } - } - - hide() { - if (this.bootstrapModal) { - this.bootstrapModal.hide(); - } - } - - openCaptureModal(linkElement) { - if (!linkElement) return; - - try { - // Reset visualize button to hidden state - const visualizeBtn = document.getElementById("visualize-btn"); - if (visualizeBtn) { - visualizeBtn.classList.add("d-none"); - } - - // Get all data attributes from the link with sanitization - const data = { - uuid: ComponentUtils.escapeHtml( - linkElement.getAttribute("data-uuid") || "", - ), - name: ComponentUtils.escapeHtml( - linkElement.getAttribute("data-name") || "", - ), - channel: ComponentUtils.escapeHtml( - linkElement.getAttribute("data-channel") || "", - ), - scanGroup: ComponentUtils.escapeHtml( - linkElement.getAttribute("data-scan-group") || "", - ), - captureType: ComponentUtils.escapeHtml( - linkElement.getAttribute("data-capture-type") || "", - ), - topLevelDir: ComponentUtils.escapeHtml( - linkElement.getAttribute("data-top-level-dir") || "", - ), - owner: ComponentUtils.escapeHtml( - linkElement.getAttribute("data-owner") || "", - ), - origin: ComponentUtils.escapeHtml( - linkElement.getAttribute("data-origin") || "", - ), - dataset: ComponentUtils.escapeHtml( - linkElement.getAttribute("data-dataset") || "", - ), - createdAt: linkElement.getAttribute("data-created-at") || "", - updatedAt: linkElement.getAttribute("data-updated-at") || "", - isPublic: linkElement.getAttribute("data-is-public") || "", - centerFrequencyGhz: - linkElement.getAttribute("data-center-frequency-ghz") || "", - isMultiChannel: linkElement.getAttribute("data-is-multi-channel") || "", - channels: linkElement.getAttribute("data-channels") || "", - }; - - // Parse owner field safely - const ownerDisplay = data.owner - ? data.owner.split("'").find((part) => part.includes("@")) || "N/A" - : "N/A"; - - // Check if this is a composite capture - const isComposite = - data.isMultiChannel === "True" || data.isMultiChannel === "true"; - - let modalContent = ` -
    -
    -
    - Basic Information -
    -
    -
    - -
    - - - - -
    -
    Click the edit button to modify the capture name
    -
    -
    -
    -

    - Capture Type: - ${data.captureType || "N/A"} -

    -

    - Origin: - ${data.origin || "N/A"} -

    -
    -
    -

    - Owner: - ${ownerDisplay} -

    -
    -
    - `; - - // Handle composite vs single capture display - if (isComposite) { - modalContent += ` -
    - Channels: - ${data.channel || "N/A"} -
    - `; - } else { - modalContent += ` -
    - Channel: - ${data.channel || "N/A"} -
    - `; - } - - modalContent += ` -
    -
    -
    -
    - Technical Details -
    -
    -
    -
    -

    - Scan Group: - ${data.scanGroup || "N/A"} -

    -

    - Dataset: - ${data.dataset || "N/A"} -

    -

    - Is Public: - ${data.isPublic === "True" ? "Yes" : "No"} -

    -
    -
    -

    - Top Level Directory: - ${data.topLevelDir || "N/A"} -

    -

    - Center Frequency: - - ${data.centerFrequencyGhz && data.centerFrequencyGhz !== "None" ? `${Number.parseFloat(data.centerFrequencyGhz).toFixed(3)} GHz` : "N/A"} - -

    -
    -
    -
    -
    -
    -
    - Timestamps -
    -
    -
    -
    -

    - Created At: -
    - - ${ComponentUtils.formatDateForModal(data.createdAt)} - -

    -
    -
    -

    - Updated At: -
    - - ${ComponentUtils.formatDateForModal(data.updatedAt)} - -

    -
    -
    -
    - -
    -
    -
    - Loading files... -
    - Loading files... -
    -
    - `; - - // Add composite-specific information if available - if (isComposite && data.channels) { - try { - // Convert Python dict syntax to valid JSON - let channelsData; - if (typeof data.channels === "string") { - // Handle Python dict syntax: {'key': 'value'} -> {"key": "value"} - const pythonDict = data.channels - .replace(/'/g, '"') // Replace single quotes with double quotes - .replace(/True/g, "true") // Replace Python True with JSON true - .replace(/False/g, "false") // Replace Python False with JSON false - .replace(/None/g, "null"); // Replace Python None with JSON null - - channelsData = JSON.parse(pythonDict); - } else { - channelsData = data.channels; - } - - if (Array.isArray(channelsData) && channelsData.length > 0) { - modalContent += ` -
    -
    Channel Details
    -
    - `; - - for (let i = 0; i < channelsData.length; i++) { - const channel = channelsData[i]; - const channelId = `channel-${i}`; - - // Format channel metadata as key-value pairs - let metadataDisplay = "N/A"; - if ( - channel.channel_metadata && - typeof channel.channel_metadata === "object" - ) { - const metadata = channel.channel_metadata; - const metadataItems = []; - - // Helper function to format values dynamically - const formatValue = (value, fieldName = "") => { - if (value === null || value === undefined) { - return "N/A"; - } - - if (typeof value === "boolean") { - return value ? "Yes" : "No"; - } - - // Handle string representations of booleans - if (typeof value === "string") { - if (value.toLowerCase() === "true") { - return "Yes"; - } - if (value.toLowerCase() === "false") { - return "No"; - } - } - - if (typeof value === "number") { - const absValue = Math.abs(value); - const valueStr = value.toString(); - const timeIndicators = [ - "computer_time", - "start_bound", - "end_bound", - "init_utc_timestamp", - ]; - // Only format as timestamp if the field name contains "time" - if ( - timeIndicators.includes(fieldName.toLowerCase()) && - valueStr.length >= 10 && - valueStr.length <= 13 - ) { - // Convert to milliseconds if it's in seconds - const timestamp = - valueStr.length === 10 ? value * 1000 : value; - return new Date(timestamp).toLocaleString(); - } - - // Only format for Giga (1e9) and Mega (1e6) ranges - if (absValue >= 1e9) { - return `${(value / 1e9).toFixed(3)} GHz`; - } - if (absValue >= 1e6) { - return `${(value / 1e6).toFixed(1)} MHz`; - } - return value.toString(); - } - - if (Array.isArray(value)) { - return value - .map((item) => formatValue(item, fieldName)) - .join(", "); - } - - if (typeof value === "object") { - return JSON.stringify(value); - } - - return String(value); - }; - - // Helper function to format field names - const formatFieldName = (fieldName) => { - return fieldName - .replace(/_/g, " ") - .replace(/\b\w/g, (l) => l.toUpperCase()); - }; - - // Loop through all metadata fields - if (Object.keys(metadata).length > 0) { - for (const [key, value] of Object.entries(metadata)) { - if (value !== undefined && value !== null) { - const formattedValue = formatValue(value, key); - const formattedKey = formatFieldName(key); - metadataItems.push( - `${formattedKey}: ${formattedValue}`, - ); - } - } - } else { - metadataItems.push("No metadata available"); - } - - if (metadataItems.length > 0) { - metadataDisplay = metadataItems.join("
    "); - } - } - - modalContent += ` -
    -

    - -

    -
    -
    -
    - ${metadataDisplay} -
    -
    -
    -
    - `; - } - - modalContent += ` -
    -
    - `; - } - } catch (e) { - console.error("Could not parse channels data for modal:", e); - console.error( - "Raw channels data that failed to parse:", - data.channels, - ); - - // Show a fallback message in the modal - modalContent += ` -
    -
    Channel Details
    -
    - - Unable to display channel details due to data format issues. -
    Raw data: ${ComponentUtils.escapeHtml(String(data.channels).substring(0, 100))}... -
    -
    - `; - } - } - - const title = data.name - ? data.name - : data.topLevelDir || "Unnamed Capture"; - this.show(title, modalContent); - - // Store capture data for later use - this.currentCaptureData = data; - - // Setup name editing handlers after modal content is loaded - this.setupNameEditingHandlers(); - - // Setup visualize button for Digital RF captures - this.setupVisualizeButton(data); - - // Load and display files for this capture - this.loadCaptureFiles(data.uuid); - } catch (error) { - console.error("Error opening capture modal:", error); - this.show("Error", "Error displaying capture details"); - } - } - - /** - * Setup visualize button for Digital RF captures - */ - setupVisualizeButton(captureData) { - const visualizeBtn = document.getElementById("visualize-btn"); - if (!visualizeBtn) return; - - // Show button only for Digital RF captures - if (captureData.captureType === "drf") { - visualizeBtn.classList.remove("d-none"); - - // Set up click handler to open visualization modal - visualizeBtn.onclick = () => { - // Use the VisualizationModal instance to open with capture data - if (window.visualizationModalInstance) { - window.visualizationModalInstance.openWithCaptureData( - captureData.uuid, - captureData.captureType, - ); - } - }; - } else { - visualizeBtn.classList.add("d-none"); - } - } - - /** - * Setup handlers for name editing functionality - */ - setupNameEditingHandlers() { - const nameInput = document.getElementById("capture-name-input"); - const editBtn = document.getElementById("edit-name-btn"); - const saveBtn = document.getElementById("save-name-btn"); - const cancelBtn = document.getElementById("cancel-name-btn"); - - if (!nameInput || !editBtn || !saveBtn || !cancelBtn) return; - - // Initially disable the input - nameInput.disabled = true; - let originalName = nameInput.value; - let isEditing = false; - - const startEditing = () => { - nameInput.disabled = false; - nameInput.focus(); - nameInput.select(); - editBtn.classList.add("d-none"); - saveBtn.classList.remove("d-none"); - cancelBtn.classList.remove("d-none"); - isEditing = true; - }; - - const stopEditing = () => { - nameInput.disabled = true; - editBtn.classList.remove("d-none"); - saveBtn.classList.add("d-none"); - cancelBtn.classList.add("d-none"); - isEditing = false; - }; - - const cancelEditing = () => { - nameInput.value = originalName; - stopEditing(); - }; - - // Edit button handler - editBtn.addEventListener("click", () => { - if (!isEditing) { - startEditing(); - } - }); - - // Cancel button handler - cancelBtn.addEventListener("click", cancelEditing); - - // Save button handler - saveBtn.addEventListener("click", async () => { - const newName = nameInput.value.trim(); - const uuid = nameInput.getAttribute("data-uuid"); - - if (!uuid) { - console.error("No UUID found for capture"); - return; - } - - // Disable buttons during save - editBtn.disabled = true; - saveBtn.disabled = true; - cancelBtn.disabled = true; - saveBtn.innerHTML = - ''; - - try { - await this.updateCaptureName(uuid, newName); - - // Success - update UI - originalName = newName; - stopEditing(); - - // Update the table display - this.updateTableNameDisplay(uuid, newName); - - // Update modal title using stored capture data - if (this.modalTitle && this.currentCaptureData) { - this.currentCaptureData.name = newName; - this.modalTitle.textContent = - newName || this.currentCaptureData.topLevelDir || "Unnamed Capture"; - } - - // Show success message - this.showSuccessMessage("Capture name updated successfully!"); - } catch (error) { - console.error("Error updating capture name:", error); - this.showErrorMessage( - "Failed to update capture name. Please try again.", - ); - // Revert to original name - nameInput.value = originalName; - } finally { - // Re-enable buttons and restore icons - editBtn.disabled = false; - saveBtn.disabled = false; - cancelBtn.disabled = false; - saveBtn.innerHTML = ''; - } - }); - - // Handle Enter key to save - nameInput.addEventListener("keypress", (e) => { - if (e.key === "Enter" && !nameInput.disabled) { - saveBtn.click(); - } - }); - - // Handle Escape key to cancel - nameInput.addEventListener("keydown", (e) => { - if (e.key === "Escape" && !nameInput.disabled) { - cancelEditing(); - } - }); - } - - /** - * Update capture name via API - */ - async updateCaptureName(uuid, newName) { - const response = await fetch(`/api/v1/assets/captures/${uuid}/`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - "X-CSRFToken": this.getCSRFToken(), - }, - body: JSON.stringify({ name: newName }), - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.detail || "Failed to update capture name"); - } - - return response.json(); - } - - /** - * Update the table display with the new name - */ - updateTableNameDisplay(uuid, newName) { - // Find all elements with this UUID and update their display - const captureLinks = document.querySelectorAll(`[data-uuid="${uuid}"]`); - - for (const link of captureLinks) { - // Update data attribute - link.dataset.name = newName; - - // Update display text if it's a capture link - if (link.classList.contains("capture-link")) { - link.textContent = newName || "Unnamed Capture"; - link.setAttribute( - "aria-label", - `View details for capture ${newName || uuid}`, - ); - link.setAttribute("title", `View capture details: ${newName || uuid}`); - } - } - } - - /** - * Clear existing alert messages from the modal - */ - clearAlerts() { - const modalBody = document.getElementById("capture-modal-body"); - if (modalBody) { - const existingAlerts = modalBody.querySelectorAll(".alert"); - for (const alert of existingAlerts) { - alert.remove(); - } - } - } - - /** - * Show success message - */ - showSuccessMessage(message) { - // Clear existing alerts first - this.clearAlerts(); - - // Create a temporary alert - const alert = document.createElement("div"); - alert.className = "alert alert-success alert-dismissible fade show"; - alert.innerHTML = ` - ${message} - - `; - - // Insert at the top of the modal body - const modalBody = document.getElementById("capture-modal-body"); - if (modalBody) { - modalBody.insertBefore(alert, modalBody.firstChild); - - // Auto-dismiss after 3 seconds - setTimeout(() => { - if (alert.parentNode) { - alert.remove(); - } - }, 3000); - } - } - - /** - * Show error message - */ - showErrorMessage(message) { - // Clear existing alerts first - this.clearAlerts(); - - // Create a temporary alert - const alert = document.createElement("div"); - alert.className = "alert alert-danger alert-dismissible fade show"; - alert.innerHTML = ` - ${message} - - `; - - // Insert at the top of the modal body - const modalBody = document.getElementById("capture-modal-body"); - if (modalBody) { - modalBody.insertBefore(alert, modalBody.firstChild); - - // Auto-dismiss after 5 seconds - setTimeout(() => { - if (alert.parentNode) { - alert.remove(); - } - }, 5000); - } - } - - /** - * Load and display files associated with the capture - */ - async loadCaptureFiles(captureUuid) { - try { - const response = await fetch(`/api/v1/assets/captures/${captureUuid}/`, { - method: "GET", - headers: { - "Content-Type": "application/json", - "X-CSRFToken": this.getCSRFToken(), - }, - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const captureData = await response.json(); - console.log("Raw capture data:", captureData); - - const files = captureData.files || []; - const filesCount = captureData.files_count || 0; - const totalSize = captureData.total_file_size || 0; - - console.log("Files info:", { - filesCount, - totalSize, - numFiles: files.length, - }); - - // Update files section with simple summary - const filesSection = document.getElementById("files-section-placeholder"); - if (filesSection) { - filesSection.innerHTML = ` -
    -
    -
    - Files Summary -
    -
    -
    -

    - Number of Files: - ${filesCount} -

    -
    -
    -

    - Total Size: - ${window.DOMUtils.formatFileSize(totalSize)} -

    -
    -
    - `; - } - } catch (error) { - console.error("Error loading capture files:", error); - const filesSection = document.getElementById("files-section-placeholder"); - if (filesSection) { - filesSection.innerHTML = ` -
    - - Error loading files information -
    - `; - } - } - } - - /** - * Format file metadata for display - */ - formatFileMetadata(file) { - const metadata = []; - - // Primary file information - most useful for users - if (file.size) { - metadata.push( - `Size: ${window.DOMUtils.formatFileSize(file.size)} (${file.size.toLocaleString()} bytes)`, - ); - } - - if (file.media_type) { - metadata.push( - `Media Type: ${ComponentUtils.escapeHtml(file.media_type)}`, - ); - } - - if (file.created_at) { - metadata.push(`Created: ${file.created_at}`); - } - - if (file.updated_at) { - metadata.push(`Updated: ${file.updated_at}`); - } - - // File properties and attributes - if (file.name) { - metadata.push( - `Name: ${ComponentUtils.escapeHtml(file.name)}`, - ); - } - - if (file.directory || file.relative_path) { - metadata.push( - `Directory: ${ComponentUtils.escapeHtml(file.directory || file.relative_path)}`, - ); - } - - // Removed permissions display - // if (file.permissions) { - // metadata.push(`Permissions: ${ComponentUtils.escapeHtml(file.permissions)}`); - // } - - if (file.owner?.username) { - metadata.push( - `Owner: ${ComponentUtils.escapeHtml(file.owner.username)}`, - ); - } - - if (file.expiration_date) { - metadata.push( - `Expires: ${new Date(file.expiration_date).toLocaleDateString()}`, - ); - } - - if (file.bucket_name) { - metadata.push( - `Storage Bucket: ${ComponentUtils.escapeHtml(file.bucket_name)}`, - ); - } - - // Removed checksum display - // if (file.sum_blake3) { - // metadata.push(`Checksum: ${ComponentUtils.escapeHtml(file.sum_blake3)}`); - // } - - // Associated resources - // TODO: Refactor this to handle multiple associations - if (file.capture?.name) { - metadata.push( - `Associated Capture: ${ComponentUtils.escapeHtml(file.capture.name)}`, - ); - } - - if (file.dataset?.name) { - metadata.push( - `Associated Dataset: ${ComponentUtils.escapeHtml(file.dataset.name)}`, - ); - } - - // Additional metadata if available - if (file.metadata && typeof file.metadata === "object") { - for (const [key, value] of Object.entries(file.metadata)) { - if (value !== null && value !== undefined) { - const formattedKey = key - .replace(/_/g, " ") - .replace(/\b\w/g, (l) => l.toUpperCase()); - let formattedValue; - - // Format different types of values - if (typeof value === "boolean") { - formattedValue = value ? "Yes" : "No"; - } else if (typeof value === "number") { - formattedValue = value.toLocaleString(); - } else if (typeof value === "object") { - formattedValue = `${JSON.stringify(value, null, 2)}`; - } else { - formattedValue = ComponentUtils.escapeHtml(String(value)); - } - - metadata.push(`${formattedKey}: ${formattedValue}`); - } - } - } - - if (metadata.length === 0) { - return '

    No metadata available for this file.

    '; - } - - return ``; - } - - /** - * Get CSRF token for API requests - */ - getCSRFToken() { - const token = document.querySelector("[name=csrfmiddlewaretoken]"); - return token ? token.value : ""; - } - - /** - * Load and display file metadata for a specific file in the modal - */ - async loadFileMetadata(fileUuid, fileName) { - const fileMetadataSection = document.getElementById( - `file-metadata-${fileUuid}`, - ); - const metadataContent = - fileMetadataSection?.querySelector(".metadata-content"); - - if (!fileMetadataSection || !metadataContent) return; - - // Toggle visibility - if (fileMetadataSection.style.display === "none") { - fileMetadataSection.style.display = "block"; - - // Check if metadata is already loaded - if (metadataContent.innerHTML.includes("Click to load metadata...")) { - // Show loading state - metadataContent.innerHTML = ` -
    -
    - Loading... -
    - Loading metadata... -
    - `; - - try { - const response = await fetch(`/api/v1/assets/files/${fileUuid}/`, { - method: "GET", - headers: { - "Content-Type": "application/json", - "X-CSRFToken": this.getCSRFToken(), - }, - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const fileData = await response.json(); - - // Format and display the metadata - const formattedMetadata = this.formatFileMetadata(fileData); - metadataContent.innerHTML = formattedMetadata; - } catch (error) { - console.error("Error loading file metadata:", error); - metadataContent.innerHTML = ` -
    - - Failed to load metadata for ${ComponentUtils.escapeHtml(fileName)}. -
    Error: ${ComponentUtils.escapeHtml(error.message)} -
    - `; - } - } - } else { - fileMetadataSection.style.display = "none"; - } - } -} - -/** - * PaginationManager - Handles pagination controls - */ -class PaginationManager { - constructor(config) { - this.containerId = config.containerId; - this.container = document.getElementById(this.containerId); - this.onPageChange = config.onPageChange; - } - - update(pagination) { - if (!this.container || !pagination) return; - - this.container.innerHTML = ""; - - if (pagination.num_pages <= 1) return; - - const ul = document.createElement("ul"); - ul.className = "pagination justify-content-center"; - - // Previous button - if (pagination.has_previous) { - ul.innerHTML += ` -
  • - - - -
  • - `; - } - - // Page numbers - const startPage = Math.max(1, pagination.number - 2); - const endPage = Math.min(pagination.num_pages, pagination.number + 2); - - for (let i = startPage; i <= endPage; i++) { - ul.innerHTML += ` -
  • - ${i} -
  • - `; - } - - // Next button - if (pagination.has_next) { - ul.innerHTML += ` -
  • - - - -
  • - `; - } - - this.container.appendChild(ul); - - // Add click handlers - const links = ul.querySelectorAll("a.page-link"); - for (const link of links) { - link.addEventListener("click", (e) => { - e.preventDefault(); - const page = Number.parseInt(e.target.dataset.page); - if (page && this.onPageChange) { - this.onPageChange(page); - } - }); - } - } -} - -// Make classes available globally -window.ComponentUtils = ComponentUtils; -window.TableManager = TableManager; -window.CapturesTableManager = CapturesTableManager; -window.FilterManager = FilterManager; -window.SearchManager = SearchManager; -window.ModalManager = ModalManager; -window.PaginationManager = PaginationManager; - -// Export classes for module use -if (typeof module !== "undefined" && module.exports) { - module.exports = { - ComponentUtils, - TableManager, - CapturesTableManager, - FilterManager, - SearchManager, - ModalManager, - PaginationManager, - }; -} - -// Add custom styles -const style = document.createElement("style"); -style.textContent = ` - .edit-name-btn:hover i, - .save-name-btn:hover i { - color: white !important; - } - - /* Hide native clear button in Chrome */ - input[type="search"]::-webkit-search-cancel-button { - -webkit-appearance: none; - display: none; - } -`; -document.head.appendChild(style); - -// Global event listener for visualization trigger buttons -document.addEventListener("DOMContentLoaded", () => { - // Initialize VisualizationModal instance if available - if (window.VisualizationModal) { - window.visualizationModalInstance = new window.VisualizationModal(); - } - - // Handle clicks on visualization trigger buttons - document.addEventListener("click", (e) => { - if (e.target.closest(".visualization-trigger-btn")) { - const button = e.target.closest(".visualization-trigger-btn"); - const captureUuid = button.getAttribute("data-capture-uuid"); - const captureType = button.getAttribute("data-capture-type"); - - if (captureUuid && captureType) { - // Use the VisualizationModal instance to open with capture data - if (window.visualizationModalInstance) { - window.visualizationModalInstance.openWithCaptureData( - captureUuid, - captureType, - ); - } - } - } - }); -}); diff --git a/gateway/sds_gateway/static/js/file-list.js b/gateway/sds_gateway/static/js/file-list.js deleted file mode 100644 index 6e8ba3dd2..000000000 --- a/gateway/sds_gateway/static/js/file-list.js +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Capture list page entrypoint. - * Controllers: captures/FileListPageController.js, captures/FileListCapturesTableManager.js. - * Full legacy copy: static/js/deprecated/file-list.js - */ -window.initializeFrequencySlider = () => { - if (window.fileListController) { - window.fileListController.initializeFrequencyFromURL(); - } -}; - -document.addEventListener("DOMContentLoaded", () => { - try { - window.fileListController = new FileListPageController(); - } catch (error) { - console.error("Error initializing file list controller:", error); - } -}); - -if (typeof module !== "undefined" && module.exports) { - const { FileListPageController } = require("./captures/FileListPageController.js"); - const { - FileListCapturesTableManager, - } = require("./captures/FileListCapturesTableManager.js"); - module.exports = { FileListController: FileListPageController, FileListCapturesTableManager }; -} diff --git a/gateway/sds_gateway/static/js/file-manager.js b/gateway/sds_gateway/static/js/file-manager.js deleted file mode 100644 index 1498e8c69..000000000 --- a/gateway/sds_gateway/static/js/file-manager.js +++ /dev/null @@ -1,1464 +0,0 @@ -class FileManager { - constructor() { - // Check browser compatibility before proceeding - if (!this.checkBrowserSupport()) { - this.showError( - "Your browser doesn't support required features. Please use a modern browser.", - null, - "browser-compatibility", - ); - return; - } - - this.droppedFiles = null; - this.boundHandlers = new Map(); // Track bound event handlers for cleanup - this.activeModals = new Set(); // Track active modals - - this._fileDrop = new FileDropManager(this); - this._fileDrop.addGlobalDropGuards(); - this.init(); - } - - convertToFiles(itemsOrFiles) { - return this._fileDrop.convertToFiles(itemsOrFiles); - } - - async collectFilesFromDataTransfer(dataTransfer) { - return this._fileDrop.collectFilesFromDataTransfer(dataTransfer); - } - - stripHtml(html) { - if (!html) return ""; - const div = document.createElement("div"); - div.innerHTML = html; - return (div.textContent || div.innerText || "").trim(); - } - - init() { - // Get container and data - this.container = document.querySelector(".files-container"); - if (!this.container) { - this.showError("Files container not found", null, "initialization"); - return; - } - - // Get data attributes - this.currentDir = this.container.dataset.currentDir; - this.userEmail = this.container.dataset.userEmail; - - // Get all file cards for data - const fileCards = document.querySelectorAll(".file-card:not(.header)"); - const items = Array.from(fileCards).map((card) => ({ - type: card.dataset.type, - name: card.querySelector(".file-name").textContent, - path: card.dataset.path, - uuid: card.dataset.uuid, - is_capture: card.dataset.isCapture === "true", - - is_shared: card.dataset.isShared === "true", - capture_uuid: card.dataset.captureUuid, - description: card.dataset.description, - modified_at: card.querySelector(".file-meta").textContent.trim(), - shared_by: card.querySelector(".file-shared").textContent.trim(), - })); - - // Get dataset options - const datasetSelect = document.getElementById("datasetSelect"); - const datasets = datasetSelect - ? Array.from(datasetSelect.options) - .slice(1) - .map((opt) => ({ - name: opt.text, - uuid: opt.value, - })) - : []; - - // Initialize all handlers - this.initializeEventListeners(); - this.initializeUploadHandlers(); - this.initializeFileClicks(); - } - - initializeEventListeners() { - const fileCards = document.querySelectorAll(".file-card"); - - for (const card of fileCards) { - if (!card.classList.contains("header")) { - const type = card.dataset.type; - // Add click handlers to directories and files - card.addEventListener("click", (e) => - this.handleFileCardClick(e, card), - ); - // Basic keyboard accessibility - card.setAttribute("tabindex", "0"); - card.addEventListener("keydown", (e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - this.handleFileCardClick(e, card); - } - }); - - if (type === "directory") { - card.style.cursor = "pointer"; - card.classList.add("clickable-directory"); - } else if (type === "file") { - card.style.cursor = "pointer"; - card.classList.add("clickable-file"); - } - } - } - } - - initializeUploadHandlers() { - // Initialize capture upload - const captureElements = { - uploadZone: document.getElementById("uploadZone"), - fileInput: document.getElementById("captureFileInput"), - // browseButton is optional in Files modal styling - browseButton: document.querySelector( - "#uploadCaptureModal .browse-button", - ), - selectedFilesList: document.getElementById("selectedFilesList"), - uploadForm: document.getElementById("uploadCaptureForm"), - }; - - // Only require the essentials; browseButton may be missing - const essentials = [ - captureElements.uploadZone, - captureElements.fileInput, - captureElements.selectedFilesList, - captureElements.uploadForm, - ]; - // Skip initialization if we're on the files page which has its own custom handler - const isFilesPage = window.location.pathname.includes("/users/files/"); - if (essentials.every((el) => el) && !isFilesPage) { - this.initializeCaptureUpload(captureElements); - } - - // Initialize text file upload - const textUploadForm = document.getElementById("uploadFileForm"); - if (textUploadForm) { - this.initializeTextFileUpload(textUploadForm); - } - } - - initializeCaptureUpload(elements) { - const { - uploadZone, - fileInput, - browseButton, - selectedFilesList, - uploadForm, - } = elements; - - if (!uploadZone || !fileInput || !selectedFilesList || !uploadForm) { - this.showError( - "Upload elements not found", - null, - "upload-initialization", - ); - return; - } - - // Handle browse button click (if present) - if (browseButton) { - browseButton.addEventListener("click", (e) => { - e.preventDefault(); - e.stopPropagation(); - fileInput.click(); - }); - } - - // Handle drag and drop - uploadZone.addEventListener("dragover", (e) => { - e.preventDefault(); - uploadZone.classList.add("drag-over"); - }); - - uploadZone.addEventListener("dragleave", () => { - uploadZone.classList.remove("drag-over"); - }); - - uploadZone.addEventListener("drop", async (e) => { - e.preventDefault(); - uploadZone.classList.remove("drag-over"); - const dt = e.dataTransfer; - if (dt) { - const files = await this.collectFilesFromDataTransfer(dt); - this.droppedFiles = files; - // Clear any existing input selection so we rely on dropped files on submit - try { - fileInput.value = ""; - } catch (_) {} - this.handleFileSelection(files); - } - }); - - // Handle file input change - fileInput.addEventListener("change", (e) => { - this.droppedFiles = null; // prefer explicit file input selection - this.handleFileSelection(this.convertToFiles(e.target.files)); - }); - - // Toggle DRF/RH input groups - const typeSelect = document.getElementById("captureTypeSelect"); - const channelGroup = document.getElementById("channelInputGroup"); - const scanGroup = document.getElementById("scanGroupInputGroup"); - const channelInput = document.getElementById("captureChannelsInput"); - - if (typeSelect) { - typeSelect.addEventListener("change", () => { - const v = typeSelect.value; - - // Use Bootstrap classes instead of inline styles - if (channelGroup) { - if (v === "drf") { - channelGroup.classList.remove("d-none"); - channelGroup.style.display = ""; - } else { - channelGroup.classList.add("d-none"); - } - } - - if (scanGroup) { - if (v === "rh") { - scanGroup.classList.remove("d-none"); - scanGroup.style.display = ""; - } else { - scanGroup.classList.add("d-none"); - } - } - - if (channelInput) { - if (v === "drf") { - channelInput.setAttribute("required", "required"); - } else { - channelInput.removeAttribute("required"); - } - } - }); - - // Trigger change event to set initial state - typeSelect.dispatchEvent(new Event("change")); - } - - // Check for globally dropped files when modal opens - if (window.selectedFiles?.length) { - this.handleFileSelection(window.selectedFiles); - } - - // Handle form submission - uploadForm.addEventListener("submit", async (e) => { - e.preventDefault(); - - const formData = new FormData(); - const submitBtn = uploadForm.querySelector('button[type="submit"]'); - const uploadText = submitBtn.querySelector(".upload-text"); - const uploadSpinner = submitBtn.querySelector(".upload-spinner"); - - try { - submitBtn.disabled = true; - uploadText.classList.add("d-none"); - uploadSpinner.classList.remove("d-none"); - - // Get CSRF token and add it when present - const csrfToken = this.getCsrfToken(); - if (csrfToken) { - formData.append("csrfmiddlewaretoken", csrfToken); - } - - // Add capture type and channels from the form - const captureType = document.getElementById("captureTypeSelect").value; - const channels = - document.getElementById("captureChannelsInput")?.value || ""; - const scanGroupVal = - document.getElementById("captureScanGroupInput")?.value || ""; - formData.append("capture_type", captureType); - formData.append("channels", channels); - if (captureType === "rh" && scanGroupVal) { - formData.append("scan_group", scanGroupVal); - } - - // Add files and their relative paths - check for globally dropped files first - const files = window.selectedFiles?.length - ? Array.from(window.selectedFiles) - : this.droppedFiles?.length - ? Array.from(this.droppedFiles) - : Array.from(fileInput.files); - - // Create an array of relative paths in the same order as files - const relativePaths = files.map( - (file) => file.webkitRelativePath || file.name, - ); - - // Add each file - for (const [index, file] of files.entries()) { - formData.append("files", file); - formData.append("relative_paths", relativePaths[index]); - } - - await this.handleUpload(formData, submitBtn, "uploadCaptureModal", { - files, - }); - } catch (error) { - const userMessage = this.getUserFriendlyErrorMessage( - error, - "capture-upload", - ); - this.showError( - `Upload failed: ${userMessage}`, - error, - "capture-upload", - ); - } finally { - submitBtn.disabled = false; - uploadText.classList.remove("d-none"); - uploadSpinner.classList.add("d-none"); - this.droppedFiles = null; - window.selectedFiles = null; // Clear global files after upload - } - }); - } - - initializeTextFileUpload(uploadForm) { - uploadForm.addEventListener("submit", async (e) => { - e.preventDefault(); - - const formData = new FormData(uploadForm); - const submitBtn = uploadForm.querySelector('button[type="submit"]'); - const uploadText = submitBtn.querySelector(".upload-text"); - const uploadSpinner = submitBtn.querySelector(".upload-spinner"); - - try { - submitBtn.disabled = true; - uploadText.classList.add("d-none"); - uploadSpinner.classList.remove("d-none"); - - // CSRF token attached in handleUpload - - await this.handleUpload(formData, submitBtn, "uploadFileModal"); - } catch (error) { - const userMessage = this.getUserFriendlyErrorMessage( - error, - "text-upload", - ); - this.showError(`Upload failed: ${userMessage}`, error, "text-upload"); - } finally { - submitBtn.disabled = false; - uploadText.classList.remove("d-none"); - uploadSpinner.classList.add("d-none"); - } - }); - } - - initializeFileClicks() { - // Wire up download confirmation for dataset and capture buttons - document.addEventListener("click", (e) => { - if ( - e.target.matches(".download-capture-btn") || - e.target.closest(".download-capture-btn") - ) { - e.preventDefault(); - e.stopPropagation(); - const btn = e.target.matches(".download-capture-btn") - ? e.target - : e.target.closest(".download-capture-btn"); - const captureUuid = btn.dataset.captureUuid; - const captureName = btn.dataset.captureName || captureUuid; - - // Validate UUID before proceeding - if (!this.isValidUuid(captureUuid)) { - console.warn("Invalid capture UUID:", captureUuid); - this.showError("Invalid capture identifier", null, "download"); - return; - } - - // Update modal text - const nameEl = document.getElementById("downloadCaptureName"); - if (nameEl) nameEl.textContent = captureName; - - // Show modal using helper method - this.openModal("downloadModal"); - - // Confirm handler - const confirmBtn = document.getElementById("confirmDownloadBtn"); - if (confirmBtn) { - const onConfirm = () => { - this.closeModal("downloadModal"); - - // Use unified download handler if available - if (window.components?.handleDownload) { - const dummyButton = document.createElement("button"); - dummyButton.style.display = "none"; - window.components.handleDownload( - "capture", - captureUuid, - dummyButton, - ); - } - }; - confirmBtn.addEventListener("click", onConfirm, { once: true }); - } - } - - if ( - e.target.matches(".download-dataset-btn") || - e.target.closest(".download-dataset-btn") - ) { - e.preventDefault(); - e.stopPropagation(); - const btn = e.target.matches(".download-dataset-btn") - ? e.target - : e.target.closest(".download-dataset-btn"); - const datasetUuid = btn.dataset.datasetUuid; - - // Validate UUID before proceeding - if (!this.isValidUuid(datasetUuid)) { - console.warn("Invalid dataset UUID:", datasetUuid); - this.showError("Invalid dataset identifier", null, "download"); - return; - } - // Show modal using helper method - this.openModal("downloadModal"); - const confirmBtn = document.getElementById("confirmDownloadBtn"); - if (confirmBtn) { - const onConfirm = () => { - this.closeModal("downloadModal"); - fetch( - `/users/download-item/dataset/${encodeURIComponent(datasetUuid)}/`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-CSRFToken": this.getCsrfToken(), - }, - }, - ) - .then(async (r) => { - try { - return await r.json(); - } catch (_) { - return {}; - } - }) - .catch(() => {}); - }; - confirmBtn.addEventListener("click", onConfirm, { once: true }); - } - } - - // Single file direct download link (GET) - const fileDownloadLink = - e.target.closest( - 'a.dropdown-item[href^="/users/files/"][href$="/download/"]', - ) || - e.target.closest( - '.dropdown-menu a[href^="/users/files/"][href$="/download/"]', - ) || - e.target.closest('a[href^="/users/files/"][href$="/download/"]'); - if (fileDownloadLink) { - e.preventDefault(); - e.stopPropagation(); - const card = fileDownloadLink.closest(".file-card"); - const fileName = - card?.querySelector(".file-name")?.textContent?.trim() || "File"; - // Use helper method to show success message - this.showSuccessMessage(`Download starting: ${fileName}`); - const href = fileDownloadLink.getAttribute("href"); - try { - window.open(href, "_blank"); - } catch (_) { - window.location.href = href; - } - return; - } - }); - } - - async handleUpload(formData, submitBtn, modalId, options = {}) { - const uploadText = submitBtn.querySelector(".upload-text"); - const uploadSpinner = submitBtn.querySelector(".upload-spinner"); - - try { - // Update UI - submitBtn.disabled = true; - uploadText.classList.add("d-none"); - uploadSpinner.classList.remove("d-none"); - - // Make request with progress (XHR for upload progress events) - const response = await new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.open("POST", "/users/upload-files/"); - xhr.withCredentials = true; - xhr.setRequestHeader("X-CSRFToken", this.getCsrfToken()); - xhr.setRequestHeader("Accept", "application/json"); - - // Progress UI elements + smoothing state - const wrap = document.getElementById("captureUploadProgressWrap"); - const bar = document.getElementById("captureUploadProgressBar"); - const text = document.getElementById("captureUploadProgressText"); - if (wrap) wrap.classList.remove("d-none"); - if (bar) { - bar.classList.add("progress-bar-striped", "progress-bar-animated"); - bar.style.width = "100%"; - bar.setAttribute("aria-valuenow", "100"); - bar.textContent = ""; - } - if (text) text.textContent = "Uploading…"; - - xhr.upload.onprogress = () => { - // Keep indeterminate to match button spinner timing (no file count) - if (text) text.textContent = "Uploading…"; - }; - - xhr.onerror = () => reject(new Error("Network error during upload")); - xhr.upload.onloadstart = () => { - if (text) text.textContent = "Starting upload…"; - }; - xhr.upload.onloadend = () => { - if (bar) { - bar.classList.add("progress-bar-striped", "progress-bar-animated"); - bar.style.width = "100%"; - bar.setAttribute("aria-valuenow", "100"); - bar.textContent = ""; - } - if (text) text.textContent = "Processing on server…"; - }; - xhr.onload = () => { - // Build a Response-like object compatible with existing code - const status = xhr.status; - const headers = new Headers({ - "content-type": xhr.getResponseHeader("content-type") || "", - }); - const bodyText = xhr.responseText || ""; - const responseLike = { - ok: status >= 200 && status < 300, - status, - headers, - json: async () => { - try { - return JSON.parse(bodyText); - } catch { - return {}; - } - }, - text: async () => bodyText, - }; - resolve(responseLike); - }; - - xhr.send(formData); - }); - - let result = null; - let fallbackText = ""; - try { - const contentType = response.headers.get("content-type") || ""; - if (contentType.includes("application/json")) { - result = await response.json(); - } else { - fallbackText = await response.text(); - } - } catch (_) {} - - if (response.ok) { - // Build a concise success message for inline banner - let successMessage = "Upload complete."; - if (result && (result.files_uploaded || result.total_files)) { - const uploaded = result.files_uploaded ?? result.total_uploaded ?? 0; - const total = result.total_files ?? result.total_uploaded ?? 0; - successMessage = `Upload complete: ${uploaded} / ${total} file${total === 1 ? "" : "s"} uploaded.`; - if (Array.isArray(result.errors) && result.errors.length) { - successMessage += " Some items were skipped or failed."; - } - } - try { - sessionStorage.setItem( - "filesAlert", - JSON.stringify({ - message: successMessage, - type: "success", - }), - ); - } catch (_) {} - // Reload to show inline banner on main page - window.location.reload(); - } else { - let message = ""; - if (result && (result.detail || result.error || result.message)) { - message = result.detail || result.error || result.message; - } else if (fallbackText) { - message = this.stripHtml(fallbackText) - .split("\n") - .slice(0, 3) - .join(" "); - } - if (!message) message = `Upload failed (${response.status})`; - // Friendly mapping for common statuses - if (response.status === 409) { - message = - "Upload skipped: a file with the same checksum already exists. Use PATCH to replace, or change the file."; - } - throw new Error(message); - } - } catch (error) { - const userMessage = this.getUserFriendlyErrorMessage( - error, - "upload-handler", - ); - try { - sessionStorage.setItem( - "filesAlert", - JSON.stringify({ - message: `Upload failed: ${userMessage}`, - type: "error", - }), - ); - // Reload to display the banner via template startup script - window.location.reload(); - } catch (_) { - this.showError( - `Upload failed: ${userMessage}`, - error, - "upload-handler", - ); - } - } finally { - // Reset UI - submitBtn.disabled = false; - uploadText.classList.remove("d-none"); - uploadSpinner.classList.add("d-none"); - } - } - - showUploadSuccess(result, modalId) { - const resultModal = new bootstrap.Modal( - document.getElementById("uploadResultModal"), - ); - const resultBody = document.getElementById("uploadResultModalBody"); - - resultBody.innerHTML = ` -
    -
    Upload Complete!
    - ${ - result.files_uploaded - ? `

    Files uploaded: ${result.files_uploaded} / ${result.total_files}

    ` - : "

    File uploaded successfully!

    " - } - ${result.errors ? `

    Errors: ${result.errors.join("
    ")}

    ` : ""} -
    - `; - - // Close upload modal and show result (guard instance) - const uploadModalEl = document.getElementById(modalId); - const uploadModalInstance = uploadModalEl - ? bootstrap.Modal.getInstance(uploadModalEl) - : null; - if (uploadModalInstance) { - uploadModalInstance.hide(); - } - resultModal.show(); - } - - // File preview methods - async showTextFilePreview(fileUuid, fileName) { - try { - // Check if this is a file we should preview - if (!this.shouldPreviewFile(fileName)) { - this.showError("This file type cannot be previewed"); - return; - } - - const content = await this.fetchFileContent(fileUuid); - this.showPreviewModal(fileName, content); - } catch (error) { - if (error.message === "File too large to preview") { - this.showError( - "File is too large to preview. Please download it instead.", - error, - "file-preview", - ); - } else { - const userMessage = this.getUserFriendlyErrorMessage( - error, - "file-preview", - ); - this.showError(userMessage, error, "file-preview"); - } - } - } - - shouldPreviewFile(fileName) { - const extension = this.getFileExtension(fileName); - return this.isPreviewableFileType(extension); - } - - async fetchFileContent(fileUuid) { - const response = await fetch(`/users/files/${fileUuid}/content/`); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || "Failed to fetch file content"); - } - - return response.text(); - } - - showPreviewModal(fileName, content) { - const modal = document.getElementById("filePreviewModal"); - const modalTitle = modal.querySelector(".modal-title"); - const previewContent = modal.querySelector(".preview-content"); - - // Enhanced accessibility - modal.setAttribute("aria-label", `Preview of ${fileName}`); - modal.setAttribute("aria-describedby", "preview-content"); - modal.setAttribute("role", "dialog"); - - modalTitle.textContent = fileName; - modalTitle.setAttribute("id", "preview-modal-title"); - - // Clear previous content - previewContent.innerHTML = ""; - previewContent.setAttribute("id", "preview-content"); - previewContent.setAttribute("aria-label", `Content of ${fileName}`); - - // Check if we should use syntax highlighting - if (this.shouldUseSyntaxHighlighting(fileName)) { - this.showSyntaxHighlightedContent(previewContent, content, fileName); - } else { - // Basic text display - const preElement = this.createElement("pre", "preview-text", content); - preElement.setAttribute("aria-label", `Text content of ${fileName}`); - previewContent.appendChild(preElement); - } - - new bootstrap.Modal(modal).show(); - } - - // Helper methods for syntax highlighting - getFileExtension(fileName) { - return fileName.split(".").pop().toLowerCase(); - } - - // Helper method to open modal with fallbacks - openModal(modalId) { - this.activeModals.add(modalId); - - if (window.components?.openCustomModal) { - window.components.openCustomModal(modalId); - } else if (typeof openCustomModal === "function") { - openCustomModal(modalId); - } else if (window.openCustomModal) { - window.openCustomModal(modalId); - } else { - const modal = document.getElementById(modalId); - if (modal) modal.style.display = "block"; - } - } - - // Helper method to close modal with fallbacks - closeModal(modalId) { - this.activeModals.delete(modalId); - - if (window.components?.closeCustomModal) { - window.components.closeCustomModal(modalId); - } else if (typeof closeCustomModal === "function") { - closeCustomModal(modalId); - } else if (window.closeCustomModal) { - window.closeCustomModal(modalId); - } else { - const modal = document.getElementById(modalId); - if (modal) modal.style.display = "none"; - } - } - - // Helper method to show success message with fallbacks - showSuccessMessage(message) { - if (window.components?.showSuccess) { - window.components.showSuccess(message); - } else { - const live = document.getElementById("aria-live-region"); - if (live) live.textContent = message; - } - } - - // Helper method to get CSRF token - getCsrfToken() { - return document.querySelector("[name=csrfmiddlewaretoken]")?.value || ""; - } - - // Helper method to check if file has extension - hasFileExtension(fileName) { - return /\.[^./]+$/.test(fileName); - } - - // Input validation methods - isValidUuid(uuid) { - if (!uuid || typeof uuid !== "string") return false; - const uuidRegex = - /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - return uuidRegex.test(uuid); - } - - isValidFileName(fileName) { - if (!fileName || typeof fileName !== "string") return false; - // Check for invalid characters and length - const invalidChars = /[<>:"/\\|?*]/; - return !invalidChars.test(fileName) && fileName.length <= 255; - } - - isValidPath(path) { - if (!path || typeof path !== "string") return false; - // Check for path traversal attempts and invalid characters - const invalidPathPatterns = /\.\.|[<>:"|?*]/; - return !invalidPathPatterns.test(path) && path.length <= 4096; - } - - sanitizeFileName(fileName) { - if (!fileName) return ""; - // Remove or replace invalid characters - return fileName.replace(/[<>:"/\\|?*]/g, "_"); - } - - // Helper method to create DOM element with attributes - createElement(tag, className, innerHTML) { - const element = document.createElement(tag); - if (className) element.className = className; - if (innerHTML) element.innerHTML = innerHTML; - return element; - } - - // Helper method to check if file type is previewable - isPreviewableFileType(extension) { - const nonPreviewableExtensions = [ - "7z", - "a", - "accdb", - "ai", - "avi", - "bak", - "bin", - "bz2", - "class", - "dll", - "dmg", - "doc", - "docx", - "ear", - "eps", - "exe", - "flv", - "gz", - "h5", - "hdf", - "hdf5", - "img", - "iso", - "jar", - "lib", - "log", - "mat", - "mdb", - "mov", - "mp3", - "mp4", - "nc", - "netcdf", - "obj", - "odp", - "ods", - "odt", - "o", - "out", - "pdf", - "pdb", - "pkg", - "ppt", - "pptx", - "psd", - "r", - "rar", - "rdata", - "rds", - "raw", - "rpm", - "sav", - "so", - "sqlite", - "svg", - "tar", - "temp", - "tmp", - "war", - "wmv", - "xls", - "xlsx", - "zip", - ]; - return !nonPreviewableExtensions.includes(extension); - } - - // Helper method to extract text from notebook cell source - extractCellSourceText(source) { - if (Array.isArray(source)) { - return source.join("") || ""; - } - if (typeof source === "string") { - return source; - } - return String(source || ""); - } - - // Helper method to extract text from notebook cell output - extractCellOutputText(output) { - if (output.output_type === "stream") { - return Array.isArray(output.text) - ? output.text.join("") - : String(output.text || ""); - } - if (output.output_type === "execute_result") { - return output.data?.["text/plain"] - ? Array.isArray(output.data["text/plain"]) - ? output.data["text/plain"].join("") - : String(output.data["text/plain"]) - : ""; - } - return ""; - } - - getLanguageFromExtension(extension) { - const languageMap = { - js: "javascript", - jsx: "javascript", - ts: "typescript", - tsx: "typescript", - py: "python", - pyw: "python", - ipynb: "json", // Jupyter notebooks are JSON - json: "json", - xml: "markup", - html: "markup", - htm: "markup", - css: "css", - scss: "css", - sass: "css", - sh: "bash", - bash: "bash", - zsh: "bash", - fish: "bash", - c: "c", - cpp: "cpp", - cc: "cpp", - cxx: "cpp", - h: "c", - hpp: "cpp", - java: "java", - php: "php", - rb: "ruby", - go: "go", - rs: "rust", - swift: "swift", - kt: "kotlin", - scala: "scala", - clj: "clojure", - hs: "haskell", - ml: "ocaml", - fs: "fsharp", - cs: "csharp", - vb: "vbnet", - sql: "sql", - r: "r", - m: "matlab", - pl: "perl", - tcl: "tcl", - lua: "lua", - vim: "vim", - yaml: "yaml", - yml: "yaml", - toml: "toml", - ini: "ini", - cfg: "ini", - conf: "ini", - md: "markdown", - markdown: "markdown", - txt: "text", - log: "text", - }; - return languageMap[extension] || "text"; - } - - shouldUseSyntaxHighlighting(fileName) { - const extension = this.getFileExtension(fileName); - const highlightableExtensions = [ - "js", - "jsx", - "ts", - "tsx", - "py", - "pyw", - "ipynb", - "json", - "xml", - "html", - "htm", - "css", - "scss", - "sass", - "sh", - "bash", - "zsh", - "fish", - "c", - "cpp", - "cc", - "cxx", - "h", - "hpp", - "java", - "php", - "rb", - "go", - "rs", - "swift", - "kt", - "scala", - "clj", - "hs", - "ml", - "fs", - "cs", - "vb", - "sql", - "r", - "m", - "pl", - "tcl", - "lua", - "vim", - "yaml", - "yml", - "toml", - "ini", - "cfg", - "conf", - "md", - "markdown", - ]; - return highlightableExtensions.includes(extension); - } - - showSyntaxHighlightedContent(container, content, fileName) { - const extension = this.getFileExtension(fileName); - const language = this.getLanguageFromExtension(extension); - - // Special handling for Jupyter notebooks - if (extension === "ipynb") { - this.showJupyterNotebookPreview(container, content, fileName); - return; - } - - // Create code element with language class - const codeElement = this.createElement("code", `language-${language}`); - codeElement.textContent = content; - - // Create pre element - const preElement = this.createElement("pre", "syntax-highlighted"); - preElement.appendChild(codeElement); - - // Add to container - container.appendChild(preElement); - - // Apply Prism.js highlighting - if (window.Prism) { - window.Prism.highlightElement(codeElement); - } - } - - showJupyterNotebookPreview(container, content, fileName) { - try { - // Parse the JSON content - const notebook = JSON.parse(content); - - // Create a container for the notebook preview - const notebookContainer = this.createElement( - "div", - "jupyter-notebook-preview", - ); - - // Add notebook metadata header - const header = this.createElement("div", "notebook-header"); - header.innerHTML = ` -
    - - ${notebook.metadata?.title || fileName} -
    -
    - ${notebook.metadata?.kernelspec?.display_name || "Python"} - ${notebook.cells?.length || 0} cells -
    - `; - notebookContainer.appendChild(header); - - // Process each cell - if (notebook.cells && Array.isArray(notebook.cells)) { - notebook.cells.forEach((cell, index) => { - const cellElement = this.createNotebookCell(cell, index); - notebookContainer.appendChild(cellElement); - }); - } - - container.appendChild(notebookContainer); - } catch (error) { - // Fallback to JSON display if parsing fails - console.warn( - "Failed to parse Jupyter notebook, falling back to JSON:", - error, - ); - this.showSyntaxHighlightedContent(container, content, "fallback.json"); - } - } - - createNotebookCell(cell, index) { - const cellContainer = this.createElement( - "div", - `notebook-cell ${cell.cell_type}`, - ); - const cellHeader = this.createElement("div", "cell-header"); - - let headerContent = ""; - if (cell.cell_type === "code") { - const execCount = - cell.execution_count !== null ? cell.execution_count : " "; - headerContent = ` - Code - In [${execCount}]: - `; - } else { - headerContent = `Markdown`; - } - - cellHeader.innerHTML = headerContent; - cellContainer.appendChild(cellHeader); - - // Cell content - const cellContent = this.createElement("div", "cell-content"); - - if (cell.cell_type === "code") { - // Code cell with syntax highlighting - const codeElement = this.createElement("code", "language-python"); - const sourceText = this.extractCellSourceText(cell.source); - codeElement.textContent = sourceText; - - const preElement = this.createElement("pre"); - preElement.appendChild(codeElement); - cellContent.appendChild(preElement); - - // Apply syntax highlighting - if (window.Prism) { - window.Prism.highlightElement(codeElement); - } - - // Add output if present - if (cell.outputs && cell.outputs.length > 0) { - const outputContainer = this.createElement("div", "cell-output"); - outputContainer.innerHTML = `Out [${cell.execution_count}]:`; - - for (const output of cell.outputs) { - const outputText = this.extractCellOutputText(output); - if (outputText) { - const outputElement = this.createElement( - "pre", - output.output_type === "stream" - ? "output-stream" - : "output-result", - ); - outputElement.textContent = outputText; - outputContainer.appendChild(outputElement); - } - } - - cellContent.appendChild(outputContainer); - } - } else { - // Markdown cell - const markdownElement = this.createElement("div", "markdown-content"); - const sourceText = this.extractCellSourceText(cell.source); - markdownElement.textContent = sourceText; - cellContent.appendChild(markdownElement); - } - - cellContainer.appendChild(cellContent); - return cellContainer; - } - - showError(message, error = null, context = "") { - // Log error details for debugging - if (error) { - console.error(`FileManager Error [${context}]:`, { - message: error.message, - stack: error.stack, - userMessage: message, - timestamp: new Date().toISOString(), - userAgent: navigator.userAgent, - }); - } else { - console.warn(`FileManager Warning [${context}]:`, message); - } - - // Show user-friendly error message - if (window.components?.showError) { - window.components.showError(message); - return; - } - const live = document.getElementById("aria-live-region"); - if (live) { - live.textContent = message; - return; - } - // Final fallback: inline banner near top - const container = - document.querySelector(".container-fluid") || document.body; - const div = this.createElement( - "div", - "alert alert-danger alert-dismissible fade show", - `${message}`, - ); - container.insertBefore(div, container.firstChild); - } - - // Enhanced error message formatting - getUserFriendlyErrorMessage(error, context = "") { - if (!error) return "An unexpected error occurred"; - - // Handle common error types - if (error.name === "NetworkError" || error.message.includes("fetch")) { - return "Network error: Please check your connection and try again"; - } - if (error.name === "TypeError" && error.message.includes("JSON")) { - return "Invalid response format: Please try again or contact support"; - } - if (error.message.includes("403") || error.message.includes("Forbidden")) { - return "Access denied: You don't have permission to perform this action"; - } - if (error.message.includes("404") || error.message.includes("Not Found")) { - return "Resource not found: The requested file or directory may have been moved or deleted"; - } - if ( - error.message.includes("500") || - error.message.includes("Internal Server Error") - ) { - return "Server error: Please try again later or contact support"; - } - - // Default user-friendly message - return error.message || "An unexpected error occurred"; - } - - escapeHtml(unsafe) { - return unsafe - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); - } - - // Memory management and cleanup - cleanup() { - // Remove all bound event handlers - for (const [element, handler] of this.boundHandlers) { - if (element?.removeEventListener) { - element.removeEventListener("click", handler); - } - } - this.boundHandlers.clear(); - - // Close all active modals - for (const modalId of this.activeModals) { - this.closeModal(modalId); - } - this.activeModals.clear(); - - // Clear file references - this.droppedFiles = null; - window.selectedFiles = null; - - console.log("FileManager cleanup completed"); - } - - // Browser compatibility check - checkBrowserSupport() { - const requiredFeatures = { - "File API": "File" in window, - FileReader: "FileReader" in window, - FormData: "FormData" in window, - "Fetch API": "fetch" in window, - Promise: "Promise" in window, - Map: "Map" in window, - Set: "Set" in window, - }; - - const missingFeatures = Object.entries(requiredFeatures) - .filter(([name, supported]) => !supported) - .map(([name]) => name); - - if (missingFeatures.length > 0) { - console.warn("Missing browser features:", missingFeatures); - return false; - } - - return true; - } - - // Track event handler for cleanup - bindEventHandler(element, event, handler) { - this.boundHandlers.set(element, handler); - element.addEventListener(event, handler); - } - - handleFileSelection(files) { - const selectedFilesList = document.getElementById("selectedFilesList"); - const selectedFiles = document.getElementById("selectedFiles"); - if (!selectedFilesList || !selectedFiles) return; - selectedFilesList.innerHTML = ""; - - const allFiles = Array.from(files || []); - // Filter out likely directory placeholders that some browsers expose on drop - const realFiles = allFiles.filter((f) => { - // Keep if size > 0 or has a known extension or MIME type - const hasExtension = this.hasFileExtension(f.name); - return f.size > 0 || hasExtension || (f.type && f.type.length > 0); - }); - - // If selection came from the file input (webkitdirectory browse), show all files. - // If it came from drag-and-drop, we may have limited UI space; still show all for clarity. - for (const file of realFiles) { - const li = this.createElement( - "li", - "", - ` - - ${file.webkitRelativePath || file.name} - `, - ); - selectedFilesList.appendChild(li); - } - - if (realFiles.length > 0) { - selectedFiles.classList.add("has-files"); - } else { - selectedFiles.classList.remove("has-files"); - } - } - - renderFileTree(node, container, path = "") { - for (const [name, value] of Object.entries(node)) { - let li; - if (value instanceof File) { - // Render file - li = this.createElement( - "li", - "", - ` - - ${name} - `, - ); - } else { - // Render directory - li = this.createElement( - "li", - "", - ` - - ${name} -
      - `, - ); - this.renderFileTree(value, li.querySelector("ul"), `${path + name}/`); - } - container.appendChild(li); - } - } - - handleFileCardClick(e, card) { - // Ignore clicks originating from the actions area (dropdown/buttons) - if (e.target.closest(".file-actions")) { - return; - } - - const type = card.dataset.type; - const path = card.dataset.path; - const uuid = card.dataset.uuid; - - if (type === "directory") { - this.handleDirectoryClick(path); - } else if (type === "dataset") { - this.handleDatasetClick(uuid); - } else if (type === "file") { - this.handleFileClick(card, uuid); - } - } - - handleDirectoryClick(path) { - if (path && this.isValidPath(path)) { - // Remove any duplicate slashes and ensure proper path format - const cleanPath = path.replace(/\/+/g, "/").replace(/\/$/, ""); - // Build the navigation URL - const navUrl = `/users/files/?dir=${encodeURIComponent(cleanPath)}`; - // Navigate to the directory using the dir query parameter - window.location.href = navUrl; - } else { - console.warn("Invalid directory path:", path); - this.showError("Invalid directory path", null, "navigation"); - } - } - - handleDatasetClick(uuid) { - if (uuid && this.isValidUuid(uuid)) { - const datasetUrl = `/users/files/?dir=/datasets/${encodeURIComponent(uuid)}`; - window.location.href = datasetUrl; - } else { - console.warn("Invalid dataset UUID:", uuid); - this.showError("Invalid dataset identifier", null, "navigation"); - } - } - - handleFileClick(card, uuid) { - if (uuid && this.isValidUuid(uuid)) { - // Prefer the exact text node for the filename and trim whitespace - const rawName = - card.querySelector(".file-name-text")?.textContent || - card.querySelector(".file-name")?.textContent || - ""; - const name = rawName.trim(); - - // Validate and sanitize filename - if (!this.isValidFileName(name)) { - console.warn("Invalid filename:", name); - this.showError("Invalid filename", null, "file-preview"); - return; - } - - const sanitizedName = this.sanitizeFileName(name); - const lower = sanitizedName.toLowerCase(); - - if (this.shouldPreviewFile(sanitizedName)) { - this.showTextFilePreview(uuid, sanitizedName); - } else if (lower.endsWith(".h5") || lower.endsWith(".hdf5")) { - // H5 files - no preview, no action - } else { - const detailUrl = `/users/file-detail/${uuid}/`; - window.location.href = detailUrl; - } - } else { - console.warn("Invalid file UUID:", uuid); - this.showError("Invalid file identifier", null, "file-preview"); - } - } -} - -// Initialize file manager when DOM is loaded -document.addEventListener("DOMContentLoaded", () => { - new FileManager(); -}); diff --git a/gateway/sds_gateway/static/js/file_list_upload_capture_modal.js b/gateway/sds_gateway/static/js/file_list_upload_capture_modal.js deleted file mode 100644 index f8120cbbe..000000000 --- a/gateway/sds_gateway/static/js/file_list_upload_capture_modal.js +++ /dev/null @@ -1,1075 +0,0 @@ -/* Upload Capture Modal JavaScript */ - -document.addEventListener("DOMContentLoaded", () => { - // Upload Capture Modal JS - let isProcessing = false; // Flag to track if processing is active - // Reset cancellation state on page load - // Add page refresh/close confirmation - let uploadInProgress = false; - - // Clear any existing result modals on page load - const existingResultModal = document.getElementById("uploadResultModal"); - if (existingResultModal) { - const modalInstance = bootstrap.Modal.getInstance(existingResultModal); - if (modalInstance) { - modalInstance.hide(); - } - } - - // Clear any upload-related session storage - if (sessionStorage.getItem("uploadInProgress")) { - sessionStorage.removeItem("uploadInProgress"); - } - - // Handle beforeunload event (page refresh/close) - window.addEventListener("beforeunload", (e) => { - if ( - isProcessing || - uploadInProgress || - sessionStorage.getItem("uploadInProgress") - ) { - e.preventDefault(); - e.returnValue = - "Upload in progress will be aborted. Are you sure you want to leave?"; - return e.returnValue; - } - }); - - // Handle visibility change (tab close/minimize) - document.addEventListener("visibilitychange", () => { - if (document.visibilityState === "hidden" && uploadInProgress) { - // Page hidden during upload - } - }); - - // Get button references - const uploadModal = document.getElementById("uploadCaptureModal"); - if (!uploadModal) { - console.warn("uploadCaptureModal not found"); - return; - } - - const cancelButton = uploadModal.querySelector(".btn-secondary"); - const closeButton = uploadModal.querySelector(".btn-close"); - const submitButton = document.getElementById("uploadSubmitBtn"); - - if (!cancelButton || !closeButton || !submitButton) { - console.warn("Required buttons not found in upload modal"); - return; - } - - // Store abort controller reference for cancellation - let currentAbortController = null; - - // Reset cancellation state when modal is opened - uploadModal.addEventListener("show.bs.modal", () => { - isProcessing = false; - currentAbortController = null; - }); - - // Reset cancellation state when files are selected - const fileInput = document.getElementById("captureFileInput"); - if (fileInput) { - fileInput.addEventListener("change", () => { - isProcessing = false; - currentAbortController = null; - }); - } - - // Reset cancellation state when modal is hidden - uploadModal.addEventListener("hidden.bs.modal", () => { - isProcessing = false; - currentAbortController = null; - }); - - // Handle cancel button click - let cancelRequested = false; - - // Helper function to handle cancellation logic - function handleCancellation(buttonType) { - if (isProcessing) { - // Cancel processing - cancelRequested = true; - // Abort current upload if controller exists - if (currentAbortController) { - currentAbortController.abort(); - } - // Update UI immediately based on button type - if (buttonType === "cancel") { - cancelButton.textContent = "Cancelling..."; - cancelButton.disabled = true; - } else if (buttonType === "close") { - closeButton.disabled = true; - closeButton.style.opacity = "0.5"; - } - // Update progress message - const progressMessage = document.getElementById("progressMessage"); - if (progressMessage) { - progressMessage.textContent = "Cancelling upload..."; - } - // Force UI reset after a short delay to ensure it happens - setTimeout(() => { - if (cancelRequested) { - resetUIState(); - } - }, 500); - } - // If not processing, let the normal button behavior handle it - } - - cancelButton.addEventListener("click", () => { - handleCancellation("cancel"); - }); - - // Handle close button (X) click - same logic as cancel button - closeButton.addEventListener("click", () => { - handleCancellation("close"); - }); - - // Helper function to check for large files - function checkForLargeFiles(files, cancelButton, submitButton) { - const progressSection = document.getElementById("checkingProgressSection"); - const LARGE_FILE_THRESHOLD = 512 * 1024 * 1024; // 512MB in bytes - const largeFiles = files.filter((file) => file.size > LARGE_FILE_THRESHOLD); - - if (largeFiles.length > 0) { - // Reset UI state - progressSection.style.display = "none"; - cancelButton.textContent = "Cancel"; - cancelButton.classList.remove("btn-warning"); - submitButton.disabled = false; - - // Create alert message - const largeFileNames = largeFiles.map((file) => file.name).join(", "); - const alertMessage = `Large files detected (over 512MB): ${largeFileNames}\n\nPlease:\n1. Skip these large files and upload the remaining files, or\n2. Use the SpectrumX SDK (https://pypi.org/project/spectrumx/) to upload large files and add them to your capture.\n\nLarge files may cause issues with the web interface.`; - - alert(alertMessage); - return true; // Indicates large files were found - } - return false; // No large files found - } - - // Helper function to check files for duplicates - async function checkFilesForDuplicates(files, cancelButton, submitButton) { - // Local progress bar variables - const progressSection = document.getElementById("checkingProgressSection"); - const progressBar = document.getElementById("checkingProgressBar"); - const progressText = document.getElementById("checkingProgressText"); - const progressMessage = document.getElementById("progressMessage"); - - // Show progress section - progressSection.style.display = "block"; - progressMessage.textContent = "Processing files for upload..."; - - // Update UI to show processing state - cancelButton.textContent = "Cancel Processing"; - submitButton.disabled = true; - - // Initialize variables for file checking - window.filesToSkip = new Set(); - window.fileCheckResults = new Map(); - - const totalFiles = files.length; - - // Get CSRF token - const getCSRFToken = () => { - const metaToken = document.querySelector('meta[name="csrf-token"]'); - if (metaToken) return metaToken.getAttribute("content"); - const inputToken = document.querySelector('[name="csrfmiddlewaretoken"]'); - if (inputToken) return inputToken.value; - const cookies = document.cookie.split(";"); - for (let i = 0; i < cookies.length; i++) { - const cookie = cookies[i].trim(); - if (cookie.startsWith("csrftoken=")) { - return cookie.substring("csrftoken=".length); - } - } - return ""; - }; - - const csrfToken = getCSRFToken(); - if (!csrfToken) { - throw new Error("CSRF token not found"); - } - - // Check each file for duplicates with progress - for (let i = 0; i < files.length; i++) { - const file = files[i]; - - // Update progress - const progress = Math.round(((i + 1) / totalFiles) * 100); - progressBar.style.width = `${progress}%`; - progressText.textContent = `${progress}%`; - - // Calculate BLAKE3 hash - const buffer = await file.arrayBuffer(); - const hasher = await hashwasm.createBLAKE3(); - hasher.init(); - hasher.update(new Uint8Array(buffer)); - const hashHex = hasher.digest("hex"); - - // Calculate directory path - let directory = "/"; - if (file.webkitRelativePath) { - const pathParts = file.webkitRelativePath.split("/"); - if (pathParts.length > 1) { - pathParts.pop(); - directory = `/${pathParts.join("/")}`; - } - } - - // Check if file exists - const checkData = { - directory: directory, - filename: file.name, - checksum: hashHex, - }; - - try { - const checkFileUrl = - document.querySelector("[data-check-file-url]")?.dataset - .checkFileUrl || "/users/check-file-exists/"; - const response = await fetch(checkFileUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-CSRFToken": csrfToken, - }, - body: JSON.stringify(checkData), - }); - - const data = await response.json(); - - // Store the result - const fileKey = `${directory}/${file.name}`; - window.fileCheckResults.set(fileKey, { - file: file, - directory: directory, - filename: file.name, - checksum: hashHex, - data: data.data, - }); - - // Mark for skipping if file exists in tree - if (data.data && data.data.file_exists_in_tree === true) { - window.filesToSkip.add(fileKey); - } - } catch (error) { - // Error checking file - console.error("Error checking file:", error); - } - - // Check for cancellation after each file check - if (cancelRequested) { - break; - } - } - - progressSection.style.display = "none"; - - // Check if cancellation was requested during file checking - if (cancelRequested) { - // Small delay to ensure UI updates are visible - progressSection.style.display = "none"; - await new Promise((resolve) => setTimeout(resolve, 100)); - // Show alert for duplicate checking cancellation - alert("Processing cancelled. No files were uploaded."); - throw new Error("Upload cancelled by user"); - } - } - - // Helper function to handle skipped files upload - async function handleSkippedFilesUpload(allRelativePaths, abortController) { - // Create form data for skipped files case - const skippedFormData = new FormData(); - - // Always add all relative paths for capture creation - for (const path of allRelativePaths) { - skippedFormData.append("all_relative_paths", path); - } - - // Add other form fields - const captureType = document.getElementById("captureTypeSelect").value; - skippedFormData.append("capture_type", captureType); - - if (captureType === "drf") { - const channels = document.getElementById("captureChannelsInput").value; - skippedFormData.append("channels", channels); - } else if (captureType === "rh") { - const scanGroup = document.getElementById("captureScanGroupInput").value; - skippedFormData.append("scan_group", scanGroup); - } - - // Don't send chunk information for skipped files - // This ensures capture creation happens - - const uploadUrl = - document.querySelector("[data-upload-url]")?.dataset.uploadUrl || - "/users/upload-capture/"; - const getCSRFToken = () => { - const metaToken = document.querySelector('meta[name="csrf-token"]'); - if (metaToken) return metaToken.getAttribute("content"); - const inputToken = document.querySelector('[name="csrfmiddlewaretoken"]'); - if (inputToken) return inputToken.value; - const cookies = document.cookie.split(";"); - for (let i = 0; i < cookies.length; i++) { - const cookie = cookies[i].trim(); - if (cookie.startsWith("csrftoken=")) { - return cookie.substring("csrftoken=".length); - } - } - return ""; - }; - - const response = await fetch(uploadUrl, { - method: "POST", - headers: { - "X-CSRFToken": getCSRFToken(), - }, - body: skippedFormData, - signal: abortController.signal, - }); - - const result = await response.json(); - return result; - } - - // Helper function to calculate total chunks - function calculateTotalChunks(filesToUpload, chunkSizeBytes) { - let totalChunks = 0; - let tempChunkSize = 0; - let tempChunkFiles = 0; // Track number of files in current chunk (mirrors currentChunk.length) - - for (let i = 0; i < filesToUpload.length; i++) { - const file = filesToUpload[i]; - - // Check if this file would exceed the chunk limit (mirrors upload logic exactly) - if (tempChunkSize + file.size > chunkSizeBytes && tempChunkFiles > 0) { - // Current chunk would exceed size limit, start new chunk - totalChunks++; - tempChunkSize = 0; - tempChunkFiles = 0; - } - - // Now add the file to the current chunk - if (file.size > chunkSizeBytes) { - // Large file gets its own chunk - totalChunks++; - tempChunkSize = 0; - tempChunkFiles = 0; - } else { - // Add to current chunk - tempChunkSize += file.size; - tempChunkFiles++; - } - } - - // Add final chunk if there are remaining files - if (tempChunkSize > 0) { - totalChunks++; - } - - return totalChunks; - } - - // Function to upload files in chunks - async function uploadFilesInChunks( - filesToUpload, - relativePathsToUpload, - allRelativePaths, - totalFiles, - ) { - // Get progress elements locally - const progressBar = document.getElementById("checkingProgressBar"); - const progressText = document.getElementById("checkingProgressText"); - const progressMessage = document.getElementById("progressMessage"); - const progressSection = document.getElementById("checkingProgressSection"); - - // Show upload progress if there are files to upload - if (filesToUpload.length > 0) { - progressSection.style.display = "block"; - progressMessage.textContent = "Uploading files and creating captures..."; - progressBar.style.width = "0%"; - progressText.textContent = "0%"; - } - - // Create AbortController for upload - const abortController = new AbortController(); - currentAbortController = abortController; - - // Chunk size for file uploads (50 MB per chunk) - const CHUNK_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB in bytes - - let allResults = { - file_upload_status: "success", - saved_files_count: 0, - captures: [], - errors: [], - message: "", - }; - - // Special case: if all files are skipped, send a single request without chunking - if (filesToUpload.length === 0) { - allResults = await handleSkippedFilesUpload( - allRelativePaths, - abortController, - ); - } else { - // Upload files in chunks based on size (50MB per chunk) - let currentChunk = []; - let currentChunkPaths = []; - let currentChunkSize = 0; - let chunkNumber = 1; - let filesProcessed = 0; - - // Calculate total chunks first - const totalChunks = calculateTotalChunks(filesToUpload, CHUNK_SIZE_BYTES); - - // Now upload files in chunks - for (let i = 0; i < filesToUpload.length; i++) { - const file = filesToUpload[i]; - const filePath = relativePathsToUpload[i]; - - // Check if this file would exceed the 50MB limit - if ( - currentChunkSize + file.size > CHUNK_SIZE_BYTES && - currentChunk.length > 0 - ) { - // Upload current chunk before adding this file - await uploadChunk( - currentChunk, - currentChunkPaths, - chunkNumber, - totalChunks, - filesProcessed, - false, - allResults, - allRelativePaths, - totalFiles, - CHUNK_SIZE_BYTES, - ); - // Reset for next chunk - currentChunk = []; - currentChunkPaths = []; - currentChunkSize = 0; - chunkNumber++; - } - - // Add file to current chunk - currentChunk.push(file); - currentChunkPaths.push(filePath); - currentChunkSize += file.size; - filesProcessed++; - - // Check if this is the last file - if (i === filesToUpload.length - 1) { - // Upload final chunk - await uploadChunk( - currentChunk, - currentChunkPaths, - chunkNumber, - totalChunks, - filesProcessed, - true, - allResults, - allRelativePaths, - totalFiles, - CHUNK_SIZE_BYTES, - ); - } - - // Check if cancel was requested - if (cancelRequested) { - break; - } - } - } - - // Check if cancellation was requested during chunk upload - if (cancelRequested) { - // Small delay to ensure UI updates are visible - await new Promise((resolve) => setTimeout(resolve, 100)); - throw new Error("Upload cancelled by user"); - } - - // Check if upload was aborted due to errors - if (allResults.file_upload_status === "error") { - // Clear the reference since upload was aborted - currentAbortController = null; - // Show error results - showUploadResults(allResults, allResults.saved_files_count, totalFiles); - return allResults; // Exit early, don't continue with normal flow - } - - // Clear the reference since upload completed - currentAbortController = null; - return allResults; - } - - // Helper function to upload a chunk - async function uploadChunk( - chunk, - chunkPaths, - chunkNum, - totalChunks, - filesProcessed, - isFinalChunk, - allResults, - allRelativePaths, - totalFiles, - CHUNK_SIZE_BYTES, - ) { - // Get progress elements locally - const progressBar = document.getElementById("checkingProgressBar"); - const progressText = document.getElementById("checkingProgressText"); - const progressMessage = document.getElementById("progressMessage"); - - // Update progress - const progress = Math.round((filesProcessed / totalFiles) * 100); - progressBar.style.width = `${progress}%`; - progressText.textContent = `${progress}%`; - - if (chunk.length === 1 && chunk[0].size > CHUNK_SIZE_BYTES) { - // Large file upload - const file = chunk[0]; - progressMessage.textContent = `Uploading large file: ${file.name} (${(file.size / (1024 * 1024)).toFixed(1)} MB)...`; - } else { - // Normal chunk upload - progressMessage.textContent = `Uploading chunk ${chunkNum}/${totalChunks} (${filesProcessed} files processed)...`; - } - - // Create form data for this chunk - const chunkFormData = new FormData(); - - // Add files for this chunk - for (const file of chunk) { - chunkFormData.append("files", file); - } - - for (const path of chunkPaths) { - chunkFormData.append("relative_paths", path); - } - - // Always add all relative paths for capture creation - for (const path of allRelativePaths) { - chunkFormData.append("all_relative_paths", path); - } - - // Add other form fields - const captureType = document.getElementById("captureTypeSelect").value; - chunkFormData.append("capture_type", captureType); - - if (captureType === "drf") { - const channels = document.getElementById("captureChannelsInput").value; - chunkFormData.append("channels", channels); - } else if (captureType === "rh") { - const scanGroup = document.getElementById("captureScanGroupInput").value; - chunkFormData.append("scan_group", scanGroup); - } - - // Add chunk information - chunkFormData.append("is_chunk", "true"); - chunkFormData.append("chunk_number", chunkNum.toString()); - chunkFormData.append("total_chunks", totalChunks.toString()); - - // Check for cancellation before starting this chunk - if (cancelRequested) { - throw new Error("Upload cancelled by user"); - } - - // Upload this chunk with timeout (longer timeout for large files) - const controller = new AbortController(); - currentAbortController = controller; - - const MIN_AVG_UPLOAD_RATE = 100 * 1024; // 100 KB/s minimum upload rate - const MIN_TIMEOUT_MS = 30000; // Minimum 30 seconds timeout - const total_chunk_size_bytes = chunk.reduce( - (total, file) => total + file.size, - 0, - ); - const calculated_timeout = - (total_chunk_size_bytes / MIN_AVG_UPLOAD_RATE) * 1000; - const timeout = Math.max(calculated_timeout, MIN_TIMEOUT_MS); // Use at least 30 seconds - - const timeoutId = setTimeout(() => controller.abort(), timeout); - - let response; - let chunkResult; - - const getCSRFToken = () => { - const metaToken = document.querySelector('meta[name="csrf-token"]'); - if (metaToken) return metaToken.getAttribute("content"); - const inputToken = document.querySelector('[name="csrfmiddlewaretoken"]'); - if (inputToken) return inputToken.value; - const cookies = document.cookie.split(";"); - for (let i = 0; i < cookies.length; i++) { - const cookie = cookies[i].trim(); - if (cookie.startsWith("csrftoken=")) { - return cookie.substring("csrftoken=".length); - } - } - return ""; - }; - - const uploadUrl = - document.querySelector("[data-upload-url]")?.dataset.uploadUrl || - "/users/upload-capture/"; - - try { - response = await fetch(uploadUrl, { - method: "POST", - headers: { - "X-CSRFToken": getCSRFToken(), - }, - body: chunkFormData, - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - chunkResult = await response.json(); - } catch (error) { - clearTimeout(timeoutId); - if (error.name === "AbortError") { - throw new Error("Upload timeout - connection may be lost"); - } - throw error; - } - - // Merge results - if (chunkResult.saved_files_count !== undefined) { - allResults.saved_files_count += chunkResult.saved_files_count; - } - - // Collect captures from the final chunk - if (chunkResult.captures && isFinalChunk) { - allResults.captures = allResults.captures.concat(chunkResult.captures); - } - - // Also collect any message from the final chunk - if (chunkResult.message && isFinalChunk) { - allResults.message = chunkResult.message; - } - - if (chunkResult.errors) { - allResults.errors = allResults.errors.concat(chunkResult.errors); - } - - // Check if any chunk failed - if (chunkResult.file_upload_status === "error") { - allResults.file_upload_status = "error"; - allResults.message = chunkResult.message || "Upload failed"; - const progressMessage = document.getElementById("progressMessage"); - if (progressMessage) { - progressMessage.textContent = - "Upload aborted due to errors. Please check the results."; - } - throw new Error(`Upload failed: ${chunkResult.message}`); - } - if (chunkResult.file_upload_status === "success") { - // Only update to success if this is the final chunk - if (isFinalChunk) { - allResults.file_upload_status = "success"; - } - } - } - - const uploadForm = document.getElementById("uploadCaptureForm"); - if (uploadForm) { - uploadForm.addEventListener("submit", async (e) => { - e.preventDefault(); - - // Set processing state - isProcessing = true; - uploadInProgress = true; - cancelRequested = false; // Reset cancel flag for new upload - sessionStorage.setItem("uploadInProgress", "true"); - - // Check if files are selected - if (!window.selectedFiles || window.selectedFiles.length === 0) { - alert("Please select files to upload."); - return; - } - - const files = window.selectedFiles; - - // Check for large files before duplicate checking - if (checkForLargeFiles(files, cancelButton, submitButton)) { - return; - } - - // Check files for duplicates - await checkFilesForDuplicates(files, cancelButton, submitButton); - - // Prepare files for upload (only non-skipped files) - const filesToUpload = []; - const relativePathsToUpload = []; - // Always collect all relative paths for capture creation, even for skipped files - const allRelativePaths = []; - let skippedFilesCount = 0; - - for (const file of files) { - let directory = "/"; - if (file.webkitRelativePath) { - const pathParts = file.webkitRelativePath.split("/"); - if (pathParts.length > 1) { - pathParts.pop(); - directory = `/${pathParts.join("/")}`; - } - } - - const fileKey = `${directory}/${file.name}`; - const relativePath = file.webkitRelativePath || file.name; - - // Add to all paths for capture creation - allRelativePaths.push(relativePath); - - // Only add to upload list if not skipped - if (!window.filesToSkip.has(fileKey)) { - filesToUpload.push(file); - relativePathsToUpload.push(relativePath); - } else { - skippedFilesCount++; - } - } - - // Upload files in chunks - try { - const uploadResults = await uploadFilesInChunks( - filesToUpload, - relativePathsToUpload, - allRelativePaths, - filesToUpload.length, - ); - - // Clear the reference since upload completed - currentAbortController = null; - - // Show results - showUploadResults( - uploadResults, - uploadResults.saved_files_count, - files.length, - skippedFilesCount, - ); - - // Don't auto-reload for successful uploads - let user close modal first - } catch (error) { - if (cancelRequested) { - // Check if this was cancelled during duplicate checking (no files uploaded yet) - if (!uploadInProgress) { - // Already showed alert in checkFilesForDuplicates function - // No need to reload since no files were uploaded - } else { - alert( - "Upload cancelled. Any files uploaded before cancellation have been saved.", - ); - // Reload page after cancellation - setTimeout(() => { - window.location.reload(); - }, 1000); - } - } else if (error.name === "AbortError") { - alert( - "Upload was interrupted. Any files uploaded before the interruption have been saved.", - ); - // Reload page after interruption - setTimeout(() => { - window.location.reload(); - }, 1000); - } else if ( - error.name === "TypeError" && - error.message.includes("fetch") - ) { - // Don't show alert for network errors after page refresh - if (uploadInProgress || sessionStorage.getItem("uploadInProgress")) { - // Suppressing network error alert during active upload - } else { - alert( - "Network error during upload. Please check your connection and try again.", - ); - } - } else { - alert(`Upload failed: ${error.message}`); - // Reload page after other errors - setTimeout(() => { - window.location.reload(); - }, 1000); - } - } finally { - // Clean up UI state - ensure this always runs - resetUIState(); - } - }); - } - - // Function to reset UI state - function resetUIState() { - // Reset submit button - if (submitButton) { - submitButton.disabled = false; - } - - // Hide progress section - const progressSection = document.getElementById("checkingProgressSection"); - if (progressSection) { - progressSection.style.display = "none"; - } - - // Reset cancel button - if (cancelButton) { - cancelButton.textContent = "Cancel"; - cancelButton.classList.remove("btn-warning"); - cancelButton.disabled = false; - } - - // Reset close button - if (closeButton) { - closeButton.disabled = false; - closeButton.style.opacity = "1"; - } - - // Reset progress elements - const progressBar = document.getElementById("checkingProgressBar"); - const progressText = document.getElementById("checkingProgressText"); - const progressMessage = document.getElementById("progressMessage"); - - if (progressBar) progressBar.style.width = "0%"; - if (progressText) progressText.textContent = "0%"; - if (progressMessage) progressMessage.textContent = ""; - - // Reset state flags - isProcessing = false; - uploadInProgress = false; - cancelRequested = false; - - // Clear session storage - sessionStorage.removeItem("uploadInProgress"); - - // Clear abort controller - currentAbortController = null; - } - - // Function to show upload results - function showUploadResults( - result, - uploadedCount, - totalCount, - skippedCount = 0, - ) { - // Check if page was refreshed during upload - if (!uploadInProgress && result.file_upload_status === "error") { - resetUIState(); // Ensure UI is reset even if modal is not shown - return; - } - - const modalBody = document.getElementById("uploadResultModalBody"); - const resultModalEl = document.getElementById("uploadResultModal"); - const modal = new bootstrap.Modal(resultModalEl); - - // Close the upload modal before showing result modal - const uploadModal = bootstrap.Modal.getInstance( - document.getElementById("uploadCaptureModal"), - ); - if (uploadModal) { - uploadModal.hide(); - } - - let msg = ""; - - if (result.file_upload_status === "success") { - // Use frontend accumulated count for accuracy, but include backend message for additional info - if (uploadedCount === 0 && totalCount > 0) { - // All files were skipped - msg = `Upload complete!
      All ${totalCount} files already existed on the server.`; - } else if (skippedCount > 0) { - // Some files were uploaded, some were skipped - msg = `Upload complete!
      Files uploaded: ${uploadedCount} / ${totalCount}`; - msg += `
      Files already exist: ${skippedCount}`; - } else { - // All files were uploaded (no skipped files) - msg = `Upload complete!
      Files uploaded: ${uploadedCount} / ${totalCount}`; - } - - if (result.captures && result.captures.length > 0) { - const uuids = result.captures - .map((uuid) => `
    • ${uuid}
    • `) - .join(""); - msg += `
      Created capture UUID(s):
        ${uuids}
      `; - } - - if (result.errors && result.errors.length > 0) { - const errs = result.errors.map((e) => `
    • ${e}
    • `).join(""); - msg += `
      Errors:
        ${errs}
      `; - msg += "
      Please check details and upload again."; - } - } else { - // Upload failed - show error message and prompt to remove error files - msg = "Upload Failed
      "; - if (result.message) { - msg += `${result.message}

      `; - } - msg += "Please check file validity and try again."; - if (result.errors && result.errors.length > 0) { - const errs = result.errors.map((e) => `
    • ${e}
    • `).join(""); - msg += `

      Error Details:
        ${errs}
      `; - } - } - - modalBody.innerHTML = msg; - modal.show(); - - // Add event listener to reload page when result modal is closed (only for successful uploads) - if (result.file_upload_status === "success") { - resultModalEl.addEventListener( - "hidden.bs.modal", - () => { - window.location.reload(); - }, - { once: true }, - ); // Only trigger once per modal instance - } - } -}); - -// Capture Type Selection JavaScript -document.addEventListener("DOMContentLoaded", () => { - const captureTypeSelect = document.getElementById("captureTypeSelect"); - const channelInputGroup = document.getElementById("channelInputGroup"); - const scanGroupInputGroup = document.getElementById("scanGroupInputGroup"); - const captureChannelsInput = document.getElementById("captureChannelsInput"); - const captureScanGroupInput = document.getElementById( - "captureScanGroupInput", - ); - - if (captureTypeSelect) { - captureTypeSelect.addEventListener("change", function () { - const selectedType = this.value; - - // Hide both input groups initially - if (channelInputGroup) - channelInputGroup.classList.add("hidden-input-group"); - if (scanGroupInputGroup) - scanGroupInputGroup.classList.add("hidden-input-group"); - - // Clear required attributes - if (captureChannelsInput) - captureChannelsInput.removeAttribute("required"); - if (captureScanGroupInput) - captureScanGroupInput.removeAttribute("required"); - - // Show appropriate input group based on selection - if (selectedType === "drf") { - if (channelInputGroup) - channelInputGroup.classList.remove("hidden-input-group"); - if (captureChannelsInput) - captureChannelsInput.setAttribute("required", "required"); - } else if (selectedType === "rh") { - if (scanGroupInputGroup) - scanGroupInputGroup.classList.remove("hidden-input-group"); - // scan_group is optional for RadioHound captures - } - }); - } - - // Reset form when modal is hidden - const uploadModal = document.getElementById("uploadCaptureModal"); - if (uploadModal) { - uploadModal.addEventListener("hidden.bs.modal", () => { - // Reset the form - const form = document.getElementById("uploadCaptureForm"); - if (form) { - form.reset(); - } - - // Hide input groups - if (channelInputGroup) - channelInputGroup.classList.add("hidden-input-group"); - if (scanGroupInputGroup) - scanGroupInputGroup.classList.add("hidden-input-group"); - - // Clear required attributes - if (captureChannelsInput) - captureChannelsInput.removeAttribute("required"); - if (captureScanGroupInput) - captureScanGroupInput.removeAttribute("required"); - - // Clear file check status - const checkStatusDiv = document.getElementById("fileCheckStatus"); - if (checkStatusDiv) { - checkStatusDiv.style.display = "none"; - } - - // Clear status alerts and file details button - const uploadModalBody = document.querySelector( - "#uploadCaptureModal .modal-body", - ); - if (uploadModalBody) { - const existingAlerts = uploadModalBody.querySelectorAll( - ".alert.alert-warning, .alert.alert-success", - ); - for (const alert of existingAlerts) { - if ( - alert.textContent.includes("will be skipped") || - alert.textContent.includes("will be uploaded") - ) { - alert.remove(); - } - } - - // Remove file details button - const detailsLink = uploadModalBody.querySelector( - "#viewFileDetailsLink", - ); - if (detailsLink) { - detailsLink.parentNode.remove(); - } - } - - // Clear global variables - if (window.filesToSkip) window.filesToSkip.clear(); - if (window.fileCheckResults) window.fileCheckResults.clear(); - }); - } -}); - -// BLAKE3 hash calculation for file deduplication -// Global variable to track files that should be skipped -window.filesToSkip = new Set(); -window.fileCheckResults = new Map(); // Store detailed results for each file - -document.addEventListener("DOMContentLoaded", () => { - const modal = document.getElementById("uploadCaptureModal"); - if (!modal) { - console.warn("uploadCaptureModal not found"); - return; - } - - modal.addEventListener("shown.bs.modal", () => { - const fileInput = document.getElementById("captureFileInput"); - if (!fileInput) { - console.warn("captureFileInput not found"); - return; - } - - // Remove any previous handler to avoid duplicates - fileInput.removeEventListener("change", window._blake3CaptureHandler); - - // Simple file handler that just stores the selected files - window._blake3CaptureHandler = async (event) => { - const files = event.target.files; - if (!files || files.length === 0) { - return; - } - - // Store the selected files for later processing - window.selectedFiles = Array.from(files); - }; - - fileInput.addEventListener("change", window._blake3CaptureHandler); - }); -}); diff --git a/gateway/sds_gateway/static/js/files-ui.js b/gateway/sds_gateway/static/js/files-ui.js deleted file mode 100644 index c29548110..000000000 --- a/gateway/sds_gateway/static/js/files-ui.js +++ /dev/null @@ -1,857 +0,0 @@ -/** - * Files UI Components - * Manages capture type selection and page initialization - */ - -// Error handling utilities -const ErrorHandler = { - shownMessages: new Set(), // Track shown messages to prevent duplicates - - showError(message, context = "", error = null) { - // Create a key for deduplication based on message and context - const messageKey = `${context}:${message}`; - - // Check if this exact message has already been shown - if (this.shownMessages.has(messageKey)) { - // Still log to console for debugging, but don't show UI message - if (error) { - console.error(`FilesUI Error [${context}]:`, { - message: error.message, - stack: error.stack, - userMessage: message, - timestamp: new Date().toISOString(), - userAgent: navigator.userAgent, - }); - } else { - console.warn(`FilesUI Warning [${context}]:`, message); - } - return; - } - - // Mark this message as shown - this.shownMessages.add(messageKey); - - // Log error details for debugging - if (error) { - console.error(`FilesUI Error [${context}]:`, { - message: error.message, - stack: error.stack, - userMessage: message, - timestamp: new Date().toISOString(), - userAgent: navigator.userAgent, - }); - } else { - console.warn(`FilesUI Warning [${context}]:`, message); - } - - // Show user-friendly error message - if (window.components?.showError) { - window.components.showError(message); - } else { - // Fallback: show in console and try to display on page - this.showFallbackError(message); - } - }, - - showFallbackError(message) { - // Try to find an error display area - const errorContainer = document.querySelector( - ".error-container, .alert-container, .files-container", - ); - if (errorContainer) { - const errorDiv = document.createElement("div"); - errorDiv.className = "alert alert-danger alert-dismissible fade show"; - errorDiv.innerHTML = ` - ${message} - - `; - errorContainer.insertBefore(errorDiv, errorContainer.firstChild); - } - }, - - getUserFriendlyErrorMessage(error, context = "") { - if (!error) return "An unexpected error occurred"; - - // Handle common error types - if (error.name === "TypeError" && error.message.includes("Cannot read")) { - return "Configuration error: Some components are not properly loaded"; - } - if (error.name === "ReferenceError") { - return "Component error: Required functionality is not available"; - } - - // Default user-friendly message - return error.message || "An unexpected error occurred"; - }, -}; - -// Browser compatibility checker -const BrowserCompatibility = { - checkRequiredFeatures() { - const requiredFeatures = { - "DOM API": "document" in window && "addEventListener" in document, - "Console API": "console" in window && "log" in console, - Map: "Map" in window, - Set: "Set" in window, - "Template Literals": (() => { - try { - // Test template literal support without eval - const test = `test${1}`; - return test === "test1"; - } catch { - return false; - } - })(), - }; - - const missingFeatures = Object.entries(requiredFeatures) - .filter(([name, supported]) => !supported) - .map(([name]) => name); - - if (missingFeatures.length > 0) { - console.warn("Missing browser features:", missingFeatures); - return false; - } - - return true; - }, - - checkBootstrapSupport() { - return ( - "bootstrap" in window || - typeof bootstrap !== "undefined" || - document.querySelector("[data-bs-toggle]") !== null - ); - }, -}; - -/** - * Capture Type Selection Handler - * Manages capture type dropdown and conditional form fields - */ -class CaptureTypeSelector { - constructor() { - this.boundHandlers = new Map(); // Track event handlers for cleanup - this.initializeElements(); - this.setupEventListeners(); - } - - initializeElements() { - this.captureTypeSelect = document.getElementById("captureTypeSelect"); - this.channelInputGroup = document.getElementById("channelInputGroup"); - this.scanGroupInputGroup = document.getElementById("scanGroupInputGroup"); - this.captureChannelsInput = document.getElementById("captureChannelsInput"); - this.captureScanGroupInput = document.getElementById( - "captureScanGroupInput", - ); - this.uploadModal = document.getElementById("uploadCaptureModal"); - - // Log which elements were found for debugging - console.log("CaptureTypeSelector elements found:", { - captureTypeSelect: !!this.captureTypeSelect, - channelInputGroup: !!this.channelInputGroup, - scanGroupInputGroup: !!this.scanGroupInputGroup, - captureChannelsInput: !!this.captureChannelsInput, - captureScanGroupInput: !!this.captureScanGroupInput, - uploadModal: !!this.uploadModal, - }); - } - - setupEventListeners() { - // Ensure boundHandlers is initialized - if (!this.boundHandlers) { - this.boundHandlers = new Map(); - } - - if (this.captureTypeSelect) { - const changeHandler = (e) => this.handleTypeChange(e); - this.boundHandlers.set(this.captureTypeSelect, changeHandler); - this.captureTypeSelect.addEventListener("change", changeHandler); - } - - if (this.uploadModal) { - const hiddenHandler = () => this.resetForm(); - this.boundHandlers.set(this.uploadModal, hiddenHandler); - this.uploadModal.addEventListener("hidden.bs.modal", hiddenHandler); - } - } - - handleTypeChange(event) { - const selectedType = event.target.value; - - // Validate capture type - if (!this.validateCaptureType(selectedType)) { - ErrorHandler.showError( - "Invalid capture type selected", - "capture-type-validation", - ); - return; - } - - // Hide both input groups initially - this.hideInputGroups(); - - // Clear required attributes - this.clearRequiredAttributes(); - - // Show appropriate input group based on selection - if (selectedType === "drf") { - this.showChannelInput(); - } else if (selectedType === "rh") { - this.showScanGroupInput(); - } - } - - hideInputGroups() { - if (this.channelInputGroup) { - this.channelInputGroup.classList.add("hidden-input-group"); - } - if (this.scanGroupInputGroup) { - this.scanGroupInputGroup.classList.add("hidden-input-group"); - } - } - - clearRequiredAttributes() { - if (this.captureChannelsInput) { - this.captureChannelsInput.removeAttribute("required"); - } - if (this.captureScanGroupInput) { - this.captureScanGroupInput.removeAttribute("required"); - } - } - - showChannelInput() { - if (this.channelInputGroup) { - this.channelInputGroup.classList.remove("hidden-input-group"); - } - if (this.captureChannelsInput) { - this.captureChannelsInput.setAttribute("required", "required"); - } - } - - showScanGroupInput() { - if (this.scanGroupInputGroup) { - this.scanGroupInputGroup.classList.remove("hidden-input-group"); - } - // scan_group is optional for RadioHound captures, so no required attribute - } - - // Input validation methods - validateCaptureType(type) { - const validTypes = ["drf", "rh"]; - return validTypes.includes(type); - } - - validateChannelInput(channels) { - if (!channels || typeof channels !== "string") return false; - // Basic validation for channel input (can be enhanced based on requirements) - return channels.trim().length > 0 && channels.length <= 1000; - } - - validateScanGroupInput(scanGroup) { - if (!scanGroup || typeof scanGroup !== "string") return false; - // Basic validation for scan group input - return scanGroup.trim().length > 0 && scanGroup.length <= 255; - } - - sanitizeInput(input) { - if (!input || typeof input !== "string") return ""; - // Remove potentially dangerous characters - return input.replace(/[<>:"/\\|?*]/g, "_").trim(); - } - - // Memory management and cleanup - cleanup() { - // Remove all bound event handlers - for (const [element, handler] of this.boundHandlers) { - if (element?.removeEventListener) { - element.removeEventListener("change", handler); - element.removeEventListener("hidden.bs.modal", handler); - } - } - this.boundHandlers.clear(); - console.log("CaptureTypeSelector cleanup completed"); - } - - resetForm() { - // Reset the form - const form = document.getElementById("uploadCaptureForm"); - if (form) { - form.reset(); - } - - // Hide input groups - this.hideInputGroups(); - - // Clear required attributes - this.clearRequiredAttributes(); - - // Clear global variables if they exist - this.cleanupGlobalState(); - } - - // Better global state management - cleanupGlobalState() { - const globalVars = ["filesToSkip", "fileCheckResults", "selectedFiles"]; - - for (const varName of globalVars) { - if (window[varName]) { - if (typeof window[varName].clear === "function") { - window[varName].clear(); - } else if (Array.isArray(window[varName])) { - window[varName].length = 0; - } else { - window[varName] = null; - } - console.log(`Cleaned up global variable: ${varName}`); - } - } - } -} - -/** - * Files Page Initialization - * Initializes modal managers, capture handlers, and user search components - */ -class FilesPageInitializer { - constructor() { - this.boundHandlers = new Map(); // Track event handlers for cleanup - this.activeHandlers = new Set(); // Track active component handlers - this.initializeComponents(); - } - - initializeComponents() { - try { - this.initializeModalManager(); - this.initializeCapturesTableManager(); - this.initializeUserSearchHandlers(); - } catch (error) { - ErrorHandler.showError( - "Failed to initialize page components", - "component-initialization", - error, - ); - } - } - - initializeModalManager() { - // Initialize ModalManager for capture modal - let modalManager = null; - try { - if (window.ModalManager) { - modalManager = new window.ModalManager({ - modalId: "capture-modal", - modalBodyId: "capture-modal-body", - modalTitleId: "capture-modal-label", - }); - - this.modalManager = modalManager; - console.log("ModalManager initialized successfully"); - } else { - ErrorHandler.showError( - "Modal functionality is not available. Some features may be limited.", - "modal-initialization", - ); - } - } catch (error) { - ErrorHandler.showError( - "Failed to initialize modal functionality", - "modal-initialization", - error, - ); - } - } - - initializeCapturesTableManager() { - // Initialize CapturesTableManager for capture edit/download functionality - try { - if (window.CapturesTableManager) { - window.capturesTableManager = new window.CapturesTableManager({ - modalHandler: this.modalManager, - }); - console.log("CapturesTableManager initialized successfully"); - } else { - ErrorHandler.showError( - "Table management functionality is not available. Some features may be limited.", - "table-initialization", - ); - } - } catch (error) { - ErrorHandler.showError( - "Failed to initialize table management functionality", - "table-initialization", - error, - ); - } - } - - initializeUserSearchHandlers() { - // Create a UserSearchHandler for each share modal - const shareModals = document.querySelectorAll(".modal[data-item-uuid]"); - - // Skip initialization if no share modals exist on this page - if (shareModals.length === 0) { - return; - } - - // Check if UserSearchHandler is available before trying to initialize - if (!window.UserSearchHandler) { - console.warn( - "UserSearchHandler not available. Share functionality will not work.", - ); - return; - } - - for (const modal of shareModals) { - this.setupUserSearchHandler(modal); - } - } - - setupUserSearchHandler(modal) { - try { - // Ensure boundHandlers and activeHandlers are initialized - if (!this.boundHandlers) { - this.boundHandlers = new Map(); - } - if (!this.activeHandlers) { - this.activeHandlers = new Set(); - } - - // Validate modal attributes - const itemUuid = modal.getAttribute("data-item-uuid"); - const itemType = modal.getAttribute("data-item-type"); - - if (!this.validateModalAttributes(itemUuid, itemType)) { - ErrorHandler.showError( - "Invalid modal configuration", - "user-search-setup", - ); - return; - } - - const handler = new window.UserSearchHandler(); - // Store the handler on the modal element - modal.userSearchHandler = handler; - this.activeHandlers.add(handler); - - // Create bound event handlers for cleanup - const showHandler = () => { - if (modal.userSearchHandler) { - modal.userSearchHandler.setItemInfo(itemUuid, itemType); - modal.userSearchHandler.init(); - } - }; - - const hideHandler = () => { - if (modal.userSearchHandler) { - modal.userSearchHandler.resetAll(); - } - }; - - // Store handlers for cleanup - this.boundHandlers.set(modal, { - show: showHandler, - hide: hideHandler, - }); - - // On modal show, set the item info and call init() - modal.addEventListener("show.bs.modal", showHandler); - - // On modal hide, reset all selections and entered data - modal.addEventListener("hidden.bs.modal", hideHandler); - - console.log(`UserSearchHandler initialized for ${itemType}: ${itemUuid}`); - } catch (error) { - ErrorHandler.showError( - "Failed to setup user search functionality", - "user-search-setup", - error, - ); - } - } - - /** - * Get initialized modal manager - * @returns {Object|null} - The modal manager instance - */ - getModalManager() { - return this.modalManager; - } - - /** - * Get captures table manager - * @returns {Object|null} - The captures table manager instance - */ - getCapturesTableManager() { - return window.capturesTableManager; - } - - // Validation methods - validateModalAttributes(uuid, type) { - if (!uuid || typeof uuid !== "string") { - console.warn("Invalid UUID in modal attributes:", uuid); - return false; - } - - if (!type || typeof type !== "string") { - console.warn("Invalid type in modal attributes:", type); - return false; - } - - // Validate UUID format (basic check) - const uuidRegex = - /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - if (!uuidRegex.test(uuid)) { - console.warn("Invalid UUID format in modal attributes:", uuid); - return false; - } - - // Validate type - const validTypes = ["capture", "dataset", "file"]; - if (!validTypes.includes(type)) { - console.warn("Invalid type in modal attributes:", type); - return false; - } - - return true; - } - - // Memory management and cleanup - cleanup() { - // Remove all bound event handlers - for (const [element, handlers] of this.boundHandlers) { - if (element?.removeEventListener) { - if (handlers.show) { - element.removeEventListener("show.bs.modal", handlers.show); - } - if (handlers.hide) { - element.removeEventListener("hidden.bs.modal", handlers.hide); - } - } - } - this.boundHandlers.clear(); - - // Cleanup active handlers - for (const handler of this.activeHandlers) { - if (handler && typeof handler.cleanup === "function") { - try { - handler.cleanup(); - } catch (error) { - console.warn("Error during handler cleanup:", error); - } - } - } - this.activeHandlers.clear(); - - console.log("FilesPageInitializer cleanup completed"); - } -} - -// Initialize when DOM is loaded -/** - * FileUploadHandler - * Handles individual file uploads (not capture uploads) - */ -class FileUploadHandler { - constructor() { - this.uploadForm = document.getElementById("uploadFileForm"); - this.fileInput = document.getElementById("fileInput"); - this.folderInput = document.getElementById("folderInput"); - this.submitBtn = document.getElementById("uploadFileSubmitBtn"); - this.clearBtn = document.getElementById("clearUploadBtn"); - this.uploadText = this.submitBtn?.querySelector(".upload-text"); - this.uploadSpinner = this.submitBtn?.querySelector(".upload-spinner"); - this.validationFeedback = document.getElementById( - "uploadValidationFeedback", - ); - - // Enable submit button when files or folders are selected - if (this.fileInput) { - this.fileInput.addEventListener("change", () => - this.updateSubmitButton(), - ); - } - if (this.folderInput) { - this.folderInput.addEventListener("change", () => - this.updateSubmitButton(), - ); - } - - // Clear button handler - if (this.clearBtn) { - this.clearBtn.addEventListener("click", () => this.clearModal()); - } - - if (this.uploadForm) { - this.uploadForm.addEventListener("submit", (e) => this.handleSubmit(e)); - } - } - - updateSubmitButton() { - if (this.submitBtn) { - const hasFiles = this.fileInput?.files.length > 0; - const hasFolders = this.folderInput?.files.length > 0; - this.submitBtn.disabled = !hasFiles && !hasFolders; - - // Hide validation feedback when files are selected - if (hasFiles || hasFolders) { - this.hideValidationFeedback(); - } - } - } - - showValidationFeedback() { - if (this.validationFeedback) { - this.validationFeedback.classList.add("d-block"); - } - // Add invalid styling to inputs - this.fileInput?.classList.add("is-invalid"); - this.folderInput?.classList.add("is-invalid"); - } - - hideValidationFeedback() { - if (this.validationFeedback) { - this.validationFeedback.classList.remove("d-block"); - } - // Remove invalid styling from inputs - this.fileInput?.classList.remove("is-invalid"); - this.folderInput?.classList.remove("is-invalid"); - } - - clearModal() { - // Reset form - if (this.uploadForm) { - this.uploadForm.reset(); - } - // Explicitly clear file inputs (form.reset() doesn't always clear file inputs) - if (this.fileInput) { - this.fileInput.value = ""; - } - if (this.folderInput) { - this.folderInput.value = ""; - } - // Hide validation feedback - this.hideValidationFeedback(); - // Update submit button state - this.updateSubmitButton(); - } - - async handleSubmit(event) { - event.preventDefault(); - - const files = Array.from(this.fileInput?.files || []); - const folderFiles = Array.from(this.folderInput?.files || []); - - if (files.length === 0 && folderFiles.length === 0) { - this.showValidationFeedback(); - return; - } - - this.setUploadingState(true); - - try { - // Debug: Check CSRF token availability - console.log("CSRF Token:", window.csrfToken); - console.log("Upload URL:", window.uploadFilesUrl); - - // Try multiple ways to get CSRF token - let csrfToken = window.csrfToken; - if (!csrfToken) { - // Fallback to DOM query - const csrfInput = document.querySelector("[name=csrfmiddlewaretoken]"); - csrfToken = csrfInput ? csrfInput.value : null; - } - - if (!csrfToken) { - throw new Error("CSRF token not found"); - } - - const formData = new FormData(); - const allFiles = [...files, ...folderFiles]; - const allRelativePaths = []; - - // Add all files to formData - for (const file of allFiles) { - formData.append("files", file); - // Use webkitRelativePath for folder files, filename for individual files - const relativePath = file.webkitRelativePath || file.name; - allRelativePaths.push(relativePath); - } - - // Add relative paths - for (const relativePath of allRelativePaths) { - formData.append("relative_paths", relativePath); - } - for (const relativePath of allRelativePaths) { - formData.append("all_relative_paths", relativePath); - } - - // Prevent capture creation when uploading files only - formData.append("capture_type", ""); - formData.append("channels", ""); - formData.append("scan_group", ""); - formData.append("csrfmiddlewaretoken", csrfToken); - - const response = await fetch(window.uploadFilesUrl, { - method: "POST", - body: formData, - headers: { - "X-CSRFToken": csrfToken, - }, - }); - - const result = await response.json(); - - if (response.ok) { - // Show success message - const fileCount = allFiles.length; - const successMsg = - fileCount === 1 - ? "1 file uploaded successfully!" - : `${fileCount} files uploaded successfully!`; - this.showResult("success", successMsg); - // Clear file inputs - this.clearModal(); - // Close modal - const modal = bootstrap.Modal.getInstance( - document.getElementById("uploadFileModal"), - ); - if (modal) modal.hide(); - // Refresh the file list - if (window.fileManager) { - window.fileManager.loadFiles(); - } - } else { - this.showResult( - "error", - result.error || "Upload failed. Please try again.", - ); - } - } catch (error) { - console.error("Upload error:", error); - this.showResult( - "error", - "Upload failed. Please check your connection and try again.", - ); - } finally { - this.setUploadingState(false); - } - } - - setUploadingState(uploading) { - if (this.submitBtn) { - this.submitBtn.disabled = uploading; - } - if (this.uploadText && this.uploadSpinner) { - this.uploadText.classList.toggle("d-none", uploading); - this.uploadSpinner.classList.toggle("d-none", !uploading); - } - } - - showResult(type, message) { - // Show result in the upload result modal - const resultModal = document.getElementById("uploadResultModal"); - const resultBody = document.getElementById("uploadResultModalBody"); - - if (resultModal && resultBody) { - resultBody.innerHTML = ` -
      - ${message} -
      - `; - const modal = new bootstrap.Modal(resultModal); - modal.show(); - } else { - // Fallback to alert - alert(message); - } - } - - cleanup() { - if (this.uploadForm) { - this.uploadForm.removeEventListener("submit", this.handleSubmit); - } - } -} - -document.addEventListener("DOMContentLoaded", () => { - // Check browser compatibility before proceeding - if (!BrowserCompatibility.checkRequiredFeatures()) { - ErrorHandler.showError( - "Your browser doesn't support required features. Please use a modern browser.", - "browser-compatibility", - ); - return; - } - - // Check Bootstrap support - if (!BrowserCompatibility.checkBootstrapSupport()) { - console.warn( - "Bootstrap not detected. Some UI features may not work properly.", - ); - } - - try { - // Check if we're on a page that needs these components - const needsCaptureSelector = - document.getElementById("captureTypeSelect") || - document.getElementById("uploadCaptureModal"); - const needsPageInitializer = - document.querySelector(".modal[data-item-uuid]") || - document.getElementById("capture-modal"); - const needsFileUploadHandler = - document.getElementById("uploadFileModal") || - document.getElementById("uploadFileForm"); - - // Initialize capture type selector only if needed - let captureSelector = null; - if (needsCaptureSelector) { - captureSelector = new CaptureTypeSelector(); - } - - // Initialize page components only if needed - let filesPageInitializer = null; - if (needsPageInitializer) { - filesPageInitializer = new FilesPageInitializer(); - window.filesPageInitializer = filesPageInitializer; - } - - // Initialize file upload handler only if needed - let fileUploadHandler = null; - if (needsFileUploadHandler) { - fileUploadHandler = new FileUploadHandler(); - window.fileUploadHandler = fileUploadHandler; - } - - // Store references for cleanup - window.filesUICleanup = () => { - if (captureSelector && typeof captureSelector.cleanup === "function") { - captureSelector.cleanup(); - } - if ( - filesPageInitializer && - typeof filesPageInitializer.cleanup === "function" - ) { - filesPageInitializer.cleanup(); - } - if ( - fileUploadHandler && - typeof fileUploadHandler.cleanup === "function" - ) { - fileUploadHandler.cleanup(); - } - }; - - console.log("Files UI initialized successfully", { - captureSelector: !!captureSelector, - pageInitializer: !!filesPageInitializer, - fileUploadHandler: !!fileUploadHandler, - }); - } catch (error) { - ErrorHandler.showError( - "Failed to initialize Files UI components", - "initialization", - error, - ); - } -}); diff --git a/gateway/sds_gateway/static/js/files-upload.js b/gateway/sds_gateway/static/js/files-upload.js deleted file mode 100644 index 43329c4e0..000000000 --- a/gateway/sds_gateway/static/js/files-upload.js +++ /dev/null @@ -1,763 +0,0 @@ -/** - * Files Upload Handler - * Manages file upload functionality, BLAKE3 hashing, and progress tracking - */ - -/** - * BLAKE3 File Handler - * Manages file selection and BLAKE3 hash calculation for deduplication - */ -class Blake3FileHandler { - constructor() { - // Initialize global variables for file tracking - this.initializeGlobalVariables(); - this.setupEventListeners(); - } - - initializeGlobalVariables() { - // Global variables to track files that should be skipped - window.filesToSkip = new Set(); - window.fileCheckResults = new Map(); // Store detailed results for each file - } - - setupEventListeners() { - const modal = document.getElementById("uploadCaptureModal"); - if (!modal) { - console.warn("uploadCaptureModal not found"); - return; - } - - modal.addEventListener("shown.bs.modal", () => { - this.setupFileInputHandler(); - }); - } - - setupFileInputHandler() { - const fileInput = document.getElementById("captureFileInput"); - if (!fileInput) { - console.warn("captureFileInput not found"); - return; - } - - // Remove any previous handler to avoid duplicates - if (window._blake3CaptureHandler) { - fileInput.removeEventListener("change", window._blake3CaptureHandler); - } - - // Create file handler that stores selected files - window._blake3CaptureHandler = async (event) => { - await this.handleFileSelection(event); - }; - - fileInput.addEventListener("change", window._blake3CaptureHandler); - } - - async handleFileSelection(event) { - const files = event.target.files; - if (!files || files.length === 0) { - return; - } - - // Store the selected files for later processing - window.selectedFiles = Array.from(files); - - console.log(`Selected ${files.length} files for upload`); - } - - /** - * Calculate BLAKE3 hash for a file - * @param {File} file - The file to hash - * @returns {Promise} - The BLAKE3 hash in hex format - */ - async calculateBlake3Hash(file) { - try { - const buffer = await file.arrayBuffer(); - const hasher = await hashwasm.createBLAKE3(); - hasher.init(); - hasher.update(new Uint8Array(buffer)); - return hasher.digest("hex"); - } catch (error) { - console.error("Error calculating BLAKE3 hash:", error); - throw error; - } - } - - /** - * Get directory path from webkitRelativePath - * @param {File} file - The file to get directory for - * @returns {string} - The directory path - */ - getDirectoryPath(file) { - if (!file.webkitRelativePath) { - return "/"; - } - - const pathParts = file.webkitRelativePath.split("/"); - if (pathParts.length > 1) { - pathParts.pop(); // Remove filename - return `/${pathParts.join("/")}`; - } - - return "/"; - } - - /** - * Check if a file exists on the server - * @param {File} file - The file to check - * @param {string} hash - The BLAKE3 hash of the file - * @returns {Promise} - The server response - */ - async checkFileExists(file, hash) { - const directory = this.getDirectoryPath(file); - - const checkData = { - directory: directory, - filename: file.name, - checksum: hash, - }; - - try { - const response = await fetch(window.checkFileExistsUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-CSRFToken": window.csrfToken, - }, - body: JSON.stringify(checkData), - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - return await response.json(); - } catch (error) { - console.error("Error checking file existence:", error); - throw error; - } - } - - /** - * Process a single file for duplicate checking - * @param {File} file - The file to process - * @returns {Promise} - Processing result - */ - async processFileForDuplicateCheck(file) { - try { - // Calculate hash - const hash = await this.calculateBlake3Hash(file); - - // Check if file exists - const checkResult = await this.checkFileExists(file, hash); - - // Store results - const directory = this.getDirectoryPath(file); - const fileKey = `${directory}/${file.name}`; - - const result = { - file: file, - directory: directory, - filename: file.name, - checksum: hash, - data: checkResult.data, - }; - - window.fileCheckResults.set(fileKey, result); - - // Mark for skipping if file exists - if (checkResult.data && checkResult.data.file_exists_in_tree === true) { - window.filesToSkip.add(fileKey); - } - - return result; - } catch (error) { - console.error("Error processing file for duplicate check:", error); - return null; - } - } -} - -/** - * Files Upload Modal Handler - * Manages file upload functionality, progress tracking, and chunked uploads - */ -class FilesUploadModal { - constructor() { - this.isProcessing = false; - this.uploadInProgress = false; - this.cancelRequested = false; - this.currentAbortController = null; - - this.initializeElements(); - this.setupEventListeners(); - this.clearExistingModals(); - } - - initializeElements() { - this.cancelButton = document.querySelector( - "#uploadCaptureModal .btn-secondary", - ); - this.submitButton = document.getElementById("uploadSubmitBtn"); - this.uploadModal = document.getElementById("uploadCaptureModal"); - this.fileInput = document.getElementById("captureFileInput"); - this.uploadForm = document.getElementById("uploadCaptureForm"); - } - - setupEventListeners() { - // Modal event listeners - if (this.uploadModal) { - this.uploadModal.addEventListener("show.bs.modal", () => - this.resetState(), - ); - this.uploadModal.addEventListener("hidden.bs.modal", () => - this.resetState(), - ); - } - - // File input change listener - if (this.fileInput) { - this.fileInput.addEventListener("change", () => this.resetState()); - } - - // Cancel button listener - if (this.cancelButton) { - this.cancelButton.addEventListener("click", () => this.handleCancel()); - } - - // Form submit listener - if (this.uploadForm) { - this.uploadForm.addEventListener("submit", (e) => this.handleSubmit(e)); - } - } - - clearExistingModals() { - const existingResultModal = document.getElementById("uploadResultModal"); - if (existingResultModal) { - const modalInstance = bootstrap.Modal.getInstance(existingResultModal); - if (modalInstance) { - modalInstance.hide(); - } - } - } - - resetState() { - this.isProcessing = false; - this.currentAbortController = null; - this.cancelRequested = false; - } - - handleCancel() { - if (this.isProcessing) { - this.cancelRequested = true; - - if (this.currentAbortController) { - this.currentAbortController.abort(); - } - - this.cancelButton.textContent = "Cancelling..."; - this.cancelButton.disabled = true; - - const progressMessage = document.getElementById("progressMessage"); - if (progressMessage) { - progressMessage.textContent = "Cancelling upload..."; - } - - setTimeout(() => { - if (this.cancelRequested) { - this.resetUIState(); - } - }, 500); - } - } - - async handleSubmit(e) { - e.preventDefault(); - - this.isProcessing = true; - this.uploadInProgress = true; - this.cancelRequested = false; - - // Check if files are selected - if (!window.selectedFiles || window.selectedFiles.length === 0) { - alert("Please select files to upload."); - return; - } - - try { - this.showProgressSection(); - await this.checkFilesForDuplicates(); - - if (this.cancelRequested) { - throw new Error("Upload cancelled by user"); - } - - await this.uploadFiles(); - } catch (error) { - this.handleError(error); - } finally { - this.resetUIState(); - } - } - - showProgressSection() { - const progressSection = document.getElementById("checkingProgressSection"); - const progressMessage = document.getElementById("progressMessage"); - - if (progressSection) { - progressSection.style.display = "block"; - } - if (progressMessage) { - progressMessage.textContent = "Checking files for duplicates..."; - } - - this.cancelButton.textContent = "Cancel Processing"; - this.cancelButton.classList.add("btn-warning"); - this.submitButton.disabled = true; - } - - async checkFilesForDuplicates() { - window.filesToSkip = new Set(); - window.fileCheckResults = new Map(); - const files = window.selectedFiles; - const totalFiles = files.length; - - const progressBar = document.getElementById("checkingProgressBar"); - const progressText = document.getElementById("checkingProgressText"); - - for (let i = 0; i < files.length; i++) { - if (this.cancelRequested) break; - - const file = files[i]; - const progress = Math.round(((i + 1) / totalFiles) * 100); - - if (progressBar) progressBar.style.width = `${progress}%`; - if (progressText) progressText.textContent = `${progress}%`; - - await this.processFile(file); - } - - if (this.cancelRequested) { - throw new Error("Upload cancelled by user"); - } - } - - async processFile(file) { - // Calculate BLAKE3 hash - const buffer = await file.arrayBuffer(); - const hasher = await hashwasm.createBLAKE3(); - hasher.init(); - hasher.update(new Uint8Array(buffer)); - const hashHex = hasher.digest("hex"); - - // Calculate directory path - let directory = "/"; - if (file.webkitRelativePath) { - const pathParts = file.webkitRelativePath.split("/"); - if (pathParts.length > 1) { - pathParts.pop(); - directory = `/${pathParts.join("/")}`; - } - } - - // Check if file exists - const checkData = { - directory: directory, - filename: file.name, - checksum: hashHex, - }; - - try { - const response = await fetch(window.checkFileExistsUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-CSRFToken": window.csrfToken, - }, - body: JSON.stringify(checkData), - }); - const data = await response.json(); - - const fileKey = `${directory}/${file.name}`; - window.fileCheckResults.set(fileKey, { - file: file, - directory: directory, - filename: file.name, - checksum: hashHex, - data: data.data, - }); - - if (data.data && data.data.file_exists_in_tree === true) { - window.filesToSkip.add(fileKey); - } - } catch (error) { - console.error("Error checking file:", error); - } - } - - async uploadFiles() { - const progressMessage = document.getElementById("progressMessage"); - const progressSection = document.getElementById("checkingProgressSection"); - const progressBar = document.getElementById("checkingProgressBar"); - const progressText = document.getElementById("checkingProgressText"); - - if (progressMessage) { - progressMessage.textContent = "Uploading files and creating captures..."; - } - if (progressBar) progressBar.style.width = "0%"; - if (progressText) progressText.textContent = "0%"; - - const files = window.selectedFiles; - const filesToUpload = []; - const relativePathsToUpload = []; - const allRelativePaths = []; - - // Process files for upload - for (const file of files) { - let directory = "/"; - if (file.webkitRelativePath) { - const pathParts = file.webkitRelativePath.split("/"); - if (pathParts.length > 1) { - pathParts.pop(); - directory = `/${pathParts.join("/")}`; - } - } - const fileKey = `${directory}/${file.name}`; - const relativePath = file.webkitRelativePath || file.name; - - console.debug( - `Processing file: ${file.name}, webkitRelativePath: '${file.webkitRelativePath}', relativePath: '${relativePath}', directory: '${directory}'`, - ); - allRelativePaths.push(relativePath); - - if (!window.filesToSkip.has(fileKey)) { - filesToUpload.push(file); - relativePathsToUpload.push(relativePath); - } - } - - console.debug( - "All relative paths being sent:", - allRelativePaths.slice(0, 5), - ); - console.debug( - "Relative paths to upload:", - relativePathsToUpload.slice(0, 5), - ); - - if (filesToUpload.length > 0 && progressSection) { - progressSection.style.display = "block"; - } - - this.currentAbortController = new AbortController(); - - let result; - if (filesToUpload.length === 0) { - result = await this.uploadSkippedFiles(allRelativePaths); - } else { - result = await this.uploadFilesInChunks( - filesToUpload, - relativePathsToUpload, - allRelativePaths, - ); - } - - this.currentAbortController = null; - this.showUploadResults(result, result.saved_files_count, files.length); - } - - async uploadSkippedFiles(allRelativePaths) { - const formData = new FormData(); - - console.debug( - "uploadSkippedFiles - allRelativePaths:", - allRelativePaths.slice(0, 5), - ); - for (const path of allRelativePaths) { - formData.append("all_relative_paths", path); - } - - this.addCaptureTypeData(formData); - formData.append("csrfmiddlewaretoken", window.csrfToken); - - const response = await fetch(window.uploadFilesUrl, { - method: "POST", - body: formData, - headers: { - "X-CSRFToken": window.csrfToken, - }, - signal: this.currentAbortController.signal, - }); - - return await response.json(); - } - - async uploadFilesInChunks( - filesToUpload, - relativePathsToUpload, - allRelativePaths, - ) { - const CHUNK_SIZE = 5; - const totalFiles = filesToUpload.length; - const progressBar = document.getElementById("checkingProgressBar"); - const progressText = document.getElementById("checkingProgressText"); - const progressMessage = document.getElementById("progressMessage"); - - const allResults = { - file_upload_status: "success", - saved_files_count: 0, - captures: [], - errors: [], - message: "", - }; - - for (let i = 0; i < filesToUpload.length; i += CHUNK_SIZE) { - if (this.cancelRequested) break; - - const chunk = filesToUpload.slice(i, i + CHUNK_SIZE); - const chunkPaths = relativePathsToUpload.slice(i, i + CHUNK_SIZE); - - const totalChunks = Math.ceil(filesToUpload.length / CHUNK_SIZE); - const currentChunk = Math.floor(i / CHUNK_SIZE) + 1; - const isFinalChunk = currentChunk === totalChunks; - - // Update progress - const progress = Math.round(((i + chunk.length) / totalFiles) * 100); - if (progressBar) progressBar.style.width = `${progress}%`; - if (progressText) progressText.textContent = `${progress}%`; - if (progressMessage) { - progressMessage.textContent = `Uploading files ${i + 1}-${Math.min(i + CHUNK_SIZE, totalFiles)} of ${totalFiles} (chunk ${currentChunk}/${totalChunks})...`; - } - - const chunkResult = await this.uploadChunk( - chunk, - chunkPaths, - allRelativePaths, - currentChunk, - totalChunks, - ); - - // Merge results - if (chunkResult.saved_files_count !== undefined) { - allResults.saved_files_count += chunkResult.saved_files_count; - } - if (chunkResult.captures && isFinalChunk) { - allResults.captures = allResults.captures.concat(chunkResult.captures); - } - if (chunkResult.errors) { - allResults.errors = allResults.errors.concat(chunkResult.errors); - } - - if (chunkResult.file_upload_status === "error") { - allResults.file_upload_status = "error"; - allResults.message = chunkResult.message || "Upload failed"; - break; - } - - if (chunkResult.file_upload_status === "success" && isFinalChunk) { - allResults.file_upload_status = "success"; - } - } - - if (this.cancelRequested) { - throw new Error("Upload cancelled by user"); - } - - return allResults; - } - - async uploadChunk( - chunk, - chunkPaths, - allRelativePaths, - currentChunk, - totalChunks, - ) { - const formData = new FormData(); - - console.debug( - `uploadChunk ${currentChunk}/${totalChunks} - chunkPaths:`, - chunkPaths, - ); - console.debug( - `uploadChunk ${currentChunk}/${totalChunks} - allRelativePaths (first 5):`, - allRelativePaths.slice(0, 5), - ); - - for (const file of chunk) { - formData.append("files", file); - } - for (const path of chunkPaths) { - formData.append("relative_paths", path); - } - for (const path of allRelativePaths) { - formData.append("all_relative_paths", path); - } - - this.addCaptureTypeData(formData); - formData.append("csrfmiddlewaretoken", window.csrfToken); - - formData.append("is_chunk", "true"); - formData.append("chunk_number", currentChunk.toString()); - formData.append("total_chunks", totalChunks.toString()); - - const controller = new AbortController(); - this.currentAbortController = controller; - const timeoutId = setTimeout(() => controller.abort(), 300000); - - try { - const response = await fetch(window.uploadFilesUrl, { - method: "POST", - body: formData, - headers: { - "X-CSRFToken": window.csrfToken, - }, - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - return await response.json(); - } catch (error) { - clearTimeout(timeoutId); - if (error.name === "AbortError") { - throw new Error("Upload timeout - connection may be lost"); - } - throw error; - } - } - - addCaptureTypeData(formData) { - const captureType = document.getElementById("captureTypeSelect").value; - formData.append("capture_type", captureType); - - if (captureType === "drf") { - const channels = document.getElementById("captureChannelsInput").value; - formData.append("channels", channels); - } else if (captureType === "rh") { - const scanGroup = document.getElementById("captureScanGroupInput").value; - formData.append("scan_group", scanGroup); - } - } - - handleError(error) { - if (this.cancelRequested) { - alert( - "Upload cancelled. Any files uploaded before cancellation have been saved.", - ); - setTimeout(() => window.location.reload(), 1000); - } else if (error.name === "AbortError") { - alert( - "Upload was interrupted. Any files uploaded before the interruption have been saved.", - ); - setTimeout(() => window.location.reload(), 1000); - } else if (error.name === "TypeError" && error.message.includes("fetch")) { - alert( - "Network error during upload. Please check your connection and try again.", - ); - } else { - alert(`Upload failed: ${error.message}`); - setTimeout(() => window.location.reload(), 1000); - } - } - - resetUIState() { - this.submitButton.disabled = false; - - const progressSection = document.getElementById("checkingProgressSection"); - if (progressSection) { - progressSection.style.display = "none"; - } - - this.cancelButton.textContent = "Cancel"; - this.cancelButton.classList.remove("btn-warning"); - this.cancelButton.disabled = false; - - const progressBar = document.getElementById("checkingProgressBar"); - const progressText = document.getElementById("checkingProgressText"); - const progressMessage = document.getElementById("progressMessage"); - - if (progressBar) progressBar.style.width = "0%"; - if (progressText) progressText.textContent = "0%"; - if (progressMessage) progressMessage.textContent = ""; - - this.isProcessing = false; - this.uploadInProgress = false; - this.cancelRequested = false; - this.currentAbortController = null; - } - - showUploadResults(result, uploadedCount, totalCount) { - const uploadModal = bootstrap.Modal.getInstance(this.uploadModal); - if (uploadModal) { - uploadModal.hide(); - } - - if (result.file_upload_status === "success") { - const uploaded = uploadedCount ?? 0; - const message = `Upload complete: ${uploaded} / ${totalCount} file${totalCount === 1 ? "" : "s"} uploaded.`; - try { - sessionStorage.setItem( - "filesAlert", - JSON.stringify({ message: message, type: "success" }), - ); - } catch (_) {} - setTimeout(() => window.location.reload(), 500); - } else { - this.showErrorModal(result); - } - } - - showErrorModal(result) { - const modalBody = document.getElementById("uploadResultModalBody"); - const resultModalEl = document.getElementById("uploadResultModal"); - const modal = new bootstrap.Modal(resultModalEl); - - let msg = "Upload Failed
      "; - if (result.message) { - msg += `${result.message}

      `; - } - msg += "Please remove the problematic files and try again."; - - if (result.errors && result.errors.length > 0) { - const errs = result.errors.map((e) => `
    • ${e}
    • `).join(""); - msg += `

      Error Details:
        ${errs}
      `; - } - - modalBody.innerHTML = msg; - modal.show(); - } -} - -// Initialize when DOM is loaded -document.addEventListener("DOMContentLoaded", () => { - // Set up session storage alert handling - const key = "filesAlert"; - const stored = sessionStorage.getItem(key); - if (stored) { - try { - const data = JSON.parse(stored); - if ( - window.components && - typeof window.components.showError === "function" && - data?.type === "error" - ) { - window.components.showError(data.message || "An error occurred."); - } else if ( - window.components && - typeof window.components.showSuccess === "function" && - data?.type === "success" - ) { - window.components.showSuccess(data.message || "Success"); - } - } catch (e) {} - sessionStorage.removeItem(key); - } - - // Initialize BLAKE3 handler first, then upload modal - new Blake3FileHandler(); - new FilesUploadModal(); -}); From d5d6a460bd9f222f7a783943a9549f871b220c70 Mon Sep 17 00:00:00 2001 From: klpoland Date: Fri, 8 May 2026 13:09:40 -0400 Subject: [PATCH 4/7] managers class files and clean-up js --- .../static/js/actions/ShareActionManager.js | 75 +- .../js/captures/CapturesTableManager.js | 6 + .../captures/FileListCapturesTableManager.js | 4 + .../js/captures/FileListPageController.js | 174 ++-- .../_FileListCapturesTableManager.body.js | 387 --------- .../captures/_FileListPageController.body.js | 610 -------------- .../static/js/core/ModalManager.js | 5 + .../static/js/core/PageController.js | 69 ++ .../static/js/core/PageLifecycleManager.js | 42 +- .../static/js/core/PaginationManager.js | 1 + .../static/js/core/TableManager.js | 16 + .../static/js/dataset/AuthorsManager.js | 60 ++ .../js/dataset/DatasetCreationHandler.js | 10 +- .../js/dataset/DatasetEditingHandler.js | 43 +- .../static/js/share/ShareGroupManager.js | 76 +- .../static/js/share/UserSearchDropdown.js | 98 +++ .../static/js/upload/ChunkUploadPipeline.js | 66 ++ .../static/js/upload/FilesUploadModal.js | 11 +- .../js/upload/UploadCaptureModalController.js | 765 +++++++++++++++++- 19 files changed, 1210 insertions(+), 1308 deletions(-) delete mode 100644 gateway/sds_gateway/static/js/captures/_FileListCapturesTableManager.body.js delete mode 100644 gateway/sds_gateway/static/js/captures/_FileListPageController.body.js create mode 100644 gateway/sds_gateway/static/js/core/PageController.js create mode 100644 gateway/sds_gateway/static/js/dataset/AuthorsManager.js create mode 100644 gateway/sds_gateway/static/js/share/UserSearchDropdown.js create mode 100644 gateway/sds_gateway/static/js/upload/ChunkUploadPipeline.js diff --git a/gateway/sds_gateway/static/js/actions/ShareActionManager.js b/gateway/sds_gateway/static/js/actions/ShareActionManager.js index f8f872eab..7a7a3d95e 100644 --- a/gateway/sds_gateway/static/js/actions/ShareActionManager.js +++ b/gateway/sds_gateway/static/js/actions/ShareActionManager.js @@ -368,34 +368,7 @@ window.ShareActionManager = class ShareActionManager { * @param {number} direction - Direction to navigate */ navigateDropdown(items, currentIndex, direction) { - // Remove current selection - for (const item of items) { - item.classList.remove("selected"); - } - - // Calculate new index - let newIndex; - if (currentIndex === -1) { - // No item is currently selected - if (direction > 0) { - // ArrowDown: start from first item - newIndex = 0; - } else { - // ArrowUp: start from last item - newIndex = items.length - 1; - } - } else { - // An item is currently selected - newIndex = currentIndex + direction; - if (newIndex < 0) newIndex = items.length - 1; - if (newIndex >= items.length) newIndex = 0; - } - - // Add selection to new item - if (items[newIndex]) { - items[newIndex].classList.add("selected"); - items[newIndex].scrollIntoView({ block: "nearest" }); - } + window.UserSearchDropdown.navigateDropdown(items, currentIndex, direction); } /** @@ -934,41 +907,9 @@ window.ShareActionManager = class ShareActionManager { * @returns {Element|null} Dropdown element */ getDropdownForInput(input) { - // First try the original pattern: user-search-dropdown-{uuid} - let dropdown = document.getElementById( - `user-search-dropdown-${input.id.replace("user-search-", "")}`, - ); - - if (dropdown) { - return dropdown; - } - - // Try alternative patterns - const alternativeIds = [ - `user-search-dropdown-${this.itemUuid}`, - "user-search-dropdown", - `${input.id.replace("user-search-", "user-search-dropdown-")}`, - `${input.id}-dropdown`, - ]; - - for (const id of alternativeIds) { - dropdown = document.getElementById(id); - if (dropdown) { - return dropdown; - } - } - - // If still not found, look for any dropdown in the same container - const container = input.closest(".user-search-input-container"); - if (container) { - dropdown = container.querySelector(".user-search-dropdown"); - if (dropdown) { - return dropdown; - } - } - - console.error(`Could not find dropdown for input: ${input.id}`); - return null; + return window.UserSearchDropdown.getDropdownForInput(input, { + itemUuid: this.itemUuid, + }); } /** @@ -976,7 +917,7 @@ window.ShareActionManager = class ShareActionManager { * @param {Element} dropdown - Dropdown element */ showDropdown(dropdown) { - dropdown.classList.remove("d-none"); + window.UserSearchDropdown.showDropdown(dropdown); } /** @@ -984,11 +925,7 @@ window.ShareActionManager = class ShareActionManager { * @param {Element} dropdown - Dropdown element */ hideDropdown(dropdown) { - dropdown.classList.add("d-none"); - // Clear any selections - for (const item of dropdown.querySelectorAll(".list-group-item")) { - item.classList.remove("selected"); - } + window.UserSearchDropdown.hideDropdown(dropdown); } /** diff --git a/gateway/sds_gateway/static/js/captures/CapturesTableManager.js b/gateway/sds_gateway/static/js/captures/CapturesTableManager.js index ce2a6f4be..283a50603 100644 --- a/gateway/sds_gateway/static/js/captures/CapturesTableManager.js +++ b/gateway/sds_gateway/static/js/captures/CapturesTableManager.js @@ -171,6 +171,12 @@ class CapturesTableManager extends TableManager { * Get CSRF token for API requests */ getCSRFToken() { + if (window.APIClient) { + try { + const client = new window.APIClient(); + return client.getCSRFToken(); + } catch (_) {} + } const token = document.querySelector("[name=csrfmiddlewaretoken]"); return token ? token.value : ""; } diff --git a/gateway/sds_gateway/static/js/captures/FileListCapturesTableManager.js b/gateway/sds_gateway/static/js/captures/FileListCapturesTableManager.js index fe990e327..1020444d6 100644 --- a/gateway/sds_gateway/static/js/captures/FileListCapturesTableManager.js +++ b/gateway/sds_gateway/static/js/captures/FileListCapturesTableManager.js @@ -391,6 +391,10 @@ class FileListCapturesTableManager extends CapturesTableManager { } } +if (typeof window !== "undefined") { + window.FileListCapturesTableManager = FileListCapturesTableManager; +} + if (typeof module !== "undefined" && module.exports) { module.exports = { FileListCapturesTableManager }; } diff --git a/gateway/sds_gateway/static/js/captures/FileListPageController.js b/gateway/sds_gateway/static/js/captures/FileListPageController.js index 59da83f79..fd1cca9d8 100644 --- a/gateway/sds_gateway/static/js/captures/FileListPageController.js +++ b/gateway/sds_gateway/static/js/captures/FileListPageController.js @@ -3,8 +3,75 @@ * Migrated from deprecated/file-list.js (FileListController). */ -class FileListPageController { +// Ensure PageController base exists before class declaration (Jest/CommonJS). +if (typeof window !== "undefined" && !window.PageController && typeof require !== "undefined") { + try { + // eslint-disable-next-line global-require, import/no-dynamic-require + const mod = require("../core/PageController.js"); + if (mod?.PageController) window.PageController = mod.PageController; + } catch (_) {} +} + +// Support both browser globals and CommonJS. +const PageControllerBase = + (typeof window !== "undefined" && window.PageController) || + (typeof PageController !== "undefined" ? PageController : null); + +function ensureFileListConfig() { + if (typeof window === "undefined") return; + if (window.FileListConfig) return; + + // In Jest/node, the config module is available via CommonJS exports. + if (typeof require !== "undefined") { + try { + // eslint-disable-next-line global-require, import/no-dynamic-require + const mod = require("../constants/FileListConfig.js"); + if (mod?.FileListConfig) { + window.FileListConfig = mod.FileListConfig; + return; + } + } catch (_) {} + } + + // Final fallback: defaults (should be overridden by constants/FileListConfig.js in browser). + window.FileListConfig = { + DEBOUNCE_DELAY: 500, + DEFAULT_SORT_BY: "created_at", + DEFAULT_SORT_ORDER: "desc", + MIN_LOADING_TIME: 500, + ELEMENT_IDS: { + SEARCH_INPUT: "search-input", + START_DATE: "start_date", + END_DATE: "end_date", + CENTER_FREQ_MIN: "centerFreqMinInput", + CENTER_FREQ_MAX: "centerFreqMaxInput", + APPLY_FILTERS: "apply-filters-btn", + CLEAR_FILTERS: "clear-filters-btn", + ITEMS_PER_PAGE: "items-per-page", + }, + }; +} + +function ensureFileListCapturesTableManager() { + if (typeof window === "undefined") return; + if (window.FileListCapturesTableManager) return; + + if (typeof require !== "undefined") { + try { + // eslint-disable-next-line global-require, import/no-dynamic-require + const mod = require("./FileListCapturesTableManager.js"); + if (mod?.FileListCapturesTableManager) { + window.FileListCapturesTableManager = mod.FileListCapturesTableManager; + } + } catch (_) {} + } +} + +class FileListPageController extends (PageControllerBase || class {}) { constructor() { + super(); + ensureFileListConfig(); + ensureFileListCapturesTableManager(); this.userInteractedWithFrequency = false; this.urlParams = new URLSearchParams(window.location.search); this.currentSortBy = @@ -13,17 +80,17 @@ class FileListPageController { this.urlParams.get("sort_order") || window.FileListConfig.DEFAULT_SORT_ORDER; // Cache DOM elements - this.cacheElements(); - - // Initialize components - this.initializeComponents(); - - // Initialize functionality - this.initializeEventHandlers(); - this.initializeFromURL(); + if (typeof this.init === "function") { + this.init(); + } else { + // Fallback if PageControllerBase wasn't available for any reason + this.cacheElements(); + this.initializeComponents(); + this.initializeEventHandlers(); + this.initializeFromURL(); + } - // Initial setup - this.updateSortIcons(); + // Initial setup (sorting handled by TableManager) this.tableManager.attachRowClickHandlers(); // Initialize dropdowns for any existing static dropdowns @@ -67,12 +134,14 @@ class FileListPageController { modalTitleId: "capture-modal-label", }); - this.tableManager = new FileListCapturesTableManager({ + this.tableManager = new window.FileListCapturesTableManager({ tableId: "captures-table", tableContainerSelector: ".table-responsive", resultsCountId: "results-count", modalHandler: this.modalManager, onSelectionChange: () => this.syncBulkAddToDatasetButton(), + // Keep current UX: clicking sort headers navigates (server-rendered sort) + sortBehavior: "reload", }); this.searchManager = new SearchManager({ @@ -95,13 +164,19 @@ class FileListPageController { * Initialize all event handlers */ initializeEventHandlers() { - this.initializeTableSorting(); this.initializeAccordions(); this.initializeFrequencyHandling(); this.initializeItemsPerPageHandler(); this.initializeAddToDatasetButton(); } + destroy() { + try { + this.tableManager?.destroy?.(); + } catch (_) {} + super.destroy(); + } + /** * Selection mode: one button to enter; when on, show Cancel and Add */ @@ -344,79 +419,6 @@ class FileListPageController { } } - /** - * Initialize table sorting functionality - */ - initializeTableSorting() { - if (!this.elements.sortableHeaders) return; - - for (const header of this.elements.sortableHeaders) { - header.style.cursor = "pointer"; - header.addEventListener("click", () => this.handleSort(header)); - } - } - - /** - * Handle sort click events - */ - handleSort(header) { - try { - const sortField = header.getAttribute("data-sort"); - const currentSort = this.urlParams.get("sort_by"); - const currentOrder = this.urlParams.get("sort_order") || "desc"; - - // Determine new sort order - let newOrder = "asc"; - if (currentSort === sortField && currentOrder === "asc") { - newOrder = "desc"; - } - - // Build new URL with sort parameters - const urlParams = new URLSearchParams(window.location.search); - urlParams.set("sort_by", sortField); - urlParams.set("sort_order", newOrder); - urlParams.set("page", "1"); - - // Navigate to sorted results - window.location.search = urlParams.toString(); - } catch (error) { - console.error("Error handling sort:", error); - } - } - - /** - * Update sort icons to show current sort state - */ - updateSortIcons() { - if (!this.elements.sortableHeaders) return; - - const currentSort = this.urlParams.get("sort_by") || window.FileListConfig.DEFAULT_SORT_BY; - const currentOrder = this.urlParams.get("sort_order") || "desc"; - - for (const header of this.elements.sortableHeaders) { - const sortField = header.getAttribute("data-sort"); - const icon = header.querySelector(".sort-icon"); - - if (icon) { - // Reset classes - icon.className = "bi sort-icon"; - - if (currentSort === sortField) { - // Add active class and appropriate direction icon - icon.classList.add("active"); - if (currentOrder === "desc") { - icon.classList.add("bi-caret-down-fill"); - } else { - icon.classList.add("bi-caret-up-fill"); - } - } else { - // Inactive columns get default down arrow - icon.classList.add("bi-caret-down-fill"); - } - } - } - } - /** * Initialize accordion behavior */ diff --git a/gateway/sds_gateway/static/js/captures/_FileListCapturesTableManager.body.js b/gateway/sds_gateway/static/js/captures/_FileListCapturesTableManager.body.js deleted file mode 100644 index 7e874dc4b..000000000 --- a/gateway/sds_gateway/static/js/captures/_FileListCapturesTableManager.body.js +++ /dev/null @@ -1,387 +0,0 @@ -class FileListCapturesTableManager extends CapturesTableManager { - /** - * UUIDs selected for quick-add / bulk actions. Class field initializes as soon as - * the instance exists (after super()), so renderRow never runs before this exists. - */ - selectedCaptureIds = new Set(); - - constructor(options) { - super(options); - this.resultsCountElement = document.getElementById(options.resultsCountId); - this.searchButton = document.getElementById("search-btn"); - this.searchButtonContent = document.getElementById("search-btn-content"); - this.searchButtonLoading = document.getElementById("search-btn-loading"); - this.onSelectionChange = options.onSelectionChange ?? null; - this.setupSelectionCheckboxHandler(); - this.setupRowClickSelection(); - } - - _notifySelectionChange() { - if (typeof this.onSelectionChange === "function") { - this.onSelectionChange(); - } - } - - /** - * Override showLoading to toggle button contents instead of showing separate indicator - */ - showLoading() { - if (this.searchButton) { - this.searchButton.disabled = true; - if (this.searchButtonContent) - this.searchButtonContent.classList.add("d-none"); - if (this.searchButtonLoading) - this.searchButtonLoading.classList.remove("d-none"); - } - } - - /** - * Override hideLoading to restore button contents - */ - hideLoading() { - if (this.searchButton) { - this.searchButton.disabled = false; - if (this.searchButtonContent) - this.searchButtonContent.classList.remove("d-none"); - if (this.searchButtonLoading) - this.searchButtonLoading.classList.add("d-none"); - } - } - - /** - * Delegated handler for selection checkboxes: keep selectedCaptureIds in sync - */ - setupSelectionCheckboxHandler() { - this._checkboxChangeHandler = (e) => { - if (!e.target.matches(".capture-select-checkbox")) return; - const uuid = e.target.getAttribute("data-capture-uuid"); - if (!uuid) return; - if (e.target.checked) { - this.selectedCaptureIds.add(uuid); - } else { - this.selectedCaptureIds.delete(uuid); - } - this._notifySelectionChange(); - }; - document.addEventListener("change", this._checkboxChangeHandler); - } - - /** - * When selection mode is active, clicking a row toggles its selection (instead of opening the modal). - * Uses capture phase so we run before the row's click handler. - */ - setupRowClickSelection() { - const table = document.getElementById(this.tableId); - if (!table) return; - this._rowClickTable = table; - - this._rowClickHandler = (e) => { - if (!table.classList.contains("selection-mode-active")) return; - if ( - e.target.closest( - "button, a, [data-bs-toggle='dropdown'], .capture-select-checkbox", - ) - ) - return; - const row = e.target.closest("tr"); - if (!row) return; - const checkbox = row.querySelector(".capture-select-checkbox"); - if (!checkbox) return; - const uuid = checkbox.getAttribute("data-capture-uuid"); - if (!uuid) return; - - if (this.selectedCaptureIds.has(uuid)) { - this.selectedCaptureIds.delete(uuid); - checkbox.checked = false; - } else { - this.selectedCaptureIds.add(uuid); - checkbox.checked = true; - } - this._notifySelectionChange(); - e.preventDefault(); - e.stopPropagation(); - }; - - table.addEventListener("click", this._rowClickHandler, true); - } - - destroy() { - if (this._checkboxChangeHandler) { - document.removeEventListener("change", this._checkboxChangeHandler); - this._checkboxChangeHandler = null; - } - if (this._rowClickHandler && this._rowClickTable) { - this._rowClickTable.removeEventListener( - "click", - this._rowClickHandler, - true, - ); - this._rowClickHandler = null; - this._rowClickTable = null; - } - super.destroy(); - } - - /** - * Initialize dropdowns with body container for proper positioning - */ - initializeDropdowns() { - if (window.DropdownUtils) { - window.DropdownUtils.initIconDropdowns(document); - } - } - - /** - * Update table with new data - */ - updateTable(captures, hasResults) { - this.selectedCaptureIds ??= new Set(); - const tbody = this.tbody ?? this.table?.querySelector("tbody"); - if (!tbody) return; - - // Update results count - this.updateResultsCount(captures, hasResults); - - if (!hasResults || captures.length === 0) { - tbody.innerHTML = ` - - - No captures found matching your search criteria. - - - `; - this._notifySelectionChange(); - return; - } - - // Build table HTML efficiently - const tableHTML = captures - .map((capture, index) => this.renderRow(capture, index)) - .join(""); - tbody.innerHTML = tableHTML; - - // Initialize dropdowns after table is updated - this.initializeDropdowns(); - this._notifySelectionChange(); - } - - /** - * Update results count display - */ - updateResultsCount(captures, hasResults) { - if (this.resultsCountElement) { - const count = hasResults && captures ? captures.length : 0; - const pluralSuffix = count === 1 ? "" : "s"; - this.resultsCountElement.textContent = `${count} capture${pluralSuffix} found`; - } - } - - /** - * Render individual table row with XSS protection - * Overrides the base class method to include file-specific columns - */ - renderRow(capture) { - this.selectedCaptureIds ??= new Set(); - // Sanitize all data before rendering - const safeData = { - uuid: ComponentUtils.escapeHtml(capture.uuid || ""), - name: ComponentUtils.escapeHtml(capture.name || ""), - channel: ComponentUtils.escapeHtml(capture.channel || ""), - scanGroup: ComponentUtils.escapeHtml(capture.scan_group || ""), - captureType: ComponentUtils.escapeHtml(capture.capture_type || ""), - captureTypeDisplay: ComponentUtils.escapeHtml( - capture.capture_type_display || "", - ), - topLevelDir: ComponentUtils.escapeHtml(capture.top_level_dir || ""), - indexName: ComponentUtils.escapeHtml(capture.index_name || ""), - owner: ComponentUtils.escapeHtml(capture.owner || ""), - origin: ComponentUtils.escapeHtml(capture.origin || ""), - dataset: ComponentUtils.escapeHtml(capture.dataset || ""), - createdAt: ComponentUtils.escapeHtml(capture.created_at || ""), - updatedAt: ComponentUtils.escapeHtml(capture.updated_at || ""), - isPublic: ComponentUtils.escapeHtml(capture.is_public || ""), - isDeleted: ComponentUtils.escapeHtml(capture.is_deleted || ""), - centerFrequencyGhz: ComponentUtils.escapeHtml( - capture.center_frequency_ghz || "", - ), - lengthOfCaptureMs: capture.length_of_capture_ms ?? 0, - fileCadenceMs: capture.file_cadence_ms ?? 1000, - perDataFileSize: capture.per_data_file_size ?? 0, - totalSize: capture.total_file_size ?? 0, - dataFilesCount: capture.data_files_count ?? 0, - dataFilesTotalSize: capture.data_files_total_size ?? 0, - totalFilesCount: capture.files.length ?? 0, - captureStartEpochSec: capture.capture_start_epoch_sec ?? 0, - }; - - let typeDisplay = safeData.captureTypeDisplay || safeData.captureType; - - if (capture.is_multi_channel) { - typeDisplay = capture.capture_type_display || safeData.captureType; - } - - // Display name with fallback to "Unnamed Capture" - const nameDisplay = safeData.name || "Unnamed Capture"; - - // Format created date to match template format - let createdDate = "-"; - if (capture.created_at) { - const date = new Date(capture.created_at); - if (!Number.isNaN(date.getTime())) { - const dateStr = date.toISOString().split("T")[0]; // YYYY-MM-DD - const timeStr = date.toLocaleTimeString("en-US", { - hour12: false, - timeZoneName: "short", - }); // HH:mm:ss TZ - createdDate = ` -
      - ${dateStr} - ${timeStr} -
      - `; - } - } - - // Check if shared (for shared icon) - const isShared = capture.is_shared_with_me || false; - const sharedIcon = isShared - ? ` - - ` - : ""; - - // Check if owner (for conditional actions and selection — only owned captures are selectable) - const isOwner = capture.is_owner === true; - - const checked = this.selectedCaptureIds.has(capture.uuid) ? " checked" : ""; - const selectCell = isOwner - ? `` - : ''; - return ` - - ${selectCell} - - - ${nameDisplay} - - ${sharedIcon} - - ${safeData.topLevelDir || "-"} - ${typeDisplay} - ${createdDate} - - - - - `; - } -} diff --git a/gateway/sds_gateway/static/js/captures/_FileListPageController.body.js b/gateway/sds_gateway/static/js/captures/_FileListPageController.body.js deleted file mode 100644 index 235e61a5b..000000000 --- a/gateway/sds_gateway/static/js/captures/_FileListPageController.body.js +++ /dev/null @@ -1,610 +0,0 @@ -class FileListPageController { - constructor() { - this.userInteractedWithFrequency = false; - this.urlParams = new URLSearchParams(window.location.search); - this.currentSortBy = - this.urlParams.get("sort_by") || window.FileListConfig.DEFAULT_SORT_BY; - this.currentSortOrder = - this.urlParams.get("sort_order") || window.FileListConfig.DEFAULT_SORT_ORDER; - - // Cache DOM elements - this.cacheElements(); - - // Initialize components - this.initializeComponents(); - - // Initialize functionality - this.initializeEventHandlers(); - this.initializeFromURL(); - - // Initial setup - this.updateSortIcons(); - this.tableManager.attachRowClickHandlers(); - - // Initialize dropdowns for any existing static dropdowns - this.initializeDropdowns(); - } - - /** - * Cache frequently accessed DOM elements - */ - cacheElements() { - this.elements = { - searchInput: document.getElementById(window.FileListConfig.ELEMENT_IDS.SEARCH_INPUT), - startDate: document.getElementById(window.FileListConfig.ELEMENT_IDS.START_DATE), - endDate: document.getElementById(window.FileListConfig.ELEMENT_IDS.END_DATE), - centerFreqMin: document.getElementById( - window.FileListConfig.ELEMENT_IDS.CENTER_FREQ_MIN, - ), - centerFreqMax: document.getElementById( - window.FileListConfig.ELEMENT_IDS.CENTER_FREQ_MAX, - ), - applyFilters: document.getElementById(window.FileListConfig.ELEMENT_IDS.APPLY_FILTERS), - clearFilters: document.getElementById(window.FileListConfig.ELEMENT_IDS.CLEAR_FILTERS), - itemsPerPage: document.getElementById(window.FileListConfig.ELEMENT_IDS.ITEMS_PER_PAGE), - sortableHeaders: document.querySelectorAll("th.sortable"), - frequencyButton: document.querySelector( - '[data-bs-target="#collapseFrequency"]', - ), - frequencyCollapse: document.getElementById("collapseFrequency"), - dateButton: document.querySelector('[data-bs-target="#collapseDate"]'), - dateCollapse: document.getElementById("collapseDate"), - }; - } - - /** - * Initialize component managers - */ - initializeComponents() { - this.modalManager = new ModalManager({ - modalId: "capture-modal", - modalBodyId: "capture-modal-body", - modalTitleId: "capture-modal-label", - }); - - this.tableManager = new FileListCapturesTableManager({ - tableId: "captures-table", - tableContainerSelector: ".table-responsive", - resultsCountId: "results-count", - modalHandler: this.modalManager, - onSelectionChange: () => this.syncBulkAddToDatasetButton(), - }); - - this.searchManager = new SearchManager({ - searchInputId: window.FileListConfig.ELEMENT_IDS.SEARCH_INPUT, - searchButtonId: "search-btn", - clearButtonId: "reset-search-btn", - searchFormId: "search-form", - onSearchStart: () => this.tableManager.showLoading(), - onSearch: (query, signal) => this.performSearch(signal), - debounceDelay: window.FileListConfig.DEBOUNCE_DELAY, - }); - - this.paginationManager = new PaginationManager({ - containerId: "captures-pagination", - onPageChange: (page) => this.handlePageChange(page), - }); - } - - /** - * Initialize all event handlers - */ - initializeEventHandlers() { - this.initializeTableSorting(); - this.initializeAccordions(); - this.initializeFrequencyHandling(); - this.initializeItemsPerPageHandler(); - this.initializeAddToDatasetButton(); - } - - /** - * Selection mode: one button to enter; when on, show Cancel and Add - */ - initializeAddToDatasetButton() { - const mainBtn = document.getElementById("add-captures-to-dataset-btn"); - const table = document.getElementById("captures-table"); - const modeButtonsWrap = document.getElementById( - "add-to-dataset-mode-buttons", - ); - const cancelBtn = document.getElementById("add-to-dataset-cancel-btn"); - const addBtn = document.getElementById("add-to-dataset-add-btn"); - if (!mainBtn || !table) return; - - const enterSelectionMode = () => { - table.classList.add("selection-mode-active"); - mainBtn.classList.add("d-none"); - mainBtn.setAttribute("aria-pressed", "true"); - if (modeButtonsWrap) modeButtonsWrap.classList.remove("d-none"); - this.syncBulkAddToDatasetButton(); - }; - - mainBtn.addEventListener("click", enterSelectionMode); - - if (cancelBtn) { - cancelBtn.addEventListener("click", () => this.exitSelectionMode()); - } - - if (addBtn) { - addBtn.addEventListener("click", () => { - const ids = Array.from(this.tableManager?.selectedCaptureIds ?? []); - if (ids.length === 0) { - if (window.showAlert) { - window.showAlert( - "Select at least one capture before adding to a dataset.", - "warning", - ); - } - return; - } - const modal = document.getElementById("quickAddToDatasetModal"); - if (modal) { - modal.dataset.captureUuids = JSON.stringify(ids); - const bsModal = bootstrap.Modal.getOrCreateInstance(modal); - bsModal.show(); - } - }); - } - } - - /** - * Exit bulk-add selection mode: hide the mode controls, uncheck all selected - * captures, and clear the selection set. - */ - exitSelectionMode() { - const mainBtn = document.getElementById("add-captures-to-dataset-btn"); - const table = document.getElementById("captures-table"); - const modeButtonsWrap = document.getElementById( - "add-to-dataset-mode-buttons", - ); - table?.classList.remove("selection-mode-active"); - mainBtn?.classList.remove("d-none"); - mainBtn?.setAttribute("aria-pressed", "false"); - modeButtonsWrap?.classList.add("d-none"); - - // Uncheck all visible checkboxes and clear the tracked set - if (this.tableManager) { - for (const uuid of this.tableManager.selectedCaptureIds) { - const cb = document.querySelector( - `.capture-select-checkbox[data-capture-uuid="${uuid}"]`, - ); - if (cb) cb.checked = false; - } - this.tableManager.selectedCaptureIds.clear(); - this.syncBulkAddToDatasetButton(); - } - } - - /** - * While selection mode is active, disable bulk "Add" until at least one capture is selected. - */ - syncBulkAddToDatasetButton() { - const addBtn = document.getElementById("add-to-dataset-add-btn"); - const table = document.getElementById("captures-table"); - if (!addBtn || !table?.classList.contains("selection-mode-active")) { - return; - } - const n = this.tableManager?.selectedCaptureIds?.size ?? 0; - addBtn.disabled = n === 0; - addBtn.title = - n === 0 - ? "Select at least one capture to add to a dataset" - : "Add selected captures to a dataset"; - addBtn.setAttribute( - "aria-label", - n === 0 - ? "Add to dataset — select at least one capture first" - : `Add ${n} selected capture${n === 1 ? "" : "s"} to a dataset`, - ); - } - - /** - * Initialize values from URL parameters - */ - initializeFromURL() { - // Set initial date values from URL - if (this.urlParams.get("date_start") && this.elements.startDate) { - this.elements.startDate.value = this.urlParams.get("date_start"); - } - if (this.urlParams.get("date_end") && this.elements.endDate) { - this.elements.endDate.value = this.urlParams.get("date_end"); - } - - // Set frequency values if they exist in URL - this.initializeFrequencyFromURL(); - } - - /** - * Handle page change events - */ - handlePageChange(page) { - const urlParams = new URLSearchParams(window.location.search); - urlParams.set("page", page.toString()); - window.location.search = urlParams.toString(); - } - - /** - * Build search parameters from form inputs - */ - buildSearchParams() { - const searchParams = new URLSearchParams(); - - const searchQuery = this.elements.searchInput?.value.trim() || ""; - const startDate = this.elements.startDate?.value || ""; - let endDate = this.elements.endDate?.value || ""; - - // If end date is set, include the full day - if (endDate) { - endDate = `${endDate}T23:59:59`; - } - - // Add search parameters - if (searchQuery) searchParams.set("search", searchQuery); - if (startDate) searchParams.set("date_start", startDate); - if (endDate) searchParams.set("date_end", endDate); - - // Only add frequency parameters if user has explicitly interacted - if (this.userInteractedWithFrequency) { - if (this.elements.centerFreqMin?.value) { - searchParams.set("min_freq", this.elements.centerFreqMin.value); - } - if (this.elements.centerFreqMax?.value) { - searchParams.set("max_freq", this.elements.centerFreqMax.value); - } - } - - searchParams.set("sort_by", this.currentSortBy); - searchParams.set("sort_order", this.currentSortOrder); - - return searchParams; - } - - /** - * Execute search API call - */ - async executeSearch(searchParams, signal) { - const apiUrl = `${window.location.pathname.replace(/\/$/, "")}/api/?${searchParams.toString()}`; - - const response = await fetch(apiUrl, { - method: "GET", - headers: { - Accept: "application/json", - }, - credentials: "same-origin", - signal: signal, - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const text = await response.text(); - try { - return JSON.parse(text); - } catch { - throw new Error("Invalid JSON response from server"); - } - } - - /** - * Update UI with search results - */ - updateUI(data) { - if (data.error) { - throw new Error(`Server error: ${data.error}`); - } - - this.tableManager.updateTable(data.captures || [], data.has_results); - } - - /** - * Update browser history without page refresh - */ - updateBrowserHistory(searchParams) { - const newUrl = `${window.location.pathname}?${searchParams.toString()}`; - window.history.pushState({}, "", newUrl); - } - - /** - * Main search function - now broken down into smaller methods - */ - async performSearch(signal) { - try { - const startTime = Date.now(); - this.tableManager.showLoading(); - - const searchParams = this.buildSearchParams(); - const data = await this.executeSearch(searchParams, signal); - - // Ensure minimum loading time is displayed - const elapsedTime = Date.now() - startTime; - if (elapsedTime < window.FileListConfig.MIN_LOADING_TIME) { - await new Promise((resolve) => - setTimeout(resolve, window.FileListConfig.MIN_LOADING_TIME - elapsedTime), - ); - } - - this.updateUI(data); - this.updateBrowserHistory(searchParams); - } catch (error) { - // Don't show error if request was aborted (user issued a new search) - if (error.name === "AbortError") { - console.log("Previous search request was cancelled"); - return; - } - - console.error("Search error:", error); - this.tableManager.showError(`Search failed: ${error.message}`); - } finally { - this.tableManager.hideLoading(); - } - } - - /** - * Initialize table sorting functionality - */ - initializeTableSorting() { - if (!this.elements.sortableHeaders) return; - - for (const header of this.elements.sortableHeaders) { - header.style.cursor = "pointer"; - header.addEventListener("click", () => this.handleSort(header)); - } - } - - /** - * Handle sort click events - */ - handleSort(header) { - try { - const sortField = header.getAttribute("data-sort"); - const currentSort = this.urlParams.get("sort_by"); - const currentOrder = this.urlParams.get("sort_order") || "desc"; - - // Determine new sort order - let newOrder = "asc"; - if (currentSort === sortField && currentOrder === "asc") { - newOrder = "desc"; - } - - // Build new URL with sort parameters - const urlParams = new URLSearchParams(window.location.search); - urlParams.set("sort_by", sortField); - urlParams.set("sort_order", newOrder); - urlParams.set("page", "1"); - - // Navigate to sorted results - window.location.search = urlParams.toString(); - } catch (error) { - console.error("Error handling sort:", error); - } - } - - /** - * Update sort icons to show current sort state - */ - updateSortIcons() { - if (!this.elements.sortableHeaders) return; - - const currentSort = this.urlParams.get("sort_by") || window.FileListConfig.DEFAULT_SORT_BY; - const currentOrder = this.urlParams.get("sort_order") || "desc"; - - for (const header of this.elements.sortableHeaders) { - const sortField = header.getAttribute("data-sort"); - const icon = header.querySelector(".sort-icon"); - - if (icon) { - // Reset classes - icon.className = "bi sort-icon"; - - if (currentSort === sortField) { - // Add active class and appropriate direction icon - icon.classList.add("active"); - if (currentOrder === "desc") { - icon.classList.add("bi-caret-down-fill"); - } else { - icon.classList.add("bi-caret-up-fill"); - } - } else { - // Inactive columns get default down arrow - icon.classList.add("bi-caret-down-fill"); - } - } - } - } - - /** - * Initialize accordion behavior - */ - initializeAccordions() { - // Frequency filter accordion - if (this.elements.frequencyButton && this.elements.frequencyCollapse) { - this.elements.frequencyButton.addEventListener("click", (e) => { - e.preventDefault(); - this.toggleAccordion( - this.elements.frequencyButton, - this.elements.frequencyCollapse, - ); - }); - } - - // Date filter accordion - if (this.elements.dateButton && this.elements.dateCollapse) { - this.elements.dateButton.addEventListener("click", (e) => { - e.preventDefault(); - this.toggleAccordion( - this.elements.dateButton, - this.elements.dateCollapse, - ); - }); - } - } - - /** - * Helper function to toggle accordion state - */ - toggleAccordion(button, collapse) { - const isCollapsed = button.classList.contains("collapsed"); - - if (isCollapsed) { - button.classList.remove("collapsed"); - button.setAttribute("aria-expanded", "true"); - collapse.classList.add("show"); - } else { - button.classList.add("collapsed"); - button.setAttribute("aria-expanded", "false"); - collapse.classList.remove("show"); - } - } - - /** - * Initialize frequency handling - */ - initializeFrequencyHandling() { - // Add event listeners to track user interaction with frequency inputs - if (this.elements.centerFreqMin) { - this.elements.centerFreqMin.addEventListener("change", () => { - this.userInteractedWithFrequency = true; - }); - } - - if (this.elements.centerFreqMax) { - this.elements.centerFreqMax.addEventListener("change", () => { - this.userInteractedWithFrequency = true; - }); - } - - // Apply filters button - if (this.elements.applyFilters) { - this.elements.applyFilters.addEventListener("click", (e) => { - e.preventDefault(); - this.performSearch(); - }); - } - - // Clear filters button - if (this.elements.clearFilters) { - this.elements.clearFilters.addEventListener("click", (e) => { - e.preventDefault(); - this.clearAllFilters(); - }); - } - } - - /** - * Clear all filter inputs - */ - clearAllFilters() { - // Get current URL parameters - const urlParams = new URLSearchParams(window.location.search); - const currentSearch = urlParams.get("search"); - - // Reset all filter inputs except search - if (this.elements.startDate) this.elements.startDate.value = ""; - if (this.elements.endDate) this.elements.endDate.value = ""; - if (this.elements.centerFreqMin) this.elements.centerFreqMin.value = ""; - if (this.elements.centerFreqMax) this.elements.centerFreqMax.value = ""; - - // Reset interaction tracking - this.userInteractedWithFrequency = false; - - // Reset frequency slider if it exists - const frequencyRangeSlider = document.getElementById( - "frequency-range-slider", - ); - if (frequencyRangeSlider?.noUiSlider) { - frequencyRangeSlider.noUiSlider.set([0, 10]); - } - - // Also reset the display values - const lowerValue = document.getElementById("frequency-range-lower"); - const upperValue = document.getElementById("frequency-range-upper"); - if (lowerValue) lowerValue.textContent = "0 GHz"; - if (upperValue) upperValue.textContent = "10 GHz"; - - // Create new URL parameters with only search and sort parameters preserved - const newParams = new URLSearchParams(); - if (currentSearch) { - newParams.set("search", currentSearch); - } - newParams.set("sort_by", this.currentSortBy); - newParams.set("sort_order", this.currentSortOrder); - - // Update URL and trigger search - window.history.pushState( - {}, - "", - `${window.location.pathname}?${newParams.toString()}`, - ); - this.performSearch(); - } - - /** - * Initialize items per page handler - */ - initializeItemsPerPageHandler() { - if (this.elements.itemsPerPage) { - this.elements.itemsPerPage.addEventListener("change", (e) => { - const urlParams = new URLSearchParams(window.location.search); - urlParams.set("items_per_page", e.target.value); - urlParams.set("page", "1"); - window.location.search = urlParams.toString(); - }); - } - } - - /** - * Initialize frequency range from URL parameters - */ - initializeFrequencyFromURL() { - if (!this.elements.centerFreqMin || !this.elements.centerFreqMax) return; - - const minFreq = Number.parseFloat(this.urlParams.get("min_freq")); - const maxFreq = Number.parseFloat(this.urlParams.get("max_freq")); - - if (!Number.isNaN(minFreq)) { - this.elements.centerFreqMin.value = minFreq; - this.userInteractedWithFrequency = true; - } - if (!Number.isNaN(maxFreq)) { - this.elements.centerFreqMax.value = maxFreq; - this.userInteractedWithFrequency = true; - } - - // Update noUiSlider if it exists - if (this.userInteractedWithFrequency) { - this.initializeFrequencySlider(); - } - } - - initializeFrequencySlider() { - try { - const minFreq = Number.parseFloat(this.urlParams.get("min_freq")); - const maxFreq = Number.parseFloat(this.urlParams.get("max_freq")); - const frequencyRangeSlider = document.getElementById( - "frequency-range-slider", - ); - if (frequencyRangeSlider?.noUiSlider) { - const currentValues = frequencyRangeSlider.noUiSlider.get(); - const newMin = !Number.isNaN(minFreq) - ? minFreq - : Number.parseFloat(currentValues[0]); - const newMax = !Number.isNaN(maxFreq) - ? maxFreq - : Number.parseFloat(currentValues[1]); - - frequencyRangeSlider.noUiSlider.set([newMin, newMax]); - } - } catch (error) { - console.error("Error initializing frequency slider:", error); - } - } - - /** - * Initialize dropdowns with body container for proper positioning - */ - initializeDropdowns() { - if (window.DropdownUtils) { - window.DropdownUtils.initIconDropdowns(document); - } - } -} diff --git a/gateway/sds_gateway/static/js/core/ModalManager.js b/gateway/sds_gateway/static/js/core/ModalManager.js index 4daf0ecd6..b74776393 100644 --- a/gateway/sds_gateway/static/js/core/ModalManager.js +++ b/gateway/sds_gateway/static/js/core/ModalManager.js @@ -893,6 +893,11 @@ class ModalManager { * Get CSRF token for API requests */ getCSRFToken() { + if (window.APIClient) { + try { + return new window.APIClient().getCSRFToken(); + } catch (_) {} + } const token = document.querySelector("[name=csrfmiddlewaretoken]"); return token ? token.value : ""; } diff --git a/gateway/sds_gateway/static/js/core/PageController.js b/gateway/sds_gateway/static/js/core/PageController.js new file mode 100644 index 000000000..65f8217ed --- /dev/null +++ b/gateway/sds_gateway/static/js/core/PageController.js @@ -0,0 +1,69 @@ +/** + * Base page controller: consistent lifecycle + cleanup. + * Intended for per-page controllers (captures/files/datasets) to extend. + */ +class PageController { + constructor() { + this._bindings = []; + this._initialized = false; + } + + /** + * Bind an event listener and track it for cleanup. + * @param {EventTarget|null} target + * @param {string} eventName + * @param {Function} handler + * @param {boolean|AddEventListenerOptions} [options] + */ + bind(target, eventName, handler, options) { + if (!target?.addEventListener) return; + target.addEventListener(eventName, handler, options); + this._bindings.push({ target, eventName, handler, options }); + } + + /** + * Remove all tracked listeners. + */ + unbindAll() { + for (const b of this._bindings) { + try { + b.target?.removeEventListener?.(b.eventName, b.handler, b.options); + } catch (_) {} + } + this._bindings = []; + } + + /** + * Initialize controller once. Subclasses should override the individual hooks. + */ + init() { + if (this._initialized) return; + this._initialized = true; + + this.cacheElements(); + this.initializeComponents(); + this.initializeEventHandlers(); + this.initializeFromURL(); + } + + // Hooks (override in subclasses) + cacheElements() {} + initializeComponents() {} + initializeEventHandlers() {} + initializeFromURL() {} + + /** + * Cleanup hook (override). Must call super.destroy(). + */ + destroy() { + this.unbindAll(); + } +} + +if (typeof window !== "undefined") { + window.PageController = PageController; +} +if (typeof module !== "undefined" && module.exports) { + module.exports = { PageController }; +} + diff --git a/gateway/sds_gateway/static/js/core/PageLifecycleManager.js b/gateway/sds_gateway/static/js/core/PageLifecycleManager.js index b8f9d8846..631e90d53 100644 --- a/gateway/sds_gateway/static/js/core/PageLifecycleManager.js +++ b/gateway/sds_gateway/static/js/core/PageLifecycleManager.js @@ -270,26 +270,38 @@ class PageLifecycleManager { * Initialize pagination */ initializePagination() { - const paginationLinks = document.querySelectorAll( - ".pagination a.page-link", - ); + if (!window.PaginationManager) { + console.error( + "PaginationManager is required but not loaded. Ensure core/PaginationManager.js is included before PageLifecycleManager initializes.", + ); + return; + } - for (const link of paginationLinks) { - // Prevent duplicate event listener attachment - if (link.dataset.paginationSetup === "true") { - continue; - } - link.dataset.paginationSetup = "true"; + const containerIds = ["captures-pagination", "datasets-pagination", "files-pagination"]; + for (const containerId of containerIds) { + const el = document.getElementById(containerId); + if (!el) continue; - link.addEventListener("click", (e) => { - e.preventDefault(); - const page = link.getAttribute("data-page"); - if (page) { + const mgr = new window.PaginationManager({ + containerId, + onPageChange: (page) => { const urlParams = new URLSearchParams(window.location.search); - urlParams.set("page", page); + urlParams.set("page", String(page)); window.location.search = urlParams.toString(); - } + }, }); + + // Server renders pagination HTML; wire clicks via the shared callback. + const links = el.querySelectorAll(".pagination a.page-link"); + for (const link of links) { + if (link.dataset.paginationSetup === "true") continue; + link.dataset.paginationSetup = "true"; + link.addEventListener("click", (e) => { + e.preventDefault(); + const p = Number.parseInt(link.getAttribute("data-page") || ""); + if (p) mgr.onPageChange?.(p); + }); + } } } diff --git a/gateway/sds_gateway/static/js/core/PaginationManager.js b/gateway/sds_gateway/static/js/core/PaginationManager.js index 22c07fe9a..c581e2e22 100644 --- a/gateway/sds_gateway/static/js/core/PaginationManager.js +++ b/gateway/sds_gateway/static/js/core/PaginationManager.js @@ -68,6 +68,7 @@ class PaginationManager { }); } } +} if (typeof window !== "undefined") { window.PaginationManager = PaginationManager; diff --git a/gateway/sds_gateway/static/js/core/TableManager.js b/gateway/sds_gateway/static/js/core/TableManager.js index f8afde55c..4b3d6b818 100644 --- a/gateway/sds_gateway/static/js/core/TableManager.js +++ b/gateway/sds_gateway/static/js/core/TableManager.js @@ -14,6 +14,8 @@ class TableManager { ); this.currentSort = { by: "created_at", order: "desc" }; this.onRowClick = config.onRowClick; + this.sortBehavior = config.sortBehavior || "pushState"; // 'pushState' | 'reload' | 'callback' + this.onSortChange = config.onSortChange || null; this.initializeSorting(); } @@ -46,6 +48,20 @@ class TableManager { } this.currentSort = { by: field, order: newOrder }; + if (this.sortBehavior === "callback" && typeof this.onSortChange === "function") { + this.onSortChange({ sort_by: field, sort_order: newOrder, page: "1" }); + return; + } + + if (this.sortBehavior === "reload") { + const urlParams = new URLSearchParams(window.location.search); + urlParams.set("sort_by", field); + urlParams.set("sort_order", newOrder); + urlParams.set("page", "1"); + window.location.search = urlParams.toString(); + return; + } + this.updateURL({ sort_by: field, sort_order: newOrder, page: "1" }); } diff --git a/gateway/sds_gateway/static/js/dataset/AuthorsManager.js b/gateway/sds_gateway/static/js/dataset/AuthorsManager.js new file mode 100644 index 000000000..dd29931a2 --- /dev/null +++ b/gateway/sds_gateway/static/js/dataset/AuthorsManager.js @@ -0,0 +1,60 @@ +/** + * Shared author list formatting and DOM readback for dataset flows. + */ +class AuthorsManager { + /** + * @param {unknown[]} authors + * @returns {string} + */ + static formatAuthors(authors) { + if (!Array.isArray(authors) || authors.length === 0) { + return "No authors specified."; + } + + return authors + .map((author) => + typeof author === "string" ? author : author.name || "Unknown", + ) + .join(", "); + } + + /** + * @returns {{ name: string, orcid_id: string, _stableId: string }[]} + */ + static getCurrentAuthorsWithDOMIds() { + const authorsList = document.querySelector(".authors-list"); + const currentAuthors = []; + + if (authorsList) { + const authorItems = authorsList.querySelectorAll( + ".author-item:not(.marked-for-removal)", + ); + + for (const authorItem of authorItems) { + const authorId = authorItem.id; + if (!authorId) { + console.error("❌ Author item missing ID"); + return; + } + + const nameInput = authorItem.querySelector(".author-name-input"); + const orcidInput = authorItem.querySelector(".author-orcid-input"); + + currentAuthors.push({ + name: nameInput?.value || "", + orcid_id: orcidInput?.value || "", + _stableId: authorId, + }); + } + } + + return currentAuthors; + } +} + +if (typeof window !== "undefined") { + window.AuthorsManager = AuthorsManager; +} +if (typeof module !== "undefined" && module.exports) { + module.exports = { AuthorsManager }; +} diff --git a/gateway/sds_gateway/static/js/dataset/DatasetCreationHandler.js b/gateway/sds_gateway/static/js/dataset/DatasetCreationHandler.js index 171f72017..04b47c476 100644 --- a/gateway/sds_gateway/static/js/dataset/DatasetCreationHandler.js +++ b/gateway/sds_gateway/static/js/dataset/DatasetCreationHandler.js @@ -1513,15 +1513,7 @@ class DatasetCreationHandler { * Format authors array into display string */ formatAuthors(authors) { - if (!Array.isArray(authors) || authors.length === 0) { - return "No authors specified."; - } - - return authors - .map((author) => - typeof author === "string" ? author : author.name || "Unknown", - ) - .join(", "); + return window.AuthorsManager.formatAuthors(authors); } } diff --git a/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js b/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js index 2fe5c87cc..c7d645b44 100644 --- a/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js +++ b/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js @@ -1672,38 +1672,7 @@ class DatasetEditingHandler { * Get current authors with DOM-based stable IDs */ getCurrentAuthorsWithDOMIds() { - const authorsList = document.querySelector(".authors-list"); - const currentAuthors = []; - - if (authorsList) { - // Get all visible author items (not marked for removal) - const authorItems = authorsList.querySelectorAll( - ".author-item:not(.marked-for-removal)", - ); - - for (const [index, authorItem] of authorItems.entries()) { - // Get the stable ID from the DOM element - const authorId = authorItem.id; - if (!authorId) { - console.error("❌ Author item missing ID"); - return; - } - - // Get current values from the inputs - const nameInput = authorItem.querySelector(".author-name-input"); - const orcidInput = authorItem.querySelector(".author-orcid-input"); - - const authorData = { - name: nameInput?.value || "", - orcid_id: orcidInput?.value || "", - _stableId: authorId, - }; - - currentAuthors.push(authorData); - } - } - - return currentAuthors; + return window.AuthorsManager.getCurrentAuthorsWithDOMIds(); } /** @@ -1755,15 +1724,7 @@ class DatasetEditingHandler { * Format authors array into display string */ formatAuthors(authors) { - if (!Array.isArray(authors) || authors.length === 0) { - return "No authors specified."; - } - - return authors - .map((author) => - typeof author === "string" ? author : author.name || "Unknown", - ) - .join(", "); + return window.AuthorsManager.formatAuthors(authors); } /** diff --git a/gateway/sds_gateway/static/js/share/ShareGroupManager.js b/gateway/sds_gateway/static/js/share/ShareGroupManager.js index 250887361..e27e7866b 100644 --- a/gateway/sds_gateway/static/js/share/ShareGroupManager.js +++ b/gateway/sds_gateway/static/js/share/ShareGroupManager.js @@ -1026,40 +1026,7 @@ class ShareGroupManager { * @returns {Element|null} Dropdown element */ getDropdownForInput(input) { - // First try the original pattern: user-search-dropdown-{uuid} - let dropdown = document.getElementById( - `user-search-dropdown-${input.id.replace("user-search-", "")}`, - ); - - if (dropdown) { - return dropdown; - } - - // Try alternative patterns - const alternativeIds = [ - "user-search-dropdown-sharegroup", - "user-search-dropdown", - `${input.id}-dropdown`, - ]; - - for (const id of alternativeIds) { - dropdown = document.getElementById(id); - if (dropdown) { - return dropdown; - } - } - - // If still not found, look for any dropdown in the same container - const container = input.closest(".user-search-input-container"); - if (container) { - dropdown = container.querySelector(".user-search-dropdown"); - if (dropdown) { - return dropdown; - } - } - - console.error(`Could not find dropdown for input: ${input.id}`); - return null; + return window.UserSearchDropdown.getDropdownForInput(input, {}); } /** @@ -1069,34 +1036,7 @@ class ShareGroupManager { * @param {number} direction - Direction to navigate */ navigateDropdown(items, currentIndex, direction) { - // Remove current selection - for (const item of items) { - item.classList.remove("selected"); - } - - // Calculate new index - let newIndex; - if (currentIndex === -1) { - // No item is currently selected - if (direction > 0) { - // ArrowDown: start from first item - newIndex = 0; - } else { - // ArrowUp: start from last item - newIndex = items.length - 1; - } - } else { - // An item is currently selected - newIndex = currentIndex + direction; - if (newIndex < 0) newIndex = items.length - 1; - if (newIndex >= items.length) newIndex = 0; - } - - // Add selection to new item - if (items[newIndex]) { - items[newIndex].classList.add("selected"); - items[newIndex].scrollIntoView({ block: "nearest" }); - } + window.UserSearchDropdown.navigateDropdown(items, currentIndex, direction); } /** @@ -1104,9 +1044,7 @@ class ShareGroupManager { * @param {Element} dropdown - Dropdown element */ showDropdown(dropdown) { - if (dropdown) { - dropdown.classList.remove("d-none"); - } + window.UserSearchDropdown.showDropdown(dropdown); } /** @@ -1114,13 +1052,7 @@ class ShareGroupManager { * @param {Element} dropdown - Dropdown element */ hideDropdown(dropdown) { - if (dropdown) { - dropdown.classList.add("d-none"); - // Clear any selections - for (const item of dropdown.querySelectorAll(".list-group-item")) { - item.classList.remove("selected"); - } - } + window.UserSearchDropdown.hideDropdown(dropdown); } /** diff --git a/gateway/sds_gateway/static/js/share/UserSearchDropdown.js b/gateway/sds_gateway/static/js/share/UserSearchDropdown.js new file mode 100644 index 000000000..5c9593db7 --- /dev/null +++ b/gateway/sds_gateway/static/js/share/UserSearchDropdown.js @@ -0,0 +1,98 @@ +/** + * Shared DOM helpers for user-search dropdowns in share modals. + * Used by ShareActionManager and ShareGroupManager. + */ +class UserSearchDropdown { + /** + * @param {HTMLInputElement} input + * @param {{ itemUuid?: string }} [options] + * @returns {Element|null} + */ + static getDropdownForInput(input, options = {}) { + if (!input) return null; + + const suffix = input.id.replace("user-search-", ""); + let dropdown = document.getElementById(`user-search-dropdown-${suffix}`); + if (dropdown) { + return dropdown; + } + + const altIds = []; + if (options.itemUuid) { + altIds.push(`user-search-dropdown-${options.itemUuid}`); + } + altIds.push( + "user-search-dropdown-sharegroup", + "user-search-dropdown", + input.id.replace("user-search-", "user-search-dropdown-"), + `${input.id}-dropdown`, + ); + + for (const id of altIds) { + dropdown = document.getElementById(id); + if (dropdown) { + return dropdown; + } + } + + const container = input.closest(".user-search-input-container"); + if (container) { + dropdown = container.querySelector(".user-search-dropdown"); + if (dropdown) { + return dropdown; + } + } + + console.error(`Could not find dropdown for input: ${input.id}`); + return null; + } + + /** + * @param {NodeListOf|Element[]} items + * @param {number} currentIndex + * @param {number} direction + */ + static navigateDropdown(items, currentIndex, direction) { + for (const item of items) { + item.classList.remove("selected"); + } + + let newIndex; + if (currentIndex === -1) { + newIndex = direction > 0 ? 0 : items.length - 1; + } else { + newIndex = currentIndex + direction; + if (newIndex < 0) newIndex = items.length - 1; + if (newIndex >= items.length) newIndex = 0; + } + + if (items[newIndex]) { + items[newIndex].classList.add("selected"); + items[newIndex].scrollIntoView({ block: "nearest" }); + } + } + + /** @param {Element|null|undefined} dropdown */ + static showDropdown(dropdown) { + if (dropdown) { + dropdown.classList.remove("d-none"); + } + } + + /** @param {Element|null|undefined} dropdown */ + static hideDropdown(dropdown) { + if (dropdown) { + dropdown.classList.add("d-none"); + for (const item of dropdown.querySelectorAll(".list-group-item")) { + item.classList.remove("selected"); + } + } + } +} + +if (typeof window !== "undefined") { + window.UserSearchDropdown = UserSearchDropdown; +} +if (typeof module !== "undefined" && module.exports) { + module.exports = { UserSearchDropdown }; +} diff --git a/gateway/sds_gateway/static/js/upload/ChunkUploadPipeline.js b/gateway/sds_gateway/static/js/upload/ChunkUploadPipeline.js new file mode 100644 index 000000000..a17dd5221 --- /dev/null +++ b/gateway/sds_gateway/static/js/upload/ChunkUploadPipeline.js @@ -0,0 +1,66 @@ +/** + * Shared capture upload FormData helpers and byte-based chunk counting. + * Used by FilesUploadModal and UploadCaptureModalController. + */ +class ChunkUploadPipeline { + /** + * Append capture type + related fields from the upload modal DOM. + * @param {FormData} formData + */ + static appendCaptureTypeToFormData(formData) { + const captureType = + document.getElementById("captureTypeSelect")?.value || ""; + formData.append("capture_type", captureType); + + if (captureType === "drf") { + formData.append( + "channels", + document.getElementById("captureChannelsInput")?.value || "", + ); + } else if (captureType === "rh") { + formData.append( + "scan_group", + document.getElementById("captureScanGroupInput")?.value || "", + ); + } + } + + /** + * Count POST chunks when grouping files by total byte size per chunk. + * @param {File[]} filesToUpload + * @param {number} chunkSizeBytes + * @returns {number} + */ + static calculateTotalChunks(filesToUpload, chunkSizeBytes) { + let totalChunks = 0; + let tempChunkSize = 0; + let tempChunkFiles = 0; + + for (const file of filesToUpload) { + if (tempChunkSize + file.size > chunkSizeBytes && tempChunkFiles > 0) { + totalChunks++; + tempChunkSize = 0; + tempChunkFiles = 0; + } + + if (file.size > chunkSizeBytes) { + totalChunks++; + tempChunkSize = 0; + tempChunkFiles = 0; + } else { + tempChunkSize += file.size; + tempChunkFiles++; + } + } + + if (tempChunkSize > 0) totalChunks++; + return totalChunks; + } +} + +if (typeof window !== "undefined") { + window.ChunkUploadPipeline = ChunkUploadPipeline; +} +if (typeof module !== "undefined" && module.exports) { + module.exports = { ChunkUploadPipeline }; +} diff --git a/gateway/sds_gateway/static/js/upload/FilesUploadModal.js b/gateway/sds_gateway/static/js/upload/FilesUploadModal.js index 09085e5de..e8622c27b 100644 --- a/gateway/sds_gateway/static/js/upload/FilesUploadModal.js +++ b/gateway/sds_gateway/static/js/upload/FilesUploadModal.js @@ -453,16 +453,7 @@ class FilesUploadModal { } addCaptureTypeData(formData) { - const captureType = document.getElementById("captureTypeSelect").value; - formData.append("capture_type", captureType); - - if (captureType === "drf") { - const channels = document.getElementById("captureChannelsInput").value; - formData.append("channels", channels); - } else if (captureType === "rh") { - const scanGroup = document.getElementById("captureScanGroupInput").value; - formData.append("scan_group", scanGroup); - } + window.ChunkUploadPipeline.appendCaptureTypeToFormData(formData); } handleError(error) { diff --git a/gateway/sds_gateway/static/js/upload/UploadCaptureModalController.js b/gateway/sds_gateway/static/js/upload/UploadCaptureModalController.js index 9f58a1fd0..bf17820e0 100644 --- a/gateway/sds_gateway/static/js/upload/UploadCaptureModalController.js +++ b/gateway/sds_gateway/static/js/upload/UploadCaptureModalController.js @@ -1,11 +1,263 @@ /** * Upload capture modal: submit, cancel/abort, progress, beforeunload guards. - * Placeholder: migrate from file_list_upload_capture_modal.js in a later step. + * Migrated from file_list_upload_capture_modal.js (file list page flow). */ class UploadCaptureModalController { - constructor() {} + constructor(options = {}) { + this.options = options; - init() {} + this.isProcessing = false; + this.uploadInProgress = false; + this.cancelRequested = false; + this.currentAbortController = null; + + this.uploadModal = null; + this.cancelButton = null; + this.closeButton = null; + this.submitButton = null; + this.fileInput = null; + + this._beforeUnloadHandler = null; + this._visibilityHandler = null; + } + + init() { + this.uploadModal = document.getElementById( + this.options.uploadModalId || "uploadCaptureModal", + ); + if (!this.uploadModal) { + console.warn("uploadCaptureModal not found"); + return; + } + + this.cancelButton = + this.uploadModal.querySelector(this.options.cancelButtonSelector) || + this.uploadModal.querySelector(".btn-secondary"); + this.closeButton = + this.uploadModal.querySelector(this.options.closeButtonSelector) || + this.uploadModal.querySelector(".btn-close"); + this.submitButton = document.getElementById( + this.options.submitButtonId || "uploadSubmitBtn", + ); + this.fileInput = document.getElementById( + this.options.fileInputId || "captureFileInput", + ); + + if (!this.cancelButton || !this.closeButton || !this.submitButton) { + console.warn("Required buttons not found in upload modal"); + return; + } + + this.clearExistingResultModal(); + this.clearUploadSessionStorage(); + this.addBeforeUnloadGuard(); + this.addVisibilityListener(); + this.addModalStateResetHandlers(); + this.addCancelHandlers(); + this.addSubmitHandler(); + this.addFileSelectionHandler(); + } + + clearExistingResultModal() { + const existingResultModal = document.getElementById("uploadResultModal"); + if (!existingResultModal) return; + if (!window.bootstrap?.Modal) return; + const modalInstance = bootstrap.Modal.getInstance(existingResultModal); + if (modalInstance) { + modalInstance.hide(); + } + } + + clearUploadSessionStorage() { + try { + if (sessionStorage.getItem("uploadInProgress")) { + sessionStorage.removeItem("uploadInProgress"); + } + } catch (_) {} + } + + addBeforeUnloadGuard() { + if (this._beforeUnloadHandler) { + window.removeEventListener("beforeunload", this._beforeUnloadHandler); + } + this._beforeUnloadHandler = (e) => { + let inProgress = false; + try { + inProgress = + this.isProcessing || + this.uploadInProgress || + Boolean(sessionStorage.getItem("uploadInProgress")); + } catch (_) { + inProgress = this.isProcessing || this.uploadInProgress; + } + if (!inProgress) return; + e.preventDefault(); + e.returnValue = + "Upload in progress will be aborted. Are you sure you want to leave?"; + return e.returnValue; + }; + window.addEventListener("beforeunload", this._beforeUnloadHandler); + } + + addVisibilityListener() { + if (this._visibilityHandler) { + document.removeEventListener("visibilitychange", this._visibilityHandler); + } + this._visibilityHandler = () => { + // Preserve legacy behavior (no-op but keeps a hook if needed later) + if (document.visibilityState === "hidden" && this.uploadInProgress) { + // page hidden during upload + } + }; + document.addEventListener("visibilitychange", this._visibilityHandler); + } + + addModalStateResetHandlers() { + this.uploadModal.addEventListener("show.bs.modal", () => { + this.isProcessing = false; + this.currentAbortController = null; + }); + this.uploadModal.addEventListener("hidden.bs.modal", () => { + this.isProcessing = false; + this.currentAbortController = null; + }); + } + + addFileSelectionHandler() { + if (!this.fileInput) return; + this.fileInput.addEventListener("change", () => { + this.isProcessing = false; + this.currentAbortController = null; + }); + } + + addCancelHandlers() { + this.cancelButton.addEventListener("click", () => { + this.handleCancellation("cancel"); + }); + this.closeButton.addEventListener("click", () => { + this.handleCancellation("close"); + }); + } + + addSubmitHandler() { + const uploadForm = document.getElementById( + this.options.uploadFormId || "uploadCaptureForm", + ); + if (!uploadForm) return; + + uploadForm.addEventListener("submit", async (e) => { + e.preventDefault(); + + this.isProcessing = true; + this.uploadInProgress = true; + this.cancelRequested = false; + try { + sessionStorage.setItem("uploadInProgress", "true"); + } catch (_) {} + + try { + if (!window.selectedFiles || window.selectedFiles.length === 0) { + alert("Please select files to upload."); + return; + } + + const files = window.selectedFiles; + + if (this.checkForLargeFiles(files, this.cancelButton, this.submitButton)) { + return; + } + + await this.checkFilesForDuplicates(files); + + const { filesToUpload, relativePathsToUpload, allRelativePaths, skippedFilesCount } = + this.partitionFilesForUpload(files); + + const uploadResults = await this.uploadFilesInChunks( + filesToUpload, + relativePathsToUpload, + allRelativePaths, + filesToUpload.length, + ); + + this.currentAbortController = null; + + this.showUploadResults( + uploadResults, + uploadResults.saved_files_count, + files.length, + skippedFilesCount, + ); + } catch (error) { + if (this.cancelRequested) { + if (!this.uploadInProgress) { + // cancelled during duplicate checking; legacy flow already alerted + } else { + alert( + "Upload cancelled. Any files uploaded before cancellation have been saved.", + ); + setTimeout(() => window.location.reload(), 1000); + } + } else if (error?.name === "AbortError") { + alert( + "Upload was interrupted. Any files uploaded before the interruption have been saved.", + ); + setTimeout(() => window.location.reload(), 1000); + } else if ( + error?.name === "TypeError" && + String(error?.message || "").includes("fetch") + ) { + let shouldSuppress = false; + try { + shouldSuppress = + this.uploadInProgress || Boolean(sessionStorage.getItem("uploadInProgress")); + } catch (_) {} + if (!shouldSuppress) { + alert( + "Network error during upload. Please check your connection and try again.", + ); + } + } else { + alert(`Upload failed: ${error?.message || "Unknown error"}`); + setTimeout(() => window.location.reload(), 1000); + } + } finally { + this.resetUIState(); + } + }); + } + + partitionFilesForUpload(files) { + const filesToUpload = []; + const relativePathsToUpload = []; + const allRelativePaths = []; + let skippedFilesCount = 0; + + for (const file of files) { + let directory = "/"; + if (file.webkitRelativePath) { + const pathParts = file.webkitRelativePath.split("/"); + if (pathParts.length > 1) { + pathParts.pop(); + directory = `/${pathParts.join("/")}`; + } + } + + const fileKey = `${directory}/${file.name}`; + const relativePath = file.webkitRelativePath || file.name; + + allRelativePaths.push(relativePath); + + if (!window.filesToSkip?.has?.(fileKey)) { + filesToUpload.push(file); + relativePathsToUpload.push(relativePath); + } else { + skippedFilesCount++; + } + } + + return { filesToUpload, relativePathsToUpload, allRelativePaths, skippedFilesCount }; + } /** * @param {File[]} files @@ -14,19 +266,514 @@ class UploadCaptureModalController { * @returns {boolean} true if large files blocked flow */ checkForLargeFiles(files, cancelButton, submitButton) { - void files; - void cancelButton; - void submitButton; - return false; + const progressSection = document.getElementById("checkingProgressSection"); + const LARGE_FILE_THRESHOLD = 512 * 1024 * 1024; // 512MB + const largeFiles = (files || []).filter( + (file) => file && file.size > LARGE_FILE_THRESHOLD, + ); + + if (largeFiles.length === 0) return false; + + if (progressSection) progressSection.style.display = "none"; + if (cancelButton) { + cancelButton.textContent = "Cancel"; + cancelButton.classList.remove("btn-warning"); + cancelButton.disabled = false; + } + if (submitButton) submitButton.disabled = false; + + const largeFileNames = largeFiles.map((file) => file.name).join(", "); + const alertMessage = `Large files detected (over 512MB): ${largeFileNames}\n\nPlease:\n1. Skip these large files and upload the remaining files, or\n2. Use the SpectrumX SDK (https://pypi.org/project/spectrumx/) to upload large files and add them to your capture.\n\nLarge files may cause issues with the web interface.`; + alert(alertMessage); + return true; + } + + getCSRFToken() { + if (window.APIClient) { + try { + return new window.APIClient().getCSRFToken(); + } catch (_) {} + } + const token = document.querySelector("[name=csrfmiddlewaretoken]"); + return token ? token.value : ""; + } + + async checkFilesForDuplicates(files) { + const progressSection = document.getElementById("checkingProgressSection"); + const progressBar = document.getElementById("checkingProgressBar"); + const progressText = document.getElementById("checkingProgressText"); + const progressMessage = document.getElementById("progressMessage"); + + if (progressSection) progressSection.style.display = "block"; + if (progressMessage) progressMessage.textContent = "Processing files for upload..."; + + if (this.cancelButton) this.cancelButton.textContent = "Cancel Processing"; + if (this.submitButton) this.submitButton.disabled = true; + + window.filesToSkip = new Set(); + window.fileCheckResults = new Map(); + + const csrfToken = this.getCSRFToken(); + if (!csrfToken) { + throw new Error("CSRF token not found"); + } + + const totalFiles = files.length; + const checkFileUrl = + document.querySelector("[data-check-file-url]")?.dataset?.checkFileUrl || + "/users/check-file-exists/"; + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + + const progress = Math.round(((i + 1) / totalFiles) * 100); + if (progressBar) progressBar.style.width = `${progress}%`; + if (progressText) progressText.textContent = `${progress}%`; + + const buffer = await file.arrayBuffer(); + const hasher = await hashwasm.createBLAKE3(); + hasher.init(); + hasher.update(new Uint8Array(buffer)); + const hashHex = hasher.digest("hex"); + + let directory = "/"; + if (file.webkitRelativePath) { + const pathParts = file.webkitRelativePath.split("/"); + if (pathParts.length > 1) { + pathParts.pop(); + directory = `/${pathParts.join("/")}`; + } + } + + const checkData = { + directory, + filename: file.name, + checksum: hashHex, + }; + + try { + const response = await fetch(checkFileUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": csrfToken, + }, + body: JSON.stringify(checkData), + }); + const data = await response.json(); + + const fileKey = `${directory}/${file.name}`; + window.fileCheckResults.set(fileKey, { + file, + directory, + filename: file.name, + checksum: hashHex, + data: data.data, + }); + if (data?.data?.file_exists_in_tree === true) { + window.filesToSkip.add(fileKey); + } + } catch (error) { + console.error("Error checking file:", error); + } + + if (this.cancelRequested) { + break; + } + } + + if (progressSection) progressSection.style.display = "none"; + + if (this.cancelRequested) { + if (progressSection) progressSection.style.display = "none"; + await new Promise((resolve) => setTimeout(resolve, 100)); + alert("Processing cancelled. No files were uploaded."); + throw new Error("Upload cancelled by user"); + } + } + + async handleSkippedFilesUpload(allRelativePaths, abortController) { + const skippedFormData = new FormData(); + for (const path of allRelativePaths) { + skippedFormData.append("all_relative_paths", path); + } + + window.ChunkUploadPipeline.appendCaptureTypeToFormData(skippedFormData); + + const uploadUrl = + document.querySelector("[data-upload-url]")?.dataset?.uploadUrl || + "/users/upload-capture/"; + const csrfToken = this.getCSRFToken(); + + const response = await fetch(uploadUrl, { + method: "POST", + headers: { "X-CSRFToken": csrfToken }, + body: skippedFormData, + signal: abortController.signal, + }); + return await response.json(); + } + + calculateTotalChunks(filesToUpload, chunkSizeBytes) { + return window.ChunkUploadPipeline.calculateTotalChunks( + filesToUpload, + chunkSizeBytes, + ); + } + + async uploadChunk({ + chunk, + chunkPaths, + chunkNum, + totalChunks, + filesProcessed, + isFinalChunk, + allResults, + allRelativePaths, + totalFiles, + chunkSizeBytes, + }) { + const progressBar = document.getElementById("checkingProgressBar"); + const progressText = document.getElementById("checkingProgressText"); + const progressMessage = document.getElementById("progressMessage"); + + const progress = Math.round((filesProcessed / totalFiles) * 100); + if (progressBar) progressBar.style.width = `${progress}%`; + if (progressText) progressText.textContent = `${progress}%`; + + if (progressMessage) { + if (chunk.length === 1 && chunk[0].size > chunkSizeBytes) { + const file = chunk[0]; + progressMessage.textContent = `Uploading large file: ${file.name} (${(file.size / (1024 * 1024)).toFixed(1)} MB)...`; + } else { + progressMessage.textContent = `Uploading chunk ${chunkNum}/${totalChunks} (${filesProcessed} files processed)...`; + } + } + + const chunkFormData = new FormData(); + for (const file of chunk) chunkFormData.append("files", file); + for (const path of chunkPaths) chunkFormData.append("relative_paths", path); + for (const path of allRelativePaths) + chunkFormData.append("all_relative_paths", path); + + window.ChunkUploadPipeline.appendCaptureTypeToFormData(chunkFormData); + + chunkFormData.append("is_chunk", "true"); + chunkFormData.append("chunk_number", String(chunkNum)); + chunkFormData.append("total_chunks", String(totalChunks)); + + if (this.cancelRequested) { + throw new Error("Upload cancelled by user"); + } + + const controller = new AbortController(); + this.currentAbortController = controller; + + const MIN_AVG_UPLOAD_RATE = 100 * 1024; // 100KB/s + const MIN_TIMEOUT_MS = 30000; + const totalChunkBytes = chunk.reduce((t, f) => t + f.size, 0); + const calculatedTimeout = (totalChunkBytes / MIN_AVG_UPLOAD_RATE) * 1000; + const timeout = Math.max(calculatedTimeout, MIN_TIMEOUT_MS); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + const uploadUrl = + document.querySelector("[data-upload-url]")?.dataset?.uploadUrl || + "/users/upload-capture/"; + const csrfToken = this.getCSRFToken(); + + let response; + let chunkResult; + try { + response = await fetch(uploadUrl, { + method: "POST", + headers: { "X-CSRFToken": csrfToken }, + body: chunkFormData, + signal: controller.signal, + }); + clearTimeout(timeoutId); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + chunkResult = await response.json(); + } catch (error) { + clearTimeout(timeoutId); + if (error?.name === "AbortError") { + throw new Error("Upload timeout - connection may be lost"); + } + throw error; + } + + if (chunkResult.saved_files_count !== undefined) { + allResults.saved_files_count += chunkResult.saved_files_count; + } + if (chunkResult.captures && isFinalChunk) { + allResults.captures = allResults.captures.concat(chunkResult.captures); + } + if (chunkResult.message && isFinalChunk) { + allResults.message = chunkResult.message; + } + if (chunkResult.errors) { + allResults.errors = allResults.errors.concat(chunkResult.errors); + } + + if (chunkResult.file_upload_status === "error") { + allResults.file_upload_status = "error"; + allResults.message = chunkResult.message || "Upload failed"; + if (progressMessage) { + progressMessage.textContent = + "Upload aborted due to errors. Please check the results."; + } + throw new Error(`Upload failed: ${chunkResult.message}`); + } + if (chunkResult.file_upload_status === "success" && isFinalChunk) { + allResults.file_upload_status = "success"; + } } - resetUIState() {} + async uploadFilesInChunks( + filesToUpload, + relativePathsToUpload, + allRelativePaths, + totalFiles, + ) { + const progressBar = document.getElementById("checkingProgressBar"); + const progressText = document.getElementById("checkingProgressText"); + const progressMessage = document.getElementById("progressMessage"); + const progressSection = document.getElementById("checkingProgressSection"); + + if (filesToUpload.length > 0) { + if (progressSection) progressSection.style.display = "block"; + if (progressMessage) + progressMessage.textContent = "Uploading files and creating captures..."; + if (progressBar) progressBar.style.width = "0%"; + if (progressText) progressText.textContent = "0%"; + } + + const abortController = new AbortController(); + this.currentAbortController = abortController; + + const CHUNK_SIZE_BYTES = 50 * 1024 * 1024; + + let allResults = { + file_upload_status: "success", + saved_files_count: 0, + captures: [], + errors: [], + message: "", + }; + + if (filesToUpload.length === 0) { + allResults = await this.handleSkippedFilesUpload( + allRelativePaths, + abortController, + ); + } else { + let currentChunk = []; + let currentChunkPaths = []; + let currentChunkSize = 0; + let chunkNumber = 1; + let filesProcessed = 0; + + const totalChunks = this.calculateTotalChunks( + filesToUpload, + CHUNK_SIZE_BYTES, + ); + + for (let i = 0; i < filesToUpload.length; i++) { + const file = filesToUpload[i]; + const filePath = relativePathsToUpload[i]; + + if ( + currentChunkSize + file.size > CHUNK_SIZE_BYTES && + currentChunk.length > 0 + ) { + await this.uploadChunk({ + chunk: currentChunk, + chunkPaths: currentChunkPaths, + chunkNum: chunkNumber, + totalChunks, + filesProcessed, + isFinalChunk: false, + allResults, + allRelativePaths, + totalFiles, + chunkSizeBytes: CHUNK_SIZE_BYTES, + }); + currentChunk = []; + currentChunkPaths = []; + currentChunkSize = 0; + chunkNumber++; + } + + currentChunk.push(file); + currentChunkPaths.push(filePath); + currentChunkSize += file.size; + filesProcessed++; + + if (i === filesToUpload.length - 1) { + await this.uploadChunk({ + chunk: currentChunk, + chunkPaths: currentChunkPaths, + chunkNum: chunkNumber, + totalChunks, + filesProcessed, + isFinalChunk: true, + allResults, + allRelativePaths, + totalFiles, + chunkSizeBytes: CHUNK_SIZE_BYTES, + }); + } + + if (this.cancelRequested) break; + } + } + + if (this.cancelRequested) { + await new Promise((resolve) => setTimeout(resolve, 100)); + throw new Error("Upload cancelled by user"); + } + + if (allResults.file_upload_status === "error") { + this.currentAbortController = null; + this.showUploadResults(allResults, allResults.saved_files_count, totalFiles); + return allResults; + } + + this.currentAbortController = null; + return allResults; + } + + resetUIState() { + if (this.submitButton) this.submitButton.disabled = false; + + const progressSection = document.getElementById("checkingProgressSection"); + if (progressSection) progressSection.style.display = "none"; + + if (this.cancelButton) { + this.cancelButton.textContent = "Cancel"; + this.cancelButton.classList.remove("btn-warning"); + this.cancelButton.disabled = false; + } + + if (this.closeButton) { + this.closeButton.disabled = false; + this.closeButton.style.opacity = "1"; + } + + const progressBar = document.getElementById("checkingProgressBar"); + const progressText = document.getElementById("checkingProgressText"); + const progressMessage = document.getElementById("progressMessage"); + if (progressBar) progressBar.style.width = "0%"; + if (progressText) progressText.textContent = "0%"; + if (progressMessage) progressMessage.textContent = ""; + + this.isProcessing = false; + this.uploadInProgress = false; + this.cancelRequested = false; + try { + sessionStorage.removeItem("uploadInProgress"); + } catch (_) {} + this.currentAbortController = null; + } /** * @param {string} buttonType */ handleCancellation(buttonType) { - void buttonType; + if (!this.isProcessing) return; + this.cancelRequested = true; + if (this.currentAbortController) { + this.currentAbortController.abort(); + } + + if (buttonType === "cancel") { + this.cancelButton.textContent = "Cancelling..."; + this.cancelButton.disabled = true; + } else if (buttonType === "close") { + this.closeButton.disabled = true; + this.closeButton.style.opacity = "0.5"; + } + + const progressMessage = document.getElementById("progressMessage"); + if (progressMessage) { + progressMessage.textContent = "Cancelling upload..."; + } + + setTimeout(() => { + if (this.cancelRequested) { + this.resetUIState(); + } + }, 500); + } + + showUploadResults(result, uploadedCount, totalCount, skippedCount = 0) { + if (!this.uploadInProgress && result?.file_upload_status === "error") { + this.resetUIState(); + return; + } + + const modalBody = document.getElementById("uploadResultModalBody"); + const resultModalEl = document.getElementById("uploadResultModal"); + if (!modalBody || !resultModalEl || !window.bootstrap?.Modal) { + return; + } + const modal = new bootstrap.Modal(resultModalEl); + + const uploadModalInstance = bootstrap.Modal.getInstance( + document.getElementById("uploadCaptureModal"), + ); + if (uploadModalInstance) { + uploadModalInstance.hide(); + } + + let msg = ""; + if (result.file_upload_status === "success") { + if (uploadedCount === 0 && totalCount > 0) { + msg = `Upload complete!
      All ${totalCount} files already existed on the server.`; + } else if (skippedCount > 0) { + msg = `Upload complete!
      Files uploaded: ${uploadedCount} / ${totalCount}`; + msg += `
      Files already exist: ${skippedCount}`; + } else { + msg = `Upload complete!
      Files uploaded: ${uploadedCount} / ${totalCount}`; + } + + if (result.captures && result.captures.length > 0) { + const uuids = result.captures + .map((uuid) => `
    • ${uuid}
    • `) + .join(""); + msg += `
      Created capture UUID(s):
        ${uuids}
      `; + } + + if (result.errors && result.errors.length > 0) { + const errs = result.errors.map((e) => `
    • ${e}
    • `).join(""); + msg += `
      Errors:
        ${errs}
      `; + msg += "
      Please check details and upload again."; + } + } else { + msg = "Upload Failed
      "; + if (result.message) { + msg += `${result.message}

      `; + } + msg += "Please check file validity and try again."; + if (result.errors && result.errors.length > 0) { + const errs = result.errors.map((e) => `
    • ${e}
    • `).join(""); + msg += `

      Error Details:
        ${errs}
      `; + } + } + + modalBody.innerHTML = msg; + modal.show(); + + if (result.file_upload_status === "success") { + resultModalEl.addEventListener( + "hidden.bs.modal", + () => { + window.location.reload(); + }, + { once: true }, + ); + } } } From d63c84fb8071a88d6c646b21208692df4615f976 Mon Sep 17 00:00:00 2001 From: klpoland Date: Fri, 8 May 2026 15:38:32 -0400 Subject: [PATCH 5/7] repoint page js, new test coverage --- ...test.js => FileListPageController.test.js} | 36 ++-- .../PaginationManager.behavior.test.js | 83 ++++++++ .../TableManager.sortBehavior.test.js | 184 ++++++++++++++++++ .../js/captures/FileListPageController.js | 29 +++ ...CapturesTableManager.csrf-behavior.test.js | 97 +++++++++ .../__tests__/PageController.behavior.test.js | 62 ++++++ ...ecycleManager.initializePagination.test.js | 58 ++++++ .../static/js/tests-config/jest.config.js | 2 +- .../static/js/tests-config/jest.setup.js | 52 ++++- .../static/js/upload/FilesPageInitializer.js | 83 ++++---- .../__tests__/ChunkUploadPipeline.test.js | 57 ++++++ ...aptureModalController.getCSRFToken.test.js | 28 +++ .../templates/users/dataset_list.html | 2 + .../templates/users/file_list.html | 3 +- .../sds_gateway/templates/users/files.html | 19 +- .../templates/users/group_captures.html | 1 + .../templates/users/share_group_list.html | 1 + 17 files changed, 723 insertions(+), 74 deletions(-) rename gateway/sds_gateway/static/js/__tests__/{file-list.test.js => FileListPageController.test.js} (95%) create mode 100644 gateway/sds_gateway/static/js/__tests__/PaginationManager.behavior.test.js create mode 100644 gateway/sds_gateway/static/js/__tests__/TableManager.sortBehavior.test.js create mode 100644 gateway/sds_gateway/static/js/captures/__tests__/CapturesTableManager.csrf-behavior.test.js create mode 100644 gateway/sds_gateway/static/js/core/__tests__/PageController.behavior.test.js create mode 100644 gateway/sds_gateway/static/js/core/__tests__/PageLifecycleManager.initializePagination.test.js create mode 100644 gateway/sds_gateway/static/js/upload/__tests__/ChunkUploadPipeline.test.js create mode 100644 gateway/sds_gateway/static/js/upload/__tests__/UploadCaptureModalController.getCSRFToken.test.js diff --git a/gateway/sds_gateway/static/js/__tests__/file-list.test.js b/gateway/sds_gateway/static/js/__tests__/FileListPageController.test.js similarity index 95% rename from gateway/sds_gateway/static/js/__tests__/file-list.test.js rename to gateway/sds_gateway/static/js/__tests__/FileListPageController.test.js index 62582e290..c4dfb0af0 100644 --- a/gateway/sds_gateway/static/js/__tests__/file-list.test.js +++ b/gateway/sds_gateway/static/js/__tests__/FileListPageController.test.js @@ -1,6 +1,6 @@ /** - * Jest tests for file-list.js - * Tests FileListController and FileListCapturesTableManager functionality + * Jest tests for captures file list page. + * Tests FileListPageController and FileListCapturesTableManager-shaped mocks. */ // Mock components.js classes that file-list.js depends on @@ -55,11 +55,12 @@ global.window.ModalManager = MockModalManager; global.window.SearchManager = MockSearchManager; global.window.PaginationManager = MockPaginationManager; -// Mock CONFIG constant (file-list.js uses it) -global.CONFIG = { +// FileListPageController reads window.FileListConfig (see constants/FileListConfig.js) +global.window.FileListConfig = { DEBOUNCE_DELAY: 300, DEFAULT_SORT_BY: "created_at", DEFAULT_SORT_ORDER: "desc", + MIN_LOADING_TIME: 500, ELEMENT_IDS: { SEARCH_INPUT: "search-input", START_DATE: "start_date", @@ -72,6 +73,9 @@ global.CONFIG = { }, }; +const { PageController } = require("../core/PageController.js"); +global.window.PageController = PageController; + // Mock ComponentUtils global.window.ComponentUtils = { escapeHtml: jest.fn((str) => { @@ -100,12 +104,9 @@ global.bootstrap.Dropdown = jest options: options, })); -// NOW import the actual classes from file-list.js -// (after all dependencies are mocked) -// Use require() instead of import so it executes after mocks are set up -const { FileListController } = require("../file-list.js"); +const { FileListPageController } = require("../captures/FileListPageController.js"); -describe("FileListController", () => { +describe("FileListPageController", () => { let fileListController; let mockElements; let mockTableManager; @@ -225,23 +226,21 @@ describe("FileListController", () => { containerId: "captures-pagination", }); - // Mock global classes (they would be imported from components.js) + // Mock global classes (loaded as separate scripts on the real page) global.ModalManager = jest.fn(() => mockModalManager); global.SearchManager = jest.fn(() => mockSearchManager); global.PaginationManager = jest.fn(() => mockPaginationManager); - global.CapturesTableManager = jest.fn(() => mockTableManager); + global.window.FileListCapturesTableManager = jest.fn(() => mockTableManager); - // Also make them available on window (file-list.js uses them without global prefix) global.window.ModalManager = global.ModalManager; global.window.SearchManager = global.SearchManager; global.window.PaginationManager = global.PaginationManager; - global.window.CapturesTableManager = global.CapturesTableManager; }); describe("Initialization", () => { test("should initialize with default sort values", () => { window.location.search = ""; - fileListController = new FileListController(); + fileListController = new FileListPageController(); expect(fileListController.currentSortBy).toBe("created_at"); expect(fileListController.currentSortOrder).toBe("desc"); @@ -275,7 +274,7 @@ describe("FileListController", () => { writable: true, }); - fileListController = new FileListController(); + fileListController = new FileListPageController(); expect(fileListController.currentSortBy).toBe("name"); expect(fileListController.currentSortOrder).toBe("asc"); @@ -285,7 +284,7 @@ describe("FileListController", () => { }); test("should cache DOM elements", () => { - fileListController = new FileListController(); + fileListController = new FileListPageController(); expect(fileListController.elements).toBeDefined(); expect(fileListController.elements.searchInput).toBe( @@ -297,11 +296,12 @@ describe("FileListController", () => { }); test("should initialize component managers", () => { - fileListController = new FileListController(); + fileListController = new FileListPageController(); expect(global.ModalManager).toHaveBeenCalled(); expect(global.SearchManager).toHaveBeenCalled(); expect(global.PaginationManager).toHaveBeenCalled(); + expect(global.window.FileListCapturesTableManager).toHaveBeenCalled(); expect(fileListController.modalManager).toBe(mockModalManager); expect(fileListController.searchManager).toBe(mockSearchManager); }); @@ -309,7 +309,7 @@ describe("FileListController", () => { describe("Search functionality", () => { beforeEach(() => { - fileListController = new FileListController(); + fileListController = new FileListPageController(); }); test("buildSearchParams should include all filter values", () => { diff --git a/gateway/sds_gateway/static/js/__tests__/PaginationManager.behavior.test.js b/gateway/sds_gateway/static/js/__tests__/PaginationManager.behavior.test.js new file mode 100644 index 000000000..8823671db --- /dev/null +++ b/gateway/sds_gateway/static/js/__tests__/PaginationManager.behavior.test.js @@ -0,0 +1,83 @@ +/** + * Pagination: deprecated/components.js vs core/PaginationManager.js + * (click on rendered link invokes onPageChange with the page number). + */ +const paginationPayload = { + num_pages: 5, + number: 2, + has_previous: true, + has_next: true, +}; + +function mountPaginationPage(url) { + document.body.innerHTML = ""; + window.history.replaceState({}, "", url); +} + +describe("deprecated components.js PaginationManager — click invokes onPageChange", () => { + let DeprecatedPaginationManager; + + beforeAll(() => { + // eslint-disable-next-line global-require + ({ PaginationManager: DeprecatedPaginationManager } = require("../deprecated/components.js")); + }); + + beforeEach(() => { + mountPaginationPage("http://localhost/captures/?page=1"); + const host = document.createElement("div"); + host.id = "pag-host"; + document.body.appendChild(host); + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + test("clicking a rendered page link calls onPageChange with that page number", () => { + const onPageChange = jest.fn(); + const mgr = new DeprecatedPaginationManager({ + containerId: "pag-host", + onPageChange, + }); + mgr.update(paginationPayload); + const linkTo4 = Array.from( + document.querySelectorAll("a.page-link"), + ).find((a) => a.getAttribute("data-page") === "4"); + expect(linkTo4).toBeTruthy(); + linkTo4.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); + expect(onPageChange).toHaveBeenCalledWith(4); + }); +}); + +describe("core/PaginationManager.js — same click → onPageChange behavior", () => { + let CorePaginationManager; + + beforeEach(() => { + mountPaginationPage("http://localhost/captures/?page=1"); + jest.resetModules(); + // eslint-disable-next-line global-require + ({ PaginationManager: CorePaginationManager } = require("../core/PaginationManager.js")); + const host = document.createElement("div"); + host.id = "pag-host-core"; + document.body.appendChild(host); + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + test("clicking a rendered page link calls onPageChange with that page number", () => { + const onPageChange = jest.fn(); + const mgr = new CorePaginationManager({ + containerId: "pag-host-core", + onPageChange, + }); + mgr.update(paginationPayload); + const linkTo4 = Array.from( + document.querySelectorAll("a.page-link"), + ).find((a) => a.getAttribute("data-page") === "4"); + expect(linkTo4).toBeTruthy(); + linkTo4.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); + expect(onPageChange).toHaveBeenCalledWith(4); + }); +}); diff --git a/gateway/sds_gateway/static/js/__tests__/TableManager.sortBehavior.test.js b/gateway/sds_gateway/static/js/__tests__/TableManager.sortBehavior.test.js new file mode 100644 index 000000000..49827d099 --- /dev/null +++ b/gateway/sds_gateway/static/js/__tests__/TableManager.sortBehavior.test.js @@ -0,0 +1,184 @@ +/** + * Behavioral contract: table header sort updates the URL the same way + * deprecated/components.js TableManager did; core/TableManager preserves + * that for sortBehavior "pushState" and adds "reload" / "callback". + * + * Uses the default Jest jsdom document/window. Swaps in the real URL / + * URLSearchParams implementations because jest.setup.js provides a minimal + * URLSearchParams that mishandles `window.location.search` (leading `?`). + */ +const TABLE_BODY = ` + + + +
      +
      +`; + +function mountSortPage(searchParamsObj) { + document.body.innerHTML = TABLE_BODY; + const u = new URL("http://localhost/captures/"); + for (const [k, v] of Object.entries(searchParamsObj)) { + u.searchParams.set(k, v); + } + // Relative URL so jsdom updates location.search reliably (absolute href can leave search empty). + window.history.replaceState({}, "", `${u.pathname}${u.search}`); +} + +describe("table sort URL behavior", () => { + let savedURLSearchParams; + let savedURL; + + beforeAll(() => { + savedURLSearchParams = global.URLSearchParams; + savedURL = global.URL; + global.URLSearchParams = window.URLSearchParams; + global.URL = window.URL; + }); + + afterAll(() => { + global.URLSearchParams = savedURLSearchParams; + global.URL = savedURL; + }); + + describe("deprecated components.js TableManager — sort updates URL (pushState)", () => { + let DeprecatedTableManager; + + beforeAll(() => { + // eslint-disable-next-line global-require + ({ TableManager: DeprecatedTableManager } = require("../deprecated/components.js")); + }); + + afterEach(() => { + jest.restoreAllMocks(); + document.body.innerHTML = ""; + }); + + beforeEach(() => { + mountSortPage({ sort_by: "created_at", sort_order: "desc" }); + jest.spyOn(window.history, "pushState"); + }); + + test("clicking another column sets sort_by, first order asc, page 1 in pushed URL", () => { + // eslint-disable-next-line no-new + new DeprecatedTableManager({ + tableId: "tm-table", + loadingIndicatorId: "tm-loading", + paginationContainerId: "tm-pag", + }); + const [nameHeader] = document.querySelectorAll("th.sortable"); + nameHeader.click(); + + expect(window.history.pushState).toHaveBeenCalled(); + const pushed = window.history.pushState.mock.calls.at(-1)[2]; + expect(pushed).toMatch(/sort_by=name/); + expect(pushed).toMatch(/sort_order=asc/); + expect(pushed).toMatch(/page=1/); + }); + }); + + describe("core/TableManager.js — pushState + callback", () => { + let CoreTableManager; + + beforeAll(() => { + // eslint-disable-next-line global-require + const { ComponentUtils } = require("../core/ComponentUtils.js"); + window.ComponentUtils = ComponentUtils; + }); + + beforeEach(() => { + mountSortPage({ sort_by: "created_at", sort_order: "desc" }); + jest.resetModules(); + // eslint-disable-next-line global-require + ({ TableManager: CoreTableManager } = require("../core/TableManager.js")); + jest.spyOn(window.history, "pushState"); + }); + + afterEach(() => { + jest.restoreAllMocks(); + document.body.innerHTML = ""; + }); + + test("default sortBehavior matches deprecated (pushState)", () => { + // eslint-disable-next-line no-new + new CoreTableManager({ + tableId: "tm-table", + loadingIndicatorId: "tm-loading", + paginationContainerId: "tm-pag", + }); + document.querySelector("th.sortable").click(); + const pushed = window.history.pushState.mock.calls.at(-1)[2]; + expect(pushed).toMatch(/sort_by=name/); + expect(pushed).toMatch(/sort_order=asc/); + expect(pushed).toMatch(/page=1/); + }); + + test("sortBehavior callback invokes onSortChange instead of history or location", () => { + const onSortChange = jest.fn(); + // eslint-disable-next-line no-new + new CoreTableManager({ + tableId: "tm-table", + loadingIndicatorId: "tm-loading", + paginationContainerId: "tm-pag", + sortBehavior: "callback", + onSortChange, + }); + document.querySelector("th.sortable").click(); + expect(onSortChange).toHaveBeenCalledWith({ + sort_by: "name", + sort_order: "asc", + page: "1", + }); + expect(window.history.pushState).not.toHaveBeenCalled(); + }); + }); + + describe("core/TableManager.js — sortBehavior reload (isolated)", () => { + beforeAll(() => { + // eslint-disable-next-line global-require + const { ComponentUtils } = require("../core/ComponentUtils.js"); + window.ComponentUtils = ComponentUtils; + }); + + afterEach(() => { + jest.restoreAllMocks(); + document.body.innerHTML = ""; + try { + delete window.location; + } catch (_) { + /* jsdom */ + } + }); + + test("sortBehavior reload assigns location.search with encoded params", () => { + mountSortPage({ sort_by: "created_at", sort_order: "desc" }); + let query = "sort_by=created_at&sort_order=desc"; + Object.defineProperty(window, "location", { + configurable: true, + value: { + pathname: "/captures/", + get search() { + return query ? `?${query}` : ""; + }, + set search(v) { + query = String(v).replace(/^\?/, ""); + }, + }, + }); + jest.resetModules(); + // eslint-disable-next-line global-require + const { TableManager: ReloadTableManager } = require("../core/TableManager.js"); + // eslint-disable-next-line no-new + new ReloadTableManager({ + tableId: "tm-table", + loadingIndicatorId: "tm-loading", + paginationContainerId: "tm-pag", + sortBehavior: "reload", + }); + document.querySelector("th.sortable").click(); + expect(query).toMatch(/sort_by=name/); + expect(query).toMatch(/sort_order=asc/); + expect(query).toMatch(/page=1/); + }); + }); +}); diff --git a/gateway/sds_gateway/static/js/captures/FileListPageController.js b/gateway/sds_gateway/static/js/captures/FileListPageController.js index fd1cca9d8..5bd46a84e 100644 --- a/gateway/sds_gateway/static/js/captures/FileListPageController.js +++ b/gateway/sds_gateway/static/js/captures/FileListPageController.js @@ -620,3 +620,32 @@ if (typeof module !== "undefined" && module.exports) { module.exports = { FileListPageController }; } +const _isJestRuntime = + typeof process !== "undefined" && + Boolean(process.env && process.env.JEST_WORKER_ID); + +if ( + typeof window !== "undefined" && + typeof document !== "undefined" && + !_isJestRuntime +) { + window.initializeFrequencySlider = function initializeFrequencySlider() { + if (window.fileListController?.initializeFrequencyFromURL) { + window.fileListController.initializeFrequencyFromURL(); + } + }; + + const _bootFileListPage = () => { + try { + window.fileListController = new FileListPageController(); + } catch (error) { + console.error("Error initializing file list page:", error); + } + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", _bootFileListPage); + } else { + _bootFileListPage(); + } +} diff --git a/gateway/sds_gateway/static/js/captures/__tests__/CapturesTableManager.csrf-behavior.test.js b/gateway/sds_gateway/static/js/captures/__tests__/CapturesTableManager.csrf-behavior.test.js new file mode 100644 index 000000000..ddc4701a4 --- /dev/null +++ b/gateway/sds_gateway/static/js/captures/__tests__/CapturesTableManager.csrf-behavior.test.js @@ -0,0 +1,97 @@ +/** + * getCSRFToken: prefers APIClient instance, then hidden input (deprecated vs core parity). + */ +const TABLE_BODY = ` +
      +
      +`; + +function mountCaptureTablePage() { + document.body.innerHTML = TABLE_BODY; +} + +function baseConfig() { + return { + tableId: "ctm-table", + loadingIndicatorId: "ctm-load", + paginationContainerId: "ctm-pag", + modalHandler: { openCaptureModal: jest.fn() }, + }; +} + +describe("deprecated components.js CapturesTableManager — getCSRFToken", () => { + let DeprecatedCapturesTableManager; + let savedAPIClient; + + beforeAll(() => { + savedAPIClient = window.APIClient; + // eslint-disable-next-line global-require + ({ CapturesTableManager: DeprecatedCapturesTableManager } = require("../../deprecated/components.js")); + }); + + beforeEach(() => { + window.APIClient = savedAPIClient; + mountCaptureTablePage(); + }); + + afterEach(() => { + window.APIClient = savedAPIClient; + document.body.innerHTML = ""; + }); + + test("returns empty string when no csrfmiddlewaretoken input (legacy path)", () => { + const mgr = new DeprecatedCapturesTableManager(baseConfig()); + expect(mgr.getCSRFToken()).toBe(""); + }); + + test("reads csrfmiddlewaretoken hidden input when present", () => { + const input = document.createElement("input"); + input.setAttribute("name", "csrfmiddlewaretoken"); + input.value = "from-input"; + document.body.appendChild(input); + const mgr = new DeprecatedCapturesTableManager(baseConfig()); + expect(mgr.getCSRFToken()).toBe("from-input"); + }); +}); + +describe("captures/CapturesTableManager.js — getCSRFToken (same contract)", () => { + let savedAPIClient; + + beforeAll(() => { + savedAPIClient = window.APIClient; + }); + + beforeEach(() => { + window.APIClient = savedAPIClient; + mountCaptureTablePage(); + jest.resetModules(); + // eslint-disable-next-line global-require + require("../../core/TableManager.js"); + // eslint-disable-next-line global-require + require("../../core/ComponentUtils.js"); + }); + + afterEach(() => { + window.APIClient = savedAPIClient; + document.body.innerHTML = ""; + }); + + test("returns token from new APIClient().getCSRFToken()", () => { + // eslint-disable-next-line global-require + const { CapturesTableManager } = require("../CapturesTableManager.js"); + const mgr = new CapturesTableManager(baseConfig()); + expect(mgr.getCSRFToken()).toBe("mock-csrf-token"); + }); + + test("falls back to csrfmiddlewaretoken input when APIClient is absent", () => { + window.APIClient = undefined; + const input = document.createElement("input"); + input.setAttribute("name", "csrfmiddlewaretoken"); + input.value = "from-input-core"; + document.body.appendChild(input); + // eslint-disable-next-line global-require + const { CapturesTableManager } = require("../CapturesTableManager.js"); + const mgr = new CapturesTableManager(baseConfig()); + expect(mgr.getCSRFToken()).toBe("from-input-core"); + }); +}); diff --git a/gateway/sds_gateway/static/js/core/__tests__/PageController.behavior.test.js b/gateway/sds_gateway/static/js/core/__tests__/PageController.behavior.test.js new file mode 100644 index 000000000..8840cae28 --- /dev/null +++ b/gateway/sds_gateway/static/js/core/__tests__/PageController.behavior.test.js @@ -0,0 +1,62 @@ +/** + * PageController: tracked listeners are removed after destroy (no double-fires). + */ +const { JSDOM } = require("jsdom"); +const { PageController } = require("../PageController.js"); + +class RecordingPage extends PageController { + constructor(el) { + super(); + this.el = el; + this.hits = 0; + } + + initializeEventHandlers() { + this.bind(this.el, "click", () => { + this.hits += 1; + }); + } +} + +describe("PageController behavior", () => { + let initialWindow; + let initialDocument; + + beforeAll(() => { + initialWindow = global.window; + initialDocument = global.document; + const dom = new JSDOM(""); + global.window = dom.window; + global.document = dom.window.document; + }); + + afterAll(() => { + global.window = initialWindow; + global.document = initialDocument; + }); + + test("init wires handler once; destroy removes it", () => { + const el = document.createElement("button"); + document.body.appendChild(el); + + const page = new RecordingPage(el); + page.init(); + el.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); + expect(page.hits).toBe(1); + + page.destroy(); + el.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); + expect(page.hits).toBe(1); + }); + + test("init is idempotent — second init does not stack duplicate handlers", () => { + const el = document.createElement("button"); + document.body.appendChild(el); + + const page = new RecordingPage(el); + page.init(); + page.init(); + el.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); + expect(page.hits).toBe(1); + }); +}); diff --git a/gateway/sds_gateway/static/js/core/__tests__/PageLifecycleManager.initializePagination.test.js b/gateway/sds_gateway/static/js/core/__tests__/PageLifecycleManager.initializePagination.test.js new file mode 100644 index 000000000..a3a0102ae --- /dev/null +++ b/gateway/sds_gateway/static/js/core/__tests__/PageLifecycleManager.initializePagination.test.js @@ -0,0 +1,58 @@ +/** + * PageLifecycleManager.initializePagination: server-rendered pagination links + * update the page query (same behavior users rely on post-refactor). + */ +const { PaginationManager } = require("../PaginationManager.js"); +const { PageLifecycleManager } = require("../PageLifecycleManager.js"); + +describe("PageLifecycleManager.initializePagination", () => { + beforeAll(() => { + window.PaginationManager = PaginationManager; + }); + + beforeEach(() => { + document.body.innerHTML = ""; + window.history.replaceState({}, "", "http://localhost/files/?page=1&q=stay"); + Object.defineProperty(document, "readyState", { + value: "loading", + configurable: true, + }); + const wrap = document.createElement("div"); + wrap.id = "files-pagination"; + wrap.innerHTML = + ''; + document.body.appendChild(wrap); + }); + + afterEach(() => { + document.body.innerHTML = ""; + delete window.location; + }); + + test("clicking a page link sets page in the query string and keeps other params", () => { + let query = "page=1&q=stay"; + Object.defineProperty(window, "location", { + configurable: true, + value: { + pathname: "/files/", + get search() { + return query ? `?${query}` : ""; + }, + set search(v) { + query = String(v).replace(/^\?/, ""); + }, + }, + }); + + const mgr = new PageLifecycleManager({ + pageType: "capture-list", + permissions: {}, + }); + mgr.initializePagination(); + document + .querySelector("#files-pagination a.page-link") + .dispatchEvent(new window.MouseEvent("click", { bubbles: true })); + expect(query).toMatch(/page=3/); + expect(query).toMatch(/q=stay/); + }); +}); diff --git a/gateway/sds_gateway/static/js/tests-config/jest.config.js b/gateway/sds_gateway/static/js/tests-config/jest.config.js index bd22ca40d..e4f20acd6 100644 --- a/gateway/sds_gateway/static/js/tests-config/jest.config.js +++ b/gateway/sds_gateway/static/js/tests-config/jest.config.js @@ -24,7 +24,7 @@ module.exports = { // Exclude specific legacy files "!sds_gateway/static/js/components.js", "!sds_gateway/static/js/file_list_upload_capture_modal.js", - "!sds_gateway/static/js/file-list.js", + "!sds_gateway/static/js/file-list.js", // legacy path (page uses deprecated/file-list.js) "!sds_gateway/static/js/file-manager.js", "!sds_gateway/static/js/files-ui.js", "!sds_gateway/static/js/vendors.js", diff --git a/gateway/sds_gateway/static/js/tests-config/jest.setup.js b/gateway/sds_gateway/static/js/tests-config/jest.setup.js index 2affad24b..f50e2783d 100644 --- a/gateway/sds_gateway/static/js/tests-config/jest.setup.js +++ b/gateway/sds_gateway/static/js/tests-config/jest.setup.js @@ -1,5 +1,13 @@ // Jest setup file for global test configuration +const { TextDecoder, TextEncoder } = require("node:util"); +if (typeof globalThis.TextEncoder === "undefined") { + globalThis.TextEncoder = TextEncoder; +} +if (typeof globalThis.TextDecoder === "undefined") { + globalThis.TextDecoder = TextDecoder; +} + // Mock DOM environment const mockDOM = { createElement: (tag) => ({ @@ -27,6 +35,7 @@ const mockDOM = { querySelector: jest.fn(() => null), querySelectorAll: jest.fn(() => []), getElementById: jest.fn(() => null), + head: { appendChild: jest.fn() }, createTextNode: jest.fn((text) => ({ textContent: text })), addEventListener: jest.fn(), removeEventListener: jest.fn(), @@ -222,16 +231,32 @@ global.bootstrap = { // Mock window.bootstrap (some code uses window.bootstrap) global.window.bootstrap = global.bootstrap; -// Mock APIClient for template rendering -global.window.APIClient = { - get: jest.fn().mockResolvedValue({ success: true }), - post: jest.fn().mockResolvedValue({ html: "
      Mock HTML
      " }), - put: jest.fn().mockResolvedValue({ success: true }), - patch: jest.fn().mockResolvedValue({ success: true }), - delete: jest.fn().mockResolvedValue({ success: true }), - request: jest.fn().mockResolvedValue({ success: true }), - getCSRFToken: jest.fn().mockReturnValue("mock-csrf-token"), - getCookie: jest.fn().mockReturnValue(null), +// Mock APIClient as a real class (production uses `new window.APIClient()`; jest.fn is not a reliable constructor) +global.window.APIClient = class MockAPIClient { + get() { + return Promise.resolve({ success: true }); + } + post() { + return Promise.resolve({ html: "
      Mock HTML
      " }); + } + put() { + return Promise.resolve({ success: true }); + } + patch() { + return Promise.resolve({ success: true }); + } + delete() { + return Promise.resolve({ success: true }); + } + request() { + return Promise.resolve({ success: true }); + } + getCSRFToken() { + return "mock-csrf-token"; + } + getCookie() { + return null; + } }; // Mock DOMUtils @@ -252,3 +277,10 @@ global.window.DOMUtils = { global.window.showAlert = jest.fn(); global.window.showToast = jest.fn(); global.window.hideToast = jest.fn(); + +const { AuthorsManager } = require("../dataset/AuthorsManager.js"); +const { UserSearchDropdown } = require("../share/UserSearchDropdown.js"); +const { ChunkUploadPipeline } = require("../upload/ChunkUploadPipeline.js"); +global.window.AuthorsManager = AuthorsManager; +global.window.UserSearchDropdown = UserSearchDropdown; +global.window.ChunkUploadPipeline = ChunkUploadPipeline; diff --git a/gateway/sds_gateway/static/js/upload/FilesPageInitializer.js b/gateway/sds_gateway/static/js/upload/FilesPageInitializer.js index c8c6cbcc5..506fb785e 100644 --- a/gateway/sds_gateway/static/js/upload/FilesPageInitializer.js +++ b/gateway/sds_gateway/static/js/upload/FilesPageInitializer.js @@ -72,30 +72,43 @@ class FilesPageInitializer { } initializeUserSearchHandlers() { - // Create a UserSearchHandler for each share modal const shareModals = document.querySelectorAll(".modal[data-item-uuid]"); - // Skip initialization if no share modals exist on this page if (shareModals.length === 0) { return; } - // Check if UserSearchHandler is available before trying to initialize - if (!window.UserSearchHandler) { + if (!window.ShareActionManager || !window.PermissionsManager) { console.warn( - "UserSearchHandler not available. Share functionality will not work.", + "ShareActionManager or PermissionsManager not available. Share modals will not initialize.", ); return; } + const permConfig = + window.filesSharePagePermissions || { + userPermissionLevel: "owner", + isOwner: true, + currentUserId: 0, + datasetPermissions: { + canShare: true, + canDownload: true, + canEditMetadata: false, + canAddAssets: false, + canRemoveAnyAssets: false, + canRemoveOwnAssets: false, + }, + }; + + const permissionsManager = new window.PermissionsManager(permConfig); + for (const modal of shareModals) { - this.setupUserSearchHandler(modal); + this.setupShareActionManager(modal, permissionsManager); } } - setupUserSearchHandler(modal) { + setupShareActionManager(modal, permissionsManager) { try { - // Ensure boundHandlers and activeHandlers are initialized if (!this.boundHandlers) { this.boundHandlers = new Map(); } @@ -103,7 +116,6 @@ class FilesPageInitializer { this.activeHandlers = new Set(); } - // Validate modal attributes const itemUuid = modal.getAttribute("data-item-uuid"); const itemType = modal.getAttribute("data-item-type"); @@ -115,41 +127,25 @@ class FilesPageInitializer { return; } - const handler = new window.UserSearchHandler(); - // Store the handler on the modal element - modal.userSearchHandler = handler; - this.activeHandlers.add(handler); - - // Create bound event handlers for cleanup - const showHandler = () => { - if (modal.userSearchHandler) { - modal.userSearchHandler.setItemInfo(itemUuid, itemType); - modal.userSearchHandler.init(); - } - }; - - const hideHandler = () => { - if (modal.userSearchHandler) { - modal.userSearchHandler.resetAll(); - } - }; - - // Store handlers for cleanup - this.boundHandlers.set(modal, { - show: showHandler, - hide: hideHandler, + const shareManager = new window.ShareActionManager({ + itemUuid, + itemType, + permissions: permissionsManager, }); + modal.shareActionManager = shareManager; + this.activeHandlers.add(shareManager); - // On modal show, set the item info and call init() - modal.addEventListener("show.bs.modal", showHandler); + const onHidden = () => { + modal.shareActionManager?.clearSelections?.(); + }; - // On modal hide, reset all selections and entered data - modal.addEventListener("hidden.bs.modal", hideHandler); + modal.addEventListener("hidden.bs.modal", onHidden); + this.boundHandlers.set(modal, { hiddenShare: onHidden }); - console.log(`UserSearchHandler initialized for ${itemType}: ${itemUuid}`); + console.log(`ShareActionManager initialized for ${itemType}: ${itemUuid}`); } catch (error) { ErrorHandler.showError( - "Failed to setup user search functionality", + "Failed to setup share modal", "user-search-setup", error, ); @@ -204,9 +200,11 @@ class FilesPageInitializer { // Memory management and cleanup cleanup() { - // Remove all bound event handlers for (const [element, handlers] of this.boundHandlers) { if (element?.removeEventListener) { + if (handlers.hiddenShare) { + element.removeEventListener("hidden.bs.modal", handlers.hiddenShare); + } if (handlers.show) { element.removeEventListener("show.bs.modal", handlers.show); } @@ -217,13 +215,12 @@ class FilesPageInitializer { } this.boundHandlers.clear(); - // Cleanup active handlers for (const handler of this.activeHandlers) { - if (handler && typeof handler.cleanup === "function") { + if (handler && typeof handler.clearSelections === "function") { try { - handler.cleanup(); + handler.clearSelections(); } catch (error) { - console.warn("Error during handler cleanup:", error); + console.warn("Error clearing share selections:", error); } } } diff --git a/gateway/sds_gateway/static/js/upload/__tests__/ChunkUploadPipeline.test.js b/gateway/sds_gateway/static/js/upload/__tests__/ChunkUploadPipeline.test.js new file mode 100644 index 000000000..173a6172f --- /dev/null +++ b/gateway/sds_gateway/static/js/upload/__tests__/ChunkUploadPipeline.test.js @@ -0,0 +1,57 @@ +/** + * @jest-environment jsdom + */ + +const { ChunkUploadPipeline } = require("../ChunkUploadPipeline.js"); + +describe("ChunkUploadPipeline", () => { + beforeEach(() => { + document.body.innerHTML = ""; + }); + + test("calculateTotalChunks counts byte-sized groups", () => { + const chunkSize = 10; + const files = [ + new File(["x"], "a.bin", { type: "application/octet-stream" }), + new File(["yy"], "b.bin", { type: "application/octet-stream" }), + ]; + Object.defineProperty(files[0], "size", { value: 6 }); + Object.defineProperty(files[1], "size", { value: 6 }); + + expect(ChunkUploadPipeline.calculateTotalChunks(files, chunkSize)).toBe(2); + }); + + test("calculateTotalChunks splits oversized single file", () => { + const chunkSize = 5; + const files = [ + new File(["xxxxxxx"], "big.bin", { type: "application/octet-stream" }), + ]; + Object.defineProperty(files[0], "size", { value: 100 }); + + expect(ChunkUploadPipeline.calculateTotalChunks(files, chunkSize)).toBe(1); + }); + + test("appendCaptureTypeToFormData reads modal fields", () => { + document.body.innerHTML = ` + + + + `; + const fd = new FormData(); + ChunkUploadPipeline.appendCaptureTypeToFormData(fd); + expect(fd.get("capture_type")).toBe("drf"); + expect(fd.get("channels")).toBe("ch1,ch2"); + }); + + test("appendCaptureTypeToFormData appends scan_group for rh", () => { + document.body.innerHTML = ` + + + + `; + const fd = new FormData(); + ChunkUploadPipeline.appendCaptureTypeToFormData(fd); + expect(fd.get("capture_type")).toBe("rh"); + expect(fd.get("scan_group")).toBe("sg-1"); + }); +}); diff --git a/gateway/sds_gateway/static/js/upload/__tests__/UploadCaptureModalController.getCSRFToken.test.js b/gateway/sds_gateway/static/js/upload/__tests__/UploadCaptureModalController.getCSRFToken.test.js new file mode 100644 index 000000000..385fa3ec8 --- /dev/null +++ b/gateway/sds_gateway/static/js/upload/__tests__/UploadCaptureModalController.getCSRFToken.test.js @@ -0,0 +1,28 @@ +/** + * @jest-environment jsdom + */ + +const { + UploadCaptureModalController, +} = require("../UploadCaptureModalController.js"); + +describe("UploadCaptureModalController — getCSRFToken", () => { + test("prefers new APIClient().getCSRFToken()", () => { + const ctrl = new UploadCaptureModalController({}); + expect(ctrl.getCSRFToken()).toBe("mock-csrf-token"); + }); + + test("falls back to hidden csrf input when APIClient throws", () => { + const orig = global.window.APIClient; + global.window.APIClient = class Bad { + getCSRFToken() { + throw new Error("fail"); + } + }; + document.body.innerHTML = + ''; + const ctrl = new UploadCaptureModalController({}); + expect(ctrl.getCSRFToken()).toBe("from-input"); + global.window.APIClient = orig; + }); +}); diff --git a/gateway/sds_gateway/templates/users/dataset_list.html b/gateway/sds_gateway/templates/users/dataset_list.html index 4ead29d05..f3c47bf20 100644 --- a/gateway/sds_gateway/templates/users/dataset_list.html +++ b/gateway/sds_gateway/templates/users/dataset_list.html @@ -40,10 +40,12 @@

      Datasets

      + + diff --git a/gateway/sds_gateway/templates/users/file_list.html b/gateway/sds_gateway/templates/users/file_list.html index b9bbb1128..ef2879918 100644 --- a/gateway/sds_gateway/templates/users/file_list.html +++ b/gateway/sds_gateway/templates/users/file_list.html @@ -360,6 +360,7 @@ {% block javascript %} {{ block.super }} + @@ -377,9 +378,9 @@ + - diff --git a/gateway/sds_gateway/templates/users/files.html b/gateway/sds_gateway/templates/users/files.html index fbbb92901..618cc7532 100644 --- a/gateway/sds_gateway/templates/users/files.html +++ b/gateway/sds_gateway/templates/users/files.html @@ -523,7 +523,8 @@ - + + @@ -537,6 +538,21 @@ + diff --git a/gateway/sds_gateway/templates/users/group_captures.html b/gateway/sds_gateway/templates/users/group_captures.html index f9f665208..af12b6fea 100644 --- a/gateway/sds_gateway/templates/users/group_captures.html +++ b/gateway/sds_gateway/templates/users/group_captures.html @@ -115,6 +115,7 @@
      {% block extra_js %} + diff --git a/gateway/sds_gateway/templates/users/share_group_list.html b/gateway/sds_gateway/templates/users/share_group_list.html index b8a2451d0..fc25de8fc 100644 --- a/gateway/sds_gateway/templates/users/share_group_list.html +++ b/gateway/sds_gateway/templates/users/share_group_list.html @@ -36,6 +36,7 @@
      {% translate "Your Share Groups" %}
      {% block javascript %} {{ block.super }} + - + @@ -567,13 +567,12 @@ window.uploadFilesUrl = window.filesConfig.urls.uploadFiles; window.csrfToken = window.filesConfig.csrfToken; - - + - + {% endblock javascript %} From dd99806b949257ede35adf87d6073e4dd586b99d Mon Sep 17 00:00:00 2001 From: klpoland Date: Wed, 13 May 2026 17:09:00 -0400 Subject: [PATCH 7/7] clean-up, clean-up --- .../__tests__/FileListPageController.test.js | 580 +------ .../PaginationManager.behavior.test.js | 25 +- .../TableManager.sortBehavior.test.js | 108 +- .../js/captures/CapturesTableManager.js | 223 --- .../captures/FileListCapturesTableManager.js | 401 ----- .../js/captures/FileListPageController.js | 237 +-- ...CapturesTableManager.csrf-behavior.test.js | 42 - .../sds_gateway/static/js/core/APIClient.js | 43 +- .../static/js/core/BrowserSupport.js | 77 - .../static/js/core/ComponentUtils.js | 33 - .../sds_gateway/static/js/core/DOMUtils.js | 261 ++- .../static/js/core/DropdownUtils.js | 52 - .../sds_gateway/static/js/core/FormatUtils.js | 109 -- .../static/js/core/GatewayChrome.js | 61 - .../static/js/core/ModalManager.js | 279 +++- .../static/js/core/NotificationUtils.js | 87 - .../static/js/core/PageController.js | 19 + .../sds_gateway/static/js/core/PageGate.js | 180 +++ .../static/js/core/PageLifecycleManager.js | 174 +- .../static/js/core/PaginationManager.js | 79 - .../static/js/core/TableManager.js | 183 --- .../static/js/core/__tests__/DOMUtils.test.js | 63 +- ...ecycleManager.initializePagination.test.js | 5 - .../__tests__/PageLifecycleManager.test.js | 3 + .../static/js/tests-config/jest.config.js | 2 +- .../static/js/tests-config/jest.setup.js | 5 +- .../static/js/upload/Blake3FileHandler.js | 182 --- .../static/js/upload/CaptureTypeSelector.js | 192 --- .../static/js/upload/ChunkUploadPipeline.js | 66 - .../static/js/upload/FileDropManager.js | 173 -- .../js/upload/FileListUploadCaptureModal.js | 1076 ------------ .../static/js/upload/FileUploadHandler.js | 230 --- .../js/upload/FilesCaptureUploadBootstrap.js | 38 - .../static/js/upload/FilesPageBootstrap.js | 76 - .../static/js/upload/FilesPageInitializer.js | 239 --- .../static/js/upload/FilesUploadModal.js | 554 ------- .../js/upload/UploadCaptureModalController.js | 786 --------- .../{FileManager.js => UploadManager.js} | 1437 ++++++++++++++--- .../static/js/upload/UploadUtils.js | 199 +++ ...aptureModalController.getCSRFToken.test.js | 2 +- ...adPipeline.test.js => UploadUtils.test.js} | 12 +- gateway/sds_gateway/templates/base.html | 1 + .../capture_list_modals_fragment.html | 9 + .../capture_list_table_fragment.html | 4 + .../templates/users/dataset_list.html | 18 +- .../templates/users/file_list.html | 114 +- .../sds_gateway/templates/users/files.html | 76 +- .../users/partials/captures_page_table.html | 16 +- gateway/sds_gateway/users/views/captures.py | 28 + 49 files changed, 2497 insertions(+), 6362 deletions(-) delete mode 100644 gateway/sds_gateway/static/js/captures/CapturesTableManager.js delete mode 100644 gateway/sds_gateway/static/js/captures/FileListCapturesTableManager.js delete mode 100644 gateway/sds_gateway/static/js/core/BrowserSupport.js delete mode 100644 gateway/sds_gateway/static/js/core/ComponentUtils.js delete mode 100644 gateway/sds_gateway/static/js/core/DropdownUtils.js delete mode 100644 gateway/sds_gateway/static/js/core/FormatUtils.js delete mode 100644 gateway/sds_gateway/static/js/core/GatewayChrome.js delete mode 100644 gateway/sds_gateway/static/js/core/NotificationUtils.js create mode 100644 gateway/sds_gateway/static/js/core/PageGate.js delete mode 100644 gateway/sds_gateway/static/js/core/PaginationManager.js delete mode 100644 gateway/sds_gateway/static/js/core/TableManager.js delete mode 100644 gateway/sds_gateway/static/js/upload/Blake3FileHandler.js delete mode 100644 gateway/sds_gateway/static/js/upload/CaptureTypeSelector.js delete mode 100644 gateway/sds_gateway/static/js/upload/ChunkUploadPipeline.js delete mode 100644 gateway/sds_gateway/static/js/upload/FileDropManager.js delete mode 100644 gateway/sds_gateway/static/js/upload/FileListUploadCaptureModal.js delete mode 100644 gateway/sds_gateway/static/js/upload/FileUploadHandler.js delete mode 100644 gateway/sds_gateway/static/js/upload/FilesCaptureUploadBootstrap.js delete mode 100644 gateway/sds_gateway/static/js/upload/FilesPageBootstrap.js delete mode 100644 gateway/sds_gateway/static/js/upload/FilesPageInitializer.js delete mode 100644 gateway/sds_gateway/static/js/upload/FilesUploadModal.js delete mode 100644 gateway/sds_gateway/static/js/upload/UploadCaptureModalController.js rename gateway/sds_gateway/static/js/upload/{FileManager.js => UploadManager.js} (51%) create mode 100644 gateway/sds_gateway/static/js/upload/UploadUtils.js rename gateway/sds_gateway/static/js/upload/__tests__/{ChunkUploadPipeline.test.js => UploadUtils.test.js} (80%) create mode 100644 gateway/sds_gateway/templates/users/components/capture_list_modals_fragment.html create mode 100644 gateway/sds_gateway/templates/users/components/capture_list_table_fragment.html diff --git a/gateway/sds_gateway/static/js/__tests__/FileListPageController.test.js b/gateway/sds_gateway/static/js/__tests__/FileListPageController.test.js index c4dfb0af0..7b216d3bf 100644 --- a/gateway/sds_gateway/static/js/__tests__/FileListPageController.test.js +++ b/gateway/sds_gateway/static/js/__tests__/FileListPageController.test.js @@ -1,30 +1,7 @@ /** - * Jest tests for captures file list page. - * Tests FileListPageController and FileListCapturesTableManager-shaped mocks. + * Jest tests for captures file list page (FileListPageController). */ -// Mock components.js classes that file-list.js depends on -// These MUST be set up BEFORE importing file-list.js -class MockTableManager { - constructor(options) { - this.options = options; - this.showLoading = jest.fn(); - this.hideLoading = jest.fn(); - this.showError = jest.fn(); - this.attachRowClickHandlers = jest.fn(); - } -} - -class MockCapturesTableManager extends MockTableManager { - constructor(options) { - super(options); - this.resultsCountElement = null; - } - updateTable() {} - updateResultsCount() {} - renderRow() {} -} - class MockSearchManager { constructor(options) { this.options = options; @@ -37,25 +14,17 @@ class MockModalManager { } } -class MockPaginationManager { - constructor(options) { - this.options = options; - } -} - -// Make these available globally (as they would be from components.js) -global.TableManager = MockTableManager; -global.CapturesTableManager = MockCapturesTableManager; -global.SearchManager = MockSearchManager; -global.ModalManager = MockModalManager; -global.PaginationManager = MockPaginationManager; +const MockModalManagerConstructor = jest.fn(function MockMM(options) { + return new MockModalManager(options); +}); +MockModalManagerConstructor.attachDocumentCaptureClickDelegation = jest.fn( + () => jest.fn(), +); -// Also make them available on window (file-list.js uses them without global prefix) -global.window.ModalManager = MockModalManager; +global.ModalManager = MockModalManagerConstructor; +global.window.ModalManager = MockModalManagerConstructor; global.window.SearchManager = MockSearchManager; -global.window.PaginationManager = MockPaginationManager; -// FileListPageController reads window.FileListConfig (see constants/FileListConfig.js) global.window.FileListConfig = { DEBOUNCE_DELAY: 300, DEFAULT_SORT_BY: "created_at", @@ -74,10 +43,11 @@ global.window.FileListConfig = { }; const { PageController } = require("../core/PageController.js"); +const { PageLifecycleManager } = require("../core/PageLifecycleManager.js"); global.window.PageController = PageController; +global.window.PageLifecycleManager = PageLifecycleManager; -// Mock ComponentUtils -global.window.ComponentUtils = { +global.window.DOMUtils = { escapeHtml: jest.fn((str) => { if (!str) return ""; return String(str) @@ -92,9 +62,11 @@ global.window.ComponentUtils = { const d = new Date(date); return d.toISOString().split("T")[0]; }), + initIconDropdowns: jest.fn(), + renderLoading: jest.fn().mockResolvedValue(true), + renderError: jest.fn().mockResolvedValue(true), }; -// Mock Bootstrap Dropdown global.bootstrap.Dropdown = jest .fn() .mockImplementation((element, options) => ({ @@ -109,15 +81,19 @@ const { FileListPageController } = require("../captures/FileListPageController.j describe("FileListPageController", () => { let fileListController; let mockElements; - let mockTableManager; let mockSearchManager; let mockModalManager; - let mockPaginationManager; + let loadTableSpy; beforeEach(() => { jest.clearAllMocks(); - // Mock DOM elements + delete global.window.__FILE_LIST_PAGE_LIFECYCLE__; + delete global.window.__FILE_LIST_LIST_REFRESH__; + delete global.window.pageLifecycleManager; + loadTableSpy = jest.fn().mockResolvedValue("
      "); + global.window.listRefreshManager = { loadTable: loadTableSpy }; + mockElements = { searchInput: { value: "", @@ -137,7 +113,6 @@ describe("FileListPageController", () => { dateCollapse: {}, }; - // Mock document methods document.getElementById = jest.fn((id) => { const idMap = { "search-input": mockElements.searchInput, @@ -150,6 +125,8 @@ describe("FileListPageController", () => { "items-per-page": mockElements.itemsPerPage, collapseFrequency: mockElements.frequencyCollapse, collapseDate: mockElements.dateCollapse, + "captures-table": { classList: { contains: jest.fn(), add: jest.fn(), remove: jest.fn() }, addEventListener: jest.fn(), querySelector: jest.fn() }, + "add-captures-to-dataset-btn": null, }; return idMap[id] || null; }); @@ -169,48 +146,44 @@ describe("FileListPageController", () => { document.querySelectorAll = jest.fn(() => []); - // Mock window.location window.location = { - pathname: "/captures/", + pathname: "/users/capture-list/", search: "", }; window.history = { pushState: jest.fn(), }; - // Mock URLSearchParams window.URLSearchParams = class URLSearchParams { constructor(search) { this.params = new Map(); - if (search) { - const pairs = search.replace("?", "").split("&"); - for (const pair of pairs) { + const q = typeof search === "string" ? search.replace("?", "") : ""; + if (q) { + for (const pair of q.split("&")) { const [key, value] = pair.split("="); - if (key) this.params.set(key, value || ""); + if (key) this.params.set(key, decodeURIComponent(value || "")); } } } get(name) { - return this.params.get(name) || null; + return this.params.has(name) ? this.params.get(name) : null; } set(name, value) { this.params.set(name, value); } + delete(name) { + this.params.delete(name); + } + *entries() { + yield* this.params.entries(); + } toString() { return Array.from(this.params.entries()) - .map(([k, v]) => `${k}=${v}`) + .map(([k, v]) => `${k}=${encodeURIComponent(v)}`) .join("&"); } }; - // Create mock managers - mockTableManager = new MockCapturesTableManager({ - tableId: "captures-table", - loadingIndicatorId: "loading-indicator", - tableContainerSelector: ".table-responsive", - resultsCountId: "results-count", - }); - mockSearchManager = new MockSearchManager({ searchInputId: "search-input", searchButtonId: "search-btn", @@ -222,19 +195,9 @@ describe("FileListPageController", () => { modalBodyId: "capture-modal-body", }); - mockPaginationManager = new MockPaginationManager({ - containerId: "captures-pagination", - }); - - // Mock global classes (loaded as separate scripts on the real page) - global.ModalManager = jest.fn(() => mockModalManager); + MockModalManagerConstructor.mockImplementation(() => mockModalManager); global.SearchManager = jest.fn(() => mockSearchManager); - global.PaginationManager = jest.fn(() => mockPaginationManager); - global.window.FileListCapturesTableManager = jest.fn(() => mockTableManager); - - global.window.ModalManager = global.ModalManager; global.window.SearchManager = global.SearchManager; - global.window.PaginationManager = global.PaginationManager; }); describe("Initialization", () => { @@ -247,25 +210,6 @@ describe("FileListPageController", () => { }); test("should initialize with URL params", () => { - // Mock URLSearchParams to return the values we want - const originalURLSearchParams = window.URLSearchParams; - window.URLSearchParams = class URLSearchParams { - constructor(search) { - this.params = new Map(); - if (search) { - const pairs = search.replace("?", "").split("&"); - for (const pair of pairs) { - const [key, value] = pair.split("="); - if (key) this.params.set(key, value || ""); - } - } - } - get(name) { - return this.params.get(name) || null; - } - }; - - // Create a mock location that will be used by URLSearchParams Object.defineProperty(window, "location", { value: { search: "?sort_by=name&sort_order=asc", @@ -278,9 +222,6 @@ describe("FileListPageController", () => { expect(fileListController.currentSortBy).toBe("name"); expect(fileListController.currentSortOrder).toBe("asc"); - - // Restore - window.URLSearchParams = originalURLSearchParams; }); test("should cache DOM elements", () => { @@ -300,10 +241,12 @@ describe("FileListPageController", () => { expect(global.ModalManager).toHaveBeenCalled(); expect(global.SearchManager).toHaveBeenCalled(); - expect(global.PaginationManager).toHaveBeenCalled(); - expect(global.window.FileListCapturesTableManager).toHaveBeenCalled(); + expect(ModalManager.attachDocumentCaptureClickDelegation).toHaveBeenCalled(); expect(fileListController.modalManager).toBe(mockModalManager); expect(fileListController.searchManager).toBe(mockSearchManager); + expect(fileListController.listRefreshManager).toBe( + global.window.listRefreshManager, + ); }); }); @@ -319,7 +262,6 @@ describe("FileListPageController", () => { mockElements.centerFreqMin.value = "1.0"; mockElements.centerFreqMax.value = "5.0"; - // Set userInteractedWithFrequency to true to include frequency params fileListController.userInteractedWithFrequency = true; const params = fileListController.buildSearchParams(); @@ -339,446 +281,18 @@ describe("FileListPageController", () => { const params = fileListController.buildSearchParams(); - // Empty values should not be set in params expect(params.get("search")).toBeNull(); expect(params.get("date_start")).toBeNull(); - // But sort params should always be set expect(params.get("sort_by")).toBe("created_at"); expect(params.get("sort_order")).toBe("desc"); }); - }); -}); - -describe("FileListCapturesTableManager", () => { - let tableManager; - let mockTbody; - let mockResultsCount; - - beforeEach(() => { - jest.clearAllMocks(); - - // Mock tbody element - mockTbody = { - innerHTML: "", - querySelector: jest.fn(), - querySelectorAll: jest.fn(() => []), - }; - - // Mock results count element - mockResultsCount = { - textContent: "", - }; - - document.querySelector = jest.fn((selector) => { - if (selector === "tbody") { - return mockTbody; - } - return null; - }); - - document.querySelectorAll = jest.fn(() => []); - - document.getElementById = jest.fn((id) => { - if (id === "results-count") { - return mockResultsCount; - } - return null; - }); - - // Mock ComponentUtils - global.window.ComponentUtils = { - escapeHtml: jest.fn((str) => { - if (!str) return ""; - return String(str) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); - }), - formatDateForModal: jest.fn((date) => { - if (!date) return "-"; - const d = new Date(date); - return d.toISOString().split("T")[0]; - }), - }; - - // Mock Bootstrap Dropdown - global.bootstrap.Dropdown = jest.fn().mockImplementation((element) => ({ - show: jest.fn(), - hide: jest.fn(), - element: element, - })); - - // Create table manager instance - // We need to import or define the class - for now, we'll test it directly - // In a real scenario, we'd import from file-list.js - tableManager = { - resultsCountElement: mockResultsCount, - renderRow: (capture) => { - // This would be the actual renderRow implementation - const safeData = { - uuid: window.ComponentUtils.escapeHtml(capture.uuid || ""), - name: window.ComponentUtils.escapeHtml(capture.name || ""), - channel: window.ComponentUtils.escapeHtml(capture.channel || ""), - scanGroup: window.ComponentUtils.escapeHtml(capture.scan_group || ""), - captureType: window.ComponentUtils.escapeHtml( - capture.capture_type || "", - ), - captureTypeDisplay: window.ComponentUtils.escapeHtml( - capture.capture_type_display || "", - ), - topLevelDir: window.ComponentUtils.escapeHtml( - capture.top_level_dir || "", - ), - owner: window.ComponentUtils.escapeHtml(capture.owner || ""), - }; - - const nameDisplay = safeData.name || "Unnamed Capture"; - const typeDisplay = - safeData.captureTypeDisplay || safeData.captureType || "-"; - - return ` - - - - ${nameDisplay} - - - ${safeData.topLevelDir || "-"} - ${typeDisplay} - - - - - - - `; - }, - updateTable: function (captures, hasResults) { - if (!mockTbody) return; - - if (!hasResults || captures.length === 0) { - mockTbody.innerHTML = ` - - - No captures found matching your search criteria. - - - `; - return; - } - - const tableHTML = captures - .map((capture, index) => this.renderRow(capture, index)) - .join(""); - mockTbody.innerHTML = tableHTML; - - // Initialize dropdowns - this.initializeDropdowns(); - }, - updateResultsCount: function (captures, hasResults) { - if (this.resultsCountElement) { - const count = hasResults && captures ? captures.length : 0; - const pluralSuffix = count === 1 ? "" : "s"; - this.resultsCountElement.textContent = `${count} capture${pluralSuffix} found`; - } - }, - initializeDropdowns: () => { - const dropdownButtons = document.querySelectorAll(".btn-icon-dropdown"); - - if (dropdownButtons.length === 0) { - return; - } - - for (const toggle of dropdownButtons) { - if (toggle._dropdown) { - continue; - } - - const dropdownMenu = toggle.nextElementSibling; - if ( - !dropdownMenu || - !dropdownMenu.classList.contains("dropdown-menu") - ) { - continue; - } - - const dropdown = new bootstrap.Dropdown(toggle, { - container: "body", - boundary: "viewport", - popperConfig: { - modifiers: [ - { - name: "preventOverflow", - options: { - boundary: "viewport", - }, - }, - ], - }, - }); - - toggle.addEventListener("show.bs.dropdown", () => { - if (dropdownMenu?.classList.contains("dropdown-menu")) { - document.body.appendChild(dropdownMenu); - } - }); - - toggle._dropdown = dropdown; - } - }, - }; - }); - - describe("updateTable", () => { - test("should render empty state when no results", () => { - tableManager.updateTable([], false); - - expect(mockTbody.innerHTML).toContain("No captures found"); - expect(mockTbody.innerHTML).toContain('colspan="5"'); - }); - - test("should render captures when results exist", () => { - const captures = [ - { - uuid: "test-uuid-1", - name: "Test Capture 1", - capture_type: "drf", - capture_type_display: "DRF", - top_level_dir: "/test/dir", - channel: "1", - owner: "test@example.com", - created_at: "2024-01-01T00:00:00Z", - }, - { - uuid: "test-uuid-2", - name: "Test Capture 2", - capture_type: "rh", - capture_type_display: "RH", - top_level_dir: "/test/dir2", - channel: "2", - owner: "test2@example.com", - created_at: "2024-01-02T00:00:00Z", - }, - ]; - - tableManager.updateTable(captures, true); - - expect(mockTbody.innerHTML).toContain("Test Capture 1"); - expect(mockTbody.innerHTML).toContain("Test Capture 2"); - expect(mockTbody.innerHTML).toContain("test-uuid-1"); - expect(mockTbody.innerHTML).toContain("test-uuid-2"); - }); - - test("should call initializeDropdowns after rendering", () => { - const spy = jest.spyOn(tableManager, "initializeDropdowns"); - const captures = [ - { - uuid: "test-uuid-1", - name: "Test Capture", - capture_type: "drf", - top_level_dir: "/test", - channel: "1", - owner: "test@example.com", - }, - ]; - - tableManager.updateTable(captures, true); - - expect(spy).toHaveBeenCalled(); - }); - }); - - describe("updateResultsCount", () => { - test("should update results count with correct pluralization", () => { - const captures = [{ uuid: "1" }]; - tableManager.updateResultsCount(captures, true); - - expect(mockResultsCount.textContent).toBe("1 capture found"); - }); - - test("should handle plural form", () => { - const captures = [{ uuid: "1" }, { uuid: "2" }]; - tableManager.updateResultsCount(captures, true); - - expect(mockResultsCount.textContent).toBe("2 captures found"); - }); - - test("should handle no results", () => { - tableManager.updateResultsCount([], false); - - expect(mockResultsCount.textContent).toBe("0 captures found"); - }); - }); - - describe("initializeDropdowns", () => { - test("should initialize Bootstrap dropdowns", () => { - const mockDropdownMenu = { - classList: { - contains: jest.fn(() => true), - }, - }; - const mockToggle = { - _dropdown: null, - nextElementSibling: mockDropdownMenu, - addEventListener: jest.fn(), - }; - - document.querySelectorAll = jest.fn(() => [mockToggle]); - - // Mock document.body properly - if (!document.body) { - document.body = document.createElement("body"); - } - document.body.appendChild = jest.fn(); - - tableManager.initializeDropdowns(); - - expect(global.bootstrap.Dropdown).toHaveBeenCalled(); - expect(mockToggle._dropdown).toBeDefined(); - expect(mockToggle.addEventListener).toHaveBeenCalledWith( - "show.bs.dropdown", - expect.any(Function), - ); - }); - - test("should skip already initialized dropdowns", () => { - const mockToggle = { - _dropdown: { show: jest.fn() }, - nextElementSibling: null, - addEventListener: jest.fn(), - }; - document.querySelectorAll = jest.fn(() => [mockToggle]); - - tableManager.initializeDropdowns(); - - expect(global.bootstrap.Dropdown).not.toHaveBeenCalled(); - }); - - test("should handle missing dropdown menu", () => { - const mockToggle = { - _dropdown: null, - nextElementSibling: null, - addEventListener: jest.fn(), - }; - - document.querySelectorAll = jest.fn(() => [mockToggle]); - - tableManager.initializeDropdowns(); - - // Should not initialize dropdown if nextElementSibling is null - expect(global.bootstrap.Dropdown).not.toHaveBeenCalled(); - }); - }); - - describe("renderRow", () => { - test("should render row with all required columns", () => { - const capture = { - uuid: "test-uuid", - name: "Test Capture", - capture_type: "drf", - capture_type_display: "DRF", - top_level_dir: "/test/dir", - channel: "1", - owner: "test@example.com", - created_at: "2024-01-01T00:00:00Z", - }; - - const html = tableManager.renderRow(capture, 0); - - expect(html).toContain("test-uuid"); - expect(html).toContain("Test Capture"); - expect(html).toContain("/test/dir"); - expect(html).toContain('headers="name-header"'); - expect(html).toContain('headers="top-level-dir-header"'); - expect(html).toContain('headers="type-header"'); - expect(html).toContain('headers="created-header"'); - expect(html).toContain('headers="actions-header"'); - }); - - test("should escape HTML in capture data", () => { - const capture = { - uuid: "test-uuid", - name: "", - capture_type: "drf", - top_level_dir: "/test/dir", - channel: "1", - owner: "test@example.com", - }; - - const html = tableManager.renderRow(capture, 0); - - expect(html).not.toContain(" + {% endblock javascript %} diff --git a/gateway/sds_gateway/templates/users/components/capture_list_modals_fragment.html b/gateway/sds_gateway/templates/users/components/capture_list_modals_fragment.html new file mode 100644 index 000000000..0e6f53536 --- /dev/null +++ b/gateway/sds_gateway/templates/users/components/capture_list_modals_fragment.html @@ -0,0 +1,9 @@ +{% comment %} +Per-capture modals for AJAX list refresh (share + web download). +{% endcomment %} +{% for cap in captures %} + {% if cap.capture %} + {% include "users/partials/share_modal.html" with item=cap.capture item_type="capture" %} + {% include "users/partials/web_download_modal.html" with item=cap.capture item_type="capture" %} + {% endif %} +{% endfor %} diff --git a/gateway/sds_gateway/templates/users/components/capture_list_table_fragment.html b/gateway/sds_gateway/templates/users/components/capture_list_table_fragment.html new file mode 100644 index 000000000..1940cf89f --- /dev/null +++ b/gateway/sds_gateway/templates/users/components/capture_list_table_fragment.html @@ -0,0 +1,4 @@ +{% comment %} +Capture list table + pagination only (AJAX fragment for ListRefreshManager). +{% endcomment %} +{% include "users/partials/captures_page_table.html" %} diff --git a/gateway/sds_gateway/templates/users/dataset_list.html b/gateway/sds_gateway/templates/users/dataset_list.html index f3c47bf20..55b8c877e 100644 --- a/gateway/sds_gateway/templates/users/dataset_list.html +++ b/gateway/sds_gateway/templates/users/dataset_list.html @@ -51,6 +51,7 @@

      Datasets

      + - - - - - - - - - + {# djlint:off #} + + {# djlint:on #} - + + {# djlint:off #} - - - - - - - - - - - + + + @@ -567,12 +558,57 @@ window.uploadFilesUrl = window.filesConfig.urls.uploadFiles; window.csrfToken = window.filesConfig.csrfToken; - - - - - - - - + {% endblock javascript %} diff --git a/gateway/sds_gateway/templates/users/partials/captures_page_table.html b/gateway/sds_gateway/templates/users/partials/captures_page_table.html index 94b9bbc3e..2b7b73a22 100644 --- a/gateway/sds_gateway/templates/users/partials/captures_page_table.html +++ b/gateway/sds_gateway/templates/users/partials/captures_page_table.html @@ -219,12 +219,13 @@ {% if captures and captures.paginator.num_pages > 1 %} -