From 29948dc58002d33e3c08adc54e7e6fda30dcc096 Mon Sep 17 00:00:00 2001 From: intSheep Date: Fri, 13 Mar 2026 17:38:01 +0800 Subject: [PATCH 01/71] feat: add scan observability workspace ui --- src/App/IRifyLayout.tsx | 7 + src/App/routers/irify-routers.tsx | 5 + src/apis/NodeManageApi/index.ts | 31 + src/apis/NodeManageApi/type.ts | 78 ++ .../IRifyScanObservabilityPage.scss | 624 ++++++++++++ .../NodeManage/IRifyScanObservabilityPage.tsx | 910 ++++++++++++++++++ vite.config.ts | 16 +- 7 files changed, 1670 insertions(+), 1 deletion(-) create mode 100644 src/pages/NodeManage/IRifyScanObservabilityPage.scss create mode 100644 src/pages/NodeManage/IRifyScanObservabilityPage.tsx diff --git a/src/App/IRifyLayout.tsx b/src/App/IRifyLayout.tsx index 2e53f14c..0bf84233 100644 --- a/src/App/IRifyLayout.tsx +++ b/src/App/IRifyLayout.tsx @@ -99,6 +99,11 @@ const collapsibleNavItems = [ label: '节点管理', path: '/node-config/manage', }, + { + key: '/node-config/observability', + label: '扫描观测', + path: '/node-config/observability', + }, ], }, { @@ -202,6 +207,8 @@ const IRifyLayout: React.FC = () => { return ['节点配置', '节点安装']; if (path.startsWith('/node-config/manage')) return ['节点配置', '节点管理']; + if (path.startsWith('/node-config/observability')) + return ['节点配置', '扫描观测']; if (path.startsWith('/system-management/userinfo')) return ['系统管理', '用户管理']; diff --git a/src/App/routers/irify-routers.tsx b/src/App/routers/irify-routers.tsx index f1747b6d..4345f0c4 100644 --- a/src/App/routers/irify-routers.tsx +++ b/src/App/routers/irify-routers.tsx @@ -16,6 +16,7 @@ import { RuleEditor } from '@/pages/RuleManagement/RuleEditor'; import SSARiskAudit from '@/pages/SSARiskAudit'; import TaskList from '@/pages/SSAScanTask/TaskList'; import IRifyNodeManagePage from '@/pages/NodeManage/IRifyNodeManagePage'; +import IRifyScanObservabilityPage from '@/pages/NodeManage/IRifyScanObservabilityPage'; import IRifySystemManagementPage from '@/pages/SystemManagement/IRifySystemManagementPage'; import ReportManage from '@/pages/ReportManage'; import CompileArtifactsPage from '@/pages/CompileArtifacts/CompileArtifactsPage'; @@ -123,6 +124,10 @@ const irifyRouters: RouteObject[] = [ path: 'manage', element: , }, + { + path: 'observability', + element: , + }, ], }, { diff --git a/src/apis/NodeManageApi/index.ts b/src/apis/NodeManageApi/index.ts index 856c595c..eb1bc8dd 100644 --- a/src/apis/NodeManageApi/index.ts +++ b/src/apis/NodeManageApi/index.ts @@ -3,6 +3,7 @@ import type { ResponseData, TableResponseData } from '@/utils/commonTypes'; import type { PostHostAliveDetectionRunRequest, QueryPalmNodeParams, + ScannerObservabilityOverview, TPostNodesDownloadDataRunRequest, } from './type'; import type { Palm } from '@/gen/schema'; @@ -53,10 +54,40 @@ const postHostAliveDetectionRun = (data: { ResponseData> >('/task/start/host-alive-detection/run', data); +const getScannerObservabilityOverview = (params?: { + task_limit?: number; +}): Promise> => + axios.get>( + '/ssa/observability/scanner/overview', + { + params, + }, + ); + +const exportScannerObservabilityDiagnostics = (params?: { + task_limit?: number; + log_limit?: number; + node_id?: string; + task_id?: string; +}): Promise> => + axios.get('/ssa/observability/scanner/diagnostics/export', { + params, + responseType: 'blob', + transformResponse: [ + (data) => ({ + data, + code: 200, + msg: '', + }), + ], + }); + export { getNodeManage, postUpdateLocation, postNodesDownloadDataRun, deleteNodeManage, postHostAliveDetectionRun, + getScannerObservabilityOverview, + exportScannerObservabilityDiagnostics, }; diff --git a/src/apis/NodeManageApi/type.ts b/src/apis/NodeManageApi/type.ts index 9b2d43cc..28f1882a 100644 --- a/src/apis/NodeManageApi/type.ts +++ b/src/apis/NodeManageApi/type.ts @@ -29,9 +29,87 @@ interface PostHostAliveDetectionRunRequest { result: Array; } +interface ScannerObservabilityRunningTask { + node_id: string; + task_id: string; + root_task_id?: string; + sub_task_id?: string; + runtime_id?: string; + type: string; + status: string; + wait_ms: number; + start_timestamp: number; + running_timestamp: number; + ddl_timestamp: number; + elapsed_ms: number; +} + +interface ScannerObservabilityNode { + node_id: string; + nickname: string; + location: string; + external_ip: string; + main_addr: string; + last_seen_at: number; + online: boolean; + cpu_percent: number; + memory_percent: number; + network_upload: number; + network_download: number; + active_count: number; + queue_count: number; + capacity: number; + recent_avg_wait_ms: number; + recent_avg_exec_ms: number; + recent_completed_count: number; + rpc_error?: string; + running_tasks: ScannerObservabilityRunningTask[]; +} + +interface ScannerObservabilityRecentTask { + task_id: string; + project_name: string; + scan_batch: number; + execute_node: string; + status: string; + phase: string; + progress: number; + language: string; + source_origin: string; + error_message?: string; + created_at: number; + started_at?: number; + finished_at?: number; +} + +interface ScannerObservabilitySummary { + total_nodes: number; + online_nodes: number; + offline_nodes: number; + total_capacity: number; + total_active: number; + total_queued: number; + recent_avg_wait_ms: number; + recent_avg_exec_ms: number; + recent_completed_count: number; +} + +interface ScannerObservabilityOverview { + generated_at: number; + summary: ScannerObservabilitySummary; + nodes: ScannerObservabilityNode[]; + running_tasks: ScannerObservabilityRunningTask[]; + recent_tasks: ScannerObservabilityRecentTask[]; +} + export type { QueryPalmNodeParams, TPostNodesDownloadDataRunRequest, NetworkPingTableProp, PostHostAliveDetectionRunRequest, + ScannerObservabilityRunningTask, + ScannerObservabilityNode, + ScannerObservabilityRecentTask, + ScannerObservabilitySummary, + ScannerObservabilityOverview, }; diff --git a/src/pages/NodeManage/IRifyScanObservabilityPage.scss b/src/pages/NodeManage/IRifyScanObservabilityPage.scss new file mode 100644 index 00000000..ffb6f8c0 --- /dev/null +++ b/src/pages/NodeManage/IRifyScanObservabilityPage.scss @@ -0,0 +1,624 @@ +.irify-scan-observability-page { + --obs-border: rgba(15, 23, 42, 0.08); + --obs-text: #0f172a; + --obs-subtle: #64748b; + --obs-surface: rgba(255, 255, 255, 0.82); + --obs-shadow: 0 28px 60px rgba(15, 23, 42, 0.08); + display: flex; + flex-direction: column; + gap: 20px; + min-height: calc(100vh - 140px); + padding: 8px 0 24px; + color: var(--obs-text); + + .ant-card { + border: 1px solid var(--obs-border); + box-shadow: var(--obs-shadow); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(248, 250, 252, 0.96) 100%); + backdrop-filter: blur(12px); + } + + .observability-hero { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 18px; + padding: 28px 32px; + overflow: hidden; + border-radius: 24px; + border: 1px solid rgba(148, 163, 184, 0.2); + background: + radial-gradient(circle at top left, rgba(251, 191, 36, 0.22), transparent 36%), + radial-gradient(circle at top right, rgba(14, 165, 233, 0.18), transparent 34%), + linear-gradient(135deg, #fff8ee 0%, #f8fbff 48%, #f5f7ff 100%); + box-shadow: + 0 24px 50px rgba(15, 23, 42, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.6); + + &::after { + content: ''; + position: absolute; + inset: auto -120px -110px auto; + width: 260px; + height: 260px; + border-radius: 50%; + background: rgba(14, 165, 233, 0.12); + filter: blur(12px); + } + } + + .hero-copy { + position: relative; + z-index: 1; + + h1 { + margin: 8px 0 10px; + color: #0f172a; + font-size: 34px; + line-height: 1.1; + font-weight: 700; + letter-spacing: -0.03em; + } + + p { + max-width: 760px; + margin: 0; + color: #475569; + font-size: 15px; + line-height: 1.75; + } + } + + .hero-kicker { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-radius: 999px; + background: rgba(15, 23, 42, 0.06); + color: #0f172a; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + .hero-actions { + position: relative; + z-index: 1; + display: flex; + align-items: flex-start; + justify-content: flex-end; + gap: 10px; + flex-wrap: wrap; + } + + .fallback-alert { + margin-bottom: 2px; + border-radius: 18px; + + .ant-alert-message { + font-weight: 600; + } + } + + .summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; + } + + .summary-card { + position: relative; + overflow: hidden; + min-height: 156px; + border-radius: 22px; + + .ant-card-body { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 12px; + padding: 22px; + } + + &::after { + content: ''; + position: absolute; + top: -48px; + right: -38px; + width: 130px; + height: 130px; + border-radius: 50%; + opacity: 0.16; + } + } + + .summary-icon { + display: inline-flex; + width: 42px; + height: 42px; + align-items: center; + justify-content: center; + border-radius: 14px; + background: rgba(255, 255, 255, 0.72); + color: #0f172a; + font-size: 18px; + } + + .summary-value { + font-size: 34px; + font-weight: 700; + line-height: 1; + letter-spacing: -0.04em; + } + + .summary-label { + color: #334155; + font-size: 14px; + font-weight: 600; + } + + .summary-meta { + color: var(--obs-subtle); + font-size: 12px; + line-height: 1.6; + } + + .accent-fire { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(255, 248, 240, 0.98)), + #fff; + + &::after { + background: #fb923c; + } + } + + .accent-cluster { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(240, 249, 255, 0.98)), + #fff; + + &::after { + background: #38bdf8; + } + } + + .accent-clock { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(241, 245, 249, 0.98)), + #fff; + + &::after { + background: #94a3b8; + } + } + + .accent-chart { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(239, 246, 255, 0.98)), + #fff; + + &::after { + background: #2563eb; + } + } + + .node-card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 16px; + } + + .section-shell { + display: flex; + flex-direction: column; + gap: 14px; + } + + .section-heading { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-end; + + h2 { + margin: 6px 0 8px; + font-size: 24px; + font-weight: 700; + letter-spacing: -0.03em; + } + + p { + margin: 0; + max-width: 760px; + color: var(--obs-subtle); + font-size: 13px; + line-height: 1.7; + } + } + + .section-heading-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + justify-content: flex-end; + } + + .section-kicker { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 5px 10px; + border-radius: 999px; + background: rgba(14, 165, 233, 0.08); + color: #0369a1; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + .node-ob-card { + padding: 18px; + text-align: left; + border: 1px solid var(--obs-border); + border-radius: 22px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.92) 0%, rgba(248, 250, 252, 0.94) 100%); + box-shadow: 0 18px 44px rgba(15, 23, 42, 0.07); + transition: + transform 0.18s ease, + border-color 0.18s ease, + box-shadow 0.18s ease; + cursor: pointer; + + &:hover { + transform: translateY(-2px); + border-color: rgba(14, 165, 233, 0.26); + box-shadow: 0 26px 48px rgba(14, 165, 233, 0.12); + } + + &.is-selected { + border-color: rgba(2, 132, 199, 0.45); + box-shadow: + 0 28px 50px rgba(14, 165, 233, 0.16), + inset 0 0 0 1px rgba(125, 211, 252, 0.3); + background: + radial-gradient(circle at top right, rgba(14, 165, 233, 0.12), transparent 34%), + linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(240, 249, 255, 0.96) 100%); + } + } + + .node-card-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + margin-bottom: 16px; + } + + .node-card-title { + font-size: 17px; + font-weight: 700; + line-height: 1.3; + color: #0f172a; + } + + .node-card-subtitle { + margin-top: 4px; + color: var(--obs-subtle); + font-size: 12px; + line-height: 1.5; + word-break: break-all; + } + + .node-metrics { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 16px; + } + + .metric-pill { + padding: 10px 12px; + border-radius: 16px; + background: rgba(15, 23, 42, 0.045); + display: flex; + flex-direction: column; + gap: 4px; + + span { + color: var(--obs-subtle); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + } + + strong { + color: #0f172a; + font-size: 16px; + font-weight: 700; + } + } + + .load-rows { + display: flex; + flex-direction: column; + gap: 10px; + } + + .load-row { + display: grid; + grid-template-columns: 34px minmax(0, 1fr) 48px; + align-items: center; + gap: 8px; + color: var(--obs-subtle); + font-size: 12px; + } + + .load-track { + height: 7px; + border-radius: 999px; + background: rgba(148, 163, 184, 0.2); + overflow: hidden; + } + + .load-fill { + height: 100%; + border-radius: 999px; + + &.cpu { + background: linear-gradient(90deg, #38bdf8 0%, #2563eb 100%); + } + + &.mem { + background: linear-gradient(90deg, #fbbf24 0%, #f97316 100%); + } + } + + .node-card-foot { + display: flex; + justify-content: space-between; + gap: 12px; + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid rgba(148, 163, 184, 0.14); + color: var(--obs-subtle); + font-size: 12px; + } + + .detail-layout { + display: grid; + grid-template-columns: minmax(360px, 420px) minmax(0, 1fr); + align-items: start; + gap: 16px; + } + + .detail-card { + border-radius: 24px; + + .ant-card-body { + padding: 22px; + } + } + + .detail-card-head { + display: flex; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; + + h2 { + margin: 0; + font-size: 22px; + font-weight: 700; + letter-spacing: -0.03em; + } + + p { + margin: 6px 0 0; + color: var(--obs-subtle); + font-size: 13px; + } + + .ant-alert { + max-width: 340px; + } + } + + .detail-metric-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 18px; + } + + .detail-metric { + padding: 14px; + border-radius: 18px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.78), rgba(241, 245, 249, 0.84)); + border: 1px solid rgba(148, 163, 184, 0.12); + display: flex; + flex-direction: column; + gap: 8px; + + span { + color: var(--obs-subtle); + font-size: 12px; + } + + strong { + color: #0f172a; + font-size: 24px; + line-height: 1; + letter-spacing: -0.04em; + } + } + + .history-section { + padding: 18px; + border-radius: 20px; + background: + linear-gradient(180deg, rgba(248, 250, 252, 0.94), rgba(255, 255, 255, 0.96)); + border: 1px solid rgba(148, 163, 184, 0.12); + } + + .history-title { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + margin-bottom: 16px; + + h3 { + margin: 0; + font-size: 16px; + font-weight: 700; + } + + span { + color: var(--obs-subtle); + font-size: 12px; + } + } + + .history-chart-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; + } + + .history-series { + padding: 16px; + border-radius: 18px; + background: rgba(255, 255, 255, 0.88); + border: 1px solid rgba(148, 163, 184, 0.12); + } + + .series-header { + display: flex; + justify-content: space-between; + margin-bottom: 16px; + color: var(--obs-subtle); + font-size: 12px; + + strong { + color: #0f172a; + font-size: 16px; + } + } + + .series-bars { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + align-items: end; + gap: 8px; + height: 140px; + } + + .series-bar { + border-radius: 999px 999px 8px 8px; + + &.cpu { + background: linear-gradient(180deg, #38bdf8 0%, #2563eb 100%); + } + + &.mem { + background: linear-gradient(180deg, #fbbf24 0%, #f97316 100%); + } + } + + .task-main-cell { + display: flex; + flex-direction: column; + gap: 4px; + } + + .task-primary { + color: #0f172a; + font-size: 13px; + font-weight: 600; + word-break: break-all; + } + + .task-secondary { + color: var(--obs-subtle); + font-size: 12px; + word-break: break-all; + } + + .status-stack { + display: flex; + flex-direction: column; + gap: 4px; + color: var(--obs-subtle); + font-size: 12px; + } + + .recent-task-card .ant-table-row-expand-icon-cell { + vertical-align: top; + } + + .recent-task-expand { + display: grid; + gap: 6px; + color: #475569; + font-size: 13px; + line-height: 1.6; + } + + .ant-table-wrapper .ant-table-thead > tr > th { + padding-top: 12px; + padding-bottom: 12px; + background: #f8fafc; + color: #475569; + font-size: 12px; + font-weight: 600; + } + + .ant-table-wrapper .ant-table-tbody > tr > td { + padding-top: 12px; + padding-bottom: 12px; + color: #334155; + font-size: 13px; + vertical-align: top; + } + + @media (max-width: 1280px) { + .detail-layout { + grid-template-columns: 1fr; + } + } + + @media (max-width: 880px) { + .observability-hero { + grid-template-columns: 1fr; + padding: 24px; + } + + .hero-actions { + justify-content: flex-start; + } + + .section-heading { + flex-direction: column; + align-items: flex-start; + } + + .section-heading-actions { + justify-content: flex-start; + } + + .summary-grid, + .node-card-grid, + .detail-metric-grid, + .history-chart-grid { + grid-template-columns: 1fr; + } + } +} diff --git a/src/pages/NodeManage/IRifyScanObservabilityPage.tsx b/src/pages/NodeManage/IRifyScanObservabilityPage.tsx new file mode 100644 index 00000000..380c2c4a --- /dev/null +++ b/src/pages/NodeManage/IRifyScanObservabilityPage.tsx @@ -0,0 +1,910 @@ +import type { FC } from 'react'; +import { useEffect, useMemo } from 'react'; +import { + Alert, + Button, + Card, + Empty, + Spin, + Table, + Tag, + message, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { + AreaChartOutlined, + CloudDownloadOutlined, + ClusterOutlined, + FieldTimeOutlined, + FireOutlined, + ReloadOutlined, +} from '@ant-design/icons'; +import { useRequest, useSafeState } from 'ahooks'; +import dayjs from 'dayjs'; +import type { Palm } from '@/gen/schema'; +import type { ResponseData } from '@/utils/commonTypes'; +import axios from '@/utils/axios'; +import { saveFile } from '@/utils'; +import { + exportScannerObservabilityDiagnostics, + getScannerObservabilityOverview, +} from '@/apis/NodeManageApi'; +import type { + ScannerObservabilityRecentTask, + ScannerObservabilityRunningTask, +} from '@/apis/NodeManageApi/type'; +import './IRifyScanObservabilityPage.scss'; + +const TASK_LIMIT = 12; + +const unwrapOverviewPayload = ( + payload: ScannerObservabilityOverview | { data?: ScannerObservabilityOverview } | undefined, +): ScannerObservabilityOverview | undefined => { + if (!payload) return undefined; + if ('summary' in payload) return payload; + return payload.data; +}; + +const buildMockOverview = (): ScannerObservabilityOverview => ({ + generated_at: dayjs().unix(), + summary: { + total_nodes: 2, + online_nodes: 2, + offline_nodes: 0, + total_capacity: 3, + total_active: 2, + total_queued: 1, + recent_avg_wait_ms: 1840, + recent_avg_exec_ms: 93210, + recent_completed_count: 6, + }, + nodes: [ + { + node_id: 'scanner-node-a', + nickname: 'IRify Beijing A', + location: 'Beijing / IDC-A', + external_ip: '10.30.1.15', + main_addr: '10.30.1.15', + last_seen_at: dayjs().unix() - 8, + online: true, + cpu_percent: 64, + memory_percent: 57, + network_upload: 812, + network_download: 1324, + active_count: 1, + queue_count: 1, + capacity: 1, + recent_avg_wait_ms: 3012, + recent_avg_exec_ms: 128440, + recent_completed_count: 3, + running_tasks: [ + { + node_id: 'scanner-node-a', + task_id: 'script-task-a1', + root_task_id: 'ssa-task-20260313-a1', + sub_task_id: 'sub-a1', + runtime_id: 'runtime-a1', + type: 'script-task', + status: 'running', + wait_ms: 0, + start_timestamp: dayjs().unix() - 210, + running_timestamp: dayjs().unix() - 210, + ddl_timestamp: dayjs().unix() + 600, + elapsed_ms: 210000, + }, + { + node_id: 'scanner-node-a', + task_id: 'script-task-a2', + root_task_id: 'ssa-task-20260313-a2', + sub_task_id: 'sub-a2', + runtime_id: 'runtime-a2', + type: 'script-task', + status: 'queued', + wait_ms: 4120, + start_timestamp: dayjs().unix() - 55, + running_timestamp: 0, + ddl_timestamp: dayjs().unix() + 600, + elapsed_ms: 55000, + }, + ], + }, + { + node_id: 'scanner-node-b', + nickname: 'IRify Shanghai B', + location: 'Shanghai / IDC-B', + external_ip: '10.30.1.16', + main_addr: '10.30.1.16', + last_seen_at: dayjs().unix() - 5, + online: true, + cpu_percent: 38, + memory_percent: 44, + network_upload: 264, + network_download: 488, + active_count: 1, + queue_count: 0, + capacity: 2, + recent_avg_wait_ms: 420, + recent_avg_exec_ms: 57980, + recent_completed_count: 3, + running_tasks: [ + { + node_id: 'scanner-node-b', + task_id: 'script-task-b1', + root_task_id: 'ssa-task-20260313-b1', + sub_task_id: 'sub-b1', + runtime_id: 'runtime-b1', + type: 'script-task', + status: 'running', + wait_ms: 381, + start_timestamp: dayjs().unix() - 93, + running_timestamp: dayjs().unix() - 92, + ddl_timestamp: dayjs().unix() + 600, + elapsed_ms: 92000, + }, + ], + }, + ], + running_tasks: [ + { + node_id: 'scanner-node-a', + task_id: 'script-task-a1', + root_task_id: 'ssa-task-20260313-a1', + sub_task_id: 'sub-a1', + runtime_id: 'runtime-a1', + type: 'script-task', + status: 'running', + wait_ms: 0, + start_timestamp: dayjs().unix() - 210, + running_timestamp: dayjs().unix() - 210, + ddl_timestamp: dayjs().unix() + 600, + elapsed_ms: 210000, + }, + { + node_id: 'scanner-node-a', + task_id: 'script-task-a2', + root_task_id: 'ssa-task-20260313-a2', + sub_task_id: 'sub-a2', + runtime_id: 'runtime-a2', + type: 'script-task', + status: 'queued', + wait_ms: 4120, + start_timestamp: dayjs().unix() - 55, + running_timestamp: 0, + ddl_timestamp: dayjs().unix() + 600, + elapsed_ms: 55000, + }, + { + node_id: 'scanner-node-b', + task_id: 'script-task-b1', + root_task_id: 'ssa-task-20260313-b1', + sub_task_id: 'sub-b1', + runtime_id: 'runtime-b1', + type: 'script-task', + status: 'running', + wait_ms: 381, + start_timestamp: dayjs().unix() - 93, + running_timestamp: dayjs().unix() - 92, + ddl_timestamp: dayjs().unix() + 600, + elapsed_ms: 92000, + }, + ], + recent_tasks: [ + { + task_id: 'ssa-task-20260313-a1', + project_name: 'JavaSecLab', + scan_batch: 18, + execute_node: 'scanner-node-a', + status: 'running', + phase: 'scan', + progress: 58, + language: 'java', + source_origin: 'git', + created_at: dayjs().unix() - 240, + started_at: dayjs().unix() - 232, + }, + { + task_id: 'ssa-task-20260313-b1', + project_name: 'JavaSecLab', + scan_batch: 19, + execute_node: 'scanner-node-b', + status: 'running', + phase: 'scan', + progress: 31, + language: 'java', + source_origin: 'git', + created_at: dayjs().unix() - 100, + started_at: dayjs().unix() - 96, + }, + { + task_id: 'ssa-task-20260313-c1', + project_name: 'Sample Fixture', + scan_batch: 17, + execute_node: 'scanner-node-b', + status: 'completed', + phase: 'scan', + progress: 100, + language: 'java', + source_origin: 'local', + created_at: dayjs().unix() - 680, + started_at: dayjs().unix() - 675, + finished_at: dayjs().unix() - 530, + }, + ], +}); + +const buildMockStats = (node?: { cpu_percent?: number; memory_percent?: number }): Palm.HealthInfos => { + const cpuBase = percentValue(node?.cpu_percent ?? 54); + const memBase = percentValue(node?.memory_percent ?? 46); + const stats = Array.from({ length: 12 }).map((_, index) => ({ + timestamp: dayjs().unix() - (11 - index) * 15, + cpu_percent: Math.max(6, Math.min(100, cpuBase - 12 + index * 3)), + memory_percent: Math.max(8, Math.min(100, memBase - 8 + index * 2)), + network_upload: 220 + index * 40, + network_download: 360 + index * 55, + disk_write: 12 + index * 4, + disk_read: 18 + index * 3, + })); + return { + timestamp: stats[stats.length - 1]?.timestamp || dayjs().unix(), + node_id: 'mock-node', + disk_use_percent: 0.41, + stats, + }; +}; + +const percentValue = (value?: number | null) => { + const n = Number(value); + if (!Number.isFinite(n)) return 0; + return Math.max(0, Math.min(100, n)); +}; + +const percentText = (value?: number | null) => `${percentValue(value).toFixed(0)}%`; + +const kbText = (value?: number | null) => { + const n = Number(value); + if (!Number.isFinite(n)) return '-'; + if (n >= 1024) return `${(n / 1024).toFixed(1)} MB/s`; + return `${n.toFixed(1)} KB/s`; +}; + +const msText = (value?: number | null) => { + const n = Number(value); + if (!Number.isFinite(n)) return '-'; + if (n >= 1000) return `${(n / 1000).toFixed(1)}s`; + return `${Math.round(n)}ms`; +}; + +const formatTs = (ts?: number | null) => { + const n = Number(ts); + if (!Number.isFinite(n) || n <= 0) return '-'; + return dayjs.unix(n).format('MM-DD HH:mm:ss'); +}; + +const formatAgo = (ts?: number | null) => { + const n = Number(ts); + if (!Number.isFinite(n) || n <= 0) return '-'; + const diff = dayjs().unix() - n; + if (diff < 60) return `${diff}s 前`; + if (diff < 3600) return `${Math.floor(diff / 60)}m 前`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h 前`; + return `${Math.floor(diff / 86400)}d 前`; +}; + +const statusColor = (status: string) => { + switch (status) { + case 'completed': + return 'success'; + case 'failed': + return 'error'; + case 'running': + return 'processing'; + case 'pending': + return 'warning'; + case 'queued': + return 'gold'; + default: + return 'default'; + } +}; + +const IRifyScanObservabilityPage: FC = () => { + const [selectedNodeId, setSelectedNodeId] = useSafeState(''); + const [usingMockData, setUsingMockData] = useSafeState(false); + const [showOfflineNodes, setShowOfflineNodes] = useSafeState(false); + + const { + data: overview, + loading, + refresh, + } = useRequest( + async () => { + try { + const { data } = await getScannerObservabilityOverview({ + task_limit: TASK_LIMIT, + }); + const overviewData = unwrapOverviewPayload(data); + if (!overviewData) { + throw new Error('empty observability payload'); + } + setUsingMockData(false); + return overviewData; + } catch (error) { + console.warn('[ScanObservability] fallback to mock data', error); + setUsingMockData(true); + return buildMockOverview(); + } + }, + { + pollingInterval: 10000, + }, + ); + + useEffect(() => { + if (selectedNodeId) return; + const firstNode = overview?.nodes?.find((item) => item.online) || overview?.nodes?.[0]; + if (firstNode?.node_id) { + setSelectedNodeId(firstNode.node_id); + } + }, [overview, selectedNodeId, setSelectedNodeId]); + + const selectedNode = useMemo( + () => overview?.nodes?.find((item) => item.node_id === selectedNodeId), + [overview, selectedNodeId], + ); + + const sortedNodes = useMemo( + () => + [...(overview?.nodes || [])].sort((a, b) => { + if (a.online !== b.online) return a.online ? -1 : 1; + return (b.last_seen_at || 0) - (a.last_seen_at || 0); + }), + [overview?.nodes], + ); + + const offlineNodeCount = useMemo( + () => sortedNodes.filter((item) => !item.online).length, + [sortedNodes], + ); + + const visibleNodes = useMemo(() => { + if (showOfflineNodes) return sortedNodes; + const onlineNodes = sortedNodes.filter((item) => item.online); + if (onlineNodes.length) return onlineNodes; + return sortedNodes.slice(0, 6); + }, [showOfflineNodes, sortedNodes]); + + const { data: nodeStats, loading: nodeStatsLoading } = useRequest( + async () => { + try { + const { data } = await axios.get< + never, + ResponseData + >('/node/stats', { + params: { + node_id: selectedNodeId, + }, + }); + return data; + } catch (error) { + console.warn('[ScanObservability] fallback node stats', error); + return buildMockStats(selectedNode); + } + }, + { + ready: !!selectedNodeId, + refreshDeps: [selectedNodeId, selectedNode], + }, + ); + + const exportBundle = async (scope: 'global' | 'node') => { + try { + const { data } = await exportScannerObservabilityDiagnostics({ + task_limit: TASK_LIMIT, + log_limit: 200, + node_id: scope === 'node' ? selectedNodeId : undefined, + }); + const suffix = + scope === 'node' && selectedNodeId ? selectedNodeId : 'global'; + saveFile( + data, + `irify-scanner-observability-${suffix}-${dayjs().format('YYYYMMDD_HHmmss')}.zip`, + ); + message.success('诊断包已开始下载'); + } catch (error) { + console.error(error); + message.error('诊断包导出失败'); + } + }; + + const runningTaskColumns = useMemo>( + () => [ + { + title: '任务', + dataIndex: 'root_task_id', + key: 'root_task_id', + render: (_, record) => ( +
+
+ {record.root_task_id || record.task_id} +
+
+ {record.runtime_id || record.sub_task_id || record.task_id} +
+
+ ), + }, + { + title: '节点', + dataIndex: 'node_id', + key: 'node_id', + width: 140, + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 110, + render: (value) => {value}, + }, + { + title: '排队等待', + dataIndex: 'wait_ms', + key: 'wait_ms', + width: 120, + render: (value) => msText(value), + }, + { + title: '已运行', + dataIndex: 'elapsed_ms', + key: 'elapsed_ms', + width: 120, + render: (value) => msText(value), + }, + { + title: '开始时间', + dataIndex: 'start_timestamp', + key: 'start_timestamp', + width: 140, + render: (value) => formatTs(value), + }, + ], + [], + ); + + const recentTaskColumns = useMemo>( + () => [ + { + title: '项目 / 批次', + dataIndex: 'project_name', + key: 'project_name', + render: (_, record) => ( +
+
{record.project_name || '-'}
+
+ scan_batch #{record.scan_batch || 0} +
+
+ ), + }, + { + title: '执行节点', + dataIndex: 'execute_node', + key: 'execute_node', + width: 160, + render: (value) => value || '-', + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 120, + render: (_, record) => ( +
+ {record.status} + {record.phase || '-'} +
+ ), + }, + { + title: '进度', + dataIndex: 'progress', + key: 'progress', + width: 110, + render: (value) => `${Math.round(Number(value) || 0)}%`, + }, + { + title: '创建时间', + dataIndex: 'created_at', + key: 'created_at', + width: 140, + render: (value) => formatTs(value), + }, + ], + [], + ); + + const selectedNodeHistory = useMemo( + () => (nodeStats?.stats || []).slice(-12), + [nodeStats], + ); + + return ( +
+
+
+ Scanner Observability +

扫描观测中心

+

+ 面向并发扫描现场验证,直接展示节点真实执行数、排队长度、最近等待耗时和最近扫描分布。 +

+
+
+ + + +
+
+ + + {usingMockData ? ( + + ) : null} +
+ +
+ +
+
+ {overview?.summary?.total_active ?? 0} +
+
真实执行中任务
+
+ 队列 {overview?.summary?.total_queued ?? 0} 个 +
+
+ +
+ +
+
+ {overview?.summary?.online_nodes ?? 0}/ + {overview?.summary?.total_nodes ?? 0} +
+
在线扫描节点
+
+ 总并发容量 {overview?.summary?.total_capacity ?? 0} +
+
+ +
+ +
+
+ {msText(overview?.summary?.recent_avg_wait_ms)} +
+
最近平均排队等待
+
+ 平均执行 {msText(overview?.summary?.recent_avg_exec_ms)} +
+
+ +
+ +
+
+ {overview?.summary?.recent_completed_count ?? 0} +
+
近窗口完成任务数
+
+ 更新时间 {formatTs(overview?.generated_at)} +
+
+
+ + {overview?.nodes?.length ? ( +
+
+
+ Live Nodes +

节点实时负载

+

按节点查看真实执行数、排队长度和最近等待时长,便于现场快速定位过载节点。

+
+
+ + 已接入节点 {overview.nodes.length} + + {offlineNodeCount ? ( + + ) : null} +
+
+
+ {visibleNodes.map((node) => ( + + ))} +
+
+ ) : ( + + + + )} + +
+ +
+
+

{selectedNode?.nickname || selectedNode?.node_id || '选择节点'}

+

+ {selectedNode?.node_id || '-'} · 最近心跳{' '} + {formatTs(selectedNode?.last_seen_at)} +

+
+ {selectedNode?.rpc_error ? ( + + ) : null} +
+ {selectedNode ? ( + <> +
+
+ 网络上传 + {kbText(selectedNode.network_upload)} +
+
+ 网络下载 + {kbText(selectedNode.network_download)} +
+
+ 当前执行 + + {selectedNode.active_count}/ + {selectedNode.capacity || 0} + +
+
+ 当前排队 + {selectedNode.queue_count} +
+
+ +
+
+

节点健康趋势

+ + 最近 {selectedNodeHistory.length} 个心跳点 + +
+ + {selectedNodeHistory.length ? ( +
+
+
+ CPU + + {percentText( + selectedNodeHistory[ + selectedNodeHistory.length - 1 + ]?.cpu_percent, + )} + +
+
+ {selectedNodeHistory.map((point) => ( +
+ ))} +
+
+
+
+ MEM + + {percentText( + selectedNodeHistory[ + selectedNodeHistory.length - 1 + ]?.memory_percent, + )} + +
+
+ {selectedNodeHistory.map((point) => ( +
+ ))} +
+
+
+ ) : ( + + )} + +
+ + ) : ( + + )} + + + +
+
+

正在执行与排队

+

+ 展示所有节点的真实执行任务与队列中的脚本调用,不再混淆成单个 task_count。 +

+
+
+ + rowKey={(record) => + `${record.node_id}-${record.task_id}-${record.runtime_id || ''}` + } + columns={runningTaskColumns} + dataSource={overview?.running_tasks || []} + pagination={false} + locale={{ emptyText: '当前没有执行中或排队中的扫描任务' }} + /> +
+
+ + +
+
+

最近扫描任务

+

+ 用于现场判断任务是否均匀分配到节点,以及异常是否集中出现在某个批次。 +

+
+
+ + rowKey={(record) => record.task_id} + columns={recentTaskColumns} + dataSource={overview?.recent_tasks || []} + pagination={false} + expandable={{ + expandedRowRender: (record) => ( +
+
任务 ID: {record.task_id}
+
语言: {record.language || '-'}
+
来源: {record.source_origin || '-'}
+
错误: {record.error_message || '-'}
+
+ ), + }} + /> +
+
+
+ ); +}; + +export default IRifyScanObservabilityPage; diff --git a/vite.config.ts b/vite.config.ts index 0561693b..56882393 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,6 +7,20 @@ import path from 'path'; export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd()); const isIRify = mode.includes('irify'); + const basicAuthUser = env.VITE_BASIC_AUTH_USER || ''; + const basicAuthPassword = env.VITE_BASIC_AUTH_PASSWORD || ''; + const proxyTarget = (() => { + if (!env.VITE_BASE_URL) return env.VITE_BASE_URL; + if (!basicAuthUser || !basicAuthPassword) return env.VITE_BASE_URL; + try { + const url = new URL(env.VITE_BASE_URL); + url.username = basicAuthUser; + url.password = basicAuthPassword; + return url.toString(); + } catch { + return env.VITE_BASE_URL; + } + })(); return { base: './', @@ -47,7 +61,7 @@ export default defineConfig(({ mode }) => { // port: 8082, proxy: { '/api': { - target: env.VITE_BASE_URL, + target: proxyTarget, changeOrigin: true, rewrite: (path) => path, }, From 00b66e623cad42eb4894ebc20bfb43d4f779931c Mon Sep 17 00:00:00 2001 From: intSheep Date: Tue, 17 Mar 2026 10:47:27 +0800 Subject: [PATCH 02/71] ux(ssa): simplify project scan entry --- src/apis/SSAScanTaskApi/index.ts | 5 + src/apis/SSAScanTaskApi/type.ts | 4 + .../ProjecManagement/ProjectManagement.tsx | 137 +++++++----------- 3 files changed, 61 insertions(+), 85 deletions(-) diff --git a/src/apis/SSAScanTaskApi/index.ts b/src/apis/SSAScanTaskApi/index.ts index 1451d2f4..d8ac2d6e 100644 --- a/src/apis/SSAScanTaskApi/index.ts +++ b/src/apis/SSAScanTaskApi/index.ts @@ -2,6 +2,7 @@ import axios from '@/utils/axios'; import type { ResponseData } from '@/utils/commonTypes'; import type { TSSAScanRequest, + TSSAScanModeOverride, TSSATaskResponse, TSSATaskQueryParams, TSSATaskListResponse, @@ -13,10 +14,14 @@ import type { const scanSSAProject = ( projectId: number, data?: TSSAScanRequest, + opts?: { scan_mode?: TSSAScanModeOverride }, ): Promise> => axios.post>( `/ssa/project/${projectId}/scan`, data || {}, + opts?.scan_mode && opts.scan_mode !== 'auto' + ? { params: { scan_mode: opts.scan_mode } } + : undefined, ); // GET /ssa/task - 查询任务列表 diff --git a/src/apis/SSAScanTaskApi/type.ts b/src/apis/SSAScanTaskApi/type.ts index cd71363c..f79e944c 100644 --- a/src/apis/SSAScanTaskApi/type.ts +++ b/src/apis/SSAScanTaskApi/type.ts @@ -1,3 +1,5 @@ +export type TSSAScanModeOverride = 'auto' | 'memory' | 'ir-db'; + // SSA 扫描请求参数 export interface TSSAScanRequest { rule_groups?: string[]; @@ -44,6 +46,8 @@ export interface TSSATask { language?: string; source_origin?: string; scan_mode?: string; + compile_action?: string; + reuse_reason?: string; error_message?: string; started_at?: number; // Unix 时间戳(秒) finished_at?: number; // Unix 时间戳(秒) diff --git a/src/pages/ProjecManagement/ProjectManagement.tsx b/src/pages/ProjecManagement/ProjectManagement.tsx index de4ff3bb..f6053ae5 100644 --- a/src/pages/ProjecManagement/ProjectManagement.tsx +++ b/src/pages/ProjecManagement/ProjectManagement.tsx @@ -36,9 +36,10 @@ import type { ColumnsType } from 'antd/es/table'; import type { MenuProps } from 'antd'; import { getSSAProjects, deleteSSAProject } from '@/apis/SSAProjectApi'; import { scanSSAProject } from '@/apis/SSAScanTaskApi'; -import type { TSSAScanRequest } from '@/apis/SSAScanTaskApi/type'; -import { scanSSAIR } from '@/apis/SSAIRApi'; -import type { TSSAIRScanRequest } from '@/apis/SSAIRApi/type'; +import type { + TSSAScanModeOverride, + TSSAScanRequest, +} from '@/apis/SSAScanTaskApi/type'; import type { TSSAProject } from '@/apis/SSAProjectApi/type'; import { getRoutePath, RouteKey } from '@/utils/routeMap'; import ProjectDrawer from './ProjectDrawer'; @@ -72,6 +73,17 @@ const languageLabelMap: Record = { yak: 'Yak', }; +const resolveMemoryScanOverrideVisible = (): boolean => { + if (typeof window === 'undefined') { + return false; + } + const query = new URLSearchParams(window.location.search); + if (query.get('ssa_memory_scan') === '1') { + return true; + } + return window.localStorage.getItem('irify:ssa-memory-scan') === '1'; +}; + const ProjectManagement: React.FC = () => { const navigate = useNavigate(); const [loading, setLoading] = useState(false); @@ -102,6 +114,8 @@ const ProjectManagement: React.FC = () => { number | null >(null); const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [showMemoryScanOverride, setShowMemoryScanOverride] = + useState(resolveMemoryScanOverrideVisible); const selectedProjects = data.filter( (item) => item.id && selectedRowKeys.includes(item.id), @@ -355,9 +369,14 @@ const ProjectManagement: React.FC = () => { return new Date(timestamp * 1000).toLocaleString(); }; + useEffect(() => { + setShowMemoryScanOverride(resolveMemoryScanOverrideVisible()); + }, []); + const handleScan = async ( record: TSSAProject, auditCarryEnabled = true, + scanMode: TSSAScanModeOverride = 'auto', ) => { if (!record.id) return; try { @@ -397,7 +416,9 @@ const ProjectManagement: React.FC = () => { ); } - const res = await scanSSAProject(record.id, scanRequest); + const res = await scanSSAProject(record.id, scanRequest, { + scan_mode: scanMode, + }); const taskId = res.data?.task_id; message.success({ content: ( @@ -427,69 +448,6 @@ const ProjectManagement: React.FC = () => { } }; - const handleScanWithIRDB = async ( - record: TSSAProject, - auditCarryEnabled = true, - ) => { - if (!record.id) return; - - const msgKey = `ssa-ir-scan-${record.id}-${Date.now()}`; - try { - message.loading({ - content: '数据库扫描:正在创建任务...', - key: msgKey, - duration: 0, - }); - - const scanReq: TSSAIRScanRequest = { - prepare_ir: true, - incremental: true, - force_full: false, - snapshot_id: String(Date.now()), - audit_carry_enabled: auditCarryEnabled, - }; - const scanNode = record.config?.ScanNode; - if (scanNode?.mode === 'manual' && scanNode.node_id) { - scanReq.node_id = scanNode.node_id; - } - const scanRes = await scanSSAIR(record.id, scanReq); - const scanData = scanRes?.data || {}; - const taskId = scanData?.task_id; - - message.success({ - content: ( - - 数据库扫描任务已创建{taskId ? ` (#${taskId})` : ''} - ,编译与扫描进度可在任务历史中查看。 - - - ), - key: msgKey, - duration: 6, - }); - } catch (err: any) { - message.error({ - content: `数据库扫描失败: ${err?.msg || err?.message || '未知错误'}`, - key: msgKey, - duration: 6, - }); - } - }; - // 语言图标映射(官方品牌 SVG 图标) const languageIconMap: Record< string, @@ -770,6 +728,9 @@ const ProjectManagement: React.FC = () => { .split('/') .pop() ?.replace(/\.git$/, '') || '代码仓库'; + const hasSchedule = Boolean( + record.config?.ScanSchedule?.enabled, + ); return ( @@ -812,22 +773,11 @@ const ProjectManagement: React.FC = () => { marginTop: 8, }} > - 扫描将在后台执行。数据库扫描会自动复用/更新 - IR(黑盒)。 + 扫描将在后台执行。系统默认会优先复用/更新编译产物; + 当前项目若启用了调度,则仍会走兼容的调度链路。
- - + + {showMemoryScanOverride && ( + + )}
From 219c1de6ffae408dfb9e481aba0cd26d1f398c0d Mon Sep 17 00:00:00 2001 From: intSheep Date: Mon, 16 Mar 2026 15:44:48 +0800 Subject: [PATCH 03/71] feat: formalize irify report center --- src/App/routers/irify-routers.tsx | 4 +- src/apis/SSAReportRecordApi/index.ts | 57 ++ src/apis/SSAReportRecordApi/type.ts | 86 +++ src/apis/SSAReportRecordFileApi/index.ts | 56 ++ src/apis/SSAReportRecordFileApi/type.ts | 29 + .../IRifyReportManagePage.scss | 285 ++++++++ .../IRifyReportManagePage.tsx | 666 ++++++++++++++++++ src/pages/IRifyReportManage/index.ts | 3 + src/pages/SSAScanTask/TaskList.tsx | 133 +++- .../VulnerabilityList/VulnerabilityList.tsx | 159 +++-- src/utils/routeMap.ts | 4 +- 11 files changed, 1404 insertions(+), 78 deletions(-) create mode 100644 src/apis/SSAReportRecordApi/index.ts create mode 100644 src/apis/SSAReportRecordApi/type.ts create mode 100644 src/apis/SSAReportRecordFileApi/index.ts create mode 100644 src/apis/SSAReportRecordFileApi/type.ts create mode 100644 src/pages/IRifyReportManage/IRifyReportManagePage.scss create mode 100644 src/pages/IRifyReportManage/IRifyReportManagePage.tsx create mode 100644 src/pages/IRifyReportManage/index.ts diff --git a/src/App/routers/irify-routers.tsx b/src/App/routers/irify-routers.tsx index 4345f0c4..48eb95dd 100644 --- a/src/App/routers/irify-routers.tsx +++ b/src/App/routers/irify-routers.tsx @@ -18,7 +18,7 @@ import TaskList from '@/pages/SSAScanTask/TaskList'; import IRifyNodeManagePage from '@/pages/NodeManage/IRifyNodeManagePage'; import IRifyScanObservabilityPage from '@/pages/NodeManage/IRifyScanObservabilityPage'; import IRifySystemManagementPage from '@/pages/SystemManagement/IRifySystemManagementPage'; -import ReportManage from '@/pages/ReportManage'; +import IRifyReportManagePage from '@/pages/IRifyReportManage'; import CompileArtifactsPage from '@/pages/CompileArtifacts/CompileArtifactsPage'; import IRifyDashboard from '@/pages/IRifyDashboard'; @@ -111,7 +111,7 @@ const irifyRouters: RouteObject[] = [ }, { path: 'reports', - element: , + element: , }, { path: 'node-config', diff --git a/src/apis/SSAReportRecordApi/index.ts b/src/apis/SSAReportRecordApi/index.ts new file mode 100644 index 00000000..c5cef34a --- /dev/null +++ b/src/apis/SSAReportRecordApi/index.ts @@ -0,0 +1,57 @@ +import axios from '@/utils/axios'; +import type { ResponseData } from '@/utils/commonTypes'; +import type { + TSSAReportRecord, + TSSAReportRecordCreateRequest, + TSSAReportRecordDetail, + TSSAReportRecordListAPIResponse, + TSSAReportRecordListResponse, + TSSAReportRecordQueryParams, +} from './type'; + +const normalizeListResponse = ( + payload?: TSSAReportRecordListAPIResponse['data'], +): TSSAReportRecordListResponse => ({ + list: payload?.data || [], + pagemeta: { + page: payload?.paging?.pagemeta?.page || 1, + limit: payload?.paging?.pagemeta?.limit || 20, + total: payload?.paging?.pagemeta?.total || 0, + total_page: payload?.paging?.pagemeta?.total_page || 1, + }, +}); + +export const querySSAReportRecords = async ( + params?: TSSAReportRecordQueryParams, +): Promise> => { + const res = await axios.get( + '/ssa/report-records', + { + params, + }, + ); + return { + ...res, + data: normalizeListResponse(res.data), + }; +}; + +export const createSSAReportRecord = ( + data: TSSAReportRecordCreateRequest, +): Promise> => + axios.post>( + '/ssa/report-records', + data, + ); + +export const fetchSSAReportRecord = ( + id: number, +): Promise> => + axios.get>( + `/ssa/report-records/${id}`, + ); + +export const deleteSSAReportRecord = ( + id: number, +): Promise> => + axios.delete>(`/ssa/report-records/${id}`); diff --git a/src/apis/SSAReportRecordApi/type.ts b/src/apis/SSAReportRecordApi/type.ts new file mode 100644 index 00000000..c0531442 --- /dev/null +++ b/src/apis/SSAReportRecordApi/type.ts @@ -0,0 +1,86 @@ +import type { ResponseData } from '@/utils/commonTypes'; + +export interface TSSAReportRecord { + id: number; + title?: string; + hash?: string; + owner?: string; + from?: string; + report_type?: string; + scope_type?: string; + scope_name?: string; + project_name?: string; + program_name?: string; + task_id?: string; + task_count?: number; + scan_batch?: number; + risk_total?: number; + risk_critical?: number; + risk_high?: number; + risk_medium?: number; + risk_low?: number; + published_at?: number; + source_finished_at?: number; + created_at?: number; + updated_at?: number; +} + +export interface TSSAReportRecordDetail extends TSSAReportRecord { + json_raw?: string; +} + +export interface TSSAReportRecordQueryParams { + page?: number; + limit?: number; + order?: 'asc' | 'desc'; + order_by?: + | 'published_at' + | 'updated_at' + | 'created_at' + | 'risk_total' + | 'project_name'; + keyword?: string; + project_name?: string; + task_id?: string; + report_type?: string; + start?: number; + end?: number; +} + +export interface TSSAReportRecordListPayload { + data?: TSSAReportRecord[]; + paging?: { + pagemeta?: { + page: number; + limit: number; + total: number; + total_page: number; + }; + }; +} + +export interface TSSAReportRecordListResponse { + list: TSSAReportRecord[]; + pagemeta: { + page: number; + limit: number; + total: number; + total_page: number; + }; +} + +export interface TSSAReportRecordCreateRequest { + task_id?: string; + task_ids?: string; + ids?: string; + program_name?: string; + severity?: string; + risk_type?: string; + from_rule?: string; + latest_disposal_status?: string; + audited_state?: 'all' | 'audited' | 'unaudited'; + report_name?: string; +} + +export type TSSAReportRecordListAPIResponse = + ResponseData; diff --git a/src/apis/SSAReportRecordFileApi/index.ts b/src/apis/SSAReportRecordFileApi/index.ts new file mode 100644 index 00000000..f3020a34 --- /dev/null +++ b/src/apis/SSAReportRecordFileApi/index.ts @@ -0,0 +1,56 @@ +import axios from '@/utils/axios'; +import type { ResponseData } from '@/utils/commonTypes'; +import type { + TSSAReportRecordFile, + TSSAReportRecordFileCreateRequest, + TSSAReportRecordFileListPayload, + TSSAReportRecordFileListResponse, +} from './type'; + +export const querySSAReportRecordFiles = async ( + reportRecordId: number, +): Promise> => { + const res = await axios.get< + never, + ResponseData + >(`/ssa/report-records/${reportRecordId}/files`); + return { + ...res, + data: { + list: res.data?.data || [], + }, + }; +}; + +export const createSSAReportRecordFile = ( + reportRecordId: number, + data: TSSAReportRecordFileCreateRequest, +): Promise> => + axios.post>( + `/ssa/report-records/${reportRecordId}/files`, + data, + ); + +export const deleteSSAReportRecordFile = ( + fileId: number, +): Promise> => + axios.delete>( + `/ssa/report-record-files/${fileId}`, + ); + +export const downloadSSAReportRecordFile = ( + fileId: number, +): Promise> => + axios.get>( + `/ssa/report-record-files/${fileId}/download`, + { + responseType: 'blob', + transformResponse: [ + (data) => ({ + data, + code: 200, + msg: '', + }), + ], + }, + ); diff --git a/src/apis/SSAReportRecordFileApi/type.ts b/src/apis/SSAReportRecordFileApi/type.ts new file mode 100644 index 00000000..56045ae7 --- /dev/null +++ b/src/apis/SSAReportRecordFileApi/type.ts @@ -0,0 +1,29 @@ +export interface TSSAReportRecordFile { + id: number; + report_record_id: number; + format?: string; + file_name?: string; + object_key?: string; + bucket?: string; + content_type?: string; + size_bytes?: number; + sha256?: string; + status?: string; + created_by?: string; + generation_error?: string; + created_at?: number; + updated_at?: number; +} + +export interface TSSAReportRecordFileCreateRequest { + format: 'pdf' | 'docx' | 'html'; + overwrite?: boolean; +} + +export interface TSSAReportRecordFileListPayload { + data?: TSSAReportRecordFile[]; +} + +export interface TSSAReportRecordFileListResponse { + list: TSSAReportRecordFile[]; +} diff --git a/src/pages/IRifyReportManage/IRifyReportManagePage.scss b/src/pages/IRifyReportManage/IRifyReportManagePage.scss new file mode 100644 index 00000000..93d6eacd --- /dev/null +++ b/src/pages/IRifyReportManage/IRifyReportManagePage.scss @@ -0,0 +1,285 @@ +.irify-report-manage-page { + min-height: 100vh; + padding: 24px; + background-color: var(--irify-bg-base); + + .report-toolbar { + margin-bottom: 16px; + display: flex; + align-items: center; + justify-content: flex-end; + } + + .report-filter-card, + .report-table-card { + border: 1px solid var(--irify-border) !important; + border-radius: 4px !important; + background: var(--irify-bg-container) !important; + box-shadow: 0 1px 2px var(--irify-glass-shadow); + margin-bottom: 16px; + } + + .report-filter-row { + display: grid; + grid-template-columns: minmax(280px, 1.4fr) 180px 280px auto; + gap: 12px; + align-items: end; + } + + .report-filter-item { + margin-bottom: 0; + } + + .report-filter-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + padding-bottom: 1px; + } + + .report-table-card { + .ant-table { + font-size: 13px; + } + + .ant-table-thead > tr > th { + font-size: 13px; + font-weight: 600; + color: var(--irify-text-secondary); + background: transparent; + } + + .ant-table-cell { + vertical-align: top; + padding-top: 14px; + padding-bottom: 14px; + } + } + + .report-table-head { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 16px; + margin-bottom: 14px; + + h3 { + margin: 0; + font-size: 16px; + color: var(--irify-text); + } + + .report-table-meta { + font-size: 13px; + color: var(--irify-text-secondary); + } + } + + .report-scope-cell, + .report-risk-cell, + .report-time-cell, + .report-status-cell { + display: flex; + flex-direction: column; + gap: 6px; + } + + .report-scope-head { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + } + + .report-scope-title { + font-size: 15px; + font-weight: 600; + color: var(--irify-text); + } + + .report-scope-tag { + margin-inline-end: 0; + border-radius: 999px; + } + + .report-scope-sub { + font-size: 12px; + color: var(--irify-text-secondary); + word-break: break-all; + } + + .report-scope-meta { + display: flex; + flex-wrap: wrap; + gap: 8px 14px; + font-size: 12px; + color: var(--irify-text-secondary); + } + + .report-risk-total { + display: inline-flex; + align-self: flex-start; + padding: 4px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + + &.tone-critical { + color: #7a1fa2; + background: rgba(122, 31, 162, 0.08); + } + + &.tone-high { + color: #b42318; + background: rgba(180, 35, 24, 0.08); + } + + &.tone-medium { + color: #b54708; + background: rgba(181, 71, 8, 0.08); + } + + &.tone-clean { + color: #027a48; + background: rgba(2, 122, 72, 0.08); + } + } + + .report-risk-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .report-risk-hint { + font-size: 12px; + line-height: 1.6; + color: var(--irify-text-secondary); + } + + .report-time-primary { + font-weight: 600; + color: var(--irify-text); + } + + .report-time-secondary { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 12px; + color: var(--irify-text-secondary); + } + + .report-status-cell { + gap: 10px; + } + + .report-action-group { + display: flex; + justify-content: flex-start; + } + + .report-file-panel { + margin-bottom: 20px; + padding: 16px 18px; + border: 1px solid var(--irify-border); + border-radius: 16px; + background: rgba(15, 23, 42, 0.02); + } + + .report-file-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; + + h4 { + margin: 0; + font-size: 16px; + color: var(--irify-text); + } + } + + .report-file-list { + display: flex; + flex-direction: column; + gap: 10px; + } + + .report-file-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 14px; + border: 1px solid var(--irify-border); + border-radius: 12px; + background: var(--irify-bg-container); + } + + .report-file-name { + font-size: 14px; + font-weight: 600; + color: var(--irify-text); + word-break: break-all; + } + + .report-file-sub { + display: flex; + flex-wrap: wrap; + gap: 8px 14px; + margin-top: 6px; + font-size: 12px; + color: var(--irify-text-secondary); + } + + @media (max-width: 1280px) { + .report-filter-row { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .report-filter-actions { + justify-content: flex-start; + } + } + + @media (max-width: 960px) { + padding: 16px; + + .report-table-head { + flex-direction: column; + align-items: flex-start; + } + + .report-file-head, + .report-file-item { + flex-direction: column; + align-items: flex-start; + } + } + + @media (max-width: 720px) { + .report-filter-row { + grid-template-columns: 1fr; + } + + .report-filter-actions { + width: 100%; + flex-wrap: wrap; + } + + .report-toolbar { + justify-content: stretch; + + .ant-space { + width: 100%; + display: flex; + } + + .ant-btn { + flex: 1; + } + } + } +} diff --git a/src/pages/IRifyReportManage/IRifyReportManagePage.tsx b/src/pages/IRifyReportManage/IRifyReportManagePage.tsx new file mode 100644 index 00000000..49085de8 --- /dev/null +++ b/src/pages/IRifyReportManage/IRifyReportManagePage.tsx @@ -0,0 +1,666 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { + DeleteOutlined, + EyeOutlined, + FolderOpenOutlined, + ReloadOutlined, +} from '@ant-design/icons'; +import { + Button, + Card, + DatePicker, + Drawer, + Empty, + Form, + Input, + Modal, + Space, + Table, + Tag, + message, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { useRequest } from 'ahooks'; +import { useNavigate } from 'react-router-dom'; +import dayjs from 'dayjs'; +import type { Dayjs } from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import 'dayjs/locale/zh-cn'; + +import { + deleteSSAReportRecord, + fetchSSAReportRecord, + querySSAReportRecords, +} from '@/apis/SSAReportRecordApi'; +import { + createSSAReportRecordFile, + deleteSSAReportRecordFile, + downloadSSAReportRecordFile, + querySSAReportRecordFiles, +} from '@/apis/SSAReportRecordFileApi'; +import type { + TSSAReportRecord, + TSSAReportRecordDetail, +} from '@/apis/SSAReportRecordApi/type'; +import type { TSSAReportRecordFile } from '@/apis/SSAReportRecordFileApi/type'; +import ReportTemplate from '@/compoments/ReportTemplate'; +import { saveFile } from '@/utils'; +import { getRoutePath, RouteKey } from '@/utils/routeMap'; + +import './IRifyReportManagePage.scss'; + +dayjs.extend(relativeTime); +dayjs.locale('zh-cn'); + +const { RangePicker } = DatePicker; + +interface TFilterFormValues { + keyword?: string; + project_name?: string; + generated_at?: [Dayjs, Dayjs]; +} + +interface TAppliedFilters { + keyword?: string; + project_name?: string; + start?: number; + end?: number; +} + +interface TReportRecordItemJSON { + type: string; + content: string; +} + +const formatTimestamp = (value?: number) => { + if (!value || value <= 0) return '-'; + return dayjs.unix(value).format('YYYY-MM-DD HH:mm:ss'); +}; + +const formatRelativeTime = (value?: number) => { + if (!value || value <= 0) return '-'; + return dayjs.unix(value).fromNow(); +}; + +const getScanBatchText = (scanBatch?: number) => { + if (!scanBatch || scanBatch <= 0) return ''; + return `第${scanBatch}批`; +}; + +const buildScopeDisplayName = (record: TSSAReportRecord) => { + const projectName = (record.project_name || '').trim(); + const batchText = getScanBatchText(record.scan_batch); + if (projectName && batchText) { + return `${projectName} ${batchText}`; + } + if (projectName) { + return projectName; + } + return record.scope_name || record.title || '未命名报告'; +}; + +const getPreviewBlocks = (jsonRaw?: string) => { + if (!jsonRaw) return []; + try { + const parsed = JSON.parse(jsonRaw) as TReportRecordItemJSON[]; + if (!Array.isArray(parsed)) return []; + return parsed + .filter((item) => + ['markdown', 'json-table', 'search-json-table', 'raw'].includes( + item?.type, + ), + ) + .map((item) => ({ + type: item.type as + | 'markdown' + | 'json-table' + | 'search-json-table' + | 'raw', + data: item.content, + })); + } catch { + return []; + } +}; + +const IRifyReportManagePage: React.FC = () => { + const navigate = useNavigate(); + const [filterForm] = Form.useForm(); + const [page, setPage] = useState(1); + const [limit, setLimit] = useState(12); + const [filters, setFilters] = useState({}); + const [previewOpen, setPreviewOpen] = useState(false); + const [previewTitle, setPreviewTitle] = useState('报告预览'); + const [previewBlocks, setPreviewBlocks] = useState([]); + const [previewRecord, setPreviewRecord] = useState( + null, + ); + const [previewFiles, setPreviewFiles] = useState( + [], + ); + const [fileActionLoading, setFileActionLoading] = useState(''); + const previewRef = useRef(null); + + const { + data: reportResponse, + loading, + refresh, + } = useRequest( + async () => { + const res = await querySSAReportRecords({ + page, + limit, + order_by: 'published_at', + order: 'desc', + keyword: filters.keyword, + project_name: filters.project_name, + start: filters.start, + end: filters.end, + }); + return res.data; + }, + { + refreshDeps: [ + page, + limit, + filters.keyword, + filters.project_name, + filters.start, + filters.end, + ], + }, + ); + + const records = reportResponse?.list || []; + const total = reportResponse?.pagemeta?.total || 0; + + const handleFilterSubmit = useCallback((values: TFilterFormValues) => { + const [start, end] = values.generated_at || []; + setPage(1); + setFilters({ + keyword: values.keyword?.trim() || undefined, + project_name: values.project_name?.trim() || undefined, + start: start ? start.startOf('day').unix() : undefined, + end: end ? end.endOf('day').unix() : undefined, + }); + }, []); + + const handleFilterReset = useCallback(() => { + filterForm.resetFields(); + setPage(1); + setFilters({}); + }, [filterForm]); + + const handlePreview = useCallback(async (record: TSSAReportRecord) => { + try { + const [detailRes, filesRes] = await Promise.all([ + fetchSSAReportRecord(record.id), + querySSAReportRecordFiles(record.id), + ]); + const detail = detailRes.data as TSSAReportRecordDetail; + const blocks = getPreviewBlocks(detail.json_raw); + if (blocks.length === 0) { + message.warning('该报告暂无可预览内容'); + return; + } + setPreviewTitle(detail.title || record.title || '报告预览'); + setPreviewBlocks(blocks); + setPreviewRecord(record); + setPreviewFiles(filesRes.data?.list || []); + setPreviewOpen(true); + } catch { + message.error('获取报告详情失败'); + } + }, []); + + const handleDownloadFile = useCallback( + async (file: TSSAReportRecordFile) => { + if (!file.id) return; + try { + setFileActionLoading(`download-${file.id}`); + const res = await downloadSSAReportRecordFile(file.id); + if (!res.data) { + throw new Error('empty report file'); + } + saveFile( + res.data, + file.file_name || `report.${file.format || 'bin'}`, + ); + } catch { + message.error('下载文件失败'); + } finally { + setFileActionLoading(''); + } + }, + [], + ); + + const refreshPreviewFiles = useCallback(async (recordId: number) => { + const filesRes = await querySSAReportRecordFiles(recordId); + setPreviewFiles(filesRes.data?.list || []); + }, []); + + const handleCreateFile = useCallback( + async (format: 'pdf' | 'docx') => { + if (!previewRecord?.id) return; + try { + setFileActionLoading(`create-${format}`); + const created = await createSSAReportRecordFile( + previewRecord.id, + { + format, + overwrite: false, + }, + ); + if (created.data?.id) { + await refreshPreviewFiles(previewRecord.id); + await handleDownloadFile(created.data); + message.success(`${format.toUpperCase()} 文件已生成`); + } + } catch { + message.error(`${format.toUpperCase()} 文件生成失败`); + } finally { + setFileActionLoading(''); + } + }, + [handleDownloadFile, previewRecord, refreshPreviewFiles], + ); + + const handleDeleteFile = useCallback( + (file: TSSAReportRecordFile) => { + if (!file.id || !previewRecord?.id) return; + Modal.confirm({ + title: '删除导出文件', + content: `确定删除文件「${file.file_name || file.id}」吗?`, + okText: '删除', + cancelText: '取消', + okButtonProps: { danger: true }, + onOk: async () => { + try { + setFileActionLoading(`delete-${file.id}`); + await deleteSSAReportRecordFile(file.id); + await refreshPreviewFiles(previewRecord.id); + message.success('文件删除成功'); + } catch { + message.error('文件删除失败'); + } finally { + setFileActionLoading(''); + } + }, + }); + }, + [previewRecord, refreshPreviewFiles], + ); + + const handleDelete = useCallback( + (record: TSSAReportRecord) => { + Modal.confirm({ + title: '删除报告记录', + content: `确定删除报告「${record.title || record.scope_name || record.id}」吗?此操作不可恢复。`, + okText: '删除', + cancelText: '取消', + okButtonProps: { danger: true }, + onOk: async () => { + try { + await deleteSSAReportRecord(record.id); + message.success('删除成功'); + refresh(); + } catch { + message.error('删除失败'); + } + }, + }); + }, + [refresh], + ); + + const goToScans = useCallback( + (record?: TSSAReportRecord) => { + const base = getRoutePath(RouteKey.IRIFY_SCANS); + navigate( + record?.task_id ? `${base}?task_id=${record.task_id}` : base, + ); + }, + [navigate], + ); + + const columns = useMemo>( + () => [ + { + title: '报告 / 范围', + dataIndex: 'title', + key: 'title', + width: 340, + render: (_, record) => ( +
+
+ + {record.title || '未命名报告'} + + + {record.report_type || 'ssa-scan'} + +
+
+ {buildScopeDisplayName(record)} +
+
+ Owner {record.owner || '-'} + + 任务范围{' '} + {record.task_count && record.task_count > 1 + ? `${record.task_count} 项` + : record.task_id || '-'} + +
+
+ ), + }, + { + title: '安全风险', + dataIndex: 'risk_total', + key: 'risks', + width: 300, + render: (_, record) => { + const totalRiskCount = Number(record.risk_total || 0); + const toneClass = + Number(record.risk_critical || 0) > 0 + ? 'tone-critical' + : Number(record.risk_high || 0) > 0 + ? 'tone-high' + : totalRiskCount > 0 + ? 'tone-medium' + : 'tone-clean'; + return ( +
+
+ {totalRiskCount > 0 + ? `共沉淀 ${totalRiskCount} 项风险` + : '当前报告未包含风险'} +
+
+ + 严重 {record.risk_critical || 0} + + + 高危 {record.risk_high || 0} + + + 中危 {record.risk_medium || 0} + + + 低危 {record.risk_low || 0} + +
+
+ ); + }, + }, + { + title: '时间', + dataIndex: 'published_at', + key: 'time', + width: 240, + render: (_, record) => ( +
+
+ 记录生成:{formatTimestamp(record.published_at)} +
+
+ + {formatRelativeTime(record.published_at)} + + + 源结果完成: + {formatTimestamp(record.source_finished_at)} + +
+
+ ), + }, + { + title: '操作', + dataIndex: 'id', + key: 'actions', + fixed: 'right', + width: 300, + render: (_, record) => ( + + + + + + ), + }, + ], + [goToScans, handleDelete, handlePreview], + ); + + return ( +
+
+ + + + +
+ + + + form={filterForm} + layout="vertical" + onFinish={handleFilterSubmit} + > +
+ + + + + + + + + +
+ + +
+
+ +
+ + +
+
+

报告记录列表

+
+
+ 当前页 {records.length} 项 / 共 {total} 项 +
+
+ + + rowKey="id" + loading={loading} + dataSource={records} + columns={columns} + scroll={{ x: 1160 }} + pagination={{ + current: page, + pageSize: limit, + total, + showSizeChanger: true, + pageSizeOptions: [12, 24, 48], + onChange: (nextPage, nextPageSize) => { + setPage(nextPage); + setLimit(nextPageSize); + }, + showTotal: (all) => `共 ${all} 项`, + }} + locale={{ + emptyText: ( + + + + ), + }} + /> +
+ + setPreviewOpen(false)} + > +
+
+

导出文件

+ + + + +
+ {previewFiles.length > 0 ? ( +
+ {previewFiles.map((file) => ( +
+
+
+ {file.file_name || + `file-${file.id}`} +
+
+ + {String( + file.format || '', + ).toUpperCase()} + + + {file.size_bytes + ? `${(file.size_bytes / 1024).toFixed(1)} KB` + : '-'} + + + {formatTimestamp( + file.created_at, + )} + +
+
+ + + + +
+ ))} +
+ ) : ( + + )} +
+
+ +
+
+
+ ); +}; + +export default IRifyReportManagePage; diff --git a/src/pages/IRifyReportManage/index.ts b/src/pages/IRifyReportManage/index.ts new file mode 100644 index 00000000..4e91fa6e --- /dev/null +++ b/src/pages/IRifyReportManage/index.ts @@ -0,0 +1,3 @@ +import IRifyReportManagePage from './IRifyReportManagePage'; + +export default IRifyReportManagePage; diff --git a/src/pages/SSAScanTask/TaskList.tsx b/src/pages/SSAScanTask/TaskList.tsx index d325f2e3..891cc793 100644 --- a/src/pages/SSAScanTask/TaskList.tsx +++ b/src/pages/SSAScanTask/TaskList.tsx @@ -51,6 +51,11 @@ import { exportSSARiskReportPDF, getSSARiskFilterOptions, } from '@/apis/SSARiskApi'; +import { createSSAReportRecord } from '@/apis/SSAReportRecordApi'; +import { + createSSAReportRecordFile, + downloadSSAReportRecordFile, +} from '@/apis/SSAReportRecordFileApi'; import type { TSSATask, TSSATaskQueryParams, @@ -629,6 +634,19 @@ const TaskList: React.FC = () => { message.warning('导出范围为空,无法导出'); return; } + const params: TSSARiskExportParams = { + report_name: values.report_name, + severity: values.severity?.join(',') || undefined, + risk_type: values.risk_type?.join(',') || undefined, + audited_state: values.audited_state || 'all', + latest_disposal_status: + values.latest_disposal_status?.join(',') || undefined, + }; + if (exportTaskIDs.length === 1) { + params.task_id = exportTaskIDs[0]; + } else { + params.task_ids = exportTaskIDs.join(','); + } try { setExportSubmitting(true); openExportProgress( @@ -637,28 +655,43 @@ const TaskList: React.FC = () => { ? '正在请求 Word 报告数据...' : '正在请求 PDF 报告数据...', ); - const params: TSSARiskExportParams = { + const record = await createSSAReportRecord({ + task_id: params.task_id, + task_ids: params.task_ids, report_name: values.report_name, - severity: values.severity?.join(',') || undefined, - risk_type: values.risk_type?.join(',') || undefined, - audited_state: values.audited_state || 'all', - latest_disposal_status: - values.latest_disposal_status?.join(',') || undefined, - }; - if (exportTaskIDs.length === 1) { - params.task_id = exportTaskIDs[0]; - } else { - params.task_ids = exportTaskIDs.join(','); + severity: params.severity, + risk_type: params.risk_type, + latest_disposal_status: params.latest_disposal_status, + audited_state: params.audited_state, + }); + const recordId = record.data?.id; + if (!recordId) { + throw new Error('empty report record id'); + } + updateExportProgress( + 42, + values.format === 'word' + ? '报告快照已保存,正在生成 Word 文件...' + : '报告快照已保存,正在生成 PDF 文件...', + ); + const file = await createSSAReportRecordFile(recordId, { + format: values.format === 'word' ? 'docx' : 'pdf', + }); + const fileId = file.data?.id; + if (!fileId) { + throw new Error('empty report file id'); + } + updateExportProgress( + 78, + values.format === 'word' + ? '文件已入库,正在下载 Word 文件...' + : '文件已入库,正在下载 PDF 文件...', + ); + const res = await downloadSSAReportRecordFile(fileId); + if (!res.data) { + throw new Error('empty report file'); } if (values.format === 'word') { - const res = await exportSSARiskReportDocx(params); - if (!res.data) { - throw new Error('empty report docx'); - } - updateExportProgress( - 78, - '报告数据已返回,正在写入 Word 文件...', - ); saveSSAReportDocx( res.data, values.report_name, @@ -670,14 +703,6 @@ const TaskList: React.FC = () => { }, ); } else { - const res = await exportSSARiskReportPDF(params); - if (!res.data) { - throw new Error('empty report pdf'); - } - updateExportProgress( - 78, - '报告数据已返回,正在写入 PDF 文件...', - ); saveSSAReportPdf( res.data, values.report_name, @@ -692,9 +717,59 @@ const TaskList: React.FC = () => { updateExportProgress(100, '导出完成'); setExportModalOpen(false); message.success('导出成功'); - } catch { + } catch (error: any) { + const errorMessage = error?.message || ''; + if (errorMessage.includes('object storage')) { + try { + updateExportProgress( + 78, + values.format === 'word' + ? '对象存储未启用,回退为直接下载 Word 文件...' + : '对象存储未启用,回退为直接下载 PDF 文件...', + ); + if (values.format === 'word') { + const res = await exportSSARiskReportDocx(params); + if (!res.data) { + throw new Error('empty report docx'); + } + saveSSAReportDocx( + res.data, + values.report_name, + (progress) => { + updateExportProgress( + progress.percent, + progress.message, + ); + }, + ); + } else { + const res = await exportSSARiskReportPDF(params); + if (!res.data) { + throw new Error('empty report pdf'); + } + saveSSAReportPdf( + res.data, + values.report_name, + (progress) => { + updateExportProgress( + progress.percent, + progress.message, + ); + }, + ); + } + updateExportProgress(100, '导出完成'); + setExportModalOpen(false); + message.success('导出成功(未写入文件资产)'); + return; + } catch (fallbackError: any) { + setExportProgress(defaultExportProgressState); + message.error(fallbackError?.message || '导出失败'); + return; + } + } setExportProgress(defaultExportProgressState); - message.error('导出失败'); + message.error(errorMessage || '导出失败'); } finally { window.setTimeout(() => { setExportProgress(defaultExportProgressState); diff --git a/src/pages/VulnerabilityList/VulnerabilityList.tsx b/src/pages/VulnerabilityList/VulnerabilityList.tsx index 552e3a36..7dd15c48 100644 --- a/src/pages/VulnerabilityList/VulnerabilityList.tsx +++ b/src/pages/VulnerabilityList/VulnerabilityList.tsx @@ -42,6 +42,11 @@ import { getSSARiskFilterOptions, getSSARisks, } from '@/apis/SSARiskApi'; +import { createSSAReportRecord } from '@/apis/SSAReportRecordApi'; +import { + createSSAReportRecordFile, + downloadSSAReportRecordFile, +} from '@/apis/SSAReportRecordFileApi'; import type { TSSARisk, TSSARiskExportParams, @@ -62,10 +67,7 @@ import relativeTime from 'dayjs/plugin/relativeTime'; import 'dayjs/locale/zh-cn'; import { SiGo, SiJavascript, SiPhp, SiPython } from 'react-icons/si'; import { DiJava } from 'react-icons/di'; -import { - saveSSAReportPdf, - saveSSAReportDocx, -} from '@/utils/ssaReportExport'; +import { saveSSAReportPdf, saveSSAReportDocx } from '@/utils/ssaReportExport'; import './VulnerabilityList.scss'; dayjs.extend(relativeTime); @@ -494,6 +496,23 @@ const VulnerabilityList: React.FC = () => { ); const handleExport = async (values: TSSAReportExportFormValues) => { + const params: TSSARiskExportParams = { + report_name: values.report_name, + severity: exportSelectedOnly + ? undefined + : values.severity?.join(',') || severity, + risk_type: exportSelectedOnly + ? undefined + : values.risk_type?.join(',') || riskType, + latest_disposal_status: exportSelectedOnly + ? undefined + : values.latest_disposal_status?.join(',') || undefined, + audited_state: exportSelectedOnly + ? 'all' + : values.audited_state || 'all', + ids: exportSelectedOnly ? selectedIds.join(',') : undefined, + program_name: exportSelectedOnly ? undefined : projectName, + }; try { setExportSubmitting(true); openExportProgress( @@ -502,58 +521,108 @@ const VulnerabilityList: React.FC = () => { ? '正在请求 Word 报告数据...' : '正在请求 PDF 报告数据...', ); - const params: TSSARiskExportParams = { + const record = await createSSAReportRecord({ + task_id: params.task_id, + ids: params.ids, + program_name: params.program_name, report_name: values.report_name, - severity: exportSelectedOnly - ? undefined - : values.severity?.join(',') || severity, - risk_type: exportSelectedOnly - ? undefined - : values.risk_type?.join(',') || riskType, - latest_disposal_status: exportSelectedOnly - ? undefined - : values.latest_disposal_status?.join(',') || undefined, - audited_state: exportSelectedOnly - ? 'all' - : values.audited_state || 'all', - ids: exportSelectedOnly ? selectedIds.join(',') : undefined, - program_name: exportSelectedOnly ? undefined : projectName, - }; + severity: params.severity, + risk_type: params.risk_type, + latest_disposal_status: params.latest_disposal_status, + audited_state: params.audited_state, + }); + const recordId = record.data?.id; + if (!recordId) { + throw new Error('empty report record id'); + } + updateExportProgress( + 42, + values.format === 'word' + ? '报告快照已保存,正在生成 Word 文件...' + : '报告快照已保存,正在生成 PDF 文件...', + ); + const file = await createSSAReportRecordFile(recordId, { + format: values.format === 'word' ? 'docx' : 'pdf', + }); + const fileId = file.data?.id; + if (!fileId) { + throw new Error('empty report file id'); + } + updateExportProgress( + 78, + values.format === 'word' + ? '文件已入库,正在下载 Word 文件...' + : '文件已入库,正在下载 PDF 文件...', + ); + const res = await downloadSSAReportRecordFile(fileId); + if (!res.data) { + throw new Error('empty report file'); + } if (values.format === 'word') { - const res = await exportSSARiskReportDocx(params); - if (!res.data) { - throw new Error('empty report docx'); - } - updateExportProgress( - 78, - '报告数据已返回,正在写入 Word 文件...', - ); saveSSAReportDocx(res.data, values.report_name, (progress) => { updateExportProgress(progress.percent, progress.message); }); } else { - const res = await exportSSARiskReportPDF(params); - if (!res.data) { - throw new Error('empty report pdf'); - } - updateExportProgress(78, '报告数据已返回,正在写入 PDF 文件...'); - saveSSAReportPdf( - res.data, - values.report_name, - (progress) => { - updateExportProgress( - progress.percent, - progress.message, - ); - }, - ); + saveSSAReportPdf(res.data, values.report_name, (progress) => { + updateExportProgress(progress.percent, progress.message); + }); } updateExportProgress(100, '导出完成'); setExportModalOpen(false); message.success('导出成功'); - } catch { + } catch (error: any) { + const errorMessage = error?.message || ''; + if (errorMessage.includes('object storage')) { + try { + updateExportProgress( + 78, + values.format === 'word' + ? '对象存储未启用,回退为直接下载 Word 文件...' + : '对象存储未启用,回退为直接下载 PDF 文件...', + ); + if (values.format === 'word') { + const res = await exportSSARiskReportDocx(params); + if (!res.data) { + throw new Error('empty report docx'); + } + saveSSAReportDocx( + res.data, + values.report_name, + (progress) => { + updateExportProgress( + progress.percent, + progress.message, + ); + }, + ); + } else { + const res = await exportSSARiskReportPDF(params); + if (!res.data) { + throw new Error('empty report pdf'); + } + saveSSAReportPdf( + res.data, + values.report_name, + (progress) => { + updateExportProgress( + progress.percent, + progress.message, + ); + }, + ); + } + updateExportProgress(100, '导出完成'); + setExportModalOpen(false); + message.success('导出成功(未写入文件资产)'); + return; + } catch (fallbackError: any) { + setExportProgress(defaultExportProgressState); + message.error(fallbackError?.message || '导出失败'); + return; + } + } setExportProgress(defaultExportProgressState); - message.error('导出失败'); + message.error(errorMessage || '导出失败'); } finally { window.setTimeout(() => { setExportProgress(defaultExportProgressState); diff --git a/src/utils/routeMap.ts b/src/utils/routeMap.ts index 8081caff..f7800098 100644 --- a/src/utils/routeMap.ts +++ b/src/utils/routeMap.ts @@ -81,7 +81,7 @@ const wizardRoutes: Partial> = { [RouteKey.IRIFY_SETTINGS_RULES]: '/settings/rules', [RouteKey.IRIFY_SETTINGS_NODES]: '/settings/nodes', [RouteKey.IRIFY_SETTINGS_USERS]: '/settings/users', - [RouteKey.IRIFY_SETTINGS_REPORTS]: '/settings/reports', + [RouteKey.IRIFY_SETTINGS_REPORTS]: '/reports', [RouteKey.IRIFY_SETTINGS_COMPILE_ARTIFACTS]: '/system-management/compile-artifacts', }; @@ -107,7 +107,7 @@ const irifyRoutes: Partial> = { [RouteKey.IRIFY_SETTINGS_RULES]: '/settings/rules', [RouteKey.IRIFY_SETTINGS_NODES]: '/settings/nodes', [RouteKey.IRIFY_SETTINGS_USERS]: '/settings/users', - [RouteKey.IRIFY_SETTINGS_REPORTS]: '/settings/reports', + [RouteKey.IRIFY_SETTINGS_REPORTS]: '/reports', [RouteKey.IRIFY_SETTINGS_COMPILE_ARTIFACTS]: '/system-management/compile-artifacts', }; From f5ab48d193e2072b4193247846f2b0c60f771c37 Mon Sep 17 00:00:00 2001 From: intSheep Date: Mon, 23 Mar 2026 14:53:38 +0800 Subject: [PATCH 04/71] fix: prevent duplicate project rows during scan launch --- .../ProjecManagement/ProjectManagement.tsx | 116 ++++++++++++++---- .../projectManagementUtils.ts | 28 +++++ tests/projectManagementUtils.test.ts | 57 +++++++++ 3 files changed, 175 insertions(+), 26 deletions(-) create mode 100644 src/pages/ProjecManagement/projectManagementUtils.ts create mode 100644 tests/projectManagementUtils.test.ts diff --git a/src/pages/ProjecManagement/ProjectManagement.tsx b/src/pages/ProjecManagement/ProjectManagement.tsx index f6053ae5..a0d6e365 100644 --- a/src/pages/ProjecManagement/ProjectManagement.tsx +++ b/src/pages/ProjecManagement/ProjectManagement.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useEffect, useState, useCallback, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { Card, @@ -43,6 +43,7 @@ import type { import type { TSSAProject } from '@/apis/SSAProjectApi/type'; import { getRoutePath, RouteKey } from '@/utils/routeMap'; import ProjectDrawer from './ProjectDrawer'; +import { dedupeSSAProjects, mergeSSAProjects } from './projectManagementUtils'; import dayjs from 'dayjs'; import './ProjectManagement.scss'; @@ -114,13 +115,33 @@ const ProjectManagement: React.FC = () => { number | null >(null); const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [scanningProjectId, setScanningProjectId] = useState( + null, + ); const [showMemoryScanOverride, setShowMemoryScanOverride] = useState(resolveMemoryScanOverrideVisible); + const dataRef = useRef([]); + const loadingRef = useRef(false); + const requestedPageKeysRef = useRef>(new Set()); const selectedProjects = data.filter( (item) => item.id && selectedRowKeys.includes(item.id), ); + useEffect(() => { + dataRef.current = data; + }, [data]); + + const resetListState = useCallback(() => { + dataRef.current = []; + requestedPageKeysRef.current = new Set(); + setPage(1); + setData([]); + setHasMore(true); + setSelectedRowKeys([]); + setScanPopoverProjectId(null); + }, []); + const fetchList = useCallback( async (options: { p: number; @@ -142,6 +163,22 @@ const ProjectManagement: React.FC = () => { dateRange, append = false, } = options; + const requestKey = JSON.stringify({ + p, + l, + projectName: projectName?.trim() || '', + language: language || '', + sourceKind: sourceKind || '', + tags: tags || '', + dateRange: dateRange ?? null, + }); + if (append && requestedPageKeysRef.current.has(requestKey)) { + return; + } + if (append) { + requestedPageKeysRef.current.add(requestKey); + } + loadingRef.current = true; setLoading(true); try { const res = await getSSAProjects({ @@ -161,7 +198,7 @@ const ProjectManagement: React.FC = () => { message.error('获取项目列表失败'); return; } - let list = res.data?.list ?? []; + let list = dedupeSSAProjects(res.data?.list ?? []); // 前端过滤(如果后端不支持这些筛选) if (sourceKind) { @@ -179,22 +216,27 @@ const ProjectManagement: React.FC = () => { }); } + list = dedupeSSAProjects(list); + const nextData = append + ? mergeSSAProjects(dataRef.current, list) + : list; + dataRef.current = nextData; + setData(nextData); + + const pageMeta = res.data?.pagemeta; + const currentPage = pageMeta?.page ?? p; + const totalPage = pageMeta?.total_page ?? currentPage; + + setPage(currentPage); + setLimit(pageMeta?.limit ?? l); + setHasMore(currentPage < totalPage); + } catch (err) { if (append) { - setData((prevData) => [...prevData, ...list]); - } else { - setData(list); + requestedPageKeysRef.current.delete(requestKey); } - setPage(res.data?.pagemeta?.page ?? p); - setLimit(res.data?.pagemeta?.limit ?? l); - - // 检查是否还有更多数据 - const currentTotal = append - ? data.length + list.length - : list.length; - setHasMore(currentTotal < (res.data?.pagemeta?.total ?? 0)); - } catch (err) { message.error('获取项目列表出错'); } finally { + loadingRef.current = false; setLoading(false); } }, @@ -202,10 +244,7 @@ const ProjectManagement: React.FC = () => { ); const reloadFirstPage = useCallback(() => { - setPage(1); - setData([]); - setHasMore(true); - setSelectedRowKeys([]); + resetListState(); fetchList({ p: 1, l: limit, @@ -218,6 +257,7 @@ const ProjectManagement: React.FC = () => { }, [ fetchList, limit, + resetListState, searchName, filterLanguage, filterSourceKind, @@ -227,10 +267,7 @@ const ProjectManagement: React.FC = () => { useEffect(() => { // 筛选条件改变时,重置到第一页 - setPage(1); - setData([]); - setHasMore(true); - setSelectedRowKeys([]); + resetListState(); fetchList({ p: 1, l: limit, @@ -246,10 +283,13 @@ const ProjectManagement: React.FC = () => { filterSourceKind, filterTags, filterDateRange, + limit, + resetListState, + fetchList, ]); const handleLoadMore = useCallback(() => { - if (loading || !hasMore) return; + if (loadingRef.current || !hasMore) return; fetchList({ p: page + 1, @@ -262,7 +302,6 @@ const ProjectManagement: React.FC = () => { append: true, }); }, [ - loading, hasMore, page, limit, @@ -378,7 +417,15 @@ const ProjectManagement: React.FC = () => { auditCarryEnabled = true, scanMode: TSSAScanModeOverride = 'auto', ) => { - if (!record.id) return; + if (!record.id || scanningProjectId === record.id) return; + const projectId = record.id; + const messageKey = `ssa-project-scan-${projectId}`; + setScanningProjectId(projectId); + message.loading({ + key: messageKey, + content: '正在创建扫描任务...', + duration: 0, + }); try { const scanRequest: TSSAScanRequest = { audit_carry_enabled: auditCarryEnabled, @@ -421,6 +468,7 @@ const ProjectManagement: React.FC = () => { }); const taskId = res.data?.task_id; message.success({ + key: messageKey, content: ( 扫描任务已创建{taskId ? ` (#${taskId})` : ''}, @@ -444,7 +492,19 @@ const ProjectManagement: React.FC = () => { duration: 6, }); } catch (err: any) { - message.error(`创建扫描失败: ${err.msg || err.message}`); + message.error({ + key: messageKey, + content: `创建扫描失败: ${ + err?.reason || + err?.msg || + err?.message || + '请检查网络后重试' + }`, + }); + } finally { + setScanningProjectId((current) => + current === projectId ? null : current, + ); } }; @@ -731,6 +791,7 @@ const ProjectManagement: React.FC = () => { const hasSchedule = Boolean( record.config?.ScanSchedule?.enabled, ); + const isScanning = scanningProjectId === record.id; return ( @@ -781,6 +842,7 @@ const ProjectManagement: React.FC = () => { diff --git a/src/pages/ProjecManagement/projectManagementUtils.ts b/src/pages/ProjecManagement/projectManagementUtils.ts new file mode 100644 index 00000000..e84f76b4 --- /dev/null +++ b/src/pages/ProjecManagement/projectManagementUtils.ts @@ -0,0 +1,28 @@ +import type { TSSAProject } from '@/apis/SSAProjectApi/type'; + +const normalizeProjectName = (projectName: string): string => + projectName.trim().toLowerCase(); + +export const getSSAProjectIdentityKey = (project: TSSAProject): string => { + if (typeof project.id === 'number') { + return `id:${project.id}`; + } + return `name:${normalizeProjectName(project.project_name)}`; +}; + +export const dedupeSSAProjects = (projects: TSSAProject[]): TSSAProject[] => { + const seen = new Set(); + return projects.filter((project) => { + const key = getSSAProjectIdentityKey(project); + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +}; + +export const mergeSSAProjects = ( + existing: TSSAProject[], + incoming: TSSAProject[], +): TSSAProject[] => dedupeSSAProjects([...existing, ...incoming]); diff --git a/tests/projectManagementUtils.test.ts b/tests/projectManagementUtils.test.ts new file mode 100644 index 00000000..fbfdc817 --- /dev/null +++ b/tests/projectManagementUtils.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import type { TSSAProject } from '../src/apis/SSAProjectApi/type'; +import { + dedupeSSAProjects, + mergeSSAProjects, +} from '../src/pages/ProjecManagement/projectManagementUtils'; + +const createProject = ( + id: number | undefined, + projectName: string, +): TSSAProject => ({ + id, + project_name: projectName, + language: 'go', +}); + +describe('projectManagementUtils', () => { + it('dedupes repeated projects by id and keeps the first entry', () => { + const projects = dedupeSSAProjects([ + createProject(1, 'alpha'), + createProject(1, 'alpha-duplicate'), + createProject(2, 'beta'), + ]); + + expect(projects).toHaveLength(2); + expect(projects[0].project_name).toBe('alpha'); + expect(projects[1].project_name).toBe('beta'); + }); + + it('falls back to normalized project name when id is missing', () => { + const projects = dedupeSSAProjects([ + createProject(undefined, ' My-App '), + createProject(undefined, 'my-app'), + createProject(undefined, 'other-app'), + ]); + + expect(projects).toHaveLength(2); + expect(projects.map((project) => project.project_name)).toEqual([ + ' My-App ', + 'other-app', + ]); + }); + + it('merges pages without re-appending already loaded projects', () => { + const merged = mergeSSAProjects( + [createProject(1, 'alpha'), createProject(2, 'beta')], + [createProject(2, 'beta-duplicate'), createProject(3, 'gamma')], + ); + + expect(merged).toHaveLength(3); + expect(merged.map((project) => project.project_name)).toEqual([ + 'alpha', + 'beta', + 'gamma', + ]); + }); +}); From a909de13f2eabc62f92f208e8f32a617a9237190 Mon Sep 17 00:00:00 2001 From: intSheep Date: Mon, 23 Mar 2026 14:58:16 +0800 Subject: [PATCH 05/71] refactor(ux): p0+p1 frontend interaction optimizations remove fake dashboard trends, consolidate 10 API calls into single Promise.all, add @tanstack/react-query with QueryClientProvider (staleTime 30s), create useUrlState hook for URL filter persistence, migrate dashboard and vulnerability list filter options to useQuery, persist filter state in URL for vulnerability list (6 filters) and project management (4 filters) --- package.json | 1 + pnpm-lock.yaml | 18 ++ src/hooks/index.ts | 1 + src/hooks/useUrlState.ts | 71 ++++ src/main.tsx | 23 +- src/pages/IRifyDashboard/IRifyDashboard.tsx | 305 ++++++++---------- .../ProjecManagement/ProjectManagement.tsx | 18 +- .../VulnerabilityList/VulnerabilityList.tsx | 43 +-- 8 files changed, 273 insertions(+), 207 deletions(-) create mode 100644 src/hooks/useUrlState.ts diff --git a/package.json b/package.json index 31a9e6b2..aee8ae97 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hello-pangea/dnd": "16.5.0", + "@tanstack/react-query": "^5.95.0", "@uiw/react-md-editor": "3.6.5", "@viz-js/viz": "3.7.0", "@yakit-libs/color": "^1.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9ab0207..74065904 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@hello-pangea/dnd': specifier: 16.5.0 version: 16.5.0(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tanstack/react-query': + specifier: ^5.95.0 + version: 5.95.0(react@18.3.1) '@uiw/react-md-editor': specifier: 3.6.5 version: 3.6.5(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1313,6 +1316,14 @@ packages: resolution: {integrity: sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==} engines: {node: '>=6'} + '@tanstack/query-core@5.95.0': + resolution: {integrity: sha512-H1/CWCe8tGL3YIVeo770Z6kPbt0B3M1d/iQXIIK1qlFiFt6G2neYdkHgLapOC8uMYNt9DmHjmGukEKgdMk1P+A==} + + '@tanstack/react-query@5.95.0': + resolution: {integrity: sha512-EMP8B+BK9zvnAemT8M/y3z/WO0NjZ7fIUY3T3wnHYK6AA3qK/k33i7tPgCXCejhX0cd4I6bJIXN2GmjrHjDBzg==} + peerDependencies: + react: ^18 || ^19 + '@turf/bbox-polygon@6.5.0': resolution: {integrity: sha512-+/r0NyL1lOG3zKZmmf6L8ommU07HliP4dgYToMoTxqzsWzyLjaj/OzgQ8rBmv703WJX+aS6yCmLuIhYqyufyuw==} @@ -8062,6 +8073,13 @@ snapshots: dependencies: defer-to-connect: 1.1.3 + '@tanstack/query-core@5.95.0': {} + + '@tanstack/react-query@5.95.0(react@18.3.1)': + dependencies: + '@tanstack/query-core': 5.95.0 + react: 18.3.1 + '@turf/bbox-polygon@6.5.0': dependencies: '@turf/helpers': 6.5.0 diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 97a8a9d1..b9c11974 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -5,3 +5,4 @@ export { default as usePermissionsSlice } from './usePermissionsSlice'; export { default as useNetworkStatus } from './useNetwork'; export { default as useEventSource } from './useEventSource'; export { default as usePreviousDeep } from './usePreviousDeep'; +export { useUrlState, serializers } from './useUrlState'; diff --git a/src/hooks/useUrlState.ts b/src/hooks/useUrlState.ts new file mode 100644 index 00000000..d4a7369f --- /dev/null +++ b/src/hooks/useUrlState.ts @@ -0,0 +1,71 @@ +import { useCallback, useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +interface Serializer { + serialize: (value: T) => string; + deserialize: (raw: string) => T; +} + +const stringSerializer: Serializer = { + serialize: (v) => v, + deserialize: (v) => v, +}; + +const numberSerializer: Serializer = { + serialize: (v) => String(v), + deserialize: (v) => Number(v), +}; + +export const serializers = { + string: stringSerializer, + number: numberSerializer, +} as const; + +interface UseUrlStateOptions { + serializer?: Serializer; +} + +/** + * Syncs a single URL search param with component state. + * Uses `replace` navigation to avoid polluting browser history. + * + * Works with HashRouter — reads/writes the query string after the hash fragment. + */ +export function useUrlState( + key: string, + defaultValue: string, + options?: UseUrlStateOptions, +): [string, (value: string | undefined) => void] { + const [searchParams, setSearchParams] = useSearchParams(); + const ser = (options?.serializer ?? stringSerializer) as Serializer; + + const value = useMemo(() => { + const raw = searchParams.get(key); + if (raw === null || raw === '') return defaultValue; + return ser.deserialize(raw); + }, [searchParams, key, defaultValue, ser]); + + const setValue = useCallback( + (next: string | undefined) => { + setSearchParams( + (prev) => { + const updated = new URLSearchParams(prev); + if ( + next === undefined || + next === defaultValue || + next === '' + ) { + updated.delete(key); + } else { + updated.set(key, ser.serialize(next)); + } + return updated; + }, + { replace: true }, + ); + }, + [setSearchParams, key, defaultValue, ser], + ); + + return [value, setValue]; +} diff --git a/src/main.tsx b/src/main.tsx index bcbe6cf5..004bca96 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,6 +2,7 @@ import ReactDOM from 'react-dom/client'; import { HashRouter } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ThemeProvider } from './theme'; @@ -13,18 +14,28 @@ import './index.scss'; import App from './App.tsx'; -// Conditionally load IRify styles const APP_MODE = import.meta.env.VITE_APP_MODE; if (APP_MODE === 'irify') { import('./styles/irify.scss'); } +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30 * 1000, + gcTime: 5 * 60 * 1000, + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}); + ReactDOM.createRoot(document.getElementById('root')!).render( - // - - - + + + + + , - // , ); diff --git a/src/pages/IRifyDashboard/IRifyDashboard.tsx b/src/pages/IRifyDashboard/IRifyDashboard.tsx index d5f1c247..8b0ef9f6 100644 --- a/src/pages/IRifyDashboard/IRifyDashboard.tsx +++ b/src/pages/IRifyDashboard/IRifyDashboard.tsx @@ -12,7 +12,7 @@ import { ArrowRightOutlined, RocketOutlined, } from '@ant-design/icons'; -import { useRequest } from 'ahooks'; +import { useQuery } from '@tanstack/react-query'; import { getSSAProjects } from '@/apis/SSAProjectApi'; import { getSSARisks } from '@/apis/SSARiskApi'; import { querySSATasks } from '@/apis/SSAScanTaskApi'; @@ -47,10 +47,7 @@ const statusLabels: Record = { cancelled: '已取消', }; -const formatProjectBatchLabel = ( - projectName?: string, - scanBatch?: number, -) => { +const formatProjectBatchLabel = (projectName?: string, scanBatch?: number) => { const name = (projectName || '').trim(); if (!name) return '未知项目'; if (scanBatch && scanBatch > 0) { @@ -59,132 +56,96 @@ const formatProjectBatchLabel = ( return name; }; -const getTotalFromRiskResp = (res: any) => Number(res?.data?.pagemeta?.total) || 0; +const getTotalFromRiskResp = (res: any) => + Number(res?.data?.pagemeta?.total) || 0; const severityOrder = ['critical', 'high', 'middle', 'low', 'info'] as const; type SeverityKey = (typeof severityOrder)[number]; -type TrendDirection = 'up' | 'down' | 'flat'; - -const getTrendTone = ( - metricKind: 'normal' | 'vuln', - direction: TrendDirection, -): 'good' | 'bad' | 'neutral' => { - if (direction === 'flat') return 'neutral'; - if (metricKind === 'vuln') { - return direction === 'down' ? 'good' : 'bad'; - } - return direction === 'up' ? 'good' : 'bad'; -}; - -const getTrendArrow = (direction: TrendDirection) => { - if (direction === 'up') return '↑'; - if (direction === 'down') return '↓'; - return '→'; -}; const IRifyDashboard: React.FC = () => { const navigate = useNavigate(); const { isDark } = useTheme(); const [workflowStep, setWorkflowStep] = useState(0); - // Fetch projects count - const { data: projectsData } = useRequest(async () => { - const res = await getSSAProjects({ page: 1, limit: 1 }); - return res.data; - }); - - // Fetch top 3 high-risk projects - const { data: topProjectsData } = useRequest(async () => { - const res = await getSSAProjects({ - page: 1, - limit: 3, - order: 'desc', - order_by: 'risk_count' - }); - return res.data; - }); - - // Fetch risks summary - const { data: risksData } = useRequest(async () => { - const res = await getSSARisks({ page: 1, limit: 1 }); - return res.data; - }); - - // Fetch severity counts using backend total for each severity. - // Do not use a small page list for stats; that causes obvious undercount. - const { data: severitySummary } = useRequest(async () => { - const [criticalRes, highRes, middleRes, warningRes, lowRes, infoRes] = - await Promise.all([ + const { data: dashboardData } = useQuery({ + queryKey: ['dashboard'], + queryFn: async () => { + const [ + projectsRes, + topProjectsRes, + risksRes, + criticalRes, + highRes, + middleRes, + warningRes, + lowRes, + infoRes, + scansRes, + ] = await Promise.all([ + getSSAProjects({ page: 1, limit: 1 }), + getSSAProjects({ + page: 1, + limit: 3, + order: 'desc', + order_by: 'risk_count', + }), + getSSARisks({ page: 1, limit: 1 }), getSSARisks({ page: 1, limit: 1, severity: 'critical' }), getSSARisks({ page: 1, limit: 1, severity: 'high' }), getSSARisks({ page: 1, limit: 1, severity: 'middle' }), getSSARisks({ page: 1, limit: 1, severity: 'warning' }), getSSARisks({ page: 1, limit: 1, severity: 'low' }), getSSARisks({ page: 1, limit: 1, severity: 'info' }), + querySSATasks({ page: 1, limit: 5 }), ]); - return { - critical: getTotalFromRiskResp(criticalRes), - high: getTotalFromRiskResp(highRes), - // warning 视为中危展示 - middle: - getTotalFromRiskResp(middleRes) + - getTotalFromRiskResp(warningRes), - low: getTotalFromRiskResp(lowRes), - info: getTotalFromRiskResp(infoRes), - }; + return { + projects: projectsRes.data, + topProjects: topProjectsRes.data, + risks: risksRes.data, + severitySummary: { + critical: getTotalFromRiskResp(criticalRes), + high: getTotalFromRiskResp(highRes), + // warning 视为中危展示 + middle: + getTotalFromRiskResp(middleRes) + + getTotalFromRiskResp(warningRes), + low: getTotalFromRiskResp(lowRes), + info: getTotalFromRiskResp(infoRes), + }, + scans: scansRes.data, + }; + }, }); - // Fetch recent scans - const { data: scansData } = useRequest(async () => { - const res = await querySSATasks({ page: 1, limit: 5 }); - return res.data; - }); + const projectCount = dashboardData?.projects?.pagemeta?.total || 0; + const riskCount = dashboardData?.risks?.pagemeta?.total || 0; + const scanCount = dashboardData?.scans?.pagemeta?.total || 0; + const topProjects = (dashboardData?.topProjects?.list || []) + .filter((project: any) => Number(project?.risk_count || 0) > 0) + .slice(0, 3); + + const severityCounts: Record = { + critical: dashboardData?.severitySummary?.critical || 0, + high: dashboardData?.severitySummary?.high || 0, + middle: dashboardData?.severitySummary?.middle || 0, + low: dashboardData?.severitySummary?.low || 0, + info: dashboardData?.severitySummary?.info || 0, + }; + + const recentScans = dashboardData?.scans?.list || []; // Calculate workflow step based on data useEffect(() => { - if ((projectsData?.pagemeta?.total || 0) > 0) { + if (projectCount > 0) { setWorkflowStep(1); - if ((scansData?.pagemeta?.total || 0) > 0) { + if (scanCount > 0) { setWorkflowStep(2); - if ((risksData?.pagemeta?.total || 0) > 0) { + if (riskCount > 0) { setWorkflowStep(3); } } } - }, [projectsData, risksData, scansData]); - - const projectCount = projectsData?.pagemeta?.total || 0; - const riskCount = risksData?.pagemeta?.total || 0; - const scanCount = scansData?.pagemeta?.total || 0; - const topProjects = (topProjectsData?.list || []) - .filter((project: any) => Number(project?.risk_count || 0) > 0) - .slice(0, 3); - - const metricTrends = { - projects: { - direction: 'up' as TrendDirection, - text: '较上周增加 2个', - }, - scans: { - direction: 'up' as TrendDirection, - text: '较上周增加 12次', - }, - vulns: { - direction: 'down' as TrendDirection, - text: '较上周减少 2.4%', - }, - }; - - // Severity breakdown should reflect global totals, not only the first page list. - const severityCounts: Record = { - critical: severitySummary?.critical || 0, - high: severitySummary?.high || 0, - middle: severitySummary?.middle || 0, - low: severitySummary?.low || 0, - info: severitySummary?.info || 0, - }; - - const recentScans = scansData?.list || []; + }, [projectCount, scanCount, riskCount]); // Dynamic text color based on theme const textColor = isDark ? '#E6EDF3' : '#1E293B'; @@ -214,7 +175,9 @@ const IRifyDashboard: React.FC = () => { {/* Workflow Stepper */} -
0 ? 'compact' : ''}`}> +
0 ? 'compact' : ''}`} + >
= 1 ? 'completed' : ''} ${workflowStep === 0 ? 'active' : ''}`} @@ -281,19 +244,6 @@ const IRifyDashboard: React.FC = () => { > {projectCount}
-
- - {getTrendArrow( - metricTrends.projects.direction, - )} - - {metricTrends.projects.text} -
查看全部 @@ -322,19 +272,6 @@ const IRifyDashboard: React.FC = () => { > {scanCount}
-
- - {getTrendArrow( - metricTrends.scans.direction, - )} - - {metricTrends.scans.text} -
查看全部 @@ -346,7 +283,9 @@ const IRifyDashboard: React.FC = () => { - navigate(getRoutePath(RouteKey.IRIFY_VULNERABILITIES)) + navigate( + getRoutePath(RouteKey.IRIFY_VULNERABILITIES), + ) } >
@@ -363,17 +302,6 @@ const IRifyDashboard: React.FC = () => { > {riskCount}
-
- - {getTrendArrow(metricTrends.vulns.direction)} - - {metricTrends.vulns.text} -
查看全部 @@ -481,17 +409,29 @@ const IRifyDashboard: React.FC = () => { /> ) : (
- - @@ -536,7 +476,11 @@ const IRifyDashboard: React.FC = () => {
@@ -586,28 +532,35 @@ const IRifyDashboard: React.FC = () => { Top 3 高危项目排行
- {topProjects.map((project: any, idx: number) => ( -
- navigate( - getRoutePath( - RouteKey.IRIFY_VULNERABILITIES, - ), - ) - } - > - {idx + 1} - - {project.project_name} - - - {' '} - {project.risk_count || 0} - -
- ))} + {topProjects.map( + (project: any, idx: number) => ( +
+ navigate( + getRoutePath( + RouteKey.IRIFY_VULNERABILITIES, + ), + ) + } + > + + {idx + 1} + + + { + project.project_name + } + + + {' '} + {project.risk_count || + 0} + +
+ ), + )}
)} diff --git a/src/pages/ProjecManagement/ProjectManagement.tsx b/src/pages/ProjecManagement/ProjectManagement.tsx index a0d6e365..9e33385f 100644 --- a/src/pages/ProjecManagement/ProjectManagement.tsx +++ b/src/pages/ProjecManagement/ProjectManagement.tsx @@ -44,6 +44,7 @@ import type { TSSAProject } from '@/apis/SSAProjectApi/type'; import { getRoutePath, RouteKey } from '@/utils/routeMap'; import ProjectDrawer from './ProjectDrawer'; import { dedupeSSAProjects, mergeSSAProjects } from './projectManagementUtils'; +import { useUrlState } from '@/hooks/useUrlState'; import dayjs from 'dayjs'; import './ProjectManagement.scss'; @@ -100,12 +101,13 @@ const ProjectManagement: React.FC = () => { >(); // 筛选条件 - const [searchName, setSearchName] = useState(''); - const [filterLanguage, setFilterLanguage] = useState(); - const [filterSourceKind, setFilterSourceKind] = useState< - string | undefined - >(); - const [filterTags, setFilterTags] = useState(); + const [searchName, setSearchName] = useUrlState('project_name', ''); + const [filterLanguage, setFilterLanguage] = useUrlState('language', ''); + const [filterSourceKind, setFilterSourceKind] = useUrlState( + 'source_kind', + '', + ); + const [filterTags, setFilterTags] = useUrlState('tags', ''); const [filterDateRange, setFilterDateRange] = useState< [number, number] | undefined >(); @@ -936,6 +938,7 @@ const ProjectManagement: React.FC = () => { placeholder="搜索项目名称" allowClear style={{ width: 220 }} + defaultValue={searchName} onSearch={handleSearch} onChange={(e) => { if (!e.target.value) { @@ -947,6 +950,7 @@ const ProjectManagement: React.FC = () => { placeholder="选择语言" allowClear style={{ width: 140 }} + value={filterLanguage || undefined} onChange={handleLanguageFilter} options={languageOptions} /> @@ -954,6 +958,7 @@ const ProjectManagement: React.FC = () => { placeholder="代码源类型" allowClear style={{ width: 140 }} + value={filterSourceKind || undefined} onChange={(value) => { setFilterSourceKind(value || undefined); setPage(1); @@ -964,6 +969,7 @@ const ProjectManagement: React.FC = () => { placeholder="标签筛选" allowClear style={{ width: 140 }} + defaultValue={filterTags} onChange={(e) => { const value = e.target.value; setFilterTags(value || undefined); diff --git a/src/pages/VulnerabilityList/VulnerabilityList.tsx b/src/pages/VulnerabilityList/VulnerabilityList.tsx index 7dd15c48..c09f8bc1 100644 --- a/src/pages/VulnerabilityList/VulnerabilityList.tsx +++ b/src/pages/VulnerabilityList/VulnerabilityList.tsx @@ -33,7 +33,7 @@ import { } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import type { MenuProps } from 'antd'; -import { useRequest } from 'ahooks'; +import { useQuery } from '@tanstack/react-query'; import { batchUpdateSSARisks, exportSSARiskReportDocx, @@ -61,6 +61,7 @@ import SSAReportExportProgressModal, { type TSSAReportExportProgressModalState, } from '@/compoments/SSAReportExportProgressModal'; import { useNavigate } from 'react-router-dom'; +import { useUrlState } from '@/hooks/useUrlState'; import { getRoutePath, RouteKey } from '@/utils/routeMap'; import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; @@ -204,21 +205,25 @@ const VulnerabilityList: React.FC = () => { defaultExportProgressState, ); - const [keywordInput, setKeywordInput] = useState(''); - const [keyword, setKeyword] = useState(''); - const [severity, setSeverity] = useState(); - const [projectName, setProjectName] = useState(); - const [riskType, setRiskType] = useState(); - const [language, setLanguage] = useState(); - const [sortValue, setSortValue] = useState('created_at-desc'); + const [keyword, setKeyword] = useUrlState('title', ''); + const [keywordInput, setKeywordInput] = useState(keyword); + const [severity, setSeverity] = useUrlState('severity', ''); + const [projectName, setProjectName] = useUrlState('project_name', ''); + const [riskType, setRiskType] = useUrlState('risk_type', ''); + const [language, setLanguage] = useUrlState('language', ''); + const [sortValue, setSortValue] = useUrlState('sort', 'created_at-desc'); const [detailDrawerVisible, setDetailDrawerVisible] = useState(false); const [detailLoading, setDetailLoading] = useState(false); const [currentRisk, setCurrentRisk] = useState(null); - const { data: filterOptions } = useRequest(async () => { - const res = await getSSARiskFilterOptions(); - return res.data as TSSARiskFilterOptions; + const { data: filterOptions } = useQuery({ + queryKey: ['ssaRiskFilterOptions'], + queryFn: async () => { + const res = await getSSARiskFilterOptions(); + return res.data as TSSARiskFilterOptions; + }, + staleTime: 60 * 1000, }); const [orderBy, order] = useMemo(() => { @@ -236,10 +241,10 @@ const VulnerabilityList: React.FC = () => { page: p, limit: PAGE_SIZE, title: keyword || undefined, - severity, - program_name: projectName, - risk_type: riskType, - language, + severity: severity || undefined, + program_name: projectName || undefined, + risk_type: riskType || undefined, + language: language || undefined, order_by: orderBy, order, }), @@ -891,7 +896,7 @@ const VulnerabilityList: React.FC = () => { allowClear showSearch style={{ width: 150 }} - value={projectName} + value={projectName || undefined} onChange={setProjectName} placeholder="选择项目" options={(filterOptions?.project_names || []).map( @@ -905,7 +910,7 @@ const VulnerabilityList: React.FC = () => { allowClear showSearch style={{ width: 150 }} - value={riskType} + value={riskType || undefined} onChange={setRiskType} placeholder="漏洞类型" options={(filterOptions?.risk_types || []).map( @@ -919,7 +924,7 @@ const VulnerabilityList: React.FC = () => { allowClear showSearch style={{ width: 130 }} - value={language} + value={language || undefined} onChange={setLanguage} placeholder="编程语言" options={(filterOptions?.languages || []).map( @@ -931,7 +936,7 @@ const VulnerabilityList: React.FC = () => { /> + setSearch((cur) => ({ + ...cur, + key: e, + })) + } + /> + it.value === search.key)?.text} + onSearch={async (value) => { + const uselessKey = options.filter((it) => it.value !== search.key)[0].value - const columns: CreateTableProps['columns'] = [ - { - dataIndex: 'cve', - title: 'CVE编号', - width: 120, - }, - { - dataIndex: 'description', - title: '概述', - width: 180, - }, - { - dataIndex: 'cwe', - title: 'CWE编号', - width: 120, - render: (text: string) => - text ? ( -
- {text.split('|').map((ele) => ( - - {ele} - - ))} + await page.editFilter({ + [search.key]: value, + [uselessKey]: undefined, + }) + setSearch((cur) => ({ + ...cur, + value, + })) + }} + /> + + setOpen(newOpen)} + content={ +
+
{ + CveDifferUpdateModalRef.current?.open({ + title: 'CVE 差量最新数据更新', + }) + setOpen(false) + }} + > + 只更新最新数据 +
+
{ + CveAllUpdateModalRef.current?.open({ + title: 'CVE 数据库更新', + type: 'all', + }) + setOpen(false) + }} + > + 全量更新 +
+
{ + CveAllUpdateModalRef.current?.open({ + title: 'CVE 数据库离线更新', + type: 'offline', + }) + setOpen(false) + }} + > + 离线更新 +
- ) : ( - '' - ), - }, - { - dataIndex: 'vulnerable_product', - title: '影响产品', - width: 120, - }, - { - dataIndex: 'severity', - title: '漏洞级别', - width: 120, - render: (value, record) => { - if (value) { - const toLowerCaseValue = value.toLowerCase(); - const targetItem = SeverityMapTag.find((item) => - item.key.includes(toLowerCaseValue), - ); - return ( -
-
- {targetItem?.name} -
- - {record.exploitability_score ?? 0} - -
- ); - } else { - return '-'; - } - }, - }, - { - dataIndex: 'published_date', - title: '披露时间', - width: 180, - render: (value) => { - const d = dayjs(value); - return d.isValid() ? d.format('YYYY-MM-DD HH:mm') : '-'; - }, - }, - { - dataIndex: 'last_modified_date', - title: '更新时间', - width: 180, - render: (value) => { - const d = dayjs(value); - return d.isValid() ? d.format('YYYY-MM-DD HH:mm') : '-'; + } + trigger="click" + > + +
+
+ ), + ProFilterSwitch: { + trigger: , + layout: 'vertical', }, - }, - ]; - - return ( -
- - - { - run({ - id, - status: value, - }); - }} - style={{ width: 120 }} - options={sensitiveInfoStatus} - /> - ); -}; + return ( + - - - - - - + + + + + + - - - - - ); - }) - .exhaustive(); - }); + // 调度枚举对应展示dom fn + const schedulingTypeFn = useMemoizedFn((schedulingType: 1 | 2 | 3) => { + return match(schedulingType) + .with(1, () => { + return null + }) + .with(2, () => { + return ( + { + if (!value) { + return Promise.resolve() + } + if (value.isBefore(dayjs())) { + return Promise.reject(new Error('所选时间不能小于当前时间')) + } + return Promise.resolve() + }, + }, + ]} + label="执行时间" + className="ml-14" + > + + + ) + }) + .with(3, () => { + return ( + <> + 第一次是否执行
} name="first"> + + + + disabledDate(date)} // 禁用日期部分 + onCalendarChange={(dates) => { + if (dates && dates[0]) { + starTimeRf.current = dates[0] // 记录选中的开始时间 + } + }} + disabledTime={(date, type) => disabledTimeRangePicker(date, type)} + /> + + 执行周期}> + + + + + + - + const collapseChildren = [ + { + key: '1', + label: '基本信息', + forceRender: true, + style: { + borderBottom: '1px solid #EAECF3', + borderRadius: '0px', + marginBottom: '8px', + }, + children: ( +
+ 报告名称
} + name={['params', 'report_name']} + rules={[ + { + message: '请输入报告名称', + required: true, + }, + ]} + > + +
- 任务名称} - name="task_id" - className="hidden" - > - - - - - - - + + + + + + { - return ( - - {option.data.value} - (当前任务量 - {option.data.size}) - - ); - }} - options={ - Array.isArray(scannerDataList) - ? scannerDataList?.map( - (it) => ({ - label: it?.name, - value: it?.name, - size: it?.size, - }), - ) - : [] - } - /> - - ) : ( - it)} - rules={[ - { - required: true, - message: '请选择节点', - }, - ]} - label={ -
- 节点选择 -
- } - > - {scannerDataList && - scannerDataList.length !== 0 ? ( - - ) : ( -
- - -
- )} -
- )) - ); - }} - - - ), - extra: ( - - {({ setFieldValue }) => { - return ( - - ); + > + + + 上传 + + } + > + { + setFieldValue(['params', scriptTypeValue === 'weakinfo' ? 'keyword' : 'target'], fileName) }} + /> - ), - }, - { - key: '3', - label: '设置调度', - forceRender: true, - style: { - borderBottom: '1px solid #EAECF3', - borderRadius: '0px', - marginBottom: '8px', - }, - extra: ( - - {({ setFieldValue }) => { + ) + }} + + 执行节点} + initialValue="1" + > + + + + {({ getFieldValue, setFieldValue }) => { + const executionNodeValue = getFieldValue(['params', 'execution_node']) + executionNodeValue === '2' && setFieldValue('scanner', undefined) + return ( + executionNodeValue === '1' && + (scannerDataList && scannerDataList?.length > 6 ? ( + 节点选择} + rules={[ + { + required: true, + message: '请选择节点', + }, + ]} + initialValue={[scannerDataList?.[0]?.name].filter((it) => it)} + > + + + prevValues?.['sched_type'] !== curValues?.['sched_type']}> + {({ getFieldValue }) => { + const formType = getFieldValue('sched_type') + return schedulingTypeFn(formType) + }} + + + ), + }, + + { + key: '4', + label: '高级参数', + forceRender: true, + style: { + borderBottom: '1px solid #EAECF3', + borderRadius: '0px', + marginBottom: '8px', + }, + extra: ( + + {({ setFieldValue }) => { + return ( + + ) + }} + + ), + children: ( +
+ {scriptTypeValue !== 'login_brute_scan' && ( + <> + 扫描模式
} + className="ml-14" + rules={[ + { + message: '请选择扫描模式', + required: true, + }, + ]} + initialValue="syn+tcp" + > + +
+ + {/* TODO 若需字段联动 dependencies 需添加监听项 */} + {({ setFieldValue }) => { + return ( 调度类型} - name="sched_type" - initialValue={1} + name={['params', 'preset-protes']} + label={
预设端口
} + initialValue="all" + getValueFromEvent={presetProtesFromCheckboxValue} + getValueProps={(value) => ({ + value: presetProtesToCheckboxValue(value), + })} > - +
+ + +