v2.1.2 核心优化与多架构发布 (#561)

* feat: v2.1.0 核心重构与功能增强

## 架构重构
- 全局变量消除,迁移至 Config/State 对象
- SMB 插件融合(smb/smb2/smbghost/smbinfo)
- 服务探测重构,实现 Nmap 风格 fallback 机制
- 输出系统重构,TXT 实时刷盘 + 双写机制
- i18n 框架升级至 go-i18n

## 性能优化
- 正则表达式预编译
- 内存优化 map[string]struct{}
- 并发指纹匹配
- SOCKS5 连接复用
- 滑动窗口调度 + 自适应线程池

## 新功能
- Web 管理界面
- 多格式 POC 适配(xray/afrog)
- 增强指纹库(3139条)
- Favicon hash 指纹识别
- 插件选择性编译(Build Tags)
- fscan-lab 靶场环境
- 默认端口扩展(62→133)

## 构建系统
- 添加 no_local tag 支持排除本地插件
- 多版本构建:fscan/fscan-nolocal/fscan-web
- CI 添加 snapshot 模式支持仅测试构建

## Bug 修复
- 修复 120+ 个问题,包括 RDP panic、批量扫描漏报、
  JSON 输出格式、Redis 检测、Context 超时等

## 测试增强
- 单元测试覆盖率 74-100%
- 并发安全测试
- 集成测试(Web/端口/服务/SSH/ICMP)

* fix(ci): 移除 PR 对 Project 自动化的触发

* fix: Elasticsearch未授权检测优先于爆破 (#554)

* fix: 修复RDP爆破高误报率问题 (#555)

- 移除 screen.go 中错误的认证结果覆盖逻辑
- 启用 NLA 协议的 ErrorCode 字段检测
- 添加 PubKeyAuth 验证确保认证真正成功
- 修复 io.go 中错误被静默忽略的问题
- 修复 socket.go/io.go 中可能导致 panic 的代码
- 修复 screen.go 中文件句柄泄漏和 log.Panic

* fix: 修复-user/-pwd凭据参数不生效的问题

问题原因:
- Parse()解析凭据后更新globalConfig
- 但BuildConfigFromFlags()创建新Config时使用默认字典
- 导致解析的UserPassPairs等凭据信息被丢弃

修复内容:
1. initialize.go: 将Parse解析的凭据结果应用到新Config
2. credential.go: 单用户密码对时创建UserPassPairs
3. rdp.go: 单凭据测试时跳过指纹识别,减少连接次数

* feat: RDP使用NLA仅验证模式,避免挤掉已登录用户

- 添加ErrNLAAuthSuccess标志用于NLA验证成功信号
- tpkt层支持nlaAuthOnly模式,验证成功后不建立完整会话
- x224层正确传播NLA验证结果
- rdpCrack改用NlaAuth进行凭据验证

* fix: 修复进度条在Windows终端满屏重复输出的问题

- 添加终端宽度检测,动态调整进度条长度
- 使用空格覆盖清除旧内容,避免残留
- 简化进度条格式,确保不超过终端宽度

* feat: 优化日志颜色方案,区分漏洞和普通信息

- 新增 LogVuln 级别(红色),用于漏洞和重要发现
- 密码爆破成功、未授权访问、POC漏洞等改用红色显示
- 普通信息(扫描统计等)改为白色
- Web指纹保持绿色

* refactor: 精简化输出,移除冗余启动信息

- 移除showParseSummary开局配置输出
- 移除LogPluginInfo/LogPluginInfoWithPort插件信息输出
- 移除alive_scanner冗余统计输出
- 移除port_scan_start扫描开始提示
- 移除handleUDPPorts SNMP死代码
- 移除相关i18n条目

* chore: 版本号更新为2.1.1

* fix: 降级依赖版本以保持Go 1.20兼容性

* feat(ldap): 添加NTLM Hash认证支持 (#433)

* chore: 清理无用的 replace 指令

* fix(ping): 修复 TTL expired 导致主机误判为存活的问题

在 ExecCommandPing 中增加错误关键词检测,当 ping 输出包含
TTL expired、Destination unreachable 等错误信息时,不再将
目标主机标记为存活。

Fixes #454

* fix(proxy): 修复透明代理导致输出全端口的问题

在代理初始化时主动探测代理行为,通过连接 RFC 5737 保留的
测试地址来检测是否存在"全回显"问题。如果探测到代理不可靠,
则在端口扫描时跳过所有端口,避免误报。

- 新增 proxyReliable 标志位标记代理可靠性
- 新增 ProbeProxyBehavior 函数探测代理行为
- 端口扫描前检查代理可靠性并输出警告

Fixes #495

* refactor: 移动debug模块到common/debug子包

* fix(web): 修复-u模式下Web插件未执行的问题

* fix: 优化输出格式和颜色显示

- 网段统计格式改为 10.253.0.0/16 网段存活: 26
- WebTitle基础信息改为白色,指纹识别单独绿色输出
- 移除重复的端口数量输出

* fix: URL解析自动补全协议头

-uf 文件中 192.168.1.1:8080 自动转为 http://192.168.1.1:8080

* fix: 修复-u/-uf模式下URLs丢失导致0目标扫描的问题

Parse阶段将URLs设置到全局状态,但Initialize随后创建新状态
并覆盖了全局状态,导致URLs数据丢失。现在在创建新状态前
先保存并迁移Parse阶段设置的URLs和HostPorts数据。

* fix: 智能检测HTTP/HTTPS协议并优化URL显示

- 修复-u/-uf模式URLs丢失导致0目标扫描问题
- detectProtocol改为主动TLS握手检测,不依赖服务名
- WebTitle输出显示完整协议(http/https)
- 隐藏标准端口(80/443)使输出更简洁

* refactor: 精简parsers包,统一配置构建入口

- 删除冗余的中间层(XXXInput、XXXParser类)
- 新增 config_builder.go 统一配置构建
- parsers包从3000+行精简至~540行
- 保留核心函数:ParseIP、ParsePort、文件读取、凭据解析

* test: 扩展parsers单元测试覆盖边缘情况

- 新增内网简写解析测试(192/172/10)
- 新增完整IP范围和无效CIDR测试
- 新增Windows行尾(CRLF)处理测试
- 新增凭据和哈希文件解析测试
- 新增端口解析边缘情况测试
- 测试覆盖率达到94.2%

* refactor: 优化控制台输出格式

- 去掉时间戳,保留[*][+]前缀
- Web输出合并WebTitle和WebFinger为一行
- 有指纹显示绿色[+],无指纹显示白色[*]
- 格式: code:xxx len:xxx title:xxx server:xxx [指纹]
- 服务探测格式: [Product:xxx ||Version:xxx] Banner:(xxx)
- 字段对齐,输出更清爽

* feat: 添加凭据测试未发现弱密码的提示

- credential_tester.go: 失败时设置 Type=ResultTypeCredential
- scanner.go: 根据结果类型在 error 级别输出'未发现弱密码'提示
- 新增 i18n 翻译 brute_no_weak_pass

使用 -log all 或 -log error 可看到此提示

* refactor(logging): 重构日志级别为层级过滤设计

- LogLevel 从 string 改为 int 类型,支持层级比较
- 层级设计:Debug(0) < Base(1) < Info(2) < Success(3) < Vuln(4) < Error(5)
- 设置一个级别后,显示该级别及以上的日志
- Error 级别始终显示,不会被配置过滤掉
- 保留向后兼容别名(LevelAll, LevelInfoSuccess 等)
- 更新测试以匹配新的层级过滤行为

* style(logging): Error级别日志改为黄色显示

* style(findnet): NetInfo输出改为每行一个IP

* refactor(ms17010): 优化错误提示,明确指出SMBv1不支持等情况

* fix(credential): 修复凭据测试结果不一致的问题

问题原因:
1. 未知错误类型不重试,导致服务端限流时跳过正确密码
2. SSH 错误分类不够准确,某些临时错误未被识别

修复内容:
1. 未知错误改为可重试(可能是临时问题)
2. 增加 SSH 特有的网络错误识别(handshake failed, disconnect 等)

* fix(portfinger): 修复SMB2服务指纹识别和NetInfo输出问题

- 添加SMB2ProgNeg探针支持现代Windows的SMB2协议
- 修复Go regexp对高位字节的UTF-8兼容问题,使用Latin-1转换
- 修复探针失败后连接重建逻辑
- 修复vendor_product字段名不匹配问题
- 修复NetInfo多行输出被其他日志打断的问题

* fix(config): 从默认端口移除9100,避免触发打印机打印 (#517)

* feat(proxy): 增强代理端口扫描的深度验证机制

- 新增4阶段深度验证:Banner读取→探测发送→响应等待→最终判定
- 新增SOCKS5错误码和代理错误文本检测
- 优化ProbeProxyBehavior探测逻辑,发送数据验证连接可达性
- 解决透明代理/全回显代理导致的假阳性问题

* fix(proxy): 修复代理深度验证的若干问题

- detector.go: 修复 AutoConfigureProxy 覆盖探测结果的问题
  只有未探测过时才设置默认 proxyReliable 值

- port_scan.go: 改进深度验证机制
  - 使用带 Host header 的 HTTP GET 请求替代 OPTIONS
  - 延长响应等待超时至 2s 以适配慢速服务器
  - 正确重置连接 deadline 避免影响后续操作

* refactor: 统一 common 包文件命名风格

Flag.go -> flag.go

* refactor(proxy): 删除自定义 contains() 函数,改用标准库

- 用 strings.Contains() 替代手写的 contains()
- 删除过时的注释

* fix(parsers): 修复带横杠域名被误识别为IP范围的问题

如 111-555.sss.com 这类域名因包含 - 被错误解析为 IP 范围,
添加 looksLikeIPRange() 检查,只有 - 前是有效 IP 才走范围解析

* fix(proxy): 修复代理模式下服务识别错误和端口漏扫问题

- port_scan.go: 验证通过后重建干净连接,避免HTTP GET探测污染服务识别
- port_scan.go: 优化验证策略,用轻量CRLF探测替代HTTP GET,超时从2.2s降至0.6s
- manager.go: 修正ProbeProxyBehavior判断逻辑,超时应视为代理正常转发

* fix(pool): 移除线程池预分配,优化大规模扫描内存占用

WithPreAlloc(true) 会预先创建所有 worker goroutine,
在大规模扫描(如 25域名×65535端口)时可能导致内存问题

* refactor(logging): 统一日志前缀,删除废弃的 LogBase

- 删除 LogBase 函数,所有调用迁移到 LogInfo/LogError
- 新增 PrefixDebug ([.]) 前缀,所有日志级别现在都有前缀
- 修复日志输出缩进不一致的问题
- 删除未使用的 PrefixDefault 常量

* perf(icmp): 实现自适应等待算法优化存活检测性能

- 新增 waitAdaptive 函数,监控响应增量实现智能提前结束
- 算法保守原则:最小等待1s + 连续500ms无新响应才提前结束
- 添加100ms检查间隔避免CPU空转
- 保留原有最大等待时间(3s/6s)作为兜底
- 添加完整单元测试覆盖各种场景

优化效果:
- 全部响应:~100ms (原3s)
- 无响应:~1s (原3s)
- 部分响应后稳定:~1.5s (原3s)

* perf(scan): 实现启发式优化提升扫描体验

1. 端口优先级排序:高价值端口(80,443,22,3389等)优先扫描
   - 用户能更快看到有意义的结果
   - 不影响端口喷洒策略

2. TCP 补充探测:ICMP 响应率<10%时自动启用
   - 对未响应主机用 TCP 80/443/22/445 补充探测
   - 解决防火墙过滤 ICMP 导致漏检的问题

* refactor(grdp): 精简RDP库,删除认证检测不需要的代码

- 删除 VNC 协议支持 (protocol/rfb, client/rfb.go)
- 删除完整客户端框架 (client/)
- 删除 RemoteApp 等插件 (plugin/)
- 删除 RLE 图形解压 (core/rle.go)
- 删除绘图指令处理 (pdu/orders.go, pdu/gdi.go)
- 精简 screen.go,移除截图和完整会话功能
- 移除未使用的 RGB 转换函数

grdp 代码从 13,044 行精简至 7,581 行,削减 42%

* refactor(common): 删除死代码,优化代码风格

- 删除未使用的 joinStrings/joinInts 函数
- 删除未使用的 memStats 字段和 getMemoryInfo 方法
- 简化 parsePasswords 中的循环为 append(...) 形式

* refactor(services): 统一数据库插件的DBWrapper

4个数据库插件(MySQL、PostgreSQL、MSSQL、Oracle)都有相同的sql.DB包装代码,
合并为通用的SQLDBWrapper,减少重复。

* refactor(core,grdp): 删除未使用的死代码

- 移除 BaseScanStrategy.LogPluginInfoWithPort 方法(无调用者)
- 移除 mcs.go 中被注释的旧 connect 函数实现

* refactor: 删除 deadcode 检测出的未使用函数

- proxy/detector.go: 删除 IsSOCKS5Standard, IsProxyInitialized
- findnet.go: 删除 NetworkInfo.OneLine, TreeFormat 方法
- port_scan.go: 删除 estimateScanTime 函数
- web_scanner.go: 删除 GetFingerprints 函数
- 清理相关测试代码

* refactor: 删除更多未使用的死代码

- parse.go: 删除 RemoveDuplicate 函数及其测试
- parsers.go: 删除 excludeHosts, removeDuplicates 别名函数
- 更新测试使用真正的函数名

* fix(test): 修复 TestParseIP_InvalidIPRange 测试用例

- 删除不合理的测试用例(无效IP被当作普通主机名处理是设计行为)
- 修复测试逻辑,只在真正通过时输出"正确"

* fix(scan): 移除域名预解析,保留原始域名进行扫描

域名预解析会将域名转换为IP,导致虚拟主机场景下HTTP访问失败
(Host头变成IP而非域名,无法正确路由)

* fix(scan): 修复 -hf 参数无法单独使用的问题

* fix(proxy): 修复透明代理环境下 SOCKS5 代理全端口误报问题

问题:在透明代理(TUN模式)环境下使用 SOCKS5 代理扫描时,
会出现全端口开放的误报,因为代理可靠性检测被透明代理污染。

修复方案(参考 fscanx):
1. 将探针从 CRLF 改为 HTTP GET,更有效检测真实连接状态
2. 删除 "uncertain" 状态,无响应一律判定为端口关闭
3. 调整超时时间以适应代理链路延迟

Fixes #524

* feat(telnet): 新增 telnetd RCE 命令执行验证,修复未授权访问日志级别

* fix: 修复 i18n.Tr vet 报错、Unicode 测试用例,移除过期域名

- 移除 i18n.Tr 中错误的 fmt.Sprintf fallback,消除 go vet 误报
- 修复 match_engine_test Unicode 测试用例与 Latin-1 转换逻辑不匹配
- README 移除过期的 fscan.club 域名
- 添加 .gitattributes 统一换行符为 LF

* refactor: 统一控制台输出风格,使用统一的日志函数

手动合并 PR #558 的改动,适配重构后的代码路径

* fix(ci): 修复版本注入和CI触发配置

- goreleaser ldflags 指向正确的包路径 common.version/commit/date
- version 改为 var 支持 ldflags 注入,banner 显示 commit 和构建日期
- test-build 触发分支增加 dev-* 通配

* fix(ci): 修复 Windows 产物 .exe.exe 双后缀问题

* feat(ci): 扩展构建架构支持 MIPS/ARM/FreeBSD/Solaris
This commit is contained in:
ZacharyZcR
2026-04-25 17:39:16 +08:00
committed by GitHub
parent 594f567650
commit 760c8ea502
927 changed files with 82001 additions and 24995 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web-ui</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+5927
View File
File diff suppressed because it is too large Load Diff
+55
View File
@@ -0,0 +1,55 @@
{
"name": "web-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^25.7.3",
"lucide-react": "^0.562.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-i18next": "^16.5.0",
"react-router-dom": "^7.11.0",
"recharts": "^3.6.0",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.4",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+144
View File
@@ -0,0 +1,144 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Terminal, Languages, Moon, Sun, Radar, BarChart3, Github, ExternalLink } from 'lucide-react';
import { ScanPage } from '@/pages/ScanPage';
import { ResultsPage } from '@/pages/ResultsPage';
import { LiveFeedProvider } from '@/contexts/LiveFeedContext';
import { Button } from '@/components/ui/button';
import './i18n';
type Page = 'scan' | 'results';
function App() {
const { t, i18n } = useTranslation();
const [currentPage, setCurrentPage] = useState<Page>('scan');
const [isDark, setIsDark] = useState(() => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem('theme');
if (stored) return stored === 'dark';
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
return false;
});
useEffect(() => {
document.documentElement.classList.toggle('dark', isDark);
localStorage.setItem('theme', isDark ? 'dark' : 'light');
}, [isDark]);
return (
<LiveFeedProvider>
<div className="min-h-screen bg-background flex flex-col">
{/* Header */}
<header className="sticky top-0 z-50 border-b bg-background/95 backdrop-blur-sm">
<div className="container h-14 sm:h-16 flex items-center justify-between">
{/* Left: Logo + Nav */}
<div className="flex items-center gap-4 sm:gap-6">
{/* Brand */}
<a
href="https://github.com/shadow1ng/fscan"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-foreground hover:opacity-80 transition-opacity"
>
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-foreground flex items-center justify-center">
<Terminal className="w-4 h-4 sm:w-5 sm:h-5 text-background" />
</div>
<span className="font-semibold text-base sm:text-lg tracking-tight">fscan</span>
<span className="text-xs text-muted-foreground font-mono px-1.5 py-0.5 rounded bg-muted hidden sm:inline">v2.1</span>
</a>
<div className="h-5 w-px bg-border hidden sm:block" />
{/* Navigation */}
<nav className="flex items-center gap-1">
<Button
variant={currentPage === 'scan' ? 'default' : 'ghost'}
size="sm"
onClick={() => setCurrentPage('scan')}
className="gap-2"
>
<Radar className="w-4 h-4" />
<span className="hidden sm:inline">{t('navScan')}</span>
</Button>
<Button
variant={currentPage === 'results' ? 'default' : 'ghost'}
size="sm"
onClick={() => setCurrentPage('results')}
className="gap-2"
>
<BarChart3 className="w-4 h-4" />
<span className="hidden sm:inline">{t('navResults')}</span>
</Button>
</nav>
</div>
{/* Right: Actions */}
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
asChild
>
<a
href="https://github.com/shadow1ng/fscan"
target="_blank"
rel="noopener noreferrer"
title="GitHub"
>
<Github className="w-5 h-5" />
</a>
</Button>
<div className="h-5 w-px bg-border mx-1" />
<Button
variant="ghost"
size="icon"
onClick={() => i18n.changeLanguage(i18n.language === 'zh' ? 'en' : 'zh')}
title={i18n.language === 'zh' ? 'English' : '中文'}
>
<Languages className="w-5 h-5" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setIsDark(!isDark)}
title={isDark ? t('lightMode') : t('darkMode')}
>
{isDark ? (
<Sun className="w-5 h-5" />
) : (
<Moon className="w-5 h-5" />
)}
</Button>
</div>
</div>
</header>
{/* Main */}
<main className="container flex-1 py-3 sm:py-4 lg:py-5">
{currentPage === 'scan' ? <ScanPage /> : <ResultsPage />}
</main>
{/* Footer */}
<footer className="border-t py-3 sm:py-4">
<div className="container flex items-center justify-center gap-2 text-xs sm:text-sm text-muted-foreground">
<Terminal className="w-4 h-4" />
<span>{t('appDescription')}</span>
<span className="opacity-40">·</span>
<a
href="https://github.com/shadow1ng/fscan"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 hover:text-foreground transition-colors"
>
GitHub
<ExternalLink className="w-3.5 h-3.5" />
</a>
</div>
</footer>
</div>
</LiveFeedProvider>
);
}
export default App;
+100
View File
@@ -0,0 +1,100 @@
import { useTranslation } from 'react-i18next';
import {
Wifi, WifiOff, Server, Network, Shield, AlertTriangle, CircleDot, Inbox, Activity
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { EmptyState } from '@/components/ui/empty-state';
import { useLiveFeed } from '@/contexts/LiveFeedContext';
interface LiveFeedProps {
compact?: boolean;
showTypeLabel?: boolean;
}
const TYPE_ICONS = {
host: Server,
port: Network,
service: Shield,
vuln: AlertTriangle,
} as const;
export function LiveFeed({ compact = false, showTypeLabel = false }: LiveFeedProps) {
const { t } = useTranslation();
const { isConnected, logs } = useLiveFeed();
const getTypeIcon = (type: string) => {
const key = type?.toLowerCase() as keyof typeof TYPE_ICONS;
return TYPE_ICONS[key] || CircleDot;
};
const getTypeLabel = (type: string) => {
switch (type?.toLowerCase()) {
case 'host': return t('typeHost');
case 'port': return t('typePort');
case 'service': return t('typeService');
case 'vuln': return t('typeVuln');
default: return type;
}
};
return (
<Card className={compact ? '' : 'flex-1 flex flex-col'}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Activity className="w-4 h-4 sm:w-5 sm:h-5 text-muted-foreground" />
{t('liveFeed')}
</CardTitle>
<div className="flex items-center gap-2">
{isConnected ? (
<Badge variant="default" className="gap-1">
<Wifi className="w-3 h-3" />
<span className="hidden sm:inline">{t('liveFeedConnected')}</span>
</Badge>
) : (
<Badge variant="destructive" className="gap-1">
<WifiOff className="w-3 h-3" />
<span className="hidden sm:inline">{t('liveFeedDisconnected')}</span>
</Badge>
)}
<Badge variant="outline" className="font-mono">{logs.length}/100</Badge>
</div>
</CardHeader>
<CardContent className={compact ? 'pt-0' : 'pt-0 flex-1 min-h-0'}>
<ScrollArea className={compact ? 'h-52 lg:h-56' : 'h-full'}>
{logs.length === 0 ? (
<EmptyState
icon={Inbox}
title={t('resultsEmpty')}
description={t('liveFeedEmptyDescription')}
className={compact ? 'py-6' : 'py-8'}
/>
) : (
<div className="space-y-0.5">
{logs.map((log) => {
const TypeIcon = getTypeIcon(log.type);
return (
<div key={log.id} className="log-line animate-fade-in group">
<span className="log-time">{log.time}</span>
<Badge
variant={log.type?.toLowerCase() as 'host' | 'port' | 'service' | 'vuln'}
className="gap-1 text-xs"
>
<TypeIcon className="w-3 h-3" />
{showTypeLabel && getTypeLabel(log.type)}
</Badge>
<span className="log-target">{log.target}</span>
<span className="text-muted-foreground truncate ml-auto text-xs">
{log.status}
</span>
</div>
);
})}
</div>
)}
</ScrollArea>
</CardContent>
</Card>
);
}
+130
View File
@@ -0,0 +1,130 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { BarChart, Bar, XAxis, YAxis, PieChart, Pie, Cell } from 'recharts';
import { BarChart3 } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart';
import { EmptyState } from '@/components/ui/empty-state';
import type { ResultItem } from '@/lib/api';
interface ResultsChartProps {
results: ResultItem[];
}
const CHART_COLORS = {
host: 'hsl(var(--chart-1))',
port: 'hsl(var(--chart-2))',
service: 'hsl(var(--chart-3))',
vuln: 'hsl(var(--chart-4))',
} as const;
export function ResultsChart({ results }: ResultsChartProps) {
const { t } = useTranslation();
const chartData = useMemo(() => {
const counts = { host: 0, port: 0, service: 0, vuln: 0 };
results.forEach(r => {
const type = r.type?.toLowerCase() as keyof typeof counts;
if (type in counts) counts[type]++;
});
return [
{ name: t('typeHost'), value: counts.host, fill: CHART_COLORS.host },
{ name: t('typePort'), value: counts.port, fill: CHART_COLORS.port },
{ name: t('typeService'), value: counts.service, fill: CHART_COLORS.service },
{ name: t('typeVuln'), value: counts.vuln, fill: CHART_COLORS.vuln },
];
}, [results, t]);
const chartConfig: ChartConfig = {
host: { label: t('typeHost'), color: CHART_COLORS.host },
port: { label: t('typePort'), color: CHART_COLORS.port },
service: { label: t('typeService'), color: CHART_COLORS.service },
vuln: { label: t('typeVuln'), color: CHART_COLORS.vuln },
};
const hasResults = results.length > 0;
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<BarChart3 className="w-4 h-4 sm:w-5 sm:h-5 text-muted-foreground" />
{t('resultsDistribution')}
</CardTitle>
<Badge variant="secondary" className="font-mono">{results.length}</Badge>
</CardHeader>
<CardContent>
{hasResults ? (
<div className="space-y-4">
{/* Pie Chart */}
<div className="flex justify-center">
<ChartContainer config={chartConfig} className="h-[160px] w-[160px] aspect-square">
<PieChart>
<Pie
data={chartData.filter(d => d.value > 0)}
dataKey="value"
nameKey="name"
innerRadius={35}
outerRadius={60}
strokeWidth={2}
stroke="hsl(var(--background))"
>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Pie>
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
</PieChart>
</ChartContainer>
</div>
{/* Legend */}
<div className="space-y-2">
{chartData.map((item, index) => (
<div key={index} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-sm shrink-0"
style={{ backgroundColor: item.fill }}
/>
<span className="text-muted-foreground">{item.name}</span>
</div>
<span className="font-mono font-medium">{item.value}</span>
</div>
))}
</div>
{/* Bar Chart */}
<ChartContainer config={chartConfig} className="h-[120px] w-full">
<BarChart data={chartData} layout="vertical" margin={{ left: 0, right: 8 }}>
<XAxis type="number" hide />
<YAxis
type="category"
dataKey="name"
tickLine={false}
axisLine={false}
width={50}
tick={{ fontSize: 11 }}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Bar dataKey="value" radius={3}>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Bar>
</BarChart>
</ChartContainer>
</div>
) : (
<EmptyState
icon={BarChart3}
title={t('chartEmptyTitle')}
description={t('chartEmptyDescription')}
className="py-6"
/>
)}
</CardContent>
</Card>
);
}
+305
View File
@@ -0,0 +1,305 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Play, Square, ChevronDown, Loader2, Target, Hash, Clock, Zap, Settings2,
User, Lock, Globe2, Ban, Activity, Wifi, CheckCircle2, XCircle
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import type { ScanRequest, ScanPreset } from '@/lib/api';
interface ScanFormProps {
formData: ScanRequest;
onFormChange: (data: ScanRequest) => void;
presets: ScanPreset[];
isRunning: boolean;
isStopping: boolean;
loading: boolean;
error: string | null;
onStart: () => void;
onStop: () => void;
}
export function ScanForm({
formData,
onFormChange,
presets,
isRunning,
isStopping,
loading,
error,
onStart,
onStop,
}: ScanFormProps) {
const { t, i18n } = useTranslation();
const [showAdvanced, setShowAdvanced] = useState(false);
const updateField = <K extends keyof ScanRequest>(key: K, value: ScanRequest[K]) => {
onFormChange({ ...formData, [key]: value });
};
const applyPreset = (presetId: string) => {
const preset = presets.find(p => p.id === presetId);
if (preset) {
onFormChange({
...formData,
ports: preset.ports,
scan_mode: preset.scan_mode,
thread_num: preset.thread_num,
timeout: preset.timeout,
});
}
};
const disabled = isRunning;
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<CardTitle className="flex items-center gap-2 text-base">
<Target className="w-4 h-4 sm:w-5 sm:h-5 text-muted-foreground" />
{t('scanTitle')}
</CardTitle>
{isRunning ? (
<Button
size="sm"
variant="destructive"
onClick={onStop}
disabled={loading || isStopping}
className="gap-2"
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Square className="w-4 h-4" />}
{t('scanStopBtn')}
</Button>
) : (
<Button
size="sm"
onClick={onStart}
disabled={loading || !formData.host}
className="gap-2"
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
{t('scanStartBtn')}
</Button>
)}
</CardHeader>
<CardContent className="space-y-4">
{error && (
<Alert variant="destructive">
<XCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Row 1: Target */}
<div className="space-y-1.5">
<Label className="field-label inline-flex items-center gap-1.5">
<Target className="w-3.5 h-3.5" />
{t('scanTarget')}
</Label>
<Input
placeholder={t('scanTargetPlaceholder')}
value={formData.host}
onChange={(e) => updateField('host', e.target.value)}
disabled={disabled}
className="field-input-mono"
/>
</div>
{/* Row 2: Ports + Preset + Threads + Timeout */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="space-y-1.5">
<Label className="field-label inline-flex items-center gap-1.5">
<Hash className="w-3.5 h-3.5" />
{t('scanPorts')}
</Label>
<Input
placeholder="1-65535"
value={formData.ports}
onChange={(e) => updateField('ports', e.target.value)}
disabled={disabled}
className="field-input-mono"
/>
</div>
<div className="space-y-1.5">
<Label className="field-label inline-flex items-center gap-1.5">
<Zap className="w-3.5 h-3.5" />
{t('scanPreset')}
</Label>
<Select onValueChange={applyPreset} disabled={disabled}>
<SelectTrigger>
<SelectValue placeholder={t('scanPresetSelect')} />
</SelectTrigger>
<SelectContent>
{presets.map(preset => (
<SelectItem key={preset.id} value={preset.id}>
{i18n.language === 'zh' ? preset.name : preset.name_en}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="field-label inline-flex items-center gap-1.5">
<Activity className="w-3.5 h-3.5" />
{t('scanThreads')}
</Label>
<Input
type="number"
value={formData.thread_num}
onChange={(e) => updateField('thread_num', parseInt(e.target.value) || 600)}
disabled={disabled}
className="field-input-mono"
/>
</div>
<div className="space-y-1.5">
<Label className="field-label inline-flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" />
{t('scanTimeout')}
</Label>
<Input
type="number"
value={formData.timeout}
onChange={(e) => updateField('timeout', parseInt(e.target.value) || 3)}
disabled={disabled}
className="field-input-mono"
/>
</div>
</div>
{/* Advanced Options */}
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={disabled}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground p-0 h-auto font-normal"
>
<Settings2 className="w-4 h-4" />
<ChevronDown className={`w-4 h-4 transition-transform ${showAdvanced ? 'rotate-180' : ''}`} />
{t('scanAdvanced')}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3">
<div className="space-y-3 p-3 sm:p-4 rounded-lg bg-muted/50 border">
{/* Switches */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 sm:gap-3">
<div className="switch-row group">
<Label className="inline-flex items-center gap-2 cursor-pointer">
<Wifi className="w-4 h-4 text-muted-foreground group-hover:text-foreground transition-colors" />
{t('scanDisablePing')}
</Label>
<Switch
checked={formData.disable_ping}
onCheckedChange={(checked) => updateField('disable_ping', checked)}
disabled={disabled}
/>
</div>
<div className="switch-row group">
<Label className="inline-flex items-center gap-2 cursor-pointer">
<Lock className="w-4 h-4 text-muted-foreground group-hover:text-foreground transition-colors" />
{t('scanDisableBrute')}
</Label>
<Switch
checked={formData.disable_brute}
onCheckedChange={(checked) => updateField('disable_brute', checked)}
disabled={disabled}
/>
</div>
<div className="switch-row group">
<Label className="inline-flex items-center gap-2 cursor-pointer">
<CheckCircle2 className="w-4 h-4 text-muted-foreground group-hover:text-foreground transition-colors" />
{t('scanAliveOnly')}
</Label>
<Switch
checked={formData.alive_only}
onCheckedChange={(checked) => updateField('alive_only', checked)}
disabled={disabled}
/>
</div>
</div>
{/* Credentials */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label className="field-label inline-flex items-center gap-1.5">
<User className="w-3.5 h-3.5" />
{t('scanUsername')}
</Label>
<Input
value={formData.username}
onChange={(e) => updateField('username', e.target.value)}
disabled={disabled}
className="field-input"
/>
</div>
<div className="space-y-1.5">
<Label className="field-label inline-flex items-center gap-1.5">
<Lock className="w-3.5 h-3.5" />
{t('scanPassword')}
</Label>
<Input
type="password"
value={formData.password}
onChange={(e) => updateField('password', e.target.value)}
disabled={disabled}
className="field-input"
/>
</div>
<div className="space-y-1.5">
<Label className="field-label inline-flex items-center gap-1.5">
<Globe2 className="w-3.5 h-3.5" />
{t('scanDomain')}
</Label>
<Input
value={formData.domain}
onChange={(e) => updateField('domain', e.target.value)}
disabled={disabled}
className="field-input"
/>
</div>
</div>
{/* Exclusions */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="field-label inline-flex items-center gap-1.5">
<Ban className="w-3.5 h-3.5" />
{t('scanExcludeHosts')}
</Label>
<Input
value={formData.exclude_hosts}
onChange={(e) => updateField('exclude_hosts', e.target.value)}
disabled={disabled}
className="field-input-mono"
/>
</div>
<div className="space-y-1.5">
<Label className="field-label inline-flex items-center gap-1.5">
<Ban className="w-3.5 h-3.5" />
{t('scanExcludePorts')}
</Label>
<Input
value={formData.exclude_ports}
onChange={(e) => updateField('exclude_ports', e.target.value)}
disabled={disabled}
className="field-input-mono"
/>
</div>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</CardContent>
</Card>
);
}
+144
View File
@@ -0,0 +1,144 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PieChart, Pie, Cell } from 'recharts';
import { Activity, CheckCircle2, XCircle, Loader2 } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart';
import { EmptyState } from '@/components/ui/empty-state';
import type { ScanStatus } from '@/lib/api';
interface StatsPanelProps {
status: ScanStatus | null;
}
const CHART_COLORS = {
hosts: 'hsl(var(--chart-1))',
ports: 'hsl(var(--chart-2))',
services: 'hsl(var(--chart-3))',
vulns: 'hsl(var(--chart-4))',
} as const;
export function StatsPanel({ status }: StatsPanelProps) {
const { t } = useTranslation();
const isRunning = status?.state === 'running';
const isStopping = status?.state === 'stopping';
const statsChartData = useMemo(() => {
if (!status?.stats) return [];
return [
{ name: t('statsHosts'), value: status.stats.hosts_scanned || 0, fill: CHART_COLORS.hosts },
{ name: t('statsPorts'), value: status.stats.ports_scanned || 0, fill: CHART_COLORS.ports },
{ name: t('statsServices'), value: status.stats.services_found || 0, fill: CHART_COLORS.services },
{ name: t('statsVulns'), value: status.stats.vulns_found || 0, fill: CHART_COLORS.vulns },
].filter(d => d.value > 0);
}, [status?.stats, t]);
const chartConfig: ChartConfig = {
hosts: { label: t('statsHosts'), color: CHART_COLORS.hosts },
ports: { label: t('statsPorts'), color: CHART_COLORS.ports },
services: { label: t('statsServices'), color: CHART_COLORS.services },
vulns: { label: t('statsVulns'), color: CHART_COLORS.vulns },
};
const totalStats = useMemo(() => {
if (!status?.stats) return 0;
return (status.stats.hosts_scanned || 0) +
(status.stats.ports_scanned || 0) +
(status.stats.services_found || 0) +
(status.stats.vulns_found || 0);
}, [status?.stats]);
const statsItems = [
{ key: 'hosts', label: t('statsHosts'), value: status?.stats.hosts_scanned || 0, color: CHART_COLORS.hosts },
{ key: 'ports', label: t('statsPorts'), value: status?.stats.ports_scanned || 0, color: CHART_COLORS.ports },
{ key: 'services', label: t('statsServices'), value: status?.stats.services_found || 0, color: CHART_COLORS.services },
{ key: 'vulns', label: t('statsVulns'), value: status?.stats.vulns_found || 0, color: CHART_COLORS.vulns },
];
return (
<Card className="h-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Activity className="w-4 h-4 sm:w-5 sm:h-5 text-muted-foreground" />
{t('resultsDistribution')}
</CardTitle>
<Badge
variant={isRunning ? 'default' : isStopping ? 'secondary' : 'outline'}
className="gap-1"
>
{isRunning ? <CheckCircle2 className="w-3 h-3" /> :
isStopping ? <Loader2 className="w-3 h-3 animate-spin" /> :
<XCircle className="w-3 h-3" />}
{isRunning ? t('scanRunning') : isStopping ? t('statusStopping') : t('statusIdle')}
</Badge>
</CardHeader>
<CardContent className="space-y-6">
{/* Progress */}
{isRunning && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{t('loading')}</span>
<span className="font-mono text-primary text-lg">{status?.progress || 0}%</span>
</div>
<Progress value={status?.progress || 0} className="h-3" />
</div>
)}
{/* Pie Chart */}
{totalStats > 0 ? (
<div className="flex justify-center py-4">
<ChartContainer config={chartConfig} className="h-[180px] w-[180px] aspect-square">
<PieChart>
<Pie
data={statsChartData}
dataKey="value"
nameKey="name"
innerRadius={45}
outerRadius={75}
strokeWidth={3}
stroke="hsl(var(--background))"
>
{statsChartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Pie>
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
</PieChart>
</ChartContainer>
</div>
) : (
<EmptyState
icon={Activity}
title={t('chartEmptyTitle')}
description={t('chartEmptyDescription')}
className="py-8"
/>
)}
{/* Stats List */}
<div className="space-y-3">
{statsItems.map((item) => (
<div key={item.key} className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
<div className="flex items-center gap-3">
<div className="w-4 h-4 rounded" style={{ backgroundColor: item.color }} />
<span className="text-muted-foreground">{item.label}</span>
</div>
<span className="font-mono font-semibold text-lg">{item.value}</span>
</div>
))}
</div>
{/* Total */}
<div className="pt-4 border-t">
<div className="flex items-center justify-between">
<span className="text-muted-foreground font-medium">{t('items')}</span>
<span className="font-mono font-bold text-2xl">{totalStats}</span>
</div>
</div>
</CardContent>
</Card>
);
}
+138
View File
@@ -0,0 +1,138 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
+62
View File
@@ -0,0 +1,62 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
success:
"border-success/50 text-success dark:border-success [&>svg]:text-success",
warning:
"border-warning/50 text-warning dark:border-warning [&>svg]:text-warning",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }
+39
View File
@@ -0,0 +1,39 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
host: "border-transparent bg-blue-500/10 text-blue-600 dark:text-blue-400",
port: "border-transparent bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
service: "border-transparent bg-amber-500/10 text-amber-600 dark:text-amber-400",
vuln: "border-transparent bg-destructive/10 text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }
+55
View File
@@ -0,0 +1,55 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
+78
View File
@@ -0,0 +1,78 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+222
View File
@@ -0,0 +1,222 @@
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
color?: string
}
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
interface ChartContainerProps extends React.ComponentProps<"div"> {
config: ChartConfig
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"]
}
const ChartContainer = React.forwardRef<HTMLDivElement, ChartContainerProps>(
({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
)
ChartContainer.displayName = "Chart"
const ChartTooltip = RechartsPrimitive.Tooltip
interface ChartTooltipContentProps extends React.ComponentProps<"div"> {
active?: boolean
payload?: Array<{
name?: string
value?: number
dataKey?: string
color?: string
fill?: string
payload?: Record<string, unknown>
}>
label?: string
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
labelFormatter?: (value: unknown, payload: unknown[]) => React.ReactNode
formatter?: (
value: unknown,
name: unknown,
item: unknown,
index: number,
payload: unknown
) => React.ReactNode
}
const ChartTooltipContent = React.forwardRef<HTMLDivElement, ChartTooltipContentProps>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
formatter,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = config[key]
const value = itemConfig?.label || label
if (labelFormatter && payload) {
return (
<div className="font-medium">
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className="font-medium">{value}</div>
}, [label, labelFormatter, payload, hideLabel, config, labelKey])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = config[key]
const indicatorColor = item.payload?.fill || item.fill || item.color
return (
<div
key={item.dataKey || index}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={{
backgroundColor: indicatorColor as string,
borderColor: indicatorColor as string,
}}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value !== undefined && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
}
+9
View File
@@ -0,0 +1,9 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+197
View File
@@ -0,0 +1,197 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
+44
View File
@@ -0,0 +1,44 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import type { LucideIcon } from "lucide-react"
interface EmptyStateProps extends React.HTMLAttributes<HTMLDivElement> {
icon?: LucideIcon
title: string
description?: string
action?: React.ReactNode
}
function EmptyState({
icon: Icon,
title,
description,
action,
className,
...props
}: EmptyStateProps) {
return (
<div
className={cn(
"flex flex-col items-center justify-center py-12 px-4 text-center",
className
)}
{...props}
>
{Icon && (
<div className="mb-4 rounded-full bg-muted p-4">
<Icon className="h-8 w-8 text-muted-foreground" />
</div>
)}
<h3 className="text-lg font-medium text-foreground mb-1">{title}</h3>
{description && (
<p className="text-sm text-muted-foreground max-w-sm mb-4">
{description}
</p>
)}
{action && <div className="mt-2">{action}</div>}
</div>
)
}
export { EmptyState }
+24
View File
@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
+23
View File
@@ -0,0 +1,23 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
+25
View File
@@ -0,0 +1,25 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }
+45
View File
@@ -0,0 +1,45 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }
+156
View File
@@ -0,0 +1,156 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"max-h-[--radix-select-content-available-height] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
+28
View File
@@ -0,0 +1,28 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }
+15
View File
@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
+26
View File
@@ -0,0 +1,26 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
+52
View File
@@ -0,0 +1,52 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+167
View File
@@ -0,0 +1,167 @@
import { createContext, useContext, useState, useCallback, useEffect, useRef, useMemo, type ReactNode } from 'react';
export type MessageType =
| 'scan_started'
| 'scan_progress'
| 'scan_result'
| 'scan_completed'
| 'scan_error'
| 'connected'
| 'ping'
| 'pong';
export interface WSMessage {
type: MessageType;
timestamp: number;
data?: unknown;
}
export interface LogEntry {
id: number;
time: string;
type: string;
target: string;
status: string;
}
interface LiveFeedContextType {
isConnected: boolean;
logs: LogEntry[];
clearLogs: () => void;
}
const LiveFeedContext = createContext<LiveFeedContextType | null>(null);
export function LiveFeedProvider({ children }: { children: ReactNode }) {
const [isConnected, setIsConnected] = useState(false);
const [logs, setLogs] = useState<LogEntry[]>([]);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(false);
// 使用 Map 做可靠去重: key = type|target, value = LogEntry
const logsMapRef = useRef<Map<string, LogEntry>>(new Map());
// 递增计数器确保 id 唯一
const counterRef = useRef(0);
const clearLogs = useCallback(() => {
logsMapRef.current.clear();
counterRef.current = 0;
setLogs([]);
}, []);
const connect = useCallback(() => {
// 防止重复连接
if (!mountedRef.current) return;
if (wsRef.current) {
const state = wsRef.current.readyState;
if (state === WebSocket.OPEN || state === WebSocket.CONNECTING) {
return;
}
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
if (mountedRef.current) {
setIsConnected(true);
}
};
ws.onclose = () => {
if (mountedRef.current) {
setIsConnected(false);
// 延迟重连
reconnectTimeoutRef.current = setTimeout(() => {
if (mountedRef.current) connect();
}, 3000);
}
};
ws.onerror = () => {
ws.close();
};
ws.onmessage = (event) => {
if (!mountedRef.current) return;
try {
const message: WSMessage = JSON.parse(event.data);
if (message.type === 'scan_result' && message.data) {
const data = message.data as Record<string, string>;
// 使用服务端时间戳(如果有)格式化时间
const serverTime = message.timestamp || Date.now();
const newEntry: LogEntry = {
id: ++counterRef.current, // 递增确保唯一且有序
time: new Date(serverTime).toLocaleTimeString(),
type: data.type || 'info',
target: data.target || '',
status: data.status || '',
};
const key = `${newEntry.type}|${newEntry.target}`;
const existing = logsMapRef.current.get(key);
// 去重逻辑:同一 type|target 只保留一条,优先保留详细信息
if (existing) {
const oldStatus = existing.status;
// 只有新的更详细才更新(保留原始 id 以维持顺序)
if ((oldStatus === 'identified' || oldStatus === 'open' || oldStatus === '') &&
newEntry.status !== 'identified' && newEntry.status !== 'open' && newEntry.status !== '') {
logsMapRef.current.set(key, { ...newEntry, id: existing.id });
}
// 已有信息,不重复添加
} else {
logsMapRef.current.set(key, newEntry);
}
// 从 Map 生成排序后的数组(按 id 倒序,最新在前)
const sorted = Array.from(logsMapRef.current.values())
.sort((a, b) => b.id - a.id)
.slice(0, 100);
setLogs(sorted);
}
} catch {
// ignore parse errors
}
};
}, []);
useEffect(() => {
mountedRef.current = true;
connect();
return () => {
mountedRef.current = false;
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
};
}, [connect]);
const value = useMemo(() => ({
isConnected,
logs,
clearLogs,
}), [isConnected, logs, clearLogs]);
return (
<LiveFeedContext.Provider value={value}>
{children}
</LiveFeedContext.Provider>
);
}
export function useLiveFeed() {
const context = useContext(LiveFeedContext);
if (!context) {
throw new Error('useLiveFeed must be used within a LiveFeedProvider');
}
return context;
}
+100
View File
@@ -0,0 +1,100 @@
import { useEffect, useRef, useCallback, useState } from 'react';
export type MessageType =
| 'scan_started'
| 'scan_progress'
| 'scan_result'
| 'scan_completed'
| 'scan_error'
| 'connected'
| 'ping'
| 'pong';
export interface WSMessage {
type: MessageType;
timestamp: number;
data?: unknown;
}
interface UseWebSocketOptions {
onMessage?: (message: WSMessage) => void;
onConnected?: () => void;
onDisconnected?: () => void;
reconnectInterval?: number;
}
export function useWebSocket(options: UseWebSocketOptions = {}) {
const {
onMessage,
onConnected,
onDisconnected,
reconnectInterval = 3000,
} = options;
const [isConnected, setIsConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
return;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
setIsConnected(true);
onConnected?.();
};
ws.onclose = () => {
setIsConnected(false);
onDisconnected?.();
// Reconnect
reconnectTimeoutRef.current = setTimeout(connect, reconnectInterval);
};
ws.onerror = () => {
ws.close();
};
ws.onmessage = (event) => {
try {
const message: WSMessage = JSON.parse(event.data);
onMessage?.(message);
} catch {
console.error('Failed to parse WebSocket message');
}
};
}, [onConnected, onDisconnected, onMessage, reconnectInterval]);
const disconnect = useCallback(() => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
wsRef.current?.close();
wsRef.current = null;
}, []);
const sendMessage = useCallback((type: MessageType, data?: unknown) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type, data, timestamp: Date.now() }));
}
}, []);
useEffect(() => {
connect();
return () => disconnect();
}, [connect, disconnect]);
return {
isConnected,
sendMessage,
connect,
disconnect,
};
}
+236
View File
@@ -0,0 +1,236 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
const resources = {
en: {
translation: {
// App
appTitle: 'Fscan Web UI',
appDescription: 'Network Security Scanner',
// Navigation
navScan: 'Scan',
navResults: 'Results',
navSettings: 'Settings',
// Scan Page
scanTitle: 'New Scan',
scanTarget: 'Target',
scanTargetPlaceholder: 'IP, IP range, domain (e.g., 192.168.1.0/24)',
scanPorts: 'Ports',
scanPortsPlaceholder: 'Port range (e.g., 1-1000,3306,8080)',
scanPreset: 'Preset',
scanPresetSelect: 'Select preset...',
scanMode: 'Scan Mode',
scanModeAll: 'All',
scanModeIcmp: 'ICMP Only',
scanThreads: 'Threads',
scanTimeout: 'Timeout (s)',
scanAdvanced: 'Advanced Options',
scanDisablePing: 'Disable Ping',
scanDisableBrute: 'Disable Brute Force',
scanAliveOnly: 'Alive Only',
scanUsername: 'Username',
scanPassword: 'Password',
scanDomain: 'Domain',
scanExcludeHosts: 'Exclude Hosts',
scanExcludePorts: 'Exclude Ports',
scanStartBtn: 'Start Scan',
scanStopBtn: 'Stop Scan',
scanRunning: 'Scan Running...',
// Status
statusIdle: 'Idle',
statusRunning: 'Running',
statusStopping: 'Stopping',
// Stats
statsHosts: 'Hosts',
statsPorts: 'Ports',
statsServices: 'Services',
statsVulns: 'Vulnerabilities',
// Results
resultsTitle: 'Scan Results',
resultsDistribution: 'Results Distribution',
chartEmptyTitle: 'No data available',
chartEmptyDescription: 'Statistics will be displayed here after scanning',
resultsExport: 'Export',
resultsClear: 'Clear',
resultsEmpty: 'No results yet',
resultsEmptyDescription: 'Results will appear here after scanning',
resultsFilterAll: 'All',
resultsFilterHosts: 'Hosts',
resultsFilterPorts: 'Ports',
resultsFilterServices: 'Services',
resultsFilterVulns: 'Vulnerabilities',
// Live Feed
liveFeed: 'Live Feed',
liveFeedConnected: 'Connected',
liveFeedDisconnected: 'Disconnected',
liveFeedEmptyDescription: 'Start a scan to see real-time results',
// Settings
settingsTitle: 'Settings',
settingsLanguage: 'Language',
settingsTheme: 'Theme',
settingsThemeLight: 'Light',
settingsThemeDark: 'Dark',
settingsThemeSystem: 'System',
// Common
loading: 'Loading...',
error: 'Error',
success: 'Success',
cancel: 'Cancel',
confirm: 'Confirm',
close: 'Close',
items: 'items',
refresh: 'Refresh',
clearAll: 'Clear all',
export: 'Export',
exportJson: 'Export JSON',
exportCsv: 'Export CSV',
// Validation & Errors
targetRequired: 'Target is required',
startScanFailed: 'Failed to start scan',
stopScanFailed: 'Failed to stop scan',
clearConfirmTitle: 'Clear Results',
clearConfirm: 'Are you sure you want to clear all results? This action cannot be undone.',
// Theme
lightMode: 'Light mode',
darkMode: 'Dark mode',
// Result Types
typeHost: 'host',
typePort: 'port',
typeService: 'service',
typeVuln: 'vuln',
},
},
zh: {
translation: {
// App
appTitle: 'Fscan Web UI',
appDescription: '网络安全扫描器',
// Navigation
navScan: '扫描',
navResults: '结果',
navSettings: '设置',
// Scan Page
scanTitle: '新建扫描',
scanTarget: '目标',
scanTargetPlaceholder: 'IP、IP段、域名 (如: 192.168.1.0/24)',
scanPorts: '端口',
scanPortsPlaceholder: '端口范围 (如: 1-1000,3306,8080)',
scanPreset: '预设',
scanPresetSelect: '选择预设...',
scanMode: '扫描模式',
scanModeAll: '全部',
scanModeIcmp: '仅ICMP',
scanThreads: '线程数',
scanTimeout: '超时(秒)',
scanAdvanced: '高级选项',
scanDisablePing: '禁用Ping',
scanDisableBrute: '禁用爆破',
scanAliveOnly: '仅存活检测',
scanUsername: '用户名',
scanPassword: '密码',
scanDomain: '域名',
scanExcludeHosts: '排除主机',
scanExcludePorts: '排除端口',
scanStartBtn: '开始扫描',
scanStopBtn: '停止扫描',
scanRunning: '扫描进行中...',
// Status
statusIdle: '空闲',
statusRunning: '运行中',
statusStopping: '停止中',
// Stats
statsHosts: '主机',
statsPorts: '端口',
statsServices: '服务',
statsVulns: '漏洞',
// Results
resultsTitle: '扫描结果',
resultsDistribution: '结果分布',
chartEmptyTitle: '暂无数据',
chartEmptyDescription: '扫描后将在此显示统计图表',
resultsExport: '导出',
resultsClear: '清空',
resultsEmpty: '暂无结果',
resultsEmptyDescription: '扫描结果将在此显示',
resultsFilterAll: '全部',
resultsFilterHosts: '主机',
resultsFilterPorts: '端口',
resultsFilterServices: '服务',
resultsFilterVulns: '漏洞',
// Live Feed
liveFeed: '实时动态',
liveFeedConnected: '已连接',
liveFeedDisconnected: '已断开',
liveFeedEmptyDescription: '开始扫描后将在此显示实时结果',
// Settings
settingsTitle: '设置',
settingsLanguage: '语言',
settingsTheme: '主题',
settingsThemeLight: '浅色',
settingsThemeDark: '深色',
settingsThemeSystem: '跟随系统',
// Common
loading: '加载中...',
error: '错误',
success: '成功',
cancel: '取消',
confirm: '确认',
close: '关闭',
items: '条',
refresh: '刷新',
clearAll: '清空全部',
export: '导出',
exportJson: '导出 JSON',
exportCsv: '导出 CSV',
// Validation & Errors
targetRequired: '请输入扫描目标',
startScanFailed: '启动扫描失败',
stopScanFailed: '停止扫描失败',
clearConfirmTitle: '清空结果',
clearConfirm: '确定要清空所有结果吗?此操作不可撤销。',
// Theme
lightMode: '浅色模式',
darkMode: '深色模式',
// Result Types
typeHost: '主机',
typePort: '端口',
typeService: '服务',
typeVuln: '漏洞',
},
},
};
i18n
.use(initReactI18next)
.init({
resources,
lng: 'zh',
fallbackLng: 'zh',
interpolation: {
escapeValue: false,
},
});
export default i18n;
+257
View File
@@ -0,0 +1,257 @@
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 99%;
--foreground: 220 14% 10%;
--card: 0 0% 100%;
--card-foreground: 220 14% 10%;
--popover: 0 0% 100%;
--popover-foreground: 220 14% 10%;
--primary: 220 14% 10%;
--primary-foreground: 0 0% 99%;
--secondary: 220 13% 95%;
--secondary-foreground: 220 14% 10%;
--muted: 220 13% 95%;
--muted-foreground: 220 9% 46%;
--accent: 220 13% 95%;
--accent-foreground: 220 14% 10%;
--destructive: 0 72% 51%;
--destructive-foreground: 0 0% 99%;
--border: 220 13% 90%;
--input: 220 13% 90%;
--ring: 220 14% 10%;
--radius: 0.5rem;
--success: 142 71% 45%;
--warning: 38 92% 50%;
/* Chart colors */
--chart-1: 217 91% 60%;
--chart-2: 160 84% 39%;
--chart-3: 43 96% 56%;
--chart-4: 0 72% 51%;
--chart-5: 262 83% 58%;
}
.dark {
--background: 220 16% 6%;
--foreground: 220 13% 95%;
--card: 220 16% 8%;
--card-foreground: 220 13% 95%;
--popover: 220 16% 8%;
--popover-foreground: 220 13% 95%;
--primary: 220 13% 95%;
--primary-foreground: 220 16% 6%;
--secondary: 220 14% 14%;
--secondary-foreground: 220 13% 95%;
--muted: 220 14% 14%;
--muted-foreground: 220 9% 55%;
--accent: 220 14% 14%;
--accent-foreground: 220 13% 95%;
--destructive: 0 62% 50%;
--destructive-foreground: 220 13% 95%;
--border: 220 14% 16%;
--input: 220 14% 16%;
--ring: 220 13% 95%;
--success: 142 71% 45%;
--warning: 38 92% 50%;
/* Chart colors */
--chart-1: 217 91% 65%;
--chart-2: 160 84% 45%;
--chart-3: 43 96% 60%;
--chart-4: 0 72% 55%;
--chart-5: 262 83% 65%;
}
}
@layer base {
* {
@apply border-border;
}
/* 响应式基础字体 - 放大 */
html {
font-size: 15px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@media (min-width: 640px) {
html {
font-size: 16px;
}
}
@media (min-width: 1024px) {
html {
font-size: 17px;
}
}
@media (min-width: 1280px) {
html {
font-size: 18px;
}
}
body {
@apply bg-background text-foreground;
font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, sans-serif;
margin: 0;
line-height: 1.6;
}
}
@layer components {
/* 响应式容器 - 全屏宽度 */
.container {
@apply mx-auto px-3 sm:px-4 lg:px-6 xl:px-8;
max-width: 100%;
}
/* Stat pill - 放大 */
.stat-pill {
@apply flex items-center gap-2 px-3 py-1.5 rounded-md bg-muted/60 text-sm;
}
.stat-pill-value {
@apply font-mono font-semibold tabular-nums text-base;
}
/* Card - 放大 */
.card-compact {
@apply rounded-lg border bg-card shadow-sm;
}
.card-compact-header {
@apply flex items-center justify-between px-4 py-3 border-b bg-muted/30;
}
.card-compact-body {
@apply p-4;
}
/* Form field - 放大 */
.field-label {
@apply text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1.5;
}
.field-input {
@apply h-10 text-sm px-3;
}
.field-input-mono {
@apply h-10 text-sm px-3 font-mono;
}
/* Live log styling - 放大 */
.log-line {
@apply flex items-center gap-3 py-1.5 px-2 text-sm font-mono rounded-md;
@apply hover:bg-muted/50 transition-colors;
}
.log-time {
@apply text-muted-foreground w-20 shrink-0 text-xs sm:text-sm;
}
.log-type {
@apply w-16 shrink-0 font-medium text-xs sm:text-sm;
}
.log-target {
@apply truncate text-xs sm:text-sm;
}
/* Type colors */
.type-host { @apply text-blue-600 dark:text-blue-400; }
.type-port { @apply text-emerald-600 dark:text-emerald-400; }
.type-service { @apply text-amber-600 dark:text-amber-400; }
.type-vuln { @apply text-red-600 dark:text-red-400; }
/* Badge - 放大 */
.badge {
@apply inline-flex items-center px-2 py-1 rounded-md text-xs font-medium uppercase tracking-wide;
}
.badge-host { @apply bg-blue-500/10 text-blue-600 dark:text-blue-400; }
.badge-port { @apply bg-emerald-500/10 text-emerald-600 dark:text-emerald-400; }
.badge-service { @apply bg-amber-500/10 text-amber-600 dark:text-amber-400; }
.badge-vuln { @apply bg-red-500/10 text-red-600 dark:text-red-400; }
/* Switch row - 放大 */
.switch-row {
@apply flex items-center justify-between py-2 px-3 rounded-md bg-muted/40 text-sm;
}
/* Scrollbar */
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: hsl(var(--border)) transparent;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: hsl(var(--border));
border-radius: 3px;
}
/* Animations */
@keyframes fade-in {
from { opacity: 0; transform: translateY(-2px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in {
animation: fade-in 0.15s ease-out;
}
@keyframes slide-up {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-slide-up {
animation: slide-up 0.2s ease-out;
}
@keyframes pulse-soft {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.animate-pulse-soft {
animation: pulse-soft 2s ease-in-out infinite;
}
/* Focus ring */
.focus-ring {
@apply focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background;
}
/* Hover lift effect */
.hover-lift {
@apply transition-transform hover:-translate-y-0.5;
}
/* Subtle shadow on hover */
.hover-shadow {
@apply transition-shadow hover:shadow-md;
}
}
@layer utilities {
.font-mono {
font-family: 'IBM Plex Mono', ui-monospace, monospace;
}
}
+141
View File
@@ -0,0 +1,141 @@
const API_BASE = '/api';
export interface ScanRequest {
host: string;
ports?: string;
exclude_hosts?: string;
exclude_ports?: string;
scan_mode?: string;
thread_num?: number;
timeout?: number;
module_thread_num?: number;
disable_ping?: boolean;
disable_brute?: boolean;
alive_only?: boolean;
username?: string;
password?: string;
domain?: string;
poc_path?: string;
poc_name?: string;
poc_full?: boolean;
disable_poc?: boolean;
}
export interface ScanStatus {
state: 'idle' | 'running' | 'stopping';
start_time?: string;
progress: number;
stats: ScanStats;
}
export interface ScanStats {
hosts_scanned: number;
ports_scanned: number;
services_found: number;
vulns_found: number;
}
export interface ResultItem {
id: number;
time: string;
type: string;
target: string;
status: string;
details?: Record<string, unknown>;
}
export interface ScanPreset {
id: string;
name: string;
name_en: string;
description: string;
description_en: string;
ports: string;
scan_mode: string;
thread_num: number;
timeout: number;
}
export interface PluginInfo {
name: string;
type: string;
description: string;
description_en: string;
enabled: boolean;
}
// API functions
export async function startScan(request: ScanRequest): Promise<{ status: string; start_time: string }> {
const response = await fetch(`${API_BASE}/scan/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to start scan');
}
return response.json();
}
export async function stopScan(): Promise<{ status: string }> {
const response = await fetch(`${API_BASE}/scan/stop`, {
method: 'POST',
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to stop scan');
}
return response.json();
}
export async function getScanStatus(): Promise<ScanStatus> {
const response = await fetch(`${API_BASE}/scan/status`);
if (!response.ok) {
throw new Error('Failed to get scan status');
}
return response.json();
}
export async function getResults(type?: string): Promise<{ items: ResultItem[]; total: number; stats: ScanStats }> {
const url = type ? `${API_BASE}/results?type=${type}` : `${API_BASE}/results`;
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to get results');
}
return response.json();
}
export async function exportResults(format: 'json' | 'csv'): Promise<Blob> {
const response = await fetch(`${API_BASE}/results/export?format=${format}`);
if (!response.ok) {
throw new Error('Failed to export results');
}
return response.blob();
}
export async function clearResults(): Promise<{ status: string }> {
const response = await fetch(`${API_BASE}/results/clear`, {
method: 'POST',
});
if (!response.ok) {
throw new Error('Failed to clear results');
}
return response.json();
}
export async function getPresets(): Promise<ScanPreset[]> {
const response = await fetch(`${API_BASE}/config/presets`);
if (!response.ok) {
throw new Error('Failed to get presets');
}
return response.json();
}
export async function getPlugins(): Promise<PluginInfo[]> {
const response = await fetch(`${API_BASE}/config/plugins`);
if (!response.ok) {
throw new Error('Failed to get plugins');
}
return response.json();
}
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+299
View File
@@ -0,0 +1,299 @@
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
Trash2, RefreshCw, Server, Network, Cog, AlertTriangle,
FileJson, FileSpreadsheet, List, Filter, Clock, CircleDot, Inbox,
Download, ChevronDown
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Skeleton } from '@/components/ui/skeleton';
import { EmptyState } from '@/components/ui/empty-state';
import { LiveFeed } from '@/components/LiveFeed';
import { ResultsChart } from '@/components/ResultsChart';
import { getResults, exportResults, clearResults, type ResultItem } from '@/lib/api';
import { useLiveFeed } from '@/contexts/LiveFeedContext';
const TYPE_ICONS = {
host: Server,
port: Network,
service: Cog,
vuln: AlertTriangle,
} as const;
export function ResultsPage() {
const { t } = useTranslation();
const { clearLogs } = useLiveFeed();
const [results, setResults] = useState<ResultItem[]>([]);
const [filter, setFilter] = useState<string>('all');
const [loading, setLoading] = useState(false);
const fetchResults = useCallback(async () => {
setLoading(true);
try {
const data = await getResults(filter === 'all' ? undefined : filter);
setResults(data.items);
} catch (err) {
console.error('Failed to fetch results:', err);
} finally {
setLoading(false);
}
}, [filter]);
useEffect(() => {
fetchResults();
}, [fetchResults]);
const handleExport = async (format: 'json' | 'csv') => {
try {
const blob = await exportResults(format);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `fscan_results.${format}`;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
console.error('Failed to export:', err);
}
};
const handleClear = async () => {
try {
await clearResults();
setResults([]);
clearLogs();
} catch (err) {
console.error('Failed to clear:', err);
}
};
const getTypeIcon = (type: string) => {
const key = type?.toLowerCase() as keyof typeof TYPE_ICONS;
return TYPE_ICONS[key] || CircleDot;
};
const getTypeLabel = (type: string) => {
switch (type?.toLowerCase()) {
case 'host': return t('typeHost');
case 'port': return t('typePort');
case 'service': return t('typeService');
case 'vuln': return t('typeVuln');
default: return type;
}
};
const filteredResults = filter === 'all'
? results
: results.filter(r => r.type === filter);
return (
<TooltipProvider>
<div className="grid grid-cols-1 lg:grid-cols-10 gap-4">
{/* Left Panel - 7 cols */}
<div className="lg:col-span-7 space-y-4">
{/* Live Feed */}
<LiveFeed compact showTypeLabel />
{/* Results Panel */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<CardTitle className="flex items-center gap-2 text-base">
<List className="w-4 h-4 sm:w-5 sm:h-5 text-muted-foreground" />
{t('resultsTitle')}
<Badge variant="secondary" className="font-mono">
{filteredResults.length} {t('items')}
</Badge>
</CardTitle>
<div className="flex items-center gap-1 sm:gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={fetchResults}
disabled={loading}
className="gap-1.5"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
<span className="hidden sm:inline">{t('refresh')}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{t('refresh')}</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-1.5">
<Download className="w-4 h-4" />
<span className="hidden sm:inline">{t('export')}</span>
<ChevronDown className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleExport('json')}>
<FileJson className="w-4 h-4 mr-2" />
JSON
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport('csv')}>
<FileSpreadsheet className="w-4 h-4 mr-2" />
CSV
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Separator orientation="vertical" className="h-5 mx-1" />
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive hover:bg-destructive/10 gap-1.5"
>
<Trash2 className="w-4 h-4" />
<span className="hidden sm:inline">{t('clearAll')}</span>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('clearConfirmTitle')}</AlertDialogTitle>
<AlertDialogDescription>{t('clearConfirm')}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={handleClear} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{t('clearAll')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardHeader>
<CardContent>
<Tabs value={filter} onValueChange={setFilter}>
<TabsList className="h-9 sm:h-10 p-1 bg-muted/50 mb-4">
<TabsTrigger value="all" className="h-7 sm:h-8 px-3 text-xs sm:text-sm gap-1.5">
<Filter className="w-3.5 h-3.5" />
{t('resultsFilterAll')}
</TabsTrigger>
<TabsTrigger value="host" className="h-7 sm:h-8 px-3 text-xs sm:text-sm gap-1.5">
<Server className="w-3.5 h-3.5" />
<span className="hidden sm:inline">{t('resultsFilterHosts')}</span>
</TabsTrigger>
<TabsTrigger value="port" className="h-7 sm:h-8 px-3 text-xs sm:text-sm gap-1.5">
<Network className="w-3.5 h-3.5" />
<span className="hidden sm:inline">{t('resultsFilterPorts')}</span>
</TabsTrigger>
<TabsTrigger value="service" className="h-7 sm:h-8 px-3 text-xs sm:text-sm gap-1.5">
<Cog className="w-3.5 h-3.5" />
<span className="hidden sm:inline">{t('resultsFilterServices')}</span>
</TabsTrigger>
<TabsTrigger value="vuln" className="h-7 sm:h-8 px-3 text-xs sm:text-sm gap-1.5">
<AlertTriangle className="w-3.5 h-3.5" />
<span className="hidden sm:inline">{t('resultsFilterVulns')}</span>
</TabsTrigger>
</TabsList>
<TabsContent value={filter} className="mt-0">
<ScrollArea className="h-[calc(100vh-420px)] min-h-[400px]">
{loading ? (
<div className="space-y-3 py-2">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-start gap-3 p-3 rounded-lg border">
<Skeleton className="w-10 h-10 rounded-lg shrink-0" />
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-4 w-48" />
</div>
<Skeleton className="h-5 w-20 shrink-0" />
</div>
))}
</div>
) : filteredResults.length === 0 ? (
<EmptyState
icon={Inbox}
title={t('resultsEmpty')}
description={t('resultsEmptyDescription')}
className="py-16"
/>
) : (
<div className="space-y-2">
{filteredResults.map((result) => {
const Icon = getTypeIcon(result.type);
const badgeVariant = result.type?.toLowerCase() as 'host' | 'port' | 'service' | 'vuln';
return (
<div
key={result.id}
className="group flex items-start gap-3 p-3 rounded-lg border bg-background hover:border-foreground/20 hover:bg-muted/30 transition-all"
>
<div className="shrink-0 w-8 h-8 sm:w-10 sm:h-10 rounded-lg flex items-center justify-center bg-muted group-hover:scale-105 transition-transform">
<Icon className="w-4 h-4 sm:w-5 sm:h-5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Badge variant={badgeVariant}>
{getTypeLabel(result.type)}
</Badge>
<span className="font-mono text-sm font-medium truncate">
{result.target}
</span>
</div>
{result.status && (
<p className="mt-1 text-sm text-muted-foreground truncate">
{result.status}
</p>
)}
</div>
<Badge variant="outline" className="shrink-0 gap-1 font-mono text-xs">
<Clock className="w-3 h-3" />
{new Date(result.time).toLocaleTimeString()}
</Badge>
</div>
);
})}
</div>
)}
</ScrollArea>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
{/* Right Panel - 3 cols */}
<div className="lg:col-span-3">
<div className="lg:sticky lg:top-20">
<ResultsChart results={results} />
</div>
</div>
</div>
</TooltipProvider>
);
}
+119
View File
@@ -0,0 +1,119 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { TooltipProvider } from '@/components/ui/tooltip';
import { ScanForm } from '@/components/ScanForm';
import { LiveFeed } from '@/components/LiveFeed';
import { StatsPanel } from '@/components/StatsPanel';
import { startScan, stopScan, getScanStatus, getPresets, type ScanRequest, type ScanStatus, type ScanPreset } from '@/lib/api';
import { useLiveFeed } from '@/contexts/LiveFeedContext';
const DEFAULT_FORM: ScanRequest = {
host: '',
ports: '',
scan_mode: 'all',
thread_num: 600,
timeout: 3,
disable_ping: false,
disable_brute: false,
alive_only: false,
username: '',
password: '',
domain: '',
exclude_hosts: '',
exclude_ports: '',
};
export function ScanPage() {
const { t } = useTranslation();
const { clearLogs } = useLiveFeed();
const [status, setStatus] = useState<ScanStatus | null>(null);
const [presets, setPresets] = useState<ScanPreset[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState<ScanRequest>(DEFAULT_FORM);
useEffect(() => {
const fetchData = async () => {
try {
const [statusData, presetsData] = await Promise.all([
getScanStatus(),
getPresets(),
]);
setStatus(statusData);
setPresets(presetsData);
} catch (err) {
console.error('Failed to fetch data:', err);
}
};
fetchData();
const interval = setInterval(async () => {
try {
const statusData = await getScanStatus();
setStatus(statusData);
} catch {
// ignore
}
}, 2000);
return () => clearInterval(interval);
}, []);
const handleStart = async () => {
if (!formData.host) {
setError(t('targetRequired'));
return;
}
setLoading(true);
setError(null);
clearLogs();
try {
await startScan(formData);
} catch (err) {
setError(err instanceof Error ? err.message : t('startScanFailed'));
} finally {
setLoading(false);
}
};
const handleStop = async () => {
setLoading(true);
try {
await stopScan();
} catch (err) {
setError(err instanceof Error ? err.message : t('stopScanFailed'));
} finally {
setLoading(false);
}
};
const isRunning = status?.state === 'running';
const isStopping = status?.state === 'stopping';
return (
<TooltipProvider>
<div className="grid grid-cols-1 lg:grid-cols-10 gap-4 h-full">
{/* Left Panel - 7 cols */}
<div className="lg:col-span-7 flex flex-col gap-4 min-h-0">
<ScanForm
formData={formData}
onFormChange={setFormData}
presets={presets}
isRunning={isRunning}
isStopping={isStopping}
loading={loading}
error={error}
onStart={handleStart}
onStop={handleStop}
/>
<LiveFeed />
</div>
{/* Right Panel - 3 cols */}
<div className="lg:col-span-3">
<StatsPanel status={status} />
</div>
</div>
</TooltipProvider>
);
}
+66
View File
@@ -0,0 +1,66 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
fontFamily: {
sans: ['IBM Plex Sans', '-apple-system', 'BlinkMacSystemFont', 'sans-serif'],
mono: ['IBM Plex Mono', 'ui-monospace', 'monospace'],
},
fontSize: {
'2xs': ['0.625rem', { lineHeight: '0.875rem' }],
},
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
success: "hsl(var(--success))",
warning: "hsl(var(--warning))",
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
spacing: {
'0.75': '0.1875rem',
'1.25': '0.3125rem',
},
},
},
plugins: [require("tailwindcss-animate")],
}
+32
View File
@@ -0,0 +1,32 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client", "node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
proxy: {
'/api': {
target: 'http://localhost:10240',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:10240',
ws: true,
},
},
},
})