@cryptotaxi247 / CoPilot / commits / cfa73122

Convert docx to pdf (#331)

* add pdf report route * refactor: Remove unused cleanup_temp_files function from reports_pdf.py * chore: update dependencies in frontend * refactor: list classes * refactor: form classes * refactor: cards classes * feat: add pdf output on case report * precommit fixes --------- Co-authored-by: Davide Di Modica <webmaster.ddm@gmail.com>

taylor_socfortress committed Nov 5, 2024 at 11:55 UTC cfa73122f030586f2485e888b0be7df40c1d34e2
37 files changed +390 -201
backend/app/incidents/routes/db_operations.py
+5 -3
@@ -933,13 +933,15 @@ async def upload_case_report_template_endpoint(
933 file: UploadFile = File(...),
934 db: AsyncSession = Depends(get_db),
935 ):
936 - # Check if the file type is a .docx
936 + # Check if the file type is a .docx or .html
937 mime_type, _ = mimetypes.guess_type(file.filename)
938 - if mime_type != "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
939 - raise HTTPException(status_code=400, detail="Invalid file type. Only .docx files are allowed.")
938 + allowed_mime_types = ["application/vnd.openxmlformats-officedocument.wordprocessingml.document", "text/html"] # .docx # .html
939 + if mime_type not in allowed_mime_types:
940 + raise HTTPException(status_code=400, detail="Invalid file type. Only .docx and .html files are allowed.")
941
942 if await report_template_exists(file.filename, db):
943 raise HTTPException(status_code=400, detail="File name already exists for this template")
944 +
945 return CaseReportTemplateDataStoreResponse(
946 case_report_template_data_store=await upload_report_template(file, db),
947 success=True,
backend/app/incidents/routes/incident_report.py
+37
@@ -29,6 +29,11 @@ from app.incidents.services.reports import create_file_response
29 from app.incidents.services.reports import download_template
30 from app.incidents.services.reports import render_document_with_context
31 from app.incidents.services.reports import save_template_to_tempfile
32 +from app.incidents.services.reports_pdf import convert_html_to_pdf
33 +from app.incidents.services.reports_pdf import create_case_context_pdf
34 +from app.incidents.services.reports_pdf import create_file_response_pdf
35 +from app.incidents.services.reports_pdf import download_template_pdf
36 +from app.incidents.services.reports_pdf import render_html_template
37
38 incidents_report_router = APIRouter()
39
@@ -203,4 +208,36 @@ async def get_cases_export_docx_route(
208 return response
209
210
211 +@incidents_report_router.post(
212 + "/generate-report-pdf",
213 + description="Generate a PDF report for a case.",
214 +)
215 +async def get_cases_export_pdf_route(
216 + request: CaseDownloadDocxRequest,
217 + session: AsyncSession = Depends(get_db),
218 +) -> FileResponse:
219 + case = await fetch_case_by_id(session, request.case_id)
220 + if not case:
221 + raise HTTPException(status_code=404, detail="No cases found")
222 +
223 + context = create_case_context_pdf(case)
224 +
225 + # Download and save the template
226 + tmp_template_name = await download_template_pdf(request.template_name)
227 +
228 + # Render the HTML template with the context
229 + rendered_html_file_name = render_html_template(tmp_template_name, context)
230 +
231 + # Convert HTML to PDF using WeasyPrint
232 + rendered_pdf_file_name = convert_html_to_pdf(rendered_html_file_name)
233 +
234 + # Create the FileResponse for PDF
235 + response = create_file_response_pdf(file_path=rendered_pdf_file_name, file_name=request.file_name.replace(".docx", ".pdf"))
236 +
237 + # Clean up temporary files
238 + cleanup_temp_files([tmp_template_name, rendered_html_file_name])
239 +
240 + return response
241 +
242 +
243 # ! TODO: ROUTE FOR MARKDOWN TEMPLATE ! #
backend/app/incidents/services/reports_pdf.py new
+142
@@ -0,0 +1,142 @@
1 +import os
2 +import platform
3 +from tempfile import NamedTemporaryFile
4 +from typing import Dict
5 +from typing import Optional
6 +
7 +import pdfkit
8 +from fastapi.responses import FileResponse
9 +from jinja2 import Environment
10 +from jinja2 import FileSystemLoader
11 +
12 +from app.data_store.data_store_operations import download_data_store
13 +
14 +
15 +async def download_template_pdf(template_name: str) -> str:
16 + """Retrieve the template file content from the data store and save it to a temporary file."""
17 + template_content = await download_data_store(bucket_name="copilot-case-report-templates", object_name=template_name)
18 + with NamedTemporaryFile(delete=False, suffix=".html") as tmp_template:
19 + tmp_template.write(template_content)
20 + return tmp_template.name
21 +
22 +
23 +def create_case_context_pdf(case) -> Dict[str, Dict[str, str]]:
24 + """Prepare the context for the Jinja template."""
25 + return {
26 + "case": {
27 + "name": case.case_name,
28 + "description": case.case_description,
29 + "assigned_to": case.assigned_to,
30 + "case_creation_time": case.case_creation_time,
31 + "id": case.id,
32 + "alerts": [
33 + {
34 + "alert_name": alert.alert.alert_name,
35 + "alert_description": alert.alert.alert_description,
36 + "status": alert.alert.status,
37 + "tags": [tag.tag.tag for tag in alert.alert.tags],
38 + "assets": [
39 + {
40 + "asset_name": asset.asset_name,
41 + "agent_id": asset.agent_id,
42 + }
43 + for asset in alert.alert.assets
44 + ],
45 + "comments": [
46 + {
47 + "comment": comment.comment,
48 + "user_name": comment.user_name,
49 + "created_at": comment.created_at,
50 + }
51 + for comment in alert.alert.comments
52 + ],
53 + "context": {
54 + "source": alert.alert.assets[0].alert_context.source
55 + if alert.alert.assets and alert.alert.assets[0].alert_context
56 + else None,
57 + "context": alert.alert.assets[0].alert_context.context
58 + if alert.alert.assets and alert.alert.assets[0].alert_context
59 + else None,
60 + }
61 + if alert.alert.assets
62 + else None,
63 + "iocs": [
64 + {
65 + "ioc_value": ioc.ioc.value,
66 + "ioc_type": ioc.ioc.type,
67 + "ioc_description": ioc.ioc.description,
68 + }
69 + for ioc in alert.alert.iocs
70 + ],
71 + }
72 + for alert in case.alerts
73 + ],
74 + },
75 + }
76 +
77 +
78 +def render_html_template(template_path: str, context: Dict[str, Dict[str, str]]) -> str:
79 + """Render the Jinja HTML template with the provided context."""
80 + template_dir = os.path.dirname(template_path)
81 + template_name = os.path.basename(template_path)
82 +
83 + env = Environment(loader=FileSystemLoader(template_dir))
84 + template = env.get_template(template_name)
85 + rendered_html = template.render(context)
86 +
87 + # Save rendered HTML to a temporary file
88 + with NamedTemporaryFile(delete=False, suffix=".html") as tmp:
89 + tmp.write(rendered_html.encode("utf-8"))
90 + return tmp.name
91 +
92 +
93 +def convert_html_to_pdf(html_path: str) -> str:
94 + """Convert the HTML file to a PDF using wkhtmltopdf via pdfkit, with dynamic path detection for different platforms."""
95 + pdf_path = html_path.replace(".html", ".pdf")
96 + wkhtmltopdf_paths = []
97 +
98 + # Determine paths to wkhtmltopdf based on the current platform
99 + try:
100 + if platform.system() == "Windows":
101 + # Common installation paths for wkhtmltopdf on Windows
102 + wkhtmltopdf_paths = [
103 + r"C:\Program Files\wkhtmltopdf\bin\wkhtmltopdf.exe",
104 + r"C:\Program Files (x86)\wkhtmltopdf\bin\wkhtmltopdf.exe",
105 + ]
106 + elif platform.system() == "Darwin": # macOS
107 + # Common installation paths for wkhtmltopdf on macOS
108 + wkhtmltopdf_paths = ["/usr/local/bin/wkhtmltopdf", "/opt/homebrew/bin/wkhtmltopdf"] # For macOS ARM (M1/M2) using Homebrew
109 + elif platform.system() == "Linux":
110 + # Common installation paths for wkhtmltopdf on Linux (Debian-based)
111 + wkhtmltopdf_paths = ["/usr/bin/wkhtmltopdf", "/usr/local/bin/wkhtmltopdf"]
112 +
113 + # Try each path until a valid executable is found
114 + path_to_wkhtmltopdf = None
115 + for path in wkhtmltopdf_paths:
116 + try:
117 + # Check if the executable can be accessed
118 + config = pdfkit.configuration(wkhtmltopdf=path)
119 + path_to_wkhtmltopdf = path
120 + break
121 + except OSError:
122 + continue
123 +
124 + # Raise an exception if no valid wkhtmltopdf path is found
125 + if path_to_wkhtmltopdf is None:
126 + raise FileNotFoundError("No valid wkhtmltopdf executable found. Ensure wkhtmltopdf is installed and accessible.")
127 +
128 + # Generate the PDF from HTML using the valid wkhtmltopdf path
129 + pdfkit.from_file(html_path, pdf_path, configuration=config)
130 + except Exception as e:
131 + raise RuntimeError(f"Failed to convert HTML to PDF: {str(e)}")
132 +
133 + return pdf_path
134 +
135 +
136 +def create_file_response_pdf(file_path: str, file_name: Optional[str] = "case_report.pdf") -> FileResponse:
137 + """Create a FileResponse object for the rendered document."""
138 + return FileResponse(
139 + file_path,
140 + filename=file_name,
141 + media_type="application/pdf",
142 + )
backend/app/incidents/templates/case_report_jinja_template.html new
+50
@@ -0,0 +1,50 @@
1 +<!DOCTYPE html>
2 +<html>
3 +<head>
4 + <title>Case Report</title>
5 + <style>
6 + body { font-family: Arial, sans-serif; }
7 + .case-info { margin-bottom: 20px; }
8 + .alert { margin-bottom: 15px; }
9 + </style>
10 +</head>
11 +<body>
12 + <h1>Case Report</h1>
13 + <div class="case-info">
14 + <p><strong>Name of Case:</strong> {{ case.name }}</p>
15 + <p><strong>Description:</strong> {{ case.description }}</p>
16 + <p><strong>Assigned To:</strong> {{ case.assigned_to }}</p>
17 + <p><strong>Case Creation Time:</strong> {{ case.case_creation_time }}</p>
18 + <p><strong>Case ID:</strong> {{ case.id }}</p>
19 + </div>
20 +
21 + <h2>Alerts:</h2>
22 + {% for alert in case.alerts %}
23 + <div class="alert">
24 + <p><strong>Alert Name:</strong> {{ alert.alert_name }}</p>
25 + <p><strong>Description:</strong> {{ alert.alert_description }}</p>
26 + <p><strong>Status:</strong> {{ alert.status }}</p>
27 + <p><strong>Tags:</strong> {{ alert.tags | join(', ') }}</p>
28 +
29 + <h3>Assets:</h3>
30 + {% for asset in alert.assets %}
31 + <p>- <strong>Asset Name:</strong> {{ asset.asset_name }} | <strong>Agent ID:</strong> {{ asset.agent_id }}</p>
32 + {% endfor %}
33 +
34 + <h3>Comments:</h3>
35 + {% for comment in alert.comments %}
36 + <p>- "{{ comment.comment }}" by {{ comment.user_name }} at {{ comment.created_at }}</p>
37 + {% endfor %}
38 +
39 + <h3>Context:</h3>
40 + <p><strong>Source:</strong> {{ alert.context.source }}</p>
41 + <p><strong>Context Details:</strong> {{ alert.context.context }}</p>
42 +
43 + <h3>IoCs:</h3>
44 + {% for ioc in alert.iocs %}
45 + <p>- <strong>IoC Value:</strong> {{ ioc.ioc_value }} | <strong>Type:</strong> {{ ioc.ioc_type }} | <strong>Description:</strong> {{ ioc.ioc_description }}</p>
46 + {% endfor %}
47 + </div>
48 + {% endfor %}
49 +</body>
50 +</html>
frontend/package-lock.json
+36 -35
@@ -56,7 +56,7 @@
56 "@types/fs-extra": "^11.0.4",
57 "@types/jsdom": "^21.1.7",
58 "@types/lodash": "^4.17.13",
59 - "@types/node": "^22.8.7",
59 + "@types/node": "^22.9.0",
60 "@types/validator": "^13.12.2",
61 "@vitejs/plugin-vue": "^5.1.4",
62 "@vitejs/plugin-vue-jsx": "^4.0.1",
@@ -83,7 +83,7 @@
83 "unplugin-vue-components": "^0.27.4",
84 "vite": "^5.4.10",
85 "vite-bundle-visualizer": "^1.2.1",
86 - "vite-plugin-vue-devtools": "^7.6.2",
86 + "vite-plugin-vue-devtools": "^7.6.3",
87 "vite-svg-loader": "^5.1.0",
88 "vitest": "^2.1.4",
89 "vue-tsc": "^2.1.10"
@@ -742,6 +742,7 @@
742 },
743 "node_modules/@clack/prompts/node_modules/is-unicode-supported": {
744 "version": "1.3.0",
745 + "extraneous": true,
746 "inBundle": true,
747 "license": "MIT",
748 "engines": {
@@ -2666,9 +2667,9 @@
2667 "dev": true
2668 },
2669 "node_modules/@types/node": {
2669 - "version": "22.8.7",
2670 - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.7.tgz",
2671 - "integrity": "sha512-LidcG+2UeYIWcMuMUpBKOnryBWG/rnmOHQR5apjn8myTQcx3rinFRn7DcIFhMnS0PPFSC6OafdIKEad0lj6U0Q==",
2670 + "version": "22.9.0",
2671 + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz",
2672 + "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==",
2673 "dev": true,
2674 "dependencies": {
2675 "undici-types": "~6.19.8"
@@ -2854,13 +2855,13 @@
2855 }
2856 },
2857 "node_modules/@typescript-eslint/typescript-estree": {
2857 - "version": "8.12.2",
2858 - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.12.2.tgz",
2859 - "integrity": "sha512-mME5MDwGe30Pq9zKPvyduyU86PH7aixwqYR2grTglAdB+AN8xXQ1vFGpYaUSJ5o5P/5znsSBeNcs5g5/2aQwow==",
2858 + "version": "8.13.0",
2859 + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.13.0.tgz",
2860 + "integrity": "sha512-v7SCIGmVsRK2Cy/LTLGN22uea6SaUIlpBcO/gnMGT/7zPtxp90bphcGf4fyrCQl3ZtiBKqVTG32hb668oIYy1g==",
2861 "dev": true,
2862 "dependencies": {
2862 - "@typescript-eslint/types": "8.12.2",
2863 - "@typescript-eslint/visitor-keys": "8.12.2",
2863 + "@typescript-eslint/types": "8.13.0",
2864 + "@typescript-eslint/visitor-keys": "8.13.0",
2865 "debug": "^4.3.4",
2866 "fast-glob": "^3.3.2",
2867 "is-glob": "^4.0.3",
@@ -2882,9 +2883,9 @@
2883 }
2884 },
2885 "node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/types": {
2885 - "version": "8.12.2",
2886 - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.12.2.tgz",
2887 - "integrity": "sha512-VwDwMF1SZ7wPBUZwmMdnDJ6sIFk4K4s+ALKLP6aIQsISkPv8jhiw65sAK6SuWODN/ix+m+HgbYDkH+zLjrzvOA==",
2886 + "version": "8.13.0",
2887 + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.13.0.tgz",
2888 + "integrity": "sha512-4cyFErJetFLckcThRUFdReWJjVsPCqyBlJTi6IDEpc1GWCIIZRFxVppjWLIMcQhNGhdWJJRYFHpHoDWvMlDzng==",
2889 "dev": true,
2890 "engines": {
2891 "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2895,12 +2896,12 @@
2896 }
2897 },
2898 "node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/visitor-keys": {
2898 - "version": "8.12.2",
2899 - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.12.2.tgz",
2900 - "integrity": "sha512-PChz8UaKQAVNHghsHcPyx1OMHoFRUEA7rJSK/mDhdq85bk+PLsUHUBqTQTFt18VJZbmxBovM65fezlheQRsSDA==",
2899 + "version": "8.13.0",
2900 + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.13.0.tgz",
2901 + "integrity": "sha512-7N/+lztJqH4Mrf0lb10R/CbI1EaAMMGyF5y0oJvFoAhafwgiRA7TXyd8TFn8FC8k5y2dTsYogg238qavRGNnlw==",
2902 "dev": true,
2903 "dependencies": {
2903 - "@typescript-eslint/types": "8.12.2",
2904 + "@typescript-eslint/types": "8.13.0",
2905 "eslint-visitor-keys": "^3.4.3"
2906 },
2907 "engines": {
@@ -3654,13 +3655,13 @@
3655 "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
3656 },
3657 "node_modules/@vue/devtools-core": {
3657 - "version": "7.6.2",
3658 - "resolved": "https://registry.npmjs.org/@vue/devtools-core/-/devtools-core-7.6.2.tgz",
3659 - "integrity": "sha512-hJfjNR3ai94Mb6i0PB42kxUPkPreS6Dl07FUaHAcw+umtkUX55jTXe7+mhsHx9NI6NFT+1WMFREIy8O81KLYyA==",
3658 + "version": "7.6.3",
3659 + "resolved": "https://registry.npmjs.org/@vue/devtools-core/-/devtools-core-7.6.3.tgz",
3660 + "integrity": "sha512-C7FOuh3Z+EmXXzDU9eRjHQL7zW7/CFovM6yCNNpUb+zXxhrn4fiqTum+a3gNau9DuzYfEtQXwZ9F7MeK0JKYVw==",
3661 "dev": true,
3662 "dependencies": {
3662 - "@vue/devtools-kit": "^7.6.2",
3663 - "@vue/devtools-shared": "^7.6.2",
3663 + "@vue/devtools-kit": "^7.6.3",
3664 + "@vue/devtools-shared": "^7.6.3",
3665 "mitt": "^3.0.1",
3666 "nanoid": "^3.3.4",
3667 "pathe": "^1.1.2",
@@ -3689,12 +3690,12 @@
3690 }
3691 },
3692 "node_modules/@vue/devtools-kit": {
3692 - "version": "7.6.2",
3693 - "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.6.2.tgz",
3694 - "integrity": "sha512-k61BxHRmcTtIQZFouF9QWt9nCCNtSdw12lhg8VNtHq5/XOBGD+ewiK27a40UJ8UPYoCJvi80hbvbYr5E/Zeu1g==",
3693 + "version": "7.6.3",
3694 + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.6.3.tgz",
3695 + "integrity": "sha512-ETsFc8GlOp04rSFN79tB2TpVloWfsSx9BoCSElV3w3CaJTSBfz42KsIi5Ka+dNTJs1jY7QVLTDeoBmUGgA9h2A==",
3696 "dev": true,
3697 "dependencies": {
3697 - "@vue/devtools-shared": "^7.6.2",
3698 + "@vue/devtools-shared": "^7.6.3",
3699 "birpc": "^0.2.19",
3700 "hookable": "^5.5.3",
3701 "mitt": "^3.0.1",
@@ -3704,9 +3705,9 @@
3705 }
3706 },
3707 "node_modules/@vue/devtools-shared": {
3707 - "version": "7.6.2",
3708 - "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.6.2.tgz",
3709 - "integrity": "sha512-lcjyJ7hCC0W0kNwnCGMLVTMvDLoZgjcq9BvboPgS+6jQyDul7fpzRSKTGtGhCHoxrDox7qBAKGbAl2Rcf7GE1A==",
3708 + "version": "7.6.3",
3709 + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.6.3.tgz",
3710 + "integrity": "sha512-wJW5QF27i16+sNQIaes8QoEZg1eqEgF83GkiPUlEQe9k7ZoHXHV7PRrnrxOKem42sIHPU813J2V/ZK1uqTJe6g==",
3711 "dev": true,
3712 "dependencies": {
3713 "rfdc": "^1.4.1"
@@ -14526,14 +14527,14 @@
14527 }
14528 },
14529 "node_modules/vite-plugin-vue-devtools": {
14529 - "version": "7.6.2",
14530 - "resolved": "https://registry.npmjs.org/vite-plugin-vue-devtools/-/vite-plugin-vue-devtools-7.6.2.tgz",
14531 - "integrity": "sha512-YPE/8AIBsomvHadZ02Kkp8yZo2FR0SFNjbC2lcMgW+hNA1ZoXu9b5oi18gTMzJcLLFRNNSMNjShA4RLqXlIR/A==",
14530 + "version": "7.6.3",
14531 + "resolved": "https://registry.npmjs.org/vite-plugin-vue-devtools/-/vite-plugin-vue-devtools-7.6.3.tgz",
14532 + "integrity": "sha512-p1rZMKzreWqxj9U05RaxY1vDoOhGYhA6iX8vKfo4nD6jqTmVoGjjk+U1g5HYwwTCdr/eck3kzO2f4gnPCjqVKA==",
14533 "dev": true,
14534 "dependencies": {
14534 - "@vue/devtools-core": "^7.6.2",
14535 - "@vue/devtools-kit": "^7.6.2",
14536 - "@vue/devtools-shared": "^7.6.2",
14535 + "@vue/devtools-core": "^7.6.3",
14536 + "@vue/devtools-kit": "^7.6.3",
14537 + "@vue/devtools-shared": "^7.6.3",
14538 "execa": "^8.0.1",
14539 "sirv": "^3.0.0",
14540 "vite-plugin-inspect": "^0.8.7",
frontend/package.json
+5 -5
@@ -86,7 +86,7 @@
86 "@types/fs-extra": "^11.0.4",
87 "@types/jsdom": "^21.1.7",
88 "@types/lodash": "^4.17.13",
89 - "@types/node": "^22.8.7",
89 + "@types/node": "^22.9.0",
90 "@types/validator": "^13.12.2",
91 "@vitejs/plugin-vue": "^5.1.4",
92 "@vitejs/plugin-vue-jsx": "^4.0.1",
@@ -113,16 +113,16 @@
113 "unplugin-vue-components": "^0.27.4",
114 "vite": "^5.4.10",
115 "vite-bundle-visualizer": "^1.2.1",
116 - "vite-plugin-vue-devtools": "^7.6.2",
116 + "vite-plugin-vue-devtools": "^7.6.3",
117 "vite-svg-loader": "^5.1.0",
118 "vitest": "^2.1.4",
119 "vue-tsc": "^2.1.10"
120 },
121 "pnpm": {
122 "overrides": {
123 - "@typescript-eslint/eslint-plugin": "^8.12.2",
123 + "@typescript-eslint/eslint-plugin": "^8.13.0",
124 "@typescript-eslint/eslint-plugin>eslint": "$eslint",
125 - "@typescript-eslint/parser": "^8.12.2",
125 + "@typescript-eslint/parser": "^8.13.0",
126 "@typescript-eslint/parser>eslint": "$eslint",
127 "eslint": "$eslint"
128 }
@@ -134,6 +134,6 @@
134 "@typescript-eslint/parser": {
135 "eslint": "^9.14.0"
136 },
137 - "@typescript-eslint/typescript-estree": "^8.12.2"
137 + "@typescript-eslint/typescript-estree": "^8.13.0"
138 }
139 }
frontend/src/api/endpoints/incidentManagement.ts
+3 -2
@@ -388,8 +388,9 @@ export default {
388 `/incidents/db_operations/case-report-template/do-default-template-exists`
389 )
390 },
391 - generateCaseReport(payload: CaseReportPayload) {
392 - return HttpClient.post<Blob>(`/incidents/report/generate-report-docx`, payload, {
391 + generateCaseReport(payload: CaseReportPayload, type: "docx" | "pdf") {
392 + const url = type === "docx" ? `/incidents/report/generate-report-docx` : `/incidents/report/generate-report-pdf`
393 + return HttpClient.post<Blob>(url, payload, {
394 responseType: "blob"
395 })
396 },
frontend/src/components/activeResponse/ActiveResponseWizard.vue
+2 -6
@@ -1,6 +1,6 @@
1 <template>
2 <n-spin :show="loading" class="active-response-wizard">
3 - <div class="wrapper flex flex-col">
3 + <div class="min-h-120 flex flex-col">
4 <div class="flex grow flex-col">
5 <n-scrollbar x-scrollable trigger="none">
6 <div class="p-7 pt-4">
@@ -59,7 +59,7 @@
59 </div>
60 </n-spin>
61 </div>
62 - <div v-else-if="current === 3" class="flex grow flex-col px-7 pb-7" style="min-height: 401px">
62 + <div v-else-if="current === 3" class="flex min-h-[401px] grow flex-col px-7 pb-7">
63 <ActiveResponseInvokeForm
64 v-if="selectedActiveResponse"
65 :active-response="selectedActiveResponse"
@@ -214,10 +214,6 @@ onMounted(() => {
214
215 <style lang="scss" scoped>
216 .active-response-wizard {
217 - .wrapper {
218 - min-height: 480px;
219 - }
220 -
217 .slide-form-right-enter-active,
218 .slide-form-right-leave-active,
219 .slide-form-left-enter-active,
frontend/src/components/agents/AgentCases.vue
+1 -8
@@ -8,7 +8,7 @@
8 </div>
9 </div>
10 </div>
11 - <div class="list my-3 flex flex-col gap-2">
11 + <div class="my-3 flex min-h-52 flex-col gap-2">
12 <template v-if="casesList.length">
13 <SocCaseItem
14 v-for="item of casesList"
@@ -75,10 +75,3 @@ onBeforeUnmount(() => {
75 abortController?.abort()
76 })
77 </script>
78 -
79 -<style lang="scss" scoped>
80 -.list {
81 - container-type: inline-size;
82 - min-height: 200px;
83 -}
84 -</style>
frontend/src/components/agents/AgentToolbar.vue
+1 -1
@@ -1,5 +1,5 @@
1 <template>
2 - <n-card class="agent-toolbar" content-style="padding:0">
2 + <n-card class="agent-toolbar" content-class="!p-0">
3 <div class="wrapper flex flex-col gap-6 px-4 py-3">
4 <div class="flex flex-col gap-2">
5 <div class="agent-search flex gap-3">
frontend/src/components/agents/agentFlow/AgentFlowCollectList.vue
+1 -8
@@ -8,7 +8,7 @@
8 </div>
9 </div>
10 </div>
11 - <div class="list my-3 flex flex-col gap-4">
11 + <div class="my-3 flex min-h-52 flex-col gap-4">
12 <template v-if="collectList.length">
13 <CollectItem
14 v-for="item of collectList"
@@ -72,10 +72,3 @@ onBeforeMount(() => {
72 getData()
73 })
74 </script>
75 -
76 -<style lang="scss" scoped>
77 -.list {
78 - container-type: inline-size;
79 - min-height: 200px;
80 -}
81 -</style>
frontend/src/components/agents/agentFlow/AgentFlowItem.vue
+1 -1
@@ -134,7 +134,7 @@
134 <n-empty v-else description="No items found" class="h-48 justify-center" />
135 </n-tab-pane>
136 <n-tab-pane name="Query stats" tab="Query stats" display-directive="show:lazy">
137 - <div class="p-7 pt-4" style="container-type: inline-size">
137 + <div class="p-7 pt-4">
138 <template v-if="flow.query_stats.length">
139 <AgentFlowQueryStat
140 v-for="stat of flow.query_stats"
frontend/src/components/agents/sca/ScaTable.vue
+1 -1
@@ -1,6 +1,6 @@
1 <template>
2 <n-spin class="sca-table" :show="loading">
3 - <n-scrollbar x-scrollable style="width: 100%">
3 + <n-scrollbar x-scrollable class="w-full">
4 <n-table :bordered="true" class="min-w-max">
5 <thead>
6 <tr>
frontend/src/components/alerts/AlertsList.vue
+1 -10
@@ -43,7 +43,7 @@
43 <n-spin :show="loading">
44 <template #description>Alerts are being fetched, this may take up to 1 minute.</template>
45
46 - <div class="list my-3 flex flex-col gap-2">
46 + <div class="my-3 flex min-h-52 flex-col gap-2">
47 <template v-if="alertsSummaryList.length">
48 <AlertsSummaryItem
49 v-for="alertsSummary of alertsSummaryList"
@@ -355,12 +355,3 @@ onBeforeUnmount(() => {
355 cancelSearch()
356 })
357 </script>
358 -
359 -<style lang="scss" scoped>
360 -.alerts-list {
361 - .list {
362 - container-type: inline-size;
363 - min-height: 200px;
364 - }
365 -}
366 -</style>
frontend/src/components/artifacts/CollectItem.vue
+3 -16
@@ -1,8 +1,8 @@
1 <template>
2 - <div>
2 + <div class="@container">
3 <CardEntity :embedded hoverable size="small">
4 - <div class="collect-item grid-auto-fit-200 grid gap-2">
5 - <CardKV v-for="prop of displayData" :key="prop.key" :class="{ 'hide-mobile': prop.hideMobile }">
4 + <div class="grid-auto-fit-200 @lg:grid flex flex-col gap-2">
5 + <CardKV v-for="prop of displayData" :key="prop.key" :class="{ '@lg:flex hidden': prop.hideMobile }">
6 <template #key>
7 {{ prop.key }}
8 </template>
@@ -102,16 +102,3 @@ onBeforeMount(() => {
102 delete jsonData.value.___id
103 })
104 </script>
105 -
106 -<style lang="scss" scoped>
107 -.collect-item {
108 - @container (max-width: 500px) {
109 - display: flex;
110 - flex-direction: column;
111 -
112 - .hide-mobile {
113 - display: none;
114 - }
115 - }
116 -}
117 -</style>
frontend/src/components/common/cards/CardStatsBars.vue
+1 -1
@@ -1,5 +1,5 @@
1 <template>
2 - <n-card content-style="padding:0" :class="{ hovered }">
2 + <n-card content-class="!p-0" :class="{ hovered }">
3 <div class="flex flex-col overflow-hidden">
4 <div class="card-header flex items-center justify-between gap-4">
5 <div class="title flex grow items-center gap-2">
frontend/src/components/common/cards/CardStatsMulti.vue
+1 -1
@@ -1,5 +1,5 @@
1 <template>
2 - <n-card content-style="padding:0" :class="{ hovered }">
2 + <n-card content-class="!p-0" :class="{ hovered }">
3 <div class="flex h-full flex-col overflow-hidden">
4 <div class="card-header flex items-center justify-between gap-4">
5 <div class="title flex grow items-center gap-2">
frontend/src/components/customers/CustomerAgents.vue
+1 -7
@@ -1,6 +1,6 @@
1 <template>
2 <n-spin :show="loading">
3 - <div class="customer-agents flex flex-col gap-2">
3 + <div class="flex min-h-28 flex-col gap-2">
4 <AgentCard
5 v-for="agent in list"
6 :key="agent.agent_id"
@@ -59,9 +59,3 @@ onBeforeMount(() => {
59 getAgents()
60 })
61 </script>
62 -
63 -<style lang="scss" scoped>
64 -.customer-agents {
65 - min-height: 100px;
66 -}
67 -</style>
frontend/src/components/customers/integrations/CustomerIntegrationForm.vue
+1 -4
@@ -1,5 +1,5 @@
1 <template>
2 - <div class="customer-integration-form flex flex-col gap-4">
2 + <div class="customer-integration-form min-h-120 flex flex-col gap-4 overflow-hidden">
3 <div>
4 <n-scrollbar x-scrollable trigger="none">
5 <div class="px-7 pb-2 pt-4">
@@ -219,9 +219,6 @@ function prev() {
219
220 <style lang="scss" scoped>
221 .customer-integration-form {
222 - min-height: 480px;
223 - overflow: hidden;
224 -
222 .slide-form-right-enter-active,
223 .slide-form-right-leave-active,
224 .slide-form-left-enter-active,
frontend/src/components/customers/networkConnectors/CustomerNetworkConnectorForm.vue
+1 -4
@@ -1,5 +1,5 @@
1 <template>
2 - <div class="customer-network-connector-form flex flex-col gap-4">
2 + <div class="customer-network-connector-form min-h-120 flex flex-col gap-4 overflow-hidden">
3 <div>
4 <n-scrollbar x-scrollable trigger="none">
5 <div class="px-7 pb-2 pt-4">
@@ -219,9 +219,6 @@ function prev() {
219
220 <style lang="scss" scoped>
221 .customer-network-connector-form {
222 - min-height: 480px;
223 - overflow: hidden;
224 -
222 .slide-form-right-enter-active,
223 .slide-form-right-leave-active,
224 .slide-form-left-enter-active,
frontend/src/components/customers/provision/CustomerProvisionWizard.vue
+1 -5
@@ -1,6 +1,6 @@
1 <template>
2 <n-spin :show="loading" class="customer-provision-wizard">
3 - <div class="wrapper flex flex-col">
3 + <div class="min-h-120 flex flex-col">
4 <div class="grow">
5 <n-scrollbar x-scrollable trigger="none">
6 <div class="p-7 pt-4">
@@ -644,10 +644,6 @@ onBeforeMount(() => {
644
645 <style lang="scss" scoped>
646 .customer-provision-wizard {
647 - .wrapper {
648 - min-height: 480px;
649 - }
650 -
647 .slide-form-right-enter-active,
648 .slide-form-right-leave-active,
649 .slide-form-left-enter-active,
frontend/src/components/incidentManagement/cases/CaseReportButton.vue
+45 -22
@@ -10,7 +10,7 @@
10 v-model:show="showForm"
11 display-directive="show"
12 preset="card"
13 - :style="{ maxWidth: 'min(600px, 90vw)', minHeight: 'min(270px, 90vh)', overflow: 'hidden' }"
13 + :style="{ maxWidth: 'min(600px, 90vw)', minHeight: 'min(240px, 90vh)', overflow: 'hidden' }"
14 title="Generate Report"
15 :bordered="false"
16 segmented
@@ -21,18 +21,20 @@
21 <n-form-item label="Template" path="template_name">
22 <CaseReportTemplateSelect v-model:value="form.template_name" />
23 </n-form-item>
24 - <n-form-item label="Filename" path="file_name">
25 - <n-input-group>
26 - <n-input
27 - v-model:value.trim="form.file_name"
28 - placeholder="Please insert File Name"
29 - clearable
30 - />
31 - <n-input-group-label>.docx</n-input-group-label>
32 - </n-input-group>
33 - </n-form-item>
34 -
35 - <div class="mt-8 flex justify-between gap-4">
24 + <n-collapse-transition :show="!!form.template_name">
25 + <n-form-item label="Filename" path="file_name">
26 + <n-input-group>
27 + <n-input
28 + v-model:value.trim="form.file_name"
29 + placeholder="Please insert File Name"
30 + clearable
31 + />
32 + <n-input-group-label v-if="reportType">.{{ reportType }}</n-input-group-label>
33 + </n-input-group>
34 + </n-form-item>
35 + </n-collapse-transition>
36 +
37 + <div class="mt-3 flex justify-between gap-4">
38 <n-button :disabled="exporting" @click="reset()">Reset</n-button>
39 <n-button
40 type="primary"
@@ -63,6 +65,7 @@ import {
65 type FormRules,
66 type FormValidationError,
67 NButton,
68 + NCollapseTransition,
69 NForm,
70 NFormItem,
71 NInput,
@@ -84,6 +87,17 @@ const showForm = ref(false)
87 const message = useMessage()
88 const form = ref<DeepNullable<CaseReportPayload>>(getClearForm())
89 const formRef = ref<FormInst | null>(null)
90 +const reportType = computed<"docx" | "pdf" | null>(() => {
91 + const ext = form.value.template_name?.split(".").pop()?.toLowerCase() || null
92 + switch (ext) {
93 + case "docx":
94 + return "docx"
95 + case "html":
96 + return "pdf"
97 + default:
98 + return null
99 + }
100 +})
101
102 const rules: FormRules = {
103 template_name: {
@@ -142,25 +156,34 @@ function resetForm() {
156 }
157
158 function exportCases() {
145 - if (!form.value.file_name || !form.value.template_name) return
159 + if (!form.value.file_name || !form.value.template_name || !reportType.value) return
160
161 exporting.value = true
162
163 + const extension = reportType.value === "pdf" ? "pdf" : "docx"
164 + const mimeType =
165 + reportType.value === "pdf"
166 + ? "application/pdf"
167 + : "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
168 +
169 const fileName = form.value.file_name
150 - ? `${form.value.file_name}.docx`
151 - : `case:${caseId}_report_${formatDate(new Date(), dFormats.datetimesec)}.docx`
170 + ? `${form.value.file_name}.${extension}`
171 + : `case:${caseId}_report_${formatDate(new Date(), dFormats.datetimesec)}.${extension}`
172
173 Api.incidentManagement
154 - .generateCaseReport({
155 - case_id: caseId,
156 - file_name: form.value.file_name,
157 - template_name: form.value.template_name
158 - })
174 + .generateCaseReport(
175 + {
176 + case_id: caseId,
177 + file_name: form.value.file_name,
178 + template_name: form.value.template_name
179 + },
180 + reportType.value
181 + )
182 .then(res => {
183 if (res.data) {
184 saveAs(
185 new Blob([res.data], {
163 - type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
186 + type: mimeType
187 }),
188 fileName
189 )
frontend/src/components/incidentManagement/cases/CaseReportTemplateManager.vue
+8 -2
@@ -29,14 +29,20 @@
29 v-model:file-list="fileList"
30 :max="1"
31 :disabled="uploading"
32 - accept="application/vnd.openxmlformats-officedocument.wordprocessingml.document, .docx, .DOCX"
32 + accept="application/vnd.openxmlformats-officedocument.wordprocessingml.document, .docx, .DOCX, text/html, .html, .HTML"
33 >
34 <n-upload-dragger>
35 <div>
36 <Icon :name="UploadIcon" :size="28" :depth="3"></Icon>
37 </div>
38 <div class="font-semibold">Click or drag a file to this area to upload</div>
39 - <p class="mt-2">Only .docx files are accepted</p>
39 + <p class="mt-2">
40 + Only
41 + <strong>.docx</strong>
42 + and
43 + <strong>.html</strong>
44 + files are accepted
45 + </p>
46 </n-upload-dragger>
47 </n-upload>
48
frontend/src/components/incidentManagement/cases/CaseReportTemplateSelect.vue
+1
@@ -7,6 +7,7 @@
7 clearable
8 :loading="loadingOptions"
9 class="overflow-hidden"
10 + to="body"
11 />
12 <n-button secondary @click="showManagerDialog = true">
13 <template #icon>
frontend/src/components/incidentManagement/sources/SourceConfigurationWizard.vue
+2 -6
@@ -1,6 +1,6 @@
1 <template>
2 <n-spin :show="loading" class="source-configuration-wizard">
3 - <div class="wrapper flex flex-col">
3 + <div class="flex min-h-48 flex-col">
4 <div class="flex grow flex-col">
5 <n-scrollbar x-scrollable trigger="none">
6 <div class="p-7 pt-4">
@@ -40,7 +40,7 @@
40 </div>
41 </div>
42
43 - <div v-else-if="current === 2" class="flex grow flex-col px-7 pb-7" style="min-height: 401px">
43 + <div v-else-if="current === 2" class="flex min-h-[401px] grow flex-col px-7 pb-7">
44 <SourceConfigurationForm
45 v-if="sourceConfigurationModel"
46 :source-configuration-model
@@ -210,10 +210,6 @@ onMounted(() => {
210
211 <style lang="scss" scoped>
212 .source-configuration-wizard {
213 - .wrapper {
214 - min-height: 180px;
215 - }
216 -
213 .slide-form-right-enter-active,
214 .slide-form-right-leave-active,
215 .slide-form-left-enter-active,
frontend/src/components/indices/ClusterHealth.vue
+17 -21
@@ -7,7 +7,7 @@
7 </div>
8 </template>
9 <n-spin :show="loading">
10 - <div v-if="cluster" class="info">
10 + <div v-if="cluster" class="min-h-14">
11 <n-scrollbar style="max-height: 500px" trigger="none">
12 <div class="card-wrap">
13 <div v-for="prop of propsOrder" :key="prop" class="box">
@@ -106,27 +106,23 @@ onBeforeMount(() => {
106
107 <style lang="scss" scoped>
108 .cluster-health {
109 - .info {
110 - min-height: 50px;
109 + .card-wrap {
110 + @apply gap-6 gap-x-6 px-4 py-3;
111 + column-width: 12rem;
112 + column-count: auto;
113
112 - .card-wrap {
113 - @apply gap-6 gap-x-6 px-4 py-3;
114 - column-width: 12rem;
115 - column-count: auto;
116 -
117 - .box {
118 - overflow: hidden;
119 - @apply mb-6;
120 - .value {
121 - font-weight: bold;
122 - margin-bottom: 2px;
123 - white-space: nowrap;
124 - }
125 - .label {
126 - @apply text-xs;
127 - font-family: var(--font-family-mono);
128 - opacity: 0.8;
129 - }
114 + .box {
115 + overflow: hidden;
116 + @apply mb-6;
117 + .value {
118 + font-weight: bold;
119 + margin-bottom: 2px;
120 + white-space: nowrap;
121 + }
122 + .label {
123 + @apply text-xs;
124 + font-family: var(--font-family-mono);
125 + opacity: 0.8;
126 }
127 }
128 }
frontend/src/components/indices/NodeAllocation.vue
+1 -2
@@ -9,7 +9,7 @@
9 </div>
10 </template>
11 <n-spin :show="loading">
12 - <div class="info">
12 + <div class="info min-h-14">
13 <template v-if="indicesAllocation.length">
14 <n-scrollbar style="max-height: 500px" trigger="none">
15 <div
@@ -126,7 +126,6 @@ onBeforeMount(() => {
126 <style lang="scss" scoped>
127 .cluster-health {
128 .info {
129 - min-height: 50px;
129 margin-left: -5px;
130 margin-right: -5px;
131
frontend/src/components/indices/UnhealthyIndices.vue
+6 -9
@@ -7,8 +7,8 @@
7 </div>
8 </template>
9 <n-spin :show="loading">
10 - <div class="info">
11 - <n-scrollbar style="max-height: 500px" trigger="none">
10 + <div class="min-h-14">
11 + <n-scrollbar class="max-h-125" trigger="none">
12 <template v-if="unhealthyIndices && unhealthyIndices.length">
13 <div
14 v-for="item of unhealthyIndices"
@@ -63,14 +63,11 @@ const unhealthyIndices = computed(() =>
63
64 <style lang="scss" scoped>
65 .unhealthy-indices {
66 - .info {
67 - min-height: 50px;
68 - .item {
69 - cursor: pointer;
66 + .item {
67 + cursor: pointer;
68
71 - &:not(:last-child) {
72 - @apply mb-3;
73 - }
69 + &:not(:last-child) {
70 + @apply mb-3;
71 }
72 }
73 }
frontend/src/components/license/bkp/LicenseEditor.vue
+4 -2
@@ -354,7 +354,9 @@ onBeforeMount(() => {
354 </script>
355
356 <style lang="scss" scoped>
357 -.license-box.loading {
358 - min-height: 100px;
357 +.license-box {
358 + &.loading {
359 + min-height: 100px;
360 + }
361 }
362 </style>
frontend/src/components/soc/SocAlerts/SocAlertAssets/SocAlertAssetsList.vue
+1 -1
@@ -1,6 +1,6 @@
1 <template>
2 <div class="soc-assets-list">
3 - <n-spin :show="loadingAssets" style="min-height: 50px">
3 + <n-spin :show="loadingAssets" class="min-h-14">
4 <div v-if="assetsList?.length" class="flex flex-col gap-2 p-7">
5 <SocAlertAssetsItem v-for="asset of assetsList" :key="asset.asset_id" :asset="asset" />
6 </div>
frontend/src/components/soc/SocAlerts/SocAlertsBookmarks.vue
+1 -5
@@ -10,7 +10,7 @@
10 </div>
11
12 <n-spin :show="loadingBookmarks">
13 - <div class="list">
13 + <div class="min-h-52">
14 <template v-if="bookmarksList.length">
15 <SocAlertItem
16 v-for="alert of bookmarksList"
@@ -133,9 +133,5 @@ onBeforeUnmount(() => {
133 .header {
134 height: 50px;
135 }
136 - .list {
137 - container-type: inline-size;
138 - min-height: 200px;
139 - }
136 }
137 </style>
frontend/src/components/soc/SocCases/SocCaseAssetLink.vue
+1 -1
@@ -40,7 +40,7 @@
40 <template #header>
41 <div class="-ml-2 py-3">SOC Case details</div>
42 </template>
43 - <div style="min-height: 50px">
43 + <div class="min-h-14">
44 <n-spin :show="loadingCase">
45 <SocCaseItem
46 v-if="socCase"
frontend/src/components/soc/SocCases/SocCaseAssetsList.vue
+1 -1
@@ -1,6 +1,6 @@
1 <template>
2 <div class="soc-assets-list">
3 - <n-spin :show="loadingAssets" style="min-height: 50px">
3 + <n-spin :show="loadingAssets" class="min-h-14">
4 <div class="flex flex-col gap-2 px-7 py-4 pb-0">
5 <div v-if="assetsState" class="box">
6 State:
frontend/src/components/stackProvisioning/StackProvisioningList.vue
+1 -8
@@ -1,7 +1,7 @@
1 <template>
2 <div class="stack-provisioning-list">
3 <n-spin :show="loading">
4 - <div class="list my-3 flex flex-col gap-2">
4 + <div class="my-3 flex min-h-52 flex-col gap-2">
5 <template v-if="list.length">
6 <StackProvisioningItem v-for="item of list" :key="item.name" :content-pack="item" />
7 </template>
@@ -49,10 +49,3 @@ onBeforeMount(() => {
49 getData()
50 })
51 </script>
52 -
53 -<style lang="scss" scoped>
54 -.list {
55 - container-type: inline-size;
56 - min-height: 200px;
57 -}
58 -</style>
frontend/src/views/agents/Overview.vue
+1 -2
@@ -355,8 +355,7 @@ onBeforeMount(() => {
355 }
356
357 .section {
358 - min-height: 200px;
359 - @apply mt-2;
358 + @apply mt-2 min-h-52;
359 }
360 }
361 </style>
frontend/src/views/graylog/Pipelines.vue
+1 -1
@@ -9,7 +9,7 @@
9 </n-button>
10 </div>
11
12 - <PipeList min-height="100px" @open-rule="openRule($event)" />
12 + <PipeList @open-rule="openRule($event)" />
13
14 <n-modal
15 v-model:show="showDetails"
frontend/tailwind.config.js
+4
@@ -60,6 +60,9 @@ export default {
60 150: "37.5rem",
61 "60vh": "60vh"
62 },
63 + minHeight: {
64 + 120: "30rem"
65 + },
66 width: {
67 5.5: "1.375rem",
68 150: "37.5rem"
@@ -71,6 +74,7 @@ export default {
74 maxHeight: {
75 "50vh": "50vh",
76 106: "26.5rem",
77 + 125: "31.25rem",
78 150: "37.5rem"
79 },
80 maxWidth: {