mirror of
https://github.com/yyhuni/xingrin.git
synced 2026-01-31 11:46:16 +08:00
refactor(migrations,frontend,backend): reorganize app structure and enhance target management UI
- Consolidate common migrations into dedicated common app module - Remove asset search materialized view migration (0002) and simplify migration structure - Reorganize target detail page with new overview and settings sub-routes - Add target overview component displaying key asset information - Add target settings component for configuration management - Enhance scan history UI with improved data table and column definitions - Update scheduled scan dialog with better form handling - Refactor target service with improved API integration - Update scan hooks (use-scans, use-scheduled-scans) with better state management - Add internationalization strings for new target management features - Update Docker initialization and startup scripts for new app structure - Bump Django to 5.2.7 and update dependencies in requirements.txt - Add WeChat group contact information to README - Improve UI tabs component with better accessibility and styling
This commit is contained in:
@@ -254,6 +254,7 @@ sudo ./uninstall.sh
|
||||
|
||||
## 📧 联系
|
||||
- 微信公众号: **塔罗安全学苑**
|
||||
- 微信群去公众号底下的菜单,有个交流群,点击就可以看到了,链接过期可以私信我拉你
|
||||
|
||||
<img src="docs/wechat-qrcode.png" alt="微信公众号" width="200">
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-02 04:45
|
||||
# Generated by Django 5.2.7 on 2026-01-06 00:55
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
import django.contrib.postgres.indexes
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
"""
|
||||
创建资产搜索 IMMV(增量维护物化视图)
|
||||
|
||||
使用 pg_ivm 扩展创建 IMMV,数据变更时自动增量更新,无需手动刷新。
|
||||
|
||||
包含:
|
||||
1. asset_search_view - Website 搜索视图
|
||||
2. endpoint_search_view - Endpoint 搜索视图
|
||||
|
||||
重要限制:
|
||||
⚠️ pg_ivm 不支持数组类型字段(ArrayField),因为其使用 anyarray 伪类型进行比较时,
|
||||
PostgreSQL 无法确定空数组的元素类型,导致错误:
|
||||
"cannot determine element type of \"anyarray\" argument"
|
||||
|
||||
因此,所有 ArrayField 字段(tech, matched_gf_patterns 等)已从 IMMV 中移除,
|
||||
搜索时通过 JOIN 原表获取。
|
||||
|
||||
如需添加新的数组字段,请:
|
||||
1. 不要将其包含在 IMMV 视图中
|
||||
2. 在搜索服务中通过 JOIN 原表获取
|
||||
"""
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('asset', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# 1. 确保 pg_trgm 扩展已启用(用于文本模糊搜索索引)
|
||||
migrations.RunSQL(
|
||||
sql="CREATE EXTENSION IF NOT EXISTS pg_trgm;",
|
||||
reverse_sql="-- pg_trgm extension kept for other uses"
|
||||
),
|
||||
|
||||
# 2. 确保 pg_ivm 扩展已启用(用于 IMMV 增量维护)
|
||||
migrations.RunSQL(
|
||||
sql="CREATE EXTENSION IF NOT EXISTS pg_ivm;",
|
||||
reverse_sql="-- pg_ivm extension kept for other uses"
|
||||
),
|
||||
|
||||
# ==================== Website IMMV ====================
|
||||
|
||||
# 2. 创建 asset_search_view IMMV
|
||||
# ⚠️ 注意:不包含 w.tech 数组字段,pg_ivm 不支持 ArrayField
|
||||
# 数组字段通过 search_service.py 中 JOIN website 表获取
|
||||
migrations.RunSQL(
|
||||
sql="""
|
||||
SELECT pgivm.create_immv('asset_search_view', $$
|
||||
SELECT
|
||||
w.id,
|
||||
w.url,
|
||||
w.host,
|
||||
w.title,
|
||||
w.status_code,
|
||||
w.response_headers,
|
||||
w.response_body,
|
||||
w.content_type,
|
||||
w.content_length,
|
||||
w.webserver,
|
||||
w.location,
|
||||
w.vhost,
|
||||
w.created_at,
|
||||
w.target_id
|
||||
FROM website w
|
||||
$$);
|
||||
""",
|
||||
reverse_sql="SELECT pgivm.drop_immv('asset_search_view');"
|
||||
),
|
||||
|
||||
# 3. 创建 asset_search_view 索引
|
||||
migrations.RunSQL(
|
||||
sql="""
|
||||
-- 唯一索引
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS asset_search_view_id_idx
|
||||
ON asset_search_view (id);
|
||||
|
||||
-- host 模糊搜索索引
|
||||
CREATE INDEX IF NOT EXISTS asset_search_view_host_trgm_idx
|
||||
ON asset_search_view USING gin (host gin_trgm_ops);
|
||||
|
||||
-- title 模糊搜索索引
|
||||
CREATE INDEX IF NOT EXISTS asset_search_view_title_trgm_idx
|
||||
ON asset_search_view USING gin (title gin_trgm_ops);
|
||||
|
||||
-- url 模糊搜索索引
|
||||
CREATE INDEX IF NOT EXISTS asset_search_view_url_trgm_idx
|
||||
ON asset_search_view USING gin (url gin_trgm_ops);
|
||||
|
||||
-- response_headers 模糊搜索索引
|
||||
CREATE INDEX IF NOT EXISTS asset_search_view_headers_trgm_idx
|
||||
ON asset_search_view USING gin (response_headers gin_trgm_ops);
|
||||
|
||||
-- response_body 模糊搜索索引
|
||||
CREATE INDEX IF NOT EXISTS asset_search_view_body_trgm_idx
|
||||
ON asset_search_view USING gin (response_body gin_trgm_ops);
|
||||
|
||||
-- status_code 索引
|
||||
CREATE INDEX IF NOT EXISTS asset_search_view_status_idx
|
||||
ON asset_search_view (status_code);
|
||||
|
||||
-- created_at 排序索引
|
||||
CREATE INDEX IF NOT EXISTS asset_search_view_created_idx
|
||||
ON asset_search_view (created_at DESC);
|
||||
""",
|
||||
reverse_sql="""
|
||||
DROP INDEX IF EXISTS asset_search_view_id_idx;
|
||||
DROP INDEX IF EXISTS asset_search_view_host_trgm_idx;
|
||||
DROP INDEX IF EXISTS asset_search_view_title_trgm_idx;
|
||||
DROP INDEX IF EXISTS asset_search_view_url_trgm_idx;
|
||||
DROP INDEX IF EXISTS asset_search_view_headers_trgm_idx;
|
||||
DROP INDEX IF EXISTS asset_search_view_body_trgm_idx;
|
||||
DROP INDEX IF EXISTS asset_search_view_status_idx;
|
||||
DROP INDEX IF EXISTS asset_search_view_created_idx;
|
||||
"""
|
||||
),
|
||||
|
||||
# ==================== Endpoint IMMV ====================
|
||||
|
||||
# 4. 创建 endpoint_search_view IMMV
|
||||
# ⚠️ 注意:不包含 e.tech 和 e.matched_gf_patterns 数组字段,pg_ivm 不支持 ArrayField
|
||||
# 数组字段通过 search_service.py 中 JOIN endpoint 表获取
|
||||
migrations.RunSQL(
|
||||
sql="""
|
||||
SELECT pgivm.create_immv('endpoint_search_view', $$
|
||||
SELECT
|
||||
e.id,
|
||||
e.url,
|
||||
e.host,
|
||||
e.title,
|
||||
e.status_code,
|
||||
e.response_headers,
|
||||
e.response_body,
|
||||
e.content_type,
|
||||
e.content_length,
|
||||
e.webserver,
|
||||
e.location,
|
||||
e.vhost,
|
||||
e.created_at,
|
||||
e.target_id
|
||||
FROM endpoint e
|
||||
$$);
|
||||
""",
|
||||
reverse_sql="SELECT pgivm.drop_immv('endpoint_search_view');"
|
||||
),
|
||||
|
||||
# 5. 创建 endpoint_search_view 索引
|
||||
migrations.RunSQL(
|
||||
sql="""
|
||||
-- 唯一索引
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS endpoint_search_view_id_idx
|
||||
ON endpoint_search_view (id);
|
||||
|
||||
-- host 模糊搜索索引
|
||||
CREATE INDEX IF NOT EXISTS endpoint_search_view_host_trgm_idx
|
||||
ON endpoint_search_view USING gin (host gin_trgm_ops);
|
||||
|
||||
-- title 模糊搜索索引
|
||||
CREATE INDEX IF NOT EXISTS endpoint_search_view_title_trgm_idx
|
||||
ON endpoint_search_view USING gin (title gin_trgm_ops);
|
||||
|
||||
-- url 模糊搜索索引
|
||||
CREATE INDEX IF NOT EXISTS endpoint_search_view_url_trgm_idx
|
||||
ON endpoint_search_view USING gin (url gin_trgm_ops);
|
||||
|
||||
-- response_headers 模糊搜索索引
|
||||
CREATE INDEX IF NOT EXISTS endpoint_search_view_headers_trgm_idx
|
||||
ON endpoint_search_view USING gin (response_headers gin_trgm_ops);
|
||||
|
||||
-- response_body 模糊搜索索引
|
||||
CREATE INDEX IF NOT EXISTS endpoint_search_view_body_trgm_idx
|
||||
ON endpoint_search_view USING gin (response_body gin_trgm_ops);
|
||||
|
||||
-- status_code 索引
|
||||
CREATE INDEX IF NOT EXISTS endpoint_search_view_status_idx
|
||||
ON endpoint_search_view (status_code);
|
||||
|
||||
-- created_at 排序索引
|
||||
CREATE INDEX IF NOT EXISTS endpoint_search_view_created_idx
|
||||
ON endpoint_search_view (created_at DESC);
|
||||
""",
|
||||
reverse_sql="""
|
||||
DROP INDEX IF EXISTS endpoint_search_view_id_idx;
|
||||
DROP INDEX IF EXISTS endpoint_search_view_host_trgm_idx;
|
||||
DROP INDEX IF EXISTS endpoint_search_view_title_trgm_idx;
|
||||
DROP INDEX IF EXISTS endpoint_search_view_url_trgm_idx;
|
||||
DROP INDEX IF EXISTS endpoint_search_view_headers_trgm_idx;
|
||||
DROP INDEX IF EXISTS endpoint_search_view_body_trgm_idx;
|
||||
DROP INDEX IF EXISTS endpoint_search_view_status_idx;
|
||||
DROP INDEX IF EXISTS endpoint_search_view_created_idx;
|
||||
"""
|
||||
),
|
||||
]
|
||||
34
backend/apps/common/migrations/0001_initial.py
Normal file
34
backend/apps/common/migrations/0001_initial.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-06 00:55
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('targets', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='BlacklistRule',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('pattern', models.CharField(help_text='规则模式,如 *.gov, 10.0.0.0/8, 192.168.1.1', max_length=255)),
|
||||
('rule_type', models.CharField(choices=[('domain', '域名'), ('ip', 'IP地址'), ('cidr', 'CIDR范围'), ('keyword', '关键词')], help_text='规则类型:domain, ip, cidr', max_length=20)),
|
||||
('scope', models.CharField(choices=[('global', '全局规则'), ('target', 'Target规则')], db_index=True, help_text='作用域:global 或 target', max_length=20)),
|
||||
('description', models.CharField(blank=True, default='', help_text='规则描述', max_length=500)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('target', models.ForeignKey(blank=True, help_text='关联的 Target(仅 scope=target 时有值)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='blacklist_rules', to='targets.target')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'blacklist_rule',
|
||||
'ordering': ['-created_at'],
|
||||
'indexes': [models.Index(fields=['scope', 'rule_type'], name='blacklist_r_scope_6ff77f_idx'), models.Index(fields=['target', 'scope'], name='blacklist_r_target__191441_idx')],
|
||||
'constraints': [models.UniqueConstraint(fields=('pattern', 'scope', 'target'), name='unique_blacklist_rule')],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
backend/apps/common/migrations/__init__.py
Normal file
0
backend/apps/common/migrations/__init__.py
Normal file
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-02 04:45
|
||||
# Generated by Django 5.2.7 on 2026-01-06 00:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-02 04:45
|
||||
# Generated by Django 5.2.7 on 2026-01-06 00:55
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
import django.db.models.deletion
|
||||
@@ -31,6 +31,20 @@ class Migration(migrations.Migration):
|
||||
'db_table': 'notification_settings',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SubfinderProviderSettings',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('providers', models.JSONField(default=dict, help_text='各 Provider 的 API Key 配置')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Subfinder Provider 配置',
|
||||
'verbose_name_plural': 'Subfinder Provider 配置',
|
||||
'db_table': 'subfinder_provider_settings',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Notification',
|
||||
fields=[
|
||||
@@ -87,7 +101,22 @@ class Migration(migrations.Migration):
|
||||
'verbose_name_plural': '扫描任务',
|
||||
'db_table': 'scan',
|
||||
'ordering': ['-created_at'],
|
||||
'indexes': [models.Index(fields=['-created_at'], name='scan_created_0bb6c7_idx'), models.Index(fields=['target'], name='scan_target__718b9d_idx'), models.Index(fields=['deleted_at', '-created_at'], name='scan_deleted_eb17e8_idx')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ScanLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('level', models.CharField(choices=[('info', 'Info'), ('warning', 'Warning'), ('error', 'Error')], default='info', help_text='日志级别', max_length=10)),
|
||||
('content', models.TextField(help_text='日志内容')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='创建时间')),
|
||||
('scan', models.ForeignKey(help_text='关联的扫描任务', on_delete=django.db.models.deletion.CASCADE, related_name='logs', to='scan.scan')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '扫描日志',
|
||||
'verbose_name_plural': '扫描日志',
|
||||
'db_table': 'scan_log',
|
||||
'ordering': ['created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
@@ -113,38 +142,34 @@ class Migration(migrations.Migration):
|
||||
'verbose_name_plural': '定时扫描任务',
|
||||
'db_table': 'scheduled_scan',
|
||||
'ordering': ['-created_at'],
|
||||
'indexes': [models.Index(fields=['-created_at'], name='scheduled_s_created_9b9c2e_idx'), models.Index(fields=['is_enabled', '-created_at'], name='scheduled_s_is_enab_23d660_idx'), models.Index(fields=['name'], name='scheduled_s_name_bf332d_idx')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ScanLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('level', models.CharField(choices=[('info', 'Info'), ('warning', 'Warning'), ('error', 'Error')], default='info', help_text='日志级别', max_length=10)),
|
||||
('content', models.TextField(help_text='日志内容')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='创建时间')),
|
||||
('scan', models.ForeignKey(db_index=True, help_text='关联的扫描任务', on_delete=django.db.models.deletion.CASCADE, related_name='logs', to='scan.scan')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '扫描日志',
|
||||
'verbose_name_plural': '扫描日志',
|
||||
'db_table': 'scan_log',
|
||||
'ordering': ['created_at'],
|
||||
'indexes': [models.Index(fields=['scan', 'created_at'], name='scan_log_scan_id_e8c8f5_idx')],
|
||||
},
|
||||
migrations.AddIndex(
|
||||
model_name='scan',
|
||||
index=models.Index(fields=['-created_at'], name='scan_created_0bb6c7_idx'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SubfinderProviderSettings',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('providers', models.JSONField(default=dict, help_text='各 Provider 的 API Key 配置')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Subfinder Provider 配置',
|
||||
'verbose_name_plural': 'Subfinder Provider 配置',
|
||||
'db_table': 'subfinder_provider_settings',
|
||||
},
|
||||
migrations.AddIndex(
|
||||
model_name='scan',
|
||||
index=models.Index(fields=['target'], name='scan_target__718b9d_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='scan',
|
||||
index=models.Index(fields=['deleted_at', '-created_at'], name='scan_deleted_eb17e8_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='scanlog',
|
||||
index=models.Index(fields=['scan', 'created_at'], name='scan_log_scan_id_c4814a_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='scheduledscan',
|
||||
index=models.Index(fields=['-created_at'], name='scheduled_s_created_9b9c2e_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='scheduledscan',
|
||||
index=models.Index(fields=['is_enabled', '-created_at'], name='scheduled_s_is_enab_23d660_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='scheduledscan',
|
||||
index=models.Index(fields=['name'], name='scheduled_s_name_bf332d_idx'),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -3,6 +3,7 @@ from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.exceptions import NotFound, APIException
|
||||
from rest_framework.filters import SearchFilter
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.db.utils import DatabaseError, IntegrityError, OperationalError
|
||||
import logging
|
||||
@@ -33,7 +34,8 @@ class ScanViewSet(viewsets.ModelViewSet):
|
||||
"""扫描任务视图集"""
|
||||
serializer_class = ScanSerializer
|
||||
pagination_class = BasePagination
|
||||
filter_backends = [SearchFilter]
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
||||
filterset_fields = ['target'] # 支持 ?target=123 过滤
|
||||
search_fields = ['target__name'] # 按目标名称搜索
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
@@ -37,6 +37,11 @@ class ScheduledScanViewSet(viewsets.ModelViewSet):
|
||||
- PUT /scheduled-scans/{id}/ 更新定时扫描
|
||||
- DELETE /scheduled-scans/{id}/ 删除定时扫描
|
||||
- POST /scheduled-scans/{id}/toggle/ 切换启用状态
|
||||
|
||||
查询参数:
|
||||
- target_id: 按目标 ID 过滤
|
||||
- organization_id: 按组织 ID 过滤
|
||||
- search: 按名称搜索
|
||||
"""
|
||||
|
||||
queryset = ScheduledScan.objects.all().order_by('-created_at')
|
||||
@@ -49,6 +54,19 @@ class ScheduledScanViewSet(viewsets.ModelViewSet):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.service = ScheduledScanService()
|
||||
|
||||
def get_queryset(self):
|
||||
"""支持按 target_id 和 organization_id 过滤"""
|
||||
queryset = super().get_queryset()
|
||||
target_id = self.request.query_params.get('target_id')
|
||||
organization_id = self.request.query_params.get('organization_id')
|
||||
|
||||
if target_id:
|
||||
queryset = queryset.filter(target_id=target_id)
|
||||
if organization_id:
|
||||
queryset = queryset.filter(organization_id=organization_id)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""根据 action 返回不同的序列化器"""
|
||||
if self.action == 'create':
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-02 04:45
|
||||
# Generated by Django 5.2.7 on 2026-01-06 00:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ INSTALLED_APPS = [
|
||||
'django.contrib.staticfiles',
|
||||
# 第三方应用
|
||||
'rest_framework',
|
||||
'django_filters', # DRF 过滤器支持
|
||||
'drf_yasg',
|
||||
'corsheaders',
|
||||
'channels', # WebSocket 支持
|
||||
|
||||
@@ -11,6 +11,9 @@ setuptools==75.6.0
|
||||
# CORS 支持
|
||||
django-cors-headers==4.3.1
|
||||
|
||||
# 过滤器支持
|
||||
django-filter==24.3
|
||||
|
||||
# 环境变量管理
|
||||
python-dotenv==1.0.1
|
||||
|
||||
|
||||
@@ -63,11 +63,7 @@ wait_for_server() {
|
||||
run_migrations() {
|
||||
log_step "执行数据库迁移..."
|
||||
|
||||
# 开发环境:先 makemigrations
|
||||
if [ "$DEV_MODE" = "true" ]; then
|
||||
docker compose exec -T server python backend/manage.py makemigrations --noinput 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 迁移文件应手动生成并提交到仓库,这里只执行 migrate
|
||||
docker compose exec -T server python backend/manage.py migrate --noinput
|
||||
log_info "数据库迁移完成"
|
||||
}
|
||||
|
||||
@@ -3,26 +3,21 @@ set -e
|
||||
|
||||
echo "[START] 启动 XingRin Server..."
|
||||
|
||||
# 1. 生成和迁移数据库
|
||||
echo " [1/3] 生成数据库迁移文件..."
|
||||
# 1. 执行数据库迁移(迁移文件应提交到仓库,这里只执行 migrate)
|
||||
echo " [1/3] 执行数据库迁移..."
|
||||
cd /app/backend
|
||||
python manage.py makemigrations
|
||||
echo " ✓ 迁移文件生成完成"
|
||||
|
||||
echo " [1.1/3] 执行数据库迁移..."
|
||||
python manage.py migrate --noinput
|
||||
echo " ✓ 数据库迁移完成"
|
||||
|
||||
echo " [1.2/3] 初始化默认扫描引擎..."
|
||||
echo " [1.1/3] 初始化默认扫描引擎..."
|
||||
python manage.py init_default_engine
|
||||
echo " ✓ 默认扫描引擎已就绪"
|
||||
|
||||
echo " [1.3/3] 初始化默认目录字典..."
|
||||
echo " [1.2/3] 初始化默认目录字典..."
|
||||
python manage.py init_wordlists
|
||||
echo " ✓ 默认目录字典已就绪"
|
||||
|
||||
|
||||
echo " [1.4/3] 初始化默认指纹库..."
|
||||
echo " [1.3/3] 初始化默认指纹库..."
|
||||
python manage.py init_fingerprints
|
||||
echo " ✓ 默认指纹库已就绪"
|
||||
|
||||
|
||||
@@ -155,7 +155,11 @@ fi
|
||||
echo -e "${GREEN}[OK]${NC} 服务已启动"
|
||||
|
||||
# 数据初始化
|
||||
./scripts/init-data.sh
|
||||
if [ "$DEV_MODE" = true ]; then
|
||||
./scripts/init-data.sh --dev
|
||||
else
|
||||
./scripts/init-data.sh
|
||||
fi
|
||||
|
||||
# 静默模式下不显示结果(由调用方显示)
|
||||
if [ "$QUIET_MODE" = true ]; then
|
||||
|
||||
@@ -5,15 +5,15 @@ import { useEffect } from "react"
|
||||
|
||||
/**
|
||||
* Target detail page (compatible with old routes)
|
||||
* Automatically redirects to websites page
|
||||
* Automatically redirects to overview page
|
||||
*/
|
||||
export default function TargetDetailsPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
// Redirect to websites page
|
||||
router.replace(`/target/${id}/websites/`)
|
||||
// Redirect to overview page
|
||||
router.replace(`/target/${id}/overview/`)
|
||||
}, [id, router])
|
||||
|
||||
return null
|
||||
|
||||
@@ -12,7 +12,8 @@ import { useTranslations } from "next-intl"
|
||||
|
||||
/**
|
||||
* Target detail layout
|
||||
* Provides shared target information and navigation for all sub-pages
|
||||
* Two-level navigation: Overview / Assets / Vulnerabilities
|
||||
* Assets has secondary navigation for different asset types
|
||||
*/
|
||||
export default function TargetLayout({
|
||||
children,
|
||||
@@ -30,26 +31,52 @@ export default function TargetLayout({
|
||||
error
|
||||
} = useTarget(Number(id))
|
||||
|
||||
// Get currently active tab
|
||||
const getActiveTab = () => {
|
||||
if (pathname.includes("/subdomain")) return "subdomain"
|
||||
if (pathname.includes("/endpoints")) return "endpoints"
|
||||
if (pathname.includes("/websites")) return "websites"
|
||||
if (pathname.includes("/directories")) return "directories"
|
||||
// Get primary navigation active tab
|
||||
const getPrimaryTab = () => {
|
||||
if (pathname.includes("/overview")) return "overview"
|
||||
if (pathname.includes("/vulnerabilities")) return "vulnerabilities"
|
||||
if (pathname.includes("/ip-addresses")) return "ip-addresses"
|
||||
return ""
|
||||
if (pathname.includes("/settings")) return "settings"
|
||||
// All asset pages fall under "assets"
|
||||
if (
|
||||
pathname.includes("/websites") ||
|
||||
pathname.includes("/subdomain") ||
|
||||
pathname.includes("/ip-addresses") ||
|
||||
pathname.includes("/endpoints") ||
|
||||
pathname.includes("/directories")
|
||||
) {
|
||||
return "assets"
|
||||
}
|
||||
return "overview"
|
||||
}
|
||||
|
||||
// Get secondary navigation active tab (for assets)
|
||||
const getSecondaryTab = () => {
|
||||
if (pathname.includes("/websites")) return "websites"
|
||||
if (pathname.includes("/subdomain")) return "subdomain"
|
||||
if (pathname.includes("/ip-addresses")) return "ip-addresses"
|
||||
if (pathname.includes("/endpoints")) return "endpoints"
|
||||
if (pathname.includes("/directories")) return "directories"
|
||||
return "websites"
|
||||
}
|
||||
|
||||
// Check if we should show secondary navigation
|
||||
const showSecondaryNav = getPrimaryTab() === "assets"
|
||||
|
||||
// Tab path mapping
|
||||
const basePath = `/target/${id}`
|
||||
const tabPaths = {
|
||||
subdomain: `${basePath}/subdomain/`,
|
||||
endpoints: `${basePath}/endpoints/`,
|
||||
websites: `${basePath}/websites/`,
|
||||
directories: `${basePath}/directories/`,
|
||||
const primaryPaths = {
|
||||
overview: `${basePath}/overview/`,
|
||||
assets: `${basePath}/websites/`, // Default to websites when clicking assets
|
||||
vulnerabilities: `${basePath}/vulnerabilities/`,
|
||||
settings: `${basePath}/settings/`,
|
||||
}
|
||||
|
||||
const secondaryPaths = {
|
||||
websites: `${basePath}/websites/`,
|
||||
subdomain: `${basePath}/subdomain/`,
|
||||
"ip-addresses": `${basePath}/ip-addresses/`,
|
||||
endpoints: `${basePath}/endpoints/`,
|
||||
directories: `${basePath}/directories/`,
|
||||
}
|
||||
|
||||
// Get counts for each tab from target data
|
||||
@@ -62,27 +89,24 @@ export default function TargetLayout({
|
||||
"ip-addresses": (target as any)?.summary?.ips || 0,
|
||||
}
|
||||
|
||||
// Calculate total assets count
|
||||
const totalAssets = counts.websites + counts.subdomain + counts["ip-addresses"] + counts.endpoints + counts.directories
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
{/* Page header skeleton */}
|
||||
<div className="flex items-center justify-between px-4 lg:px-6">
|
||||
<div className="w-full max-w-xl space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-6 w-6 rounded-md" />
|
||||
<Skeleton className="h-7 w-48" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</div>
|
||||
{/* Header skeleton */}
|
||||
<div className="flex items-center gap-2 px-4 lg:px-6">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
|
||||
{/* Tabs navigation skeleton */}
|
||||
<div className="flex items-center justify-between px-4 lg:px-6">
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-9 w-20" />
|
||||
<Skeleton className="h-9 w-24" />
|
||||
</div>
|
||||
{/* Tabs skeleton */}
|
||||
<div className="flex gap-1 px-4 lg:px-6">
|
||||
<Skeleton className="h-9 w-20" />
|
||||
<Skeleton className="h-9 w-20" />
|
||||
<Skeleton className="h-9 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -123,74 +147,38 @@ export default function TargetLayout({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between px-4 lg:px-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<Target />
|
||||
{target.name}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">{target.description || t("noDescription")}</p>
|
||||
</div>
|
||||
{/* Header: Page label + Target name */}
|
||||
<div className="flex items-center gap-2 text-sm px-4 lg:px-6">
|
||||
<span className="text-muted-foreground">{t("breadcrumb.targetDetail")}</span>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<span className="font-medium flex items-center gap-1.5">
|
||||
<Target className="h-4 w-4" />
|
||||
{target.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tabs navigation - Use Link to ensure progress bar is triggered */}
|
||||
<div className="flex items-center justify-between px-4 lg:px-6">
|
||||
<Tabs value={getActiveTab()} className="w-full">
|
||||
{/* Primary navigation */}
|
||||
<div className="px-4 lg:px-6">
|
||||
<Tabs value={getPrimaryTab()}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="websites" asChild>
|
||||
<Link href={tabPaths.websites} className="flex items-center gap-0.5">
|
||||
Websites
|
||||
{counts.websites > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
|
||||
{counts.websites}
|
||||
</Badge>
|
||||
)}
|
||||
<TabsTrigger value="overview" asChild>
|
||||
<Link href={primaryPaths.overview} className="flex items-center gap-0.5">
|
||||
{t("tabs.overview")}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="subdomain" asChild>
|
||||
<Link href={tabPaths.subdomain} className="flex items-center gap-0.5">
|
||||
Subdomains
|
||||
{counts.subdomain > 0 && (
|
||||
<TabsTrigger value="assets" asChild>
|
||||
<Link href={primaryPaths.assets} className="flex items-center gap-0.5">
|
||||
{t("tabs.assets")}
|
||||
{totalAssets > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
|
||||
{counts.subdomain}
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="ip-addresses" asChild>
|
||||
<Link href={tabPaths["ip-addresses"]} className="flex items-center gap-0.5">
|
||||
IP Addresses
|
||||
{counts["ip-addresses"] > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
|
||||
{counts["ip-addresses"]}
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="endpoints" asChild>
|
||||
<Link href={tabPaths.endpoints} className="flex items-center gap-0.5">
|
||||
URLs
|
||||
{counts.endpoints > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
|
||||
{counts.endpoints}
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="directories" asChild>
|
||||
<Link href={tabPaths.directories} className="flex items-center gap-0.5">
|
||||
Directories
|
||||
{counts.directories > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
|
||||
{counts.directories}
|
||||
{totalAssets}
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="vulnerabilities" asChild>
|
||||
<Link href={tabPaths.vulnerabilities} className="flex items-center gap-0.5">
|
||||
Vulnerabilities
|
||||
<Link href={primaryPaths.vulnerabilities} className="flex items-center gap-0.5">
|
||||
{t("tabs.vulnerabilities")}
|
||||
{counts.vulnerabilities > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
|
||||
{counts.vulnerabilities}
|
||||
@@ -198,10 +186,75 @@ export default function TargetLayout({
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings" asChild>
|
||||
<Link href={primaryPaths.settings} className="flex items-center gap-0.5">
|
||||
{t("tabs.settings")}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Secondary navigation (only for assets) */}
|
||||
{showSecondaryNav && (
|
||||
<div className="flex items-center px-4 lg:px-6">
|
||||
<Tabs value={getSecondaryTab()} className="w-full">
|
||||
<TabsList variant="underline">
|
||||
<TabsTrigger value="websites" variant="underline" asChild>
|
||||
<Link href={secondaryPaths.websites} className="flex items-center gap-0.5">
|
||||
Websites
|
||||
{counts.websites > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
|
||||
{counts.websites}
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="subdomain" variant="underline" asChild>
|
||||
<Link href={secondaryPaths.subdomain} className="flex items-center gap-0.5">
|
||||
Subdomains
|
||||
{counts.subdomain > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
|
||||
{counts.subdomain}
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="ip-addresses" variant="underline" asChild>
|
||||
<Link href={secondaryPaths["ip-addresses"]} className="flex items-center gap-0.5">
|
||||
IPs
|
||||
{counts["ip-addresses"] > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
|
||||
{counts["ip-addresses"]}
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="endpoints" variant="underline" asChild>
|
||||
<Link href={secondaryPaths.endpoints} className="flex items-center gap-0.5">
|
||||
URLs
|
||||
{counts.endpoints > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
|
||||
{counts.endpoints}
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="directories" variant="underline" asChild>
|
||||
<Link href={secondaryPaths.directories} className="flex items-center gap-0.5">
|
||||
Directories
|
||||
{counts.directories > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
|
||||
{counts.directories}
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sub-page content */}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
19
frontend/app/[locale]/target/[id]/overview/page.tsx
Normal file
19
frontend/app/[locale]/target/[id]/overview/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import { useParams } from "next/navigation"
|
||||
import { TargetOverview } from "@/components/target/target-overview"
|
||||
|
||||
/**
|
||||
* Target overview page
|
||||
* Displays target statistics and summary information
|
||||
*/
|
||||
export default function TargetOverviewPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const targetId = Number(id)
|
||||
|
||||
return (
|
||||
<div className="px-4 lg:px-6">
|
||||
<TargetOverview targetId={targetId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,15 +5,15 @@ import { useEffect } from "react"
|
||||
|
||||
/**
|
||||
* Target detail default page
|
||||
* Automatically redirects to websites page
|
||||
* Automatically redirects to overview page
|
||||
*/
|
||||
export default function TargetDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
// Redirect to websites page
|
||||
router.replace(`/target/${id}/websites/`)
|
||||
// Redirect to overview page
|
||||
router.replace(`/target/${id}/overview/`)
|
||||
}, [id, router])
|
||||
|
||||
return null
|
||||
|
||||
19
frontend/app/[locale]/target/[id]/settings/page.tsx
Normal file
19
frontend/app/[locale]/target/[id]/settings/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import { useParams } from "next/navigation"
|
||||
import { TargetSettings } from "@/components/target/target-settings"
|
||||
|
||||
/**
|
||||
* Target settings page
|
||||
* Contains blacklist configuration and other settings
|
||||
*/
|
||||
export default function TargetSettingsPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const targetId = Number(id)
|
||||
|
||||
return (
|
||||
<div className="px-4 lg:px-6">
|
||||
<TargetSettings targetId={targetId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -161,6 +161,7 @@ interface CreateColumnsProps {
|
||||
handleStop: (scan: ScanRecord) => void
|
||||
handleViewProgress?: (scan: ScanRecord) => void
|
||||
t: ScanHistoryTranslations
|
||||
hideTargetColumn?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,7 +174,9 @@ export const createScanHistoryColumns = ({
|
||||
handleStop,
|
||||
handleViewProgress,
|
||||
t,
|
||||
}: CreateColumnsProps): ColumnDef<ScanRecord>[] => [
|
||||
hideTargetColumn = false,
|
||||
}: CreateColumnsProps): ColumnDef<ScanRecord>[] => {
|
||||
const columns: ColumnDef<ScanRecord>[] = [
|
||||
{
|
||||
id: "select",
|
||||
size: 40,
|
||||
@@ -574,3 +577,11 @@ export const createScanHistoryColumns = ({
|
||||
enableHiding: false,
|
||||
},
|
||||
]
|
||||
|
||||
// Filter out targetName column if hideTargetColumn is true
|
||||
if (hideTargetColumn) {
|
||||
return columns.filter(col => (col as any).accessorKey !== 'targetName')
|
||||
}
|
||||
|
||||
return columns
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ interface ScanHistoryDataTableProps {
|
||||
onPaginationChange?: (pagination: { pageIndex: number; pageSize: number }) => void
|
||||
hideToolbar?: boolean
|
||||
hidePagination?: boolean
|
||||
pageSizeOptions?: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,6 +51,7 @@ export function ScanHistoryDataTable({
|
||||
onPaginationChange,
|
||||
hideToolbar = false,
|
||||
hidePagination = false,
|
||||
pageSizeOptions,
|
||||
}: ScanHistoryDataTableProps) {
|
||||
const t = useTranslations("common.status")
|
||||
const tScan = useTranslations("scan.history")
|
||||
@@ -84,6 +86,7 @@ export function ScanHistoryDataTable({
|
||||
paginationInfo={paginationInfo}
|
||||
onPaginationChange={onPaginationChange}
|
||||
hidePagination={hidePagination}
|
||||
pageSizeOptions={pageSizeOptions}
|
||||
// Selection
|
||||
onSelectionChange={onSelectionChange}
|
||||
// Bulk operations
|
||||
|
||||
@@ -31,9 +31,14 @@ import { ScanProgressDialog, buildScanProgressData, type ScanProgressData } from
|
||||
*/
|
||||
interface ScanHistoryListProps {
|
||||
hideToolbar?: boolean
|
||||
targetId?: number // Filter by target ID
|
||||
pageSize?: number // Custom page size
|
||||
hideTargetColumn?: boolean // Hide target column (useful when showing scans for a specific target)
|
||||
pageSizeOptions?: number[] // Custom page size options
|
||||
hidePagination?: boolean // Hide pagination completely
|
||||
}
|
||||
|
||||
export function ScanHistoryList({ hideToolbar = false }: ScanHistoryListProps) {
|
||||
export function ScanHistoryList({ hideToolbar = false, targetId, pageSize: customPageSize, hideTargetColumn = false, pageSizeOptions, hidePagination = false }: ScanHistoryListProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [selectedScans, setSelectedScans] = useState<ScanRecord[]>([])
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
@@ -97,7 +102,7 @@ export function ScanHistoryList({ hideToolbar = false }: ScanHistoryListProps) {
|
||||
// Pagination state
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
pageSize: customPageSize || 10,
|
||||
})
|
||||
|
||||
// Search state
|
||||
@@ -115,6 +120,7 @@ export function ScanHistoryList({ hideToolbar = false }: ScanHistoryListProps) {
|
||||
page: pagination.pageIndex + 1, // API page numbers start from 1
|
||||
pageSize: pagination.pageSize,
|
||||
search: searchQuery || undefined,
|
||||
target: targetId,
|
||||
})
|
||||
|
||||
// Reset search state when request completes
|
||||
@@ -278,8 +284,9 @@ export function ScanHistoryList({ hideToolbar = false }: ScanHistoryListProps) {
|
||||
handleStop: handleStopScan,
|
||||
handleViewProgress,
|
||||
t: translations,
|
||||
hideTargetColumn,
|
||||
}),
|
||||
[navigate, translations]
|
||||
[navigate, translations, hideTargetColumn]
|
||||
)
|
||||
|
||||
// Error handling
|
||||
@@ -330,6 +337,8 @@ export function ScanHistoryList({ hideToolbar = false }: ScanHistoryListProps) {
|
||||
}}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
hideToolbar={hideToolbar}
|
||||
pageSizeOptions={pageSizeOptions}
|
||||
hidePagination={hidePagination}
|
||||
/>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
|
||||
@@ -104,10 +104,12 @@ export function CreateScheduledScanDialog({
|
||||
{ id: 5, title: t("steps.scheduleSettings"), icon: IconClock },
|
||||
]
|
||||
|
||||
// Preset mode: skip target selection but keep basic info for name editing
|
||||
const PRESET_STEPS = [
|
||||
{ id: 1, title: t("steps.selectEngine"), icon: IconSettings },
|
||||
{ id: 2, title: t("steps.editConfig"), icon: IconCode },
|
||||
{ id: 3, title: t("steps.scheduleSettings"), icon: IconClock },
|
||||
{ id: 1, title: t("steps.basicInfo"), icon: IconInfoCircle },
|
||||
{ id: 2, title: t("steps.selectEngine"), icon: IconSettings },
|
||||
{ id: 3, title: t("steps.editConfig"), icon: IconCode },
|
||||
{ id: 4, title: t("steps.scheduleSettings"), icon: IconClock },
|
||||
]
|
||||
|
||||
const [orgSearchInput, setOrgSearchInput] = React.useState("")
|
||||
@@ -240,15 +242,18 @@ export function CreateScheduledScanDialog({
|
||||
const validateCurrentStep = (): boolean => {
|
||||
if (hasPreset) {
|
||||
switch (currentStep) {
|
||||
case 1: // Select engine
|
||||
case 1: // Basic info (preset mode)
|
||||
if (!name.trim()) { toast.error(t("form.taskNameRequired")); return false }
|
||||
return true
|
||||
case 2: // 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
|
||||
case 3: // Edit config
|
||||
if (!configuration.trim()) { toast.error(t("form.configurationRequired")); return false }
|
||||
if (!isYamlValid) { toast.error(t("form.yamlInvalid")); return false }
|
||||
return true
|
||||
case 3: // Schedule
|
||||
case 4: // Schedule
|
||||
const parts = cronExpression.trim().split(/\s+/)
|
||||
if (parts.length !== 5) { toast.error(t("form.cronRequired")); return false }
|
||||
return true
|
||||
@@ -352,7 +357,7 @@ export function CreateScheduledScanDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="border-t h-[480px] overflow-hidden">
|
||||
{/* Step 1: Basic Info + Scan Mode */}
|
||||
{/* Step 1: Basic Info + Scan Mode (full mode only) */}
|
||||
{currentStep === 1 && !hasPreset && (
|
||||
<div className="p-6 space-y-6 overflow-y-auto h-full">
|
||||
<div className="space-y-2">
|
||||
@@ -394,6 +399,29 @@ export function CreateScheduledScanDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 1: Basic Info (preset mode - name only, target is locked) */}
|
||||
{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>
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<Label>{t("form.scanTarget")}</Label>
|
||||
<div className="flex items-center gap-2 p-4 border rounded-lg bg-muted/50">
|
||||
<IconTarget className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="font-medium">{presetTargetName || presetOrganizationName}</span>
|
||||
<Badge variant="secondary" className="ml-auto">
|
||||
{presetTargetId ? t("form.targetScan") : t("form.organizationScan")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("form.presetTargetHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Select Target (Organization or Target) */}
|
||||
{currentStep === 2 && !hasPreset && (
|
||||
<div className="p-6 space-y-4 overflow-y-auto h-full">
|
||||
@@ -475,8 +503,8 @@ export function CreateScheduledScanDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3 (full) / Step 1 (preset): Select Engine */}
|
||||
{((currentStep === 3 && !hasPreset) || (currentStep === 1 && hasPreset)) && engines.length > 0 && (
|
||||
{/* Step 3 (full) / Step 2 (preset): Select Engine */}
|
||||
{((currentStep === 3 && !hasPreset) || (currentStep === 2 && hasPreset)) && engines.length > 0 && (
|
||||
<EnginePresetSelector
|
||||
engines={engines}
|
||||
selectedEngineIds={engineIds}
|
||||
@@ -488,8 +516,8 @@ export function CreateScheduledScanDialog({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 4 (full) / Step 2 (preset): Edit Configuration */}
|
||||
{((currentStep === 4 && !hasPreset) || (currentStep === 2 && hasPreset)) && (
|
||||
{/* Step 4 (full) / Step 3 (preset): Edit Configuration */}
|
||||
{((currentStep === 4 && !hasPreset) || (currentStep === 3 && hasPreset)) && (
|
||||
<ScanConfigEditor
|
||||
configuration={configuration}
|
||||
onChange={handleManualConfigChange}
|
||||
@@ -500,8 +528,8 @@ export function CreateScheduledScanDialog({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 5 (full) / Step 3 (preset): Schedule Settings */}
|
||||
{((currentStep === 5 && !hasPreset) || (currentStep === 3 && hasPreset)) && (
|
||||
{/* Step 5 (full) / Step 4 (preset): Schedule Settings */}
|
||||
{((currentStep === 5 && !hasPreset) || (currentStep === 4 && hasPreset)) && (
|
||||
<div className="p-6 space-y-6 overflow-y-auto h-full">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("form.cronExpression")} *</Label>
|
||||
|
||||
341
frontend/components/target/target-overview.tsx
Normal file
341
frontend/components/target/target-overview.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import Link from "next/link"
|
||||
import { useTranslations, useLocale } from "next-intl"
|
||||
import {
|
||||
Globe,
|
||||
Network,
|
||||
Server,
|
||||
Link2,
|
||||
FolderOpen,
|
||||
ShieldAlert,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Calendar,
|
||||
ChevronRight,
|
||||
CheckCircle2,
|
||||
PauseCircle,
|
||||
} from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useTarget } from "@/hooks/use-targets"
|
||||
import { useScheduledScans } from "@/hooks/use-scheduled-scans"
|
||||
import { ScanHistoryList } from "@/components/scan/history/scan-history-list"
|
||||
import { getDateLocale } from "@/lib/date-utils"
|
||||
|
||||
interface TargetOverviewProps {
|
||||
targetId: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Target overview component
|
||||
* Displays statistics cards for the target
|
||||
*/
|
||||
export function TargetOverview({ targetId }: TargetOverviewProps) {
|
||||
const t = useTranslations("pages.targetDetail.overview")
|
||||
const locale = useLocale()
|
||||
|
||||
const { data: target, isLoading, error } = useTarget(targetId)
|
||||
const { data: scheduledScansData, isLoading: isLoadingScans } = useScheduledScans({
|
||||
targetId,
|
||||
pageSize: 5
|
||||
})
|
||||
|
||||
const scheduledScans = scheduledScansData?.results || []
|
||||
const totalScheduledScans = scheduledScansData?.total || 0
|
||||
const enabledScans = scheduledScans.filter(s => s.isEnabled)
|
||||
|
||||
// Format date helper
|
||||
const formatDate = (dateString: string | undefined): string => {
|
||||
if (!dateString) return "-"
|
||||
return new Date(dateString).toLocaleString(getDateLocale(locale), {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
// Format short date for scheduled scans
|
||||
const formatShortDate = (dateString: string | undefined): string => {
|
||||
if (!dateString) return "-"
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const tomorrow = new Date(now)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
|
||||
// Check if it's today
|
||||
if (date.toDateString() === now.toDateString()) {
|
||||
return t("scheduledScans.today") + " " + date.toLocaleTimeString(getDateLocale(locale), {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
// Check if it's tomorrow
|
||||
if (date.toDateString() === tomorrow.toDateString()) {
|
||||
return t("scheduledScans.tomorrow") + " " + date.toLocaleTimeString(getDateLocale(locale), {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
// Otherwise show date
|
||||
return date.toLocaleString(getDateLocale(locale), {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
// Get next execution time from enabled scans
|
||||
const getNextExecution = () => {
|
||||
const enabledWithNextRun = enabledScans.filter(s => s.nextRunTime)
|
||||
if (enabledWithNextRun.length === 0) return null
|
||||
|
||||
const sorted = enabledWithNextRun.sort((a, b) =>
|
||||
new Date(a.nextRunTime!).getTime() - new Date(b.nextRunTime!).getTime()
|
||||
)
|
||||
return sorted[0]
|
||||
}
|
||||
|
||||
const nextExecution = getNextExecution()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats cards skeleton */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !target) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<AlertTriangle className="h-10 w-10 text-destructive mb-4" />
|
||||
<p className="text-muted-foreground">{t("loadError")}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const summary = (target as any).summary || {}
|
||||
const vulnSummary = summary.vulnerabilities || { total: 0, critical: 0, high: 0, medium: 0, low: 0 }
|
||||
|
||||
const assetCards = [
|
||||
{
|
||||
title: t("cards.websites"),
|
||||
value: summary.websites || 0,
|
||||
icon: Globe,
|
||||
href: `/target/${targetId}/websites/`,
|
||||
},
|
||||
{
|
||||
title: t("cards.subdomains"),
|
||||
value: summary.subdomains || 0,
|
||||
icon: Network,
|
||||
href: `/target/${targetId}/subdomain/`,
|
||||
},
|
||||
{
|
||||
title: t("cards.ips"),
|
||||
value: summary.ips || 0,
|
||||
icon: Server,
|
||||
href: `/target/${targetId}/ip-addresses/`,
|
||||
},
|
||||
{
|
||||
title: t("cards.urls"),
|
||||
value: summary.endpoints || 0,
|
||||
icon: Link2,
|
||||
href: `/target/${targetId}/endpoints/`,
|
||||
},
|
||||
{
|
||||
title: t("cards.directories"),
|
||||
value: summary.directories || 0,
|
||||
icon: FolderOpen,
|
||||
href: `/target/${targetId}/directories/`,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Target info */}
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>{t("createdAt")}: {formatDate(target.createdAt)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>{t("lastScanned")}: {formatDate(target.lastScannedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Asset statistics cards */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">{t("assetsTitle")}</h3>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||
{assetCards.map((card) => (
|
||||
<Link key={card.title} href={card.href}>
|
||||
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{card.title}</CardTitle>
|
||||
<card.icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{card.value.toLocaleString()}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scheduled Scans + Vulnerability Statistics (Two columns) */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* Scheduled Scans Card */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle className="text-sm font-medium">{t("scheduledScans.title")}</CardTitle>
|
||||
</div>
|
||||
<Link href={`/target/${targetId}/settings/`}>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs">
|
||||
{t("scheduledScans.manage")}
|
||||
<ChevronRight className="h-3 w-3 ml-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isLoadingScans ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</div>
|
||||
) : totalScheduledScans === 0 ? (
|
||||
<div className="text-center py-4">
|
||||
<Clock className="h-8 w-8 text-muted-foreground/50 mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">{t("scheduledScans.empty")}</p>
|
||||
<Link href={`/target/${targetId}/settings/`}>
|
||||
<Button variant="link" size="sm" className="mt-1">
|
||||
{t("scheduledScans.createFirst")}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Stats row */}
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("scheduledScans.configured")}: </span>
|
||||
<span className="font-medium">{totalScheduledScans}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("scheduledScans.enabled")}: </span>
|
||||
<span className="font-medium text-green-600">{enabledScans.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Next execution */}
|
||||
{nextExecution && (
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">{t("scheduledScans.nextRun")}: </span>
|
||||
<span className="font-medium">{formatShortDate(nextExecution.nextRunTime)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Task list */}
|
||||
<div className="space-y-2 pt-2 border-t">
|
||||
{scheduledScans.slice(0, 3).map((scan) => (
|
||||
<div key={scan.id} className="flex items-center gap-2 text-sm">
|
||||
{scan.isEnabled ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<PauseCircle className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className={`truncate ${!scan.isEnabled ? 'text-muted-foreground' : ''}`}>
|
||||
{scan.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{totalScheduledScans > 3 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("scheduledScans.more", { count: totalScheduledScans - 3 })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Vulnerability Statistics Card */}
|
||||
<Link href={`/target/${targetId}/vulnerabilities/`}>
|
||||
<Card className="hover:border-primary/50 transition-colors cursor-pointer h-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert className="h-4 w-4 text-red-500" />
|
||||
<CardTitle className="text-sm font-medium">{t("vulnerabilitiesTitle")}</CardTitle>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs">
|
||||
{t("viewAll")}
|
||||
<ChevronRight className="h-3 w-3 ml-1" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Total count */}
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold">{vulnSummary.total}</span>
|
||||
<span className="text-sm text-muted-foreground">{t("cards.vulnerabilities")}</span>
|
||||
</div>
|
||||
|
||||
{/* Severity breakdown */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500" />
|
||||
<span className="text-sm text-muted-foreground">{t("severity.critical")}</span>
|
||||
<span className="text-sm font-medium ml-auto">{vulnSummary.critical}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-orange-500" />
|
||||
<span className="text-sm text-muted-foreground">{t("severity.high")}</span>
|
||||
<span className="text-sm font-medium ml-auto">{vulnSummary.high}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-500" />
|
||||
<span className="text-sm text-muted-foreground">{t("severity.medium")}</span>
|
||||
<span className="text-sm font-medium ml-auto">{vulnSummary.medium}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-blue-500" />
|
||||
<span className="text-sm text-muted-foreground">{t("severity.low")}</span>
|
||||
<span className="text-sm font-medium ml-auto">{vulnSummary.low}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Scan history */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">{t("scanHistoryTitle")}</h3>
|
||||
<ScanHistoryList targetId={targetId} hideToolbar pageSize={5} hideTargetColumn pageSizeOptions={[5, 10, 20, 50, 100]} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
369
frontend/components/target/target-settings.tsx
Normal file
369
frontend/components/target/target-settings.tsx
Normal file
@@ -0,0 +1,369 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState, useEffect } from "react"
|
||||
import { useTranslations, useLocale } from "next-intl"
|
||||
import { AlertTriangle, Loader2, Ban, Clock } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { useTargetBlacklist, useUpdateTargetBlacklist, useTarget } from "@/hooks/use-targets"
|
||||
import { useScheduledScans, useToggleScheduledScan, useDeleteScheduledScan } from "@/hooks/use-scheduled-scans"
|
||||
import { ScheduledScanDataTable } from "@/components/scan/scheduled/scheduled-scan-data-table"
|
||||
import { createScheduledScanColumns } from "@/components/scan/scheduled/scheduled-scan-columns"
|
||||
import { CreateScheduledScanDialog } from "@/components/scan/scheduled/create-scheduled-scan-dialog"
|
||||
import { EditScheduledScanDialog } from "@/components/scan/scheduled/edit-scheduled-scan-dialog"
|
||||
import { DataTableSkeleton } from "@/components/ui/data-table-skeleton"
|
||||
import type { ScheduledScan } from "@/types/scheduled-scan.types"
|
||||
|
||||
interface TargetSettingsProps {
|
||||
targetId: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Target settings component
|
||||
* Contains blacklist configuration and scheduled scans
|
||||
*/
|
||||
export function TargetSettings({ targetId }: TargetSettingsProps) {
|
||||
const t = useTranslations("pages.targetDetail.settings")
|
||||
const tColumns = useTranslations("columns")
|
||||
const tCommon = useTranslations("common")
|
||||
const tScan = useTranslations("scan")
|
||||
const tConfirm = useTranslations("common.confirm")
|
||||
const locale = useLocale()
|
||||
|
||||
const [blacklistText, setBlacklistText] = useState("")
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
|
||||
// Scheduled scan states
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [editingScheduledScan, setEditingScheduledScan] = useState<ScheduledScan | null>(null)
|
||||
const [deletingScheduledScan, setDeletingScheduledScan] = useState<ScheduledScan | null>(null)
|
||||
|
||||
// Pagination state
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
|
||||
// Fetch target data for preset name
|
||||
const { data: target } = useTarget(targetId)
|
||||
|
||||
// Fetch blacklist data
|
||||
const { data, isLoading, error } = useTargetBlacklist(targetId)
|
||||
const updateBlacklist = useUpdateTargetBlacklist()
|
||||
|
||||
// Fetch scheduled scans for this target
|
||||
const {
|
||||
data: scheduledScansData,
|
||||
isLoading: isLoadingScans,
|
||||
isFetching,
|
||||
refetch
|
||||
} = useScheduledScans({
|
||||
targetId,
|
||||
page,
|
||||
pageSize,
|
||||
search: searchQuery || undefined
|
||||
})
|
||||
const { mutate: toggleScheduledScan } = useToggleScheduledScan()
|
||||
const { mutate: deleteScheduledScan } = useDeleteScheduledScan()
|
||||
|
||||
const scheduledScans = scheduledScansData?.results || []
|
||||
const total = scheduledScansData?.total || 0
|
||||
const totalPages = scheduledScansData?.totalPages || 1
|
||||
|
||||
// Build translation object for columns
|
||||
const translations = React.useMemo(() => ({
|
||||
columns: {
|
||||
taskName: tColumns("scheduledScan.taskName"),
|
||||
scanEngine: tColumns("scheduledScan.scanEngine"),
|
||||
cronExpression: tColumns("scheduledScan.cronExpression"),
|
||||
scope: tColumns("scheduledScan.scope"),
|
||||
status: tColumns("common.status"),
|
||||
nextRun: tColumns("scheduledScan.nextRun"),
|
||||
runCount: tColumns("scheduledScan.runCount"),
|
||||
lastRun: tColumns("scheduledScan.lastRun"),
|
||||
},
|
||||
actions: {
|
||||
editTask: tScan("editTask"),
|
||||
delete: tCommon("actions.delete"),
|
||||
openMenu: tCommon("actions.openMenu"),
|
||||
},
|
||||
status: {
|
||||
enabled: tCommon("status.enabled"),
|
||||
disabled: tCommon("status.disabled"),
|
||||
},
|
||||
cron: {
|
||||
everyMinute: tScan("cron.everyMinute"),
|
||||
everyNMinutes: tScan.raw("cron.everyNMinutes") as string,
|
||||
everyHour: tScan.raw("cron.everyHour") as string,
|
||||
everyNHours: tScan.raw("cron.everyNHours") as string,
|
||||
everyDay: tScan.raw("cron.everyDay") as string,
|
||||
everyWeek: tScan.raw("cron.everyWeek") as string,
|
||||
everyMonth: tScan.raw("cron.everyMonth") as string,
|
||||
weekdays: tScan.raw("cron.weekdays") as string[],
|
||||
},
|
||||
}), [tColumns, tCommon, tScan])
|
||||
|
||||
// Initialize text when data loads
|
||||
useEffect(() => {
|
||||
if (data?.patterns) {
|
||||
setBlacklistText(data.patterns.join("\n"))
|
||||
setHasChanges(false)
|
||||
}
|
||||
}, [data])
|
||||
|
||||
// Reset search state when request completes
|
||||
useEffect(() => {
|
||||
if (!isFetching && isSearching) {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}, [isFetching, isSearching])
|
||||
|
||||
// Handle text change
|
||||
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setBlacklistText(e.target.value)
|
||||
setHasChanges(true)
|
||||
}
|
||||
|
||||
// Handle save
|
||||
const handleSave = () => {
|
||||
const patterns = blacklistText
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
|
||||
updateBlacklist.mutate(
|
||||
{ targetId, patterns },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setHasChanges(false)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Format date
|
||||
const formatDate = React.useCallback((dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString(locale === "zh" ? "zh-CN" : "en-US", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}, [locale])
|
||||
|
||||
// Edit task
|
||||
const handleEdit = React.useCallback((scan: ScheduledScan) => {
|
||||
setEditingScheduledScan(scan)
|
||||
setEditDialogOpen(true)
|
||||
}, [])
|
||||
|
||||
// Delete task (open confirmation dialog)
|
||||
const handleDelete = React.useCallback((scan: ScheduledScan) => {
|
||||
setDeletingScheduledScan(scan)
|
||||
setDeleteDialogOpen(true)
|
||||
}, [])
|
||||
|
||||
// Confirm delete task
|
||||
const confirmDelete = React.useCallback(() => {
|
||||
if (deletingScheduledScan) {
|
||||
deleteScheduledScan(deletingScheduledScan.id)
|
||||
setDeleteDialogOpen(false)
|
||||
setDeletingScheduledScan(null)
|
||||
}
|
||||
}, [deletingScheduledScan, deleteScheduledScan])
|
||||
|
||||
// Toggle task enabled status
|
||||
const handleToggleStatus = React.useCallback((scan: ScheduledScan, enabled: boolean) => {
|
||||
toggleScheduledScan({ id: scan.id, isEnabled: enabled })
|
||||
}, [toggleScheduledScan])
|
||||
|
||||
// Search handler
|
||||
const handleSearchChange = (value: string) => {
|
||||
setIsSearching(true)
|
||||
setSearchQuery(value)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
// Page change handler
|
||||
const handlePageChange = React.useCallback((newPage: number) => {
|
||||
setPage(newPage)
|
||||
}, [])
|
||||
|
||||
// Page size change handler
|
||||
const handlePageSizeChange = React.useCallback((newPageSize: number) => {
|
||||
setPageSize(newPageSize)
|
||||
setPage(1)
|
||||
}, [])
|
||||
|
||||
// Add new task
|
||||
const handleAddNew = React.useCallback(() => {
|
||||
setCreateDialogOpen(true)
|
||||
}, [])
|
||||
|
||||
// Create column definition (hide scope column since we're filtering by target)
|
||||
const columns = React.useMemo(() => {
|
||||
const allColumns = createScheduledScanColumns({
|
||||
formatDate,
|
||||
handleEdit,
|
||||
handleDelete,
|
||||
handleToggleStatus,
|
||||
t: translations,
|
||||
})
|
||||
// Filter out the scope column since all scans are for this target
|
||||
return allColumns.filter(col => (col as { accessorKey?: string }).accessorKey !== 'scanMode')
|
||||
}, [formatDate, handleEdit, handleDelete, handleToggleStatus, translations])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-4 w-96" />
|
||||
</div>
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<AlertTriangle className="h-10 w-10 text-destructive mb-4" />
|
||||
<p className="text-muted-foreground">{t("loadError")}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Blacklist section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Ban className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle>{t("blacklist.title")}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>{t("blacklist.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Rules hint */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{t("blacklist.rulesTitle")}:</span>
|
||||
<span><code className="bg-muted px-1.5 py-0.5 rounded text-xs">*.gov</code> {t("blacklist.rules.domainShort")}</span>
|
||||
<span><code className="bg-muted px-1.5 py-0.5 rounded text-xs">*cdn*</code> {t("blacklist.rules.keywordShort")}</span>
|
||||
<span><code className="bg-muted px-1.5 py-0.5 rounded text-xs">192.168.1.1</code> {t("blacklist.rules.ipShort")}</span>
|
||||
<span><code className="bg-muted px-1.5 py-0.5 rounded text-xs">10.0.0.0/8</code> {t("blacklist.rules.cidrShort")}</span>
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<Textarea
|
||||
value={blacklistText}
|
||||
onChange={handleTextChange}
|
||||
placeholder={t("blacklist.placeholder")}
|
||||
className="min-h-[240px] font-mono text-sm"
|
||||
/>
|
||||
|
||||
{/* Save button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || updateBlacklist.isPending}
|
||||
>
|
||||
{updateBlacklist.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{t("blacklist.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Scheduled Scans section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle>{t("scheduledScans.title")}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>{t("scheduledScans.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingScans ? (
|
||||
<DataTableSkeleton rows={3} columns={6} toolbarButtonCount={1} />
|
||||
) : (
|
||||
<ScheduledScanDataTable
|
||||
data={scheduledScans}
|
||||
columns={columns}
|
||||
onAddNew={handleAddNew}
|
||||
searchPlaceholder={tScan("scheduled.searchPlaceholder")}
|
||||
searchValue={searchQuery}
|
||||
onSearch={handleSearchChange}
|
||||
isSearching={isSearching}
|
||||
addButtonText={tScan("scheduled.createTitle")}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create Dialog */}
|
||||
<CreateScheduledScanDialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
presetTargetId={targetId}
|
||||
presetTargetName={target?.name}
|
||||
onSuccess={() => refetch()}
|
||||
/>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<EditScheduledScanDialog
|
||||
open={editDialogOpen}
|
||||
onOpenChange={setEditDialogOpen}
|
||||
scheduledScan={editingScheduledScan}
|
||||
onSuccess={() => refetch()}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{tConfirm("deleteTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{tConfirm("deleteScheduledScanMessage", { name: deletingScheduledScan?.name ?? "" })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{tCommon("actions.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{tCommon("actions.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -18,15 +18,22 @@ function Tabs({
|
||||
)
|
||||
}
|
||||
|
||||
interface TabsListProps extends React.ComponentProps<typeof TabsPrimitive.List> {
|
||||
variant?: "default" | "underline"
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
}: TabsListProps) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
"inline-flex w-fit items-center justify-center",
|
||||
variant === "default" && "bg-muted text-muted-foreground h-9 rounded-lg p-[3px]",
|
||||
variant === "underline" && "h-10 gap-4 border-b border-border bg-transparent p-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -34,15 +41,22 @@ function TabsList({
|
||||
)
|
||||
}
|
||||
|
||||
interface TabsTriggerProps extends React.ComponentProps<typeof TabsPrimitive.Trigger> {
|
||||
variant?: "default" | "underline"
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
}: TabsTriggerProps) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-zinc-500 dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap cursor-pointer transition-[color,box-shadow] focus-visible:ring-[1px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"inline-flex items-center justify-center gap-1.5 text-sm font-medium whitespace-nowrap cursor-pointer transition-all focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
variant === "default" && "data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-zinc-500 dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground h-[calc(100%-1px)] flex-1 rounded-md border border-transparent px-2 py-1 focus-visible:ring-[1px] focus-visible:outline-1 data-[state=active]:shadow-sm",
|
||||
variant === "underline" && "text-muted-foreground data-[state=active]:text-foreground h-10 px-1 pb-3 -mb-px border-b-2 border-transparent data-[state=active]:border-primary rounded-none bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -29,6 +29,17 @@ export function useRunningScans(page = 1, pageSize = 10) {
|
||||
return useScans({ page, pageSize, status: 'running' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取目标的扫描历史
|
||||
*/
|
||||
export function useTargetScans(targetId: number, pageSize = 5) {
|
||||
return useQuery({
|
||||
queryKey: ['scans', 'target', targetId, pageSize],
|
||||
queryFn: () => getScans({ target: targetId, pageSize }),
|
||||
enabled: !!targetId,
|
||||
})
|
||||
}
|
||||
|
||||
export function useScan(id: number) {
|
||||
return useQuery({
|
||||
queryKey: ['scan', id],
|
||||
|
||||
@@ -14,7 +14,13 @@ import type { CreateScheduledScanRequest, UpdateScheduledScanRequest } from '@/t
|
||||
/**
|
||||
* 获取定时扫描列表
|
||||
*/
|
||||
export function useScheduledScans(params: { page?: number; pageSize?: number; search?: string } = { page: 1, pageSize: 10 }) {
|
||||
export function useScheduledScans(params: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
search?: string
|
||||
targetId?: number
|
||||
organizationId?: number
|
||||
} = { page: 1, pageSize: 10 }) {
|
||||
return useQuery({
|
||||
queryKey: ['scheduled-scans', params],
|
||||
queryFn: () => getScheduledScans(params),
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
linkTargetOrganizations,
|
||||
unlinkTargetOrganizations,
|
||||
getTargetEndpoints,
|
||||
getTargetBlacklist,
|
||||
updateTargetBlacklist,
|
||||
} from '@/services/target.service'
|
||||
import type {
|
||||
CreateTargetRequest,
|
||||
@@ -304,3 +306,34 @@ export function useTargetEndpoints(
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取目标的黑名单规则
|
||||
*/
|
||||
export function useTargetBlacklist(targetId: number) {
|
||||
return useQuery({
|
||||
queryKey: ['targets', targetId, 'blacklist'],
|
||||
queryFn: () => getTargetBlacklist(targetId),
|
||||
enabled: !!targetId,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新目标的黑名单规则
|
||||
*/
|
||||
export function useUpdateTargetBlacklist() {
|
||||
const queryClient = useQueryClient()
|
||||
const toastMessages = useToastMessages()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ targetId, patterns }: { targetId: number; patterns: string[] }) =>
|
||||
updateTargetBlacklist(targetId, patterns),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['targets', variables.targetId, 'blacklist'] })
|
||||
toastMessages.success('toast.blacklist.save.success')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toastMessages.errorFromCode(getErrorCode(error?.response?.data), 'toast.blacklist.save.error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -837,7 +837,9 @@
|
||||
"noConfig": "No config",
|
||||
"capabilitiesCount": "{count} capabilities",
|
||||
"selected": "Selected",
|
||||
"selectedEngines": "{count} engines selected"
|
||||
"selectedEngines": "{count} engines selected",
|
||||
"scanTarget": "Scan Target",
|
||||
"presetTargetHint": "Target is preset and cannot be changed. To scan other targets, create from the global scheduled scans page."
|
||||
},
|
||||
"presets": {
|
||||
"everyHour": "Every Hour",
|
||||
@@ -1766,6 +1768,12 @@
|
||||
"error": "Failed to fetch system logs, please check backend",
|
||||
"recovered": "System log connection recovered"
|
||||
}
|
||||
},
|
||||
"blacklist": {
|
||||
"save": {
|
||||
"success": "Blacklist rules saved",
|
||||
"error": "Failed to save blacklist rules"
|
||||
}
|
||||
}
|
||||
},
|
||||
"quickScan": {
|
||||
@@ -2012,6 +2020,9 @@
|
||||
},
|
||||
"targetDetail": {
|
||||
"noDescription": "No description",
|
||||
"breadcrumb": {
|
||||
"targetDetail": "Target Detail"
|
||||
},
|
||||
"error": {
|
||||
"title": "Load Failed",
|
||||
"message": "An error occurred while fetching target data"
|
||||
@@ -2019,6 +2030,107 @@
|
||||
"notFound": {
|
||||
"title": "Target Not Found",
|
||||
"message": "Target with ID {id} not found"
|
||||
},
|
||||
"tabs": {
|
||||
"overview": "Overview",
|
||||
"assets": "Assets",
|
||||
"vulnerabilities": "Vulnerabilities",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"settings": {
|
||||
"loadError": "Failed to load settings",
|
||||
"blacklist": {
|
||||
"title": "Blacklist Rules",
|
||||
"description": "Assets matching the following rules will be automatically excluded during scanning.",
|
||||
"rulesTitle": "Supported Rule Types",
|
||||
"rules": {
|
||||
"domain": "Domain wildcard, matches specified suffix",
|
||||
"domainShort": "Domain",
|
||||
"keyword": "Keyword match, contains specified string",
|
||||
"keywordShort": "Keyword",
|
||||
"ip": "Exact IP address match",
|
||||
"ipShort": "IP",
|
||||
"cidr": "Matches IP range",
|
||||
"cidrShort": "CIDR"
|
||||
},
|
||||
"placeholder": "Enter rules, one per line",
|
||||
"save": "Save Rules"
|
||||
},
|
||||
"scheduledScans": {
|
||||
"title": "Scheduled Scans",
|
||||
"description": "Configure automated scan tasks for this target",
|
||||
"create": "New Scheduled Scan",
|
||||
"empty": "No scheduled scans",
|
||||
"emptyHint": "Click the button above to create a scheduled scan",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"nextRun": "Next run",
|
||||
"runCount": "Run count",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"cronDaily": "Daily at {time}",
|
||||
"cronWeekly": "Every {day} at {time}",
|
||||
"cronMonthly": "Monthly on day {day} at {time}",
|
||||
"weekdays": {
|
||||
"sun": "Sunday",
|
||||
"mon": "Monday",
|
||||
"tue": "Tuesday",
|
||||
"wed": "Wednesday",
|
||||
"thu": "Thursday",
|
||||
"fri": "Friday",
|
||||
"sat": "Saturday"
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "Confirm Delete",
|
||||
"description": "Are you sure you want to delete the scheduled scan \"{name}\"? This action cannot be undone.",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Delete"
|
||||
}
|
||||
}
|
||||
},
|
||||
"overview": {
|
||||
"loadError": "Failed to load target data",
|
||||
"createdAt": "Created",
|
||||
"lastScanned": "Last Scanned",
|
||||
"assetsTitle": "Assets",
|
||||
"vulnerabilitiesTitle": "Vulnerabilities",
|
||||
"scanHistoryTitle": "Scan History",
|
||||
"recentScans": "Recent Scans",
|
||||
"noScans": "No scan records",
|
||||
"viewAll": "View all",
|
||||
"cards": {
|
||||
"websites": "Websites",
|
||||
"subdomains": "Subdomains",
|
||||
"ips": "IP Addresses",
|
||||
"urls": "URLs",
|
||||
"directories": "Directories",
|
||||
"vulnerabilities": "Total Vulnerabilities"
|
||||
},
|
||||
"severity": {
|
||||
"critical": "Critical",
|
||||
"high": "High",
|
||||
"medium": "Medium",
|
||||
"low": "Low"
|
||||
},
|
||||
"scanStatus": {
|
||||
"completed": "Completed",
|
||||
"running": "Running",
|
||||
"failed": "Failed",
|
||||
"cancelled": "Cancelled",
|
||||
"initiated": "Pending"
|
||||
},
|
||||
"scheduledScans": {
|
||||
"title": "Scheduled Scans",
|
||||
"manage": "Manage",
|
||||
"empty": "No scheduled scans",
|
||||
"createFirst": "Create your first scheduled scan",
|
||||
"configured": "Configured",
|
||||
"enabled": "Enabled",
|
||||
"nextRun": "Next run",
|
||||
"today": "Today",
|
||||
"tomorrow": "Tomorrow",
|
||||
"more": "+{count} more"
|
||||
}
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
|
||||
@@ -837,7 +837,9 @@
|
||||
"noConfig": "无配置",
|
||||
"capabilitiesCount": "{count} 项能力",
|
||||
"selected": "已选择",
|
||||
"selectedEngines": "已选择 {count} 个引擎"
|
||||
"selectedEngines": "已选择 {count} 个引擎",
|
||||
"scanTarget": "扫描目标",
|
||||
"presetTargetHint": "目标已预设,无法更改。如需扫描其他目标,请从全局定时扫描页面创建。"
|
||||
},
|
||||
"presets": {
|
||||
"everyHour": "每小时",
|
||||
@@ -1766,6 +1768,12 @@
|
||||
"error": "系统日志获取失败,请检查后端接口",
|
||||
"recovered": "系统日志连接已恢复"
|
||||
}
|
||||
},
|
||||
"blacklist": {
|
||||
"save": {
|
||||
"success": "黑名单规则已保存",
|
||||
"error": "保存黑名单规则失败"
|
||||
}
|
||||
}
|
||||
},
|
||||
"quickScan": {
|
||||
@@ -2012,6 +2020,9 @@
|
||||
},
|
||||
"targetDetail": {
|
||||
"noDescription": "暂无描述",
|
||||
"breadcrumb": {
|
||||
"targetDetail": "目标详情"
|
||||
},
|
||||
"error": {
|
||||
"title": "加载失败",
|
||||
"message": "获取目标数据时出现错误"
|
||||
@@ -2019,6 +2030,107 @@
|
||||
"notFound": {
|
||||
"title": "目标不存在",
|
||||
"message": "未找到ID为 {id} 的目标"
|
||||
},
|
||||
"tabs": {
|
||||
"overview": "概览",
|
||||
"assets": "资产",
|
||||
"vulnerabilities": "漏洞",
|
||||
"settings": "设置"
|
||||
},
|
||||
"settings": {
|
||||
"loadError": "加载设置失败",
|
||||
"blacklist": {
|
||||
"title": "黑名单规则",
|
||||
"description": "扫描时将自动排除匹配以下规则的资产。",
|
||||
"rulesTitle": "支持的规则类型",
|
||||
"rules": {
|
||||
"domain": "域名通配符,匹配指定后缀",
|
||||
"domainShort": "域名",
|
||||
"keyword": "关键词匹配,包含指定字符串",
|
||||
"keywordShort": "关键词",
|
||||
"ip": "精确匹配 IP 地址",
|
||||
"ipShort": "IP",
|
||||
"cidr": "匹配 IP 网段范围",
|
||||
"cidrShort": "CIDR"
|
||||
},
|
||||
"placeholder": "输入规则,每行一个",
|
||||
"save": "保存规则"
|
||||
},
|
||||
"scheduledScans": {
|
||||
"title": "定时扫描",
|
||||
"description": "为该目标配置自动执行的扫描任务",
|
||||
"create": "新建定时扫描",
|
||||
"empty": "暂无定时扫描任务",
|
||||
"emptyHint": "点击上方按钮创建定时扫描任务",
|
||||
"enabled": "已启用",
|
||||
"disabled": "已禁用",
|
||||
"nextRun": "下次执行",
|
||||
"runCount": "执行次数",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"cronDaily": "每天 {time}",
|
||||
"cronWeekly": "每周{day} {time}",
|
||||
"cronMonthly": "每月{day}日 {time}",
|
||||
"weekdays": {
|
||||
"sun": "日",
|
||||
"mon": "一",
|
||||
"tue": "二",
|
||||
"wed": "三",
|
||||
"thu": "四",
|
||||
"fri": "五",
|
||||
"sat": "六"
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "确认删除",
|
||||
"description": "确定要删除定时扫描任务「{name}」吗?此操作无法撤销。",
|
||||
"cancel": "取消",
|
||||
"confirm": "删除"
|
||||
}
|
||||
}
|
||||
},
|
||||
"overview": {
|
||||
"loadError": "加载目标数据失败",
|
||||
"createdAt": "创建时间",
|
||||
"lastScanned": "最后扫描",
|
||||
"assetsTitle": "资产统计",
|
||||
"vulnerabilitiesTitle": "漏洞统计",
|
||||
"scanHistoryTitle": "扫描历史",
|
||||
"recentScans": "最近扫描",
|
||||
"noScans": "暂无扫描记录",
|
||||
"viewAll": "查看全部",
|
||||
"cards": {
|
||||
"websites": "网站",
|
||||
"subdomains": "子域名",
|
||||
"ips": "IP 地址",
|
||||
"urls": "URL",
|
||||
"directories": "目录",
|
||||
"vulnerabilities": "漏洞总数"
|
||||
},
|
||||
"severity": {
|
||||
"critical": "严重",
|
||||
"high": "高危",
|
||||
"medium": "中危",
|
||||
"low": "低危"
|
||||
},
|
||||
"scanStatus": {
|
||||
"completed": "已完成",
|
||||
"running": "运行中",
|
||||
"failed": "失败",
|
||||
"cancelled": "已取消",
|
||||
"initiated": "等待中"
|
||||
},
|
||||
"scheduledScans": {
|
||||
"title": "定时扫描",
|
||||
"manage": "管理",
|
||||
"empty": "暂无定时扫描任务",
|
||||
"createFirst": "创建第一个定时扫描",
|
||||
"configured": "已配置",
|
||||
"enabled": "已启用",
|
||||
"nextRun": "下次执行",
|
||||
"today": "今天",
|
||||
"tomorrow": "明天",
|
||||
"more": "+{count} 更多"
|
||||
}
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
|
||||
@@ -10,12 +10,26 @@ import { USE_MOCK, mockDelay, getMockScheduledScans, getMockScheduledScanById }
|
||||
/**
|
||||
* Get scheduled scan list
|
||||
*/
|
||||
export async function getScheduledScans(params?: { page?: number; pageSize?: number; search?: string }): Promise<GetScheduledScansResponse> {
|
||||
export async function getScheduledScans(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
search?: string
|
||||
targetId?: number
|
||||
organizationId?: number
|
||||
}): Promise<GetScheduledScansResponse> {
|
||||
if (USE_MOCK) {
|
||||
await mockDelay()
|
||||
return getMockScheduledScans(params)
|
||||
}
|
||||
const res = await api.get<GetScheduledScansResponse>('/scheduled-scans/', { params })
|
||||
// Convert camelCase to snake_case for query params (djangorestframework-camel-case doesn't convert query params)
|
||||
const apiParams: Record<string, unknown> = {}
|
||||
if (params?.page) apiParams.page = params.page
|
||||
if (params?.pageSize) apiParams.pageSize = params.pageSize
|
||||
if (params?.search) apiParams.search = params.search
|
||||
if (params?.targetId) apiParams.target_id = params.targetId
|
||||
if (params?.organizationId) apiParams.organization_id = params.organizationId
|
||||
|
||||
const res = await api.get<GetScheduledScansResponse>('/scheduled-scans/', { params: apiParams })
|
||||
return res.data
|
||||
}
|
||||
|
||||
|
||||
@@ -159,3 +159,22 @@ export async function getTargetEndpoints(
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get target's blacklist rules
|
||||
*/
|
||||
export async function getTargetBlacklist(id: number): Promise<{ patterns: string[] }> {
|
||||
const response = await api.get<{ patterns: string[] }>(`/targets/${id}/blacklist/`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update target's blacklist rules (full replace)
|
||||
*/
|
||||
export async function updateTargetBlacklist(
|
||||
id: number,
|
||||
patterns: string[]
|
||||
): Promise<{ count: number }> {
|
||||
const response = await api.put<{ count: number }>(`/targets/${id}/blacklist/`, { patterns })
|
||||
return response.data
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ export interface GetScansParams {
|
||||
pageSize?: number
|
||||
status?: ScanStatus
|
||||
search?: string
|
||||
target?: number // Filter by target ID
|
||||
}
|
||||
|
||||
export interface GetScansResponse {
|
||||
|
||||
Reference in New Issue
Block a user