Compare commits

...

2 Commits

Author SHA1 Message Date
yyhuni
adb53c9f85 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
2026-01-04 08:58:31 +08:00
github-actions[bot]
8dd3f0536e chore: bump version to v1.3.11-dev 2026-01-03 11:54:31 +00:00
19 changed files with 1157 additions and 795 deletions

View File

@@ -1 +1 @@
v1.3.10-dev
v1.3.11-dev

View File

@@ -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

View File

@@ -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):

View File

@@ -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

View File

@@ -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',
]

View File

@@ -4,13 +4,21 @@
- UTF-8 BOMExcel 兼容)
- 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

View File

@@ -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)}")

View File

@@ -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

View File

@@ -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;
}

View 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>
)
}

View File

@@ -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 */}

View File

@@ -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" />

View 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>
)
}

View File

@@ -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">

View File

@@ -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>

View File

@@ -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 */}

View File

@@ -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",

View File

@@ -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} 个无效输入,请修正后继续",

View File

@@ -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')
}
}