From 891e31c9ec4265bf3970ba00cb69d0df827e7531 Mon Sep 17 00:00:00 2001 From: Daniele Mangiagli Date: Tue, 20 Oct 2026 08:12:52 +0200 Subject: [PATCH 1/2] add: activity manager for async export report --- public/locales/gsa-de.json | 25 +- public/locales/gsa-en.json | 23 + public/locales/gsa-it.json | 25 +- public/locales/gsa-ja.json | 23 + public/locales/gsa-zh_CN.json | 23 + public/locales/gsa-zh_TW.json | 23 + src/gmp/capabilities/capabilities.ts | 1 + .../commands/__tests__/report-export.test.ts | 24 +- src/gmp/commands/report-export.ts | 41 +- src/gmp/models/report-export.ts | 16 + src/web/components/form/Download.tsx | 4 +- src/web/components/structure/Header.tsx | 23 +- .../structure/__tests__/Header.test.tsx | 19 +- src/web/pages/Page.tsx | 53 +- .../reports/AuditDeltaReportDetailsPage.jsx | 74 +- .../pages/reports/AuditReportDetailsPage.tsx | 72 +- src/web/pages/reports/DeltaDetailsPage.tsx | 48 +- .../pages/reports/DownloadReportDialog.tsx | 53 +- src/web/pages/reports/ReportDetailsPage.tsx | 83 +- .../__tests__/AuditDeltaDetailsPage.test.jsx | 70 ++ .../__tests__/AuditReportDetailsPage.test.tsx | 94 +- .../__tests__/DownloadReportDialog.test.tsx | 68 +- .../__tests__/ReportDetailsPage.test.tsx | 186 ++- .../report-export/ReportExportActivity.tsx | 94 ++ src/web/report-export/ReportExportJobRow.tsx | 193 +++ src/web/report-export/ReportExportManager.tsx | 111 ++ .../__fixtures__/report-export-mocks.ts | 93 ++ .../__tests__/ReportExportManager.test.tsx | 1055 +++++++++++++++++ .../build-report-export-request.test.ts | 57 + .../__tests__/useReportExport.test.tsx | 781 ++++++++++++ .../build-report-export-request.ts | 79 ++ .../report-export-activity-styles.ts | 149 +++ .../report-export-job-display.ts | 105 ++ .../report-export/report-export-job-model.ts | 270 +++++ .../report-export/useReportExportActivity.ts | 68 ++ .../report-export/useReportExportMutations.ts | 47 + .../report-export/useReportExportQueries.ts | 95 ++ src/web/report-export/useReportExports.ts | 401 +++++++ 38 files changed, 4434 insertions(+), 235 deletions(-) create mode 100644 src/web/report-export/ReportExportActivity.tsx create mode 100644 src/web/report-export/ReportExportJobRow.tsx create mode 100644 src/web/report-export/ReportExportManager.tsx create mode 100644 src/web/report-export/__fixtures__/report-export-mocks.ts create mode 100644 src/web/report-export/__tests__/ReportExportManager.test.tsx create mode 100644 src/web/report-export/__tests__/build-report-export-request.test.ts create mode 100644 src/web/report-export/__tests__/useReportExport.test.tsx create mode 100644 src/web/report-export/build-report-export-request.ts create mode 100644 src/web/report-export/report-export-activity-styles.ts create mode 100644 src/web/report-export/report-export-job-display.ts create mode 100644 src/web/report-export/report-export-job-model.ts create mode 100644 src/web/report-export/useReportExportActivity.ts create mode 100644 src/web/report-export/useReportExportMutations.ts create mode 100644 src/web/report-export/useReportExportQueries.ts create mode 100644 src/web/report-export/useReportExports.ts diff --git a/public/locales/gsa-de.json b/public/locales/gsa-de.json index d9090d39d2..bd19f212c2 100644 --- a/public/locales/gsa-de.json +++ b/public/locales/gsa-de.json @@ -336,6 +336,11 @@ "Call Template": "Vorlage (Call Template)", "Call Type": "Typ (Call Type)", "Cancel": "Abbrechen", + "Cancel report export": "Berichtsexport abbrechen", + "Canceled": "Abgebrochen", + "Canceling": "Abbruch läuft", + "Cancellation failed: {{error}}": "Abbruch fehlgeschlagen: {{error}}", + "Cancellation requested": "Abbruch angefordert", "Cannot clear maintenance window: missing setting ID.": "Kann Wartungsfenster nicht löschen: fehlende Einstellungs-ID.", "Cannot delete {{entity}}": "Kann {{entity}} nicht löschen", "Cannot modify {{entity}}": "Kann {{entity}} nicht ändern", @@ -426,11 +431,13 @@ "Chart: Vulnerabilities by CVSS": "Diagramm: Schwachstellen nach CVSS", "Chart: Vulnerabilities by Hosts": "Diagramm: Schwachstellen nach Hosts", "Chart: Vulnerabilities by Severity Class": "Diagramm: Schwachstellen nach Schweregradklasse", + "Checking": "Überprüfung läuft", "Checksum for agent installer {{name}} {{version}} copied to clipboard.": "Prüfsumme für Agent-Installationsprogramm {{name}} {{version}} in die Zwischenablage kopiert.", "Choose Display": "Anzeige auswählen", "Choose from the dropdown of common schedules, or select 'Custom cron expression' in the list to enter your own cron schedule.": "Wählen Sie aus dem Dropdown-Menü der gängigen Zeitpläne oder wählen Sie in der Liste 'Benutzerdefinierter Cron-Ausdruck', um Ihren eigenen Cron-Zeitplan einzugeben.", "Choose Tag": "Tag auswählen", "Clear": "Zurücksetzen", + "Clear completed": "Abgeschlossene löschen", "Clear input": "Eingabe löschen", "Client Certificate": "Benutzerzertifikat", "Client Certificate (from Credential)": "Benutzerzertifikat (aus Anmeldedaten)", @@ -449,6 +456,7 @@ "Clone User": "Benutzer klonen", "Clone Web Application Target": "Webanwendungsziel klonen", "Close": "Schließen", + "Close export activity": "Exportaktivität schließen", "Closed": "Geschlossen", "Closed CVEs": "Geschlossene CVEs", "Comment": "Kommentar", @@ -717,7 +725,7 @@ "Do you really want to remove the Dashboard {{name}} and its configuration?": "Möchten Sie das Dashboard {{name}} und seine Einstellungen wirklich löschen?", "Documentation": "Dokumentation", "Done": "Abgeschlossen", - "Download": "", + "Download": "Herunterladen", "Download Agent Installer": "Agent-Installationsprogramm herunterladen", "Download Agent Support Bundle": "Agent-Supportpaket herunterladen", "Download CA Certificate": "CA-Zertifikat herunterladen", @@ -725,6 +733,7 @@ "Download Client Certificate": "Client-Zertifikat herunterladen", "Download CSV": "CSV-Daten herunterladen", "Download Debian (.deb) Package": "Debian-Paket (.deb) herunterladen", + "Download failed": "Download fehlgeschlagen", "Download filtered Report": "Gefilterten Bericht herunterladen", "Download Greenbone Compliance Report": "Greenbone Compliance-Bericht herunterladen", "Download Public Key": "Öffentlichen Schlüssel herunterladen", @@ -733,6 +742,7 @@ "Download TLS Certificate": "TLS-Zertifikat herunterladen", "Download TLS Certificate as .pem": "TLS-Zertifikat als .pem herunterladen", "Download Windows Executable (.exe)": "Windows-Programmdatei (.exe) herunterladen", + "Downloading": "Wird heruntergeladen", "Duration": "Laufzeit", "Duration of last Scan": "Dauer des letzten Scans", "During the maintenance window, scan scheduling is suspended to prevent scans from running.": "Während des Wartungsfensters wird die Planung von Scans ausgesetzt, um zu verhindern, dass Scans ausgeführt werden.", @@ -905,6 +915,7 @@ "Export Credential as XML": "Anmeldedaten als XML exportieren", "Export CVE": "CVE exportieren", "Export DFN-CERT Advisory": "DFN-CERT-Advisory exportieren", + "Export failed": "Export fehlgeschlagen", "Export Filter": "Filter exportieren", "Export Filter as XML": "Filter als XML exportieren", "Export Group": "Gruppe exportieren", @@ -1005,6 +1016,7 @@ "General": "Allgemein", "General Command Permissions": "Generelle Berechtigungen für Befehle", "General note": "Allgemeine Anmerkung", + "Generating": "Wird generiert", "Global {{type}}": "Globale(r) {{type}}", "GMP error during authentication": "GMP-Fehler während der Authentifizierung", "Gone": "Entfallen", @@ -1621,6 +1633,7 @@ "Ports": "Ports", "Preferences": "Vorgaben", "Prefs": "Vorgaben", + "Preparing": "Wird vorbereitet", "Present": "Präsent", "Previous": "Vorherige", "Previous completed report of the same task": "Vorheriger abgeschlossener Bericht der selben Aufgabe", @@ -1633,6 +1646,7 @@ "Processing": "Verarbeiten", "Product": "Produkt", "Product Detection Result": "Ergebnis zur Produkterkennung", + "Progress: {{progress}}": "Fortschritt: {{progress}}", "Protocol": "Protokoll", "Provider Urgency": "Dringlichkeit des Anbieters", "Public Key": "Öffentlicher Schlüssel", @@ -1679,6 +1693,7 @@ "Remove Dashboard": "Dashboard entfernen", "Remove Dashboard {{name}}": "Dashboard {{name}} entfernen", "Remove Filter": "Filter entfernen", + "Remove from activity": "Aus Aktivität entfernen", "Remove from Assets": "Aus Assets entfernen", "Remove schedule": "Zeitplan entfernen", "Remove schedule {{number}}": "Zeitplan {{number}} entfernen", @@ -1707,7 +1722,12 @@ "Report content removed from Assets.": "Berichtinhalt wurde aus Assets entfernt.", "Report Details": "Bericht Details", "Report download not available": "Bericht-Download ist nicht verfügbar", + "Report download started.": "Bericht-Download gestartet.", + "Report export": "Berichtsexport", + "Report export activity": "Exportaktivität des Berichts", "Report Export File Name": "Dateiname für Bericht-Export", + "Report export: {{report}}": "Berichtsexport: {{report}}", + "Report exports ({{count}})": "Berichtsexporte ({{count}})", "Report for Greenbone Sensor": "Bericht für Greenbone Sensor", "Report for Last": "Bericht für letzte(n/s)", "Report for Last Day": "Bericht für den letzten Tag", @@ -1778,6 +1798,7 @@ "Resume Requested": "Fortsetzen angefragt", "Retry": "Wiederholen", "Retry Attempts": "Wiederholungsversuche", + "Retry cancellation": "Abbruch erneut versuchen", "Retry Delay (seconds)": "Wiederholungs-Verzögerung (Sekunden)", "Reverse Lookup Only": "Nur Invers-Lookup", "Reverse Lookup Unify": "Invers-Lookup-Vereinheitlichung", @@ -2239,6 +2260,7 @@ "UDP": "UDP", "UDP Port Count": "UDP-Portanzahl", "Ultimate Delete Requested": "Endgültig Löschen Angefragt", + "Unavailable": "Nicht verfügbar", "Unchanged": "Unverändert", "undefined": "undefiniert", "Undefined": "Undefiniert", @@ -2324,6 +2346,7 @@ "View Details of Schedule {{name}} (Next due: {{time}})": "Zeige Details des Zeitplans {{name}} (Fälligkeit: {{time}})", "View Details of Schedule {{name}} (Next due: over)": "Zeige Details des Zeitplans {{name}} (Fälligkeit: abgelaufen)", "View list of all reports for Task {{name}}, including unfinished ones": "Zeige Liste aller Berichte der Aufgabe {{name}}, inklusive unvollendeter", + "View report details": "Berichtdetails anzeigen", "Vulnerabilities": "Schwachstellen", "Vulnerabilities by CVSS (Total: {{count}})": "Schwachstellen nach CVSS (Gesamt: {{count}})", "Vulnerabilities by Hosts": "Schwachstellen nach Hosts", diff --git a/public/locales/gsa-en.json b/public/locales/gsa-en.json index 85184ad1ff..97db02884e 100644 --- a/public/locales/gsa-en.json +++ b/public/locales/gsa-en.json @@ -336,6 +336,11 @@ "Call Template": "Call Template", "Call Type": "Call Type", "Cancel": "Cancel", + "Cancel report export": "Cancel report export", + "Canceled": "Canceled", + "Canceling": "Canceling", + "Cancellation failed: {{error}}": "Cancellation failed: {{error}}", + "Cancellation requested": "Cancellation requested", "Cannot clear maintenance window: missing setting ID.": "", "Cannot delete {{entity}}": "Cannot delete {{entity}}", "Cannot modify {{entity}}": "Cannot modify {{entity}}", @@ -426,11 +431,13 @@ "Chart: Vulnerabilities by CVSS": "Chart: Vulnerabilities by CVSS", "Chart: Vulnerabilities by Hosts": "Chart: Vulnerabilities by Hosts", "Chart: Vulnerabilities by Severity Class": "Chart: Vulnerabilities by Severity Class", + "Checking": "Checking", "Checksum for agent installer {{name}} {{version}} copied to clipboard.": "Checksum for agent installer {{name}} {{version}} copied to clipboard.", "Choose Display": "Choose Display", "Choose from the dropdown of common schedules, or select 'Custom cron expression' in the list to enter your own cron schedule.": "", "Choose Tag": "Choose Tag", "Clear": "Clear", + "Clear completed": "Clear completed", "Clear input": "Clear input", "Client Certificate": "Client Certificate", "Client Certificate (from Credential)": "Client Certificate (from Credential)", @@ -449,6 +456,7 @@ "Clone User": "Clone User", "Clone Web Application Target": "", "Close": "Close", + "Close export activity": "Close export activity", "Closed": "Closed", "Closed CVEs": "Closed CVEs", "Comment": "Comment", @@ -725,6 +733,7 @@ "Download Client Certificate": "Download Client Certificate", "Download CSV": "Download CSV", "Download Debian (.deb) Package": "Download Debian (.deb) Package", + "Download failed": "Download failed", "Download filtered Report": "Download filtered Report", "Download Greenbone Compliance Report": "Download Greenbone Compliance Report", "Download Public Key": "Download Public Key", @@ -733,6 +742,7 @@ "Download TLS Certificate": "Download TLS Certificate", "Download TLS Certificate as .pem": "Download TLS Certificate as .pem", "Download Windows Executable (.exe)": "Download Windows Executable (.exe)", + "Downloading": "Downloading", "Duration": "Duration", "Duration of last Scan": "Duration of last Scan", "During the maintenance window, scan scheduling is suspended to prevent scans from running.": "", @@ -905,6 +915,7 @@ "Export Credential as XML": "Export Credential as XML", "Export CVE": "Export CVE", "Export DFN-CERT Advisory": "Export DFN-CERT Advisory", + "Export failed": "Export failed", "Export Filter": "Export Filter", "Export Filter as XML": "Export Filter as XML", "Export Group": "Export Group", @@ -1005,6 +1016,7 @@ "General": "General", "General Command Permissions": "General Command Permissions", "General note": "General note", + "Generating": "Generating", "Global {{type}}": "Global {{type}}", "GMP error during authentication": "GMP error during authentication", "Gone": "Gone", @@ -1621,6 +1633,7 @@ "Ports": "Ports", "Preferences": "Preferences", "Prefs": "Prefs", + "Preparing": "Preparing", "Present": "Present", "Previous": "Previous", "Previous completed report of the same task": "Previous completed report of the same task", @@ -1633,6 +1646,7 @@ "Processing": "Processing", "Product": "Product", "Product Detection Result": "Product Detection Result", + "Progress: {{progress}}": "Progress: {{progress}}", "Protocol": "Protocol", "Provider Urgency": "Provider Urgency", "Public Key": "Public Key", @@ -1679,6 +1693,7 @@ "Remove Dashboard": "Remove Dashboard", "Remove Dashboard {{name}}": "Remove Dashboard {{name}}", "Remove Filter": "Remove Filter", + "Remove from activity": "Remove from activity", "Remove from Assets": "Remove from Assets", "Remove schedule": "Remove schedule", "Remove schedule {{number}}": "Remove schedule {{number}}", @@ -1707,7 +1722,12 @@ "Report content removed from Assets.": "Report content removed from Assets.", "Report Details": "Report Details", "Report download not available": "Report download not available", + "Report download started.": "Report download started.", + "Report export": "Report export", + "Report export activity": "Report export activity", "Report Export File Name": "Report Export File Name", + "Report export: {{report}}": "Report export: {{report}}", + "Report exports ({{count}})": "Report exports ({{count}})", "Report for Greenbone Sensor": "Report for Greenbone Sensor", "Report for Last": "Report for Last", "Report for Last Day": "Report for Last Day", @@ -1778,6 +1798,7 @@ "Resume Requested": "Resume Requested", "Retry": "", "Retry Attempts": "", + "Retry cancellation": "Retry cancellation", "Retry Delay (seconds)": "", "Reverse Lookup Only": "Reverse Lookup Only", "Reverse Lookup Unify": "Reverse Lookup Unify", @@ -2239,6 +2260,7 @@ "UDP": "UDP", "UDP Port Count": "UDP Port Count", "Ultimate Delete Requested": "Ultimate Delete Requested", + "Unavailable": "Unavailable", "Unchanged": "Unchanged", "undefined": "undefined", "Undefined": "Undefined", @@ -2324,6 +2346,7 @@ "View Details of Schedule {{name}} (Next due: {{time}})": "View Details of Schedule {{name}} (Next due: {{time}})", "View Details of Schedule {{name}} (Next due: over)": "View Details of Schedule {{name}} (Next due: over)", "View list of all reports for Task {{name}}, including unfinished ones": "View list of all reports for Task {{name}}, including unfinished ones", + "View report details": "View report details", "Vulnerabilities": "Vulnerabilities", "Vulnerabilities by CVSS (Total: {{count}})": "Vulnerabilities by CVSS (Total: {{count}})", "Vulnerabilities by Hosts": "Vulnerabilities by Hosts", diff --git a/public/locales/gsa-it.json b/public/locales/gsa-it.json index 1342d4693c..9c2a1cb7e5 100644 --- a/public/locales/gsa-it.json +++ b/public/locales/gsa-it.json @@ -87,7 +87,7 @@ "{{type}} is already active": "{{type}} è già attivo", "{{type}} is for import only": "{{type}} è solo per l'importazione", "{{type}} is not stopped": "{{type}} non è interrotto", - "{{type}} is scheduled": "", + "{{type}} is scheduled": "{{type}} è pianificato", "{{type}} owned by {{owner}}": "{{type}} di proprietà di {{owner}}", "{{value}}: {{count}} (severity: {{severity}})": "{{value}}: {{count}} (gravità: {{severity}})", "# of Agents": "# di agenti", @@ -336,6 +336,11 @@ "Call Template": "Template della chiamata", "Call Type": "Tipo di chiamata", "Cancel": "Annulla", + "Cancel report export": "Annulla esportazione report", + "Canceled": "Annullato", + "Canceling": "Annullamento in corso", + "Cancellation failed: {{error}}": "Annullamento fallito: {{error}}", + "Cancellation requested": "Richiesto annullamento", "Cannot clear maintenance window: missing setting ID.": "Impossibile cancellare la finestra di manutenzione: ID impostazione mancante.", "Cannot delete {{entity}}": "Impossibile eliminare {{entity}}", "Cannot modify {{entity}}": "Impossibile modificare {{entity}}", @@ -426,11 +431,13 @@ "Chart: Vulnerabilities by CVSS": "Grafico: vulnerabilità per CVSS", "Chart: Vulnerabilities by Hosts": "Grafico: vulnerabilità per host", "Chart: Vulnerabilities by Severity Class": "Grafico: vulnerabilità per classe di gravità", + "Checking": "Verifica in corso", "Checksum for agent installer {{name}} {{version}} copied to clipboard.": "Checksum per l'installer dell'agente {{name}} {{version}} copiato negli appunti.", "Choose Display": "Scegli la visualizzazione", "Choose from the dropdown of common schedules, or select 'Custom cron expression' in the list to enter your own cron schedule.": "Scegli dal menu a tendina delle pianificazioni comuni, o seleziona 'Custom cron expression' nell'elenco per inserire la tua pianificazione cron personalizzata.", "Choose Tag": "Scegli tag", "Clear": "Cancella", + "Clear completed": "Cancella completati", "Clear input": "Cancella input", "Client Certificate": "Certificato client", "Client Certificate (from Credential)": "Certificato client (dalla credenziale)", @@ -449,6 +456,7 @@ "Clone User": "Clona utente", "Clone Web Application Target": "Clona Web Application Target", "Close": "Chiudi", + "Close export activity": "Chiudi attività di esportazione", "Closed": "Chiuso", "Closed CVEs": "CVE chiusi", "Comment": "Commento", @@ -725,6 +733,7 @@ "Download Client Certificate": "Scarica Certificato client", "Download CSV": "Scarica CSV", "Download Debian (.deb) Package": "Scarica pacchetto Debian (.deb)", + "Download failed": "", "Download filtered Report": "Scarica report filtrato", "Download Greenbone Compliance Report": "Scarica Greenbone Compliance Report", "Download Public Key": "Scarica chiave pubblica", @@ -733,6 +742,7 @@ "Download TLS Certificate": "Scarica Certificato TLS", "Download TLS Certificate as .pem": "Scarica Certificato TLS come .pem", "Download Windows Executable (.exe)": "Scarica eseguibile Windows (.exe)", + "Downloading": "", "Duration": "Durata", "Duration of last Scan": "Durata dell'ultima scansione", "During the maintenance window, scan scheduling is suspended to prevent scans from running.": "Durante la finestra di manutenzione, la pianificazione delle scansioni è sospesa per impedire l'esecuzione delle scansioni.", @@ -905,6 +915,7 @@ "Export Credential as XML": "Esporta Credenziale come XML", "Export CVE": "Esporta CVE", "Export DFN-CERT Advisory": "Esporta DFN-CERT Advisory", + "Export failed": "", "Export Filter": "Esporta Filtro", "Export Filter as XML": "Esporta Filtro come XML", "Export Group": "Esporta Gruppo", @@ -1005,6 +1016,7 @@ "General": "Generale", "General Command Permissions": "Autorizzazioni generali dei comandi", "General note": "Nota generale", + "Generating": "", "Global {{type}}": "{{type}} globale", "GMP error during authentication": "Errore GMP durante l'autenticazione", "Gone": "Assente", @@ -1621,6 +1633,7 @@ "Ports": "Porte", "Preferences": "Preferenze", "Prefs": "Preferenze", + "Preparing": "", "Present": "Presente", "Previous": "Precedente", "Previous completed report of the same task": "Report completato precedente della stessa task", @@ -1633,6 +1646,7 @@ "Processing": "Elaborazione", "Product": "Prodotto", "Product Detection Result": "Risultato di rilevamento prodotto", + "Progress: {{progress}}": "", "Protocol": "Protocollo", "Provider Urgency": "Urgenza Provider", "Public Key": "Chiave pubblica", @@ -1679,6 +1693,7 @@ "Remove Dashboard": "Rimuovi dashboard", "Remove Dashboard {{name}}": "Rimuovi dashboard {{name}}", "Remove Filter": "Rimuovi filtro", + "Remove from activity": "", "Remove from Assets": "Rimuovi dagli asset", "Remove schedule": "Rimuovi pianificazione", "Remove schedule {{number}}": "Rimuovi pianificazione {{number}}", @@ -1707,7 +1722,12 @@ "Report content removed from Assets.": "Contenuto del report rimosso dagli asset.", "Report Details": "Dettagli report", "Report download not available": "Download report non disponibile", + "Report download started.": "", + "Report export": "", + "Report export activity": "", "Report Export File Name": "Nome file di esportazione report", + "Report export: {{report}}": "", + "Report exports ({{count}})": "", "Report for Greenbone Sensor": "Report per Greenbone Sensor", "Report for Last": "Report per l'ultimo", "Report for Last Day": "Report dell'ultimo giorno", @@ -1778,6 +1798,7 @@ "Resume Requested": "Ripresa richiesta", "Retry": "Riprova", "Retry Attempts": "Tentativi di ripetizione", + "Retry cancellation": "", "Retry Delay (seconds)": "Ritardo di ripetizione (secondi)", "Reverse Lookup Only": "Solo reverse lookup", "Reverse Lookup Unify": "Unifica reverse lookup", @@ -2239,6 +2260,7 @@ "UDP": "UDP", "UDP Port Count": "Conteggio porte UDP", "Ultimate Delete Requested": "Eliminazione definitiva richiesta", + "Unavailable": "", "Unchanged": "Invariato", "undefined": "undefined", "Undefined": "Non definito", @@ -2324,6 +2346,7 @@ "View Details of Schedule {{name}} (Next due: {{time}})": "Visualizza dettagli della pianificazione {{name}} (Prossima esecuzione: {{time}})", "View Details of Schedule {{name}} (Next due: over)": "Visualizza dettagli della pianificazione {{name}} (Prossima esecuzione: terminata)", "View list of all reports for Task {{name}}, including unfinished ones": "Visualizza l'elenco di tutti i rapporti per il task {{name}}, inclusi quelli non completati", + "View report details": "", "Vulnerabilities": "Vulnerabilità", "Vulnerabilities by CVSS (Total: {{count}})": "Vulnerabilità per CVSS (Totale: {{count}})", "Vulnerabilities by Hosts": "Vulnerabilità per host", diff --git a/public/locales/gsa-ja.json b/public/locales/gsa-ja.json index a77e083819..4d42bc8c9a 100644 --- a/public/locales/gsa-ja.json +++ b/public/locales/gsa-ja.json @@ -336,6 +336,11 @@ "Call Template": "コールテンプレート", "Call Type": "コールタイプ", "Cancel": "キャンセル", + "Cancel report export": "", + "Canceled": "", + "Canceling": "", + "Cancellation failed: {{error}}": "", + "Cancellation requested": "", "Cannot clear maintenance window: missing setting ID.": "メンテナンスウィンドウをクリアできません: 設定IDがありません。", "Cannot delete {{entity}}": "{{entity}}を削除できません", "Cannot modify {{entity}}": "{{entity}}を変更できません", @@ -426,11 +431,13 @@ "Chart: Vulnerabilities by CVSS": "チャート: CVSS別脆弱性", "Chart: Vulnerabilities by Hosts": "チャート: ホスト別脆弱性", "Chart: Vulnerabilities by Severity Class": "チャート: 深刻度クラス別脆弱性", + "Checking": "", "Checksum for agent installer {{name}} {{version}} copied to clipboard.": "エージェントインストーラー {{name}} {{version}} のチェックサムをクリップボードにコピーしました。", "Choose Display": "表示の選択", "Choose from the dropdown of common schedules, or select 'Custom cron expression' in the list to enter your own cron schedule.": "よく使われるスケジュールのドロップダウンから選択するか、リストで 'Custom cron expression' を選択して独自のcronスケジュールを入力してください。", "Choose Tag": "タグの選択", "Clear": "クリア", + "Clear completed": "", "Clear input": "入力をクリア", "Client Certificate": "クライアント証明書", "Client Certificate (from Credential)": "クライアント証明書 (クレデンシャルから)", @@ -449,6 +456,7 @@ "Clone User": "ユーザーを複製", "Clone Web Application Target": "Webアプリケーションターゲットを複製", "Close": "閉じる", + "Close export activity": "", "Closed": "クローズ済み", "Closed CVEs": "クローズ済みCVE", "Comment": "コメント", @@ -725,6 +733,7 @@ "Download Client Certificate": "クライアント証明書をダウンロード", "Download CSV": "CSVをダウンロード", "Download Debian (.deb) Package": "Debian (.deb)パッケージをダウンロード", + "Download failed": "", "Download filtered Report": "フィルター済みのレポートをダウンロード", "Download Greenbone Compliance Report": "Greenboneコンプライアンスレポートをダウンロード", "Download Public Key": "公開鍵をダウンロード", @@ -733,6 +742,7 @@ "Download TLS Certificate": "TLS証明書をダウンロード", "Download TLS Certificate as .pem": "TLS証明書を.pem形式でダウンロード", "Download Windows Executable (.exe)": "Windows実行ファイル (.exe)をダウンロード", + "Downloading": "", "Duration": "期間", "Duration of last Scan": "前回スキャンの所要時間", "During the maintenance window, scan scheduling is suspended to prevent scans from running.": "メンテナンス期間中は、スキャンが実行されないようにスキャンのスケジュール設定が一時停止されます。", @@ -905,6 +915,7 @@ "Export Credential as XML": "クレデンシャルをXMLとしてエクスポート", "Export CVE": "CVEをエクスポート", "Export DFN-CERT Advisory": "DFN-CERTアドバイザリをエクスポート", + "Export failed": "", "Export Filter": "フィルターをエクスポート", "Export Filter as XML": "フィルターをXMLとしてエクスポート", "Export Group": "グループをエクスポート", @@ -1005,6 +1016,7 @@ "General": "全般", "General Command Permissions": "一般コマンド権限", "General note": "全般ノート", + "Generating": "", "Global {{type}}": "グローバル{{type}}", "GMP error during authentication": "認証中にGMPエラーが発生しました", "Gone": "消失", @@ -1621,6 +1633,7 @@ "Ports": "ポート", "Preferences": "設定", "Prefs": "設定", + "Preparing": "", "Present": "存在", "Previous": "前へ", "Previous completed report of the same task": "同じタスクの前回完了したレポート", @@ -1633,6 +1646,7 @@ "Processing": "処理中", "Product": "製品", "Product Detection Result": "製品検出結果", + "Progress: {{progress}}": "", "Protocol": "プロトコル", "Provider Urgency": "提供元の緊急度", "Public Key": "公開鍵", @@ -1679,6 +1693,7 @@ "Remove Dashboard": "ダッシュボードを削除", "Remove Dashboard {{name}}": "ダッシュボード{{name}}を削除", "Remove Filter": "フィルターを削除", + "Remove from activity": "", "Remove from Assets": "アセットから削除", "Remove schedule": "", "Remove schedule {{number}}": "", @@ -1707,7 +1722,12 @@ "Report content removed from Assets.": "レポート内容をアセットから削除しました。", "Report Details": "レポート詳細", "Report download not available": "レポートのダウンロードは利用できません", + "Report download started.": "", + "Report export": "", + "Report export activity": "", "Report Export File Name": "レポートエクスポートファイル名", + "Report export: {{report}}": "", + "Report exports ({{count}})": "", "Report for Greenbone Sensor": "Greenbone Sensor向けレポート", "Report for Last": "直近のレポート", "Report for Last Day": "直近1日のレポート", @@ -1778,6 +1798,7 @@ "Resume Requested": "再開要求済み", "Retry": "再試行", "Retry Attempts": "再試行回数", + "Retry cancellation": "", "Retry Delay (seconds)": "再試行の遅延 (秒)", "Reverse Lookup Only": "逆引きのみ", "Reverse Lookup Unify": "逆引きの統合", @@ -2239,6 +2260,7 @@ "UDP": "UDP", "UDP Port Count": "UDPポート数", "Ultimate Delete Requested": "完全削除要求済み", + "Unavailable": "", "Unchanged": "変更なし", "undefined": "未定義", "Undefined": "未定義", @@ -2324,6 +2346,7 @@ "View Details of Schedule {{name}} (Next due: {{time}})": "スケジュール {{name}} の詳細を表示 (次回実行: {{time}})", "View Details of Schedule {{name}} (Next due: over)": "スケジュール {{name}} の詳細を表示 (次回実行: 終了)", "View list of all reports for Task {{name}}, including unfinished ones": "タスク {{name}} の未完了分を含むすべてのレポート一覧を表示", + "View report details": "", "Vulnerabilities": "脆弱性", "Vulnerabilities by CVSS (Total: {{count}})": "CVSS別脆弱性 (合計: {{count}})", "Vulnerabilities by Hosts": "", diff --git a/public/locales/gsa-zh_CN.json b/public/locales/gsa-zh_CN.json index 5d8aeacfb3..1c303a9b00 100644 --- a/public/locales/gsa-zh_CN.json +++ b/public/locales/gsa-zh_CN.json @@ -336,6 +336,11 @@ "Call Template": "Call 模板", "Call Type": "Call 类型", "Cancel": "取消", + "Cancel report export": "", + "Canceled": "", + "Canceling": "", + "Cancellation failed: {{error}}": "", + "Cancellation requested": "", "Cannot clear maintenance window: missing setting ID.": "", "Cannot delete {{entity}}": "无法删除{{entity}}", "Cannot modify {{entity}}": "无法修改{{entity}}", @@ -426,11 +431,13 @@ "Chart: Vulnerabilities by CVSS": "图表:漏洞按CVSS分类", "Chart: Vulnerabilities by Hosts": "图表:漏洞按主机分类", "Chart: Vulnerabilities by Severity Class": "图表:漏洞按严重性分类", + "Checking": "", "Checksum for agent installer {{name}} {{version}} copied to clipboard.": "代理安装程序 {{name}} {{version}} 的校验和已复制到剪贴板。", "Choose Display": "选择图表", "Choose from the dropdown of common schedules, or select 'Custom cron expression' in the list to enter your own cron schedule.": "", "Choose Tag": "选择标签", "Clear": "清除", + "Clear completed": "", "Clear input": "清除输入", "Client Certificate": "客户端证书", "Client Certificate (from Credential)": "客户端证书(来自凭据)", @@ -449,6 +456,7 @@ "Clone User": "复制用户", "Clone Web Application Target": "", "Close": "关闭", + "Close export activity": "", "Closed": "关闭", "Closed CVEs": "已关闭的CVEs", "Comment": "描述", @@ -725,6 +733,7 @@ "Download Client Certificate": "下载客户端证书", "Download CSV": "下载CSV", "Download Debian (.deb) Package": "下载Debian包(.deb)", + "Download failed": "", "Download filtered Report": "下载报告", "Download Greenbone Compliance Report": "下载Greenbone合规报告", "Download Public Key": "下载公钥", @@ -733,6 +742,7 @@ "Download TLS Certificate": "下载TLS证书", "Download TLS Certificate as .pem": "下载pem格式TLS证书", "Download Windows Executable (.exe)": "下载Windows可执行程序 (.exe)", + "Downloading": "", "Duration": "持续时间", "Duration of last Scan": "上次扫描持续时间", "During the maintenance window, scan scheduling is suspended to prevent scans from running.": "", @@ -905,6 +915,7 @@ "Export Credential as XML": "导出用户凭据为XML格式", "Export CVE": "导出CVE", "Export DFN-CERT Advisory": "导出DFN-CERT", + "Export failed": "", "Export Filter": "导出筛选", "Export Filter as XML": "导出筛选至XML", "Export Group": "导出群组", @@ -1005,6 +1016,7 @@ "General": "常规", "General Command Permissions": "常规命令权限", "General note": "一般", + "Generating": "", "Global {{type}}": "全局 {{type}}", "GMP error during authentication": "GMP身份验证错误", "Gone": "丢失", @@ -1621,6 +1633,7 @@ "Ports": "端口", "Preferences": "偏好", "Prefs": "前缀", + "Preparing": "", "Present": "存在", "Previous": "上一页", "Previous completed report of the same task": "同一任务的上一次报告", @@ -1633,6 +1646,7 @@ "Processing": "处理中", "Product": "产品", "Product Detection Result": "产品检测结果", + "Progress: {{progress}}": "", "Protocol": "协议", "Provider Urgency": "提供者紧急程度", "Public Key": "公钥", @@ -1679,6 +1693,7 @@ "Remove Dashboard": "移除仪表盘", "Remove Dashboard {{name}}": "移除仪表盘 {{name}}", "Remove Filter": "清空筛选", + "Remove from activity": "", "Remove from Assets": "从资产移除", "Remove schedule": "", "Remove schedule {{number}}": "", @@ -1707,7 +1722,12 @@ "Report content removed from Assets.": "报告内容已从资产中删除.", "Report Details": "报告详情", "Report download not available": "不能下载报告", + "Report download started.": "", + "Report export": "", + "Report export activity": "", "Report Export File Name": "报告导出文件名", + "Report export: {{report}}": "", + "Report exports ({{count}})": "", "Report for Greenbone Sensor": "Greenbone Sensor 报告", "Report for Last": "报告 最近一", "Report for Last Day": "最近一天", @@ -1778,6 +1798,7 @@ "Resume Requested": "恢复请求", "Retry": "重试", "Retry Attempts": "", + "Retry cancellation": "", "Retry Delay (seconds)": "", "Reverse Lookup Only": "仅反向查找", "Reverse Lookup Unify": "反向查找统一", @@ -2239,6 +2260,7 @@ "UDP": "UDP", "UDP Port Count": "UDP 端口数量", "Ultimate Delete Requested": "请求最终删除", + "Unavailable": "", "Unchanged": "未更改", "undefined": "未定义", "Undefined": "未定义", @@ -2324,6 +2346,7 @@ "View Details of Schedule {{name}} (Next due: {{time}})": "查看计划{{name}}的详细信息(下一个周期: {{time}})", "View Details of Schedule {{name}} (Next due: over)": "查看计划{{name}}的详细信息(下一个周期: 最后一次)", "View list of all reports for Task {{name}}, including unfinished ones": "查看任务{{name}}的所有报告(包括未完成的报告)", + "View report details": "", "Vulnerabilities": "漏洞", "Vulnerabilities by CVSS (Total: {{count}})": "漏洞按CVSS分类(总计:{{count}})", "Vulnerabilities by Hosts": "", diff --git a/public/locales/gsa-zh_TW.json b/public/locales/gsa-zh_TW.json index f9fff13f8c..6cbba672db 100644 --- a/public/locales/gsa-zh_TW.json +++ b/public/locales/gsa-zh_TW.json @@ -336,6 +336,11 @@ "Call Template": "", "Call Type": "", "Cancel": "", + "Cancel report export": "", + "Canceled": "", + "Canceling": "", + "Cancellation failed: {{error}}": "", + "Cancellation requested": "", "Cannot clear maintenance window: missing setting ID.": "", "Cannot delete {{entity}}": "", "Cannot modify {{entity}}": "", @@ -426,11 +431,13 @@ "Chart: Vulnerabilities by CVSS": "", "Chart: Vulnerabilities by Hosts": "", "Chart: Vulnerabilities by Severity Class": "", + "Checking": "", "Checksum for agent installer {{name}} {{version}} copied to clipboard.": "", "Choose Display": "", "Choose from the dropdown of common schedules, or select 'Custom cron expression' in the list to enter your own cron schedule.": "", "Choose Tag": "", "Clear": "", + "Clear completed": "", "Clear input": "", "Client Certificate": "", "Client Certificate (from Credential)": "", @@ -449,6 +456,7 @@ "Clone User": "再製使用者", "Clone Web Application Target": "", "Close": "", + "Close export activity": "", "Closed": "", "Closed CVEs": "", "Comment": "備註", @@ -725,6 +733,7 @@ "Download Client Certificate": "", "Download CSV": "下載 CSV", "Download Debian (.deb) Package": "", + "Download failed": "", "Download filtered Report": "", "Download Greenbone Compliance Report": "", "Download Public Key": "", @@ -733,6 +742,7 @@ "Download TLS Certificate": "下載 TLS 憑證", "Download TLS Certificate as .pem": "下載 TLS 憑證", "Download Windows Executable (.exe)": "", + "Downloading": "", "Duration": "期間", "Duration of last Scan": "", "During the maintenance window, scan scheduling is suspended to prevent scans from running.": "", @@ -905,6 +915,7 @@ "Export Credential as XML": "", "Export CVE": "匯出 CVE", "Export DFN-CERT Advisory": "", + "Export failed": "", "Export Filter": "匯出篩選器", "Export Filter as XML": "匯出篩選器為 XML", "Export Group": "匯出群組", @@ -1005,6 +1016,7 @@ "General": "一般", "General Command Permissions": "", "General note": "", + "Generating": "", "Global {{type}}": "", "GMP error during authentication": "", "Gone": "", @@ -1621,6 +1633,7 @@ "Ports": "連接埠", "Preferences": "", "Prefs": "", + "Preparing": "", "Present": "", "Previous": "", "Previous completed report of the same task": "", @@ -1633,6 +1646,7 @@ "Processing": "", "Product": "產品", "Product Detection Result": "", + "Progress: {{progress}}": "", "Protocol": "通訊協定", "Provider Urgency": "", "Public Key": "", @@ -1679,6 +1693,7 @@ "Remove Dashboard": "移除資訊看板", "Remove Dashboard {{name}}": "移除資訊看板 {{name}}", "Remove Filter": "", + "Remove from activity": "", "Remove from Assets": "", "Remove schedule": "", "Remove schedule {{number}}": "", @@ -1707,7 +1722,12 @@ "Report content removed from Assets.": "", "Report Details": "說明:主機細節", "Report download not available": "沒有可用的代理程式", + "Report download started.": "", + "Report export": "", + "Report export activity": "", "Report Export File Name": "", + "Report export: {{report}}": "", + "Report exports ({{count}})": "", "Report for Greenbone Sensor": "", "Report for Last": "", "Report for Last Day": "最近一日報告", @@ -1778,6 +1798,7 @@ "Resume Requested": "", "Retry": "重試", "Retry Attempts": "", + "Retry cancellation": "", "Retry Delay (seconds)": "", "Reverse Lookup Only": "", "Reverse Lookup Unify": "", @@ -2239,6 +2260,7 @@ "UDP": "", "UDP Port Count": "", "Ultimate Delete Requested": "", + "Unavailable": "", "Unchanged": "", "undefined": "", "Undefined": "", @@ -2324,6 +2346,7 @@ "View Details of Schedule {{name}} (Next due: {{time}})": "", "View Details of Schedule {{name}} (Next due: over)": "", "View list of all reports for Task {{name}}, including unfinished ones": "", + "View report details": "", "Vulnerabilities": "", "Vulnerabilities by CVSS (Total: {{count}})": "", "Vulnerabilities by Hosts": "", diff --git a/src/gmp/capabilities/capabilities.ts b/src/gmp/capabilities/capabilities.ts index ac0383d342..057f6ec3e3 100644 --- a/src/gmp/capabilities/capabilities.ts +++ b/src/gmp/capabilities/capabilities.ts @@ -99,6 +99,7 @@ const CAPABILITY_NAMES = [ 'get_preferences', 'get_reports', 'get_report_configs', + 'get_report_exports', 'get_report_formats', 'get_results', 'get_roles', diff --git a/src/gmp/commands/__tests__/report-export.test.ts b/src/gmp/commands/__tests__/report-export.test.ts index fa41192f89..bc64059199 100644 --- a/src/gmp/commands/__tests__/report-export.test.ts +++ b/src/gmp/commands/__tests__/report-export.test.ts @@ -8,6 +8,7 @@ import ReportExportCommand from 'gmp/commands/report-export'; import { createActionResultResponse, createHttp, + createPlainResponse, createResponse, } from 'gmp/commands/testing'; import date from 'gmp/models/date'; @@ -196,7 +197,7 @@ describe('ReportExportCommand tests', () => { test('should download a completed export', async () => { const data = new ArrayBuffer(8); - const fakeHttp = createHttp(data); + const fakeHttp = createHttp(createPlainResponse(data)); const exportCommand = new ReportExportCommand(fakeHttp); const response = await exportCommand.downloadReportExport({ @@ -210,7 +211,26 @@ describe('ReportExportCommand tests', () => { }, responseType: 'arraybuffer', }); - expect(response).toBe(data); + expect(response.data).toBe(data); + }); + + test('should decode the base64 file content of a XML wrapped download', async () => { + const bytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]); // "%PDF" + const base64 = btoa('%PDF'); + const fakeHttp = createHttp( + createPlainResponse( + new TextEncoder().encode( + `${base64}`, + ).buffer as ArrayBuffer, + ), + ); + const exportCommand = new ReportExportCommand(fakeHttp); + + const response = await exportCommand.downloadReportExport({ + reportExportId: 'export-uuid', + }); + + expect(response.data).toEqual(bytes.buffer as ArrayBuffer); }); test('should cancel a report export', async () => { diff --git a/src/gmp/commands/report-export.ts b/src/gmp/commands/report-export.ts index 3a8ba339a3..39130aea2f 100644 --- a/src/gmp/commands/report-export.ts +++ b/src/gmp/commands/report-export.ts @@ -51,6 +51,29 @@ interface GetReportExportResponseData extends XmlResponseData { const log = logger.getLogger('gmp.commands.report-export'); +const CONTENT_REGEX = /([\s\S]*?)<\/content>/; + +// gsad answers a download either with the raw file bytes or with the GMP +// XML document carrying the file base64 encoded in . Normalize to +// raw file bytes. +const decodeReportExportFile = (data: ArrayBuffer): ArrayBuffer => { + const text = new TextDecoder().decode(data); + const match = CONTENT_REGEX.exec(text); + if (!match) { + return data; + } + try { + const binary = atob(match[1].replace(/\s+/g, '')); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) { + bytes[index] = binary.charCodeAt(index); + } + return bytes.buffer; + } catch { + throw new Error('Invalid base64 file content in the download response'); + } +}; + class ReportExportCommand extends HttpCommand { constructor(http: Http) { super(http); @@ -132,14 +155,18 @@ class ReportExportCommand extends HttpCommand { return response.setData(entity); } - downloadReportExport({reportExportId}: ReportExportParams) { - return this.httpRequestWithRejectionTransform('get', { - args: { - cmd: 'download_report_export', - report_export_id: reportExportId, + async downloadReportExport({reportExportId}: ReportExportParams) { + const response = await this.httpRequestWithRejectionTransform( + 'get', + { + args: { + cmd: 'download_report_export', + report_export_id: reportExportId, + }, + responseType: 'arraybuffer', }, - responseType: 'arraybuffer', - }); + ); + return response.setData(decodeReportExportFile(response.data)); } cancelReportExport({reportExportId}: ReportExportParams) { diff --git a/src/gmp/models/report-export.ts b/src/gmp/models/report-export.ts index 1b9b41e03c..2d57858dac 100644 --- a/src/gmp/models/report-export.ts +++ b/src/gmp/models/report-export.ts @@ -21,6 +21,20 @@ const STATUSES = [ 'expired', ] as const; +// statuses of exports still being processed by gvmd +export const ACTIVE_REPORT_EXPORT_STATUSES = [ + 'pending', + 'running', + 'cancel_requested', +] as const; + +export const isReportExportStatusActive = ( + status?: string, +): status is (typeof ACTIVE_REPORT_EXPORT_STATUSES)[number] => + (ACTIVE_REPORT_EXPORT_STATUSES as readonly string[]).includes( + status as string, + ); + const PROGRESSES = ['queued', 'preparing', 'generating', 'completed'] as const; const REPORT_TYPES = ['scan', 'audit', 'delta_scan', 'delta_audit'] as const; @@ -172,4 +186,6 @@ class ReportExport extends BaseModel { } } +export type {ReportExport}; + export default ReportExport; diff --git a/src/web/components/form/Download.tsx b/src/web/components/form/Download.tsx index 43a3c7446e..48e65ca233 100644 --- a/src/web/components/form/Download.tsx +++ b/src/web/components/form/Download.tsx @@ -7,6 +7,7 @@ import React from 'react'; interface DownloadProps { filename?: string; + onClick?: React.MouseEventHandler; } export type DownloadData = string | ArrayBuffer; @@ -56,7 +57,7 @@ class Download extends React.Component { } render() { - const {filename} = this.props; + const {filename, onClick} = this.props; return ( { @@ -65,6 +66,7 @@ class Download extends React.Component { aria-hidden download={filename} style={{display: 'none'}} + onClick={onClick} > Download diff --git a/src/web/components/structure/Header.tsx b/src/web/components/structure/Header.tsx index a9cd1caf64..1186c584cb 100644 --- a/src/web/components/structure/Header.tsx +++ b/src/web/components/structure/Header.tsx @@ -6,6 +6,7 @@ import {useCallback} from 'react'; import {AppHeader} from '@greenbone/ui-lib'; import {useLocation, useNavigate} from 'react-router'; +import styled from 'styled-components'; import {LogoutIcon, MySettingsIcon} from 'web/components/icon'; import SessionTimer from 'web/components/session-timer/SessionTimer'; import getLogo from 'web/components/structure/GetLogo'; @@ -16,10 +17,25 @@ import useTranslation from 'web/hooks/useTranslation'; import useUserIsLoggedIn from 'web/hooks/useUserIsLoggedIn'; import useUserName from 'web/hooks/useUserName'; import useUserTimezone from 'web/hooks/useUserTimezone'; +import {ReportExportActivity} from 'web/report-export/ReportExportActivity'; import {ROUTES} from 'web/route-paths'; import {type ApplianceLogo} from 'web/utils/appliance-data'; import {saveLastVisitedPage} from 'web/utils/user-last-visited-page'; +const HeaderTools = styled.div` + align-items: center; + display: flex; + gap: 8px; + + @media (max-width: 600px) { + min-width: 32px; + + > :first-child { + display: none; + } + } +`; + const Header = () => { const [_] = useTranslation(); @@ -70,7 +86,12 @@ const Header = () => { logoLink={ROUTES.dashboards.url} manualLink={manualURL} menuPoints={menuPoints} - sessionTimer={} + sessionTimer={ + + + + + } userNavWidth={150} username={username} /> diff --git a/src/web/components/structure/__tests__/Header.test.tsx b/src/web/components/structure/__tests__/Header.test.tsx index bc932b0727..f12862bd14 100644 --- a/src/web/components/structure/__tests__/Header.test.tsx +++ b/src/web/components/structure/__tests__/Header.test.tsx @@ -5,9 +5,11 @@ import {describe, test, expect, testing} from '@gsa/testing'; import {fireEvent, rendererWith, screen, waitFor} from 'web/testing'; +import CollectionCounts from 'gmp/collection/collection-counts'; import date from 'gmp/models/date'; import {createSession} from 'gmp/testing'; import Header from 'web/components/structure/Header'; +import ReportExportManager from 'web/report-export/ReportExportManager'; const createGmp = () => ({ settings: { @@ -19,8 +21,21 @@ const createGmp = () => ({ sessionTimeout: date(Date.now() + 3600 * 1000), }), doLogout: testing.fn().mockResolvedValue(undefined), + reportexport: { + getReportExports: testing.fn().mockResolvedValue({ + data: [], + meta: {counts: new CollectionCounts({first: 1})}, + }), + }, }); +const renderHeader = (render: (element: React.ReactElement) => unknown) => + render( + +
+ , + ); + describe('Header tests', () => { test('renders component', async () => { const {render} = rendererWith({ @@ -28,7 +43,7 @@ describe('Header tests', () => { router: true, }); - render(
); + renderHeader(render); expect(screen.getByText('UTC')).toBeVisible(); @@ -72,7 +87,7 @@ describe('Header tests', () => { router: true, }); - render(
); + renderHeader(render); const settingsBtn = screen.getByText('testUser'); fireEvent.mouseOver(settingsBtn); diff --git a/src/web/pages/Page.tsx b/src/web/pages/Page.tsx index 523c90125f..ab27483beb 100644 --- a/src/web/pages/Page.tsx +++ b/src/web/pages/Page.tsx @@ -20,6 +20,7 @@ import useGmp from 'web/hooks/useGmp'; import useLoadCapabilities from 'web/hooks/useLoadCapabilities'; import useLoadFeatures from 'web/hooks/useLoadFeatures'; import useTranslation from 'web/hooks/useTranslation'; +import ReportExportManager from 'web/report-export/ReportExportManager'; import Theme from 'web/utils/theme'; interface PageProps { @@ -70,31 +71,33 @@ const Page = ({children}: PageProps) => { return ( -
- - - - - - {isDefined(gmp.settings.vendorVersion) - ? gmp.settings.vendorVersion - : _('Version {{version}}', {version: GSA_VERSION})} - - - -
- - - - {children} - - -
-
- + +
+ + + + + + {isDefined(gmp.settings.vendorVersion) + ? gmp.settings.vendorVersion + : _('Version {{version}}', {version: GSA_VERSION})} + + + +
+ + + + {children} + + +
+
+ + ); diff --git a/src/web/pages/reports/AuditDeltaReportDetailsPage.jsx b/src/web/pages/reports/AuditDeltaReportDetailsPage.jsx index 027d28baa5..9e65cb16ff 100644 --- a/src/web/pages/reports/AuditDeltaReportDetailsPage.jsx +++ b/src/web/pages/reports/AuditDeltaReportDetailsPage.jsx @@ -16,7 +16,6 @@ import QueryFilter from 'gmp/models/filter/query-filter'; import {isActive} from 'gmp/models/task'; import {first} from 'gmp/utils/array'; import {isDefined, hasValue} from 'gmp/utils/identity'; -import withDownload from 'web/components/form/withDownload'; import Reload, { NO_RELOAD, USE_DEFAULT_RELOAD_INTERVAL_ACTIVE, @@ -29,6 +28,9 @@ import DeltaReportDetailsContent from 'web/pages/reports/DeltaReportDetailsConte import DownloadReportDialog from 'web/pages/reports/DownloadReportDialog'; import ReportDetailsFilterDialog from 'web/pages/reports/ReportDetailsFilterDialog'; import TargetComponent from 'web/pages/targets/TargetComponent'; +import {buildReportExportRequest} from 'web/report-export/build-report-export-request'; +import {useReportExportManager} from 'web/report-export/ReportExportManager'; +import {ROUTES} from 'web/route-paths'; import { loadAllEntities as loadFilters, selector as filterSelector, @@ -53,7 +55,6 @@ import {getUserSettingsDefaults} from 'web/store/usersettings/defaults/selectors import {getReportComposerDefaults} from 'web/store/usersettings/selectors'; import compose from 'web/utils/compose'; import PropTypes from 'web/utils/prop-types'; -import {generateFilename} from 'web/utils/Render'; const log = logger.getLogger('web.pages.report.deltadetailspage'); @@ -72,6 +73,9 @@ const getTarget = (entity = {}) => { }; const AuditDeltaReportDetails = props => { + const params = useParams(); + const {id: reportId, deltaid: deltaReportId} = params; + const [showFilterDialog, setShowFilterDialog] = useState(false); const [showDownloadReportDialog, setShowDownloadReportDialog] = useState(false); @@ -79,7 +83,7 @@ const AuditDeltaReportDetails = props => { const [isUpdating, setIsUpdating] = useState(false); // storeAsDefault is set in SaveDialogContent // eslint-disable-next-line no-unused-vars - const [storeAsDefault, setStoreAsDefault] = useState(); + const [storeAsDefault] = useState(); const [sorting, setSorting] = useState({ results: { @@ -87,6 +91,10 @@ const AuditDeltaReportDetails = props => { sortReverse: true, }, errors: { + reportUrl: + reportId && deltaReportId + ? ROUTES.auditReportDelta.url(reportId, deltaReportId) + : undefined, sortField: 'error', sortReverse: false, }, @@ -95,8 +103,6 @@ const AuditDeltaReportDetails = props => { const [_] = useTranslation(); const gmp = useGmp(); const dispatch = useDispatch(); - const params = useParams(); - const {id: reportId, deltaid: deltaReportId} = params; const reportFormatsSel = useSelector(reportFormatsSelector); const reportConfigsSel = useSelector(reportConfigsSelector); @@ -127,6 +133,8 @@ const AuditDeltaReportDetails = props => { }); const isLoading = !isDefined(entity); + const {exportReport} = useReportExportManager(); + useEffect(() => { dispatch(loadUserSettingDefaults(gmp)()); dispatch(loadFilters(gmp)(RESULTS_FILTER_FILTER)); @@ -258,7 +266,7 @@ const AuditDeltaReportDetails = props => { }; const handleReportDownload = state => { - const {reportFilter, onDownload} = props; + const {reportFilter} = props; const {includeNotes, includeOverrides, reportFormatId, storeAsDefault} = state; @@ -277,37 +285,27 @@ const AuditDeltaReportDetails = props => { dispatch(saveReportComposerDefaults(gmp)(defaults)); } - const report_format = reportFormats - ? reportFormats.find(format => reportFormatId === format.id) + const reportFormat = reportFormats + ? reportFormats.find(format => format.id === reportFormatId) : undefined; - const extension = isDefined(report_format) - ? report_format.extension - : 'unknown'; // unknown should never happen but we should be save here - - return gmp.auditreport - .download(entity, { - reportFormatId, - deltaReportId, - filter: newFilter, - }) - .then(response => { - setShowDownloadReportDialog(false); - const {data} = response; - const filename = generateFilename({ - creationTime: entity.creationTime, - extension, - fileNameFormat: reportExportFileName, - id: entity.id, - modificationTime: entity.modificationTime, - reportFormat: report_format?.name, - resourceName: entity.task.name, - resourceType: 'report', - username, - }); - - onDownload({filename, data}); - }, handleError); + const request = buildReportExportRequest({ + kind: 'delta_audit', + entity, + format: reportFormat ?? {id: reportFormatId}, + filter: newFilter, + deltaReportId, + reportUrl: + reportId && deltaReportId + ? ROUTES.auditReportDelta.url(reportId, deltaReportId) + : undefined, + fileNameFormat: reportExportFileName, + username, + fallbackReportTitle: _('Report'), + }); + return exportReport(request).then(started => { + if (started) setShowDownloadReportDialog(false); + }); }; const handleFilterCreated = filter => { @@ -425,7 +423,6 @@ AuditDeltaReportDetails.propTypes = { showSuccessMessage: PropTypes.func.isRequired, target: PropTypes.model, username: PropTypes.string, - onDownload: PropTypes.func.isRequired, }; const reloadInterval = report => @@ -512,7 +509,4 @@ DeltaAuditReportDetailsWrapper.propTypes = { defaultFilter: PropTypes.filter, }; -export default compose( - withDialogNotification, - withDownload, -)(DeltaAuditReportDetailsWrapper); +export default compose(withDialogNotification)(DeltaAuditReportDetailsWrapper); diff --git a/src/web/pages/reports/AuditReportDetailsPage.tsx b/src/web/pages/reports/AuditReportDetailsPage.tsx index 840840c57a..3e22eed497 100644 --- a/src/web/pages/reports/AuditReportDetailsPage.tsx +++ b/src/web/pages/reports/AuditReportDetailsPage.tsx @@ -37,8 +37,10 @@ import DownloadReportDialog, { } from 'web/pages/reports/DownloadReportDialog'; import ReportDetailsFilterDialog from 'web/pages/reports/ReportDetailsFilterDialog'; import TargetComponent from 'web/pages/targets/TargetComponent'; +import {buildReportExportRequest} from 'web/report-export/build-report-export-request'; +import {useReportExportManager} from 'web/report-export/ReportExportManager'; +import {ROUTES} from 'web/route-paths'; import {createPEMCertificate} from 'web/utils/certificates'; -import {generateFilename} from 'web/utils/Render'; interface ReportComposerDefaults { defaultReportFormatId?: string; @@ -46,6 +48,13 @@ interface ReportComposerDefaults { includeOverrides?: boolean; } +interface DownloadReportState { + includeNotes: boolean; + includeOverrides: boolean; + reportFormatId: string; + storeAsDefault: boolean; +} + interface ReportTargetRef { id: string; } @@ -106,6 +115,7 @@ const AuditReportDetailsPage = () => { useState(false); const [reportComposerDefaults, setReportComposerDefaults] = useState({}); + const {exportReport} = useReportExportManager(); // Filter management const [pageFilter, , {changeFilter}] = usePageFilter( @@ -280,13 +290,12 @@ const AuditReportDetailsPage = () => { }, []); const handleReportDownload = useCallback( - async ({ - includeNotes, - includeOverrides, - reportFormatId, - storeAsDefault, - }: DownloadReportDialogValues) => { + async (values: DownloadReportDialogValues) => { + const state = values as unknown as DownloadReportState; if (!entity || !reportFilter) return; + const {includeNotes, includeOverrides, reportFormatId, storeAsDefault} = + state; + const newFilter = reportFilter .set('notes', includeNotes) .set('overrides', includeOverrides); @@ -306,40 +315,22 @@ const AuditReportDetailsPage = () => { } } - const reportFormat = reportFormats?.find( - format => reportFormatId === format.id, - ); - - const extension = isDefined(reportFormat) - ? reportFormat.extension - : 'unknown'; - try { - const [response, reportExportFileName] = await Promise.all([ - auditreport.download( - {id: entity.id}, - { - reportFormatId, - filter: newFilter, - }, - ), - reportExportFileNameQuery(), - ]); - setShowDownloadReportDialog(false); - const {data} = response; - const filename = generateFilename({ - creationTime: entity.creationTime, - extension, + const reportFormat = reportFormats?.find( + format => format.id === reportFormatId, + ); + const reportExportFileName = await reportExportFileNameQuery(); + const request = buildReportExportRequest({ + kind: 'audit', + entity, + format: reportFormat ?? {id: reportFormatId}, + filter: newFilter, + reportUrl: ROUTES.auditReport.url(reportId), fileNameFormat: reportExportFileName, - id: entity.id, - modificationTime: entity.modificationTime, - reportFormat: reportFormat?.name, - resourceName: entity.task?.name, - resourceType: 'report', username, + fallbackReportTitle: _('Report'), }); - - handleDownload({filename, data}); + if (await exportReport(request)) setShowDownloadReportDialog(false); } catch (error) { log.error(error); showError(error as Error); @@ -347,15 +338,16 @@ const AuditReportDetailsPage = () => { }, [ entity, - auditreport, gmp.user, - handleDownload, + reportId, reportComposerDefaults, reportFilter, reportFormats, + reportExportFileNameQuery, showError, + exportReport, username, - reportExportFileNameQuery, + _, ], ); diff --git a/src/web/pages/reports/DeltaDetailsPage.tsx b/src/web/pages/reports/DeltaDetailsPage.tsx index 63bc2c1a1c..89ed645f86 100644 --- a/src/web/pages/reports/DeltaDetailsPage.tsx +++ b/src/web/pages/reports/DeltaDetailsPage.tsx @@ -38,6 +38,9 @@ import DeltaReportDetailsContent from 'web/pages/reports/DeltaReportDetailsConte import DownloadReportDialog from 'web/pages/reports/DownloadReportDialog'; import ReportDetailsFilterDialog from 'web/pages/reports/ReportDetailsFilterDialog'; import TargetComponent from 'web/pages/targets/TargetComponent'; +import {buildReportExportRequest} from 'web/report-export/build-report-export-request'; +import {useReportExportManager} from 'web/report-export/ReportExportManager'; +import {ROUTES} from 'web/route-paths'; import { loadAllEntities as loadFilters, selector as filterSelector, @@ -62,7 +65,6 @@ import {getUserSettingsDefaultFilter} from 'web/store/usersettings/defaultfilter import {loadUserSettingDefaults} from 'web/store/usersettings/defaults/actions'; import {getUserSettingsDefaults} from 'web/store/usersettings/defaults/selectors'; import {getReportComposerDefaults} from 'web/store/usersettings/selectors'; -import {generateFilename} from 'web/utils/Render'; import {DESC} from 'web/utils/sort-direction'; interface UseReportStateParams { @@ -275,7 +277,8 @@ const DeltaReportDetails = () => { ); const [startTimer, clearTimer] = useReload(memoizedReloadFn, timeoutFunc); - const {downloadComponent, download: onDownload} = useDownload(); + const {downloadComponent} = useDownload(); + const {exportReport} = useReportExportManager(); const {dialogNotification, showError, showErrorMessage, showSuccessMessage} = useDialogNotification(); @@ -423,42 +426,29 @@ const DeltaReportDetails = () => { saveReportComposerDefaults(defaults); } - const report_format = reportFormats.find(f => chosenFormatId === f.id); - - const extension = report_format?.extension || 'unknown'; - if (!entity?.id) { throw new Error('Entity ID is undefined'); } try { - const response = await gmp.report.download( - {id: entity.id}, - { - reportConfigId, - reportFormatId: chosenFormatId, - deltaReportId, - filter: newFilter, - }, + const reportFormat = reportFormats.find( + format => format.id === chosenFormatId, ); - - setShowDownloadReportDialog(false); - - const {data} = response; - - const filename = generateFilename({ - creationTime: entity.creationTime, - extension, + const request = buildReportExportRequest({ + kind: 'delta_scan', + entity, + format: reportFormat ?? {id: chosenFormatId}, + filter: newFilter, + reportConfigId, + deltaReportId, + reportUrl: deltaReportId + ? ROUTES.reportDelta.url(reportId, deltaReportId) + : undefined, fileNameFormat: reportExportFileName, - id: entity.id, - modificationTime: entity.modificationTime, - reportFormat: report_format?.name, - resourceName: entity.task?.name, - resourceType: 'report', username, + fallbackReportTitle: _('Report'), }); - - onDownload({filename, data}); + if (await exportReport(request)) setShowDownloadReportDialog(false); } catch (error) { handleError(error as Error); } diff --git a/src/web/pages/reports/DownloadReportDialog.tsx b/src/web/pages/reports/DownloadReportDialog.tsx index 4286940a04..f95c474b37 100644 --- a/src/web/pages/reports/DownloadReportDialog.tsx +++ b/src/web/pages/reports/DownloadReportDialog.tsx @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import {useState} from 'react'; import {type FilterType} from 'gmp/models/filter'; import type ReportConfig from 'gmp/models/report-config'; import type ReportFormat from 'gmp/models/report-format'; @@ -76,20 +75,20 @@ const DownloadReportDialog = ({ selectSaveId(reportConfigs, defaultReportConfigId, '') ?? ''; } - const [reportFormatIdInState, setReportFormatIdInState] = useState( - selectSaveId(reportFormats, defaultReportFormatId), - ); - const [reportConfigIdInState, setReportConfigIdInState] = useState( - initialReportConfigId, - ); - - const handleReportFormatIdChange = (value: string) => { - setReportConfigIdInState(''); - setReportFormatIdInState(value); - }; - - const handleReportConfigIdChange = (value: string) => { - setReportConfigIdInState(value); + // The initial values are the only values of the dialog. Changing the + // format or config updates them via the onValueChange function of the + // SaveDialog render props. + const unControlledValues: DownloadReportDialogValues = { + includeNotes, + includeOverrides, + storeAsDefault, + reportConfigId: initialReportConfigId, + // reportFormatId can only be undefined on the initial render or if no report format is available + // in future it should not be allowed to save without a valid report format and then reportFormatId is always defined + reportFormatId: selectSaveId( + reportFormats, + defaultReportFormatId, + ) as string, }; const showContainerScanningWarning = @@ -109,16 +108,6 @@ const DownloadReportDialog = ({ }); }; - const unControlledValues: DownloadReportDialogValues = { - includeNotes, - includeOverrides, - storeAsDefault, - reportConfigId: reportConfigIdInState, - // reportFormatId can only be undefined on the initial render or if no report format is available - // in future it should not be allowed to save without a valid report format and then reportFormatId is always defined - reportFormatId: reportFormatIdInState as string, - }; - return ( { const filteredReportConfigs = isDefined(reportConfigs) ? reportConfigs.filter( - config => config.reportFormat?.id === reportFormatIdInState, + config => config.reportFormat?.id === values.reportFormatId, ) : []; const filteredReportFormats = getFilteredReportFormats(); + const handleReportFormatIdChange = (value: string) => { + // a changed format invalidates the selected report config + onValueChange('', 'reportConfigId'); + onValueChange(value, 'reportFormatId'); + }; + return ( <> @@ -171,9 +166,9 @@ const DownloadReportDialog = ({ '', )} name="reportConfigId" - value={reportConfigIdInState} + value={values.reportConfigId} width="auto" - onChange={handleReportConfigIdChange} + onChange={value => onValueChange(value, 'reportConfigId')} /> )} diff --git a/src/web/pages/reports/ReportDetailsPage.tsx b/src/web/pages/reports/ReportDetailsPage.tsx index 0663acf784..b89e49fbcb 100644 --- a/src/web/pages/reports/ReportDetailsPage.tsx +++ b/src/web/pages/reports/ReportDetailsPage.tsx @@ -39,8 +39,10 @@ import DownloadReportDialog, { import ReportDetailsContent from 'web/pages/reports/ReportDetailsContent'; import ReportDetailsFilterDialog from 'web/pages/reports/ReportDetailsFilterDialog'; import TargetComponent from 'web/pages/targets/TargetComponent'; +import {buildReportExportRequest} from 'web/report-export/build-report-export-request'; +import {useReportExportManager} from 'web/report-export/ReportExportManager'; +import {ROUTES} from 'web/route-paths'; import {createPEMCertificate} from 'web/utils/certificates'; -import {generateFilename} from 'web/utils/Render'; interface ReportComposerDefaults { defaultReportConfigId?: string; @@ -49,6 +51,14 @@ interface ReportComposerDefaults { includeOverrides?: boolean; } +interface DownloadReportState { + includeNotes: boolean; + includeOverrides: boolean; + reportConfigId: string; + reportFormatId: string; + storeAsDefault: boolean; +} + interface ReportTargetRef { id: string; } @@ -98,6 +108,7 @@ const ReportDetailsPage = () => { useState(false); const [reportComposerDefaults, setReportComposerDefaults] = useState({}); + const {exportReport} = useReportExportManager(); // Filter management const [pageFilter, , {changeFilter}] = usePageFilter( @@ -283,14 +294,17 @@ const ReportDetailsPage = () => { }, []); const handleReportDownload = useCallback( - async ({ - includeNotes, - includeOverrides, - reportConfigId, - reportFormatId, - storeAsDefault, - }: DownloadReportDialogValues) => { + async (values: DownloadReportDialogValues) => { + const state = values as unknown as DownloadReportState; if (!entity || !reportFilter) return; + const { + includeNotes, + includeOverrides, + reportConfigId, + reportFormatId, + storeAsDefault, + } = state; + const newFilter = reportFilter .set('notes', includeNotes) .set('overrides', includeOverrides); @@ -311,41 +325,28 @@ const ReportDetailsPage = () => { } } - const reportFormat = reportFormats?.find( - format => reportFormatId === format.id, - ); - - const extension = isDefined(reportFormat) - ? reportFormat.extension - : 'unknown'; - try { - const [response, reportExportFileName] = await Promise.all([ - gmp.report.download( - {id: entity.id}, - { - reportConfigId, - reportFormatId, - filter: newFilter, - }, - ), - reportExportFileNameQuery(), - ]); - setShowDownloadReportDialog(false); - const {data} = response; - const filename = generateFilename({ - creationTime: entity.creationTime, - extension, + const reportFormat = reportFormats?.find( + format => format.id === reportFormatId, + ); + const reportExportFileName = await reportExportFileNameQuery(); + const request = buildReportExportRequest({ + kind: 'scan', + entity: { + id: entity.id as string, + creationTime: entity.creationTime, + modificationTime: entity.modificationTime, + task: entity.task, + }, + format: reportFormat ?? {id: reportFormatId}, + filter: newFilter, + reportConfigId, + reportUrl: ROUTES.report.url(reportId), fileNameFormat: reportExportFileName, - id: entity.id as string, - modificationTime: entity.modificationTime, - reportFormat: reportFormat?.name, - resourceName: entity.task?.name, - resourceType: 'report', username, + fallbackReportTitle: _('Report'), }); - - handleDownload({filename, data}); + if (await exportReport(request)) setShowDownloadReportDialog(false); } catch (error) { log.error(error); showError(error as Error); @@ -354,13 +355,15 @@ const ReportDetailsPage = () => { [ entity, gmp, - handleDownload, + reportId, reportComposerDefaults, reportExportFileNameQuery, reportFilter, reportFormats, + exportReport, showError, username, + _, ], ); diff --git a/src/web/pages/reports/__tests__/AuditDeltaDetailsPage.test.jsx b/src/web/pages/reports/__tests__/AuditDeltaDetailsPage.test.jsx index fdeac13bcf..0b866f684e 100644 --- a/src/web/pages/reports/__tests__/AuditDeltaDetailsPage.test.jsx +++ b/src/web/pages/reports/__tests__/AuditDeltaDetailsPage.test.jsx @@ -5,12 +5,15 @@ import {describe, expect, test, testing} from '@gsa/testing'; import {fireEvent, rendererWith, screen, within} from 'web/testing'; +import {Route, Routes} from 'react-router'; import CollectionCounts from 'gmp/collection/collection-counts'; import Filter from 'gmp/models/filter'; import QueryFilter from 'gmp/models/filter/query-filter'; import {createSession} from 'gmp/testing'; import {getMockAuditDeltaReport} from 'web/pages/reports/__fixtures__/MockAuditDeltaReport'; +import AuditDeltaReportDetailsPage from 'web/pages/reports/AuditDeltaReportDetailsPage'; import DeltaReportDetailsContent from 'web/pages/reports/DeltaReportDetailsContent'; +import ReportExportManager from 'web/report-export/ReportExportManager'; const filter = QueryFilter.fromString( 'apply_overrides=0 compliance_levels=ynui rows=10 min_qod=70 first=1 sort=compliant', @@ -68,6 +71,73 @@ const createGmp = ({reportResultsThreshold = 10} = {}) => ({ }, }), }, + filters: { + get: testing.fn().mockResolvedValue({ + data: [], + meta: { + filter: QueryFilter.fromString(), + counts: new CollectionCounts(), + }, + }), + }, + reportconfigs: { + get: testing.fn().mockResolvedValue({ + data: [], + meta: { + filter: QueryFilter.fromString(), + counts: new CollectionCounts(), + }, + }), + }, + reportformats: { + get: testing.fn().mockResolvedValue({ + data: [], + meta: { + filter: QueryFilter.fromString(), + counts: new CollectionCounts(), + }, + }), + }, + auditreport: { + getDelta: testing.fn().mockReturnValue(new Promise(() => {})), + }, + reportexport: { + getReportExports: testing.fn().mockResolvedValue({ + data: [], + meta: {counts: new CollectionCounts({first: 1})}, + }), + }, +}); + +describe('AuditDeltaReportDetailsPage', () => { + test('loads the delta report IDs from the route', () => { + const reportId = '5567b85d-bf96-4e0e-8edf-c3b3b213e849'; + const deltaReportId = 'baec34db-a7ce-4a08-856c-edb5569587d6'; + const gmp = createGmp(); + const {render} = rendererWith({ + gmp, + capabilities: true, + router: true, + route: `/audit-report/delta/${reportId}/${deltaReportId}`, + }); + + render( + + + } + path="/audit-report/delta/:id/:deltaid" + /> + + , + ); + + expect(gmp.auditreport.getDelta).toHaveBeenCalledWith( + {id: reportId}, + {id: deltaReportId}, + expect.anything(), + ); + }); }); describe('AuditDeltaDetailsContent tests', () => { diff --git a/src/web/pages/reports/__tests__/AuditReportDetailsPage.test.tsx b/src/web/pages/reports/__tests__/AuditReportDetailsPage.test.tsx index 47eb259c54..4a1760e523 100644 --- a/src/web/pages/reports/__tests__/AuditReportDetailsPage.test.tsx +++ b/src/web/pages/reports/__tests__/AuditReportDetailsPage.test.tsx @@ -5,15 +5,25 @@ import {describe, expect, test, testing} from '@gsa/testing'; import {fireEvent, rendererWith, screen, waitFor, within} from 'web/testing'; +import {showErrorNotification} from '@greenbone/ui-lib'; +import type * as UiLib from '@greenbone/ui-lib'; import {Route, Routes} from 'react-router'; +import {vi} from 'vitest'; import CollectionCounts from 'gmp/collection/collection-counts'; import {ROWS_PER_PAGE_SETTING_ID} from 'gmp/commands/user'; import type FilterType from 'gmp/models/filter/filter-type'; import QueryFilter from 'gmp/models/filter/query-filter'; +import ReportFormat from 'gmp/models/report-format'; import {createSession} from 'gmp/testing'; import {currentSettingsDefaultResponse} from 'web/pages/__fixtures__/current-settings'; import {getMockAuditReport} from 'web/pages/reports/__fixtures__/MockAuditReport'; import AuditReportDetailsPage from 'web/pages/reports/AuditReportDetailsPage'; +import ReportExportManager from 'web/report-export/ReportExportManager'; + +vi.mock('@greenbone/ui-lib', async importOriginal => ({ + ...(await importOriginal()), + showErrorNotification: vi.fn(), +})); interface CollectionResponse { data: unknown[]; @@ -89,6 +99,17 @@ const createGmp = () => ({ reportformats: { get: testing.fn().mockResolvedValue(emptyCollectionResponse), }, + reportexport: { + exportAuditReport: testing + .fn() + .mockResolvedValue({data: {id: 'export-uuid'}}), + get: testing.fn().mockResolvedValue({ + data: {id: 'export-uuid', status: 'running', progress: 'generating'}, + }), + downloadReportExport: testing + .fn() + .mockResolvedValue({data: new ArrayBuffer(8)}), + }, auditreport: { get: testing.fn().mockResolvedValue({data: entity}), addAssets: testing.fn().mockResolvedValue({}), @@ -148,9 +169,11 @@ const setupRenderer = (gmp = createGmp()) => { const renderPage = (render: ReturnType['render']) => render( - - } path="/audit-report/:id" /> - , + + + } path="/audit-report/:id" /> + + , ); describe('AuditReportDetailsPage', () => { @@ -388,8 +411,19 @@ describe('AuditReportDetailsPage', () => { }); describe('Report download flow', () => { - test('should call auditreport download when download dialog OK is clicked', async () => { + test('uses async export for an XML audit report format', async () => { const gmp = createGmp(); + gmp.reportformats.get.mockResolvedValue({ + data: [ + new ReportFormat({ + id: 'xml-format', + name: 'XML', + extension: 'xml', + content_type: 'text/xml', + }), + ], + meta: emptyCollectionResponse.meta, + }); const {render} = setupRenderer(gmp); renderPage(render); @@ -404,8 +438,48 @@ describe('AuditReportDetailsPage', () => { fireEvent.click(screen.getByRole('button', {name: 'OK'})); await waitFor(() => { - expect(gmp.auditreport.download).toHaveBeenCalled(); + expect(gmp.reportexport.exportAuditReport).toHaveBeenCalledWith( + expect.objectContaining({ + reportId: entity.id, + reportFormatId: 'xml-format', + }), + ); + }); + expect(gmp.auditreport.download).not.toHaveBeenCalled(); + }); + + test('uses async export for a PDF audit report format', async () => { + const gmp = createGmp(); + gmp.reportformats.get.mockResolvedValue({ + data: [ + new ReportFormat({ + id: 'pdf-format', + name: 'PDF', + extension: 'pdf', + content_type: 'application/pdf', + }), + ], + meta: emptyCollectionResponse.meta, + }); + + const {render} = setupRenderer(gmp); + renderPage(render); + await screen.findByTitle(/^Download filtered Report/); + fireEvent.click(screen.getByTitle(/^Download filtered Report/)); + await screen.findByRole('heading', { + name: /Compose Content for Compliance Report/, + }); + fireEvent.click(screen.getByRole('button', {name: 'OK'})); + + await waitFor(() => { + expect(gmp.reportexport.exportAuditReport).toHaveBeenCalledWith( + expect.objectContaining({ + reportId: entity.id, + reportFormatId: 'pdf-format', + }), + ); }); + expect(gmp.auditreport.download).not.toHaveBeenCalled(); }); test('should close download dialog after successful download', async () => { @@ -432,10 +506,10 @@ describe('AuditReportDetailsPage', () => { }); }); - test('should show error when download fails', async () => { + test('shows an error notification when the export creation fails', async () => { testing.spyOn(console, 'error').mockImplementation(() => {}); const gmp = createGmp(); - gmp.auditreport.download = testing + gmp.reportexport.exportAuditReport = testing .fn() .mockRejectedValue(new Error('Download failed')); @@ -451,7 +525,11 @@ describe('AuditReportDetailsPage', () => { fireEvent.click(screen.getByRole('button', {name: 'OK'})); - await screen.findByText(/Download failed/); + // the creation failure is surfaced via the activity manager's + // error notification + await waitFor(() => + expect(showErrorNotification).toHaveBeenCalledWith('Download failed'), + ); }); }); diff --git a/src/web/pages/reports/__tests__/DownloadReportDialog.test.tsx b/src/web/pages/reports/__tests__/DownloadReportDialog.test.tsx index 2ec7948812..13c5098b04 100644 --- a/src/web/pages/reports/__tests__/DownloadReportDialog.test.tsx +++ b/src/web/pages/reports/__tests__/DownloadReportDialog.test.tsx @@ -4,11 +4,14 @@ */ import {describe, test, expect, testing} from '@gsa/testing'; -import {render, screen} from 'web/testing'; +import {render, screen, fireEvent, waitFor} from 'web/testing'; import QueryFilter from 'gmp/models/filter/query-filter'; +import ReportConfig from 'gmp/models/report-config'; import ReportFormat from 'gmp/models/report-format'; import {CONTAINER_SCANNING_RESULTS_THRESHOLD} from 'gmp/settings'; import DownloadReportDialog from 'web/pages/reports/DownloadReportDialog'; +import {getSelectItemElementsForSelect} from 'web/testing/actions'; +import {getSelectElement} from 'web/testing/custom-queries'; const filter = QueryFilter.fromString('rows=100'); @@ -26,6 +29,69 @@ const defaultProps = { }; describe('DownloadReportDialog', () => { + test('submits the picked report format instead of the initial one', async () => { + const onSave = testing.fn().mockResolvedValue(undefined); + render(); + + const select = getSelectElement(); + const items = await getSelectItemElementsForSelect(select); + fireEvent.click(items[1]); // pick CSV instead of the default PDF + + fireEvent.click(screen.getByTestId('dialog-save-button')); + + await waitFor(() => + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({reportFormatId: 'rf-2'}), + ), + ); + }); + + test('resets the report config when the report format changes', async () => { + const reportConfigs = [ + ReportConfig.fromElement({ + _id: 'config-1', + name: 'Config 1', + report_format: {_id: 'rf-1'}, + }), + ReportConfig.fromElement({ + _id: 'config-2', + name: 'Config 2', + report_format: {_id: 'rf-2'}, + }), + ]; + const onSave = testing.fn().mockResolvedValue(undefined); + render( + , + ); + + const selects = screen.getAllByTestId('form-select'); + const configSelect = await getSelectItemElementsForSelect( + selects[1] as HTMLSelectElement, + ); + fireEvent.click(configSelect[1]); // pick Config 2 for the default format + + const formatItems = await getSelectItemElementsForSelect( + selects[0] as HTMLSelectElement, + ); + fireEvent.click(formatItems[1]); // switch the format to CSV + + fireEvent.click(screen.getByTestId('dialog-save-button')); + + await waitFor(() => + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + reportFormatId: 'rf-2', + reportConfigId: '', + }), + ), + ); + }); + test('should render the dialog with scan report title', () => { render(); diff --git a/src/web/pages/reports/__tests__/ReportDetailsPage.test.tsx b/src/web/pages/reports/__tests__/ReportDetailsPage.test.tsx index 1c3d06eb84..b62e6d4103 100644 --- a/src/web/pages/reports/__tests__/ReportDetailsPage.test.tsx +++ b/src/web/pages/reports/__tests__/ReportDetailsPage.test.tsx @@ -5,18 +5,29 @@ import {describe, test, expect, testing} from '@gsa/testing'; import {rendererWith, fireEvent, screen, within, waitFor} from 'web/testing'; +import {showErrorNotification} from '@greenbone/ui-lib'; +import type * as UiLib from '@greenbone/ui-lib'; import {Route, Routes} from 'react-router'; +import {vi} from 'vitest'; import CollectionCounts from 'gmp/collection/collection-counts'; import {ROWS_PER_PAGE_SETTING_ID} from 'gmp/commands/user'; import type FilterType from 'gmp/models/filter/filter-type'; import QueryFilter from 'gmp/models/filter/query-filter'; import Report from 'gmp/models/report'; +import ReportFormat from 'gmp/models/report-format'; import {OPENVASD_SCANNER_TYPE} from 'gmp/models/scanner'; import {TASK_STATUS} from 'gmp/models/task'; import {createSession} from 'gmp/testing'; import {currentSettingsDefaultResponse} from 'web/pages/__fixtures__/current-settings'; import {getMockReport} from 'web/pages/reports/__fixtures__/MockReport'; import ReportDetailsPage from 'web/pages/reports/ReportDetailsPage'; +import {ReportExportActivity} from 'web/report-export/ReportExportActivity'; +import ReportExportManager from 'web/report-export/ReportExportManager'; + +vi.mock('@greenbone/ui-lib', async importOriginal => ({ + ...(await importOriginal()), + showErrorNotification: vi.fn(), +})); interface CollectionResponse { data: unknown[]; @@ -91,6 +102,24 @@ const createGmp = () => ({ reportformats: { get: testing.fn().mockResolvedValue(emptyCollectionResponse), }, + reportexport: { + exportScanReport: testing + .fn() + .mockResolvedValue({data: {id: 'export-uuid'}}), + get: testing.fn().mockResolvedValue({ + data: {id: 'export-uuid', status: 'running', progress: 'generating'}, + }), + downloadReportExport: testing + .fn() + .mockResolvedValue({data: new ArrayBuffer(8)}), + cancelReportExport: testing.fn().mockResolvedValue({}), + }, + reportexports: { + get: testing.fn().mockResolvedValue({ + data: [], + meta: {counts: new CollectionCounts({first: 1, rows: 100})}, + }), + }, report: { get: testing.fn().mockResolvedValue({data: entity}), addAssets: testing.fn().mockResolvedValue({}), @@ -154,9 +183,11 @@ const setupRenderer = (gmp = createGmp()) => { const renderPage = (render: ReturnType['render']) => render( - - } path="/report/:id" /> - , + + + } path="/report/:id" /> + + , ); describe('ReportDetailsPage tests', () => { @@ -451,8 +482,19 @@ describe('ReportDetailsPage tests', () => { }); describe('Report download flow', () => { - test('should call report download when download dialog OK is clicked', async () => { + test('uses async export for an XML report format', async () => { const gmp = createGmp(); + gmp.reportformats.get.mockResolvedValue({ + data: [ + new ReportFormat({ + id: 'xml-format', + name: 'XML', + extension: 'xml', + content_type: 'text/xml', + }), + ], + meta: emptyCollectionResponse.meta, + }); const {render} = setupRenderer(gmp); renderPage(render); @@ -467,8 +509,132 @@ describe('ReportDetailsPage tests', () => { fireEvent.click(screen.getByRole('button', {name: 'OK'})); await waitFor(() => { - expect(gmp.report.download).toHaveBeenCalled(); + expect(gmp.reportexport.exportScanReport).toHaveBeenCalledWith( + expect.objectContaining({ + reportId: entity.id, + reportFormatId: 'xml-format', + }), + ); + }); + expect(gmp.report.download).not.toHaveBeenCalled(); + }); + + test('uses async export for a PDF report format', async () => { + const gmp = createGmp(); + gmp.reportformats.get.mockResolvedValue({ + data: [ + new ReportFormat({ + id: 'pdf-format', + name: 'PDF', + extension: 'pdf', + content_type: 'application/pdf; charset=binary', + }), + ], + meta: emptyCollectionResponse.meta, + }); + + const {render} = setupRenderer(gmp); + renderPage(render); + await screen.findByTitle(/^Download filtered Report/); + fireEvent.click(screen.getByTitle(/^Download filtered Report/)); + await screen.findByRole('heading', { + name: /Compose Content for Scan Report/, + }); + fireEvent.click(screen.getByRole('button', {name: 'OK'})); + + await waitFor(() => { + expect(gmp.reportexport.exportScanReport).toHaveBeenCalledWith( + expect.objectContaining({ + reportId: entity.id, + reportFormatId: 'pdf-format', + }), + ); }); + expect(gmp.report.download).not.toHaveBeenCalled(); + }); + + test('allows retrying a PDF export after canceling it', async () => { + const gmp = createGmp(); + let exportCount = 0; + const exportStatuses: Record = {}; + gmp.reportformats.get.mockResolvedValue({ + data: [ + new ReportFormat({ + id: 'pdf-format', + name: 'PDF', + extension: 'pdf', + content_type: 'application/pdf; charset=binary', + }), + ], + meta: emptyCollectionResponse.meta, + }); + gmp.reportexport.exportScanReport.mockImplementation(async () => { + const id = `export-${++exportCount}`; + exportStatuses[id] = exportCount === 1 ? 'running' : 'done'; + return {data: {id}}; + }); + gmp.reportexport.get = testing + .fn() + .mockImplementation( + async ({reportExportId}: {reportExportId: string}) => ({ + data: { + id: reportExportId, + status: exportStatuses[reportExportId], + progress: 'generating', + owner: {name: 'admin'}, + }, + }), + ); + + const {render} = setupRenderer(gmp); + render( + + <> + + + } path="/report/:id" /> + + + , + ); + + await screen.findByTitle(/^Download filtered Report/); + fireEvent.click(screen.getByTitle(/^Download filtered Report/)); + await screen.findByRole('heading', { + name: /Compose Content for Scan Report/, + }); + fireEvent.click(screen.getByRole('button', {name: 'OK'})); + await waitFor(() => + expect(gmp.reportexport.exportScanReport).toHaveBeenCalledTimes(1), + ); + + const activityPopover = await screen.findByTestId( + 'report-export-activity-popover', + ); + await within(activityPopover).findByText('Generating'); + exportStatuses['export-1'] = 'canceled'; + fireEvent.click( + within(activityPopover).getByRole('button', { + name: 'Cancel report export', + hidden: true, + }), + ); + await within(activityPopover).findByText('Canceled'); + + fireEvent.click(screen.getByTitle(/^Download filtered Report/)); + await screen.findByRole('heading', { + name: /Compose Content for Scan Report/, + }); + fireEvent.click(screen.getByRole('button', {name: 'OK'})); + + await waitFor(() => { + expect(gmp.reportexport.exportScanReport).toHaveBeenCalledTimes(2); + expect(gmp.reportexport.downloadReportExport).toHaveBeenCalledTimes(1); + }); + // the canceled row stays visible until it is removed; the fresh + // export downloads automatically + expect(within(activityPopover).getByText('Canceled')).toBeInTheDocument(); + expect(within(activityPopover).getByText('Complete')).toBeInTheDocument(); }); test('should close download dialog after successful download', async () => { @@ -495,10 +661,10 @@ describe('ReportDetailsPage tests', () => { }); }); - test('should show error when download fails', async () => { + test('shows an error notification when the export creation fails', async () => { testing.spyOn(console, 'error').mockImplementation(() => {}); const gmp = createGmp(); - gmp.report.download = testing + gmp.reportexport.exportScanReport = testing .fn() .mockRejectedValue(new Error('Download failed')); @@ -514,7 +680,11 @@ describe('ReportDetailsPage tests', () => { fireEvent.click(screen.getByRole('button', {name: 'OK'})); - await screen.findByText(/Download failed/); + // the creation failure is surfaced via the activity manager's + // error notification + await waitFor(() => + expect(showErrorNotification).toHaveBeenCalledWith('Download failed'), + ); }); }); diff --git a/src/web/report-export/ReportExportActivity.tsx b/src/web/report-export/ReportExportActivity.tsx new file mode 100644 index 0000000000..fcc46138ca --- /dev/null +++ b/src/web/report-export/ReportExportActivity.tsx @@ -0,0 +1,94 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {Popover} from '@mantine/core'; +import {DownloadIcon, TrashcanIcon, XIcon} from 'web/components/icon'; +import Column from 'web/components/layout/Column'; +import Row from 'web/components/layout/Row'; +import useTranslation from 'web/hooks/useTranslation'; +import { + ActivityDot, + ActivityTitle, + ActivityTrigger, +} from 'web/report-export/report-export-activity-styles'; +import {isReportExportJobFinished} from 'web/report-export/report-export-job-model'; +import ReportExportJobRow from 'web/report-export/ReportExportJobRow'; +import useReportExportActivity from 'web/report-export/useReportExportActivity'; +import Theme from 'web/utils/theme'; + +export const ReportExportActivity = () => { + const [_] = useTranslation(); + + const { + activityOpen, + changeActivityOpen, + clearFinished, + indicatorState, + shouldRenderActivity, + visibleJobs, + } = useReportExportActivity(); + + if (!shouldRenderActivity) return null; + + return ( + + + changeActivityOpen(!activityOpen)} + > + + + + + + + + + {_('Report exports ({{count}})', {count: visibleJobs.length})} + + + {visibleJobs.some(isReportExportJobFinished) && ( + clearFinished()} + /> + )} + changeActivityOpen(false)} + /> + + + {visibleJobs.map(job => ( + + ))} + + + + ); +}; diff --git a/src/web/report-export/ReportExportJobRow.tsx b/src/web/report-export/ReportExportJobRow.tsx new file mode 100644 index 0000000000..210207ac59 --- /dev/null +++ b/src/web/report-export/ReportExportJobRow.tsx @@ -0,0 +1,193 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import DateTime from 'web/components/date/DateTime'; +import { + AlertCircleIcon, + CheckIcon, + CircleXDeleteIcon, + DownloadIcon, + RefreshIcon, + ScheduleIcon, + XIcon, +} from 'web/components/icon'; +import Column from 'web/components/layout/Column'; +import Row from 'web/components/layout/Row'; +import Link from 'web/components/link/Link'; +import useTranslation from 'web/hooks/useTranslation'; +import { + BADGE_COLORS, + BADGE_ICON_SIZE, + ActivityDivider, + ActivityErrorText, + ActivityJob, + ActivityMetadataSeparator, + ActivityStatusDetail, + ActivityTimestamp, + ActivityTitle, + Spinning, + StatusBadge, + StatusIcon, +} from 'web/report-export/report-export-activity-styles'; +import { + type ReportExportStatusTone, + getReportExportCancelTitle, + getReportExportJobDisplay, + getReportExportJobTitle, +} from 'web/report-export/report-export-job-display'; +import { + getReportExportActions, + type ReportExportJob, +} from 'web/report-export/report-export-job-model'; +import {useReportExportManager} from 'web/report-export/ReportExportManager'; +import Theme from 'web/utils/theme'; +import {formattedUserSettingLongDate} from 'web/utils/user-setting-time-date-formatters'; + +const getActivityStatusIcon = (tone: ReportExportStatusTone) => { + switch (tone) { + case 'active': + return ( + + + + ); + case 'queued': + return ; + case 'downloading': + return ; + case 'canceled': + return ; + case 'error': + return ; + case 'complete': + return ; + } +}; + +interface ReportExportJobRowProps { + job: ReportExportJob; +} + +const ReportExportJobRow = ({job}: ReportExportJobRowProps) => { + const [_] = useTranslation(); + const {supportsCancellation, cancel, dismiss, retry} = + useReportExportManager(); + const actions = getReportExportActions(job); + const canCancel = supportsCancellation && actions.cancel; + const status = getReportExportJobDisplay(job, _); + const cancelTitle = getReportExportCancelTitle(job, _); + const actionButtons = [ + { + visible: canCancel, + element: ( + cancel(job.key)} + /> + ), + }, + { + visible: actions.retry, + element: ( + retry(job.key)} + /> + ), + }, + { + visible: actions.dismiss, + element: ( + dismiss(job.key)} + /> + ), + }, + ] + .filter(({visible}) => visible) + .map(({element}) => element); + + return ( + + + + + + + {status.label} + + + {job.exportData?.creationTime && ( + + {' '} + {_('Created:')}{' '} + + + )} + + + {getReportExportJobTitle(job, _)} + + {status.detail && ( + {status.detail} + )} + {job.cancelError && ( + + {_('Cancellation failed: {{error}}', { + error: job.cancelError.message, + })} + + )} + + {(job.reportUrl || actionButtons.length > 0) && ( + <> + + + {job.reportUrl && ( + {_('View report details')} + )} + {actionButtons.length > 0 && ( + + {actionButtons} + + )} + + + )} + + ); +}; + +export default ReportExportJobRow; diff --git a/src/web/report-export/ReportExportManager.tsx b/src/web/report-export/ReportExportManager.tsx new file mode 100644 index 0000000000..eb26db5244 --- /dev/null +++ b/src/web/report-export/ReportExportManager.tsx @@ -0,0 +1,111 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { + createContext, + useCallback, + useContext, + useMemo, + useRef, + useState, + type Dispatch, + type ReactNode, + type SetStateAction, +} from 'react'; +import { + showErrorNotification, + showSuccessNotification, +} from '@greenbone/ui-lib'; +import Download from 'web/components/form/Download'; +import CapabilitiesContext from 'web/components/provider/CapabilitiesProvider'; +import useTranslation from 'web/hooks/useTranslation'; +import useReportExport, { + type ReportExportJob, + type StartReportExportParams, +} from 'web/report-export/useReportExports'; + +interface ReportExportManagerContextValue { + exportReport: (params: StartReportExportParams) => Promise; + cancel: (key: string) => Promise; + dismiss: (key: string) => void; + retry: (key: string) => Promise; + clearFinished: () => void; + isActive: boolean; + supportsCancellation: boolean; + activityOpen: boolean; + setActivityOpen: Dispatch>; + jobs: ReportExportJob[]; +} + +interface ReportExportManagerProps { + children: ReactNode; +} + +const ReportExportManagerContext = createContext< + ReportExportManagerContextValue | undefined +>(undefined); + +const ReportExportManager = ({children}: ReportExportManagerProps) => { + const [_] = useTranslation(); + const [activityOpen, setActivityOpen] = useState(false); + const downloadRef = useRef(null); + const capabilities = useContext(CapabilitiesContext); + const supportsCancellation = + capabilities?.mayOp('get_report_exports') ?? false; + const handleDownload = useCallback( + (data: ArrayBuffer | string, filename: string, mimetype?: string) => { + if (!downloadRef.current) throw new Error('Download is unavailable'); + downloadRef.current.setFilename(filename); + downloadRef.current.setData(data, mimetype); + downloadRef.current.download(); + if (!activityOpen) { + showSuccessNotification('', _('Report download started.')); + } + }, + [activityOpen, _], + ); + const reportExport = useReportExport({ + onDownload: handleDownload, + onError: error => { + if (!activityOpen) showErrorNotification(error.message); + }, + }); + const contextValue = useMemo( + () => ({ + exportReport: (params: StartReportExportParams) => + reportExport.start(params), + cancel: (key: string) => + supportsCancellation ? reportExport.cancel(key) : Promise.resolve(), + dismiss: reportExport.dismiss, + retry: reportExport.retry, + clearFinished: reportExport.clearFinished, + isActive: reportExport.isActive, + supportsCancellation, + activityOpen, + setActivityOpen, + jobs: reportExport.jobs, + }), + [activityOpen, reportExport, supportsCancellation], + ); + + return ( + + event.stopPropagation()} /> + {children} + + ); +}; + +export const useReportExportManager = () => { + const context = useContext(ReportExportManagerContext); + if (!context) { + throw new Error( + 'useReportExportManager must be used within ReportExportManager', + ); + } + return context; +}; + +export default ReportExportManager; diff --git a/src/web/report-export/__fixtures__/report-export-mocks.ts b/src/web/report-export/__fixtures__/report-export-mocks.ts new file mode 100644 index 0000000000..a74b1d1e87 --- /dev/null +++ b/src/web/report-export/__fixtures__/report-export-mocks.ts @@ -0,0 +1,93 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {testing} from '@gsa/testing'; +import CollectionCounts from 'gmp/collection/collection-counts'; +import {createSession} from 'gmp/testing'; + +export const exportPayload = { + reportId: 'report-uuid', + reportFormatId: 'format-uuid', +}; + +export const exportData = ( + status: string, + progress = 'generating', + id = 'export-uuid-1', +) => ({id, status, progress, owner: {name: 'test-user'}}); + +export const ownedExportData = ( + status: string, + progress = 'generating', + id = 'export-uuid-1', +) => ({ + ...exportData(status, progress, id), + extension: 'xml', +}); + +export const inventoryResponse = (exports_: unknown[]) => ({ + data: exports_, + meta: { + counts: new CollectionCounts({ + first: 1, + rows: 100, + filtered: exports_.length, + length: exports_.length, + }), + }, +}); + +export const createGmp = (exports_: Record[] = []) => { + let createCount = 0; + const inventory = {exports: exports_}; + return { + inventory, + session: createSession({token: 'test-token', username: 'test-user'}), + settings: { + reloadInterval: 15000, + reloadIntervalActive: 3000, + reloadIntervalInactive: 60000, + }, + reportexport: { + // gvmd answers with an id for both newly created and reused exports + exportScanReport: testing + .fn() + .mockImplementation(() => + Promise.resolve({data: {id: `export-uuid-${++createCount}`}}), + ), + exportAuditReport: testing.fn().mockResolvedValue({ + data: {id: 'audit-export-uuid'}, + }), + exportDeltaScanReport: testing.fn().mockResolvedValue({ + data: {id: 'delta-scan-export-uuid'}, + }), + exportDeltaAuditReport: testing.fn().mockResolvedValue({ + data: {id: 'delta-audit-export-uuid'}, + }), + // per-export status read; statuses are driven via the inventory list + get: testing + .fn() + .mockImplementation( + async ({reportExportId}: {reportExportId: string}) => ({ + data: inventory.exports.find(item => item.id === reportExportId), + }), + ), + downloadReportExport: testing.fn().mockResolvedValue({ + data: new ArrayBuffer(8), + }), + cancelReportExport: testing.fn().mockResolvedValue({}), + }, + }; +}; + +export const changeSession = ( + gmp: {session: {token?: string; username?: string; listener: (() => void)[]}}, + token?: string, + username?: string, +) => { + gmp.session.token = token; + gmp.session.username = username; + gmp.session.listener.forEach(listener => listener()); +}; diff --git a/src/web/report-export/__tests__/ReportExportManager.test.tsx b/src/web/report-export/__tests__/ReportExportManager.test.tsx new file mode 100644 index 0000000000..23b7ec63d5 --- /dev/null +++ b/src/web/report-export/__tests__/ReportExportManager.test.tsx @@ -0,0 +1,1055 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {useState} from 'react'; +import {beforeEach, describe, expect, test, testing} from '@gsa/testing'; +import { + act, + fireEvent, + rendererWith, + screen, + waitFor, + within, +} from 'web/testing'; +import { + showErrorNotification, + showSuccessNotification, +} from '@greenbone/ui-lib'; +import type * as UiLib from '@greenbone/ui-lib'; +import {vi} from 'vitest'; +import Capabilities from 'gmp/capabilities/capabilities'; +import date from 'gmp/models/date'; +import { + createGmp, + exportPayload, +} from 'web/report-export/__fixtures__/report-export-mocks'; +import {ReportExportActivity} from 'web/report-export/ReportExportActivity'; +import ReportExportManager, { + useReportExportManager, +} from 'web/report-export/ReportExportManager'; + +vi.mock('@greenbone/ui-lib', async importOriginal => ({ + ...(await importOriginal()), + showSuccessNotification: vi.fn(), + showErrorNotification: vi.fn(), +})); + +const MultiExportStarter = ({ + reports = [ + {reportTitle: 'Report A', filename: 'Report A.xml'}, + {reportTitle: 'Report B', filename: 'Report B.xml'}, + ], +}) => { + const {exportReport} = useReportExportManager(); + return ( + + ); +}; + +const CompletedExportStarter = ({ + filename = 'completed-report.xml', + reportTitle = 'Completed report', +}) => { + const {exportReport} = useReportExportManager(); + return ( + + ); +}; + +const DifferentReportStarter = () => { + const {exportReport} = useReportExportManager(); + return ( + + ); +}; + +const DeltaExportStarter = () => { + const {exportReport} = useReportExportManager(); + return ( + + ); +}; + +const ReportPage = ({onStarted}: {onStarted: () => void}) => { + const {exportReport} = useReportExportManager(); + + return ( + + ); +}; + +const PageSwitcher = () => { + const [isReportPage, setIsReportPage] = useState(true); + + return isReportPage ? ( + setIsReportPage(false)} /> + ) : ( +

Another page

+ ); +}; + +describe('ReportExportManager', () => { + beforeEach(() => testing.clearAllMocks()); + + test.each([ + ['report.PDF', 'Release.1', 'Report export: Release.1 (.pdf)'], + ['report.csv', 'Unnamed', 'Report export: Unnamed (.csv)'], + ['report', 'Release.1', 'Report export: Release.1'], + ['report.', 'Unnamed', 'Report export: Unnamed'], + ['.report', 'Unnamed', 'Report export: Unnamed'], + ['report.pdf', '', 'Report export (.pdf)'], + ])( + 'shows the activity title for %s', + async (filename, reportTitle, title) => { + const gmp = createGmp([]); + const {render} = rendererWith({gmp, capabilities: true}); + + render( + + <> + + + + , + ); + + fireEvent.click( + screen.getByRole('button', {name: 'Start completed export'}), + ); + + await waitFor(() => expect(screen.getByText(title)).toBeInTheDocument()); + }, + ); + + test.each([ + ['error', 'Export failed'], + ['expired', 'Unavailable'], + ['canceled', 'Canceled'], + ])( + 'terminal %s takes precedence over queued progress', + async (status, label) => { + const gmp = createGmp([ + { + id: 'export-1', + status, + progress: 'queued', + errorMessage: 'Generation detail', + owner: {name: 'test-user'}, + extension: 'xml', + }, + ]); + gmp.reportexport.exportScanReport.mockResolvedValue({ + data: {id: 'export-1'}, + }); + const {render} = rendererWith({gmp, capabilities: true}); + render( + + + + , + ); + fireEvent.click(screen.getByText('Start completed export')); + expect(await screen.findByText(label)).toBeInTheDocument(); + expect(screen.queryByText('Queued')).not.toBeInTheDocument(); + if (status !== 'canceled') + expect(screen.getByText('Generation detail')).toBeInTheDocument(); + expect( + screen.getByRole('button', { + name: 'Remove from activity', + hidden: true, + }), + ).toBeInTheDocument(); + expect(gmp.reportexport.downloadReportExport).not.toHaveBeenCalled(); + }, + ); + + test('groups title, status and creation time above the action zone', async () => { + const gmp = createGmp([ + { + id: 'generating-export', + status: 'running', + progress: 'generating', + owner: {name: 'test-user'}, + extension: 'pdf', + creationTime: date('2026-10-15T11:00:00Z'), + }, + { + id: 'failed-export', + status: 'error', + errorMessage: 'Report failed', + owner: {name: 'test-user'}, + extension: 'pdf', + creationTime: date('2026-10-15T10:00:00Z'), + }, + ]); + gmp.reportexport.exportScanReport + .mockResolvedValueOnce({data: {id: 'generating-export'}}) + .mockResolvedValueOnce({data: {id: 'failed-export'}}); + const {render} = rendererWith({gmp}); + render( + + + + , + ); + + fireEvent.click(screen.getByRole('button', {name: 'Start two exports'})); + await screen.findByText('Generating'); + await screen.findByText('Export failed'); + const firstRow = screen.getAllByTestId('report-export-job')[0]; + const content = within(firstRow).getByTestId('report-export-content'); + expect(content.children[0]).toHaveAttribute( + 'data-testid', + 'report-export-metadata', + ); + expect(content.children[1]).toHaveAttribute( + 'data-testid', + 'report-export-title', + ); + expect( + within(content).getByTestId('report-export-title'), + ).toHaveTextContent('Report export:'); + expect( + within(firstRow).getByTestId('report-export-metadata'), + ).toContainElement(within(firstRow).getByTestId('report-export-status')); + expect( + within(firstRow).getByTestId('report-export-metadata'), + ).toContainElement( + within(firstRow).getByTestId('report-export-created-at'), + ); + expect( + within(firstRow).getByTestId('report-export-created-at'), + ).toHaveStyle({marginLeft: 'auto'}); + expect( + within(firstRow).getByTestId('report-export-created-at').firstChild, + ).toHaveAttribute('aria-hidden', 'true'); + const failedRow = screen.getAllByTestId('report-export-job')[1]; + const failedFooter = within(failedRow).getByTestId('report-export-footer'); + expect(failedFooter.previousSibling).toHaveAttribute( + 'data-testid', + 'report-export-divider', + ); + expect( + within(failedFooter).getByTestId('report-export-actions'), + ).toBeInTheDocument(); + expect( + within(failedFooter).getByTestId('report-export-actions'), + ).toBeInTheDocument(); + // the footer row spreads its content; Mantine Flex applies justify via + // a CSS variable, so assert the computed style + expect(failedFooter).toHaveComputedStyle( + 'justify-content', + 'space-between', + ); + expect( + within(failedFooter).getByLabelText('Remove from activity'), + ).toBeInTheDocument(); + expect( + within(failedRow).getByTestId('report-export-created-at'), + ).toBeInTheDocument(); + }); + + test('keeps completed rows and the total count visible while the activity is open', async () => { + const gmp = createGmp([ + { + id: 'export-uuid-1', + status: 'running', + owner: {name: 'test-user'}, + extension: 'xml', + }, + ]); + gmp.reportexport.exportScanReport.mockResolvedValue({ + data: {id: 'export-uuid-1'}, + }); + const {render} = rendererWith({gmp}); + render( + + + + , + ); + fireEvent.click(screen.getByText('Start completed export')); + // the in-flight export opens the activity + expect(await screen.findByText('Generating')).toBeInTheDocument(); + + // the export finishes while the activity is open and is downloaded + // automatically + gmp.inventory.exports[0] = { + id: 'export-uuid-1', + status: 'done', + owner: {name: 'test-user'}, + extension: 'xml', + }; + await waitFor( + () => + expect(gmp.reportexport.downloadReportExport).toHaveBeenCalledTimes(1), + {timeout: 7000}, + ); + + // the completed row and the total count stay visible while open + expect(await screen.findByText('Complete')).toBeInTheDocument(); + expect(screen.getByText('Report exports (1)')).toBeInTheDocument(); + }, 15000); + + test('keeps the activity visible after closing a completed download', async () => { + const gmp = createGmp([ + { + id: 'export-1', + status: 'done', + owner: {name: 'test-user'}, + extension: 'xml', + }, + ]); + gmp.reportexport.exportScanReport.mockResolvedValue({ + data: {id: 'export-1'}, + }); + // the export row is no longer listed after the download (e.g. removed + // by the server's retention cleanup) + gmp.reportexport.downloadReportExport.mockImplementation(async () => { + gmp.inventory.exports = []; + return {data: new ArrayBuffer(8)}; + }); + const {render} = rendererWith({gmp}); + const view = render( + + + + , + ); + fireEvent.click(screen.getByText('Start completed export')); + expect(await screen.findByText('Complete')).toBeInTheDocument(); + fireEvent.click( + screen.getByRole('button', {name: 'Close export activity', hidden: true}), + ); + + // the completed export keeps the activity visible after closing + const activityButton = screen.getByTestId('report-export-activity-button'); + expect(activityButton).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(activityButton); + expect(await screen.findByText('Report exports (1)')).toBeInTheDocument(); + expect(screen.getByText('Complete')).toBeInTheDocument(); + + view.unmount(); + expect(gmp.reportexport.downloadReportExport).toHaveBeenCalledTimes(1); + }); + + test('clears finished rows and keeps in-flight exports on Clear completed', async () => { + const gmp = createGmp(); + let createCount = 0; + gmp.reportexport.exportScanReport.mockImplementation(async () => { + createCount += 1; + const id = `export-uuid-${createCount}`; + gmp.inventory.exports.push({ + id, + status: + createCount === 1 ? 'done' : createCount === 2 ? 'error' : 'running', + progress: 'generating', + owner: {name: 'test-user'}, + extension: 'xml', + errorMessage: createCount === 2 ? 'Report failed' : undefined, + }); + return {data: {id}}; + }); + const {render} = rendererWith({gmp, capabilities: true}); + render( + + + + , + ); + + fireEvent.click(screen.getByRole('button', {name: 'Start two exports'})); + await screen.findByText('Complete'); + await screen.findByText('Export failed'); + await screen.findByText('Generating'); + + fireEvent.click(screen.getByTestId('report-export-clear-completed')); + + // only the in-flight export remains + expect(await screen.findByText('Report exports (1)')).toBeInTheDocument(); + expect(screen.getByText('Generating')).toBeInTheDocument(); + expect(screen.queryByText('Complete')).not.toBeInTheDocument(); + expect(screen.queryByText('Export failed')).not.toBeInTheDocument(); + expect( + screen.queryByTestId('report-export-clear-completed'), + ).not.toBeInTheDocument(); + }); + + test('distinguishes exports of the same report by extension', async () => { + const gmp = createGmp(); + const {render} = rendererWith({gmp, capabilities: false}); + + render( + + <> + + + + , + ); + + fireEvent.click(screen.getByRole('button', {name: 'Start two exports'})); + + await waitFor(() => { + expect( + screen.getByText('Report export: Unnamed (.pdf)'), + ).toBeInTheDocument(); + expect( + screen.getByText('Report export: Unnamed (.xml)'), + ).toBeInTheDocument(); + }); + expect(gmp.reportexport.exportScanReport).toHaveBeenCalledTimes(2); + }); + + test('shows a preparing state while export creation is pending', async () => { + const gmp = createGmp([ + { + id: 'export-preparing', + status: 'running', + progress: 'generating', + owner: {name: 'test-user'}, + }, + ]); + let resolveCreate: ((response: {data: {id: string}}) => void) | undefined; + gmp.reportexport.exportScanReport.mockImplementation( + () => + new Promise(resolve => { + resolveCreate = resolve; + }), + ); + const {render} = rendererWith({gmp, capabilities: true}); + + render( + + <> + + + + , + ); + + fireEvent.click( + screen.getByRole('button', {name: 'Start completed export'}), + ); + + const activityPopover = await screen.findByTestId( + 'report-export-activity-popover', + ); + expect(within(activityPopover).getByText('Preparing')).toBeInTheDocument(); + expect( + within(activityPopover).getByTestId('report-export-status'), + ).toHaveAttribute('data-state', 'active'); + + await act(async () => { + resolveCreate?.({data: {id: 'export-preparing'}}); + }); + expect( + await within(activityPopover).findByText('Generating'), + ).toBeInTheDocument(); + }); + + test('shows canceled exports as a distinct removable state', async () => { + const gmp = createGmp(); + let status = 'running'; + gmp.inventory.exports.push({ + id: 'export-canceled', + get status() { + return status; + }, + progress: 'generating', + owner: {name: 'test-user'}, + }); + gmp.reportexport.exportScanReport.mockResolvedValue({ + data: {id: 'export-canceled'}, + }); + const {render} = rendererWith({gmp, capabilities: true}); + + render( + + <> + + + + , + ); + + fireEvent.click( + screen.getByRole('button', {name: 'Start completed export'}), + ); + const activityPopover = await screen.findByTestId( + 'report-export-activity-popover', + ); + await within(activityPopover).findByText('Generating'); + status = 'canceled'; + + fireEvent.click( + within(activityPopover).getByRole('button', { + name: 'Cancel report export', + hidden: true, + }), + ); + + expect( + await within(activityPopover).findByText('Canceled'), + ).toBeInTheDocument(); + expect( + within(activityPopover).getByTestId('report-export-status'), + ).toHaveAttribute('data-state', 'canceled'); + expect( + within(activityPopover).getByRole('button', { + name: 'Remove from activity', + hidden: true, + }), + ).toBeInTheDocument(); + }); + + test('uses async export for XML without report capability', async () => { + const gmp = createGmp(); + gmp.reportexport.exportDeltaScanReport.mockResolvedValue({ + data: {id: 'delta-export-id'}, + }); + const {render} = rendererWith({gmp, capabilities: false}); + + render( + + <> + + + + , + ); + + fireEvent.click(screen.getByRole('button', {name: 'Start delta export'})); + await waitFor(() => + expect(gmp.reportexport.exportDeltaScanReport).toHaveBeenCalledTimes(1), + ); + }); + + test('does not apply cancellation to another report export', async () => { + const gmp = createGmp(); + let exportCount = 0; + gmp.reportexport.exportScanReport.mockImplementation(async () => { + const id = `export-${++exportCount}`; + // the created export is listed by gvmd right away + gmp.inventory.exports.push({ + id, + status: 'running', + progress: 'generating', + owner: {name: 'test-user'}, + }); + return {data: {id}}; + }); + const {render} = rendererWith({gmp, capabilities: true}); + + render( + + <> + + + + + , + ); + + fireEvent.click( + screen.getByRole('button', {name: 'Start completed export'}), + ); + const activityPopover = await screen.findByTestId( + 'report-export-activity-popover', + ); + await within(activityPopover).findByText('Generating'); + gmp.inventory.exports[0].status = 'canceled'; + fireEvent.click( + within(activityPopover).getByRole('button', { + name: 'Cancel report export', + hidden: true, + }), + ); + await within(activityPopover).findByText('Canceled'); + + fireEvent.click( + screen.getByRole('button', {name: 'Start another report export'}), + ); + expect( + await within(activityPopover).findByText('Generating'), + ).toBeInTheDocument(); + expect(gmp.reportexport.exportScanReport).toHaveBeenCalledTimes(2); + expect(within(activityPopover).getByText('Canceled')).toBeInTheDocument(); + expect(gmp.reportexport.cancelReportExport).toHaveBeenCalledTimes(1); + }); + + test('keeps progress and cancellation available after the report page unmounts', async () => { + const gmp = createGmp([ + { + id: 'export-uuid', + status: 'running', + progress: 'generating', + owner: {name: 'test-user'}, + }, + ]); + gmp.reportexport.exportScanReport.mockResolvedValue({ + data: {id: 'export-uuid'}, + }); + gmp.reportexport.cancelReportExport.mockRejectedValue( + new Error('Unknown command'), + ); + const {render} = rendererWith({gmp, capabilities: true}); + + render( + + <> + + + + , + ); + + fireEvent.click( + screen.getByRole('button', {name: 'Start export and navigate'}), + ); + + expect(await screen.findByText('Another page')).toBeInTheDocument(); + const activityPopover = await screen.findByTestId( + 'report-export-activity-popover', + ); + expect( + within(activityPopover).getByRole('button', { + name: 'Close export activity', + hidden: true, + }), + ).toBeInTheDocument(); + expect( + screen.getByTestId('report-export-activity-button').querySelector('svg'), + ).toHaveClass('lucide-download'); + expect(screen.getByTestId('report-export-activity-button')).toHaveAttribute( + 'aria-expanded', + 'true', + ); + expect(screen.getByText('Generating')).toBeInTheDocument(); + + fireEvent.click( + within(activityPopover).getByRole('button', { + name: 'Cancel report export', + hidden: true, + }), + ); + + await waitFor(() => { + expect(gmp.reportexport.cancelReportExport).toHaveBeenCalledTimes(1); + }); + + expect( + await screen.findByText('Cancellation failed: Unknown command'), + ).toBeInTheDocument(); + expect(screen.getByText('Generating')).toBeInTheDocument(); + expect( + within(activityPopover).getByRole('button', { + name: 'Retry cancellation', + hidden: true, + }), + ).toBeInTheDocument(); + expect( + within(activityPopover).queryByRole('button', { + name: 'Cancel report export', + hidden: true, + }), + ).not.toBeInTheDocument(); + + const activityButton = screen.getByTestId('report-export-activity-button'); + fireEvent.click(activityButton); + expect(activityButton).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(activityButton); + expect(activityButton).toHaveAttribute('aria-expanded', 'true'); + }); + + test('retains cancellation errors when activity closes during the request', async () => { + const gmp = createGmp([ + {id: 'export-1', status: 'running', owner: {name: 'test-user'}}, + ]); + gmp.reportexport.exportScanReport.mockResolvedValue({ + data: {id: 'export-1'}, + }); + let rejectCancellation: ((error: Error) => void) | undefined; + gmp.reportexport.cancelReportExport.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectCancellation = reject; + }), + ); + const {render} = rendererWith({gmp, capabilities: true}); + render( + + + + , + ); + fireEvent.click(screen.getByText('Start completed export')); + await screen.findByText('Generating'); + fireEvent.click( + screen.getByRole('button', {name: 'Cancel report export', hidden: true}), + ); + await waitFor(() => + expect(gmp.reportexport.cancelReportExport).toHaveBeenCalledTimes(1), + ); + fireEvent.click( + screen.getByRole('button', {name: 'Close export activity', hidden: true}), + ); + await act(async () => { + rejectCancellation?.(new Error('Cancellation denied')); + }); + await waitFor(() => expect(showErrorNotification).toHaveBeenCalledTimes(1)); + expect(showErrorNotification).toHaveBeenCalledWith('Cancellation denied'); + fireEvent.click(screen.getByTestId('report-export-activity-button')); + expect( + await screen.findByText('Cancellation failed: Cancellation denied'), + ).toBeInTheDocument(); + fireEvent.click( + screen.getByRole('button', {name: 'Retry cancellation', hidden: true}), + ); + await waitFor(() => + expect( + screen.queryByText('Cancellation failed: Cancellation denied'), + ).not.toBeInTheDocument(), + ); + expect(showErrorNotification).toHaveBeenCalledTimes(1); + }); + + test('lists multiple report exports independently', async () => { + const gmp = createGmp(); + let exportCount = 0; + const createExport = testing.fn().mockImplementation(() => { + exportCount += 1; + return Promise.resolve({data: {id: `export-${exportCount}`}}); + }); + gmp.reportexport.exportScanReport = createExport; + gmp.inventory.exports.push( + { + id: 'export-1', + status: 'running', + progress: 'generating', + owner: {name: 'test-user'}, + }, + { + id: 'export-2', + status: 'pending', + progress: 'queued', + owner: {name: 'test-user'}, + }, + ); + const {render} = rendererWith({gmp, capabilities: true}); + + render( + + <> + + + + , + ); + + fireEvent.click(screen.getByRole('button', {name: 'Start two exports'})); + + expect( + await screen.findByText('Report export: Report A (.xml)'), + ).toBeInTheDocument(); + expect( + screen.getByText('Report export: Report B (.xml)'), + ).toBeInTheDocument(); + const reportJobs = screen.getAllByTestId('report-export-job'); + expect( + within(reportJobs[0]).getByText('Report export: Report B (.xml)'), + ).toBeInTheDocument(); + expect( + within(reportJobs[1]).getByText('Report export: Report A (.xml)'), + ).toBeInTheDocument(); + const generatingStatus = await screen.findByText('Generating'); + expect(generatingStatus).toBeInTheDocument(); + expect( + generatingStatus.closest('[data-testid="report-export-status"]'), + ).toHaveAttribute('data-state', 'active'); + const queuedStatus = await screen.findByText('Queued'); + expect( + queuedStatus.closest('[data-testid="report-export-status"]'), + ).toHaveAttribute('data-state', 'queued'); + expect(createExport).toHaveBeenCalledTimes(2); + }); + + test('retains a completed export and its report link after browser handoff', async () => { + const gmp = createGmp([ + { + id: 'export-completed', + status: 'done', + progress: 'completed', + owner: {name: 'test-user'}, + }, + ]); + gmp.reportexport.exportScanReport.mockResolvedValue({ + data: {id: 'export-completed'}, + }); + let resolveDownload: ((response: {data: ArrayBuffer}) => void) | undefined; + gmp.reportexport.downloadReportExport.mockImplementation( + () => + new Promise(resolve => { + resolveDownload = resolve; + }), + ); + window.URL.createObjectURL = testing.fn().mockReturnValue('blob:report'); + window.URL.revokeObjectURL = testing.fn(); + const {render} = rendererWith({gmp, capabilities: true}); + + render( + + <> + + + + , + ); + + fireEvent.click( + screen.getByRole('button', {name: 'Start completed export'}), + ); + + expect(await screen.findByText('Report exports (1)')).toBeInTheDocument(); + await waitFor(() => + expect(gmp.reportexport.downloadReportExport).toHaveBeenCalledTimes(1), + ); + expect( + screen.getByText('Report export: Completed report (.xml)'), + ).toBeInTheDocument(); + expect(screen.queryByText('Complete')).not.toBeInTheDocument(); + expect( + screen + .getByText('Downloading') + .closest('[data-testid="report-export-status"]'), + ).toHaveAttribute('data-state', 'downloading'); + expect( + screen.getByRole('link', {name: 'View report details', hidden: true}), + ).toHaveAttribute('href', '/report/report-uuid'); + expect( + screen.queryByRole('button', { + name: 'Remove from activity', + hidden: true, + }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { + name: 'Cancel report export', + hidden: true, + }), + ).not.toBeInTheDocument(); + await act(async () => { + resolveDownload?.({data: new ArrayBuffer(8)}); + }); + + expect(showSuccessNotification).not.toHaveBeenCalled(); + expect(await screen.findByText('Complete')).toHaveAttribute( + 'data-state', + 'complete', + ); + expect( + screen.getByText('Report export: Completed report (.xml)'), + ).toBeInTheDocument(); + expect( + screen.getByRole('link', {name: 'View report details', hidden: true}), + ).toHaveAttribute('href', '/report/report-uuid'); + expect( + screen.getByRole('button', { + name: 'Remove from activity', + hidden: true, + }), + ).toBeInTheDocument(); + + fireEvent.click( + screen.getByRole('button', { + name: 'Remove from activity', + hidden: true, + }), + ); + await waitFor(() => + expect( + screen.queryByTestId('report-export-activity-button'), + ).not.toBeInTheDocument(), + ); + }); + + test('allows cancellation for users with report export access', async () => { + const gmp = createGmp([ + { + id: 'export-unsupported-cancel', + status: 'running', + owner: {name: 'test-user'}, + }, + ]); + gmp.reportexport.exportScanReport.mockResolvedValue({ + data: {id: 'export-unsupported-cancel'}, + }); + const {render} = rendererWith({ + gmp, + capabilities: new Capabilities(['get_report_exports']), + }); + + render( + + <> + + + + , + ); + + fireEvent.click(screen.getByRole('button', {name: 'Start two exports'})); + await screen.findByText('Report export: Report A (.xml)'); + fireEvent.click( + await screen.findByRole('button', { + name: 'Cancel report export', + hidden: true, + }), + ); + await waitFor(() => + expect(gmp.reportexport.cancelReportExport).toHaveBeenCalledTimes(1), + ); + }); + + test('labels terminal-row removal separately from closing activity', async () => { + const gmp = createGmp([ + { + id: 'export-failed', + status: 'error', + errorMessage: 'Export failed', + owner: {name: 'test-user'}, + }, + ]); + gmp.reportexport.exportScanReport.mockResolvedValue({ + data: {id: 'export-failed'}, + }); + const {render} = rendererWith({gmp, capabilities: false}); + + render( + + <> + + + + , + ); + + fireEvent.click( + screen.getByRole('button', {name: 'Start completed export'}), + ); + + const activityPopover = await screen.findByTestId( + 'report-export-activity-popover', + ); + expect( + await within(activityPopover).findByRole('button', { + name: 'Remove from activity', + hidden: true, + }), + ).toBeInTheDocument(); + expect( + within(activityPopover).getByRole('button', { + name: 'Close export activity', + hidden: true, + }), + ).toBeInTheDocument(); + + fireEvent.click( + within(activityPopover).getByRole('button', { + name: 'Remove from activity', + hidden: true, + }), + ); + await waitFor(() => + expect(screen.queryByTestId('report-export-activity-button')).toBeNull(), + ); + }); +}); diff --git a/src/web/report-export/__tests__/build-report-export-request.test.ts b/src/web/report-export/__tests__/build-report-export-request.test.ts new file mode 100644 index 0000000000..95d6f7de2f --- /dev/null +++ b/src/web/report-export/__tests__/build-report-export-request.test.ts @@ -0,0 +1,57 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {describe, expect, test} from '@gsa/testing'; +import QueryFilter from 'gmp/models/filter/query-filter'; +import {buildReportExportRequest} from 'web/report-export/build-report-export-request'; + +describe('buildReportExportRequest', () => { + test.each([ + ['scan', 'scan', true, false], + ['audit', 'audit', false, false], + ['delta_scan', 'delta_scan', true, true], + ['delta_audit', 'delta_audit', false, true], + ] as const)( + 'builds the %s request without dropping shared or kind-specific metadata', + (kind, expectedKind, includesConfig, includesDelta) => { + const filter = QueryFilter.fromString('rows=100'); + const request = buildReportExportRequest({ + kind, + entity: {id: 'report-id', task: {name: 'Task report'}}, + format: { + id: 'format-id', + name: 'XML', + extension: 'xml', + content_type: 'text/xml', + }, + filter, + reportConfigId: 'config-id', + deltaReportId: 'delta-id', + reportUrl: '/reports/report-id', + fileNameFormat: '%N', + username: 'alice', + fallbackReportTitle: 'Report', + }); + + expect(request).toMatchObject({ + kind: expectedKind, + filename: 'Task report.xml', + reportTitle: 'report-id', + reportUrl: '/reports/report-id', + payload: { + reportId: 'report-id', + reportFormatId: 'format-id', + filter, + }, + }); + expect(request.payload.reportConfigId).toBe( + includesConfig ? 'config-id' : undefined, + ); + expect(request.payload.deltaReportId).toBe( + includesDelta ? 'delta-id' : undefined, + ); + }, + ); +}); diff --git a/src/web/report-export/__tests__/useReportExport.test.tsx b/src/web/report-export/__tests__/useReportExport.test.tsx new file mode 100644 index 0000000000..0f7af74455 --- /dev/null +++ b/src/web/report-export/__tests__/useReportExport.test.tsx @@ -0,0 +1,781 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {beforeEach, describe, expect, test, testing} from '@gsa/testing'; +import {act, rendererWith, waitFor} from 'web/testing'; +import { + changeSession, + createGmp, + exportData, + exportPayload, + ownedExportData, +} from 'web/report-export/__fixtures__/report-export-mocks'; +import { + createReportExportJob, + getReportExportActions, + retainReportExportJobs, +} from 'web/report-export/report-export-job-model'; +import useReportExport, { + REPORT_EXPORT_DOWNLOAD_RETRY_INTERVAL, +} from 'web/report-export/useReportExports'; + +const onError = testing.fn(); + +describe('useReportExport', () => { + beforeEach(() => testing.clearAllMocks()); + + const startParams = { + kind: 'scan' as const, + payload: exportPayload, + filename: 'report.xml', + reportTitle: 'Test report', + }; + + const renderReportExport = ( + gmp: ReturnType, + callbacks?: {onDownload?: typeof onError; onError?: typeof onError}, + ) => + rendererWith({gmp}).renderHook(() => + useReportExport({onError, onDownload: testing.fn(), ...callbacks}), + ); + + test('tracks a started export via status polling until it auto-downloads', async () => { + const gmp = createGmp([ownedExportData('running')]); + const onDownload = testing.fn(); + const {result} = renderReportExport(gmp, {onDownload}); + + await act(async () => { + await result.current.start(startParams); + }); + await waitFor(() => + expect(result.current.jobs[0].view.kind).toBe('generating'), + ); + expect(gmp.reportexport.get).toHaveBeenCalledWith({ + reportExportId: 'export-uuid-1', + }); + + gmp.inventory.exports[0] = ownedExportData('done', 'completed'); + await waitFor(() => expect(onDownload).toHaveBeenCalledTimes(1), { + timeout: 7000, + }); + expect(gmp.reportexport.downloadReportExport).toHaveBeenCalledTimes(1); + await waitFor(() => + expect(result.current.jobs[0].view.kind).toBe('complete'), + ); + }); + + test('stops polling the status once the export is done', async () => { + const gmp = createGmp([ownedExportData('done', 'completed')]); + const onDownload = testing.fn(); + const {result} = renderReportExport(gmp, {onDownload}); + + await act(async () => { + await result.current.start(startParams); + }); + await waitFor(() => expect(onDownload).toHaveBeenCalledTimes(1)); + const callsAfterDone = gmp.reportexport.get.mock.calls.length; + await new Promise(resolve => setTimeout(resolve, 100)); + expect(gmp.reportexport.get.mock.calls.length).toBe(callsAfterDone); + expect(result.current.jobs[0].view.kind).toBe('complete'); + }); + + test('never requests the report export list', async () => { + const gmp = createGmp(); + const {result} = renderReportExport(gmp); + await act(async () => { + await result.current.start(startParams); + }); + await waitFor(() => expect(result.current.jobs).toHaveLength(1)); + expect(gmp.reportexport.get).toHaveBeenCalledTimes(1); + expect(onError).not.toHaveBeenCalled(); + }); + + test('retries a failed browser handoff by re-downloading from gvmd', async () => { + // gvmd retains the generated file, so a retry downloads it again + const gmp = createGmp([ownedExportData('done', 'completed')]); + const onDownload = testing.fn().mockImplementationOnce(() => { + throw new Error('Browser handoff failed'); + }); + const {result} = renderReportExport(gmp, {onDownload}); + await act(async () => { + await result.current.start(startParams); + }); + await waitFor(() => + expect(result.current.jobs[0].view).toMatchObject({ + kind: 'failed', + reason: 'download', + error: new Error('Browser handoff failed'), + }), + ); + await act(async () => { + await result.current.retry(result.current.jobs[0].key); + }); + expect(gmp.reportexport.downloadReportExport).toHaveBeenCalledTimes(2); + expect(onDownload).toHaveBeenCalledTimes(2); + expect(result.current.jobs[0].view.kind).toBe('complete'); + }); + + test('does not auto-retry a permanently rejected download', async () => { + const gmp = createGmp([ownedExportData('done', 'completed')]); + gmp.reportexport.downloadReportExport.mockRejectedValue( + Object.assign(new Error('Access denied'), {status: 403}), + ); + const {result} = renderReportExport(gmp); + await act(async () => { + await result.current.start(startParams); + }); + await waitFor(() => + expect(result.current.jobs[0].view).toMatchObject({ + kind: 'failed', + reason: 'unavailable', + }), + ); + expect(getReportExportActions(result.current.jobs[0])).toMatchObject({ + retry: true, + dismiss: true, + }); + // a permanent rejection is not followed by the automatic retry + expect(gmp.reportexport.downloadReportExport).toHaveBeenCalledTimes(1); + }); + + test('merges identical create responses into one tracked export', async () => { + const gmp = createGmp([ownedExportData('done', 'completed')]); + gmp.reportexport.exportScanReport.mockResolvedValue({ + data: {id: 'export-uuid-1'}, + }); + const onDownload = testing.fn(); + const {result} = renderReportExport(gmp, {onDownload}); + await act(async () => { + await Promise.all([ + result.current.start(startParams), + result.current.start(startParams), + ]); + }); + await waitFor(() => expect(result.current.jobs).toHaveLength(1)); + await waitFor(() => expect(onDownload).toHaveBeenCalledTimes(1)); + expect(gmp.reportexport.downloadReportExport).toHaveBeenCalledTimes(1); + }); + + test('bounds completed activity while retaining unfinished jobs', () => { + const completedJobs = Array.from({length: 60}, (_, index) => + createReportExportJob({ + key: `receipt-${index}`, + exportId: `export-${index}`, + filename: 'report.xml', + reportTitle: 'Report', + downloaded: true, + }), + ); + const unfinishedJob = createReportExportJob( + { + key: 'unfinished', + exportId: 'unfinished-export', + filename: 'report.xml', + reportTitle: 'Unfinished', + }, + exportData('running', 'generating', 'unfinished-export') as never, + ); + + const retainedJobs = retainReportExportJobs([ + ...completedJobs, + unfinishedJob, + ]); + + expect(retainedJobs).toHaveLength(51); + expect(retainedJobs).toContain(unfinishedJob); + expect(retainedJobs).not.toContain(completedJobs[0]); + }); + + test('retries a not-ready download later than the status poll interval', () => { + expect(REPORT_EXPORT_DOWNLOAD_RETRY_INTERVAL).toBe(3000); + }); + + test('fails when the export cannot be found by the status query', async () => { + const gmp = createGmp(); + const onDownload = testing.fn(); + const {result} = renderReportExport(gmp, {onDownload}); + + await act(async () => { + await result.current.start(startParams); + }); + + await waitFor(() => + expect(result.current.jobs[0].view).toMatchObject({ + kind: 'failed', + reason: 'unavailable', + }), + ); + expect(onDownload).not.toHaveBeenCalled(); + expect(gmp.reportexport.downloadReportExport).not.toHaveBeenCalled(); + }); + + test('retains export creation errors and invokes the error callback', async () => { + const gmp = createGmp([]); + gmp.reportexport.exportScanReport.mockRejectedValue( + new Error('Export creation failed'), + ); + const {result} = renderReportExport(gmp); + + await act(async () => { + expect(await result.current.start(startParams)).toBe(false); + }); + + expect(result.current.jobs[0].view).toMatchObject({ + kind: 'failed', + error: new Error('Export creation failed'), + }); + expect(onError).toHaveBeenCalledWith(new Error('Export creation failed')); + }); + + test('retries the download once when the export file is not ready yet', async () => { + const gmp = createGmp([ownedExportData('done', 'completed')]); + gmp.reportexport.downloadReportExport + .mockRejectedValueOnce(new Error('Report export is not ready')) + .mockResolvedValue({data: new ArrayBuffer(8)}); + const onDownload = testing.fn(); + const {result} = renderReportExport(gmp, {onDownload}); + + await act(async () => { + await result.current.start(startParams); + }); + await waitFor( + () => + expect(gmp.reportexport.downloadReportExport).toHaveBeenCalledTimes(2), + {timeout: 7000}, + ); + + expect(onDownload).toHaveBeenCalledTimes(1); + await waitFor(() => + expect(result.current.jobs[0].view.kind).toBe('complete'), + ); + expect(result.current.jobs).toHaveLength(1); + }, 10000); + + test('surfaces the manual retry action after a second failed download attempt', async () => { + const gmp = createGmp([ownedExportData('done', 'completed')]); + gmp.reportexport.downloadReportExport + .mockRejectedValueOnce(new Error('Report export is not ready')) + .mockRejectedValue(new Error('Report export is not ready')); + const onDownload = testing.fn(); + const {result} = renderReportExport(gmp, {onDownload}); + + await act(async () => { + await result.current.start(startParams); + }); + await waitFor( + () => + expect(gmp.reportexport.downloadReportExport).toHaveBeenCalledTimes(2), + {timeout: 7000}, + ); + await waitFor(() => + expect(result.current.jobs[0].view).toMatchObject({ + kind: 'failed', + reason: 'download', + }), + ); + expect(getReportExportActions(result.current.jobs[0])).toMatchObject({ + retry: true, + dismiss: true, + }); + expect(onError).toHaveBeenCalledWith( + new Error('Report export is not ready'), + ); + + gmp.reportexport.downloadReportExport.mockResolvedValue({ + data: new ArrayBuffer(8), + }); + await act(async () => { + await result.current.retry(result.current.jobs[0].key); + }); + await waitFor(() => + expect(result.current.jobs[0].view.kind).toBe('complete'), + ); + }, 10000); + + test('retains the export after handing the file to the browser', async () => { + const gmp = createGmp([ownedExportData('done', 'completed')]); + let resolveDownload: ((response: {data: ArrayBuffer}) => void) | undefined; + gmp.reportexport.downloadReportExport.mockImplementation( + () => + new Promise(resolve => { + resolveDownload = resolve; + }), + ); + const {result} = renderReportExport(gmp); + + await act(async () => { + await result.current.start(startParams); + }); + await waitFor(() => expect(result.current.jobs).toHaveLength(1)); + expect(result.current.jobs[0].view.kind).toBe('downloading'); + expect(result.current.isActive).toBe(true); + + act(() => result.current.dismiss(result.current.jobs[0].key)); + expect(result.current.jobs).toHaveLength(1); + + await act(async () => { + resolveDownload?.({data: new ArrayBuffer(8)}); + }); + + await waitFor(() => + expect(result.current.jobs[0].view.kind).toBe('complete'), + ); + expect(result.current.jobs).toHaveLength(1); + expect(result.current.isActive).toBe(false); + + act(() => result.current.dismiss(result.current.jobs[0].key)); + await waitFor(() => expect(result.current.jobs).toHaveLength(0)); + }); + + test('cancels an export while its status is still being checked', async () => { + const gmp = createGmp(); + // the first status response is deferred + let resolveStatus: ((response: {data: unknown}) => void) | undefined; + gmp.reportexport.get.mockImplementationOnce( + () => + new Promise(resolve => { + resolveStatus = resolve; + }), + ); + const {result} = renderReportExport(gmp); + + await act(async () => { + await result.current.start(startParams); + }); + // bound but the status has not arrived yet + await waitFor(() => + expect(result.current.jobs[0].view.kind).toBe('checking'), + ); + expect(getReportExportActions(result.current.jobs[0])).toMatchObject({ + cancel: true, + dismiss: true, + }); + + await act(async () => { + await result.current.cancel(result.current.jobs[0].key); + }); + expect(gmp.reportexport.cancelReportExport).toHaveBeenCalledWith({ + reportExportId: 'export-uuid-1', + }); + + // the status arrives after the cancellation request + resolveStatus?.({data: ownedExportData('cancel_requested')}); + await waitFor( + () => expect(result.current.jobs[0].view.kind).toBe('canceling'), + {timeout: 7000}, + ); + }, 15000); + + test('sends a queued cancellation after export creation returns its id', async () => { + const gmp = createGmp([ownedExportData('running')]); + let resolveCreate: ((response: {data: {id: string}}) => void) | undefined; + gmp.reportexport.exportScanReport.mockImplementation( + () => + new Promise(resolve => { + resolveCreate = resolve; + }), + ); + const {result} = renderReportExport(gmp); + let startPromise: Promise; + + act(() => { + startPromise = result.current.start(startParams); + }); + await waitFor(() => + expect(result.current.jobs[0].view.kind).toBe('creating'), + ); + await act(async () => { + await result.current.cancel(result.current.jobs[0].key); + }); + expect(gmp.reportexport.cancelReportExport).not.toHaveBeenCalled(); + expect(result.current.jobs[0].view.kind).toBe('canceling'); + + await act(async () => { + resolveCreate?.({data: {id: 'export-uuid-1'}}); + await startPromise; + }); + + await waitFor(() => + expect(gmp.reportexport.cancelReportExport).toHaveBeenCalledWith({ + reportExportId: 'export-uuid-1', + }), + ); + }); + + test('drops a queued cancellation when export creation fails', async () => { + const gmp = createGmp([]); + let rejectCreate: ((error: Error) => void) | undefined; + gmp.reportexport.exportScanReport.mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectCreate = reject; + }), + ); + const {result} = renderReportExport(gmp); + let startPromise: Promise; + + act(() => { + startPromise = result.current.start(startParams); + }); + await act(async () => { + await result.current.cancel(result.current.jobs[0].key); + }); + expect(result.current.jobs[0].view.kind).toBe('canceling'); + + await act(async () => { + rejectCreate?.(new Error('Export creation failed')); + await startPromise; + }); + + expect(result.current.jobs[0].view).toMatchObject({ + kind: 'failed', + reason: 'export', + }); + expect(gmp.reportexport.cancelReportExport).not.toHaveBeenCalled(); + }); + + test('does not send generation cancellation during a completed-file transfer', async () => { + const gmp = createGmp([ownedExportData('done', 'completed')]); + let resolveDownload: ((response: {data: ArrayBuffer}) => void) | undefined; + gmp.reportexport.downloadReportExport.mockImplementation( + () => + new Promise(resolve => { + resolveDownload = resolve; + }), + ); + const onDownload = testing.fn(); + const {result} = renderReportExport(gmp, {onDownload}); + + await act(async () => { + await result.current.start(startParams); + }); + await waitFor(() => + expect(result.current.jobs[0].view.kind).toBe('downloading'), + ); + await act(async () => { + await result.current.cancel(result.current.jobs[0].key); + }); + expect(gmp.reportexport.cancelReportExport).not.toHaveBeenCalled(); + + await act(async () => { + resolveDownload?.({data: new ArrayBuffer(8)}); + }); + + expect(onDownload).toHaveBeenCalledTimes(1); + expect(result.current.jobs[0].view.kind).toBe('complete'); + }); + + test('cancels an active export', async () => { + const gmp = createGmp([ownedExportData('running')]); + gmp.reportexport.cancelReportExport.mockImplementationOnce(async () => { + gmp.inventory.exports[0] = ownedExportData('cancel_requested'); + return {}; + }); + const {result} = renderReportExport(gmp); + + await act(async () => { + await result.current.start(startParams); + }); + await waitFor(() => + expect(result.current.jobs[0].view.kind).toBe('generating'), + ); + await act(async () => { + await result.current.cancel(result.current.jobs[0].key); + }); + expect(gmp.reportexport.cancelReportExport).toHaveBeenCalledWith({ + reportExportId: 'export-uuid-1', + }); + await waitFor(() => + expect(result.current.jobs[0].view.kind).toBe('canceling'), + ); + + gmp.inventory.exports[0] = ownedExportData('canceled'); + await waitFor( + () => expect(result.current.jobs[0].view.kind).toBe('canceled'), + {timeout: 7000}, + ); + expect(getReportExportActions(result.current.jobs[0])).toMatchObject({ + dismiss: true, + cancel: false, + }); + }); + + test('keeps the export active when cancellation is rejected', async () => { + const gmp = createGmp([ownedExportData('running')]); + gmp.reportexport.cancelReportExport.mockRejectedValue( + new Error('Unknown command'), + ); + const onCancelError = testing.fn(); + const {result} = renderReportExport(gmp, {onError: onCancelError}); + + await act(async () => { + await result.current.start(startParams); + }); + await waitFor(() => + expect(result.current.jobs[0].view.kind).toBe('generating'), + ); + + await act(async () => { + await result.current.cancel(result.current.jobs[0].key); + }); + + expect(result.current.jobs[0].view.kind).toBe('generating'); + expect(result.current.isActive).toBe(true); + expect(result.current.jobs[0].cancelError?.message).toBe('Unknown command'); + expect(onCancelError).toHaveBeenCalledWith(new Error('Unknown command')); + }); + + test('re-exports a canceled report with a fresh export id', async () => { + // gvmd only reuses pending/running exports; a canceled export always + // gets a new row + const gmp = createGmp(); + let status1 = 'running'; + let status2 = 'running'; + let createCount = 0; + gmp.reportexport.exportScanReport.mockImplementation(async () => { + createCount += 1; + return {data: {id: `export-uuid-${createCount}`}}; + }); + gmp.reportexport.get.mockImplementation( + async ({reportExportId}: {reportExportId: string}) => ({ + data: ownedExportData( + reportExportId === 'export-uuid-1' ? status1 : status2, + 'generating', + reportExportId, + ), + }), + ); + const onDownload = testing.fn(); + const {result} = renderReportExport(gmp, {onDownload}); + const params = {...startParams, reportUrl: '/report/report-uuid'}; + + await act(async () => { + await result.current.start(params); + }); + await waitFor(() => + expect(result.current.jobs[0].view.kind).toBe('generating'), + ); + const canceledJobKey = result.current.jobs[0].key; + status1 = 'canceled'; + + await act(async () => { + await result.current.cancel(canceledJobKey); + }); + await waitFor( + () => expect(result.current.jobs[0].view.kind).toBe('canceled'), + {timeout: 7000}, + ); + + await act(async () => { + await result.current.start(params); + }); + + expect(gmp.reportexport.exportScanReport).toHaveBeenCalledTimes(2); + expect(canceledJobKey).toContain('report-export-'); + status2 = 'done'; + await waitFor(() => expect(onDownload).toHaveBeenCalledTimes(1), { + timeout: 7000, + }); + expect(gmp.reportexport.downloadReportExport).toHaveBeenCalledTimes(1); + expect(onDownload).toHaveBeenCalledWith( + new ArrayBuffer(8), + 'report.xml', + undefined, + ); + const completedJob = result.current.jobs.find( + job => job.exportId === 'export-uuid-2', + ); + expect(completedJob?.view.kind).toBe('complete'); + }, 20000); + + test('tracks multiple exports independently', async () => { + const gmp = createGmp([ + exportData('running', 'generating', 'export-uuid-1'), + exportData('running', 'generating', 'export-uuid-2'), + exportData('running', 'generating', 'export-uuid-3'), + exportData('pending', 'queued', 'export-uuid-4'), + ]); + const {result} = renderReportExport(gmp); + + await act(async () => { + await Promise.all([ + result.current.start(startParams), + result.current.start(startParams), + result.current.start(startParams), + result.current.start(startParams), + ]); + }); + + await waitFor(() => expect(result.current.jobs).toHaveLength(4)); + expect(gmp.reportexport.exportScanReport).toHaveBeenCalledTimes(4); + expect(result.current.jobs.map(job => job.view.kind)).toEqual([ + 'generating', + 'generating', + 'generating', + 'queued', + ]); + }); + + test('keeps checking and recovers after transient status failures', async () => { + const gmp = createGmp([ownedExportData('running')]); + gmp.reportexport.get + .mockRejectedValueOnce(new Error('Temporary network error')) + .mockRejectedValueOnce(new Error('Temporary network error')) + .mockRejectedValueOnce(new Error('Temporary network error')); + const {result} = renderReportExport(gmp); + + await act(async () => { + await result.current.start(startParams); + }); + await waitFor(() => + expect(result.current.jobs[0].view.kind).toBe('checking'), + ); + expect(result.current.isActive).toBe(true); + + await waitFor( + () => expect(result.current.jobs[0].view.kind).toBe('generating'), + {timeout: 10000}, + ); + expect(gmp.reportexport.get.mock.calls.length).toBeGreaterThan(3); + }, 15000); + + test('does not retry or notify when a transfer fails after logout', async () => { + const gmp = createGmp([ownedExportData('done', 'completed')]); + let rejectDownload: ((error: Error) => void) | undefined; + gmp.reportexport.downloadReportExport.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectDownload = reject; + }), + ); + const onDownload = testing.fn(); + const {result} = renderReportExport(gmp, {onDownload}); + await act(async () => { + await result.current.start(startParams); + }); + await waitFor(() => + expect(gmp.reportexport.downloadReportExport).toHaveBeenCalledTimes(1), + ); + act(() => changeSession(gmp)); + await act(async () => { + rejectDownload?.(new Error('Session ended')); + }); + expect(result.current.jobs).toHaveLength(0); + expect(gmp.reportexport.downloadReportExport).toHaveBeenCalledTimes(1); + expect(onError).not.toHaveBeenCalled(); + expect(onDownload).not.toHaveBeenCalled(); + expect(await result.current.start(startParams)).toBe(false); + }); + + test('ignores cancellation that completes after logout', async () => { + const gmp = createGmp([ownedExportData('running')]); + let resolveCancellation: ((value: unknown) => void) | undefined; + gmp.reportexport.cancelReportExport.mockImplementationOnce( + () => + new Promise(resolve => { + resolveCancellation = resolve; + }), + ); + const {result} = renderReportExport(gmp); + await act(async () => { + await result.current.start(startParams); + }); + await waitFor(() => + expect(result.current.jobs[0]?.view.kind).toBe('generating'), + ); + + let cancellation: Promise | undefined; + act(() => { + cancellation = result.current.cancel(result.current.jobs[0].key); + }); + await waitFor(() => + expect(gmp.reportexport.cancelReportExport).toHaveBeenCalledTimes(1), + ); + act(() => changeSession(gmp)); + expect(result.current.jobs).toHaveLength(0); + await act(async () => { + resolveCancellation?.({}); + await cancellation; + }); + + // the cancellation completes after logout and must not affect anything + expect(result.current.jobs).toHaveLength(0); + expect(result.current.isActive).toBe(false); + expect(onError).not.toHaveBeenCalled(); + act(() => changeSession(gmp, 'refreshed-token', 'test-user')); + expect(result.current.jobs).toHaveLength(0); + expect(gmp.reportexport.cancelReportExport).toHaveBeenCalledTimes(1); + }); + + test('ignores export creation that completes after logout', async () => { + const gmp = createGmp(); + let resolveCreate: ((response: {data: {id: string}}) => void) | undefined; + gmp.reportexport.exportScanReport.mockImplementationOnce( + () => + new Promise(resolve => { + resolveCreate = resolve; + }), + ); + const onDownload = testing.fn(); + const {result} = renderReportExport(gmp, {onDownload}); + let creation: Promise | undefined; + act(() => { + creation = result.current.start(startParams); + }); + await waitFor(() => + expect(gmp.reportexport.exportScanReport).toHaveBeenCalledTimes(1), + ); + act(() => changeSession(gmp)); + act(() => changeSession(gmp, 'other-token', 'other-user')); + await act(async () => { + resolveCreate?.({data: {id: 'old-export'}}); + expect(await creation).toBe(false); + }); + expect(result.current.jobs).toHaveLength(0); + expect(gmp.reportexport.downloadReportExport).not.toHaveBeenCalled(); + expect(onDownload).not.toHaveBeenCalled(); + }); + + test('uses async commands for non-PDF formats across report types', async () => { + const gmp = createGmp(); + gmp.reportexport.exportScanReport.mockResolvedValue({ + data: {id: 'scan-export'}, + }); + gmp.reportexport.exportAuditReport.mockResolvedValue({ + data: {id: 'audit-export'}, + }); + gmp.reportexport.exportDeltaScanReport.mockResolvedValue({ + data: {id: 'delta-scan-export'}, + }); + gmp.reportexport.exportDeltaAuditReport.mockResolvedValue({ + data: {id: 'delta-audit-export'}, + }); + const {result} = renderReportExport(gmp); + const csvParams = { + ...startParams, + payload: {...exportPayload, reportFormatId: 'csv-format-id'}, + }; + + await act(async () => { + await Promise.all([ + result.current.start({...csvParams, kind: 'scan'}), + result.current.start({...csvParams, kind: 'audit'}), + result.current.start({...csvParams, kind: 'delta_scan'}), + result.current.start({...csvParams, kind: 'delta_audit'}), + ]); + }); + + for (const command of [ + gmp.reportexport.exportScanReport, + gmp.reportexport.exportAuditReport, + gmp.reportexport.exportDeltaScanReport, + gmp.reportexport.exportDeltaAuditReport, + ]) { + expect(command).toHaveBeenCalledWith( + expect.objectContaining({reportFormatId: 'csv-format-id'}), + ); + } + }); +}); diff --git a/src/web/report-export/build-report-export-request.ts b/src/web/report-export/build-report-export-request.ts new file mode 100644 index 0000000000..5e5ec3f2f7 --- /dev/null +++ b/src/web/report-export/build-report-export-request.ts @@ -0,0 +1,79 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {type Date} from 'gmp/models/date'; +import {type FilterType} from 'gmp/models/filter'; +import { + type ReportExportKind, + type StartReportExportParams, +} from 'web/report-export/report-export-job-model'; +import {generateFilename} from 'web/utils/Render'; + +interface ReportExportEntity { + id: string; + creationTime?: Date; + modificationTime?: Date; + task?: {name?: string}; +} + +interface ReportExportFormat { + id: string; + name?: string; + extension?: string; + content_type?: string; +} + +interface BuildReportExportRequestParams { + kind: ReportExportKind; + entity: ReportExportEntity; + format: ReportExportFormat; + filter: FilterType; + reportConfigId?: string; + deltaReportId?: string; + reportUrl?: string; + fileNameFormat?: string; + username?: string; + fallbackReportTitle: string; +} + +export const buildReportExportRequest = ({ + kind, + entity, + format, + filter, + reportConfigId, + deltaReportId, + reportUrl, + fileNameFormat, + username, +}: BuildReportExportRequestParams): StartReportExportParams => ({ + kind, + filename: generateFilename({ + creationTime: entity.creationTime, + extension: format.extension || 'unknown', + fileNameFormat, + id: entity.id, + modificationTime: entity.modificationTime, + reportFormat: format.name, + resourceName: entity.task?.name, + resourceType: 'report', + username, + }), + reportTitle: entity.id, + reportUrl, + payload: { + reportId: entity.id, + reportFormatId: format.id, + reportConfigId: + kind === 'scan' || kind === 'delta_scan' + ? reportConfigId || undefined + : undefined, + deltaReportId: + kind === 'delta_scan' || kind === 'delta_audit' + ? deltaReportId + : undefined, + filter, + }, +}); diff --git a/src/web/report-export/report-export-activity-styles.ts b/src/web/report-export/report-export-activity-styles.ts new file mode 100644 index 0000000000..82bd739bc9 --- /dev/null +++ b/src/web/report-export/report-export-activity-styles.ts @@ -0,0 +1,149 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {Badge, EThemeColors} from '@greenbone/ui-lib'; +import styled, {keyframes} from 'styled-components'; +import {type ReportExportStatusTone} from 'web/report-export/report-export-job-display'; +import Theme from 'web/utils/theme'; + +export const BADGE_COLORS: Record = { + active: EThemeColors.Green, + queued: EThemeColors.Blue, + downloading: EThemeColors.Blue, + canceled: EThemeColors.NeutralLight, + error: EThemeColors.RedLight, + complete: EThemeColors.Green, +}; + +const spin = keyframes` + to { + transform: rotate(360deg); + } +`; + +// the ui-lib Spinner is a page-level loading container without a size prop, +// so the badge icon spins the refresh icon itself +export const Spinning = styled.span` + animation: ${spin} 1.2s linear infinite; + display: inline-flex; +`; + +// light badge look (colored text on light background) instead of Badge's +// solid fill +export const StatusBadge = styled(Badge)<{$tone: ReportExportStatusTone}>` + && { + ${props => + props.$tone === 'queued' || props.$tone === 'downloading' + ? ` + background-color: var(--info-tip-background-color); + color: var(--info-tip-color); + ` + : ''} + } + flex: 0 0 auto; + font-size: 12px; + font-weight: 600; + justify-content: flex-start; + line-height: 1; + white-space: nowrap; +`; + +export const StatusIcon = styled.span` + align-items: center; + display: inline-flex; + height: 14px; + width: 14px; +`; + +export const BADGE_ICON_SIZE: [string, string] = ['14px', '14px']; + +export const ActivityTrigger = styled.button` + align-items: center; + background: transparent; + border: none; + cursor: pointer; + display: inline-flex; + padding: 4px; + position: relative; + + @media (max-width: 600px) { + background: var(--mantine-color-black); + position: fixed; + right: 8px; + top: 9px; + } +`; + +export const ActivityDot = styled.span<{ + $state: 'active' | 'error' | 'complete'; +}>` + background: ${props => { + if (props.$state === 'error') return Theme.darkRed; + if (props.$state === 'active') return Theme.green; + return Theme.mediumGray; + }}; + border: 2px solid ${Theme.darkGray}; + border-radius: 50%; + height: 9px; + position: absolute; + right: 0; + top: 1px; + width: 9px; +`; + +export const ActivityTitle = styled.span` + display: block; + color: ${Theme.black}; + font-size: 14px; + font-weight: 600; + min-width: 0; + overflow-wrap: anywhere; + width: 100%; +`; + +export const ActivityErrorText = styled.div` + color: ${Theme.darkRed}; + font-size: 14px; +`; + +export const ActivityStatusDetail = styled.span` + color: ${Theme.darkGray}; + font-size: 12px; + overflow-wrap: anywhere; +`; + +export const ActivityMetadataSeparator = styled.span` + color: ${Theme.darkGray}; + flex: 0 0 auto; +`; + +export const ActivityTimestamp = styled.div` + color: ${Theme.darkGray}; + font-size: 12px; + line-height: 1.3; + margin-left: auto; + min-width: 0; + overflow-wrap: anywhere; + + span { + font-weight: 400; + } +`; + +export const ActivityJob = styled.div` + background: ${Theme.white}; + border: 1px solid ${Theme.lightGray}; + border-radius: 6px; + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; + padding: 16px; +`; + +export const ActivityDivider = styled.div` + border-top: 1px solid ${Theme.lightGray}; + width: 100%; +`; diff --git a/src/web/report-export/report-export-job-display.ts b/src/web/report-export/report-export-job-display.ts new file mode 100644 index 0000000000..623fc79099 --- /dev/null +++ b/src/web/report-export/report-export-job-display.ts @@ -0,0 +1,105 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {type TranslateFunc} from 'web/hooks/useTranslation'; +import { + type ReportExportJobView, + type ReportExportJob, +} from 'web/report-export/report-export-job-model'; + +export type ReportExportStatusTone = + | 'active' + | 'queued' + | 'downloading' + | 'canceled' + | 'error' + | 'complete'; + +export interface ReportExportJobDisplay { + label: string; + detail?: string; + tone: ReportExportStatusTone; +} + +const getReportExportFailureLabel = ( + reason: Extract['reason'], + _: TranslateFunc, +) => { + switch (reason) { + case 'export': + return _('Export failed'); + case 'download': + return _('Download failed'); + case 'unavailable': + return _('Unavailable'); + } +}; + +export const getReportExportJobDisplay = ( + {view}: ReportExportJob, + _: TranslateFunc, +): ReportExportJobDisplay => { + switch (view.kind) { + case 'creating': + return {label: _('Preparing'), tone: 'active'}; + case 'checking': + return {label: _('Checking'), tone: 'active'}; + case 'queued': + return {label: _('Queued'), tone: 'queued'}; + case 'generating': + return { + label: view.progress === 'preparing' ? _('Preparing') : _('Generating'), + detail: + view.progress && + view.progress !== 'preparing' && + view.progress !== 'generating' + ? _('Progress: {{progress}}', {progress: view.progress}) + : undefined, + tone: 'active', + }; + case 'canceling': + return {label: _('Canceling'), tone: 'queued'}; + case 'downloading': + return { + label: _('Downloading'), + tone: 'downloading', + }; + case 'complete': + return {label: _('Complete'), tone: 'complete'}; + case 'canceled': + return {label: _('Canceled'), tone: 'canceled'}; + case 'failed': + return { + label: getReportExportFailureLabel(view.reason, _), + detail: view.error.message, + tone: 'error', + }; + } +}; + +export const getReportExportJobTitle = ( + job: ReportExportJob, + _: TranslateFunc, +) => { + let title = _('Report export'); + if (job.reportTitle) { + title = _('Report export: {{report}}', {report: job.reportTitle}); + } + const extensionIndex = job.filename.lastIndexOf('.'); + const extension = + extensionIndex > 0 + ? job.filename.slice(extensionIndex + 1).toLowerCase() + : ''; + return extension ? `${title} (.${extension})` : title; +}; + +export const getReportExportCancelTitle = ( + {view, cancelError}: ReportExportJob, + _: TranslateFunc, +) => { + if (view.kind === 'canceling') return _('Cancellation requested'); + if (cancelError) return _('Retry cancellation'); + return _('Cancel report export'); +}; diff --git a/src/web/report-export/report-export-job-model.ts b/src/web/report-export/report-export-job-model.ts new file mode 100644 index 0000000000..d0b4a19e87 --- /dev/null +++ b/src/web/report-export/report-export-job-model.ts @@ -0,0 +1,270 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {type ReportExportPayload} from 'gmp/commands/report-export'; +import {type ReportExport} from 'gmp/models/report-export'; +import {isDefined} from 'gmp/utils/identity'; + +type ReportExportProgress = NonNullable; + +export type ReportExportKind = 'scan' | 'audit' | 'delta_scan' | 'delta_audit'; + +export const MAX_RETAINED_EXPORT_HISTORY_COUNT = 50; + +export interface StartReportExportParams { + kind: ReportExportKind; + payload: ReportExportPayload; + filename: string; + reportTitle: string; + reportUrl?: string; +} + +// Server state lives entirely in the per-export status poll. A record only +// holds the client-side bits the server cannot observe: which export it maps +// to, its display metadata, and local operation state. +export interface ReportExportRecord { + key: string; + exportId?: string; + filename: string; + reportTitle: string; + reportUrl?: string; + // cancel was requested while the export id did not exist yet + cancelRequested?: boolean; + downloading?: boolean; + downloaded?: boolean; + createError?: Error; + downloadError?: Error; + cancelError?: Error; +} + +type ReportExportFailureReason = 'export' | 'download' | 'unavailable'; + +export type ReportExportJobView = + | { + kind: + | 'creating' + | 'checking' + | 'queued' + | 'canceling' + | 'downloading' + | 'complete' + | 'canceled'; + } + | {kind: 'generating'; progress?: ReportExportProgress} + | {kind: 'failed'; reason: ReportExportFailureReason; error: Error}; + +export interface ReportExportJob extends ReportExportRecord { + view: ReportExportJobView; + exportData?: ReportExport; +} + +// kinds that end a job; every other kind is part of an active export +const TERMINAL_JOB_VIEW_KINDS = new Set([ + 'complete', + 'canceled', + 'failed', +]); + +export const isReportExportJobFinished = ({view}: ReportExportJob) => + TERMINAL_JOB_VIEW_KINDS.has(view.kind); + +// kinds whose export can still be canceled on the server +const CANCELABLE_JOB_VIEW_KINDS = new Set([ + 'creating', + 'checking', + 'queued', + 'generating', +]); + +export const getReportExportActions = ({ + view, +}: ReportExportJob): { + active: boolean; + cancel: boolean; + dismiss: boolean; + retry: boolean; +} => { + const isActive = !TERMINAL_JOB_VIEW_KINDS.has(view.kind); + return { + active: isActive, + cancel: CANCELABLE_JOB_VIEW_KINDS.has(view.kind), + dismiss: !isActive || view.kind === 'checking', + retry: + view.kind === 'failed' && + (view.reason === 'unavailable' || view.reason === 'download'), + }; +}; + +export class ReportExportUnavailableError extends Error {} + +const getHttpStatus = (error: unknown) => + error && typeof error === 'object' && 'status' in error + ? error.status + : undefined; + +export const isPermanentReportExportError = (error: unknown) => { + const httpStatus = getHttpStatus(error); + return ( + error instanceof ReportExportUnavailableError || + (typeof httpStatus === 'number' && + [400, 401, 403, 404].includes(httpStatus)) + ); +}; + +const getDownloadFailureReason = (error: Error): ReportExportFailureReason => + isPermanentReportExportError(error) ? 'unavailable' : 'download'; + +const createFailedView = (reason: ReportExportFailureReason, error: Error) => ({ + kind: 'failed' as const, + reason, + error, +}); + +// Decides the outcomes the server determines on its own: terminal statuses +// (error, canceled) and statuses the client does not know (e.g. from a newer +// gvmd version). Known generation statuses return undefined and are mapped +// by the caller. +const getServerStatusView = ( + exportData?: ReportExport, +): ReportExportJobView | undefined => { + const status = exportData?.status; + switch (status) { + case undefined: + return undefined; + case 'error': + return createFailedView( + 'export', + new Error(exportData?.errorMessage || 'Export failed'), + ); + case 'canceled': + return {kind: 'canceled'}; + case 'pending': + case 'running': + case 'cancel_requested': + case 'done': + return undefined; + default: + return createFailedView( + 'unavailable', + new Error( + exportData?.errorMessage || 'Export is in an unexpected state', + ), + ); + } +}; + +const getReportExportJobView = ( + record: ReportExportRecord, + exportData?: ReportExport, + statusError?: Error | null, +): ReportExportJobView => { + const serverStatusView = getServerStatusView(exportData); + if (record.createError) { + return createFailedView('export', record.createError); + } + if (record.downloaded) return {kind: 'complete'}; + if (serverStatusView) return serverStatusView; + if (record.downloadError) { + return createFailedView( + getDownloadFailureReason(record.downloadError), + record.downloadError, + ); + } + if (record.downloading) return {kind: 'downloading'}; + if (!record.exportId) { + return record.cancelRequested ? {kind: 'canceling'} : {kind: 'creating'}; + } + if (record.cancelRequested) return {kind: 'canceling'}; + switch (exportData?.status) { + case 'cancel_requested': + return {kind: 'canceling'}; + case 'pending': + return {kind: 'queued'}; + case 'running': + return {kind: 'generating', progress: exportData?.progress}; + case 'done': + return {kind: 'downloading'}; + } + // no status data yet: keep checking; a permanent status failure is + // terminal + if (statusError && isPermanentReportExportError(statusError)) { + return createFailedView(getDownloadFailureReason(statusError), statusError); + } + return {kind: 'checking'}; +}; + +export const createReportExportJob = ( + record: ReportExportRecord, + exportData?: ReportExport, + statusError?: Error | null, +): ReportExportJob => ({ + ...record, + view: getReportExportJobView(record, exportData, statusError), + exportData, +}); + +export interface AdoptedExportMetadata { + filename: string; + reportTitle: string; + reportUrl?: string; +} + +// Binds a create response to the record waiting for it. When gvmd reuses an +// export id, the re-export intent (metadata and queued cancellation) carries +// over to the record already tracking it. +export const adoptCreatedExportRecord = ( + previous: ReportExportRecord[], + key: string, + exportId: string, + metadata: AdoptedExportMetadata, +): ReportExportRecord[] => { + const target = previous.find(record => record.key === key); + if (!isDefined(target)) return previous; + const existing = previous.find( + record => record.key !== key && record.exportId === exportId, + ); + if (!isDefined(existing)) { + return previous.map(record => + record.key === key ? {...record, exportId} : record, + ); + } + return previous + .map(record => + record.key === existing.key + ? { + ...record, + ...metadata, + cancelRequested: record.cancelRequested || target.cancelRequested, + } + : record, + ) + .filter(record => record.key !== key); +}; + +export const shouldAutoDownloadRecord = ( + record: ReportExportRecord, + exportData?: ReportExport, +) => + isDefined(record.exportId) && + !record.downloading && + !record.downloaded && + !isDefined(record.createError) && + !isDefined(record.downloadError) && + exportData?.status === 'done'; + +export const retainReportExportJobs = (jobs: ReportExportJob[]) => { + const retainedJobKeys = new Set( + jobs.filter(job => getReportExportActions(job).active).map(job => job.key), + ); + const recentJobKeys = new Set( + jobs + .filter(job => !retainedJobKeys.has(job.key)) + .slice(-MAX_RETAINED_EXPORT_HISTORY_COUNT) + .map(job => job.key), + ); + return jobs.filter( + job => retainedJobKeys.has(job.key) || recentJobKeys.has(job.key), + ); +}; diff --git a/src/web/report-export/useReportExportActivity.ts b/src/web/report-export/useReportExportActivity.ts new file mode 100644 index 0000000000..6b098eed6f --- /dev/null +++ b/src/web/report-export/useReportExportActivity.ts @@ -0,0 +1,68 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {useEffect, useRef} from 'react'; +import {type ReportExportJob} from 'web/report-export/report-export-job-model'; +import {useReportExportManager} from 'web/report-export/ReportExportManager'; + +type IndicatorState = 'error' | 'active' | 'complete'; + +const isComplete = (job: ReportExportJob) => job.view.kind === 'complete'; + +// newest exports first; jobs without a server creation time (started but +// not yet polled) count as the newest and come first. Infinity - Infinity +// is NaN, which is falsy, so the fallback orders those ties by tracking +// order. dayjs instances order by their valueOf(). +const getReportExportJobSortKey = (job: ReportExportJob) => + job.exportData?.creationTime?.valueOf() ?? Number.POSITIVE_INFINITY; + +const compareReportExportJobs = ( + first: {job: ReportExportJob; index: number}, + second: {job: ReportExportJob; index: number}, +) => + getReportExportJobSortKey(second.job) - + getReportExportJobSortKey(first.job) || second.index - first.index; + +const useReportExportActivity = () => { + const {activityOpen, clearFinished, isActive, jobs, setActivityOpen} = + useReportExportManager(); + const previousJobKeys = useRef(new Set(jobs.map(job => job.key))); + + // Every tracked row stays visible and counted until it is removed by the + // user or the login session ends; completed rows keep the activity + // accessible after closing the popover. + const visibleJobs = jobs + .map((job, index) => ({job, index})) + .sort(compareReportExportJobs) + .map(({job}) => job); + const changeActivityOpen = setActivityOpen; + + // Newly started exports open the activity automatically. + useEffect(() => { + const additions = jobs + .filter(job => !previousJobKeys.current.has(job.key) && !isComplete(job)) + .map(job => job.key); + if (additions.length) { + setActivityOpen(true); + } + previousJobKeys.current = new Set(jobs.map(job => job.key)); + }, [jobs, setActivityOpen]); + + let indicatorState: IndicatorState = 'complete'; + if (isActive) indicatorState = 'active'; + if (visibleJobs.some(job => job.view.kind === 'failed' || job.cancelError)) + indicatorState = 'error'; + + return { + activityOpen, + changeActivityOpen, + clearFinished, + indicatorState, + shouldRenderActivity: visibleJobs.length > 0, + visibleJobs, + }; +}; + +export default useReportExportActivity; diff --git a/src/web/report-export/useReportExportMutations.ts b/src/web/report-export/useReportExportMutations.ts new file mode 100644 index 0000000000..6f9a764e8d --- /dev/null +++ b/src/web/report-export/useReportExportMutations.ts @@ -0,0 +1,47 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {useMemo} from 'react'; +import useGmp from 'web/hooks/useGmp'; +import useGmpMutation from 'web/queries/useGmpMutation'; +import {type StartReportExportParams} from 'web/report-export/report-export-job-model'; + +const EXPORT_METHODS = { + scan: 'exportScanReport', + audit: 'exportAuditReport', + delta_scan: 'exportDeltaScanReport', + delta_audit: 'exportDeltaAuditReport', +} as const; + +export const useReportExportMutations = () => { + const gmp = useGmp(); + const create = useGmpMutation< + Pick, + {data: {id: string}}, + Error + >({ + gmpMethod: ({kind, payload}) => + gmp.reportexport[EXPORT_METHODS[kind]](payload), + }); + const cancel = useGmpMutation({ + gmpMethod: reportExportId => + gmp.reportexport.cancelReportExport({reportExportId}), + }); + const download = useGmpMutation({ + gmpMethod: reportExportId => + gmp.reportexport.downloadReportExport({reportExportId}), + }); + + return useMemo( + () => ({ + create: create.mutateAsync, + cancel: cancel.mutateAsync, + download: download.mutateAsync, + }), + [create.mutateAsync, cancel.mutateAsync, download.mutateAsync], + ); +}; + +export type ReportExportMutations = ReturnType; diff --git a/src/web/report-export/useReportExportQueries.ts b/src/web/report-export/useReportExportQueries.ts new file mode 100644 index 0000000000..d80b5c4ee2 --- /dev/null +++ b/src/web/report-export/useReportExportQueries.ts @@ -0,0 +1,95 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {queryOptions, useQueries} from '@tanstack/react-query'; +import {isReportExportStatusActive} from 'gmp/models/report-export'; +import {isDefined} from 'gmp/utils/identity'; +import {USE_DEFAULT_RELOAD_INTERVAL_ACTIVE} from 'web/components/loading/Reload'; +import useGmp from 'web/hooks/useGmp'; +import useSessionToken from 'web/hooks/useSessionToken'; +import useUserName from 'web/hooks/useUserName'; +import {resolveRefetchInterval} from 'web/queries/helpers'; +import { + isPermanentReportExportError, + ReportExportUnavailableError, + type ReportExportRecord, +} from 'web/report-export/report-export-job-model'; + +export const reportExportQueryId = 'get_report_export'; + +export const reportExportQueryKey = ( + token?: string, + username?: string, + id?: string, +) => [reportExportQueryId, token, username, id]; + +// Status of a single report export, polled while the export is still being +// generated. Token-scoped so a session switch never reads another session's +// cache entries. +export const reportExportQueryOptions = ( + gmp: { + reportexport: Pick['reportexport'], 'get'>; + }, + token: string | undefined, + username: string | undefined, + settings: { + reloadInterval: number; + reloadIntervalActive: number; + reloadIntervalInactive: number; + }, + id?: string, +) => + queryOptions({ + queryKey: reportExportQueryKey(token, username, id), + enabled: Boolean(token && username && id), + queryFn: async () => { + if (!id) + throw new ReportExportUnavailableError('Report export ID is missing'); + const response = await gmp.reportexport.get({reportExportId: id}); + const reportExport = response.data; + if (!isDefined(reportExport) || reportExport.id !== id) + throw new ReportExportUnavailableError( + 'Report export is no longer available', + ); + return reportExport; + }, + retry: (count, error) => !isPermanentReportExportError(error) && count < 2, + retryDelay: (count: number) => Math.min(1000 * 2 ** count, 30000), + refetchInterval: query => { + if (isPermanentReportExportError(query.state.error)) return false; + if (query.state.error) return settings.reloadIntervalActive; + return !query.state.data || + isReportExportStatusActive(query.state.data.status) + ? resolveRefetchInterval(USE_DEFAULT_RELOAD_INTERVAL_ACTIVE, settings) + : false; + }, + }); + +// Status queries for every tracked export at once. +export const useReportExportStatusQueries = (records: ReportExportRecord[]) => { + const gmp = useGmp(); + const token = useSessionToken(); + const username = useUserName(); + return useQueries({ + queries: records.map(record => { + const options = reportExportQueryOptions( + gmp, + token, + username, + gmp.settings, + record.exportId, + ); + return { + ...options, + enabled: + options.enabled && + isDefined(record.exportId) && + !isDefined(record.createError) && + !record.downloading && + !record.downloaded, + }; + }), + }); +}; diff --git a/src/web/report-export/useReportExports.ts b/src/web/report-export/useReportExports.ts new file mode 100644 index 0000000000..8370e5ef14 --- /dev/null +++ b/src/web/report-export/useReportExports.ts @@ -0,0 +1,401 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {useQueryClient} from '@tanstack/react-query'; +import {v4 as uuid} from 'uuid'; +import type Gmp from 'gmp/gmp'; +import type ReportExport from 'gmp/models/report-export'; +import {isDefined} from 'gmp/utils/identity'; +import useGmp from 'web/hooks/useGmp'; +import useLatestCallback from 'web/hooks/useLatestCallback'; +import useSessionToken from 'web/hooks/useSessionToken'; +import useUserName from 'web/hooks/useUserName'; +import { + type ReportExportRecord, + type StartReportExportParams, + adoptCreatedExportRecord, + createReportExportJob, + getReportExportActions, + isPermanentReportExportError, + isReportExportJobFinished, + retainReportExportJobs, + shouldAutoDownloadRecord, +} from 'web/report-export/report-export-job-model'; +import {useReportExportMutations} from 'web/report-export/useReportExportMutations'; +import { + reportExportQueryKey, + useReportExportStatusQueries, +} from 'web/report-export/useReportExportQueries'; + +export type { + ReportExportJob, + ReportExportKind, + ReportExportJobView, + StartReportExportParams, +} from 'web/report-export/report-export-job-model'; + +export const REPORT_EXPORT_DOWNLOAD_RETRY_INTERVAL = 3000; + +export interface ReportExportCallbacks { + onDownload: ( + data: ArrayBuffer | string, + filename: string, + mimetype?: string, + ) => void; + onError?: (error: Error) => void; +} + +const toError = (error: unknown) => { + if (error instanceof Error) return error; + if (typeof error === 'string') return new Error(error); + return new Error('Unexpected report export error'); +}; + +// Async continuations must not act after the login session changed. A GMP +// token belongs to exactly one user, so comparing the token is sufficient. +const isLive = (gmp: Pick, ownerToken?: string) => + isDefined(gmp.session.token) && + (ownerToken === undefined || gmp.session.token === ownerToken); + +const useReportExport = ({onDownload, onError}: ReportExportCallbacks) => { + const gmp = useGmp(); + const token = useSessionToken(); + const username = useUserName(); + const queryClient = useQueryClient(); + const mutations = useReportExportMutations(); + + const [records, setRecords] = useState([]); + const inFlightIds = useRef(new Set()); + const cancelingIds = useRef(new Set()); + const disposedRef = useRef(false); + const recordsRef = useRef([]); + useEffect(() => { + recordsRef.current = records; + }, [records]); + useEffect(() => { + disposedRef.current = false; + return () => { + disposedRef.current = true; + }; + }, []); + + const handoffDownload = useLatestCallback( + (data: ArrayBuffer | string, filename: string, mimetype?: string) => { + onDownload(data, filename, mimetype); + }, + ); + const notify = useLatestCallback((error: Error) => { + onError?.(error); + }); + + // Local activity only makes sense within one login session. + const lastTokenRef = useRef(undefined); + useEffect(() => { + if (isDefined(lastTokenRef.current) && lastTokenRef.current !== token) { + setRecords([]); + inFlightIds.current.clear(); + cancelingIds.current.clear(); + queryClient.removeQueries({queryKey: ['get_report_export']}); + } + lastTokenRef.current = token; + }, [token, queryClient]); + + const updateRecord = useCallback( + (key: string, update: (record: ReportExportRecord) => ReportExportRecord) => + setRecords(previous => + previous.some(record => record.key === key) + ? previous.map(record => + record.key === key ? update(record) : record, + ) + : previous, + ), + [], + ); + + const removeRecord = useCallback( + (key: string) => + setRecords(previous => + previous.some(record => record.key === key) + ? previous.filter(record => record.key !== key) + : previous, + ), + [], + ); + + const statusQueries = useReportExportStatusQueries(records); + const exportById = useMemo(() => { + const exports = new Map(); + records.forEach((record, index) => { + const data = statusQueries[index]?.data; + if (isDefined(record.exportId) && isDefined(data)) { + exports.set(record.exportId, data); + } + }); + return exports; + }, [records, statusQueries]); + + const jobsByKey = useMemo( + () => + new Map( + records + .map((record, index) => + createReportExportJob( + record, + isDefined(record.exportId) + ? exportById.get(record.exportId) + : undefined, + statusQueries[index]?.error, + ), + ) + .map(job => [job.key, job] as const), + ), + [records, exportById, statusQueries], + ); + + const requestCancellation = useLatestCallback( + async (key: string, exportId: string) => { + // one cancellation request at a time per export + if (cancelingIds.current.has(exportId)) return; + cancelingIds.current.add(exportId); + const ownerToken = gmp.session.token; + try { + await mutations.cancel(exportId); + if (isLive(gmp, ownerToken)) + updateRecord(key, current => ({ + ...current, + cancelRequested: false, + cancelError: undefined, + })); + } catch (error) { + if (isLive(gmp, ownerToken)) { + const failure = toError(error); + updateRecord(key, current => ({ + ...current, + cancelRequested: false, + cancelError: failure, + })); + notify(failure); + } + } finally { + cancelingIds.current.delete(exportId); + if (isLive(gmp, ownerToken)) { + // not awaited: invalidation must not block on a refetch whose + // query may still be waiting for a pending response + queryClient + .invalidateQueries({ + queryKey: reportExportQueryKey(token, username, exportId), + }) + .catch(() => {}); + } + } + }, + ); + + const handoff = ( + record: ReportExportRecord, + data: ArrayBuffer | string, + mimetype?: string, + ) => { + const {key, filename} = record; + const reportExportId = record.exportId; + if (!isDefined(reportExportId)) return; + try { + handoffDownload(data, filename, mimetype); + } catch (error) { + inFlightIds.current.delete(reportExportId); + const failure = toError(error); + updateRecord(key, current => ({ + ...current, + downloading: false, + downloaded: false, + downloadError: failure, + })); + notify(failure); + return; + } + inFlightIds.current.delete(reportExportId); + updateRecord(key, current => ({ + ...current, + downloading: false, + downloaded: true, + downloadError: undefined, + })); + }; + + // after the retry delay, the resumed attempt must still be meaningful: + // the session must be alive, the hook mounted, and the export tracked + const canResumeRetry = ( + ownerToken: string | undefined, + reportExportId: string, + ) => + isLive(gmp, ownerToken) && + !disposedRef.current && + recordsRef.current.some(item => item.exportId === reportExportId); + + const attemptDownload = useLatestCallback( + // a named function expression so the automatic retry can re-enter itself + async function retryableDownload( + key: string, + reportExportId: string, + mimetype?: string, + retried = false, + ) { + if (inFlightIds.current.has(reportExportId)) return; + const record = recordsRef.current.find(item => item.key === key); + if (!record) return; + inFlightIds.current.add(reportExportId); + updateRecord(key, current => ({ + ...current, + downloading: true, + downloadError: undefined, + })); + const ownerToken = gmp.session.token; + try { + const response = await mutations.download(reportExportId); + if (!isLive(gmp, ownerToken)) return; + handoff(record, response.data, mimetype); + } catch (error) { + if (!isLive(gmp, ownerToken)) return; + const failure = toError(error); + if (!isPermanentReportExportError(failure) && !retried) { + // single automatic retry; the bytes were not received yet. a + // manual retry during the wait is a no-op (still in flight). + await new Promise(resolve => + setTimeout(resolve, REPORT_EXPORT_DOWNLOAD_RETRY_INTERVAL), + ); + if (!canResumeRetry(ownerToken, reportExportId)) return; + inFlightIds.current.delete(reportExportId); + await retryableDownload(key, reportExportId, undefined, true); + return; + } + updateRecord(key, current => ({ + ...current, + downloading: false, + downloadError: failure, + })); + notify(failure); + } finally { + inFlightIds.current.delete(reportExportId); + } + }, + ); + + // Replay a cancellation that was queued while the export id did not exist + // yet, whenever the record becomes bound. + useEffect(() => { + for (const record of records) { + if (!isDefined(record.exportId) || !record.cancelRequested) continue; + requestCancellation(record.key, record.exportId).catch(() => {}); + } + }, [records, requestCancellation]); + + // Pick up completed tracked exports and hand their file to the browser. + useEffect(() => { + for (const record of records) { + const exportId = record.exportId; + if (!isDefined(exportId)) continue; + const item = exportById.get(exportId); + if (!shouldAutoDownloadRecord(record, item)) continue; + attemptDownload( + record.key, + exportId, + item?.contentType ?? undefined, + ).catch(() => {}); + } + }, [records, exportById, attemptDownload]); + + const start = async (params: StartReportExportParams) => { + if (!isLive(gmp)) { + return false; + } + const ownerToken = gmp.session.token; + const key = `report-export-${uuid()}`; + setRecords(previous => [ + ...previous, + { + key, + filename: params.filename, + reportTitle: params.reportTitle, + reportUrl: params.reportUrl, + }, + ]); + try { + const response = await mutations.create({ + kind: params.kind, + payload: params.payload, + }); + if (!isLive(gmp, ownerToken)) { + removeRecord(key); + return false; + } + // gvmd reuses only pending/running exports, so an identical request + // can return the id of an export this client already tracks. + setRecords(previous => + adoptCreatedExportRecord(previous, key, response.data.id, params), + ); + return true; + } catch (error) { + if (!isLive(gmp, ownerToken)) { + removeRecord(key); + return false; + } + const failure = toError(error); + updateRecord(key, current => ({...current, createError: failure})); + notify(failure); + return false; + } + }; + + const cancel = async (key: string) => { + const job = jobsByKey.get(key); + const record = recordsRef.current.find(item => item.key === key); + if (!job || !record || !getReportExportActions(job).cancel) return; + if (!isDefined(record.exportId)) { + // gvmd only accepts cancellation for existing exports; queue it until + // the id is known + updateRecord(key, current => ({...current, cancelRequested: true})); + return; + } + await requestCancellation(record.key, record.exportId); + }; + + const dismiss = (key: string) => { + const job = jobsByKey.get(key); + if (!job || !getReportExportActions(job).dismiss) return; + removeRecord(key); + }; + + const clearFinished = () => { + setRecords(previous => + previous.filter(record => { + const job = jobsByKey.get(record.key); + return !job || !isReportExportJobFinished(job); + }), + ); + }; + + const retry = async (key: string) => { + const job = jobsByKey.get(key); + if (!job || !getReportExportActions(job).retry) return; + if (isDefined(job.exportId)) + // a re-download is always possible: gvmd retains the generated file + await attemptDownload(key, job.exportId, undefined, false); + }; + + const jobs = retainReportExportJobs([...jobsByKey.values()]); + + return { + start, + cancel, + dismiss, + retry, + clearFinished, + jobs, + isActive: jobs.some(job => getReportExportActions(job).active), + }; +}; + +export default useReportExport; From 3ed6293d1d6f51cd2799680508d4a46f46121cb2 Mon Sep 17 00:00:00 2001 From: Daniele Mangiagli Date: Tue, 20 Oct 2026 13:07:20 +0200 Subject: [PATCH 2/2] fix: handle report export status failures and expir --- .../commands/__tests__/report-export.test.ts | 19 +- src/gmp/commands/report-export.ts | 25 +- .../reports/AuditDeltaReportDetailsPage.jsx | 19 +- .../pages/reports/AuditReportDetailsPage.tsx | 16 +- src/web/pages/reports/DeltaDetailsPage.tsx | 8 +- src/web/pages/reports/ReportDetailsPage.tsx | 17 +- src/web/report-export/ReportExportJobRow.tsx | 4 +- src/web/report-export/ReportExportManager.tsx | 74 +++-- .../__tests__/ReportExportManager.test.tsx | 69 ++++- .../build-report-export-request.test.ts | 1 - .../__tests__/useReportExport.test.tsx | 257 ++++++++++++++++-- .../build-report-export-request.ts | 1 - .../report-export-job-display.ts | 2 + .../report-export/report-export-job-model.ts | 64 +++-- .../report-export/useReportExportActivity.ts | 10 +- .../report-export/useReportExportMutations.ts | 11 +- src/web/report-export/useReportExports.ts | 88 ++++-- 17 files changed, 502 insertions(+), 183 deletions(-) diff --git a/src/gmp/commands/__tests__/report-export.test.ts b/src/gmp/commands/__tests__/report-export.test.ts index bc64059199..739c05b0ea 100644 --- a/src/gmp/commands/__tests__/report-export.test.ts +++ b/src/gmp/commands/__tests__/report-export.test.ts @@ -214,23 +214,20 @@ describe('ReportExportCommand tests', () => { expect(response.data).toBe(data); }); - test('should decode the base64 file content of a XML wrapped download', async () => { - const bytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]); // "%PDF" - const base64 = btoa('%PDF'); - const fakeHttp = createHttp( - createPlainResponse( - new TextEncoder().encode( - `${base64}`, - ).buffer as ArrayBuffer, - ), - ); + test('should pass the raw file bytes through unmodified', async () => { + // gsad decodes the base64 file content before answering. A raw report + // may itself contain a tag and must never be touched. + const data = new TextEncoder().encode( + 'SGVsbG8=', + ).buffer as ArrayBuffer; + const fakeHttp = createHttp(createPlainResponse(data)); const exportCommand = new ReportExportCommand(fakeHttp); const response = await exportCommand.downloadReportExport({ reportExportId: 'export-uuid', }); - expect(response.data).toEqual(bytes.buffer as ArrayBuffer); + expect(response.data).toBe(data); }); test('should cancel a report export', async () => { diff --git a/src/gmp/commands/report-export.ts b/src/gmp/commands/report-export.ts index 39130aea2f..ed3f110a07 100644 --- a/src/gmp/commands/report-export.ts +++ b/src/gmp/commands/report-export.ts @@ -51,29 +51,6 @@ interface GetReportExportResponseData extends XmlResponseData { const log = logger.getLogger('gmp.commands.report-export'); -const CONTENT_REGEX = /([\s\S]*?)<\/content>/; - -// gsad answers a download either with the raw file bytes or with the GMP -// XML document carrying the file base64 encoded in . Normalize to -// raw file bytes. -const decodeReportExportFile = (data: ArrayBuffer): ArrayBuffer => { - const text = new TextDecoder().decode(data); - const match = CONTENT_REGEX.exec(text); - if (!match) { - return data; - } - try { - const binary = atob(match[1].replace(/\s+/g, '')); - const bytes = new Uint8Array(binary.length); - for (let index = 0; index < binary.length; index++) { - bytes[index] = binary.charCodeAt(index); - } - return bytes.buffer; - } catch { - throw new Error('Invalid base64 file content in the download response'); - } -}; - class ReportExportCommand extends HttpCommand { constructor(http: Http) { super(http); @@ -166,7 +143,7 @@ class ReportExportCommand extends HttpCommand { responseType: 'arraybuffer', }, ); - return response.setData(decodeReportExportFile(response.data)); + return response; } cancelReportExport({reportExportId}: ReportExportParams) { diff --git a/src/web/pages/reports/AuditDeltaReportDetailsPage.jsx b/src/web/pages/reports/AuditDeltaReportDetailsPage.jsx index 9e65cb16ff..a592d50eed 100644 --- a/src/web/pages/reports/AuditDeltaReportDetailsPage.jsx +++ b/src/web/pages/reports/AuditDeltaReportDetailsPage.jsx @@ -29,7 +29,7 @@ import DownloadReportDialog from 'web/pages/reports/DownloadReportDialog'; import ReportDetailsFilterDialog from 'web/pages/reports/ReportDetailsFilterDialog'; import TargetComponent from 'web/pages/targets/TargetComponent'; import {buildReportExportRequest} from 'web/report-export/build-report-export-request'; -import {useReportExportManager} from 'web/report-export/ReportExportManager'; +import {useReportExportActions} from 'web/report-export/ReportExportManager'; import {ROUTES} from 'web/route-paths'; import { loadAllEntities as loadFilters, @@ -85,16 +85,17 @@ const AuditDeltaReportDetails = props => { // eslint-disable-next-line no-unused-vars const [storeAsDefault] = useState(); + const reportUrl = + reportId && deltaReportId + ? ROUTES.auditReportDelta.url(reportId, deltaReportId) + : undefined; + const [sorting, setSorting] = useState({ results: { sortField: 'compliant', sortReverse: true, }, errors: { - reportUrl: - reportId && deltaReportId - ? ROUTES.auditReportDelta.url(reportId, deltaReportId) - : undefined, sortField: 'error', sortReverse: false, }, @@ -133,7 +134,7 @@ const AuditDeltaReportDetails = props => { }); const isLoading = !isDefined(entity); - const {exportReport} = useReportExportManager(); + const {exportReport} = useReportExportActions(); useEffect(() => { dispatch(loadUserSettingDefaults(gmp)()); @@ -295,13 +296,9 @@ const AuditDeltaReportDetails = props => { format: reportFormat ?? {id: reportFormatId}, filter: newFilter, deltaReportId, - reportUrl: - reportId && deltaReportId - ? ROUTES.auditReportDelta.url(reportId, deltaReportId) - : undefined, + reportUrl, fileNameFormat: reportExportFileName, username, - fallbackReportTitle: _('Report'), }); return exportReport(request).then(started => { if (started) setShowDownloadReportDialog(false); diff --git a/src/web/pages/reports/AuditReportDetailsPage.tsx b/src/web/pages/reports/AuditReportDetailsPage.tsx index 3e22eed497..e3145e1a28 100644 --- a/src/web/pages/reports/AuditReportDetailsPage.tsx +++ b/src/web/pages/reports/AuditReportDetailsPage.tsx @@ -38,7 +38,7 @@ import DownloadReportDialog, { import ReportDetailsFilterDialog from 'web/pages/reports/ReportDetailsFilterDialog'; import TargetComponent from 'web/pages/targets/TargetComponent'; import {buildReportExportRequest} from 'web/report-export/build-report-export-request'; -import {useReportExportManager} from 'web/report-export/ReportExportManager'; +import {useReportExportActions} from 'web/report-export/ReportExportManager'; import {ROUTES} from 'web/route-paths'; import {createPEMCertificate} from 'web/utils/certificates'; @@ -48,13 +48,6 @@ interface ReportComposerDefaults { includeOverrides?: boolean; } -interface DownloadReportState { - includeNotes: boolean; - includeOverrides: boolean; - reportFormatId: string; - storeAsDefault: boolean; -} - interface ReportTargetRef { id: string; } @@ -115,7 +108,7 @@ const AuditReportDetailsPage = () => { useState(false); const [reportComposerDefaults, setReportComposerDefaults] = useState({}); - const {exportReport} = useReportExportManager(); + const {exportReport} = useReportExportActions(); // Filter management const [pageFilter, , {changeFilter}] = usePageFilter( @@ -291,10 +284,9 @@ const AuditReportDetailsPage = () => { const handleReportDownload = useCallback( async (values: DownloadReportDialogValues) => { - const state = values as unknown as DownloadReportState; if (!entity || !reportFilter) return; const {includeNotes, includeOverrides, reportFormatId, storeAsDefault} = - state; + values; const newFilter = reportFilter .set('notes', includeNotes) @@ -328,7 +320,6 @@ const AuditReportDetailsPage = () => { reportUrl: ROUTES.auditReport.url(reportId), fileNameFormat: reportExportFileName, username, - fallbackReportTitle: _('Report'), }); if (await exportReport(request)) setShowDownloadReportDialog(false); } catch (error) { @@ -347,7 +338,6 @@ const AuditReportDetailsPage = () => { showError, exportReport, username, - _, ], ); diff --git a/src/web/pages/reports/DeltaDetailsPage.tsx b/src/web/pages/reports/DeltaDetailsPage.tsx index 89ed645f86..d822cedb18 100644 --- a/src/web/pages/reports/DeltaDetailsPage.tsx +++ b/src/web/pages/reports/DeltaDetailsPage.tsx @@ -22,7 +22,6 @@ import type ReportFormat from 'gmp/models/report-format'; import {isActive} from 'gmp/models/task'; import {first} from 'gmp/utils/array'; import {isDefined, hasValue} from 'gmp/utils/identity'; -import useDownload from 'web/components/form/useDownload'; import { NO_RELOAD, USE_DEFAULT_RELOAD_INTERVAL_ACTIVE, @@ -39,7 +38,7 @@ import DownloadReportDialog from 'web/pages/reports/DownloadReportDialog'; import ReportDetailsFilterDialog from 'web/pages/reports/ReportDetailsFilterDialog'; import TargetComponent from 'web/pages/targets/TargetComponent'; import {buildReportExportRequest} from 'web/report-export/build-report-export-request'; -import {useReportExportManager} from 'web/report-export/ReportExportManager'; +import {useReportExportActions} from 'web/report-export/ReportExportManager'; import {ROUTES} from 'web/route-paths'; import { loadAllEntities as loadFilters, @@ -277,8 +276,7 @@ const DeltaReportDetails = () => { ); const [startTimer, clearTimer] = useReload(memoizedReloadFn, timeoutFunc); - const {downloadComponent} = useDownload(); - const {exportReport} = useReportExportManager(); + const {exportReport} = useReportExportActions(); const {dialogNotification, showError, showErrorMessage, showSuccessMessage} = useDialogNotification(); @@ -446,7 +444,6 @@ const DeltaReportDetails = () => { : undefined, fileNameFormat: reportExportFileName, username, - fallbackReportTitle: _('Report'), }); if (await exportReport(request)) setShowDownloadReportDialog(false); } catch (error) { @@ -567,7 +564,6 @@ const DeltaReportDetails = () => { onSave={handleReportDownload} /> )} - {downloadComponent} {dialogNotification} ); diff --git a/src/web/pages/reports/ReportDetailsPage.tsx b/src/web/pages/reports/ReportDetailsPage.tsx index b89e49fbcb..92b20720cd 100644 --- a/src/web/pages/reports/ReportDetailsPage.tsx +++ b/src/web/pages/reports/ReportDetailsPage.tsx @@ -40,7 +40,7 @@ import ReportDetailsContent from 'web/pages/reports/ReportDetailsContent'; import ReportDetailsFilterDialog from 'web/pages/reports/ReportDetailsFilterDialog'; import TargetComponent from 'web/pages/targets/TargetComponent'; import {buildReportExportRequest} from 'web/report-export/build-report-export-request'; -import {useReportExportManager} from 'web/report-export/ReportExportManager'; +import {useReportExportActions} from 'web/report-export/ReportExportManager'; import {ROUTES} from 'web/route-paths'; import {createPEMCertificate} from 'web/utils/certificates'; @@ -51,14 +51,6 @@ interface ReportComposerDefaults { includeOverrides?: boolean; } -interface DownloadReportState { - includeNotes: boolean; - includeOverrides: boolean; - reportConfigId: string; - reportFormatId: string; - storeAsDefault: boolean; -} - interface ReportTargetRef { id: string; } @@ -108,7 +100,7 @@ const ReportDetailsPage = () => { useState(false); const [reportComposerDefaults, setReportComposerDefaults] = useState({}); - const {exportReport} = useReportExportManager(); + const {exportReport} = useReportExportActions(); // Filter management const [pageFilter, , {changeFilter}] = usePageFilter( @@ -295,7 +287,6 @@ const ReportDetailsPage = () => { const handleReportDownload = useCallback( async (values: DownloadReportDialogValues) => { - const state = values as unknown as DownloadReportState; if (!entity || !reportFilter) return; const { includeNotes, @@ -303,7 +294,7 @@ const ReportDetailsPage = () => { reportConfigId, reportFormatId, storeAsDefault, - } = state; + } = values; const newFilter = reportFilter .set('notes', includeNotes) @@ -344,7 +335,6 @@ const ReportDetailsPage = () => { reportUrl: ROUTES.report.url(reportId), fileNameFormat: reportExportFileName, username, - fallbackReportTitle: _('Report'), }); if (await exportReport(request)) setShowDownloadReportDialog(false); } catch (error) { @@ -363,7 +353,6 @@ const ReportDetailsPage = () => { exportReport, showError, username, - _, ], ); diff --git a/src/web/report-export/ReportExportJobRow.tsx b/src/web/report-export/ReportExportJobRow.tsx index 210207ac59..64b997bcd5 100644 --- a/src/web/report-export/ReportExportJobRow.tsx +++ b/src/web/report-export/ReportExportJobRow.tsx @@ -41,7 +41,7 @@ import { getReportExportActions, type ReportExportJob, } from 'web/report-export/report-export-job-model'; -import {useReportExportManager} from 'web/report-export/ReportExportManager'; +import {useReportExportActions} from 'web/report-export/ReportExportManager'; import Theme from 'web/utils/theme'; import {formattedUserSettingLongDate} from 'web/utils/user-setting-time-date-formatters'; @@ -73,7 +73,7 @@ interface ReportExportJobRowProps { const ReportExportJobRow = ({job}: ReportExportJobRowProps) => { const [_] = useTranslation(); const {supportsCancellation, cancel, dismiss, retry} = - useReportExportManager(); + useReportExportActions(); const actions = getReportExportActions(job); const canCancel = supportsCancellation && actions.cancel; const status = getReportExportJobDisplay(job, _); diff --git a/src/web/report-export/ReportExportManager.tsx b/src/web/report-export/ReportExportManager.tsx index eb26db5244..eb25e351ee 100644 --- a/src/web/report-export/ReportExportManager.tsx +++ b/src/web/report-export/ReportExportManager.tsx @@ -26,14 +26,17 @@ import useReportExport, { type StartReportExportParams, } from 'web/report-export/useReportExports'; -interface ReportExportManagerContextValue { +interface ReportExportActionsContextValue { exportReport: (params: StartReportExportParams) => Promise; cancel: (key: string) => Promise; dismiss: (key: string) => void; retry: (key: string) => Promise; clearFinished: () => void; - isActive: boolean; supportsCancellation: boolean; +} + +interface ReportExportActivityContextValue { + isActive: boolean; activityOpen: boolean; setActivityOpen: Dispatch>; jobs: ReportExportJob[]; @@ -43,8 +46,12 @@ interface ReportExportManagerProps { children: ReactNode; } -const ReportExportManagerContext = createContext< - ReportExportManagerContextValue | undefined +const ReportExportActionsContext = createContext< + ReportExportActionsContextValue | undefined +>(undefined); + +const ReportExportActivityContext = createContext< + ReportExportActivityContextValue | undefined >(undefined); const ReportExportManager = ({children}: ReportExportManagerProps) => { @@ -72,37 +79,62 @@ const ReportExportManager = ({children}: ReportExportManagerProps) => { if (!activityOpen) showErrorNotification(error.message); }, }); - const contextValue = useMemo( + const {start, cancel, dismiss, retry, clearFinished} = reportExport; + const cancelAction = useCallback( + (key: string) => (supportsCancellation ? cancel(key) : Promise.resolve()), + [cancel, supportsCancellation], + ); + // Actions are stable callbacks; consumers of only exportReport must not + // re-render on activity updates. + const actionsContextValue = useMemo( () => ({ - exportReport: (params: StartReportExportParams) => - reportExport.start(params), - cancel: (key: string) => - supportsCancellation ? reportExport.cancel(key) : Promise.resolve(), - dismiss: reportExport.dismiss, - retry: reportExport.retry, - clearFinished: reportExport.clearFinished, - isActive: reportExport.isActive, + exportReport: start, + cancel: cancelAction, + dismiss, + retry, + clearFinished, supportsCancellation, + }), + [start, cancelAction, dismiss, retry, clearFinished, supportsCancellation], + ); + const activityContextValue = useMemo( + () => ({ + isActive: reportExport.isActive, activityOpen, setActivityOpen, jobs: reportExport.jobs, }), - [activityOpen, reportExport, supportsCancellation], + [reportExport.isActive, reportExport.jobs, activityOpen], ); return ( - - event.stopPropagation()} /> - {children} - + + + event.stopPropagation()} + /> + {children} + + ); }; -export const useReportExportManager = () => { - const context = useContext(ReportExportManagerContext); +export const useReportExportActions = () => { + const context = useContext(ReportExportActionsContext); + if (!context) { + throw new Error( + 'useReportExportActions must be used within ReportExportManager', + ); + } + return context; +}; + +export const useReportExportActivityState = () => { + const context = useContext(ReportExportActivityContext); if (!context) { throw new Error( - 'useReportExportManager must be used within ReportExportManager', + 'useReportExportActivityState must be used within ReportExportManager', ); } return context; diff --git a/src/web/report-export/__tests__/ReportExportManager.test.tsx b/src/web/report-export/__tests__/ReportExportManager.test.tsx index 23b7ec63d5..ed59fe070f 100644 --- a/src/web/report-export/__tests__/ReportExportManager.test.tsx +++ b/src/web/report-export/__tests__/ReportExportManager.test.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import {useState} from 'react'; +import {useEffect, useState} from 'react'; import {beforeEach, describe, expect, test, testing} from '@gsa/testing'; import { act, @@ -27,7 +27,7 @@ import { } from 'web/report-export/__fixtures__/report-export-mocks'; import {ReportExportActivity} from 'web/report-export/ReportExportActivity'; import ReportExportManager, { - useReportExportManager, + useReportExportActions, } from 'web/report-export/ReportExportManager'; vi.mock('@greenbone/ui-lib', async importOriginal => ({ @@ -42,7 +42,7 @@ const MultiExportStarter = ({ {reportTitle: 'Report B', filename: 'Report B.xml'}, ], }) => { - const {exportReport} = useReportExportManager(); + const {exportReport} = useReportExportActions(); return ( + ); + }; + + const gmp = createGmp([ + { + id: 'running-export', + status: 'running', + progress: 'generating', + owner: {name: 'test-user'}, + extension: 'pdf', + creationTime: date('2026-10-15T11:00:00Z'), + }, + ]); + gmp.reportexport.exportScanReport.mockResolvedValue({ + data: {id: 'running-export'}, + }); + const {render} = rendererWith({gmp, capabilities: true}); + render( + + + + , + ); + await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1)); + + fireEvent.click(screen.getByRole('button', {name: 'Start export'})); + // the polled status of the running export arrives while the probe + // must stay untouched + await screen.findByText('Generating'); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + }); + expect(onRender).toHaveBeenCalledTimes(1); + }); + test.each([ ['report.PDF', 'Release.1', 'Report export: Release.1 (.pdf)'], ['report.csv', 'Unnamed', 'Report export: Unnamed (.csv)'], diff --git a/src/web/report-export/__tests__/build-report-export-request.test.ts b/src/web/report-export/__tests__/build-report-export-request.test.ts index 95d6f7de2f..68b071cbbc 100644 --- a/src/web/report-export/__tests__/build-report-export-request.test.ts +++ b/src/web/report-export/__tests__/build-report-export-request.test.ts @@ -32,7 +32,6 @@ describe('buildReportExportRequest', () => { reportUrl: '/reports/report-id', fileNameFormat: '%N', username: 'alice', - fallbackReportTitle: 'Report', }); expect(request).toMatchObject({ diff --git a/src/web/report-export/__tests__/useReportExport.test.tsx b/src/web/report-export/__tests__/useReportExport.test.tsx index 0f7af74455..5241f6afce 100644 --- a/src/web/report-export/__tests__/useReportExport.test.tsx +++ b/src/web/report-export/__tests__/useReportExport.test.tsx @@ -4,7 +4,8 @@ */ import {beforeEach, describe, expect, test, testing} from '@gsa/testing'; -import {act, rendererWith, waitFor} from 'web/testing'; +import {act, renderHook, rendererWith, waitFor} from 'web/testing'; +import {QueryClient, QueryClientProvider} from '@tanstack/react-query'; import { changeSession, createGmp, @@ -15,11 +16,13 @@ import { import { createReportExportJob, getReportExportActions, - retainReportExportJobs, + ReportExportUnavailableError, + retainReportExportRecords, } from 'web/report-export/report-export-job-model'; import useReportExport, { REPORT_EXPORT_DOWNLOAD_RETRY_INTERVAL, } from 'web/report-export/useReportExports'; +import {TestingGmpProvider} from 'web/testing/Components'; const onError = testing.fn(); @@ -158,40 +161,152 @@ describe('useReportExport', () => { expect(gmp.reportexport.downloadReportExport).toHaveBeenCalledTimes(1); }); - test('bounds completed activity while retaining unfinished jobs', () => { - const completedJobs = Array.from({length: 60}, (_, index) => - createReportExportJob({ - key: `receipt-${index}`, - exportId: `export-${index}`, + test('bounds completed records while retaining unfinished ones', () => { + const completedRecords = Array.from({length: 60}, (_, index) => ({ + key: `receipt-${index}`, + exportId: `export-${index}`, + filename: 'report.xml', + reportTitle: 'Report', + downloaded: true, + })); + const unfinishedRecord = { + key: 'unfinished', + exportId: 'unfinished-export', + filename: 'report.xml', + reportTitle: 'Unfinished', + }; + + const retainedRecords = retainReportExportRecords( + [...completedRecords, unfinishedRecord], + record => + record.exportId + ? { + data: exportData( + 'running', + 'generating', + record.exportId, + ) as never, + } + : {}, + ); + + expect(retainedRecords).toHaveLength(51); + expect(retainedRecords).toContain(unfinishedRecord); + expect(retainedRecords).not.toContain(completedRecords[0]); + }); + + test('shows unavailable when a permanent status error follows cached status data', () => { + const job = createReportExportJob( + { + key: 'gone', + exportId: 'export-uuid-1', filename: 'report.xml', reportTitle: 'Report', - downloaded: true, - }), + }, + exportData('running', 'generating', 'export-uuid-1') as never, + new ReportExportUnavailableError('Report export is no longer available'), ); - const unfinishedJob = createReportExportJob( + + expect(job.view).toMatchObject({kind: 'failed', reason: 'status'}); + expect(getReportExportActions(job)).toMatchObject({ + active: false, + dismiss: true, + retry: true, + }); + }); + + test('offers no retry for an expired export', () => { + const job = createReportExportJob( { - key: 'unfinished', - exportId: 'unfinished-export', + key: 'expired', + exportId: 'export-uuid-1', filename: 'report.xml', - reportTitle: 'Unfinished', + reportTitle: 'Report', }, - exportData('running', 'generating', 'unfinished-export') as never, + exportData('expired', 'expired', 'export-uuid-1') as never, ); - const retainedJobs = retainReportExportJobs([ - ...completedJobs, - unfinishedJob, - ]); - - expect(retainedJobs).toHaveLength(51); - expect(retainedJobs).toContain(unfinishedJob); - expect(retainedJobs).not.toContain(completedJobs[0]); + expect(job.view).toMatchObject({kind: 'failed', reason: 'expired'}); + expect(getReportExportActions(job)).toMatchObject({ + active: false, + dismiss: true, + retry: false, + }); }); test('retries a not-ready download later than the status poll interval', () => { expect(REPORT_EXPORT_DOWNLOAD_RETRY_INTERVAL).toBe(3000); }); + test('marks a polled export as unavailable when the status query fails permanently', async () => { + const gmp = createGmp([ownedExportData('running')]); + const onDownload = testing.fn(); + const {result} = renderReportExport(gmp, {onDownload}); + + await act(async () => { + await result.current.start(startParams); + }); + await waitFor(() => + expect(result.current.jobs[0].view.kind).toBe('generating'), + ); + + // the export vanishes on the server while the client still holds the + // last running status + gmp.inventory.exports = []; + await waitFor( + () => + expect(result.current.jobs[0].view).toMatchObject({ + kind: 'failed', + reason: 'status', + }), + {timeout: 7000}, + ); + expect(getReportExportActions(result.current.jobs[0]).dismiss).toBe(true); + expect(onDownload).not.toHaveBeenCalled(); + }); + + test('prunes finished records beyond the history limit together with their status queries', async () => { + const gmp = createGmp( + Array.from({length: 60}, (_, index) => + ownedExportData('canceled', 'canceled', `export-uuid-${index + 1}`), + ), + ); + // the query client is created here so the cache can be inspected + const queryClient = new QueryClient({ + //@ts-expect-error + logger: {log: () => {}, warn: () => {}, error: () => {}}, + defaultOptions: { + queries: {retry: false, gcTime: 0}, + mutations: {retry: false}, + }, + }); + const onDownload = testing.fn(); + const {result} = renderHook( + () => useReportExport({onError, onDownload: testing.fn()}), + { + wrapper: ({children}) => ( + + + {children} + + + ), + }, + ); + + await act(async () => { + await Promise.all( + Array.from({length: 60}, () => result.current.start(startParams)), + ); + }); + + await waitFor(() => expect(result.current.jobs).toHaveLength(50)); + // every finished record beyond the history limit is dropped, including + // its status query cache entry / observer + expect(queryClient.getQueryCache().getAll().length).toBe(50); + expect(onDownload).not.toHaveBeenCalled(); + }); + test('fails when the export cannot be found by the status query', async () => { const gmp = createGmp(); const onDownload = testing.fn(); @@ -204,13 +319,109 @@ describe('useReportExport', () => { await waitFor(() => expect(result.current.jobs[0].view).toMatchObject({ kind: 'failed', - reason: 'unavailable', + reason: 'status', }), ); expect(onDownload).not.toHaveBeenCalled(); expect(gmp.reportexport.downloadReportExport).not.toHaveBeenCalled(); }); + test('re-checks the status query when retrying a failed status check', async () => { + const gmp = createGmp(); + const {result} = renderReportExport(gmp); + + await act(async () => { + await result.current.start(startParams); + }); + await waitFor(() => + expect(result.current.jobs[0].view).toMatchObject({ + kind: 'failed', + reason: 'status', + }), + ); + const getCallCount = gmp.reportexport.get.mock.calls.length; + + // retry re-checks the status; the export is still gone, so the row + // stays failed and dismissable + await act(async () => { + await result.current.retry(result.current.jobs[0].key); + }); + expect(gmp.reportexport.get.mock.calls.length).toBeGreaterThan( + getCallCount, + ); + expect(result.current.jobs[0].view).toMatchObject({ + kind: 'failed', + reason: 'status', + }); + expect(getReportExportActions(result.current.jobs[0])).toMatchObject({ + dismiss: true, + retry: true, + }); + }); + + test('recovers a status-failed export when the export exists again', async () => { + const gmp = createGmp(); + const onDownload = testing.fn(); + const {result} = renderReportExport(gmp, {onDownload}); + + await act(async () => { + await result.current.start(startParams); + }); + await waitFor(() => + expect(result.current.jobs[0].view).toMatchObject({ + kind: 'failed', + reason: 'status', + }), + ); + + gmp.inventory.exports = [ownedExportData('running')]; + await act(async () => { + await result.current.retry(result.current.jobs[0].key); + }); + await waitFor(() => + expect(result.current.jobs[0].view.kind).toBe('generating'), + ); + + gmp.inventory.exports[0] = ownedExportData('done', 'completed'); + // the re-check resumes the job and the poll completes the download + await waitFor(() => expect(onDownload).toHaveBeenCalledTimes(1), { + timeout: 7000, + }); + expect(result.current.jobs[0].view.kind).toBe('complete'); + }, 10000); + + test('does not re-download when retrying an expired export', async () => { + const gmp = createGmp([ownedExportData('expired', 'expired')]); + const onDownload = testing.fn(); + const {result} = renderReportExport(gmp, {onDownload}); + + await act(async () => { + await result.current.start(startParams); + }); + await waitFor(() => + expect(result.current.jobs[0].view).toMatchObject({ + kind: 'failed', + reason: 'expired', + }), + ); + + await act(async () => { + await result.current.retry(result.current.jobs[0].key); + }); + + // the expired file is gone; retry must not attempt a download and the + // row stays removable + expect(gmp.reportexport.downloadReportExport).not.toHaveBeenCalled(); + expect(result.current.jobs[0].view).toMatchObject({ + kind: 'failed', + reason: 'expired', + }); + expect(getReportExportActions(result.current.jobs[0])).toMatchObject({ + dismiss: true, + retry: false, + }); + }); + test('retains export creation errors and invokes the error callback', async () => { const gmp = createGmp([]); gmp.reportexport.exportScanReport.mockRejectedValue( diff --git a/src/web/report-export/build-report-export-request.ts b/src/web/report-export/build-report-export-request.ts index 5e5ec3f2f7..d13e642204 100644 --- a/src/web/report-export/build-report-export-request.ts +++ b/src/web/report-export/build-report-export-request.ts @@ -35,7 +35,6 @@ interface BuildReportExportRequestParams { reportUrl?: string; fileNameFormat?: string; username?: string; - fallbackReportTitle: string; } export const buildReportExportRequest = ({ diff --git a/src/web/report-export/report-export-job-display.ts b/src/web/report-export/report-export-job-display.ts index 623fc79099..84095816d0 100644 --- a/src/web/report-export/report-export-job-display.ts +++ b/src/web/report-export/report-export-job-display.ts @@ -32,6 +32,8 @@ const getReportExportFailureLabel = ( return _('Export failed'); case 'download': return _('Download failed'); + case 'status': + case 'expired': case 'unavailable': return _('Unavailable'); } diff --git a/src/web/report-export/report-export-job-model.ts b/src/web/report-export/report-export-job-model.ts index d0b4a19e87..589eb81078 100644 --- a/src/web/report-export/report-export-job-model.ts +++ b/src/web/report-export/report-export-job-model.ts @@ -39,7 +39,12 @@ export interface ReportExportRecord { cancelError?: Error; } -type ReportExportFailureReason = 'export' | 'download' | 'unavailable'; +type ReportExportFailureReason = + | 'export' + | 'download' + | 'status' + | 'expired' + | 'unavailable'; export type ReportExportJobView = | { @@ -93,7 +98,9 @@ export const getReportExportActions = ({ dismiss: !isActive || view.kind === 'checking', retry: view.kind === 'failed' && - (view.reason === 'unavailable' || view.reason === 'download'), + (view.reason === 'unavailable' || + view.reason === 'download' || + view.reason === 'status'), }; }; @@ -145,6 +152,13 @@ const getServerStatusView = ( case 'cancel_requested': case 'done': return undefined; + case 'expired': + // the generated file was removed by the server retention; there is + // nothing left to download or re-check + return createFailedView( + 'expired', + new Error(exportData?.errorMessage || 'Export is expired'), + ); default: return createFailedView( 'unavailable', @@ -176,6 +190,11 @@ const getReportExportJobView = ( if (!record.exportId) { return record.cancelRequested ? {kind: 'canceling'} : {kind: 'creating'}; } + // A permanent status failure outranks cached status data: the export is + // gone (e.g. 404) even though an earlier poll still reported it running. + if (statusError && isPermanentReportExportError(statusError)) { + return createFailedView('status', statusError); + } if (record.cancelRequested) return {kind: 'canceling'}; switch (exportData?.status) { case 'cancel_requested': @@ -187,11 +206,7 @@ const getReportExportJobView = ( case 'done': return {kind: 'downloading'}; } - // no status data yet: keep checking; a permanent status failure is - // terminal - if (statusError && isPermanentReportExportError(statusError)) { - return createFailedView(getDownloadFailureReason(statusError), statusError); - } + // no status data yet: keep checking return {kind: 'checking'}; }; @@ -254,17 +269,30 @@ export const shouldAutoDownloadRecord = ( !isDefined(record.downloadError) && exportData?.status === 'done'; -export const retainReportExportJobs = (jobs: ReportExportJob[]) => { - const retainedJobKeys = new Set( - jobs.filter(job => getReportExportActions(job).active).map(job => job.key), - ); - const recentJobKeys = new Set( - jobs - .filter(job => !retainedJobKeys.has(job.key)) - .slice(-MAX_RETAINED_EXPORT_HISTORY_COUNT) - .map(job => job.key), +// Retention bounds the tracked exports: active exports are always kept, +// finished ones only as the most recent history entries. The status of a +// record is resolved by the caller (query results or cache reads), so the +// retention can be applied to the records themselves and not only to the +// displayed jobs. +export const retainReportExportRecords = ( + records: ReportExportRecord[], + resolveStatus: (record: ReportExportRecord) => { + data?: ReportExport; + error?: Error | null; + }, +): ReportExportRecord[] => { + const activeIndexes = new Set(); + const finishedIndexes: number[] = []; + records.forEach((record, index) => { + const {data, error} = resolveStatus(record); + const job = createReportExportJob(record, data, error); + if (getReportExportActions(job).active) activeIndexes.add(index); + else finishedIndexes.push(index); + }); + const retainedHistory = new Set( + finishedIndexes.slice(-MAX_RETAINED_EXPORT_HISTORY_COUNT), ); - return jobs.filter( - job => retainedJobKeys.has(job.key) || recentJobKeys.has(job.key), + return records.filter( + (_, index) => activeIndexes.has(index) || retainedHistory.has(index), ); }; diff --git a/src/web/report-export/useReportExportActivity.ts b/src/web/report-export/useReportExportActivity.ts index 6b098eed6f..06424785a7 100644 --- a/src/web/report-export/useReportExportActivity.ts +++ b/src/web/report-export/useReportExportActivity.ts @@ -5,7 +5,10 @@ import {useEffect, useRef} from 'react'; import {type ReportExportJob} from 'web/report-export/report-export-job-model'; -import {useReportExportManager} from 'web/report-export/ReportExportManager'; +import { + useReportExportActions, + useReportExportActivityState, +} from 'web/report-export/ReportExportManager'; type IndicatorState = 'error' | 'active' | 'complete'; @@ -26,8 +29,9 @@ const compareReportExportJobs = ( getReportExportJobSortKey(first.job) || second.index - first.index; const useReportExportActivity = () => { - const {activityOpen, clearFinished, isActive, jobs, setActivityOpen} = - useReportExportManager(); + const {clearFinished} = useReportExportActions(); + const {activityOpen, isActive, jobs, setActivityOpen} = + useReportExportActivityState(); const previousJobKeys = useRef(new Set(jobs.map(job => job.key))); // Every tracked row stays visible and counted until it is removed by the diff --git a/src/web/report-export/useReportExportMutations.ts b/src/web/report-export/useReportExportMutations.ts index 6f9a764e8d..4f568eb679 100644 --- a/src/web/report-export/useReportExportMutations.ts +++ b/src/web/report-export/useReportExportMutations.ts @@ -29,18 +29,17 @@ export const useReportExportMutations = () => { gmpMethod: reportExportId => gmp.reportexport.cancelReportExport({reportExportId}), }); - const download = useGmpMutation({ - gmpMethod: reportExportId => - gmp.reportexport.downloadReportExport({reportExportId}), - }); return useMemo( () => ({ create: create.mutateAsync, cancel: cancel.mutateAsync, - download: download.mutateAsync, + // downloads bypass React Query: the file bytes must not be retained + // in the mutation cache + download: (reportExportId: string) => + gmp.reportexport.downloadReportExport({reportExportId}), }), - [create.mutateAsync, cancel.mutateAsync, download.mutateAsync], + [create.mutateAsync, cancel.mutateAsync, gmp.reportexport], ); }; diff --git a/src/web/report-export/useReportExports.ts b/src/web/report-export/useReportExports.ts index 8370e5ef14..9ec50f6ccb 100644 --- a/src/web/report-export/useReportExports.ts +++ b/src/web/report-export/useReportExports.ts @@ -21,7 +21,7 @@ import { getReportExportActions, isPermanentReportExportError, isReportExportJobFinished, - retainReportExportJobs, + retainReportExportRecords, shouldAutoDownloadRecord, } from 'web/report-export/report-export-job-model'; import {useReportExportMutations} from 'web/report-export/useReportExportMutations'; @@ -125,22 +125,42 @@ const useReportExport = ({onDownload, onError}: ReportExportCallbacks) => { [], ); - const statusQueries = useReportExportStatusQueries(records); + // Retention prunes the tracked records themselves so finished history + // beyond the limit cannot keep status query observers or cache entries + // alive. The record state is adjusted during render (React's documented + // derived-state pattern): the prune only removes records, so the + // adjustment converges after a single re-render. + const retainedRecords = retainReportExportRecords(records, record => { + const state = isDefined(record.exportId) + ? queryClient.getQueryState( + reportExportQueryKey(token, username, record.exportId), + ) + : undefined; + return { + data: state?.data as ReportExport | undefined, + error: state?.error ?? undefined, + }; + }); + if (retainedRecords.length !== records.length) { + setRecords(retainedRecords); + } + + const statusQueries = useReportExportStatusQueries(retainedRecords); const exportById = useMemo(() => { const exports = new Map(); - records.forEach((record, index) => { + retainedRecords.forEach((record, index) => { const data = statusQueries[index]?.data; if (isDefined(record.exportId) && isDefined(data)) { exports.set(record.exportId, data); } }); return exports; - }, [records, statusQueries]); + }, [retainedRecords, statusQueries]); const jobsByKey = useMemo( () => new Map( - records + retainedRecords .map((record, index) => createReportExportJob( record, @@ -152,7 +172,7 @@ const useReportExport = ({onDownload, onError}: ReportExportCallbacks) => { ) .map(job => [job.key, job] as const), ), - [records, exportById, statusQueries], + [retainedRecords, exportById, statusQueries], ); const requestCancellation = useLatestCallback( @@ -286,15 +306,15 @@ const useReportExport = ({onDownload, onError}: ReportExportCallbacks) => { // Replay a cancellation that was queued while the export id did not exist // yet, whenever the record becomes bound. useEffect(() => { - for (const record of records) { + for (const record of retainedRecords) { if (!isDefined(record.exportId) || !record.cancelRequested) continue; requestCancellation(record.key, record.exportId).catch(() => {}); } - }, [records, requestCancellation]); + }, [retainedRecords, requestCancellation]); // Pick up completed tracked exports and hand their file to the browser. useEffect(() => { - for (const record of records) { + for (const record of retainedRecords) { const exportId = record.exportId; if (!isDefined(exportId)) continue; const item = exportById.get(exportId); @@ -305,9 +325,9 @@ const useReportExport = ({onDownload, onError}: ReportExportCallbacks) => { item?.contentType ?? undefined, ).catch(() => {}); } - }, [records, exportById, attemptDownload]); + }, [retainedRecords, exportById, attemptDownload]); - const start = async (params: StartReportExportParams) => { + const start = useLatestCallback(async (params: StartReportExportParams) => { if (!isLive(gmp)) { return false; } @@ -347,9 +367,9 @@ const useReportExport = ({onDownload, onError}: ReportExportCallbacks) => { notify(failure); return false; } - }; + }); - const cancel = async (key: string) => { + const cancel = useLatestCallback(async (key: string) => { const job = jobsByKey.get(key); const record = recordsRef.current.find(item => item.key === key); if (!job || !record || !getReportExportActions(job).cancel) return; @@ -360,32 +380,56 @@ const useReportExport = ({onDownload, onError}: ReportExportCallbacks) => { return; } await requestCancellation(record.key, record.exportId); - }; + }); - const dismiss = (key: string) => { + const dismiss = useLatestCallback((key: string) => { const job = jobsByKey.get(key); if (!job || !getReportExportActions(job).dismiss) return; removeRecord(key); - }; + }); - const clearFinished = () => { + const clearFinished = useLatestCallback(() => { setRecords(previous => previous.filter(record => { const job = jobsByKey.get(record.key); return !job || !isReportExportJobFinished(job); }), ); - }; + }); - const retry = async (key: string) => { + const retry = useLatestCallback(async (key: string) => { const job = jobsByKey.get(key); if (!job || !getReportExportActions(job).retry) return; - if (isDefined(job.exportId)) + if (isDefined(job.exportId) && job.view.kind === 'failed') { + if (job.view.reason === 'status') { + // the status check itself failed; a re-download cannot recover a + // missing export, but re-checking can resume it + await queryClient.refetchQueries({ + queryKey: reportExportQueryKey(token, username, job.exportId), + }); + return; + } // a re-download is always possible: gvmd retains the generated file await attemptDownload(key, job.exportId, undefined, false); - }; + } + }); - const jobs = retainReportExportJobs([...jobsByKey.values()]); + const jobs = [...jobsByKey.values()]; + + // Remove the status queries of records that left the tracked set, so + // pruned or dismissed history does not leave cache entries behind. + const previousRecordsRef = useRef([]); + useEffect(() => { + const previous = previousRecordsRef.current; + previousRecordsRef.current = records; + const trackedKeys = new Set(records.map(record => record.key)); + for (const record of previous) { + if (trackedKeys.has(record.key) || !isDefined(record.exportId)) continue; + queryClient.removeQueries({ + queryKey: reportExportQueryKey(token, username, record.exportId), + }); + } + }, [records, queryClient, token, username]); return { start,