Compare commits

..

3 Commits

Author SHA1 Message Date
yyhuni
8a8062a12d refactor(scan): rename merged_configuration to yaml_configuration
- Rename `merged_configuration` field to `yaml_configuration` in Scan and ScheduledScan models for clarity
- Update all references across scan repositories, services, views, and serializers
- Update database migration to reflect field name change with improved help text
- Update frontend components to use new field naming convention
- Add YAML editor component for improved configuration editing in UI
- Update engine configuration retrieval in initiate_scan_flow to use new field name
- Remove unused asset tasks __init__.py module
- Simplify README feedback section for better clarity
- Update frontend type definitions and internationalization messages for consistency
2026-01-03 19:50:20 +08:00
yyhuni
55908a2da5 fix(asset,scan): improve decorator usage and dialog layout
- Fix transaction.non_atomic_requests decorator usage in AssetSearchExportView by wrapping with method_decorator for proper class-based view compatibility
- Update scan progress dialog to use flexible width (sm:max-w-fit sm:min-w-[450px]) instead of fixed width for better responsiveness
- Refactor engine names display from single Badge to grid layout with multiple badges for improved readability when multiple engines are present
- Add proper spacing and alignment adjustments (gap-4, items-start) to accommodate multi-line engine badge display
- Add text-xs and whitespace-nowrap to engine badges for consistent styling in grid layout
2026-01-03 18:46:44 +08:00
github-actions[bot]
22a7d4f091 chore: bump version to v1.3.10-dev 2026-01-03 10:45:32 +00:00
25 changed files with 905 additions and 199 deletions

View File

@@ -242,13 +242,9 @@ sudo ./uninstall.sh
## 🤝 反馈与贡献
- 🐛 **如果发现 Bug** 可以点击右边链接进行提交 [Issue](https://github.com/yyhuni/xingrin/issues)
- 💡 **有新想法比如UI设计功能设计等** 欢迎点击右边链接进行提交建议 [Issue](https://github.com/yyhuni/xingrin/issues)
- 💡 **发现 Bug有新想法比如UI设计功能设计等** 欢迎点击右边链接进行提交建议 [Issue](https://github.com/yyhuni/xingrin/issues) 或者公众号私信
## 📧 联系
- 目前版本就我个人使用,可能会有很多边界问题
- 如有问题,建议,其他,优先提交[Issue](https://github.com/yyhuni/xingrin/issues),也可以直接给我的公众号发消息,我都会回复的
- 微信公众号: **塔罗安全学苑**
<img src="docs/wechat-qrcode.png" alt="微信公众号" width="200">

View File

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

View File

@@ -1,7 +0,0 @@
"""
Asset 应用的任务模块
注意:物化视图刷新已移至 APScheduler 定时任务apps.engine.scheduler
"""
__all__ = []

View File

@@ -35,6 +35,7 @@ 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 apps.common.response_helpers import success_response, error_response
from apps.common.error_codes import ErrorCodes
@@ -315,7 +316,7 @@ class AssetSearchExportView(APIView):
return headers, formatters
@transaction.non_atomic_requests
@method_decorator(transaction.non_atomic_requests)
def get(self, request: Request):
"""导出搜索结果为 CSV流式导出无数量限制"""
from apps.common.utils import generate_csv_rows

View File

@@ -115,7 +115,7 @@ def initiate_scan_flow(
# ==================== Task 2: 获取引擎配置 ====================
from apps.scan.models import Scan
scan = Scan.objects.get(id=scan_id)
engine_config = scan.merged_configuration
engine_config = scan.yaml_configuration
# 使用 engine_names 进行显示
display_engine_name = ', '.join(scan.engine_names) if scan.engine_names else engine_name

View File

@@ -57,7 +57,7 @@ class Migration(migrations.Migration):
('id', models.AutoField(primary_key=True, serialize=False)),
('engine_ids', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), default=list, help_text='引擎 ID 列表', size=None)),
('engine_names', models.JSONField(default=list, help_text='引擎名称列表,如 ["引擎A", "引擎B"]')),
('merged_configuration', models.TextField(default='', help_text='合并后的 YAML 配置')),
('yaml_configuration', models.TextField(default='', help_text='YAML 格式的扫描配置')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='任务创建时间')),
('stopped_at', models.DateTimeField(blank=True, help_text='扫描结束时间', null=True)),
('status', models.CharField(choices=[('cancelled', '已取消'), ('completed', '已完成'), ('failed', '失败'), ('initiated', '初始化'), ('running', '运行中')], db_index=True, default='initiated', help_text='任务状态', max_length=20)),
@@ -97,7 +97,7 @@ class Migration(migrations.Migration):
('name', models.CharField(help_text='任务名称', max_length=200)),
('engine_ids', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), default=list, help_text='引擎 ID 列表', size=None)),
('engine_names', models.JSONField(default=list, help_text='引擎名称列表,如 ["引擎A", "引擎B"]')),
('merged_configuration', models.TextField(default='', help_text='合并后的 YAML 配置')),
('yaml_configuration', models.TextField(default='', help_text='YAML 格式的扫描配置')),
('cron_expression', models.CharField(default='0 2 * * *', help_text='Cron 表达式,格式:分 时 日 月 周', max_length=100)),
('is_enabled', models.BooleanField(db_index=True, default=True, help_text='是否启用')),
('run_count', models.IntegerField(default=0, help_text='已执行次数')),

View File

@@ -30,9 +30,9 @@ class Scan(models.Model):
default=list,
help_text='引擎名称列表,如 ["引擎A", "引擎B"]'
)
merged_configuration = models.TextField(
yaml_configuration = models.TextField(
default='',
help_text='合并后的 YAML 配置'
help_text='YAML 格式的扫描配置'
)
created_at = models.DateTimeField(auto_now_add=True, help_text='任务创建时间')
@@ -136,9 +136,9 @@ class ScheduledScan(models.Model):
default=list,
help_text='引擎名称列表,如 ["引擎A", "引擎B"]'
)
merged_configuration = models.TextField(
yaml_configuration = models.TextField(
default='',
help_text='合并后的 YAML 配置'
help_text='YAML 格式的扫描配置'
)
# 关联的组织(组织扫描模式:执行时动态获取组织下所有目标)

View File

@@ -104,7 +104,7 @@ class DjangoScanRepository:
target: Target,
engine_ids: List[int],
engine_names: List[str],
merged_configuration: str,
yaml_configuration: str,
results_dir: str,
status: ScanStatus = ScanStatus.INITIATED
) -> Scan:
@@ -115,7 +115,7 @@ class DjangoScanRepository:
target: 扫描目标
engine_ids: 引擎 ID 列表
engine_names: 引擎名称列表
merged_configuration: 合并后的 YAML 配置
yaml_configuration: YAML 格式的扫描配置
results_dir: 结果目录
status: 初始状态
@@ -126,7 +126,7 @@ class DjangoScanRepository:
target=target,
engine_ids=engine_ids,
engine_names=engine_names,
merged_configuration=merged_configuration,
yaml_configuration=yaml_configuration,
results_dir=results_dir,
status=status,
container_ids=[]

View File

@@ -31,7 +31,7 @@ class ScheduledScanDTO:
name: str = ''
engine_ids: List[int] = None # 多引擎支持
engine_names: List[str] = None # 引擎名称列表
merged_configuration: str = '' # 合并后的配置
yaml_configuration: str = '' # YAML 格式的扫描配置
organization_id: Optional[int] = None # 组织扫描模式
target_id: Optional[int] = None # 目标扫描模式
cron_expression: Optional[str] = None
@@ -114,7 +114,7 @@ class DjangoScheduledScanRepository:
name=dto.name,
engine_ids=dto.engine_ids,
engine_names=dto.engine_names,
merged_configuration=dto.merged_configuration,
yaml_configuration=dto.yaml_configuration,
organization_id=dto.organization_id, # 组织扫描模式
target_id=dto.target_id if not dto.organization_id else None, # 目标扫描模式
cron_expression=dto.cron_expression,
@@ -147,8 +147,8 @@ class DjangoScheduledScanRepository:
scheduled_scan.engine_ids = dto.engine_ids
if dto.engine_names is not None:
scheduled_scan.engine_names = dto.engine_names
if dto.merged_configuration is not None:
scheduled_scan.merged_configuration = dto.merged_configuration
if dto.yaml_configuration is not None:
scheduled_scan.yaml_configuration = dto.yaml_configuration
if dto.cron_expression is not None:
scheduled_scan.cron_expression = dto.cron_expression
if dto.is_enabled is not None:

View File

@@ -4,6 +4,41 @@ from django.db.models import Count
from .models import Scan, ScheduledScan
# ==================== 通用验证 Mixin ====================
class ScanConfigValidationMixin:
"""扫描配置验证 Mixin提供通用的验证方法"""
def validate_configuration(self, value):
"""验证 YAML 配置格式"""
import yaml
if not value or not value.strip():
raise serializers.ValidationError("configuration 不能为空")
try:
yaml.safe_load(value)
except yaml.YAMLError as e:
raise serializers.ValidationError(f"无效的 YAML 格式: {str(e)}")
return value
def validate_engine_ids(self, value):
"""验证引擎 ID 列表"""
if not value:
raise serializers.ValidationError("engine_ids 不能为空,请至少选择一个扫描引擎")
return value
def validate_engine_names(self, value):
"""验证引擎名称列表"""
if not value:
raise serializers.ValidationError("engine_names 不能为空")
return value
# ==================== 扫描任务序列化器 ====================
class ScanSerializer(serializers.ModelSerializer):
"""扫描任务序列化器"""
target_name = serializers.SerializerMethodField()
@@ -82,12 +117,12 @@ class ScanHistorySerializer(serializers.ModelSerializer):
return summary
class QuickScanSerializer(serializers.Serializer):
class QuickScanSerializer(ScanConfigValidationMixin, serializers.Serializer):
"""
快速扫描序列化器
功能:
- 接收目标列表和引擎配置
- 接收目标列表和 YAML 配置
- 自动创建/获取目标
- 立即发起扫描
"""
@@ -101,11 +136,24 @@ class QuickScanSerializer(serializers.Serializer):
help_text='目标列表,每个目标包含 name 字段'
)
# 扫描引擎 ID 列表
# YAML 配置(必填)
configuration = serializers.CharField(
required=True,
help_text='YAML 格式的扫描配置(必填)'
)
# 扫描引擎 ID 列表(必填,用于记录和显示)
engine_ids = serializers.ListField(
child=serializers.IntegerField(),
required=True,
help_text='使用的扫描引擎 ID 列表 (必填)'
help_text='使用的扫描引擎 ID 列表必填'
)
# 引擎名称列表(必填,用于记录和显示)
engine_names = serializers.ListField(
child=serializers.CharField(),
required=True,
help_text='引擎名称列表(必填)'
)
def validate_targets(self, value):
@@ -127,12 +175,6 @@ class QuickScanSerializer(serializers.Serializer):
raise serializers.ValidationError(f"{idx + 1} 个目标的 name 不能为空")
return value
def validate_engine_ids(self, value):
"""验证引擎 ID 列表"""
if not value:
raise serializers.ValidationError("engine_ids 不能为空")
return value
# ==================== 定时扫描序列化器 ====================
@@ -171,7 +213,7 @@ class ScheduledScanSerializer(serializers.ModelSerializer):
return 'organization' if obj.organization_id else 'target'
class CreateScheduledScanSerializer(serializers.Serializer):
class CreateScheduledScanSerializer(ScanConfigValidationMixin, serializers.Serializer):
"""创建定时扫描任务序列化器
扫描模式(二选一):
@@ -180,9 +222,25 @@ class CreateScheduledScanSerializer(serializers.Serializer):
"""
name = serializers.CharField(max_length=200, help_text='任务名称')
# YAML 配置(必填)
configuration = serializers.CharField(
required=True,
help_text='YAML 格式的扫描配置(必填)'
)
# 扫描引擎 ID 列表(必填,用于记录和显示)
engine_ids = serializers.ListField(
child=serializers.IntegerField(),
help_text='扫描引擎 ID 列表'
required=True,
help_text='扫描引擎 ID 列表(必填)'
)
# 引擎名称列表(必填,用于记录和显示)
engine_names = serializers.ListField(
child=serializers.CharField(),
required=True,
help_text='引擎名称列表(必填)'
)
# 组织扫描模式
@@ -206,11 +264,61 @@ class CreateScheduledScanSerializer(serializers.Serializer):
)
is_enabled = serializers.BooleanField(default=True, help_text='是否立即启用')
def validate_engine_ids(self, value):
"""验证引擎 ID 列表"""
if not value:
raise serializers.ValidationError("engine_ids 不能为空")
return value
def validate(self, data):
"""验证 organization_id 和 target_id 互斥"""
organization_id = data.get('organization_id')
target_id = data.get('target_id')
if not organization_id and not target_id:
raise serializers.ValidationError('必须提供 organization_id 或 target_id 其中之一')
if organization_id and target_id:
raise serializers.ValidationError('organization_id 和 target_id 只能提供其中之一')
return data
class InitiateScanSerializer(ScanConfigValidationMixin, serializers.Serializer):
"""发起扫描任务序列化器
扫描模式(二选一):
- 组织扫描:提供 organization_id扫描组织下所有目标
- 目标扫描:提供 target_id扫描单个目标
"""
# YAML 配置(必填)
configuration = serializers.CharField(
required=True,
help_text='YAML 格式的扫描配置(必填)'
)
# 扫描引擎 ID 列表(必填)
engine_ids = serializers.ListField(
child=serializers.IntegerField(),
required=True,
help_text='扫描引擎 ID 列表(必填)'
)
# 引擎名称列表(必填)
engine_names = serializers.ListField(
child=serializers.CharField(),
required=True,
help_text='引擎名称列表(必填)'
)
# 组织扫描模式
organization_id = serializers.IntegerField(
required=False,
allow_null=True,
help_text='组织 ID组织扫描模式'
)
# 目标扫描模式
target_id = serializers.IntegerField(
required=False,
allow_null=True,
help_text='目标 ID目标扫描模式'
)
def validate(self, data):
"""验证 organization_id 和 target_id 互斥"""

View File

@@ -282,7 +282,7 @@ class ScanCreationService:
targets: List[Target],
engine_ids: List[int],
engine_names: List[str],
merged_configuration: str,
yaml_configuration: str,
scheduled_scan_name: str | None = None
) -> List[Scan]:
"""
@@ -292,7 +292,7 @@ class ScanCreationService:
targets: 目标列表
engine_ids: 引擎 ID 列表
engine_names: 引擎名称列表
merged_configuration: 合并后的 YAML 配置
yaml_configuration: YAML 格式的扫描配置
scheduled_scan_name: 定时扫描任务名称(可选,用于通知显示)
Returns:
@@ -312,7 +312,7 @@ class ScanCreationService:
target=target,
engine_ids=engine_ids,
engine_names=engine_names,
merged_configuration=merged_configuration,
yaml_configuration=yaml_configuration,
results_dir=scan_workspace_dir,
status=ScanStatus.INITIATED,
container_ids=[],

View File

@@ -117,12 +117,12 @@ class ScanService:
targets: List[Target],
engine_ids: List[int],
engine_names: List[str],
merged_configuration: str,
yaml_configuration: str,
scheduled_scan_name: str | None = None
) -> List[Scan]:
"""批量创建扫描任务(委托给 ScanCreationService"""
return self.creation_service.create_scans(
targets, engine_ids, engine_names, merged_configuration, scheduled_scan_name
targets, engine_ids, engine_names, yaml_configuration, scheduled_scan_name
)
# ==================== 状态管理方法(委托给 ScanStateService ====================

View File

@@ -54,7 +54,7 @@ class ScheduledScanService:
def create(self, dto: ScheduledScanDTO) -> ScheduledScan:
"""
创建定时扫描任务
创建定时扫描任务(使用引擎 ID 合并配置)
流程:
1. 验证参数
@@ -88,7 +88,7 @@ class ScheduledScanService:
# 设置 DTO 的合并配置和引擎名称
dto.engine_names = engine_names
dto.merged_configuration = merged_configuration
dto.yaml_configuration = merged_configuration
# 3. 创建数据库记录
scheduled_scan = self.repo.create(dto)
@@ -107,12 +107,49 @@ class ScheduledScanService:
return scheduled_scan
def _validate_create_dto(self, dto: ScheduledScanDTO) -> None:
"""验证创建 DTO"""
from apps.targets.repositories import DjangoOrganizationRepository
def create_with_configuration(self, dto: ScheduledScanDTO) -> ScheduledScan:
"""
创建定时扫描任务(直接使用前端传递的配置)
if not dto.name:
raise ValidationError('任务名称不能为空')
流程:
1. 验证参数
2. 直接使用 dto.yaml_configuration
3. 创建数据库记录
4. 计算并设置 next_run_time
Args:
dto: 定时扫描 DTO必须包含 yaml_configuration
Returns:
创建的 ScheduledScan 对象
Raises:
ValidationError: 参数验证失败
"""
# 1. 验证参数
self._validate_create_dto_with_configuration(dto)
# 2. 创建数据库记录(直接使用 dto 中的配置)
scheduled_scan = self.repo.create(dto)
# 3. 如果有 cron 表达式且已启用,计算下次执行时间
if scheduled_scan.cron_expression and scheduled_scan.is_enabled:
next_run_time = self._calculate_next_run_time(scheduled_scan)
if next_run_time:
self.repo.update_next_run_time(scheduled_scan.id, next_run_time)
scheduled_scan.next_run_time = next_run_time
logger.info(
"创建定时扫描任务 - ID: %s, 名称: %s, 下次执行: %s",
scheduled_scan.id, scheduled_scan.name, scheduled_scan.next_run_time
)
return scheduled_scan
def _validate_create_dto(self, dto: ScheduledScanDTO) -> None:
"""验证创建 DTO使用引擎 ID"""
# 基础验证
self._validate_base_dto(dto)
if not dto.engine_ids:
raise ValidationError('必须选择扫描引擎')
@@ -121,6 +158,21 @@ class ScheduledScanService:
for engine_id in dto.engine_ids:
if not self.engine_repo.get_by_id(engine_id):
raise ValidationError(f'扫描引擎 ID {engine_id} 不存在')
def _validate_create_dto_with_configuration(self, dto: ScheduledScanDTO) -> None:
"""验证创建 DTO使用前端传递的配置"""
# 基础验证
self._validate_base_dto(dto)
if not dto.yaml_configuration:
raise ValidationError('配置不能为空')
def _validate_base_dto(self, dto: ScheduledScanDTO) -> None:
"""验证 DTO 的基础字段(公共逻辑)"""
from apps.targets.repositories import DjangoOrganizationRepository
if not dto.name:
raise ValidationError('任务名称不能为空')
# 验证扫描模式organization_id 和 target_id 互斥)
if not dto.organization_id and not dto.target_id:
@@ -178,7 +230,7 @@ class ScheduledScanService:
merged_configuration = merge_engine_configs(engines)
dto.engine_names = engine_names
dto.merged_configuration = merged_configuration
dto.yaml_configuration = merged_configuration
# 更新数据库记录
scheduled_scan = self.repo.update(scheduled_scan_id, dto)
@@ -329,7 +381,7 @@ class ScheduledScanService:
立即触发扫描(支持组织扫描和目标扫描两种模式)
复用 ScanService 的逻辑,与 API 调用保持一致。
使用存储的 merged_configuration 而不是重新合并。
使用存储的 yaml_configuration 而不是重新合并。
"""
from apps.scan.services.scan_service import ScanService
@@ -347,7 +399,7 @@ class ScheduledScanService:
targets=targets,
engine_ids=scheduled_scan.engine_ids,
engine_names=scheduled_scan.engine_names,
merged_configuration=scheduled_scan.merged_configuration,
yaml_configuration=scheduled_scan.yaml_configuration,
scheduled_scan_name=scheduled_scan.name
)

View File

@@ -16,7 +16,7 @@ logger = logging.getLogger(__name__)
from ..models import Scan, ScheduledScan
from ..serializers import (
ScanSerializer, ScanHistorySerializer, QuickScanSerializer,
ScheduledScanSerializer, CreateScheduledScanSerializer,
InitiateScanSerializer, ScheduledScanSerializer, CreateScheduledScanSerializer,
UpdateScheduledScanSerializer, ToggleScheduledScanSerializer
)
from ..services.scan_service import ScanService
@@ -111,7 +111,7 @@ class ScanViewSet(viewsets.ModelViewSet):
快速扫描接口
功能:
1. 接收目标列表和引擎配置
1. 接收目标列表和 YAML 配置
2. 自动解析输入(支持 URL、域名、IP、CIDR
3. 批量创建 Target、Website、Endpoint 资产
4. 立即发起批量扫描
@@ -119,7 +119,9 @@ class ScanViewSet(viewsets.ModelViewSet):
请求参数:
{
"targets": [{"name": "example.com"}, {"name": "https://example.com/api"}],
"engine_ids": [1, 2]
"configuration": "subdomain_discovery:\n enabled: true\n ...",
"engine_ids": [1, 2], // 可选,用于记录
"engine_names": ["引擎A", "引擎B"] // 可选,用于记录
}
支持的输入格式:
@@ -134,7 +136,9 @@ class ScanViewSet(viewsets.ModelViewSet):
serializer.is_valid(raise_exception=True)
targets_data = serializer.validated_data['targets']
engine_ids = serializer.validated_data.get('engine_ids')
configuration = serializer.validated_data['configuration']
engine_ids = serializer.validated_data.get('engine_ids', [])
engine_names = serializer.validated_data.get('engine_names', [])
try:
# 提取输入字符串列表
@@ -154,19 +158,13 @@ class ScanViewSet(viewsets.ModelViewSet):
status_code=status.HTTP_400_BAD_REQUEST
)
# 2. 准备多引擎扫描
# 2. 直接使用前端传递的配置创建扫描
scan_service = ScanService()
_, merged_configuration, engine_names, engine_ids = scan_service.prepare_initiate_scan_multi_engine(
target_id=targets[0].id, # 使用第一个目标来验证引擎
engine_ids=engine_ids
)
# 3. 批量发起扫描
created_scans = scan_service.create_scans(
targets=targets,
engine_ids=engine_ids,
engine_names=engine_names,
merged_configuration=merged_configuration
yaml_configuration=configuration
)
# 检查是否成功创建扫描任务
@@ -195,17 +193,6 @@ class ScanViewSet(viewsets.ModelViewSet):
},
status_code=status.HTTP_201_CREATED
)
except ConfigConflictError as e:
return error_response(
code='CONFIG_CONFLICT',
message=str(e),
details=[
{'key': k, 'engines': [e1, e2]}
for k, e1, e2 in e.conflicts
],
status_code=status.HTTP_400_BAD_REQUEST
)
except ValidationError as e:
return error_response(
@@ -228,48 +215,53 @@ class ScanViewSet(viewsets.ModelViewSet):
请求参数:
- organization_id: 组织ID (int, 可选)
- target_id: 目标ID (int, 可选)
- configuration: YAML 配置字符串 (str, 必填)
- engine_ids: 扫描引擎ID列表 (list[int], 必填)
- engine_names: 引擎名称列表 (list[str], 必填)
注意: organization_id 和 target_id 二选一
返回:
- 扫描任务详情(单个或多个)
"""
# 获取请求数据
organization_id = request.data.get('organization_id')
target_id = request.data.get('target_id')
engine_ids = request.data.get('engine_ids')
# 使用 serializer 验证请求数据
serializer = InitiateScanSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
# 验证 engine_ids
if not engine_ids:
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='缺少必填参数: engine_ids',
status_code=status.HTTP_400_BAD_REQUEST
)
if not isinstance(engine_ids, list):
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='engine_ids 必须是数组',
status_code=status.HTTP_400_BAD_REQUEST
)
# 获取验证后的数据
organization_id = serializer.validated_data.get('organization_id')
target_id = serializer.validated_data.get('target_id')
configuration = serializer.validated_data['configuration']
engine_ids = serializer.validated_data['engine_ids']
engine_names = serializer.validated_data['engine_names']
try:
# 步骤1准备多引擎扫描所需的数据
# 获取目标列表
scan_service = ScanService()
targets, merged_configuration, engine_names, engine_ids = scan_service.prepare_initiate_scan_multi_engine(
organization_id=organization_id,
target_id=target_id,
engine_ids=engine_ids
)
# 步骤2批量创建扫描记录并分发扫描任务
if organization_id:
from apps.targets.repositories import DjangoOrganizationRepository
org_repo = DjangoOrganizationRepository()
organization = org_repo.get_by_id(organization_id)
if not organization:
raise ObjectDoesNotExist(f'Organization ID {organization_id} 不存在')
targets = org_repo.get_targets(organization_id)
if not targets:
raise ValidationError(f'组织 ID {organization_id} 下没有目标')
else:
from apps.targets.repositories import DjangoTargetRepository
target_repo = DjangoTargetRepository()
target = target_repo.get_by_id(target_id)
if not target:
raise ObjectDoesNotExist(f'Target ID {target_id} 不存在')
targets = [target]
# 直接使用前端传递的配置创建扫描
created_scans = scan_service.create_scans(
targets=targets,
engine_ids=engine_ids,
engine_names=engine_names,
merged_configuration=merged_configuration
yaml_configuration=configuration
)
# 检查是否成功创建扫描任务
@@ -290,17 +282,6 @@ class ScanViewSet(viewsets.ModelViewSet):
},
status_code=status.HTTP_201_CREATED
)
except ConfigConflictError as e:
return error_response(
code='CONFIG_CONFLICT',
message=str(e),
details=[
{'key': k, 'engines': [e1, e2]}
for k, e1, e2 in e.conflicts
],
status_code=status.HTTP_400_BAD_REQUEST
)
except ObjectDoesNotExist as e:
# 资源不存在错误(由 service 层抛出)

View File

@@ -68,30 +68,22 @@ class ScheduledScanViewSet(viewsets.ModelViewSet):
data = serializer.validated_data
dto = ScheduledScanDTO(
name=data['name'],
engine_ids=data['engine_ids'],
engine_ids=data.get('engine_ids', []),
engine_names=data.get('engine_names', []),
yaml_configuration=data['configuration'],
organization_id=data.get('organization_id'),
target_id=data.get('target_id'),
cron_expression=data.get('cron_expression', '0 2 * * *'),
is_enabled=data.get('is_enabled', True),
)
scheduled_scan = self.service.create(dto)
scheduled_scan = self.service.create_with_configuration(dto)
response_serializer = ScheduledScanSerializer(scheduled_scan)
return success_response(
data=response_serializer.data,
status_code=status.HTTP_201_CREATED
)
except ConfigConflictError as e:
return error_response(
code='CONFIG_CONFLICT',
message=str(e),
details=[
{'key': k, 'engines': [e1, e2]}
for k, e1, e2 in e.conflicts
],
status_code=status.HTTP_400_BAD_REQUEST
)
except ValidationError as e:
return error_response(
code=ErrorCodes.VALIDATION_ERROR,

View File

@@ -1,6 +1,6 @@
"use client"
import React, { useState, useMemo } from "react"
import React, { useState, useMemo, useCallback } from "react"
import { Play, Settings2 } from "lucide-react"
import { useTranslations } from "next-intl"
@@ -13,11 +13,22 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
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 } from "@/lib/engine-config"
import { CAPABILITY_CONFIG, getEngineIcon, parseEngineCapabilities, mergeEngineConfigurations } from "@/lib/engine-config"
import type { Organization } from "@/types/organization.types"
@@ -49,6 +60,13 @@ export function InitiateScanDialog({
const tCommon = useTranslations("common.actions")
const [selectedEngineIds, setSelectedEngineIds] = useState<number[]>([])
const [isSubmitting, setIsSubmitting] = useState(false)
// 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 { data: engines, isLoading, error } = useEngines()
@@ -66,16 +84,67 @@ export function InitiateScanDialog({
return Array.from(allCaps)
}, [selectedEngines])
const handleEngineToggle = (engineId: number, checked: boolean) => {
// 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) {
setSelectedEngineIds((prev) => [...prev, engineId])
newEngineIds = [...selectedEngineIds, engineId]
} else {
setSelectedEngineIds((prev) => prev.filter((id) => id !== engineId))
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 })
setShowOverwriteConfirm(true)
} else {
applyEngineChange(engineId, checked)
}
}
const handleOverwriteConfirm = () => {
if (pendingEngineChange) {
applyEngineChange(pendingEngineChange.engineId, pendingEngineChange.checked)
}
setShowOverwriteConfirm(false)
setPendingEngineChange(null)
}
const handleOverwriteCancel = () => {
setShowOverwriteConfirm(false)
setPendingEngineChange(null)
}
const handleConfigurationChange = (value: string) => {
setConfiguration(value)
setIsConfigEdited(true)
}
const handleYamlValidationChange = (isValid: boolean) => {
setIsYamlValid(isValid)
}
const handleInitiate = async () => {
if (!selectedEngineIds.length) return
if (selectedEngineIds.length === 0) {
toast.error(tToast("noEngineSelected"))
return
}
if (!configuration.trim()) {
toast.error(tToast("emptyConfig"))
return
}
if (!organizationId && !targetId) {
toast.error(tToast("paramError"), { description: tToast("paramErrorDesc") })
return
@@ -85,7 +154,9 @@ export function InitiateScanDialog({
const response = await initiateScan({
organizationId,
targetId,
configuration,
engineIds: selectedEngineIds,
engineNames: selectedEngines.map(e => e.name),
})
// 后端返回 201 说明成功创建扫描任务
@@ -96,19 +167,14 @@ export function InitiateScanDialog({
onSuccess?.()
onOpenChange(false)
setSelectedEngineIds([])
setConfiguration("")
setIsConfigEdited(false)
} catch (err: unknown) {
console.error("Failed to initiate scan:", err)
// 处理配置冲突错误
const error = err as { response?: { data?: { error?: { code?: string; message?: string } } } }
if (error?.response?.data?.error?.code === 'CONFIG_CONFLICT') {
toast.error(tToast("configConflict"), {
description: error.response.data.error.message,
})
} else {
toast.error(tToast("initiateScanFailed"), {
description: err instanceof Error ? err.message : tToast("unknownError"),
})
}
toast.error(tToast("initiateScanFailed"), {
description: error?.response?.data?.error?.message || (err instanceof Error ? err.message : tToast("unknownError")),
})
} finally {
setIsSubmitting(false)
}
@@ -117,7 +183,11 @@ export function InitiateScanDialog({
const handleOpenChange = (newOpen: boolean) => {
if (!isSubmitting) {
onOpenChange(newOpen)
if (!newOpen) setSelectedEngineIds([])
if (!newOpen) {
setSelectedEngineIds([])
setConfiguration("")
setIsConfigEdited(false)
}
}
}
@@ -220,30 +290,49 @@ export function InitiateScanDialog({
{selectedEngines.length > 0 ? (
<>
<div className="px-4 py-3 border-b bg-muted/30 shrink-0 min-w-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 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">
<pre className="h-full p-3 text-xs font-mono overflow-auto whitespace-pre-wrap break-all">
{selectedEngines.map((e) => e.configuration || `# ${t("noConfig")}`).join("\n\n")}
</pre>
<YamlEditor
value={configuration}
onChange={handleConfigurationChange}
disabled={isSubmitting}
onValidationChange={handleYamlValidationChange}
/>
</div>
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<Settings2 className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p className="text-sm">{t("selectEngineHint")}</p>
<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>
)}
@@ -254,7 +343,7 @@ export function InitiateScanDialog({
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isSubmitting}>
{tCommon("cancel")}
</Button>
<Button onClick={handleInitiate} disabled={!selectedEngineIds.length || isSubmitting}>
<Button onClick={handleInitiate} disabled={selectedEngineIds.length === 0 || !configuration.trim() || !isYamlValid || isSubmitting}>
{isSubmitting ? (
<>
<LoadingSpinner />
@@ -269,6 +358,26 @@ export function InitiateScanDialog({
</Button>
</DialogFooter>
</DialogContent>
{/* Overwrite confirmation dialog */}
<AlertDialog open={showOverwriteConfirm} onOpenChange={setShowOverwriteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("overwriteConfirm.title")}</AlertDialogTitle>
<AlertDialogDescription>
{t("overwriteConfirm.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleOverwriteCancel}>
{t("overwriteConfirm.cancel")}
</AlertDialogCancel>
<AlertDialogAction onClick={handleOverwriteConfirm}>
{t("overwriteConfirm.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
)
}

View File

@@ -11,16 +11,27 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} 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 { quickScan } from "@/services/scan.service"
import { CAPABILITY_CONFIG, getEngineIcon, parseEngineCapabilities } from "@/lib/engine-config"
import { CAPABILITY_CONFIG, getEngineIcon, parseEngineCapabilities, mergeEngineConfigurations } from "@/lib/engine-config"
import { TargetValidator } from "@/lib/target-validator"
import { useEngines } from "@/hooks/use-engines"
@@ -37,6 +48,13 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
const [targetInput, setTargetInput] = React.useState("")
const [selectedEngineIds, setSelectedEngineIds] = React.useState<number[]>([])
// 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 { data: engines, isLoading, error } = useEngines()
const lineNumbersRef = React.useRef<HTMLDivElement | null>(null)
@@ -70,9 +88,19 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
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([])
setConfiguration("")
setIsConfigEdited(false)
setStep(1)
}
@@ -81,16 +109,52 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
if (!isOpen) resetForm()
}
const handleEngineToggle = (engineId: number, checked: boolean) => {
const applyEngineChange = (engineId: number, checked: boolean) => {
let newEngineIds: number[]
if (checked) {
setSelectedEngineIds((prev) => [...prev, engineId])
newEngineIds = [...selectedEngineIds, engineId]
} else {
setSelectedEngineIds((prev) => prev.filter((id) => id !== engineId))
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 })
setShowOverwriteConfirm(true)
} else {
applyEngineChange(engineId, checked)
}
}
const handleOverwriteConfirm = () => {
if (pendingEngineChange) {
applyEngineChange(pendingEngineChange.engineId, pendingEngineChange.checked)
}
setShowOverwriteConfirm(false)
setPendingEngineChange(null)
}
const handleOverwriteCancel = () => {
setShowOverwriteConfirm(false)
setPendingEngineChange(null)
}
const handleConfigurationChange = (value: string) => {
setConfiguration(value)
setIsConfigEdited(true)
}
const handleYamlValidationChange = (isValid: boolean) => {
setIsYamlValid(isValid)
}
const canProceedToStep2 = validInputs.length > 0 && !hasErrors
const canSubmit = selectedEngineIds.length > 0
const canSubmit = selectedEngineIds.length > 0 && configuration.trim().length > 0 && isYamlValid
const handleNext = () => {
if (step === 1 && canProceedToStep2) setStep(2)
@@ -118,6 +182,10 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
toast.error(t("toast.selectEngine"))
return
}
if (!configuration.trim()) {
toast.error(t("toast.emptyConfig"))
return
}
const targets = validInputs.map(r => r.originalInput)
@@ -125,7 +193,9 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
try {
const response = await quickScan({
targets: targets.map(name => ({ name })),
configuration,
engineIds: selectedEngineIds,
engineNames: selectedEngines.map(e => e.name),
})
const { targetStats, scans, count } = response
@@ -139,13 +209,7 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
handleClose(false)
} catch (error: unknown) {
const err = error as { response?: { data?: { error?: { code?: string; message?: string }; detail?: string } } }
if (err?.response?.data?.error?.code === 'CONFIG_CONFLICT') {
toast.error(t("toast.configConflict"), {
description: err.response.data.error.message,
})
} else {
toast.error(err?.response?.data?.detail || err?.response?.data?.error?.message || t("toast.createFailed"))
}
toast.error(err?.response?.data?.detail || err?.response?.data?.error?.message || t("toast.createFailed"))
} finally {
setIsSubmitting(false)
}
@@ -338,6 +402,11 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
<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 && (
@@ -353,17 +422,30 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
</div>
)}
<div className="flex-1 bg-muted/50 rounded-lg border overflow-hidden min-h-0">
<pre className="h-full p-3 text-xs font-mono overflow-auto whitespace-pre-wrap break-all">
{selectedEngines.map((e) => e.configuration || `# ${t("noConfig")}`).join("\n\n")}
</pre>
<YamlEditor
value={configuration}
onChange={handleConfigurationChange}
disabled={isSubmitting}
onValidationChange={handleYamlValidationChange}
/>
</div>
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<Settings2 className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p className="text-sm">{t("selectEngineHint")}</p>
<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>
)}
@@ -418,6 +500,26 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
</div>
</DialogFooter>
</DialogContent>
{/* Overwrite confirmation dialog */}
<AlertDialog open={showOverwriteConfirm} onOpenChange={setShowOverwriteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("overwriteConfirm.title")}</AlertDialogTitle>
<AlertDialogDescription>
{t("overwriteConfirm.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleOverwriteCancel}>
{t("overwriteConfirm.cancel")}
</AlertDialogCancel>
<AlertDialogAction onClick={handleOverwriteConfirm}>
{t("overwriteConfirm.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
)
}

View File

@@ -195,7 +195,7 @@ export function ScanProgressDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogContent className="sm:max-w-fit sm:min-w-[450px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ScanStatusIcon status={data.status} />
@@ -209,9 +209,19 @@ export function ScanProgressDialog({
<span className="text-muted-foreground">{t("target")}</span>
<span className="font-medium">{data.targetName}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{t("engine")}</span>
<Badge variant="secondary">{data.engineNames?.join(", ") || "-"}</Badge>
<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">
{data.engineNames?.length ? (
data.engineNames.map((name) => (
<Badge key={name} variant="secondary" className="text-xs whitespace-nowrap">
{name}
</Badge>
))
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
</div>
{data.startedAt && (
<div className="flex items-center justify-between text-sm">

View File

@@ -9,11 +9,22 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
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,
@@ -43,6 +54,7 @@ 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"
@@ -124,6 +136,13 @@ export function CreateScheduledScanDialog({
const [selectedOrgId, setSelectedOrgId] = React.useState<number | null>(null)
const [selectedTargetId, setSelectedTargetId] = React.useState<number | null>(null)
const [cronExpression, setCronExpression] = React.useState("0 2 * * *")
// 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)
React.useEffect(() => {
if (open) {
@@ -143,6 +162,30 @@ export function CreateScheduledScanDialog({
const engines: ScanEngine[] = enginesData || []
const organizations: Organization[] = organizationsData?.organizations || []
// Get selected engines for display
const selectedEngines = React.useMemo(() => {
if (!engineIds.length || !engines.length) return []
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([])
@@ -150,15 +193,53 @@ export function CreateScheduledScanDialog({
setSelectedOrgId(null)
setSelectedTargetId(null)
setCronExpression("0 2 * * *")
setConfiguration("")
setIsConfigEdited(false)
resetStep()
}
const handleEngineToggle = (engineId: number, checked: boolean) => {
const applyEngineChange = (engineId: number, checked: boolean) => {
let newEngineIds: number[]
if (checked) {
setEngineIds((prev) => [...prev, engineId])
newEngineIds = [...engineIds, engineId]
} else {
setEngineIds((prev) => prev.filter((id) => id !== engineId))
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 })
setShowOverwriteConfirm(true)
} else {
applyEngineChange(engineId, checked)
}
}
const handleOverwriteConfirm = () => {
if (pendingEngineChange) {
applyEngineChange(pendingEngineChange.engineId, pendingEngineChange.checked)
}
setShowOverwriteConfirm(false)
setPendingEngineChange(null)
}
const handleOverwriteCancel = () => {
setShowOverwriteConfirm(false)
setPendingEngineChange(null)
}
const handleConfigurationChange = (value: string) => {
setConfiguration(value)
setIsConfigEdited(true)
}
const handleYamlValidationChange = (isValid: boolean) => {
setIsYamlValid(isValid)
}
const handleOpenChange = (isOpen: boolean) => {
@@ -180,6 +261,8 @@ export function CreateScheduledScanDialog({
case 1:
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:
const parts = cronExpression.trim().split(/\s+/)
@@ -193,6 +276,8 @@ export function CreateScheduledScanDialog({
case 1:
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:
@@ -216,7 +301,9 @@ export function CreateScheduledScanDialog({
if (!validateCurrentStep()) return
const request: CreateScheduledScanRequest = {
name: name.trim(),
configuration: configuration.trim(),
engineIds: engineIds,
engineNames: selectedEngines.map(e => e.name),
cronExpression: cronExpression.trim(),
}
if (selectionMode === "organization" && selectedOrgId) {
@@ -306,7 +393,7 @@ export function CreateScheduledScanDialog({
{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-[200px] overflow-y-auto space-y-2">
<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>
) : (
@@ -333,6 +420,36 @@ export function CreateScheduledScanDialog({
</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>
)}
@@ -504,6 +621,26 @@ export function CreateScheduledScanDialog({
)}
</div>
</DialogContent>
{/* Overwrite confirmation dialog */}
<AlertDialog open={showOverwriteConfirm} onOpenChange={setShowOverwriteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("overwriteConfirm.title")}</AlertDialogTitle>
<AlertDialogDescription>
{t("overwriteConfirm.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleOverwriteCancel}>
{t("overwriteConfirm.cancel")}
</AlertDialogCancel>
<AlertDialogAction onClick={handleOverwriteConfirm}>
{t("overwriteConfirm.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
)
}

View File

@@ -0,0 +1,162 @@
"use client"
import React, { useState, useCallback } from "react"
import Editor from "@monaco-editor/react"
import * as yaml from "js-yaml"
import { AlertCircle, CheckCircle2 } from "lucide-react"
import { useColorTheme } from "@/hooks/use-color-theme"
import { useTranslations } from "next-intl"
import { cn } from "@/lib/utils"
interface YamlEditorProps {
value: string
onChange: (value: string) => void
placeholder?: string
disabled?: boolean
height?: string
className?: string
showValidation?: boolean
onValidationChange?: (isValid: boolean, error?: { message: string; line?: number; column?: number }) => void
}
/**
* YAML Editor component with Monaco Editor
* Provides VSCode-level editing experience with syntax highlighting and validation
*/
export function YamlEditor({
value,
onChange,
placeholder,
disabled = false,
height = "100%",
className,
showValidation = true,
onValidationChange,
}: YamlEditorProps) {
const t = useTranslations("common.yamlEditor")
const { currentTheme } = useColorTheme()
const [isEditorReady, setIsEditorReady] = useState(false)
const [yamlError, setYamlError] = useState<{ message: string; line?: number; column?: number } | null>(null)
// Validate YAML syntax
const validateYaml = useCallback((content: string) => {
if (!content.trim()) {
setYamlError(null)
onValidationChange?.(true)
return true
}
try {
yaml.load(content)
setYamlError(null)
onValidationChange?.(true)
return true
} catch (error) {
const yamlException = error as yaml.YAMLException
const errorInfo = {
message: yamlException.message,
line: yamlException.mark?.line ? yamlException.mark.line + 1 : undefined,
column: yamlException.mark?.column ? yamlException.mark.column + 1 : undefined,
}
setYamlError(errorInfo)
onValidationChange?.(false, errorInfo)
return false
}
}, [onValidationChange])
// Handle editor content change
const handleEditorChange = useCallback((newValue: string | undefined) => {
const content = newValue || ""
onChange(content)
validateYaml(content)
}, [onChange, validateYaml])
// 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>
</div>
</div>
}
/>
</div>
{/* Error message display */}
{yamlError && (
<div className="flex items-start gap-2 p-2 bg-destructive/10 border-t border-destructive/20">
<AlertCircle className="h-3.5 w-3.5 text-destructive mt-0.5 flex-shrink-0" />
<div className="flex-1 text-xs">
<p className="font-medium text-destructive">
{yamlError.line && yamlError.column
? t("errorLocation", { line: yamlError.line, column: yamlError.column })
: t("syntaxError")}
</p>
<p className="text-muted-foreground truncate">{yamlError.message}</p>
</div>
</div>
)}
</div>
)
}

View File

@@ -80,3 +80,14 @@ export function parseEngineCapabilities(configuration: string): string[] {
return []
}
}
/**
* Merge multiple engine configurations into a single YAML string
* Simply concatenates configurations with separators
*/
export function mergeEngineConfigurations(configurations: string[]): string {
const validConfigs = configurations.filter(c => c && c.trim())
if (validConfigs.length === 0) return ""
if (validConfigs.length === 1) return validConfigs[0]
return validConfigs.join("\n\n# ---\n\n")
}

View File

@@ -175,6 +175,12 @@
"website": "Website",
"description": "Description"
},
"yamlEditor": {
"syntaxError": "Syntax Error",
"syntaxValid": "Syntax Valid",
"errorLocation": "Line {line}, Column {column}",
"loading": "Loading editor..."
},
"theme": {
"switchToLight": "Switch to light mode",
"switchToDark": "Switch to dark mode",
@@ -749,8 +755,14 @@
"taskNameRequired": "Please enter task name",
"scanEngine": "Scan Engine",
"scanEnginePlaceholder": "Select scan engine",
"scanEngineDesc": "Select the scan engine configuration to use",
"scanEngineDesc": "Select engine to auto-fill configuration, or edit directly",
"scanEngineRequired": "Please select a scan engine",
"configuration": "Scan Configuration",
"configurationPlaceholder": "Enter YAML scan configuration...",
"configurationDesc": "YAML format scan configuration, select engine to auto-fill or edit manually",
"configurationRequired": "Please enter scan configuration",
"yamlInvalid": "Invalid YAML configuration, please check syntax",
"configEdited": "Edited",
"selectScanMode": "Select Scan Mode",
"organizationScan": "Organization Scan",
"organizationScanDesc": "Select organization, dynamically fetch all targets at execution",
@@ -803,7 +815,14 @@
},
"toast": {
"selectOrganization": "Please select an organization",
"selectTarget": "Please select a scan target"
"selectTarget": "Please select a scan target",
"configConflict": "Configuration conflict"
},
"overwriteConfirm": {
"title": "Overwrite Configuration",
"description": "You have manually edited the configuration. Changing engines will overwrite your changes. Do you want to continue?",
"cancel": "Cancel",
"confirm": "Overwrite"
}
},
"engine": {
@@ -1405,6 +1424,8 @@
"initiateScanFailed": "Failed to initiate scan",
"noScansCreated": "No scan tasks were created",
"unknownError": "Unknown error",
"noEngineSelected": "Please select at least one scan engine",
"emptyConfig": "Scan configuration cannot be empty",
"engineNameRequired": "Please enter engine name",
"configRequired": "Configuration content is required",
"yamlSyntaxError": "YAML syntax error",
@@ -1741,6 +1762,7 @@
"noValidTarget": "Please enter at least one valid target",
"hasInvalidInputs": "{count} invalid inputs, please fix before continuing",
"selectEngine": "Please select a scan engine",
"emptyConfig": "Scan configuration cannot be empty",
"getEnginesFailed": "Failed to get engine list",
"createFailed": "Failed to create scan task",
"createSuccess": "Created {count} scan tasks",

View File

@@ -175,6 +175,12 @@
"website": "官网",
"description": "描述"
},
"yamlEditor": {
"syntaxError": "语法错误",
"syntaxValid": "语法正确",
"errorLocation": "第 {line} 行,第 {column} 列",
"loading": "加载编辑器..."
},
"theme": {
"switchToLight": "切换到亮色模式",
"switchToDark": "切换到暗色模式",
@@ -749,8 +755,14 @@
"taskNameRequired": "请输入任务名称",
"scanEngine": "扫描引擎",
"scanEnginePlaceholder": "选择扫描引擎",
"scanEngineDesc": "选择要使用的扫描引擎配置",
"scanEngineDesc": "选择引擎可快速填充配置,也可直接编辑配置",
"scanEngineRequired": "请选择扫描引擎",
"configuration": "扫描配置",
"configurationPlaceholder": "请输入 YAML 格式的扫描配置...",
"configurationDesc": "YAML 格式的扫描配置,可选择引擎自动填充或手动编辑",
"configurationRequired": "请输入扫描配置",
"yamlInvalid": "YAML 配置格式错误,请检查语法",
"configEdited": "已编辑",
"selectScanMode": "选择扫描模式",
"organizationScan": "组织扫描",
"organizationScanDesc": "选择组织,执行时动态获取其下所有目标",
@@ -803,7 +815,14 @@
},
"toast": {
"selectOrganization": "请选择一个组织",
"selectTarget": "请选择一个扫描目标"
"selectTarget": "请选择一个扫描目标",
"configConflict": "配置冲突"
},
"overwriteConfirm": {
"title": "覆盖配置确认",
"description": "您已手动编辑了配置,切换引擎将覆盖当前配置。确定要继续吗?",
"cancel": "取消",
"confirm": "确定覆盖"
}
},
"engine": {
@@ -1405,6 +1424,8 @@
"initiateScanFailed": "发起扫描失败",
"noScansCreated": "未创建任何扫描任务",
"unknownError": "未知错误",
"noEngineSelected": "请选择至少一个扫描引擎",
"emptyConfig": "扫描配置不能为空",
"engineNameRequired": "请输入引擎名称",
"configRequired": "配置内容不能为空",
"yamlSyntaxError": "YAML 语法错误",
@@ -1741,6 +1762,7 @@
"noValidTarget": "请输入至少一个有效目标",
"hasInvalidInputs": "存在 {count} 个无效输入,请修正后继续",
"selectEngine": "请选择扫描引擎",
"emptyConfig": "扫描配置不能为空",
"getEnginesFailed": "获取引擎列表失败",
"createFailed": "创建扫描任务失败",
"createSuccess": "已创建 {count} 个扫描任务",

View File

@@ -82,7 +82,9 @@ export interface GetScansResponse {
export interface InitiateScanRequest {
organizationId?: number // Organization ID (choose one)
targetId?: number // Target ID (choose one)
configuration: string // YAML configuration string (required)
engineIds: number[] // Scan engine ID list (required)
engineNames: string[] // Engine name list (required)
}
/**
@@ -90,7 +92,9 @@ export interface InitiateScanRequest {
*/
export interface QuickScanRequest {
targets: { name: string }[] // Target list
configuration: string // YAML configuration string (required)
engineIds: number[] // Scan engine ID list (required)
engineNames: string[] // Engine name list (required)
}
/**

View File

@@ -31,7 +31,9 @@ export interface ScheduledScan {
// Create scheduled scan request (organizationId and targetId are mutually exclusive)
export interface CreateScheduledScanRequest {
name: string
engineIds: number[] // Engine ID list
configuration: string // YAML configuration string (required)
engineIds: number[] // Engine ID list (required)
engineNames: string[] // Engine name list (required)
organizationId?: number // Organization scan mode
targetId?: number // Target scan mode
cronExpression: string // Cron expression, format: minute hour day month weekday
@@ -41,7 +43,9 @@ export interface CreateScheduledScanRequest {
// Update scheduled scan request (organizationId and targetId are mutually exclusive)
export interface UpdateScheduledScanRequest {
name?: string
engineIds?: number[] // Engine ID list
configuration?: string // YAML configuration string
engineIds?: number[] // Engine ID list (optional, for reference)
engineNames?: string[] // Engine name list (optional, for reference)
organizationId?: number // Organization scan mode (clears targetId when set)
targetId?: number // Target scan mode (clears organizationId when set)
cronExpression?: string