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:
yyhuni
2026-01-06 10:42:38 +08:00
parent 4c1282e9bb
commit 9b63203b5a
35 changed files with 1424 additions and 367 deletions

View File

@@ -254,6 +254,7 @@ sudo ./uninstall.sh
## 📧 联系
- 微信公众号: **塔罗安全学苑**
- 微信群去公众号底下的菜单,有个交流群,点击就可以看到了,链接过期可以私信我拉你
<img src="docs/wechat-qrcode.png" alt="微信公众号" width="200">

View 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
import django.contrib.postgres.fields
import django.contrib.postgres.indexes

View File

@@ -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;
"""
),
]

View 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')],
},
),
]

View 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

View 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
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'),
),
]

View File

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

View File

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

View 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

View File

@@ -51,6 +51,7 @@ INSTALLED_APPS = [
'django.contrib.staticfiles',
# 第三方应用
'rest_framework',
'django_filters', # DRF 过滤器支持
'drf_yasg',
'corsheaders',
'channels', # WebSocket 支持

View File

@@ -11,6 +11,9 @@ setuptools==75.6.0
# CORS 支持
django-cors-headers==4.3.1
# 过滤器支持
django-filter==24.3
# 环境变量管理
python-dotenv==1.0.1

View File

@@ -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 "数据库迁移完成"
}

View File

@@ -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 " ✓ 默认指纹库已就绪"

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -66,6 +66,7 @@ export interface GetScansParams {
pageSize?: number
status?: ScanStatus
search?: string
target?: number // Filter by target ID
}
export interface GetScansResponse {