mirror of
https://github.com/yyhuni/xingrin.git
synced 2026-01-31 19:53:11 +08:00
Compare commits
2 Commits
v1.3.11-de
...
v1.3.12-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adb53c9f85 | ||
|
|
8dd3f0536e |
@@ -11,7 +11,6 @@
|
||||
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from typing import Optional, List, Dict, Any, Tuple, Literal, Iterator
|
||||
|
||||
from django.db import connection
|
||||
@@ -370,13 +369,14 @@ class AssetSearchService:
|
||||
logger.error(f"搜索查询失败: {e}, SQL: {sql}, params: {params}")
|
||||
raise
|
||||
|
||||
def count(self, query: str, asset_type: AssetType = 'website') -> int:
|
||||
def count(self, query: str, asset_type: AssetType = 'website', statement_timeout_ms: int = 300000) -> int:
|
||||
"""
|
||||
统计搜索结果数量
|
||||
|
||||
Args:
|
||||
query: 搜索查询字符串
|
||||
asset_type: 资产类型 ('website' 或 'endpoint')
|
||||
statement_timeout_ms: SQL 语句超时时间(毫秒),默认 5 分钟
|
||||
|
||||
Returns:
|
||||
int: 结果总数
|
||||
@@ -390,6 +390,8 @@ class AssetSearchService:
|
||||
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
# 为导出设置更长的超时时间(仅影响当前会话)
|
||||
cursor.execute(f"SET LOCAL statement_timeout = {statement_timeout_ms}")
|
||||
cursor.execute(sql, params)
|
||||
return cursor.fetchone()[0]
|
||||
except Exception as e:
|
||||
@@ -400,15 +402,17 @@ class AssetSearchService:
|
||||
self,
|
||||
query: str,
|
||||
asset_type: AssetType = 'website',
|
||||
batch_size: int = 1000
|
||||
batch_size: int = 1000,
|
||||
statement_timeout_ms: int = 300000
|
||||
) -> Iterator[Dict[str, Any]]:
|
||||
"""
|
||||
流式搜索资产(使用服务端游标,内存友好)
|
||||
流式搜索资产(使用分批查询,内存友好)
|
||||
|
||||
Args:
|
||||
query: 搜索查询字符串
|
||||
asset_type: 资产类型 ('website' 或 'endpoint')
|
||||
batch_size: 每批获取的数量
|
||||
statement_timeout_ms: SQL 语句超时时间(毫秒),默认 5 分钟
|
||||
|
||||
Yields:
|
||||
Dict: 单条搜索结果
|
||||
@@ -419,25 +423,38 @@ class AssetSearchService:
|
||||
view_name = VIEW_MAPPING.get(asset_type, 'asset_search_view')
|
||||
select_fields = ENDPOINT_SELECT_FIELDS if asset_type == 'endpoint' else WEBSITE_SELECT_FIELDS
|
||||
|
||||
sql = f"""
|
||||
SELECT {select_fields}
|
||||
FROM {view_name}
|
||||
WHERE {where_clause}
|
||||
ORDER BY created_at DESC
|
||||
"""
|
||||
|
||||
# 生成唯一的游标名称,避免并发请求冲突
|
||||
cursor_name = f'export_cursor_{uuid.uuid4().hex[:8]}'
|
||||
# 使用 OFFSET/LIMIT 分批查询(Django 不支持命名游标)
|
||||
offset = 0
|
||||
|
||||
try:
|
||||
# 使用服务端游标,避免一次性加载所有数据到内存
|
||||
with connection.cursor(name=cursor_name) as cursor:
|
||||
cursor.itersize = batch_size
|
||||
cursor.execute(sql, params)
|
||||
columns = [col[0] for col in cursor.description]
|
||||
while True:
|
||||
sql = f"""
|
||||
SELECT {select_fields}
|
||||
FROM {view_name}
|
||||
WHERE {where_clause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT {batch_size} OFFSET {offset}
|
||||
"""
|
||||
|
||||
for row in cursor:
|
||||
with connection.cursor() as cursor:
|
||||
# 为导出设置更长的超时时间(仅影响当前会话)
|
||||
cursor.execute(f"SET LOCAL statement_timeout = {statement_timeout_ms}")
|
||||
cursor.execute(sql, params)
|
||||
columns = [col[0] for col in cursor.description]
|
||||
rows = cursor.fetchall()
|
||||
|
||||
if not rows:
|
||||
break
|
||||
|
||||
for row in rows:
|
||||
yield dict(zip(columns, row))
|
||||
|
||||
# 如果返回的行数少于 batch_size,说明已经是最后一批
|
||||
if len(rows) < batch_size:
|
||||
break
|
||||
|
||||
offset += batch_size
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"流式搜索查询失败: {e}, SQL: {sql}, params: {params}")
|
||||
raise
|
||||
|
||||
@@ -8,7 +8,6 @@ from rest_framework.request import Request
|
||||
from rest_framework.exceptions import NotFound, ValidationError as DRFValidationError
|
||||
from django.core.exceptions import ValidationError, ObjectDoesNotExist
|
||||
from django.db import DatabaseError, IntegrityError, OperationalError
|
||||
from django.http import StreamingHttpResponse
|
||||
|
||||
from ..serializers import (
|
||||
SubdomainListSerializer, WebSiteSerializer, DirectorySerializer,
|
||||
@@ -243,7 +242,7 @@ class SubdomainViewSet(viewsets.ModelViewSet):
|
||||
|
||||
CSV 列:name, created_at
|
||||
"""
|
||||
from apps.common.utils import generate_csv_rows, format_datetime
|
||||
from apps.common.utils import create_csv_export_response, format_datetime
|
||||
|
||||
target_pk = self.kwargs.get('target_pk')
|
||||
if not target_pk:
|
||||
@@ -254,12 +253,12 @@ class SubdomainViewSet(viewsets.ModelViewSet):
|
||||
headers = ['name', 'created_at']
|
||||
formatters = {'created_at': format_datetime}
|
||||
|
||||
response = StreamingHttpResponse(
|
||||
generate_csv_rows(data_iterator, headers, formatters),
|
||||
content_type='text/csv; charset=utf-8'
|
||||
return create_csv_export_response(
|
||||
data_iterator=data_iterator,
|
||||
headers=headers,
|
||||
filename=f"target-{target_pk}-subdomains.csv",
|
||||
field_formatters=formatters
|
||||
)
|
||||
response['Content-Disposition'] = f'attachment; filename="target-{target_pk}-subdomains.csv"'
|
||||
return response
|
||||
|
||||
|
||||
class WebSiteViewSet(viewsets.ModelViewSet):
|
||||
@@ -369,7 +368,7 @@ class WebSiteViewSet(viewsets.ModelViewSet):
|
||||
|
||||
CSV 列:url, host, location, title, status_code, content_length, content_type, webserver, tech, response_body, response_headers, vhost, created_at
|
||||
"""
|
||||
from apps.common.utils import generate_csv_rows, format_datetime, format_list_field
|
||||
from apps.common.utils import create_csv_export_response, format_datetime, format_list_field
|
||||
|
||||
target_pk = self.kwargs.get('target_pk')
|
||||
if not target_pk:
|
||||
@@ -387,12 +386,12 @@ class WebSiteViewSet(viewsets.ModelViewSet):
|
||||
'tech': lambda x: format_list_field(x, separator=','),
|
||||
}
|
||||
|
||||
response = StreamingHttpResponse(
|
||||
generate_csv_rows(data_iterator, headers, formatters),
|
||||
content_type='text/csv; charset=utf-8'
|
||||
return create_csv_export_response(
|
||||
data_iterator=data_iterator,
|
||||
headers=headers,
|
||||
filename=f"target-{target_pk}-websites.csv",
|
||||
field_formatters=formatters
|
||||
)
|
||||
response['Content-Disposition'] = f'attachment; filename="target-{target_pk}-websites.csv"'
|
||||
return response
|
||||
|
||||
|
||||
class DirectoryViewSet(viewsets.ModelViewSet):
|
||||
@@ -499,7 +498,7 @@ class DirectoryViewSet(viewsets.ModelViewSet):
|
||||
|
||||
CSV 列:url, status, content_length, words, lines, content_type, duration, created_at
|
||||
"""
|
||||
from apps.common.utils import generate_csv_rows, format_datetime
|
||||
from apps.common.utils import create_csv_export_response, format_datetime
|
||||
|
||||
target_pk = self.kwargs.get('target_pk')
|
||||
if not target_pk:
|
||||
@@ -515,12 +514,12 @@ class DirectoryViewSet(viewsets.ModelViewSet):
|
||||
'created_at': format_datetime,
|
||||
}
|
||||
|
||||
response = StreamingHttpResponse(
|
||||
generate_csv_rows(data_iterator, headers, formatters),
|
||||
content_type='text/csv; charset=utf-8'
|
||||
return create_csv_export_response(
|
||||
data_iterator=data_iterator,
|
||||
headers=headers,
|
||||
filename=f"target-{target_pk}-directories.csv",
|
||||
field_formatters=formatters
|
||||
)
|
||||
response['Content-Disposition'] = f'attachment; filename="target-{target_pk}-directories.csv"'
|
||||
return response
|
||||
|
||||
|
||||
class EndpointViewSet(viewsets.ModelViewSet):
|
||||
@@ -630,7 +629,7 @@ class EndpointViewSet(viewsets.ModelViewSet):
|
||||
|
||||
CSV 列:url, host, location, title, status_code, content_length, content_type, webserver, tech, response_body, response_headers, vhost, matched_gf_patterns, created_at
|
||||
"""
|
||||
from apps.common.utils import generate_csv_rows, format_datetime, format_list_field
|
||||
from apps.common.utils import create_csv_export_response, format_datetime, format_list_field
|
||||
|
||||
target_pk = self.kwargs.get('target_pk')
|
||||
if not target_pk:
|
||||
@@ -649,12 +648,12 @@ class EndpointViewSet(viewsets.ModelViewSet):
|
||||
'matched_gf_patterns': lambda x: format_list_field(x, separator=','),
|
||||
}
|
||||
|
||||
response = StreamingHttpResponse(
|
||||
generate_csv_rows(data_iterator, headers, formatters),
|
||||
content_type='text/csv; charset=utf-8'
|
||||
return create_csv_export_response(
|
||||
data_iterator=data_iterator,
|
||||
headers=headers,
|
||||
filename=f"target-{target_pk}-endpoints.csv",
|
||||
field_formatters=formatters
|
||||
)
|
||||
response['Content-Disposition'] = f'attachment; filename="target-{target_pk}-endpoints.csv"'
|
||||
return response
|
||||
|
||||
|
||||
class HostPortMappingViewSet(viewsets.ModelViewSet):
|
||||
@@ -707,7 +706,7 @@ class HostPortMappingViewSet(viewsets.ModelViewSet):
|
||||
|
||||
CSV 列:ip, host, port, created_at
|
||||
"""
|
||||
from apps.common.utils import generate_csv_rows, format_datetime
|
||||
from apps.common.utils import create_csv_export_response, format_datetime
|
||||
|
||||
target_pk = self.kwargs.get('target_pk')
|
||||
if not target_pk:
|
||||
@@ -722,14 +721,12 @@ class HostPortMappingViewSet(viewsets.ModelViewSet):
|
||||
'created_at': format_datetime
|
||||
}
|
||||
|
||||
# 生成流式响应
|
||||
response = StreamingHttpResponse(
|
||||
generate_csv_rows(data_iterator, headers, formatters),
|
||||
content_type='text/csv; charset=utf-8'
|
||||
return create_csv_export_response(
|
||||
data_iterator=data_iterator,
|
||||
headers=headers,
|
||||
filename=f"target-{target_pk}-ip-addresses.csv",
|
||||
field_formatters=formatters
|
||||
)
|
||||
response['Content-Disposition'] = f'attachment; filename="target-{target_pk}-ip-addresses.csv"'
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class VulnerabilityViewSet(viewsets.ModelViewSet):
|
||||
@@ -801,7 +798,7 @@ class SubdomainSnapshotViewSet(viewsets.ModelViewSet):
|
||||
|
||||
CSV 列:name, created_at
|
||||
"""
|
||||
from apps.common.utils import generate_csv_rows, format_datetime
|
||||
from apps.common.utils import create_csv_export_response, format_datetime
|
||||
|
||||
scan_pk = self.kwargs.get('scan_pk')
|
||||
if not scan_pk:
|
||||
@@ -812,12 +809,12 @@ class SubdomainSnapshotViewSet(viewsets.ModelViewSet):
|
||||
headers = ['name', 'created_at']
|
||||
formatters = {'created_at': format_datetime}
|
||||
|
||||
response = StreamingHttpResponse(
|
||||
generate_csv_rows(data_iterator, headers, formatters),
|
||||
content_type='text/csv; charset=utf-8'
|
||||
return create_csv_export_response(
|
||||
data_iterator=data_iterator,
|
||||
headers=headers,
|
||||
filename=f"scan-{scan_pk}-subdomains.csv",
|
||||
field_formatters=formatters
|
||||
)
|
||||
response['Content-Disposition'] = f'attachment; filename="scan-{scan_pk}-subdomains.csv"'
|
||||
return response
|
||||
|
||||
|
||||
class WebsiteSnapshotViewSet(viewsets.ModelViewSet):
|
||||
@@ -855,7 +852,7 @@ class WebsiteSnapshotViewSet(viewsets.ModelViewSet):
|
||||
|
||||
CSV 列:url, host, location, title, status_code, content_length, content_type, webserver, tech, response_body, response_headers, vhost, created_at
|
||||
"""
|
||||
from apps.common.utils import generate_csv_rows, format_datetime, format_list_field
|
||||
from apps.common.utils import create_csv_export_response, format_datetime, format_list_field
|
||||
|
||||
scan_pk = self.kwargs.get('scan_pk')
|
||||
if not scan_pk:
|
||||
@@ -873,12 +870,12 @@ class WebsiteSnapshotViewSet(viewsets.ModelViewSet):
|
||||
'tech': lambda x: format_list_field(x, separator=','),
|
||||
}
|
||||
|
||||
response = StreamingHttpResponse(
|
||||
generate_csv_rows(data_iterator, headers, formatters),
|
||||
content_type='text/csv; charset=utf-8'
|
||||
return create_csv_export_response(
|
||||
data_iterator=data_iterator,
|
||||
headers=headers,
|
||||
filename=f"scan-{scan_pk}-websites.csv",
|
||||
field_formatters=formatters
|
||||
)
|
||||
response['Content-Disposition'] = f'attachment; filename="scan-{scan_pk}-websites.csv"'
|
||||
return response
|
||||
|
||||
|
||||
class DirectorySnapshotViewSet(viewsets.ModelViewSet):
|
||||
@@ -913,7 +910,7 @@ class DirectorySnapshotViewSet(viewsets.ModelViewSet):
|
||||
|
||||
CSV 列:url, status, content_length, words, lines, content_type, duration, created_at
|
||||
"""
|
||||
from apps.common.utils import generate_csv_rows, format_datetime
|
||||
from apps.common.utils import create_csv_export_response, format_datetime
|
||||
|
||||
scan_pk = self.kwargs.get('scan_pk')
|
||||
if not scan_pk:
|
||||
@@ -929,12 +926,12 @@ class DirectorySnapshotViewSet(viewsets.ModelViewSet):
|
||||
'created_at': format_datetime,
|
||||
}
|
||||
|
||||
response = StreamingHttpResponse(
|
||||
generate_csv_rows(data_iterator, headers, formatters),
|
||||
content_type='text/csv; charset=utf-8'
|
||||
return create_csv_export_response(
|
||||
data_iterator=data_iterator,
|
||||
headers=headers,
|
||||
filename=f"scan-{scan_pk}-directories.csv",
|
||||
field_formatters=formatters
|
||||
)
|
||||
response['Content-Disposition'] = f'attachment; filename="scan-{scan_pk}-directories.csv"'
|
||||
return response
|
||||
|
||||
|
||||
class EndpointSnapshotViewSet(viewsets.ModelViewSet):
|
||||
@@ -972,7 +969,7 @@ class EndpointSnapshotViewSet(viewsets.ModelViewSet):
|
||||
|
||||
CSV 列:url, host, location, title, status_code, content_length, content_type, webserver, tech, response_body, response_headers, vhost, matched_gf_patterns, created_at
|
||||
"""
|
||||
from apps.common.utils import generate_csv_rows, format_datetime, format_list_field
|
||||
from apps.common.utils import create_csv_export_response, format_datetime, format_list_field
|
||||
|
||||
scan_pk = self.kwargs.get('scan_pk')
|
||||
if not scan_pk:
|
||||
@@ -991,12 +988,12 @@ class EndpointSnapshotViewSet(viewsets.ModelViewSet):
|
||||
'matched_gf_patterns': lambda x: format_list_field(x, separator=','),
|
||||
}
|
||||
|
||||
response = StreamingHttpResponse(
|
||||
generate_csv_rows(data_iterator, headers, formatters),
|
||||
content_type='text/csv; charset=utf-8'
|
||||
return create_csv_export_response(
|
||||
data_iterator=data_iterator,
|
||||
headers=headers,
|
||||
filename=f"scan-{scan_pk}-endpoints.csv",
|
||||
field_formatters=formatters
|
||||
)
|
||||
response['Content-Disposition'] = f'attachment; filename="scan-{scan_pk}-endpoints.csv"'
|
||||
return response
|
||||
|
||||
|
||||
class HostPortMappingSnapshotViewSet(viewsets.ModelViewSet):
|
||||
@@ -1031,7 +1028,7 @@ class HostPortMappingSnapshotViewSet(viewsets.ModelViewSet):
|
||||
|
||||
CSV 列:ip, host, port, created_at
|
||||
"""
|
||||
from apps.common.utils import generate_csv_rows, format_datetime
|
||||
from apps.common.utils import create_csv_export_response, format_datetime
|
||||
|
||||
scan_pk = self.kwargs.get('scan_pk')
|
||||
if not scan_pk:
|
||||
@@ -1046,14 +1043,12 @@ class HostPortMappingSnapshotViewSet(viewsets.ModelViewSet):
|
||||
'created_at': format_datetime
|
||||
}
|
||||
|
||||
# 生成流式响应
|
||||
response = StreamingHttpResponse(
|
||||
generate_csv_rows(data_iterator, headers, formatters),
|
||||
content_type='text/csv; charset=utf-8'
|
||||
return create_csv_export_response(
|
||||
data_iterator=data_iterator,
|
||||
headers=headers,
|
||||
filename=f"scan-{scan_pk}-ip-addresses.csv",
|
||||
field_formatters=formatters
|
||||
)
|
||||
response['Content-Disposition'] = f'attachment; filename="scan-{scan_pk}-ip-addresses.csv"'
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class VulnerabilitySnapshotViewSet(viewsets.ModelViewSet):
|
||||
|
||||
@@ -33,9 +33,7 @@ from urllib.parse import urlparse, urlunparse
|
||||
from rest_framework import status
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.request import Request
|
||||
from django.http import StreamingHttpResponse
|
||||
from django.db import connection, transaction
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.db import connection
|
||||
|
||||
from apps.common.response_helpers import success_response, error_response
|
||||
from apps.common.error_codes import ErrorCodes
|
||||
@@ -286,10 +284,7 @@ class AssetSearchExportView(APIView):
|
||||
asset_type: 资产类型 ('website' 或 'endpoint',默认 'website')
|
||||
|
||||
Response:
|
||||
CSV 文件流(使用服务端游标,支持大数据量导出)
|
||||
|
||||
注意:使用 @transaction.non_atomic_requests 装饰器,
|
||||
因为服务端游标不能在事务块内使用。
|
||||
CSV 文件(带 Content-Length,支持浏览器显示下载进度)
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
@@ -316,10 +311,9 @@ class AssetSearchExportView(APIView):
|
||||
|
||||
return headers, formatters
|
||||
|
||||
@method_decorator(transaction.non_atomic_requests)
|
||||
def get(self, request: Request):
|
||||
"""导出搜索结果为 CSV(流式导出,无数量限制)"""
|
||||
from apps.common.utils import generate_csv_rows
|
||||
"""导出搜索结果为 CSV(带 Content-Length,支持下载进度显示)"""
|
||||
from apps.common.utils import create_csv_export_response
|
||||
|
||||
# 获取搜索查询
|
||||
query = request.query_params.get('q', '').strip()
|
||||
@@ -352,18 +346,16 @@ class AssetSearchExportView(APIView):
|
||||
# 获取表头和格式化器
|
||||
headers, formatters = self._get_headers_and_formatters(asset_type)
|
||||
|
||||
# 获取流式数据迭代器
|
||||
data_iterator = self.service.search_iter(query, asset_type)
|
||||
|
||||
# 生成文件名
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
filename = f'search_{asset_type}_{timestamp}.csv'
|
||||
|
||||
# 返回流式响应
|
||||
response = StreamingHttpResponse(
|
||||
generate_csv_rows(data_iterator, headers, formatters),
|
||||
content_type='text/csv; charset=utf-8'
|
||||
# 使用通用导出工具
|
||||
data_iterator = self.service.search_iter(query, asset_type)
|
||||
return create_csv_export_response(
|
||||
data_iterator=data_iterator,
|
||||
headers=headers,
|
||||
filename=filename,
|
||||
field_formatters=formatters,
|
||||
show_progress=True # 显示下载进度
|
||||
)
|
||||
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||||
|
||||
return response
|
||||
|
||||
@@ -11,6 +11,7 @@ from .csv_utils import (
|
||||
generate_csv_rows,
|
||||
format_list_field,
|
||||
format_datetime,
|
||||
create_csv_export_response,
|
||||
UTF8_BOM,
|
||||
)
|
||||
|
||||
@@ -24,5 +25,6 @@ __all__ = [
|
||||
'generate_csv_rows',
|
||||
'format_list_field',
|
||||
'format_datetime',
|
||||
'create_csv_export_response',
|
||||
'UTF8_BOM',
|
||||
]
|
||||
|
||||
@@ -4,13 +4,21 @@
|
||||
- UTF-8 BOM(Excel 兼容)
|
||||
- RFC 4180 规范转义
|
||||
- 流式生成(内存友好)
|
||||
- 带 Content-Length 的文件响应(支持浏览器下载进度显示)
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Iterator, Dict, Any, List, Callable, Optional
|
||||
|
||||
from django.http import FileResponse, StreamingHttpResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# UTF-8 BOM,确保 Excel 正确识别编码
|
||||
UTF8_BOM = '\ufeff'
|
||||
|
||||
@@ -114,3 +122,123 @@ def format_datetime(dt: Optional[datetime]) -> str:
|
||||
dt = timezone.localtime(dt)
|
||||
|
||||
return dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
|
||||
def create_csv_export_response(
|
||||
data_iterator: Iterator[Dict[str, Any]],
|
||||
headers: List[str],
|
||||
filename: str,
|
||||
field_formatters: Optional[Dict[str, Callable]] = None,
|
||||
show_progress: bool = True
|
||||
) -> FileResponse | StreamingHttpResponse:
|
||||
"""
|
||||
创建 CSV 导出响应
|
||||
|
||||
根据 show_progress 参数选择响应类型:
|
||||
- True: 使用临时文件 + FileResponse,带 Content-Length(浏览器显示下载进度)
|
||||
- False: 使用 StreamingHttpResponse(内存更友好,但无下载进度)
|
||||
|
||||
Args:
|
||||
data_iterator: 数据迭代器,每个元素是一个字典
|
||||
headers: CSV 表头列表
|
||||
filename: 下载文件名(如 "export_2024.csv")
|
||||
field_formatters: 字段格式化函数字典
|
||||
show_progress: 是否显示下载进度(默认 True)
|
||||
|
||||
Returns:
|
||||
FileResponse 或 StreamingHttpResponse
|
||||
|
||||
Example:
|
||||
>>> data_iter = service.iter_data()
|
||||
>>> headers = ['url', 'host', 'created_at']
|
||||
>>> formatters = {'created_at': format_datetime}
|
||||
>>> response = create_csv_export_response(
|
||||
... data_iter, headers, 'websites.csv', formatters
|
||||
... )
|
||||
>>> return response
|
||||
"""
|
||||
if show_progress:
|
||||
return _create_file_response(data_iterator, headers, filename, field_formatters)
|
||||
else:
|
||||
return _create_streaming_response(data_iterator, headers, filename, field_formatters)
|
||||
|
||||
|
||||
def _create_file_response(
|
||||
data_iterator: Iterator[Dict[str, Any]],
|
||||
headers: List[str],
|
||||
filename: str,
|
||||
field_formatters: Optional[Dict[str, Callable]] = None
|
||||
) -> FileResponse:
|
||||
"""
|
||||
创建带 Content-Length 的文件响应(支持浏览器下载进度)
|
||||
|
||||
实现方式:先写入临时文件,再返回 FileResponse
|
||||
"""
|
||||
# 创建临时文件
|
||||
temp_file = tempfile.NamedTemporaryFile(
|
||||
mode='w',
|
||||
suffix='.csv',
|
||||
delete=False,
|
||||
encoding='utf-8'
|
||||
)
|
||||
temp_path = temp_file.name
|
||||
|
||||
try:
|
||||
# 流式写入 CSV 数据到临时文件
|
||||
for row in generate_csv_rows(data_iterator, headers, field_formatters):
|
||||
temp_file.write(row)
|
||||
temp_file.close()
|
||||
|
||||
# 获取文件大小
|
||||
file_size = os.path.getsize(temp_path)
|
||||
|
||||
# 创建文件响应
|
||||
response = FileResponse(
|
||||
open(temp_path, 'rb'),
|
||||
content_type='text/csv; charset=utf-8',
|
||||
as_attachment=True,
|
||||
filename=filename
|
||||
)
|
||||
response['Content-Length'] = file_size
|
||||
|
||||
# 设置清理回调:响应完成后删除临时文件
|
||||
original_close = response.file_to_stream.close
|
||||
def close_and_cleanup():
|
||||
original_close()
|
||||
try:
|
||||
os.unlink(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
response.file_to_stream.close = close_and_cleanup
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
# 清理临时文件
|
||||
try:
|
||||
temp_file.close()
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
os.unlink(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
logger.error(f"创建 CSV 导出响应失败: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def _create_streaming_response(
|
||||
data_iterator: Iterator[Dict[str, Any]],
|
||||
headers: List[str],
|
||||
filename: str,
|
||||
field_formatters: Optional[Dict[str, Callable]] = None
|
||||
) -> StreamingHttpResponse:
|
||||
"""
|
||||
创建流式响应(无 Content-Length,内存更友好)
|
||||
"""
|
||||
response = StreamingHttpResponse(
|
||||
generate_csv_rows(data_iterator, headers, field_formatters),
|
||||
content_type='text/csv; charset=utf-8'
|
||||
)
|
||||
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||||
return response
|
||||
|
||||
@@ -1,23 +1,50 @@
|
||||
from rest_framework import serializers
|
||||
from django.db.models import Count
|
||||
import yaml
|
||||
|
||||
from .models import Scan, ScheduledScan
|
||||
|
||||
|
||||
# ==================== 通用验证 Mixin ====================
|
||||
|
||||
class DuplicateKeyLoader(yaml.SafeLoader):
|
||||
"""自定义 YAML Loader,检测重复 key"""
|
||||
pass
|
||||
|
||||
|
||||
def _check_duplicate_keys(loader, node, deep=False):
|
||||
"""检测 YAML mapping 中的重复 key"""
|
||||
mapping = {}
|
||||
for key_node, value_node in node.value:
|
||||
key = loader.construct_object(key_node, deep=deep)
|
||||
if key in mapping:
|
||||
raise yaml.constructor.ConstructorError(
|
||||
"while constructing a mapping", node.start_mark,
|
||||
f"发现重复的配置项 '{key}',后面的配置会覆盖前面的配置,请删除重复项", key_node.start_mark
|
||||
)
|
||||
mapping[key] = loader.construct_object(value_node, deep=deep)
|
||||
return mapping
|
||||
|
||||
|
||||
DuplicateKeyLoader.add_constructor(
|
||||
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
|
||||
_check_duplicate_keys
|
||||
)
|
||||
|
||||
|
||||
class ScanConfigValidationMixin:
|
||||
"""扫描配置验证 Mixin,提供通用的验证方法"""
|
||||
|
||||
def validate_configuration(self, value):
|
||||
"""验证 YAML 配置格式"""
|
||||
"""验证 YAML 配置格式,包括检测重复 key"""
|
||||
import yaml
|
||||
|
||||
if not value or not value.strip():
|
||||
raise serializers.ValidationError("configuration 不能为空")
|
||||
|
||||
try:
|
||||
yaml.safe_load(value)
|
||||
# 使用自定义 Loader 检测重复 key
|
||||
yaml.load(value, Loader=DuplicateKeyLoader)
|
||||
except yaml.YAMLError as e:
|
||||
raise serializers.ValidationError(f"无效的 YAML 格式: {str(e)}")
|
||||
|
||||
|
||||
@@ -636,7 +636,7 @@ class TestDataGenerator:
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO scan (
|
||||
target_id, engine_ids, engine_names, merged_configuration, status, worker_id, progress, current_stage,
|
||||
target_id, engine_ids, engine_names, yaml_configuration, status, worker_id, progress, current_stage,
|
||||
results_dir, error_message, container_ids, stage_progress,
|
||||
cached_subdomains_count, cached_websites_count, cached_endpoints_count,
|
||||
cached_ips_count, cached_directories_count, cached_vulns_total,
|
||||
@@ -749,7 +749,7 @@ class TestDataGenerator:
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO scheduled_scan (
|
||||
name, engine_ids, engine_names, merged_configuration, organization_id, target_id, cron_expression, is_enabled,
|
||||
name, engine_ids, engine_names, yaml_configuration, organization_id, target_id, cron_expression, is_enabled,
|
||||
run_count, last_run_time, next_run_time, created_at, updated_at
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW() - INTERVAL '%s days', NOW())
|
||||
ON CONFLICT DO NOTHING
|
||||
|
||||
@@ -38,6 +38,8 @@ http {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 300s; # 5分钟,支持大数据量导出
|
||||
proxy_send_timeout 300s;
|
||||
proxy_pass http://backend;
|
||||
}
|
||||
|
||||
|
||||
318
frontend/components/scan/engine-preset-selector.tsx
Normal file
318
frontend/components/scan/engine-preset-selector.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
"use client"
|
||||
|
||||
import React, { useMemo, useCallback } from "react"
|
||||
import { Play, Server, Settings, Zap } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CAPABILITY_CONFIG, parseEngineCapabilities, mergeEngineConfigurations } from "@/lib/engine-config"
|
||||
|
||||
import type { ScanEngine } from "@/types/engine.types"
|
||||
|
||||
export interface EnginePreset {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
engineIds: number[]
|
||||
}
|
||||
|
||||
interface EnginePresetSelectorProps {
|
||||
engines: ScanEngine[]
|
||||
selectedEngineIds: number[]
|
||||
selectedPresetId: string | null
|
||||
onPresetChange: (presetId: string | null) => void
|
||||
onEngineIdsChange: (engineIds: number[]) => void
|
||||
onConfigurationChange: (config: string) => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function EnginePresetSelector({
|
||||
engines,
|
||||
selectedEngineIds,
|
||||
selectedPresetId,
|
||||
onPresetChange,
|
||||
onEngineIdsChange,
|
||||
onConfigurationChange,
|
||||
disabled = false,
|
||||
className,
|
||||
}: EnginePresetSelectorProps) {
|
||||
const t = useTranslations("scan.initiate")
|
||||
const tStages = useTranslations("scan.progress.stages")
|
||||
|
||||
// Preset definitions with precise engine filtering
|
||||
const enginePresets = useMemo(() => {
|
||||
if (!engines?.length) return []
|
||||
|
||||
// Categorize engines by their capabilities
|
||||
const fullScanEngines: number[] = []
|
||||
const reconEngines: number[] = []
|
||||
const vulnEngines: number[] = []
|
||||
|
||||
engines.forEach(e => {
|
||||
const caps = parseEngineCapabilities(e.configuration || "")
|
||||
const hasRecon = caps.includes("subdomain_discovery") || caps.includes("port_scan") || caps.includes("site_scan") || caps.includes("directory_scan") || caps.includes("url_fetch")
|
||||
const hasVuln = caps.includes("vuln_scan")
|
||||
|
||||
if (hasRecon && hasVuln) {
|
||||
// Full capability engine - only for full scan
|
||||
fullScanEngines.push(e.id)
|
||||
} else if (hasRecon && !hasVuln) {
|
||||
// Recon only engine
|
||||
reconEngines.push(e.id)
|
||||
} else if (hasVuln && !hasRecon) {
|
||||
// Vuln only engine
|
||||
vulnEngines.push(e.id)
|
||||
}
|
||||
})
|
||||
|
||||
return [
|
||||
{
|
||||
id: "full",
|
||||
label: t("presets.fullScan"),
|
||||
description: t("presets.fullScanDesc"),
|
||||
icon: Zap,
|
||||
engineIds: fullScanEngines,
|
||||
},
|
||||
{
|
||||
id: "recon",
|
||||
label: t("presets.recon"),
|
||||
description: t("presets.reconDesc"),
|
||||
icon: Server,
|
||||
engineIds: reconEngines,
|
||||
},
|
||||
{
|
||||
id: "vuln",
|
||||
label: t("presets.vulnScan"),
|
||||
description: t("presets.vulnScanDesc"),
|
||||
icon: Play,
|
||||
engineIds: vulnEngines,
|
||||
},
|
||||
{
|
||||
id: "custom",
|
||||
label: t("presets.custom"),
|
||||
description: t("presets.customDesc"),
|
||||
icon: Settings,
|
||||
engineIds: [],
|
||||
},
|
||||
]
|
||||
}, [engines, t])
|
||||
|
||||
const selectedEngines = useMemo(() => {
|
||||
if (!selectedEngineIds.length || !engines) return []
|
||||
return engines.filter((e) => selectedEngineIds.includes(e.id))
|
||||
}, [selectedEngineIds, engines])
|
||||
|
||||
const selectedCapabilities = useMemo(() => {
|
||||
if (!selectedEngines.length) return []
|
||||
const allCaps = new Set<string>()
|
||||
selectedEngines.forEach((engine) => {
|
||||
parseEngineCapabilities(engine.configuration || "").forEach((cap) => allCaps.add(cap))
|
||||
})
|
||||
return Array.from(allCaps)
|
||||
}, [selectedEngines])
|
||||
|
||||
// Get currently selected preset details
|
||||
const selectedPreset = useMemo(() => {
|
||||
return enginePresets.find(p => p.id === selectedPresetId)
|
||||
}, [enginePresets, selectedPresetId])
|
||||
|
||||
// Get engines for the selected preset
|
||||
const presetEngines = useMemo(() => {
|
||||
if (!selectedPreset || selectedPreset.id === "custom") return []
|
||||
return engines?.filter(e => selectedPreset.engineIds.includes(e.id)) || []
|
||||
}, [selectedPreset, engines])
|
||||
|
||||
// Update configuration when engines change
|
||||
const updateConfigurationFromEngines = useCallback((engineIds: number[]) => {
|
||||
if (!engines) return
|
||||
const selectedEngs = engines.filter(e => engineIds.includes(e.id))
|
||||
const mergedConfig = mergeEngineConfigurations(selectedEngs.map(e => e.configuration || ""))
|
||||
onConfigurationChange(mergedConfig)
|
||||
}, [engines, onConfigurationChange])
|
||||
|
||||
const handlePresetSelect = useCallback((preset: EnginePreset) => {
|
||||
onPresetChange(preset.id)
|
||||
if (preset.id !== "custom") {
|
||||
onEngineIdsChange(preset.engineIds)
|
||||
updateConfigurationFromEngines(preset.engineIds)
|
||||
} else {
|
||||
// Custom mode - keep current selection or clear
|
||||
if (selectedEngineIds.length === 0) {
|
||||
onConfigurationChange("")
|
||||
}
|
||||
}
|
||||
}, [onPresetChange, onEngineIdsChange, updateConfigurationFromEngines, selectedEngineIds.length, onConfigurationChange])
|
||||
|
||||
const handleEngineToggle = useCallback((engineId: number, checked: boolean) => {
|
||||
let newEngineIds: number[]
|
||||
if (checked) {
|
||||
newEngineIds = [...selectedEngineIds, engineId]
|
||||
} else {
|
||||
newEngineIds = selectedEngineIds.filter((id) => id !== engineId)
|
||||
}
|
||||
onEngineIdsChange(newEngineIds)
|
||||
updateConfigurationFromEngines(newEngineIds)
|
||||
}, [selectedEngineIds, onEngineIdsChange, updateConfigurationFromEngines])
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full", className)}>
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{/* Compact preset cards */}
|
||||
<div className="grid grid-cols-4 gap-3 mb-4">
|
||||
{enginePresets.map((preset) => {
|
||||
const isActive = selectedPresetId === preset.id
|
||||
const PresetIcon = preset.icon
|
||||
const matchedEngines = preset.id === "custom"
|
||||
? []
|
||||
: engines?.filter(e => preset.engineIds.includes(e.id)) || []
|
||||
|
||||
return (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
onClick={() => handlePresetSelect(preset)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex flex-col items-center p-3 rounded-lg border-2 text-center transition-all",
|
||||
isActive
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-muted/30",
|
||||
disabled && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"flex h-10 w-10 items-center justify-center rounded-lg mb-2",
|
||||
isActive ? "bg-primary text-primary-foreground" : "bg-muted"
|
||||
)}>
|
||||
<PresetIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="text-sm font-medium">{preset.label}</span>
|
||||
{preset.id !== "custom" && (
|
||||
<span className="text-xs text-muted-foreground mt-1">
|
||||
{matchedEngines.length} {t("presets.enginesCount")}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Selected preset details */}
|
||||
{selectedPresetId && selectedPresetId !== "custom" && (
|
||||
<div className="border rounded-lg p-4 bg-muted/10">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="font-medium">{selectedPreset?.label}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">{selectedPreset?.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Capabilities */}
|
||||
<div className="mb-4">
|
||||
<h4 className="text-xs font-medium text-muted-foreground mb-2">{t("presets.capabilities")}</h4>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedCapabilities.map((capKey) => {
|
||||
const config = CAPABILITY_CONFIG[capKey]
|
||||
return (
|
||||
<Badge key={capKey} variant="outline" className={cn("text-xs", config?.color)}>
|
||||
{tStages(capKey)}
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Engines list */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-muted-foreground mb-2">{t("presets.usedEngines")}</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{presetEngines.map((engine) => (
|
||||
<span key={engine.id} className="text-sm px-3 py-1.5 bg-background rounded-md border">
|
||||
{engine.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom mode engine selection */}
|
||||
{selectedPresetId === "custom" && (
|
||||
<div className="border rounded-lg p-4 bg-muted/10">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="font-medium">{selectedPreset?.label}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">{selectedPreset?.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Capabilities - dynamically calculated from selected engines */}
|
||||
<div className="mb-4">
|
||||
<h4 className="text-xs font-medium text-muted-foreground mb-2">{t("presets.capabilities")}</h4>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedCapabilities.length > 0 ? (
|
||||
selectedCapabilities.map((capKey) => {
|
||||
const config = CAPABILITY_CONFIG[capKey]
|
||||
return (
|
||||
<Badge key={capKey} variant="outline" className={cn("text-xs", config?.color)}>
|
||||
{tStages(capKey)}
|
||||
</Badge>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">{t("presets.noCapabilities")}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Engines list - selectable */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-muted-foreground mb-2">{t("presets.usedEngines")}</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{engines?.map((engine) => {
|
||||
const isSelected = selectedEngineIds.includes(engine.id)
|
||||
return (
|
||||
<label
|
||||
key={engine.id}
|
||||
htmlFor={`preset-engine-${engine.id}`}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-1.5 rounded-md cursor-pointer transition-all border",
|
||||
isSelected
|
||||
? "bg-primary/10 border-primary/30"
|
||||
: "hover:bg-muted/50 border-border",
|
||||
disabled && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id={`preset-engine-${engine.id}`}
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => {
|
||||
handleEngineToggle(engine.id, checked as boolean)
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span className="text-sm">{engine.name}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!selectedPresetId && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Server className="h-12 w-12 mb-4 opacity-50" />
|
||||
<p className="text-sm">{t("presets.selectHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState, useMemo, useCallback } from "react"
|
||||
import { Play, Settings2 } from "lucide-react"
|
||||
import { Play, Server, Settings, ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
@@ -23,12 +22,9 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { YamlEditor } from "@/components/ui/yaml-editor"
|
||||
import { LoadingSpinner } from "@/components/loading-spinner"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CAPABILITY_CONFIG, getEngineIcon, parseEngineCapabilities, mergeEngineConfigurations } from "@/lib/engine-config"
|
||||
import { EnginePresetSelector } from "./engine-preset-selector"
|
||||
import { ScanConfigEditor } from "./scan-config-editor"
|
||||
|
||||
import type { Organization } from "@/types/organization.types"
|
||||
|
||||
@@ -57,79 +53,63 @@ export function InitiateScanDialog({
|
||||
}: InitiateScanDialogProps) {
|
||||
const t = useTranslations("scan.initiate")
|
||||
const tToast = useTranslations("toast")
|
||||
const tCommon = useTranslations("common.actions")
|
||||
const [step, setStep] = useState(1)
|
||||
const [selectedEngineIds, setSelectedEngineIds] = useState<number[]>([])
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(null)
|
||||
|
||||
// Configuration state management
|
||||
const [configuration, setConfiguration] = useState("")
|
||||
const [isConfigEdited, setIsConfigEdited] = useState(false)
|
||||
const [isYamlValid, setIsYamlValid] = useState(true)
|
||||
const [showOverwriteConfirm, setShowOverwriteConfirm] = useState(false)
|
||||
const [pendingEngineChange, setPendingEngineChange] = useState<{ engineId: number; checked: boolean } | null>(null)
|
||||
const [pendingConfigChange, setPendingConfigChange] = useState<string | null>(null)
|
||||
|
||||
const { data: engines, isLoading, error } = useEngines()
|
||||
const { data: engines } = useEngines()
|
||||
|
||||
const steps = [
|
||||
{ id: 1, title: t("steps.selectEngine"), icon: Server },
|
||||
{ id: 2, title: t("steps.editConfig"), icon: Settings },
|
||||
]
|
||||
|
||||
const selectedEngines = useMemo(() => {
|
||||
if (!selectedEngineIds.length || !engines) return []
|
||||
return engines.filter((e) => selectedEngineIds.includes(e.id))
|
||||
}, [selectedEngineIds, engines])
|
||||
|
||||
const selectedCapabilities = useMemo(() => {
|
||||
if (!selectedEngines.length) return []
|
||||
const allCaps = new Set<string>()
|
||||
selectedEngines.forEach((engine) => {
|
||||
parseEngineCapabilities(engine.configuration || "").forEach((cap) => allCaps.add(cap))
|
||||
})
|
||||
return Array.from(allCaps)
|
||||
}, [selectedEngines])
|
||||
|
||||
// Update configuration when engines change (if not manually edited)
|
||||
const updateConfigurationFromEngines = useCallback((engineIds: number[]) => {
|
||||
if (!engines) return
|
||||
const selectedEngs = engines.filter(e => engineIds.includes(e.id))
|
||||
const mergedConfig = mergeEngineConfigurations(selectedEngs.map(e => e.configuration || ""))
|
||||
setConfiguration(mergedConfig)
|
||||
}, [engines])
|
||||
|
||||
const applyEngineChange = (engineId: number, checked: boolean) => {
|
||||
let newEngineIds: number[]
|
||||
if (checked) {
|
||||
newEngineIds = [...selectedEngineIds, engineId]
|
||||
} else {
|
||||
newEngineIds = selectedEngineIds.filter((id) => id !== engineId)
|
||||
}
|
||||
setSelectedEngineIds(newEngineIds)
|
||||
updateConfigurationFromEngines(newEngineIds)
|
||||
setIsConfigEdited(false)
|
||||
}
|
||||
|
||||
const handleEngineToggle = (engineId: number, checked: boolean) => {
|
||||
if (isConfigEdited) {
|
||||
// User has edited config, show confirmation
|
||||
setPendingEngineChange({ engineId, checked })
|
||||
// Handle configuration change from preset selector (may need confirmation)
|
||||
const handlePresetConfigChange = useCallback((value: string) => {
|
||||
if (isConfigEdited && configuration !== value) {
|
||||
setPendingConfigChange(value)
|
||||
setShowOverwriteConfirm(true)
|
||||
} else {
|
||||
applyEngineChange(engineId, checked)
|
||||
setConfiguration(value)
|
||||
setIsConfigEdited(false)
|
||||
}
|
||||
}
|
||||
}, [isConfigEdited, configuration])
|
||||
|
||||
// Handle manual config editing
|
||||
const handleManualConfigChange = useCallback((value: string) => {
|
||||
setConfiguration(value)
|
||||
setIsConfigEdited(true)
|
||||
}, [])
|
||||
|
||||
const handleEngineIdsChange = useCallback((engineIds: number[]) => {
|
||||
setSelectedEngineIds(engineIds)
|
||||
}, [])
|
||||
|
||||
const handleOverwriteConfirm = () => {
|
||||
if (pendingEngineChange) {
|
||||
applyEngineChange(pendingEngineChange.engineId, pendingEngineChange.checked)
|
||||
if (pendingConfigChange !== null) {
|
||||
setConfiguration(pendingConfigChange)
|
||||
setIsConfigEdited(false)
|
||||
}
|
||||
setShowOverwriteConfirm(false)
|
||||
setPendingEngineChange(null)
|
||||
setPendingConfigChange(null)
|
||||
}
|
||||
|
||||
const handleOverwriteCancel = () => {
|
||||
setShowOverwriteConfirm(false)
|
||||
setPendingEngineChange(null)
|
||||
}
|
||||
|
||||
const handleConfigurationChange = (value: string) => {
|
||||
setConfiguration(value)
|
||||
setIsConfigEdited(true)
|
||||
setPendingConfigChange(null)
|
||||
}
|
||||
|
||||
const handleYamlValidationChange = (isValid: boolean) => {
|
||||
@@ -184,6 +164,8 @@ export function InitiateScanDialog({
|
||||
if (!isSubmitting) {
|
||||
onOpenChange(newOpen)
|
||||
if (!newOpen) {
|
||||
setStep(1)
|
||||
setSelectedPresetId(null)
|
||||
setSelectedEngineIds([])
|
||||
setConfiguration("")
|
||||
setIsConfigEdited(false)
|
||||
@@ -191,172 +173,96 @@ export function InitiateScanDialog({
|
||||
}
|
||||
}
|
||||
|
||||
const canProceedToStep2 = selectedPresetId !== null && selectedEngineIds.length > 0
|
||||
const canSubmit = selectedEngineIds.length > 0 && configuration.trim().length > 0 && isYamlValid
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-[90vw] sm:max-w-[900px] p-0 gap-0">
|
||||
<DialogHeader className="px-6 pt-6 pb-4">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Play className="h-5 w-5" />
|
||||
{t("title")}
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
{targetName ? (
|
||||
<>
|
||||
{t("targetDesc")} <span className="font-medium text-foreground">{targetName}</span> {t("selectEngine")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t("orgDesc")} <span className="font-medium text-foreground">{organization?.name}</span> {t("selectEngine")}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex border-t h-[480px]">
|
||||
{/* Left side engine list */}
|
||||
<div className="w-[260px] border-r flex flex-col shrink-0">
|
||||
<div className="px-4 py-3 border-b bg-muted/30 shrink-0">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("selectEngineTitle")}
|
||||
{selectedEngineIds.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground font-normal ml-2">
|
||||
{t("selectedCount", { count: selectedEngineIds.length })}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-2">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<LoadingSpinner />
|
||||
<span className="ml-2 text-sm text-muted-foreground">{t("loading")}</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="py-8 text-center text-sm text-destructive">{t("loadFailed")}</div>
|
||||
) : !engines?.length ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">{t("noEngines")}</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Play className="h-5 w-5" />
|
||||
{t("title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1">
|
||||
{targetName ? (
|
||||
<>{t("targetDesc")} <span className="font-medium text-foreground">{targetName}</span></>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{engines.map((engine) => {
|
||||
const capabilities = parseEngineCapabilities(engine.configuration || "")
|
||||
const EngineIcon = getEngineIcon(capabilities)
|
||||
const primaryCap = capabilities[0]
|
||||
const iconConfig = primaryCap ? CAPABILITY_CONFIG[primaryCap] : null
|
||||
const isSelected = selectedEngineIds.includes(engine.id)
|
||||
|
||||
return (
|
||||
<label
|
||||
key={engine.id}
|
||||
htmlFor={`engine-${engine.id}`}
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-all",
|
||||
isSelected
|
||||
? "bg-primary/10 border border-primary/30"
|
||||
: "hover:bg-muted/50 border border-transparent"
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id={`engine-${engine.id}`}
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => handleEngineToggle(engine.id, checked as boolean)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-md shrink-0",
|
||||
iconConfig?.color || "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<EngineIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate">{engine.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{capabilities.length > 0 ? t("capabilities", { count: capabilities.length }) : t("noConfig")}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<>{t("orgDesc")} <span className="font-medium text-foreground">{organization?.name}</span></>
|
||||
)}
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</div>
|
||||
{/* Step indicator */}
|
||||
<div className="text-sm text-muted-foreground mr-8">
|
||||
{t("stepIndicator", { current: step, total: steps.length })}
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Right side engine details */}
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden w-0">
|
||||
{selectedEngines.length > 0 ? (
|
||||
<>
|
||||
<div className="px-4 py-3 border-b bg-muted/30 shrink-0 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap gap-1.5 flex-1">
|
||||
{selectedCapabilities.map((capKey) => {
|
||||
const config = CAPABILITY_CONFIG[capKey]
|
||||
return (
|
||||
<Badge key={capKey} variant="outline" className={cn("text-xs", config?.color)}>
|
||||
{config?.label || capKey}
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{isConfigEdited && (
|
||||
<Badge variant="outline" className="text-xs shrink-0">
|
||||
{t("configEdited")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col overflow-hidden p-4 min-w-0">
|
||||
<div className="flex-1 bg-muted/50 rounded-lg border overflow-hidden min-h-0 min-w-0">
|
||||
<YamlEditor
|
||||
value={configuration}
|
||||
onChange={handleConfigurationChange}
|
||||
disabled={isSubmitting}
|
||||
onValidationChange={handleYamlValidationChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<div className="border-t h-[480px] overflow-hidden">
|
||||
{/* Step 1: Select preset/engines */}
|
||||
{step === 1 && engines && (
|
||||
<EnginePresetSelector
|
||||
engines={engines}
|
||||
selectedEngineIds={selectedEngineIds}
|
||||
selectedPresetId={selectedPresetId}
|
||||
onPresetChange={setSelectedPresetId}
|
||||
onEngineIdsChange={handleEngineIdsChange}
|
||||
onConfigurationChange={handlePresetConfigChange}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 2: Edit configuration */}
|
||||
{step === 2 && (
|
||||
<ScanConfigEditor
|
||||
configuration={configuration}
|
||||
onChange={handleManualConfigChange}
|
||||
onValidationChange={handleYamlValidationChange}
|
||||
selectedEngines={selectedEngines}
|
||||
isConfigEdited={isConfigEdited}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{step === 1 && selectedEngineIds.length > 0 && (
|
||||
<span className="text-primary">{t("selectedCount", { count: selectedEngineIds.length })}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{step > 1 && (
|
||||
<Button variant="outline" onClick={() => setStep(step - 1)} disabled={isSubmitting}>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
{t("back")}
|
||||
</Button>
|
||||
)}
|
||||
{step === 1 ? (
|
||||
<Button onClick={() => setStep(2)} disabled={!canProceedToStep2}>
|
||||
{t("next")}
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="px-4 py-3 border-b bg-muted/30 shrink-0">
|
||||
<h3 className="text-sm font-medium">{t("configTitle")}</h3>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col overflow-hidden p-4">
|
||||
<div className="flex-1 bg-muted/50 rounded-lg border overflow-hidden min-h-0">
|
||||
<YamlEditor
|
||||
value={configuration}
|
||||
onChange={handleConfigurationChange}
|
||||
disabled={isSubmitting}
|
||||
onValidationChange={handleYamlValidationChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleInitiate} disabled={!canSubmit || isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<LoadingSpinner />
|
||||
{t("initiating")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4" />
|
||||
{t("startScan")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-6 py-4 border-t">
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isSubmitting}>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleInitiate} disabled={selectedEngineIds.length === 0 || !configuration.trim() || !isYamlValid || isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<LoadingSpinner />
|
||||
{t("initiating")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4" />
|
||||
{t("startScan")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
{/* Overwrite confirmation dialog */}
|
||||
|
||||
@@ -23,17 +23,15 @@ import {
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { YamlEditor } from "@/components/ui/yaml-editor"
|
||||
import { LoadingSpinner } from "@/components/loading-spinner"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toast } from "sonner"
|
||||
import { Zap, Settings2, AlertCircle, ChevronRight, ChevronLeft, Target, Server } from "lucide-react"
|
||||
import { Zap, AlertCircle, ChevronRight, ChevronLeft, Target, Server, Settings } from "lucide-react"
|
||||
import { quickScan } from "@/services/scan.service"
|
||||
import { CAPABILITY_CONFIG, getEngineIcon, parseEngineCapabilities, mergeEngineConfigurations } from "@/lib/engine-config"
|
||||
import { TargetValidator } from "@/lib/target-validator"
|
||||
import { useEngines } from "@/hooks/use-engines"
|
||||
import { EnginePresetSelector } from "./engine-preset-selector"
|
||||
import { ScanConfigEditor } from "./scan-config-editor"
|
||||
|
||||
interface QuickScanDialogProps {
|
||||
trigger?: React.ReactNode
|
||||
@@ -47,15 +45,16 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
|
||||
|
||||
const [targetInput, setTargetInput] = React.useState("")
|
||||
const [selectedEngineIds, setSelectedEngineIds] = React.useState<number[]>([])
|
||||
const [selectedPresetId, setSelectedPresetId] = React.useState<string | null>(null)
|
||||
|
||||
// Configuration state management
|
||||
const [configuration, setConfiguration] = React.useState("")
|
||||
const [isConfigEdited, setIsConfigEdited] = React.useState(false)
|
||||
const [isYamlValid, setIsYamlValid] = React.useState(true)
|
||||
const [showOverwriteConfirm, setShowOverwriteConfirm] = React.useState(false)
|
||||
const [pendingEngineChange, setPendingEngineChange] = React.useState<{ engineId: number; checked: boolean } | null>(null)
|
||||
const [pendingConfigChange, setPendingConfigChange] = React.useState<string | null>(null)
|
||||
|
||||
const { data: engines, isLoading, error } = useEngines()
|
||||
const { data: engines } = useEngines()
|
||||
|
||||
const lineNumbersRef = React.useRef<HTMLDivElement | null>(null)
|
||||
|
||||
@@ -79,26 +78,10 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
|
||||
return engines.filter(e => selectedEngineIds.includes(e.id))
|
||||
}, [selectedEngineIds, engines])
|
||||
|
||||
const selectedCapabilities = React.useMemo(() => {
|
||||
if (!selectedEngines.length) return []
|
||||
const allCaps = new Set<string>()
|
||||
selectedEngines.forEach((engine) => {
|
||||
parseEngineCapabilities(engine.configuration || "").forEach((cap) => allCaps.add(cap))
|
||||
})
|
||||
return Array.from(allCaps)
|
||||
}, [selectedEngines])
|
||||
|
||||
// Update configuration when engines change (if not manually edited)
|
||||
const updateConfigurationFromEngines = React.useCallback((engineIds: number[]) => {
|
||||
if (!engines) return
|
||||
const selectedEngs = engines.filter(e => engineIds.includes(e.id))
|
||||
const mergedConfig = mergeEngineConfigurations(selectedEngs.map(e => e.configuration || ""))
|
||||
setConfiguration(mergedConfig)
|
||||
}, [engines])
|
||||
|
||||
const resetForm = () => {
|
||||
setTargetInput("")
|
||||
setSelectedEngineIds([])
|
||||
setSelectedPresetId(null)
|
||||
setConfiguration("")
|
||||
setIsConfigEdited(false)
|
||||
setStep(1)
|
||||
@@ -109,44 +92,39 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
|
||||
if (!isOpen) resetForm()
|
||||
}
|
||||
|
||||
const applyEngineChange = (engineId: number, checked: boolean) => {
|
||||
let newEngineIds: number[]
|
||||
if (checked) {
|
||||
newEngineIds = [...selectedEngineIds, engineId]
|
||||
} else {
|
||||
newEngineIds = selectedEngineIds.filter((id) => id !== engineId)
|
||||
}
|
||||
setSelectedEngineIds(newEngineIds)
|
||||
updateConfigurationFromEngines(newEngineIds)
|
||||
setIsConfigEdited(false)
|
||||
}
|
||||
|
||||
const handleEngineToggle = (engineId: number, checked: boolean) => {
|
||||
if (isConfigEdited) {
|
||||
// User has edited config, show confirmation
|
||||
setPendingEngineChange({ engineId, checked })
|
||||
// Handle configuration change from preset selector (may need confirmation)
|
||||
const handlePresetConfigChange = React.useCallback((value: string) => {
|
||||
if (isConfigEdited && configuration !== value) {
|
||||
setPendingConfigChange(value)
|
||||
setShowOverwriteConfirm(true)
|
||||
} else {
|
||||
applyEngineChange(engineId, checked)
|
||||
setConfiguration(value)
|
||||
setIsConfigEdited(false)
|
||||
}
|
||||
}
|
||||
}, [isConfigEdited, configuration])
|
||||
|
||||
// Handle manual config editing
|
||||
const handleManualConfigChange = React.useCallback((value: string) => {
|
||||
setConfiguration(value)
|
||||
setIsConfigEdited(true)
|
||||
}, [])
|
||||
|
||||
const handleEngineIdsChange = React.useCallback((engineIds: number[]) => {
|
||||
setSelectedEngineIds(engineIds)
|
||||
}, [])
|
||||
|
||||
const handleOverwriteConfirm = () => {
|
||||
if (pendingEngineChange) {
|
||||
applyEngineChange(pendingEngineChange.engineId, pendingEngineChange.checked)
|
||||
if (pendingConfigChange !== null) {
|
||||
setConfiguration(pendingConfigChange)
|
||||
setIsConfigEdited(false)
|
||||
}
|
||||
setShowOverwriteConfirm(false)
|
||||
setPendingEngineChange(null)
|
||||
setPendingConfigChange(null)
|
||||
}
|
||||
|
||||
const handleOverwriteCancel = () => {
|
||||
setShowOverwriteConfirm(false)
|
||||
setPendingEngineChange(null)
|
||||
}
|
||||
|
||||
const handleConfigurationChange = (value: string) => {
|
||||
setConfiguration(value)
|
||||
setIsConfigEdited(true)
|
||||
setPendingConfigChange(null)
|
||||
}
|
||||
|
||||
const handleYamlValidationChange = (isValid: boolean) => {
|
||||
@@ -154,10 +132,12 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
|
||||
}
|
||||
|
||||
const canProceedToStep2 = validInputs.length > 0 && !hasErrors
|
||||
const canProceedToStep3 = selectedPresetId !== null && selectedEngineIds.length > 0
|
||||
const canSubmit = selectedEngineIds.length > 0 && configuration.trim().length > 0 && isYamlValid
|
||||
|
||||
const handleNext = () => {
|
||||
if (step === 1 && canProceedToStep2) setStep(2)
|
||||
else if (step === 2 && canProceedToStep3) setStep(3)
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
@@ -167,6 +147,7 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
|
||||
const steps = [
|
||||
{ id: 1, title: t("step1Title"), icon: Target },
|
||||
{ id: 2, title: t("step2Title"), icon: Server },
|
||||
{ id: 3, title: t("step3Title"), icon: Settings },
|
||||
]
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -243,36 +224,8 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
|
||||
</DialogDescription>
|
||||
</div>
|
||||
{/* Step indicator */}
|
||||
<div className="flex items-center gap-2 mr-8">
|
||||
{steps.map((s, index) => (
|
||||
<React.Fragment key={s.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (s.id < step) setStep(s.id)
|
||||
else if (s.id === 2 && canProceedToStep2) setStep(2)
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm transition-colors",
|
||||
step === s.id
|
||||
? "bg-primary text-primary-foreground"
|
||||
: step > s.id
|
||||
? "bg-primary/20 text-primary cursor-pointer hover:bg-primary/30"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}
|
||||
disabled={s.id > step && !(s.id === 2 && canProceedToStep2)}
|
||||
>
|
||||
<s.icon className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{s.title}</span>
|
||||
</button>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={cn(
|
||||
"w-8 h-[2px]",
|
||||
step > s.id ? "bg-primary/50" : "bg-muted"
|
||||
)} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
<div className="text-sm text-muted-foreground mr-8">
|
||||
{t("stepIndicator", { current: step, total: steps.length })}
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
@@ -323,136 +276,30 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Select engines */}
|
||||
{step === 2 && (
|
||||
<div className="flex h-full">
|
||||
<div className="w-[320px] border-r flex flex-col shrink-0">
|
||||
<div className="px-4 py-3 border-b bg-muted/30 shrink-0">
|
||||
<h3 className="text-sm font-medium">{t("selectEngine")}</h3>
|
||||
{selectedEngineIds.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("selectedCount", { count: selectedEngineIds.length })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-2">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<LoadingSpinner />
|
||||
<span className="ml-2 text-sm text-muted-foreground">{t("loading")}</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="py-8 text-center text-sm text-destructive">{t("loadFailed")}</div>
|
||||
) : !engines?.length ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">{t("noEngines")}</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{engines.map((engine) => {
|
||||
const capabilities = parseEngineCapabilities(engine.configuration || "")
|
||||
const EngineIcon = getEngineIcon(capabilities)
|
||||
const primaryCap = capabilities[0]
|
||||
const iconConfig = primaryCap ? CAPABILITY_CONFIG[primaryCap] : null
|
||||
const isSelected = selectedEngineIds.includes(engine.id)
|
||||
|
||||
return (
|
||||
<label
|
||||
key={engine.id}
|
||||
htmlFor={`quick-engine-${engine.id}`}
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-all",
|
||||
isSelected
|
||||
? "bg-primary/10 border border-primary/30"
|
||||
: "hover:bg-muted/50 border border-transparent"
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id={`quick-engine-${engine.id}`}
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => handleEngineToggle(engine.id, checked as boolean)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-md shrink-0",
|
||||
iconConfig?.color || "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<EngineIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate">{engine.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{capabilities.length > 0 ? t("capabilities", { count: capabilities.length }) : t("noConfig")}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
{selectedEngines.length > 0 ? (
|
||||
<>
|
||||
<div className="px-4 py-3 border-b bg-muted/30 flex items-center gap-2 shrink-0">
|
||||
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium truncate">
|
||||
{selectedEngines.map((e) => e.name).join(", ")}
|
||||
</h3>
|
||||
{isConfigEdited && (
|
||||
<Badge variant="outline" className="ml-auto text-xs">
|
||||
{t("configEdited")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col overflow-hidden p-4 gap-3">
|
||||
{selectedCapabilities.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 shrink-0">
|
||||
{selectedCapabilities.map((capKey) => {
|
||||
const config = CAPABILITY_CONFIG[capKey]
|
||||
return (
|
||||
<Badge key={capKey} variant="outline" className={cn("text-xs", config?.color)}>
|
||||
{config?.label || capKey}
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 bg-muted/50 rounded-lg border overflow-hidden min-h-0">
|
||||
<YamlEditor
|
||||
value={configuration}
|
||||
onChange={handleConfigurationChange}
|
||||
disabled={isSubmitting}
|
||||
onValidationChange={handleYamlValidationChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="px-4 py-3 border-b bg-muted/30 flex items-center gap-2 shrink-0">
|
||||
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium">{t("configTitle")}</h3>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col overflow-hidden p-4">
|
||||
<div className="flex-1 bg-muted/50 rounded-lg border overflow-hidden min-h-0">
|
||||
<YamlEditor
|
||||
value={configuration}
|
||||
onChange={handleConfigurationChange}
|
||||
disabled={isSubmitting}
|
||||
onValidationChange={handleYamlValidationChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Step 2: Select preset/engines */}
|
||||
{step === 2 && engines && (
|
||||
<EnginePresetSelector
|
||||
engines={engines}
|
||||
selectedEngineIds={selectedEngineIds}
|
||||
selectedPresetId={selectedPresetId}
|
||||
onPresetChange={setSelectedPresetId}
|
||||
onEngineIdsChange={handleEngineIdsChange}
|
||||
onConfigurationChange={handlePresetConfigChange}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 3: Edit configuration */}
|
||||
{step === 3 && (
|
||||
<ScanConfigEditor
|
||||
configuration={configuration}
|
||||
onChange={handleManualConfigChange}
|
||||
onValidationChange={handleYamlValidationChange}
|
||||
selectedEngines={selectedEngines}
|
||||
isConfigEdited={isConfigEdited}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-4 py-4 border-t !flex !items-center !justify-between">
|
||||
@@ -474,10 +321,10 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
|
||||
{t("back")}
|
||||
</Button>
|
||||
)}
|
||||
{step === 1 ? (
|
||||
{step < 3 ? (
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={!canProceedToStep2}
|
||||
disabled={step === 1 ? !canProceedToStep2 : !canProceedToStep3}
|
||||
>
|
||||
{t("next")}
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
|
||||
86
frontend/components/scan/scan-config-editor.tsx
Normal file
86
frontend/components/scan/scan-config-editor.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
"use client"
|
||||
|
||||
import React, { useMemo } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { YamlEditor } from "@/components/ui/yaml-editor"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CAPABILITY_CONFIG, parseEngineCapabilities } from "@/lib/engine-config"
|
||||
|
||||
import type { ScanEngine } from "@/types/engine.types"
|
||||
|
||||
interface ScanConfigEditorProps {
|
||||
configuration: string
|
||||
onChange: (value: string) => void
|
||||
onValidationChange?: (isValid: boolean) => void
|
||||
selectedEngines?: ScanEngine[]
|
||||
selectedCapabilities?: string[]
|
||||
isConfigEdited?: boolean
|
||||
disabled?: boolean
|
||||
showCapabilities?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ScanConfigEditor({
|
||||
configuration,
|
||||
onChange,
|
||||
onValidationChange,
|
||||
selectedEngines = [],
|
||||
selectedCapabilities: propCapabilities,
|
||||
isConfigEdited = false,
|
||||
disabled = false,
|
||||
showCapabilities = true,
|
||||
className,
|
||||
}: ScanConfigEditorProps) {
|
||||
const t = useTranslations("scan.initiate")
|
||||
const tStages = useTranslations("scan.progress.stages")
|
||||
|
||||
// Calculate capabilities from selected engines if not provided
|
||||
const capabilities = useMemo(() => {
|
||||
if (propCapabilities) return propCapabilities
|
||||
if (!selectedEngines.length) return []
|
||||
const allCaps = new Set<string>()
|
||||
selectedEngines.forEach((engine) => {
|
||||
parseEngineCapabilities(engine.configuration || "").forEach((cap) => allCaps.add(cap))
|
||||
})
|
||||
return Array.from(allCaps)
|
||||
}, [selectedEngines, propCapabilities])
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full", className)}>
|
||||
{/* Capabilities header */}
|
||||
{showCapabilities && (
|
||||
<div className="px-4 py-2 border-b bg-muted/30 flex items-center gap-2 shrink-0">
|
||||
{capabilities.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{capabilities.map((capKey) => {
|
||||
const config = CAPABILITY_CONFIG[capKey]
|
||||
return (
|
||||
<Badge key={capKey} variant="outline" className={cn("text-xs py-0", config?.color)}>
|
||||
{tStages(capKey)}
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{isConfigEdited && (
|
||||
<Badge variant="outline" className="ml-auto text-xs">
|
||||
{t("configEdited")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* YAML Editor */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<YamlEditor
|
||||
value={configuration}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
onValidationChange={onValidationChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -195,7 +195,7 @@ export function ScanProgressDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-fit sm:min-w-[450px]">
|
||||
<DialogContent className="sm:max-w-[500px] sm:min-w-[450px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ScanStatusIcon status={data.status} />
|
||||
@@ -211,7 +211,7 @@ export function ScanProgressDialog({
|
||||
</div>
|
||||
<div className="flex items-start justify-between text-sm gap-4">
|
||||
<span className="text-muted-foreground shrink-0">{t("engine")}</span>
|
||||
<div className="grid grid-cols-[repeat(2,auto)] gap-1.5 justify-end">
|
||||
<div className="flex flex-wrap gap-1.5 justify-end">
|
||||
{data.engineNames?.length ? (
|
||||
data.engineNames.map((name) => (
|
||||
<Badge key={name} variant="secondary" className="text-xs whitespace-nowrap">
|
||||
|
||||
@@ -24,7 +24,6 @@ import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { YamlEditor } from "@/components/ui/yaml-editor"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -45,6 +44,8 @@ import {
|
||||
IconClock,
|
||||
IconInfoCircle,
|
||||
IconSearch,
|
||||
IconSettings,
|
||||
IconCode,
|
||||
} from "@tabler/icons-react"
|
||||
import { CronExpressionParser } from "cron-parser"
|
||||
import cronstrue from "cronstrue/i18n"
|
||||
@@ -54,11 +55,11 @@ import { useTargets } from "@/hooks/use-targets"
|
||||
import { useEngines } from "@/hooks/use-engines"
|
||||
import { useOrganizations } from "@/hooks/use-organizations"
|
||||
import { useTranslations, useLocale } from "next-intl"
|
||||
import { mergeEngineConfigurations, CAPABILITY_CONFIG, parseEngineCapabilities } from "@/lib/engine-config"
|
||||
import type { CreateScheduledScanRequest } from "@/types/scheduled-scan.types"
|
||||
import type { ScanEngine } from "@/types/engine.types"
|
||||
import type { Target } from "@/types/target.types"
|
||||
import type { Organization } from "@/types/organization.types"
|
||||
import { EnginePresetSelector } from "../engine-preset-selector"
|
||||
import { ScanConfigEditor } from "../scan-config-editor"
|
||||
|
||||
|
||||
interface CreateScheduledScanDialogProps {
|
||||
@@ -97,14 +98,16 @@ export function CreateScheduledScanDialog({
|
||||
|
||||
const FULL_STEPS = [
|
||||
{ id: 1, title: t("steps.basicInfo"), icon: IconInfoCircle },
|
||||
{ id: 2, title: t("steps.scanMode"), icon: IconBuilding },
|
||||
{ id: 3, title: t("steps.selectTarget"), icon: IconTarget },
|
||||
{ id: 4, title: t("steps.scheduleSettings"), icon: IconClock },
|
||||
{ id: 2, title: t("steps.selectTarget"), icon: IconTarget },
|
||||
{ id: 3, title: t("steps.selectEngine"), icon: IconSettings },
|
||||
{ id: 4, title: t("steps.editConfig"), icon: IconCode },
|
||||
{ id: 5, title: t("steps.scheduleSettings"), icon: IconClock },
|
||||
]
|
||||
|
||||
const PRESET_STEPS = [
|
||||
{ id: 1, title: t("steps.basicInfo"), icon: IconInfoCircle },
|
||||
{ id: 2, title: t("steps.scheduleSettings"), icon: IconClock },
|
||||
{ id: 1, title: t("steps.selectEngine"), icon: IconSettings },
|
||||
{ id: 2, title: t("steps.editConfig"), icon: IconCode },
|
||||
{ id: 3, title: t("steps.scheduleSettings"), icon: IconClock },
|
||||
]
|
||||
|
||||
const [orgSearchInput, setOrgSearchInput] = React.useState("")
|
||||
@@ -132,6 +135,7 @@ export function CreateScheduledScanDialog({
|
||||
|
||||
const [name, setName] = React.useState("")
|
||||
const [engineIds, setEngineIds] = React.useState<number[]>([])
|
||||
const [selectedPresetId, setSelectedPresetId] = React.useState<string | null>(null)
|
||||
const [selectionMode, setSelectionMode] = React.useState<SelectionMode>("organization")
|
||||
const [selectedOrgId, setSelectedOrgId] = React.useState<number | null>(null)
|
||||
const [selectedTargetId, setSelectedTargetId] = React.useState<number | null>(null)
|
||||
@@ -142,7 +146,7 @@ export function CreateScheduledScanDialog({
|
||||
const [isConfigEdited, setIsConfigEdited] = React.useState(false)
|
||||
const [isYamlValid, setIsYamlValid] = React.useState(true)
|
||||
const [showOverwriteConfirm, setShowOverwriteConfirm] = React.useState(false)
|
||||
const [pendingEngineChange, setPendingEngineChange] = React.useState<{ engineId: number; checked: boolean } | null>(null)
|
||||
const [pendingConfigChange, setPendingConfigChange] = React.useState<string | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
@@ -159,7 +163,7 @@ export function CreateScheduledScanDialog({
|
||||
}, [open, presetOrganizationId, presetOrganizationName, presetTargetId, presetTargetName, t])
|
||||
|
||||
const targets: Target[] = targetsData?.targets || []
|
||||
const engines: ScanEngine[] = enginesData || []
|
||||
const engines = enginesData || []
|
||||
const organizations: Organization[] = organizationsData?.organizations || []
|
||||
|
||||
// Get selected engines for display
|
||||
@@ -168,27 +172,10 @@ export function CreateScheduledScanDialog({
|
||||
return engines.filter(e => engineIds.includes(e.id))
|
||||
}, [engineIds, engines])
|
||||
|
||||
// Get selected capabilities for display
|
||||
const selectedCapabilities = React.useMemo(() => {
|
||||
if (!selectedEngines.length) return []
|
||||
const allCaps = new Set<string>()
|
||||
selectedEngines.forEach((engine) => {
|
||||
parseEngineCapabilities(engine.configuration || "").forEach((cap) => allCaps.add(cap))
|
||||
})
|
||||
return Array.from(allCaps)
|
||||
}, [selectedEngines])
|
||||
|
||||
// Update configuration when engines change (if not manually edited)
|
||||
const updateConfigurationFromEngines = React.useCallback((newEngineIds: number[]) => {
|
||||
if (!engines.length) return
|
||||
const selectedEngs = engines.filter(e => newEngineIds.includes(e.id))
|
||||
const mergedConfig = mergeEngineConfigurations(selectedEngs.map(e => e.configuration || ""))
|
||||
setConfiguration(mergedConfig)
|
||||
}, [engines])
|
||||
|
||||
const resetForm = () => {
|
||||
setName("")
|
||||
setEngineIds([])
|
||||
setSelectedPresetId(null)
|
||||
setSelectionMode("organization")
|
||||
setSelectedOrgId(null)
|
||||
setSelectedTargetId(null)
|
||||
@@ -198,44 +185,39 @@ export function CreateScheduledScanDialog({
|
||||
resetStep()
|
||||
}
|
||||
|
||||
const applyEngineChange = (engineId: number, checked: boolean) => {
|
||||
let newEngineIds: number[]
|
||||
if (checked) {
|
||||
newEngineIds = [...engineIds, engineId]
|
||||
} else {
|
||||
newEngineIds = engineIds.filter((id) => id !== engineId)
|
||||
}
|
||||
setEngineIds(newEngineIds)
|
||||
updateConfigurationFromEngines(newEngineIds)
|
||||
setIsConfigEdited(false)
|
||||
}
|
||||
|
||||
const handleEngineToggle = (engineId: number, checked: boolean) => {
|
||||
if (isConfigEdited) {
|
||||
// User has edited config, show confirmation
|
||||
setPendingEngineChange({ engineId, checked })
|
||||
// Handle configuration change from preset selector (may need confirmation)
|
||||
const handlePresetConfigChange = React.useCallback((value: string) => {
|
||||
if (isConfigEdited && configuration !== value) {
|
||||
setPendingConfigChange(value)
|
||||
setShowOverwriteConfirm(true)
|
||||
} else {
|
||||
applyEngineChange(engineId, checked)
|
||||
setConfiguration(value)
|
||||
setIsConfigEdited(false)
|
||||
}
|
||||
}
|
||||
}, [isConfigEdited, configuration])
|
||||
|
||||
// Handle manual config editing
|
||||
const handleManualConfigChange = React.useCallback((value: string) => {
|
||||
setConfiguration(value)
|
||||
setIsConfigEdited(true)
|
||||
}, [])
|
||||
|
||||
const handleEngineIdsChange = React.useCallback((newEngineIds: number[]) => {
|
||||
setEngineIds(newEngineIds)
|
||||
}, [])
|
||||
|
||||
const handleOverwriteConfirm = () => {
|
||||
if (pendingEngineChange) {
|
||||
applyEngineChange(pendingEngineChange.engineId, pendingEngineChange.checked)
|
||||
if (pendingConfigChange !== null) {
|
||||
setConfiguration(pendingConfigChange)
|
||||
setIsConfigEdited(false)
|
||||
}
|
||||
setShowOverwriteConfirm(false)
|
||||
setPendingEngineChange(null)
|
||||
setPendingConfigChange(null)
|
||||
}
|
||||
|
||||
const handleOverwriteCancel = () => {
|
||||
setShowOverwriteConfirm(false)
|
||||
setPendingEngineChange(null)
|
||||
}
|
||||
|
||||
const handleConfigurationChange = (value: string) => {
|
||||
setConfiguration(value)
|
||||
setIsConfigEdited(true)
|
||||
setPendingConfigChange(null)
|
||||
}
|
||||
|
||||
const handleYamlValidationChange = (isValid: boolean) => {
|
||||
@@ -258,13 +240,15 @@ export function CreateScheduledScanDialog({
|
||||
const validateCurrentStep = (): boolean => {
|
||||
if (hasPreset) {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
if (!name.trim()) { toast.error(t("form.taskNameRequired")); return false }
|
||||
case 1: // Select engine
|
||||
if (!selectedPresetId) { toast.error(t("form.scanEngineRequired")); return false }
|
||||
if (engineIds.length === 0) { toast.error(t("form.scanEngineRequired")); return false }
|
||||
return true
|
||||
case 2: // Edit config
|
||||
if (!configuration.trim()) { toast.error(t("form.configurationRequired")); return false }
|
||||
if (!isYamlValid) { toast.error(t("form.yamlInvalid")); return false }
|
||||
return true
|
||||
case 2:
|
||||
case 3: // Schedule
|
||||
const parts = cronExpression.trim().split(/\s+/)
|
||||
if (parts.length !== 5) { toast.error(t("form.cronRequired")); return false }
|
||||
return true
|
||||
@@ -273,21 +257,25 @@ export function CreateScheduledScanDialog({
|
||||
}
|
||||
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
case 1: // Basic info
|
||||
if (!name.trim()) { toast.error(t("form.taskNameRequired")); return false }
|
||||
if (engineIds.length === 0) { toast.error(t("form.scanEngineRequired")); return false }
|
||||
if (!configuration.trim()) { toast.error(t("form.configurationRequired")); return false }
|
||||
if (!isYamlValid) { toast.error(t("form.yamlInvalid")); return false }
|
||||
return true
|
||||
case 2: return true
|
||||
case 3:
|
||||
case 2: // Select target
|
||||
if (selectionMode === "organization") {
|
||||
if (!selectedOrgId) { toast.error(t("toast.selectOrganization")); return false }
|
||||
} else {
|
||||
if (!selectedTargetId) { toast.error(t("toast.selectTarget")); return false }
|
||||
}
|
||||
return true
|
||||
case 4:
|
||||
case 3: // Select engine
|
||||
if (!selectedPresetId) { toast.error(t("form.scanEngineRequired")); return false }
|
||||
if (engineIds.length === 0) { toast.error(t("form.scanEngineRequired")); return false }
|
||||
return true
|
||||
case 4: // Edit config
|
||||
if (!configuration.trim()) { toast.error(t("form.configurationRequired")); return false }
|
||||
if (!isYamlValid) { toast.error(t("form.yamlInvalid")); return false }
|
||||
return true
|
||||
case 5: // Schedule
|
||||
const cronParts = cronExpression.trim().split(/\s+/)
|
||||
if (cronParts.length !== 5) { toast.error(t("form.cronRequired")); return false }
|
||||
return true
|
||||
@@ -349,112 +337,30 @@ export function CreateScheduledScanDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("createTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("createDesc")}</DialogDescription>
|
||||
<DialogContent className="max-w-[900px] p-0 gap-0">
|
||||
<DialogHeader className="px-6 pt-6 pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<DialogTitle>{t("createTitle")}</DialogTitle>
|
||||
<DialogDescription className="mt-1">{t("createDesc")}</DialogDescription>
|
||||
</div>
|
||||
{/* Step indicator */}
|
||||
<div className="text-sm text-muted-foreground mr-8">
|
||||
{t("stepIndicator", { current: currentStep, total: totalSteps })}
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center justify-between px-2 py-4">
|
||||
{steps.map((step, index) => (
|
||||
<React.Fragment key={step.id}>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className={cn(
|
||||
"flex h-10 w-10 items-center justify-center rounded-full border-2 transition-colors",
|
||||
currentStep > step.id ? "border-primary bg-primary text-primary-foreground"
|
||||
: currentStep === step.id ? "border-primary text-primary"
|
||||
: "border-muted text-muted-foreground"
|
||||
)}>
|
||||
{currentStep > step.id ? <IconCheck className="h-5 w-5" /> : <step.icon className="h-5 w-5" />}
|
||||
</div>
|
||||
<span className={cn("text-xs font-medium", currentStep >= step.id ? "text-foreground" : "text-muted-foreground")}>
|
||||
{step.title}
|
||||
</span>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={cn("h-0.5 flex-1 mx-2", currentStep > step.id ? "bg-primary" : "bg-muted")} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-4 px-1">
|
||||
{currentStep === 1 && (
|
||||
<div className="space-y-6">
|
||||
<div className="border-t h-[480px] overflow-hidden">
|
||||
{/* Step 1: Basic Info + Scan Mode */}
|
||||
{currentStep === 1 && !hasPreset && (
|
||||
<div className="p-6 space-y-6 overflow-y-auto h-full">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">{t("form.taskName")} *</Label>
|
||||
<Input id="name" placeholder={t("form.taskNamePlaceholder")} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<p className="text-xs text-muted-foreground">{t("form.taskNameDesc")}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("form.scanEngine")} *</Label>
|
||||
{engineIds.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">{t("form.selectedEngines", { count: engineIds.length })}</p>
|
||||
)}
|
||||
<div className="border rounded-md p-3 max-h-[150px] overflow-y-auto space-y-2">
|
||||
{engines.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("form.noEngine")}</p>
|
||||
) : (
|
||||
engines.map((engine) => (
|
||||
<label
|
||||
key={engine.id}
|
||||
htmlFor={`engine-${engine.id}`}
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-all",
|
||||
engineIds.includes(engine.id)
|
||||
? "bg-primary/10 border border-primary/30"
|
||||
: "hover:bg-muted/50 border border-transparent"
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id={`engine-${engine.id}`}
|
||||
checked={engineIds.includes(engine.id)}
|
||||
onCheckedChange={(checked) => handleEngineToggle(engine.id, checked as boolean)}
|
||||
/>
|
||||
<span className="text-sm">{engine.name}</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("form.scanEngineDesc")}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{t("form.configuration")} *</Label>
|
||||
{isConfigEdited && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("form.configEdited")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{selectedCapabilities.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedCapabilities.map((capKey) => {
|
||||
const config = CAPABILITY_CONFIG[capKey]
|
||||
return (
|
||||
<Badge key={capKey} variant="outline" className={cn("text-xs", config?.color)}>
|
||||
{config?.label || capKey}
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="border rounded-md overflow-hidden h-[180px]">
|
||||
<YamlEditor
|
||||
value={configuration}
|
||||
onChange={handleConfigurationChange}
|
||||
onValidationChange={handleYamlValidationChange}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("form.configurationDesc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 2 && !hasPreset && (
|
||||
<div className="space-y-6">
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<Label>{t("form.selectScanMode")}</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
@@ -481,15 +387,16 @@ export function CreateScheduledScanDialog({
|
||||
{selectionMode === "target" && <IconCheck className="h-5 w-5 text-primary" />}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectionMode === "organization" ? t("form.organizationScanHint") : t("form.targetScanHint")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectionMode === "organization" ? t("form.organizationScanHint") : t("form.targetScanHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 3 && !hasPreset && (
|
||||
<div className="space-y-4">
|
||||
{/* Step 2: Select Target (Organization or Target) */}
|
||||
{currentStep === 2 && !hasPreset && (
|
||||
<div className="p-6 space-y-4 overflow-y-auto h-full">
|
||||
{selectionMode === "organization" ? (
|
||||
<>
|
||||
<Label>{t("form.selectOrganization")}</Label>
|
||||
@@ -568,8 +475,34 @@ export function CreateScheduledScanDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3 (full) / Step 1 (preset): Select Engine */}
|
||||
{((currentStep === 3 && !hasPreset) || (currentStep === 1 && hasPreset)) && engines.length > 0 && (
|
||||
<EnginePresetSelector
|
||||
engines={engines}
|
||||
selectedEngineIds={engineIds}
|
||||
selectedPresetId={selectedPresetId}
|
||||
onPresetChange={setSelectedPresetId}
|
||||
onEngineIdsChange={handleEngineIdsChange}
|
||||
onConfigurationChange={handlePresetConfigChange}
|
||||
disabled={isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 4 (full) / Step 2 (preset): Edit Configuration */}
|
||||
{((currentStep === 4 && !hasPreset) || (currentStep === 2 && hasPreset)) && (
|
||||
<div className="space-y-6">
|
||||
<ScanConfigEditor
|
||||
configuration={configuration}
|
||||
onChange={handleManualConfigChange}
|
||||
onValidationChange={handleYamlValidationChange}
|
||||
selectedEngines={selectedEngines}
|
||||
isConfigEdited={isConfigEdited}
|
||||
disabled={isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 5 (full) / Step 3 (preset): Schedule Settings */}
|
||||
{((currentStep === 5 && !hasPreset) || (currentStep === 3 && hasPreset)) && (
|
||||
<div className="p-6 space-y-6 overflow-y-auto h-full">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("form.cronExpression")} *</Label>
|
||||
<Input placeholder={t("form.cronPlaceholder")} value={cronExpression} onChange={(e) => setCronExpression(e.target.value)} className="font-mono" />
|
||||
@@ -606,9 +539,7 @@ export function CreateScheduledScanDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<div className="px-6 py-4 border-t flex justify-between">
|
||||
<Button variant="outline" onClick={goToPrevStep} disabled={currentStep === 1}>
|
||||
<IconChevronLeft className="h-4 w-4 mr-1" />{t("buttons.previous")}
|
||||
</Button>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState, useCallback } from "react"
|
||||
import React, { useState, useCallback, useEffect } from "react"
|
||||
import Editor from "@monaco-editor/react"
|
||||
import * as yaml from "js-yaml"
|
||||
import { AlertCircle, CheckCircle2 } from "lucide-react"
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useColorTheme } from "@/hooks/use-color-theme"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -15,7 +15,6 @@ interface YamlEditorProps {
|
||||
disabled?: boolean
|
||||
height?: string
|
||||
className?: string
|
||||
showValidation?: boolean
|
||||
onValidationChange?: (isValid: boolean, error?: { message: string; line?: number; column?: number }) => void
|
||||
}
|
||||
|
||||
@@ -30,14 +29,45 @@ export function YamlEditor({
|
||||
disabled = false,
|
||||
height = "100%",
|
||||
className,
|
||||
showValidation = true,
|
||||
onValidationChange,
|
||||
}: YamlEditorProps) {
|
||||
const t = useTranslations("common.yamlEditor")
|
||||
const { currentTheme } = useColorTheme()
|
||||
const [isEditorReady, setIsEditorReady] = useState(false)
|
||||
const [shouldMount, setShouldMount] = useState(false)
|
||||
const [yamlError, setYamlError] = useState<{ message: string; line?: number; column?: number } | null>(null)
|
||||
|
||||
// Delay mounting to avoid Monaco hitTest error on rapid container changes
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setShouldMount(true), 50)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
// Check for duplicate keys in YAML content
|
||||
const checkDuplicateKeys = useCallback((content: string): { key: string; line: number } | null => {
|
||||
const lines = content.split('\n')
|
||||
const keyStack: { indent: number; keys: Set<string> }[] = [{ indent: -1, keys: new Set() }]
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
// Skip empty lines and comments
|
||||
if (!line.trim() || line.trim().startsWith('#')) continue
|
||||
|
||||
// Match top-level keys (no leading whitespace, ends with colon)
|
||||
const topLevelMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_-]*):\s*(?:#.*)?$/)
|
||||
if (topLevelMatch) {
|
||||
const key = topLevelMatch[1]
|
||||
const currentLevel = keyStack[0]
|
||||
|
||||
if (currentLevel.keys.has(key)) {
|
||||
return { key, line: i + 1 }
|
||||
}
|
||||
currentLevel.keys.add(key)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}, [])
|
||||
|
||||
// Validate YAML syntax
|
||||
const validateYaml = useCallback((content: string) => {
|
||||
if (!content.trim()) {
|
||||
@@ -46,6 +76,19 @@ export function YamlEditor({
|
||||
return true
|
||||
}
|
||||
|
||||
// First check for duplicate keys
|
||||
const duplicateKey = checkDuplicateKeys(content)
|
||||
if (duplicateKey) {
|
||||
const errorInfo = {
|
||||
message: t("duplicateKey", { key: duplicateKey.key }),
|
||||
line: duplicateKey.line,
|
||||
column: 1,
|
||||
}
|
||||
setYamlError(errorInfo)
|
||||
onValidationChange?.(false, errorInfo)
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
yaml.load(content)
|
||||
setYamlError(null)
|
||||
@@ -62,7 +105,7 @@ export function YamlEditor({
|
||||
onValidationChange?.(false, errorInfo)
|
||||
return false
|
||||
}
|
||||
}, [onValidationChange])
|
||||
}, [onValidationChange, checkDuplicateKeys, t])
|
||||
|
||||
// Handle editor content change
|
||||
const handleEditorChange = useCallback((newValue: string | undefined) => {
|
||||
@@ -73,74 +116,63 @@ export function YamlEditor({
|
||||
|
||||
// Handle editor mount
|
||||
const handleEditorDidMount = useCallback(() => {
|
||||
setIsEditorReady(true)
|
||||
// Validate initial content
|
||||
validateYaml(value)
|
||||
}, [validateYaml, value])
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full", className)}>
|
||||
{/* Validation status */}
|
||||
{showValidation && (
|
||||
<div className="flex items-center justify-end px-2 py-1 border-b bg-muted/30">
|
||||
{value.trim() && (
|
||||
yamlError ? (
|
||||
<div className="flex items-center gap-1 text-xs text-destructive">
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
<span>{t("syntaxError")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
<span>{t("syntaxValid")}</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Monaco Editor */}
|
||||
<div className={cn("flex-1 overflow-hidden", yamlError ? 'border-destructive' : '')}>
|
||||
<Editor
|
||||
height={height}
|
||||
defaultLanguage="yaml"
|
||||
value={value}
|
||||
onChange={handleEditorChange}
|
||||
onMount={handleEditorDidMount}
|
||||
theme={currentTheme.isDark ? "vs-dark" : "light"}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 12,
|
||||
lineNumbers: "on",
|
||||
wordWrap: "off",
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
formatOnPaste: true,
|
||||
formatOnType: true,
|
||||
folding: true,
|
||||
foldingStrategy: "indentation",
|
||||
showFoldingControls: "mouseover",
|
||||
bracketPairColorization: {
|
||||
enabled: true,
|
||||
},
|
||||
padding: {
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
},
|
||||
readOnly: disabled,
|
||||
placeholder: placeholder,
|
||||
}}
|
||||
loading={
|
||||
<div className="flex items-center justify-center h-full bg-muted/30">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<p className="text-xs text-muted-foreground">{t("loading")}</p>
|
||||
{shouldMount ? (
|
||||
<Editor
|
||||
height={height}
|
||||
defaultLanguage="yaml"
|
||||
value={value}
|
||||
onChange={handleEditorChange}
|
||||
onMount={handleEditorDidMount}
|
||||
theme={currentTheme.isDark ? "vs-dark" : "light"}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 12,
|
||||
lineNumbers: "off",
|
||||
wordWrap: "off",
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
formatOnPaste: true,
|
||||
formatOnType: true,
|
||||
folding: true,
|
||||
foldingStrategy: "indentation",
|
||||
showFoldingControls: "mouseover",
|
||||
bracketPairColorization: {
|
||||
enabled: true,
|
||||
},
|
||||
padding: {
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
},
|
||||
readOnly: disabled,
|
||||
placeholder: placeholder,
|
||||
}}
|
||||
loading={
|
||||
<div className="flex items-center justify-center h-full bg-muted/30">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<p className="text-xs text-muted-foreground">{t("loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full bg-muted/30">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<p className="text-xs text-muted-foreground">{t("loading")}</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error message display */}
|
||||
|
||||
@@ -179,7 +179,8 @@
|
||||
"syntaxError": "Syntax Error",
|
||||
"syntaxValid": "Syntax Valid",
|
||||
"errorLocation": "Line {line}, Column {column}",
|
||||
"loading": "Loading editor..."
|
||||
"loading": "Loading editor...",
|
||||
"duplicateKey": "Duplicate key '{key}' found. Later values will override earlier ones. Please remove duplicates."
|
||||
},
|
||||
"theme": {
|
||||
"switchToLight": "Switch to light mode",
|
||||
@@ -660,7 +661,40 @@
|
||||
"noConfig": "No config",
|
||||
"initiating": "Initiating...",
|
||||
"startScan": "Start Scan",
|
||||
"selectedCount": "{count} engines selected"
|
||||
"selectedCount": "{count} engines selected",
|
||||
"configTitle": "Scan Configuration",
|
||||
"configEdited": "Edited",
|
||||
"stepIndicator": "Step {current}/{total}",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"steps": {
|
||||
"selectEngine": "Select Engine",
|
||||
"editConfig": "Edit Config"
|
||||
},
|
||||
"presets": {
|
||||
"title": "Quick Select",
|
||||
"fullScan": "Full Scan",
|
||||
"fullScanDesc": "Complete security assessment covering asset discovery to vulnerability detection",
|
||||
"recon": "Reconnaissance",
|
||||
"reconDesc": "Discover and identify target assets including subdomains, ports, sites and fingerprints",
|
||||
"vulnScan": "Vulnerability Scan",
|
||||
"vulnScanDesc": "Detect security vulnerabilities on known assets",
|
||||
"custom": "Custom",
|
||||
"customDesc": "Manually select engine combination",
|
||||
"customHint": "Click to manually select engines",
|
||||
"selectHint": "Please select a scan preset",
|
||||
"selectEngines": "Select Engines",
|
||||
"enginesCount": "engines",
|
||||
"capabilities": "Capabilities",
|
||||
"usedEngines": "Used Engines",
|
||||
"noCapabilities": "Please select engines"
|
||||
},
|
||||
"overwriteConfirm": {
|
||||
"title": "Overwrite Configuration",
|
||||
"description": "You have manually edited the configuration. Changing engines will overwrite your changes. Continue?",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Overwrite"
|
||||
}
|
||||
},
|
||||
"cron": {
|
||||
"everyMinute": "Every minute",
|
||||
@@ -742,10 +776,13 @@
|
||||
"createDesc": "Configure scheduled scan task and set execution plan",
|
||||
"editTitle": "Edit Scheduled Scan",
|
||||
"editDesc": "Modify scheduled scan task configuration",
|
||||
"stepIndicator": "Step {current}/{total}",
|
||||
"steps": {
|
||||
"basicInfo": "Basic Info",
|
||||
"scanMode": "Scan Mode",
|
||||
"selectTarget": "Select Target",
|
||||
"selectEngine": "Select Engine",
|
||||
"editConfig": "Edit Config",
|
||||
"scheduleSettings": "Schedule Settings"
|
||||
},
|
||||
"form": {
|
||||
@@ -794,6 +831,8 @@
|
||||
"organizationModeHint": "In organization scan mode, all targets under this organization will be dynamically fetched at execution",
|
||||
"noAvailableTarget": "No available targets",
|
||||
"noEngine": "No engines available",
|
||||
"noConfig": "No config",
|
||||
"capabilitiesCount": "{count} capabilities",
|
||||
"selected": "Selected",
|
||||
"selectedEngines": "{count} engines selected"
|
||||
},
|
||||
@@ -1730,7 +1769,8 @@
|
||||
},
|
||||
"step1Title": "Enter Targets",
|
||||
"step2Title": "Select Engines",
|
||||
"step3Title": "Confirm",
|
||||
"step3Title": "Edit Config",
|
||||
"stepIndicator": "Step {current}/{total}",
|
||||
"step1Hint": "Enter scan targets in the left input box, one per line",
|
||||
"step": "Step {current}/{total} · {title}",
|
||||
"targetPlaceholder": "Enter one target per line, supported formats:\n\nDomain: example.com, sub.example.com\nIP Address: 192.168.1.1, 10.0.0.1\nCIDR: 192.168.1.0/24, 10.0.0.0/8\nURL: https://example.com/api/v1",
|
||||
@@ -1758,6 +1798,14 @@
|
||||
"andMore": "{count} more...",
|
||||
"selectedEngines": "Selected Engines",
|
||||
"confirmSummary": "Will scan {targetCount} targets with {engineCount} engines",
|
||||
"configTitle": "Scan Configuration",
|
||||
"configEdited": "Edited",
|
||||
"overwriteConfirm": {
|
||||
"title": "Overwrite Configuration",
|
||||
"description": "You have manually edited the configuration. Changing engines will overwrite your changes. Continue?",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Overwrite"
|
||||
},
|
||||
"toast": {
|
||||
"noValidTarget": "Please enter at least one valid target",
|
||||
"hasInvalidInputs": "{count} invalid inputs, please fix before continuing",
|
||||
|
||||
@@ -179,7 +179,8 @@
|
||||
"syntaxError": "语法错误",
|
||||
"syntaxValid": "语法正确",
|
||||
"errorLocation": "第 {line} 行,第 {column} 列",
|
||||
"loading": "加载编辑器..."
|
||||
"loading": "加载编辑器...",
|
||||
"duplicateKey": "发现重复的配置项 '{key}',后面的配置会覆盖前面的,请删除重复项"
|
||||
},
|
||||
"theme": {
|
||||
"switchToLight": "切换到亮色模式",
|
||||
@@ -660,7 +661,40 @@
|
||||
"noConfig": "无配置",
|
||||
"initiating": "发起中...",
|
||||
"startScan": "开始扫描",
|
||||
"selectedCount": "已选择 {count} 个引擎"
|
||||
"selectedCount": "已选择 {count} 个引擎",
|
||||
"configTitle": "扫描配置",
|
||||
"configEdited": "已编辑",
|
||||
"stepIndicator": "步骤 {current}/{total}",
|
||||
"back": "上一步",
|
||||
"next": "下一步",
|
||||
"steps": {
|
||||
"selectEngine": "选择引擎",
|
||||
"editConfig": "编辑配置"
|
||||
},
|
||||
"presets": {
|
||||
"title": "推荐组合",
|
||||
"fullScan": "全量扫描",
|
||||
"fullScanDesc": "完整的安全评估,覆盖资产发现到漏洞检测的全部流程",
|
||||
"recon": "信息收集",
|
||||
"reconDesc": "发现和识别目标资产,包括子域名、端口、站点和指纹",
|
||||
"vulnScan": "漏洞扫描",
|
||||
"vulnScanDesc": "对已知资产进行安全漏洞检测",
|
||||
"custom": "自定义",
|
||||
"customDesc": "手动选择引擎组合",
|
||||
"customHint": "点击选择后手动勾选引擎",
|
||||
"selectHint": "请选择一个扫描方案",
|
||||
"selectEngines": "选择引擎",
|
||||
"enginesCount": "个引擎",
|
||||
"capabilities": "涉及能力",
|
||||
"usedEngines": "使用引擎",
|
||||
"noCapabilities": "请选择引擎"
|
||||
},
|
||||
"overwriteConfirm": {
|
||||
"title": "覆盖配置确认",
|
||||
"description": "您已手动编辑过配置,切换引擎将覆盖当前配置。是否继续?",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认覆盖"
|
||||
}
|
||||
},
|
||||
"cron": {
|
||||
"everyMinute": "每分钟",
|
||||
@@ -742,10 +776,13 @@
|
||||
"createDesc": "配置定时扫描任务,设置执行计划",
|
||||
"editTitle": "编辑定时扫描",
|
||||
"editDesc": "修改定时扫描任务配置",
|
||||
"stepIndicator": "步骤 {current}/{total}",
|
||||
"steps": {
|
||||
"basicInfo": "基本信息",
|
||||
"scanMode": "扫描模式",
|
||||
"selectTarget": "选择目标",
|
||||
"selectEngine": "选择引擎",
|
||||
"editConfig": "编辑配置",
|
||||
"scheduleSettings": "调度设置"
|
||||
},
|
||||
"form": {
|
||||
@@ -794,6 +831,8 @@
|
||||
"organizationModeHint": "组织扫描模式下,执行时将动态获取该组织下所有目标",
|
||||
"noAvailableTarget": "暂无可用目标",
|
||||
"noEngine": "暂无可用引擎",
|
||||
"noConfig": "无配置",
|
||||
"capabilitiesCount": "{count} 项能力",
|
||||
"selected": "已选择",
|
||||
"selectedEngines": "已选择 {count} 个引擎"
|
||||
},
|
||||
@@ -1730,7 +1769,8 @@
|
||||
},
|
||||
"step1Title": "输入目标",
|
||||
"step2Title": "选择引擎",
|
||||
"step3Title": "确认",
|
||||
"step3Title": "编辑配置",
|
||||
"stepIndicator": "步骤 {current}/{total}",
|
||||
"step1Hint": "在左侧输入框中输入扫描目标,每行一个",
|
||||
"step": "步骤 {current}/{total} · {title}",
|
||||
"targetPlaceholder": "每行输入一个目标,支持以下格式:\n\n域名: example.com, sub.example.com\nIP地址: 192.168.1.1, 10.0.0.1\nCIDR网段: 192.168.1.0/24, 10.0.0.0/8\nURL: https://example.com/api/v1",
|
||||
@@ -1758,6 +1798,14 @@
|
||||
"andMore": "还有 {count} 个...",
|
||||
"selectedEngines": "已选引擎",
|
||||
"confirmSummary": "将使用 {engineCount} 个引擎扫描 {targetCount} 个目标",
|
||||
"configTitle": "扫描配置",
|
||||
"configEdited": "已编辑",
|
||||
"overwriteConfirm": {
|
||||
"title": "覆盖配置确认",
|
||||
"description": "您已手动编辑过配置,切换引擎将覆盖当前配置。是否继续?",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认覆盖"
|
||||
},
|
||||
"toast": {
|
||||
"noValidTarget": "请输入至少一个有效目标",
|
||||
"hasInvalidInputs": "存在 {count} 个无效输入,请修正后继续",
|
||||
|
||||
@@ -42,34 +42,17 @@ export class SearchService {
|
||||
/**
|
||||
* 导出搜索结果为 CSV
|
||||
* GET /api/assets/search/export/
|
||||
*
|
||||
* 使用浏览器原生下载,支持显示下载进度
|
||||
*/
|
||||
static async exportCSV(query: string, assetType: AssetType): Promise<void> {
|
||||
const queryParams = new URLSearchParams()
|
||||
queryParams.append('q', query)
|
||||
queryParams.append('asset_type', assetType)
|
||||
|
||||
const response = await api.get(
|
||||
`/assets/search/export/?${queryParams.toString()}`,
|
||||
{ responseType: 'blob' }
|
||||
)
|
||||
|
||||
// 从响应头获取文件名
|
||||
const contentDisposition = response.headers?.['content-disposition']
|
||||
let filename = `search_${assetType}_${new Date().toISOString().slice(0, 10)}.csv`
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename="?([^"]+)"?/)
|
||||
if (match) filename = match[1]
|
||||
}
|
||||
|
||||
// 创建下载链接
|
||||
const blob = new Blob([response.data as BlobPart], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
// 直接打开下载链接,使用浏览器原生下载管理器
|
||||
// 这样可以显示下载进度,且不会阻塞页面
|
||||
const downloadUrl = `/api/assets/search/export/?${queryParams.toString()}`
|
||||
window.open(downloadUrl, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user