From adb53c9f85d0b771820d5777eb655fa2a30b7df0 Mon Sep 17 00:00:00 2001 From: yyhuni Date: Sun, 4 Jan 2026 08:58:31 +0800 Subject: [PATCH] feat(asset,scan): add configurable statement timeout and improve CSV export - Add statement_timeout_ms parameter to search_service count() and stream_search() methods for long-running exports - Replace server-side cursors with OFFSET/LIMIT batching for better Django compatibility - Introduce create_csv_export_response() utility function to standardize CSV export handling - Add engine-preset-selector and scan-config-editor components for enhanced scan configuration UI - Update YAML editor component with improved styling and functionality - Add i18n translations for new scan configuration features in English and Chinese - Refactor CSV export endpoints to use new utility function instead of manual StreamingHttpResponse - Remove unused uuid import from search_service.py - Update nginx configuration for improved performance - Enhance search service with configurable timeout support for large dataset exports --- backend/apps/asset/services/search_service.py | 55 +-- backend/apps/asset/views/asset_views.py | 125 ++++--- backend/apps/asset/views/search_views.py | 32 +- backend/apps/common/utils/__init__.py | 2 + backend/apps/common/utils/csv_utils.py | 128 +++++++ backend/apps/scan/serializers.py | 31 +- backend/scripts/generate_test_data_sql.py | 4 +- docker/nginx/nginx.conf | 2 + .../scan/engine-preset-selector.tsx | 318 +++++++++++++++++ .../components/scan/initiate-scan-dialog.tsx | 320 +++++++----------- .../components/scan/quick-scan-dialog.tsx | 269 ++++----------- .../components/scan/scan-config-editor.tsx | 86 +++++ .../components/scan/scan-progress-dialog.tsx | 4 +- .../create-scheduled-scan-dialog.tsx | 277 ++++++--------- frontend/components/ui/yaml-editor.tsx | 160 +++++---- frontend/messages/en.json | 54 ++- frontend/messages/zh.json | 54 ++- frontend/services/search.service.ts | 29 +- 18 files changed, 1156 insertions(+), 794 deletions(-) create mode 100644 frontend/components/scan/engine-preset-selector.tsx create mode 100644 frontend/components/scan/scan-config-editor.tsx diff --git a/backend/apps/asset/services/search_service.py b/backend/apps/asset/services/search_service.py index 55e51063..91ea4209 100644 --- a/backend/apps/asset/services/search_service.py +++ b/backend/apps/asset/services/search_service.py @@ -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 diff --git a/backend/apps/asset/views/asset_views.py b/backend/apps/asset/views/asset_views.py index f15d44f4..d609be65 100644 --- a/backend/apps/asset/views/asset_views.py +++ b/backend/apps/asset/views/asset_views.py @@ -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): diff --git a/backend/apps/asset/views/search_views.py b/backend/apps/asset/views/search_views.py index 59bf79d3..614c4a59 100644 --- a/backend/apps/asset/views/search_views.py +++ b/backend/apps/asset/views/search_views.py @@ -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 diff --git a/backend/apps/common/utils/__init__.py b/backend/apps/common/utils/__init__.py index 27040b4b..1312dc08 100644 --- a/backend/apps/common/utils/__init__.py +++ b/backend/apps/common/utils/__init__.py @@ -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', ] diff --git a/backend/apps/common/utils/csv_utils.py b/backend/apps/common/utils/csv_utils.py index b42daaea..fb857d9a 100644 --- a/backend/apps/common/utils/csv_utils.py +++ b/backend/apps/common/utils/csv_utils.py @@ -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 diff --git a/backend/apps/scan/serializers.py b/backend/apps/scan/serializers.py index 5fa20d77..bf7cd639 100644 --- a/backend/apps/scan/serializers.py +++ b/backend/apps/scan/serializers.py @@ -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)}") diff --git a/backend/scripts/generate_test_data_sql.py b/backend/scripts/generate_test_data_sql.py index 317be49d..5c4df68a 100644 --- a/backend/scripts/generate_test_data_sql.py +++ b/backend/scripts/generate_test_data_sql.py @@ -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 diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf index 25dc2f30..1cd522d3 100644 --- a/docker/nginx/nginx.conf +++ b/docker/nginx/nginx.conf @@ -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; } diff --git a/frontend/components/scan/engine-preset-selector.tsx b/frontend/components/scan/engine-preset-selector.tsx new file mode 100644 index 00000000..fe51555b --- /dev/null +++ b/frontend/components/scan/engine-preset-selector.tsx @@ -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() + 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 ( +
+
+ {/* Compact preset cards */} +
+ {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 ( + + ) + })} +
+ + {/* Selected preset details */} + {selectedPresetId && selectedPresetId !== "custom" && ( +
+
+
+

{selectedPreset?.label}

+

{selectedPreset?.description}

+
+
+ + {/* Capabilities */} +
+

{t("presets.capabilities")}

+
+ {selectedCapabilities.map((capKey) => { + const config = CAPABILITY_CONFIG[capKey] + return ( + + {tStages(capKey)} + + ) + })} +
+
+ + {/* Engines list */} +
+

{t("presets.usedEngines")}

+
+ {presetEngines.map((engine) => ( + + {engine.name} + + ))} +
+
+
+ )} + + {/* Custom mode engine selection */} + {selectedPresetId === "custom" && ( +
+
+
+

{selectedPreset?.label}

+

{selectedPreset?.description}

+
+
+ + {/* Capabilities - dynamically calculated from selected engines */} +
+

{t("presets.capabilities")}

+
+ {selectedCapabilities.length > 0 ? ( + selectedCapabilities.map((capKey) => { + const config = CAPABILITY_CONFIG[capKey] + return ( + + {tStages(capKey)} + + ) + }) + ) : ( + {t("presets.noCapabilities")} + )} +
+
+ + {/* Engines list - selectable */} +
+

{t("presets.usedEngines")}

+
+ {engines?.map((engine) => { + const isSelected = selectedEngineIds.includes(engine.id) + return ( + + ) + })} +
+
+
+ )} + + {/* Empty state */} + {!selectedPresetId && ( +
+ +

{t("presets.selectHint")}

+
+ )} +
+
+ ) +} diff --git a/frontend/components/scan/initiate-scan-dialog.tsx b/frontend/components/scan/initiate-scan-dialog.tsx index 17ce90c7..537396d5 100644 --- a/frontend/components/scan/initiate-scan-dialog.tsx +++ b/frontend/components/scan/initiate-scan-dialog.tsx @@ -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([]) const [isSubmitting, setIsSubmitting] = useState(false) + const [selectedPresetId, setSelectedPresetId] = useState(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(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() - 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 ( - - - {t("title")} - - {targetName ? ( - <> - {t("targetDesc")} {targetName} {t("selectEngine")} - - ) : ( - <> - {t("orgDesc")} {organization?.name} {t("selectEngine")} - - )} - - - - -
- {/* Left side engine list */} -
-
-

- {t("selectEngineTitle")} - {selectedEngineIds.length > 0 && ( - - {t("selectedCount", { count: selectedEngineIds.length })} - - )} -

-
-
-
- {isLoading ? ( -
- - {t("loading")} -
- ) : error ? ( -
{t("loadFailed")}
- ) : !engines?.length ? ( -
{t("noEngines")}
+
+
+ + + {t("title")} + + + {targetName ? ( + <>{t("targetDesc")} {targetName} ) : ( -
- {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 ( - - ) - })} -
+ <>{t("orgDesc")} {organization?.name} )} -
+ +
+ {/* Step indicator */} +
+ {t("stepIndicator", { current: step, total: steps.length })}
+ - {/* Right side engine details */} -
- {selectedEngines.length > 0 ? ( - <> -
-
-
- {selectedCapabilities.map((capKey) => { - const config = CAPABILITY_CONFIG[capKey] - return ( - - {config?.label || capKey} - - ) - })} -
- {isConfigEdited && ( - - {t("configEdited")} - - )} -
-
-
-
- -
-
- +
+ {/* Step 1: Select preset/engines */} + {step === 1 && engines && ( + + )} + + {/* Step 2: Edit configuration */} + {step === 2 && ( + + )} +
+ +
+
+ {step === 1 && selectedEngineIds.length > 0 && ( + {t("selectedCount", { count: selectedEngineIds.length })} + )} +
+
+ {step > 1 && ( + + )} + {step === 1 ? ( + ) : ( -
-
-

{t("configTitle")}

-
-
-
- -
-
-
+ )}
- - - - - {/* Overwrite confirmation dialog */} diff --git a/frontend/components/scan/quick-scan-dialog.tsx b/frontend/components/scan/quick-scan-dialog.tsx index 6b4c693c..ae5a6eda 100644 --- a/frontend/components/scan/quick-scan-dialog.tsx +++ b/frontend/components/scan/quick-scan-dialog.tsx @@ -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([]) + const [selectedPresetId, setSelectedPresetId] = React.useState(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(null) - const { data: engines, isLoading, error } = useEngines() + const { data: engines } = useEngines() const lineNumbersRef = React.useRef(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() - 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) {
{/* Step indicator */} -
- {steps.map((s, index) => ( - - - {index < steps.length - 1 && ( -
s.id ? "bg-primary/50" : "bg-muted" - )} /> - )} - - ))} +
+ {t("stepIndicator", { current: step, total: steps.length })}
@@ -323,136 +276,30 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
)} - {/* Step 2: Select engines */} - {step === 2 && ( -
-
-
-

{t("selectEngine")}

- {selectedEngineIds.length > 0 && ( -

- {t("selectedCount", { count: selectedEngineIds.length })} -

- )} -
-
-
- {isLoading ? ( -
- - {t("loading")} -
- ) : error ? ( -
{t("loadFailed")}
- ) : !engines?.length ? ( -
{t("noEngines")}
- ) : ( -
- {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 ( - - ) - })} -
- )} -
-
-
-
- {selectedEngines.length > 0 ? ( - <> -
- -

- {selectedEngines.map((e) => e.name).join(", ")} -

- {isConfigEdited && ( - - {t("configEdited")} - - )} -
-
- {selectedCapabilities.length > 0 && ( -
- {selectedCapabilities.map((capKey) => { - const config = CAPABILITY_CONFIG[capKey] - return ( - - {config?.label || capKey} - - ) - })} -
- )} -
- -
-
- - ) : ( -
-
- -

{t("configTitle")}

-
-
-
- -
-
-
- )} -
-
+ {/* Step 2: Select preset/engines */} + {step === 2 && engines && ( + )} + {/* Step 3: Edit configuration */} + {step === 3 && ( + + )}
@@ -474,10 +321,10 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) { {t("back")} )} - {step === 1 ? ( + {step < 3 ? (
{t("engine")} -
+
{data.engineNames?.length ? ( data.engineNames.map((name) => ( diff --git a/frontend/components/scan/scheduled/create-scheduled-scan-dialog.tsx b/frontend/components/scan/scheduled/create-scheduled-scan-dialog.tsx index a8846418..b0eb87ec 100644 --- a/frontend/components/scan/scheduled/create-scheduled-scan-dialog.tsx +++ b/frontend/components/scan/scheduled/create-scheduled-scan-dialog.tsx @@ -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([]) + const [selectedPresetId, setSelectedPresetId] = React.useState(null) const [selectionMode, setSelectionMode] = React.useState("organization") const [selectedOrgId, setSelectedOrgId] = React.useState(null) const [selectedTargetId, setSelectedTargetId] = React.useState(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(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() - 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 ( - - - {t("createTitle")} - {t("createDesc")} + + +
+
+ {t("createTitle")} + {t("createDesc")} +
+ {/* Step indicator */} +
+ {t("stepIndicator", { current: currentStep, total: totalSteps })} +
+
-
- {steps.map((step, index) => ( - -
-
step.id ? "border-primary bg-primary text-primary-foreground" - : currentStep === step.id ? "border-primary text-primary" - : "border-muted text-muted-foreground" - )}> - {currentStep > step.id ? : } -
- = step.id ? "text-foreground" : "text-muted-foreground")}> - {step.title} - -
- {index < steps.length - 1 && ( -
step.id ? "bg-primary" : "bg-muted")} /> - )} - - ))} -
- - - -
- {currentStep === 1 && ( -
+
+ {/* Step 1: Basic Info + Scan Mode */} + {currentStep === 1 && !hasPreset && ( +
setName(e.target.value)} />

{t("form.taskNameDesc")}

-
- - {engineIds.length > 0 && ( -

{t("form.selectedEngines", { count: engineIds.length })}

- )} -
- {engines.length === 0 ? ( -

{t("form.noEngine")}

- ) : ( - engines.map((engine) => ( - - )) - )} -
-

{t("form.scanEngineDesc")}

-
-
-
- - {isConfigEdited && ( - - {t("form.configEdited")} - - )} -
- {selectedCapabilities.length > 0 && ( -
- {selectedCapabilities.map((capKey) => { - const config = CAPABILITY_CONFIG[capKey] - return ( - - {config?.label || capKey} - - ) - })} -
- )} -
- -
-

{t("form.configurationDesc")}

-
-
- )} - - {currentStep === 2 && !hasPreset && ( -
+
@@ -481,15 +387,16 @@ export function CreateScheduledScanDialog({ {selectionMode === "target" && }
+

+ {selectionMode === "organization" ? t("form.organizationScanHint") : t("form.targetScanHint")} +

-

- {selectionMode === "organization" ? t("form.organizationScanHint") : t("form.targetScanHint")} -

)} - {currentStep === 3 && !hasPreset && ( -
+ {/* Step 2: Select Target (Organization or Target) */} + {currentStep === 2 && !hasPreset && ( +
{selectionMode === "organization" ? ( <> @@ -568,8 +475,34 @@ export function CreateScheduledScanDialog({
)} + {/* Step 3 (full) / Step 1 (preset): Select Engine */} + {((currentStep === 3 && !hasPreset) || (currentStep === 1 && hasPreset)) && engines.length > 0 && ( + + )} + + {/* Step 4 (full) / Step 2 (preset): Edit Configuration */} {((currentStep === 4 && !hasPreset) || (currentStep === 2 && hasPreset)) && ( -
+ + )} + + {/* Step 5 (full) / Step 3 (preset): Schedule Settings */} + {((currentStep === 5 && !hasPreset) || (currentStep === 3 && hasPreset)) && ( +
setCronExpression(e.target.value)} className="font-mono" /> @@ -606,9 +539,7 @@ export function CreateScheduledScanDialog({ )}
- - -
+
diff --git a/frontend/components/ui/yaml-editor.tsx b/frontend/components/ui/yaml-editor.tsx index 85bd8324..87e4f4f1 100644 --- a/frontend/components/ui/yaml-editor.tsx +++ b/frontend/components/ui/yaml-editor.tsx @@ -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 }[] = [{ 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 (
- {/* Validation status */} - {showValidation && ( -
- {value.trim() && ( - yamlError ? ( -
- - {t("syntaxError")} -
- ) : ( -
- - {t("syntaxValid")} -
- ) - )} -
- )} - {/* Monaco Editor */}
- -
-
-

{t("loading")}

+ {shouldMount ? ( + +
+
+

{t("loading")}

+
+ } + /> + ) : ( +
+
+
+

{t("loading")}

- } - /> +
+ )}
{/* Error message display */} diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 2c202994..1c680d44 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -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", diff --git a/frontend/messages/zh.json b/frontend/messages/zh.json index ae1a1051..a6fb4786 100644 --- a/frontend/messages/zh.json +++ b/frontend/messages/zh.json @@ -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} 个无效输入,请修正后继续", diff --git a/frontend/services/search.service.ts b/frontend/services/search.service.ts index 5f6bb31c..616c35a3 100644 --- a/frontend/services/search.service.ts +++ b/frontend/services/search.service.ts @@ -42,34 +42,17 @@ export class SearchService { /** * 导出搜索结果为 CSV * GET /api/assets/search/export/ + * + * 使用浏览器原生下载,支持显示下载进度 */ static async exportCSV(query: string, assetType: AssetType): Promise { 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') } }