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: {