mirror of
https://github.com/yyhuni/xingrin.git
synced 2026-02-01 20:23:23 +08:00
Compare commits
82 Commits
dev
...
002-server
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fd832ce22 | ||
|
|
e76ecaac15 | ||
|
|
08e6c7fbe3 | ||
|
|
5adb239547 | ||
|
|
896ae7743d | ||
|
|
d5c363294b | ||
|
|
4734f7a576 | ||
|
|
46b1d5a1d1 | ||
|
|
66fa60c415 | ||
|
|
3d54d26c7e | ||
|
|
b4a289b198 | ||
|
|
b727b2d001 | ||
|
|
b859fc9062 | ||
|
|
49b5fbef28 | ||
|
|
11112a68f6 | ||
|
|
9049b096ba | ||
|
|
ca6c0eb082 | ||
|
|
64bcd9a6f5 | ||
|
|
443e2172e4 | ||
|
|
c6dcfb0a5b | ||
|
|
25ae325c69 | ||
|
|
cab83d89cf | ||
|
|
0f8fff2dc4 | ||
|
|
6e48b97dc2 | ||
|
|
ed757d6e14 | ||
|
|
2aa1afbabf | ||
|
|
35ac64db57 | ||
|
|
b4bfab92e3 | ||
|
|
72210c42d0 | ||
|
|
91aaf7997f | ||
|
|
32e3179d58 | ||
|
|
487f7c84b5 | ||
|
|
b2cc83f569 | ||
|
|
f854cf09be | ||
|
|
7e1c2c187a | ||
|
|
4abb259ca0 | ||
|
|
bbef6af000 | ||
|
|
ba0864ed16 | ||
|
|
f54827829a | ||
|
|
170021130c | ||
|
|
b540f69152 | ||
|
|
d7f1e04855 | ||
|
|
68ad18e6da | ||
|
|
a7542d4a34 | ||
|
|
6f02d9f3c5 | ||
|
|
794846ca7a | ||
|
|
5eea7b2621 | ||
|
|
069527a7f1 | ||
|
|
e542633ad3 | ||
|
|
e8a9606d3b | ||
|
|
dc2e1e027d | ||
|
|
b1847faa3a | ||
|
|
e699842492 | ||
|
|
08a4807bef | ||
|
|
191ff9837b | ||
|
|
679dff9037 | ||
|
|
ce4330b628 | ||
|
|
4ce6b148f8 | ||
|
|
a89f775ee9 | ||
|
|
e3003f33f9 | ||
|
|
3760684b64 | ||
|
|
bfd7e11d09 | ||
|
|
f758feb0d0 | ||
|
|
8798eed337 | ||
|
|
bd1e25cfd5 | ||
|
|
d775055572 | ||
|
|
00dfad60b8 | ||
|
|
a5c48fe4d4 | ||
|
|
85c880731c | ||
|
|
c6b6507412 | ||
|
|
af457dc44c | ||
|
|
9e01a6aa5e | ||
|
|
ed80772e6f | ||
|
|
a22af21dcb | ||
|
|
8de950a7a5 | ||
|
|
9db84221e9 | ||
|
|
0728f3c01d | ||
|
|
4aa7b3d68a | ||
|
|
3946a53337 | ||
|
|
c94fe1ec4b | ||
|
|
6dea525527 | ||
|
|
5b0416972a |
45
.github/workflows/check-generated-files.yml
vendored
Normal file
45
.github/workflows/check-generated-files.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
name: Check Generated Files
|
||||
|
||||
on:
|
||||
workflow_call: # 只在被其他 workflow 调用时运行
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-22.04 # 固定版本,避免 runner 更新导致 CI 行为变化
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24' # 与 go.mod 保持一致
|
||||
|
||||
- name: Generate files for all workflows
|
||||
working-directory: worker
|
||||
run: make generate
|
||||
|
||||
- name: Check for differences
|
||||
run: |
|
||||
if ! git diff --exit-code; then
|
||||
echo "❌ Generated files are out of date!"
|
||||
echo "Please run: cd worker && make generate"
|
||||
echo ""
|
||||
echo "Changed files:"
|
||||
git status --porcelain
|
||||
echo ""
|
||||
echo "Diff:"
|
||||
git diff
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Generated files are up to date"
|
||||
|
||||
- name: Run metadata consistency tests
|
||||
working-directory: worker
|
||||
run: make test-metadata
|
||||
|
||||
- name: Run all tests
|
||||
working-directory: worker
|
||||
run: make test
|
||||
13
.github/workflows/ci.yml
vendored
Normal file
13
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-generated:
|
||||
uses: ./.github/workflows/check-generated-files.yml
|
||||
169
.gitignore
vendored
169
.gitignore
vendored
@@ -1,137 +1,60 @@
|
||||
# ============================
|
||||
# 操作系统相关文件
|
||||
# ============================
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
# Go
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.test
|
||||
*.out
|
||||
vendor/
|
||||
go.work
|
||||
|
||||
# ============================
|
||||
# 前端 (Next.js/Node.js) 相关
|
||||
# ============================
|
||||
# 依赖目录
|
||||
front-back/node_modules/
|
||||
front-back/.pnpm-store/
|
||||
# Build artifacts
|
||||
dist/
|
||||
build/
|
||||
bin/
|
||||
|
||||
# Next.js 构建产物
|
||||
front-back/.next/
|
||||
front-back/out/
|
||||
front-back/dist/
|
||||
|
||||
# 环境变量文件
|
||||
front-back/.env
|
||||
front-back/.env.local
|
||||
front-back/.env.development.local
|
||||
front-back/.env.test.local
|
||||
front-back/.env.production.local
|
||||
|
||||
# 运行时和缓存
|
||||
front-back/.turbo/
|
||||
front-back/.swc/
|
||||
front-back/.eslintcache
|
||||
front-back/.tsbuildinfo
|
||||
|
||||
# ============================
|
||||
# 后端 (Python/Django) 相关
|
||||
# ============================
|
||||
# Python 虚拟环境
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# Python 编译文件
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Django 相关
|
||||
backend/db.sqlite3
|
||||
backend/db.sqlite3-journal
|
||||
backend/media/
|
||||
backend/staticfiles/
|
||||
backend/.env
|
||||
backend/.env.local
|
||||
|
||||
# Python 测试和覆盖率
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
*.cover
|
||||
.hypothesis/
|
||||
|
||||
# ============================
|
||||
# 后端 (Go) 相关
|
||||
# ============================
|
||||
# 编译产物
|
||||
backend/bin/
|
||||
backend/dist/
|
||||
backend/*.exe
|
||||
backend/*.exe~
|
||||
backend/*.dll
|
||||
backend/*.so
|
||||
backend/*.dylib
|
||||
|
||||
# 测试相关
|
||||
backend/*.test
|
||||
backend/*.out
|
||||
backend/*.prof
|
||||
|
||||
# Go workspace 文件
|
||||
backend/go.work
|
||||
backend/go.work.sum
|
||||
|
||||
# Go 依赖管理
|
||||
backend/vendor/
|
||||
|
||||
# ============================
|
||||
# IDE 和编辑器相关
|
||||
# ============================
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
.cursor/
|
||||
.claude/
|
||||
.kiro/
|
||||
.playwright-mcp/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# ============================
|
||||
# Docker 相关
|
||||
# ============================
|
||||
docker/.env
|
||||
docker/.env.local
|
||||
|
||||
# SSL 证书和私钥(不应提交)
|
||||
docker/nginx/ssl/*.pem
|
||||
docker/nginx/ssl/*.key
|
||||
docker/nginx/ssl/*.crt
|
||||
|
||||
# ============================
|
||||
# 日志文件和扫描结果
|
||||
# ============================
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
**/.env
|
||||
**/.env.local
|
||||
**/.env.*.local
|
||||
*.log
|
||||
logs/
|
||||
results/
|
||||
.venv/
|
||||
|
||||
# 开发脚本运行时文件(进程 ID 和启动日志)
|
||||
backend/scripts/dev/.pids/
|
||||
# Testing
|
||||
coverage.txt
|
||||
*.coverprofile
|
||||
.hypothesis/
|
||||
|
||||
# ============================
|
||||
# 临时文件
|
||||
# ============================
|
||||
# Temporary files
|
||||
*.tmp
|
||||
tmp/
|
||||
temp/
|
||||
.cache/
|
||||
|
||||
HGETALL
|
||||
KEYS
|
||||
vuln_scan/input_endpoints.txt
|
||||
open-in-v0
|
||||
.kiro/
|
||||
.claude/
|
||||
.specify/
|
||||
|
||||
# AI Assistant directories
|
||||
codex/
|
||||
openspec/
|
||||
specs/
|
||||
AGENTS.md
|
||||
WARP.md
|
||||
.opencode/
|
||||
|
||||
# SSL certificates
|
||||
docker/nginx/ssl/*.pem
|
||||
docker/nginx/ssl/*.key
|
||||
docker/nginx/ssl/*.crt
|
||||
333
README.md
333
README.md
@@ -1,333 +0,0 @@
|
||||
<h1 align="center">XingRin - 星环</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>攻击面管理平台 (ASM) | 自动化资产发现与漏洞扫描系统</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/yyhuni/xingrin/stargazers"><img src="https://img.shields.io/github/stars/yyhuni/xingrin?style=flat-square&logo=github" alt="GitHub stars"></a>
|
||||
<a href="https://github.com/yyhuni/xingrin/network/members"><img src="https://img.shields.io/github/forks/yyhuni/xingrin?style=flat-square&logo=github" alt="GitHub forks"></a>
|
||||
<a href="https://github.com/yyhuni/xingrin/issues"><img src="https://img.shields.io/github/issues/yyhuni/xingrin?style=flat-square&logo=github" alt="GitHub issues"></a>
|
||||
<a href="https://github.com/yyhuni/xingrin/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-PolyForm%20NC-blue?style=flat-square" alt="License"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#功能特性">功能特性</a> •
|
||||
<a href="#全局资产搜索">资产搜索</a> •
|
||||
<a href="#快速开始">快速开始</a> •
|
||||
<a href="#文档">文档</a> •
|
||||
<a href="#反馈与贡献">反馈与贡献</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub>关键词: ASM | 攻击面管理 | 漏洞扫描 | 资产发现 | 资产搜索 | Bug Bounty | 渗透测试 | Nuclei | 子域名枚举 | EASM</sub>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 在线 Demo
|
||||
|
||||
**[https://xingrin.vercel.app/](https://xingrin.vercel.app/)**
|
||||
|
||||
> 仅用于 UI 展示,未接入后端数据库
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<b>现代化 UI</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/light.png" alt="Light Mode" width="24%">
|
||||
<img src="docs/screenshots/bubblegum.png" alt="Bubblegum" width="24%">
|
||||
<img src="docs/screenshots/cosmic-night.png" alt="Cosmic Night" width="24%">
|
||||
<img src="docs/screenshots/quantum-rose.png" alt="Quantum Rose" width="24%">
|
||||
</p>
|
||||
|
||||
## 文档
|
||||
|
||||
- [技术文档](./docs/README.md) - 技术文档导航(持续完善中)
|
||||
- [快速开始](./docs/quick-start.md) - 一键安装和部署指南
|
||||
- [版本管理](./docs/version-management.md) - Git Tag 驱动的自动化版本管理系统
|
||||
- [Nuclei 模板架构](./docs/nuclei-template-architecture.md) - 模板仓库的存储与同步
|
||||
- [字典文件架构](./docs/wordlist-architecture.md) - 字典文件的存储与同步
|
||||
- [扫描流程架构](./docs/scan-flow-architecture.md) - 完整扫描流程与工具编排
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 扫描能力
|
||||
|
||||
| 功能 | 状态 | 工具 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 子域名扫描 | 已完成 | Subfinder, Amass, PureDNS | 被动收集 + 主动爆破,聚合 50+ 数据源 |
|
||||
| 端口扫描 | 已完成 | Naabu | 自定义端口范围 |
|
||||
| 站点发现 | 已完成 | HTTPX | HTTP 探测,自动获取标题、状态码、技术栈 |
|
||||
| 指纹识别 | 已完成 | XingFinger | 2.7W+ 指纹规则,多源指纹库 |
|
||||
| URL 收集 | 已完成 | Waymore, Katana | 历史数据 + 主动爬取 |
|
||||
| 目录扫描 | 已完成 | FFUF | 高速爆破,智能字典 |
|
||||
| 漏洞扫描 | 已完成 | Nuclei, Dalfox | 9000+ POC 模板,XSS 检测 |
|
||||
| 站点截图 | 已完成 | Playwright | WebP 高压缩存储 |
|
||||
|
||||
### 平台能力
|
||||
|
||||
| 功能 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| 目标管理 | 已完成 | 多层级组织,支持域名/IP 目标 |
|
||||
| 资产快照 | 已完成 | 扫描结果对比,追踪资产变化 |
|
||||
| 黑名单过滤 | 已完成 | 全局 + Target 级,支持通配符/CIDR |
|
||||
| 定时任务 | 已完成 | Cron 表达式,自动化周期扫描 |
|
||||
| 分布式扫描 | 已完成 | 多 Worker 节点,负载感知调度 |
|
||||
| 全局搜索 | 已完成 | 表达式语法,多字段组合查询 |
|
||||
| 通知推送 | 已完成 | 企业微信、Telegram、Discord |
|
||||
| API 密钥管理 | 已完成 | 可视化配置各数据源 API Key |
|
||||
|
||||
### 扫描流程架构
|
||||
|
||||
完整的扫描流程包括:子域名发现、端口扫描、站点发现、指纹识别、URL 收集、目录扫描、漏洞扫描等阶段
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
START["开始扫描"]
|
||||
|
||||
subgraph STAGE1["阶段 1: 资产发现"]
|
||||
direction TB
|
||||
SUB["子域名发现<br/>subfinder, amass, puredns"]
|
||||
PORT["端口扫描<br/>naabu"]
|
||||
SITE["站点识别<br/>httpx"]
|
||||
FINGER["指纹识别<br/>xingfinger"]
|
||||
SUB --> PORT --> SITE --> FINGER
|
||||
end
|
||||
|
||||
subgraph STAGE2["阶段 2: 深度分析"]
|
||||
direction TB
|
||||
URL["URL 收集<br/>waymore, katana"]
|
||||
DIR["目录扫描<br/>ffuf"]
|
||||
SCREENSHOT["站点截图<br/>playwright"]
|
||||
end
|
||||
|
||||
subgraph STAGE3["阶段 3: 漏洞检测"]
|
||||
VULN["漏洞扫描<br/>nuclei, dalfox"]
|
||||
end
|
||||
|
||||
FINISH["扫描完成"]
|
||||
|
||||
START --> STAGE1
|
||||
FINGER --> STAGE2
|
||||
STAGE2 --> STAGE3
|
||||
STAGE3 --> FINISH
|
||||
|
||||
style START fill:#34495e,stroke:#2c3e50,stroke-width:2px,color:#fff
|
||||
style FINISH fill:#27ae60,stroke:#229954,stroke-width:2px,color:#fff
|
||||
style STAGE1 fill:#3498db,stroke:#2980b9,stroke-width:2px,color:#fff
|
||||
style STAGE2 fill:#9b59b6,stroke:#8e44ad,stroke-width:2px,color:#fff
|
||||
style STAGE3 fill:#e67e22,stroke:#d35400,stroke-width:2px,color:#fff
|
||||
style SUB fill:#5dade2,stroke:#3498db,stroke-width:1px,color:#fff
|
||||
style PORT fill:#5dade2,stroke:#3498db,stroke-width:1px,color:#fff
|
||||
style SITE fill:#5dade2,stroke:#3498db,stroke-width:1px,color:#fff
|
||||
style FINGER fill:#5dade2,stroke:#3498db,stroke-width:1px,color:#fff
|
||||
style URL fill:#bb8fce,stroke:#9b59b6,stroke-width:1px,color:#fff
|
||||
style DIR fill:#bb8fce,stroke:#9b59b6,stroke-width:1px,color:#fff
|
||||
style SCREENSHOT fill:#bb8fce,stroke:#9b59b6,stroke-width:1px,color:#fff
|
||||
style VULN fill:#f0b27a,stroke:#e67e22,stroke-width:1px,color:#fff
|
||||
```
|
||||
|
||||
详细说明请查看 [扫描流程架构文档](./docs/scan-flow-architecture.md)
|
||||
|
||||
### 分布式架构
|
||||
- **多节点扫描** - 支持部署多个 Worker 节点,横向扩展扫描能力
|
||||
- **本地节点** - 零配置,安装即自动注册本地 Docker Worker
|
||||
- **远程节点** - SSH 一键部署远程 VPS 作为扫描节点
|
||||
- **负载感知调度** - 实时感知节点负载,自动分发任务到最优节点
|
||||
- **节点监控** - 实时心跳检测,CPU/内存/磁盘状态监控
|
||||
- **断线重连** - 节点离线自动检测,恢复后自动重新接入
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph MASTER["主服务器 (Master Server)"]
|
||||
direction TB
|
||||
|
||||
REDIS["Redis 负载缓存"]
|
||||
|
||||
subgraph SCHEDULER["任务调度器 (Task Distributor)"]
|
||||
direction TB
|
||||
SUBMIT["接收扫描任务"]
|
||||
SELECT["负载感知选择"]
|
||||
DISPATCH["智能分发"]
|
||||
|
||||
SUBMIT --> SELECT
|
||||
SELECT --> DISPATCH
|
||||
end
|
||||
|
||||
REDIS -.负载数据.-> SELECT
|
||||
end
|
||||
|
||||
subgraph WORKERS["Worker 节点集群"]
|
||||
direction TB
|
||||
|
||||
W1["Worker 1 (本地)<br/>CPU: 45% | MEM: 60%"]
|
||||
W2["Worker 2 (远程)<br/>CPU: 30% | MEM: 40%"]
|
||||
W3["Worker N (远程)<br/>CPU: 90% | MEM: 85%"]
|
||||
end
|
||||
|
||||
DISPATCH -->|任务分发| W1
|
||||
DISPATCH -->|任务分发| W2
|
||||
DISPATCH -->|高负载跳过| W3
|
||||
|
||||
W1 -.心跳上报.-> REDIS
|
||||
W2 -.心跳上报.-> REDIS
|
||||
W3 -.心跳上报.-> REDIS
|
||||
```
|
||||
|
||||
### 全局资产搜索
|
||||
- **多类型搜索** - 支持 Website 和 Endpoint 两种资产类型
|
||||
- **表达式语法** - 支持 `=`(模糊)、`==`(精确)、`!=`(不等于)操作符
|
||||
- **逻辑组合** - 支持 `&&` (AND) 和 `||` (OR) 逻辑组合
|
||||
- **多字段查询** - 支持 host、url、title、tech、status、body、header 字段
|
||||
- **CSV 导出** - 流式导出全部搜索结果,无数量限制
|
||||
|
||||
#### 搜索语法示例
|
||||
|
||||
```bash
|
||||
# 基础搜索
|
||||
host="api" # host 包含 "api"
|
||||
status=="200" # 状态码精确等于 200
|
||||
tech="nginx" # 技术栈包含 nginx
|
||||
|
||||
# 组合搜索
|
||||
host="api" && status=="200" # host 包含 api 且状态码为 200
|
||||
tech="vue" || tech="react" # 技术栈包含 vue 或 react
|
||||
|
||||
# 复杂查询
|
||||
host="admin" && tech="php" && status=="200"
|
||||
url="/api/v1" && status!="404"
|
||||
```
|
||||
|
||||
### 可视化界面
|
||||
- **数据统计** - 资产/漏洞统计仪表盘
|
||||
- **实时通知** - WebSocket 消息推送
|
||||
- **通知推送** - 实时企业微信,tg,discard消息推送服务
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- **操作系统**: Ubuntu 20.04+ / Debian 11+
|
||||
- **系统架构**: AMD64 (x86_64) / ARM64 (aarch64)
|
||||
- **硬件**: 2核 4G 内存起步,20GB+ 磁盘空间
|
||||
|
||||
### 一键安装
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone https://github.com/yyhuni/xingrin.git
|
||||
cd xingrin
|
||||
|
||||
# 安装并启动(生产模式)
|
||||
sudo ./install.sh
|
||||
|
||||
# 中国大陆用户推荐使用镜像加速(第三方加速服务可能会失效,不保证长期可用)
|
||||
sudo ./install.sh --mirror
|
||||
```
|
||||
|
||||
> **--mirror 参数说明**
|
||||
> - 自动配置 Docker 镜像加速(国内镜像源)
|
||||
> - 加速 Git 仓库克隆(Nuclei 模板等)
|
||||
|
||||
### 访问服务
|
||||
|
||||
- **Web 界面**: `https://ip:8083`
|
||||
- **默认账号**: admin / admin(首次登录后请修改密码)
|
||||
|
||||
### 常用命令
|
||||
|
||||
```bash
|
||||
# 启动服务
|
||||
sudo ./start.sh
|
||||
|
||||
# 停止服务
|
||||
sudo ./stop.sh
|
||||
|
||||
# 重启服务
|
||||
sudo ./restart.sh
|
||||
|
||||
# 卸载
|
||||
sudo ./uninstall.sh
|
||||
```
|
||||
|
||||
## 反馈与贡献
|
||||
|
||||
- **发现 Bug,有新想法,比如UI设计,功能设计等** 欢迎点击右边链接进行提交建议 [Issue](https://github.com/yyhuni/xingrin/issues) 或者公众号私信
|
||||
|
||||
## 联系
|
||||
- 微信公众号: **塔罗安全学苑**
|
||||
- 微信群去公众号底下的菜单,有个交流群,点击就可以看到了,链接过期可以私信我拉你
|
||||
|
||||
<img src="docs/wechat-qrcode.png" alt="微信公众号" width="200">
|
||||
|
||||
### 关注公众号免费领取指纹库
|
||||
|
||||
| 指纹库 | 数量 |
|
||||
|--------|------|
|
||||
| ehole.json | 21,977 |
|
||||
| ARL.yaml | 9,264 |
|
||||
| goby.json | 7,086 |
|
||||
| FingerprintHub.json | 3,147 |
|
||||
|
||||
> 关注公众号回复「指纹」即可获取
|
||||
|
||||
## 赞助支持
|
||||
|
||||
如果这个项目对你有帮助,谢谢请我能喝杯蜜雪冰城,你的star和赞助是我免费更新的动力
|
||||
|
||||
<p>
|
||||
<img src="docs/wx_pay.jpg" alt="微信支付" width="200">
|
||||
<img src="docs/zfb_pay.jpg" alt="支付宝" width="200">
|
||||
</p>
|
||||
|
||||
|
||||
## 免责声明
|
||||
|
||||
**重要:请在使用前仔细阅读**
|
||||
|
||||
1. 本工具仅供**授权的安全测试**和**安全研究**使用
|
||||
2. 使用者必须确保已获得目标系统的**合法授权**
|
||||
3. **严禁**将本工具用于未经授权的渗透测试或攻击行为
|
||||
4. 未经授权扫描他人系统属于**违法行为**,可能面临法律责任
|
||||
5. 开发者**不对任何滥用行为负责**
|
||||
|
||||
使用本工具即表示您同意:
|
||||
- 仅在合法授权范围内使用
|
||||
- 遵守所在地区的法律法规
|
||||
- 承担因滥用产生的一切后果
|
||||
|
||||
## Star History
|
||||
|
||||
如果这个项目对你有帮助,请给一个 Star 支持一下!
|
||||
|
||||
[](https://star-history.com/#yyhuni/xingrin&Date)
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目采用 [GNU General Public License v3.0](LICENSE) 许可证。
|
||||
|
||||
### 允许的用途
|
||||
|
||||
- 个人学习和研究
|
||||
- 商业和非商业使用
|
||||
- 修改和分发
|
||||
- 专利使用
|
||||
- 私人使用
|
||||
|
||||
### 义务和限制
|
||||
|
||||
- **开源义务**:分发时必须提供源代码
|
||||
- **相同许可**:衍生作品必须使用相同许可证
|
||||
- **版权声明**:必须保留原始版权和许可证声明
|
||||
- **责任免除**:不提供任何担保
|
||||
- 未经授权的渗透测试
|
||||
- 任何违法行为
|
||||
13
agent/.air.toml
Normal file
13
agent/.air.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
root = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
cmd = "go build -o ./tmp/agent ./cmd/agent"
|
||||
bin = "./tmp/agent"
|
||||
delay = 1000
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
exclude_dir = ["tmp", "vendor", ".git"]
|
||||
exclude_regex = ["_test\\.go"]
|
||||
|
||||
[log]
|
||||
time = true
|
||||
41
agent/Dockerfile
Normal file
41
agent/Dockerfile
Normal file
@@ -0,0 +1,41 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ============================================
|
||||
# Go Agent - build
|
||||
# ============================================
|
||||
FROM golang:1.25.6 AS builder
|
||||
|
||||
ARG GO111MODULE=on
|
||||
ARG GOPROXY=https://goproxy.cn,direct
|
||||
|
||||
ENV GO111MODULE=$GO111MODULE
|
||||
ENV GOPROXY=$GOPROXY
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Cache dependencies
|
||||
COPY agent/go.mod agent/go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source
|
||||
COPY agent ./agent
|
||||
|
||||
WORKDIR /src/agent
|
||||
|
||||
# Build (static where possible)
|
||||
RUN CGO_ENABLED=0 go build -o /out/agent ./cmd/agent
|
||||
|
||||
# ============================================
|
||||
# Go Agent - runtime
|
||||
# ============================================
|
||||
FROM debian:bookworm-20260112-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /out/agent /usr/local/bin/agent
|
||||
|
||||
CMD ["agent"]
|
||||
37
agent/cmd/agent/main.go
Normal file
37
agent/cmd/agent/main.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/yyhuni/lunafox/agent/internal/app"
|
||||
"github.com/yyhuni/lunafox/agent/internal/config"
|
||||
"github.com/yyhuni/lunafox/agent/internal/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := logger.Init(os.Getenv("LOG_LEVEL")); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "logger init failed: %v\n", err)
|
||||
}
|
||||
defer logger.Sync()
|
||||
|
||||
cfg, err := config.Load(os.Args[1:])
|
||||
if err != nil {
|
||||
logger.Log.Fatal("failed to load config", zap.Error(err))
|
||||
}
|
||||
wsURL, err := config.BuildWebSocketURL(cfg.ServerURL)
|
||||
if err != nil {
|
||||
logger.Log.Fatal("invalid server URL", zap.Error(err))
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if err := app.Run(ctx, *cfg, wsURL); err != nil {
|
||||
logger.Log.Fatal("agent stopped", zap.Error(err))
|
||||
}
|
||||
}
|
||||
48
agent/go.mod
Normal file
48
agent/go.mod
Normal file
@@ -0,0 +1,48 @@
|
||||
module github.com/yyhuni/lunafox/agent
|
||||
|
||||
go 1.24.5
|
||||
|
||||
require (
|
||||
github.com/docker/docker v28.5.2+incompatible
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/opencontainers/image-spec v1.1.1
|
||||
github.com/shirou/gopsutil/v3 v3.24.5
|
||||
go.uber.org/zap v1.27.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.6.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/morikuni/aec v1.1.0 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect
|
||||
go.opentelemetry.io/otel v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
gotest.tools/v3 v3.5.2 // indirect
|
||||
)
|
||||
131
agent/go.sum
Normal file
131
agent/go.sum
Normal file
@@ -0,0 +1,131 @@
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
|
||||
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
|
||||
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||
github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
|
||||
github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
|
||||
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
139
agent/internal/app/agent.go
Normal file
139
agent/internal/app/agent.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/yyhuni/lunafox/agent/internal/config"
|
||||
"github.com/yyhuni/lunafox/agent/internal/docker"
|
||||
"github.com/yyhuni/lunafox/agent/internal/domain"
|
||||
"github.com/yyhuni/lunafox/agent/internal/health"
|
||||
"github.com/yyhuni/lunafox/agent/internal/logger"
|
||||
"github.com/yyhuni/lunafox/agent/internal/metrics"
|
||||
"github.com/yyhuni/lunafox/agent/internal/protocol"
|
||||
"github.com/yyhuni/lunafox/agent/internal/task"
|
||||
"github.com/yyhuni/lunafox/agent/internal/update"
|
||||
agentws "github.com/yyhuni/lunafox/agent/internal/websocket"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func Run(ctx context.Context, cfg config.Config, wsURL string) error {
|
||||
configUpdater := config.NewUpdater(cfg)
|
||||
|
||||
version := cfg.AgentVersion
|
||||
hostname := os.Getenv("AGENT_HOSTNAME")
|
||||
if hostname == "" {
|
||||
var err error
|
||||
hostname, err = os.Hostname()
|
||||
if err != nil || hostname == "" {
|
||||
hostname = "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
logger.Log.Info("agent starting",
|
||||
zap.String("version", version),
|
||||
zap.String("hostname", hostname),
|
||||
zap.String("server", cfg.ServerURL),
|
||||
zap.String("ws", wsURL),
|
||||
zap.Int("maxTasks", cfg.MaxTasks),
|
||||
zap.Int("cpuThreshold", cfg.CPUThreshold),
|
||||
zap.Int("memThreshold", cfg.MemThreshold),
|
||||
zap.Int("diskThreshold", cfg.DiskThreshold),
|
||||
)
|
||||
|
||||
client := agentws.NewClient(wsURL, cfg.APIKey)
|
||||
collector := metrics.NewCollector()
|
||||
healthManager := health.NewManager()
|
||||
taskCounter := &task.Counter{}
|
||||
heartbeat := agentws.NewHeartbeatSender(client, collector, healthManager, version, hostname, taskCounter.Count)
|
||||
|
||||
taskClient := task.NewClient(cfg.ServerURL, cfg.APIKey)
|
||||
puller := task.NewPuller(taskClient, collector, taskCounter, cfg.MaxTasks, cfg.CPUThreshold, cfg.MemThreshold, cfg.DiskThreshold)
|
||||
|
||||
taskQueue := make(chan *domain.Task, cfg.MaxTasks)
|
||||
puller.SetOnTask(func(t *domain.Task) {
|
||||
logger.Log.Info("task received",
|
||||
zap.Int("taskId", t.ID),
|
||||
zap.Int("scanId", t.ScanID),
|
||||
zap.String("workflow", t.WorkflowName),
|
||||
zap.Int("stage", t.Stage),
|
||||
zap.String("target", t.TargetName),
|
||||
)
|
||||
taskQueue <- t
|
||||
})
|
||||
|
||||
dockerClient, err := docker.NewClient()
|
||||
if err != nil {
|
||||
logger.Log.Warn("docker client unavailable", zap.Error(err))
|
||||
} else {
|
||||
logger.Log.Info("docker client ready")
|
||||
}
|
||||
|
||||
workerToken := os.Getenv("WORKER_TOKEN")
|
||||
if workerToken == "" {
|
||||
return errors.New("WORKER_TOKEN environment variable is required")
|
||||
}
|
||||
logger.Log.Info("worker token loaded")
|
||||
|
||||
executor := task.NewExecutor(dockerClient, taskClient, taskCounter, cfg.ServerURL, workerToken, version)
|
||||
defer func() {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := executor.Shutdown(shutdownCtx); err != nil && !errors.Is(err, context.DeadlineExceeded) {
|
||||
logger.Log.Error("executor shutdown error", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
updater := update.NewUpdater(dockerClient, healthManager, puller, executor, configUpdater, cfg.APIKey, workerToken)
|
||||
|
||||
handler := agentws.NewHandler()
|
||||
handler.OnTaskAvailable(puller.NotifyTaskAvailable)
|
||||
handler.OnTaskCancel(func(taskID int) {
|
||||
logger.Log.Info("task cancel requested", zap.Int("taskId", taskID))
|
||||
executor.MarkCancelled(taskID)
|
||||
executor.CancelTask(taskID)
|
||||
})
|
||||
handler.OnConfigUpdate(func(payload protocol.ConfigUpdatePayload) {
|
||||
logger.Log.Info("config update received",
|
||||
zap.String("maxTasks", formatOptionalInt(payload.MaxTasks)),
|
||||
zap.String("cpuThreshold", formatOptionalInt(payload.CPUThreshold)),
|
||||
zap.String("memThreshold", formatOptionalInt(payload.MemThreshold)),
|
||||
zap.String("diskThreshold", formatOptionalInt(payload.DiskThreshold)),
|
||||
)
|
||||
cfgUpdate := config.Update{
|
||||
MaxTasks: payload.MaxTasks,
|
||||
CPUThreshold: payload.CPUThreshold,
|
||||
MemThreshold: payload.MemThreshold,
|
||||
DiskThreshold: payload.DiskThreshold,
|
||||
}
|
||||
configUpdater.Apply(cfgUpdate)
|
||||
puller.UpdateConfig(cfgUpdate.MaxTasks, cfgUpdate.CPUThreshold, cfgUpdate.MemThreshold, cfgUpdate.DiskThreshold)
|
||||
})
|
||||
handler.OnUpdateRequired(updater.HandleUpdateRequired)
|
||||
client.SetOnMessage(handler.Handle)
|
||||
|
||||
logger.Log.Info("starting heartbeat sender")
|
||||
go heartbeat.Start(ctx)
|
||||
logger.Log.Info("starting task puller")
|
||||
go func() {
|
||||
_ = puller.Run(ctx)
|
||||
}()
|
||||
logger.Log.Info("starting task executor")
|
||||
go executor.Start(ctx, taskQueue)
|
||||
|
||||
logger.Log.Info("connecting to server websocket")
|
||||
if err := client.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatOptionalInt(value *int) string {
|
||||
if value == nil {
|
||||
return "nil"
|
||||
}
|
||||
return strconv.Itoa(*value)
|
||||
}
|
||||
53
agent/internal/config/config.go
Normal file
53
agent/internal/config/config.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Config represents runtime settings for the agent.
|
||||
type Config struct {
|
||||
ServerURL string
|
||||
APIKey string
|
||||
AgentVersion string
|
||||
MaxTasks int
|
||||
CPUThreshold int
|
||||
MemThreshold int
|
||||
DiskThreshold int
|
||||
}
|
||||
|
||||
// Validate ensures config values are usable.
|
||||
func (c *Config) Validate() error {
|
||||
if c.ServerURL == "" {
|
||||
return errors.New("server URL is required")
|
||||
}
|
||||
if c.APIKey == "" {
|
||||
return errors.New("api key is required")
|
||||
}
|
||||
if c.AgentVersion == "" {
|
||||
return errors.New("AGENT_VERSION environment variable is required")
|
||||
}
|
||||
if c.MaxTasks < 1 {
|
||||
return errors.New("max tasks must be at least 1")
|
||||
}
|
||||
if err := validatePercent("cpu threshold", c.CPUThreshold); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validatePercent("mem threshold", c.MemThreshold); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validatePercent("disk threshold", c.DiskThreshold); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := BuildWebSocketURL(c.ServerURL); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePercent(name string, value int) error {
|
||||
if value < 1 || value > 100 {
|
||||
return fmt.Errorf("%s must be between 1 and 100", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
87
agent/internal/config/loader.go
Normal file
87
agent/internal/config/loader.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMaxTasks = 5
|
||||
defaultCPUThreshold = 85
|
||||
defaultMemThreshold = 85
|
||||
defaultDiskThreshold = 90
|
||||
)
|
||||
|
||||
// Load parses configuration from environment variables and CLI flags.
|
||||
func Load(args []string) (*Config, error) {
|
||||
maxTasks, err := readEnvInt("MAX_TASKS", defaultMaxTasks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cpuThreshold, err := readEnvInt("CPU_THRESHOLD", defaultCPUThreshold)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
memThreshold, err := readEnvInt("MEM_THRESHOLD", defaultMemThreshold)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
diskThreshold, err := readEnvInt("DISK_THRESHOLD", defaultDiskThreshold)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
ServerURL: strings.TrimSpace(os.Getenv("SERVER_URL")),
|
||||
APIKey: strings.TrimSpace(os.Getenv("API_KEY")),
|
||||
AgentVersion: strings.TrimSpace(os.Getenv("AGENT_VERSION")),
|
||||
MaxTasks: maxTasks,
|
||||
CPUThreshold: cpuThreshold,
|
||||
MemThreshold: memThreshold,
|
||||
DiskThreshold: diskThreshold,
|
||||
}
|
||||
|
||||
fs := flag.NewFlagSet("agent", flag.ContinueOnError)
|
||||
serverURL := fs.String("server-url", cfg.ServerURL, "Server base URL (e.g. https://1.1.1.1:8080)")
|
||||
apiKey := fs.String("api-key", cfg.APIKey, "Agent API key")
|
||||
maxTasksFlag := fs.Int("max-tasks", cfg.MaxTasks, "Maximum concurrent tasks")
|
||||
cpuThresholdFlag := fs.Int("cpu-threshold", cfg.CPUThreshold, "CPU threshold percentage")
|
||||
memThresholdFlag := fs.Int("mem-threshold", cfg.MemThreshold, "Memory threshold percentage")
|
||||
diskThresholdFlag := fs.Int("disk-threshold", cfg.DiskThreshold, "Disk threshold percentage")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg.ServerURL = strings.TrimSpace(*serverURL)
|
||||
cfg.APIKey = strings.TrimSpace(*apiKey)
|
||||
cfg.MaxTasks = *maxTasksFlag
|
||||
cfg.CPUThreshold = *cpuThresholdFlag
|
||||
cfg.MemThreshold = *memThresholdFlag
|
||||
cfg.DiskThreshold = *diskThresholdFlag
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func readEnvInt(key string, fallback int) (int, error) {
|
||||
val, ok := os.LookupEnv(key)
|
||||
if !ok {
|
||||
return fallback, nil
|
||||
}
|
||||
val = strings.TrimSpace(val)
|
||||
if val == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
parsed, err := strconv.Atoi(val)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid %s: %w", key, err)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
75
agent/internal/config/loader_test.go
Normal file
75
agent/internal/config/loader_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadConfigFromEnvAndFlags(t *testing.T) {
|
||||
t.Setenv("SERVER_URL", "https://example.com")
|
||||
t.Setenv("API_KEY", "abc12345")
|
||||
t.Setenv("AGENT_VERSION", "v1.2.3")
|
||||
t.Setenv("MAX_TASKS", "5")
|
||||
t.Setenv("CPU_THRESHOLD", "80")
|
||||
t.Setenv("MEM_THRESHOLD", "81")
|
||||
t.Setenv("DISK_THRESHOLD", "82")
|
||||
|
||||
cfg, err := Load([]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("load failed: %v", err)
|
||||
}
|
||||
if cfg.ServerURL != "https://example.com" {
|
||||
t.Fatalf("expected server url from env")
|
||||
}
|
||||
if cfg.MaxTasks != 5 {
|
||||
t.Fatalf("expected max tasks from env")
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--server-url=https://override.example.com",
|
||||
"--api-key=deadbeef",
|
||||
"--max-tasks=9",
|
||||
"--cpu-threshold=70",
|
||||
"--mem-threshold=71",
|
||||
"--disk-threshold=72",
|
||||
}
|
||||
cfg, err = Load(args)
|
||||
if err != nil {
|
||||
t.Fatalf("load failed: %v", err)
|
||||
}
|
||||
if cfg.ServerURL != "https://override.example.com" {
|
||||
t.Fatalf("expected server url from args")
|
||||
}
|
||||
if cfg.APIKey != "deadbeef" {
|
||||
t.Fatalf("expected api key from args")
|
||||
}
|
||||
if cfg.MaxTasks != 9 {
|
||||
t.Fatalf("expected max tasks from args")
|
||||
}
|
||||
if cfg.CPUThreshold != 70 || cfg.MemThreshold != 71 || cfg.DiskThreshold != 72 {
|
||||
t.Fatalf("expected thresholds from args")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigMissingRequired(t *testing.T) {
|
||||
t.Setenv("SERVER_URL", "")
|
||||
t.Setenv("API_KEY", "")
|
||||
t.Setenv("AGENT_VERSION", "v1.2.3")
|
||||
|
||||
_, err := Load([]string{})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when required values missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigInvalidEnvValue(t *testing.T) {
|
||||
t.Setenv("SERVER_URL", "https://example.com")
|
||||
t.Setenv("API_KEY", "abc")
|
||||
t.Setenv("AGENT_VERSION", "v1.2.3")
|
||||
t.Setenv("MAX_TASKS", "nope")
|
||||
|
||||
_, err := Load([]string{})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for invalid MAX_TASKS")
|
||||
}
|
||||
}
|
||||
|
||||
49
agent/internal/config/updater.go
Normal file
49
agent/internal/config/updater.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/yyhuni/lunafox/agent/internal/domain"
|
||||
)
|
||||
|
||||
// Update holds optional configuration updates.
|
||||
type Update = domain.ConfigUpdate
|
||||
|
||||
// Updater manages runtime configuration changes.
|
||||
type Updater struct {
|
||||
mu sync.RWMutex
|
||||
cfg Config
|
||||
}
|
||||
|
||||
// NewUpdater creates an updater with initial config.
|
||||
func NewUpdater(cfg Config) *Updater {
|
||||
return &Updater{cfg: cfg}
|
||||
}
|
||||
|
||||
// Apply updates the configuration and returns the new snapshot.
|
||||
func (u *Updater) Apply(update Update) Config {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
|
||||
if update.MaxTasks != nil && *update.MaxTasks > 0 {
|
||||
u.cfg.MaxTasks = *update.MaxTasks
|
||||
}
|
||||
if update.CPUThreshold != nil && *update.CPUThreshold > 0 {
|
||||
u.cfg.CPUThreshold = *update.CPUThreshold
|
||||
}
|
||||
if update.MemThreshold != nil && *update.MemThreshold > 0 {
|
||||
u.cfg.MemThreshold = *update.MemThreshold
|
||||
}
|
||||
if update.DiskThreshold != nil && *update.DiskThreshold > 0 {
|
||||
u.cfg.DiskThreshold = *update.DiskThreshold
|
||||
}
|
||||
|
||||
return u.cfg
|
||||
}
|
||||
|
||||
// Snapshot returns a copy of current config.
|
||||
func (u *Updater) Snapshot() Config {
|
||||
u.mu.RLock()
|
||||
defer u.mu.RUnlock()
|
||||
return u.cfg
|
||||
}
|
||||
39
agent/internal/config/updater_test.go
Normal file
39
agent/internal/config/updater_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestUpdaterApplyAndSnapshot(t *testing.T) {
|
||||
cfg := Config{
|
||||
ServerURL: "https://example.com",
|
||||
APIKey: "key",
|
||||
MaxTasks: 2,
|
||||
CPUThreshold: 70,
|
||||
MemThreshold: 80,
|
||||
DiskThreshold: 90,
|
||||
}
|
||||
|
||||
updater := NewUpdater(cfg)
|
||||
snapshot := updater.Snapshot()
|
||||
if snapshot.MaxTasks != 2 || snapshot.CPUThreshold != 70 {
|
||||
t.Fatalf("unexpected snapshot values")
|
||||
}
|
||||
|
||||
invalid := 0
|
||||
update := Update{MaxTasks: &invalid, CPUThreshold: &invalid}
|
||||
snapshot = updater.Apply(update)
|
||||
if snapshot.MaxTasks != 2 || snapshot.CPUThreshold != 70 {
|
||||
t.Fatalf("expected invalid update to be ignored")
|
||||
}
|
||||
|
||||
maxTasks := 5
|
||||
cpu := 85
|
||||
mem := 60
|
||||
snapshot = updater.Apply(Update{
|
||||
MaxTasks: &maxTasks,
|
||||
CPUThreshold: &cpu,
|
||||
MemThreshold: &mem,
|
||||
})
|
||||
if snapshot.MaxTasks != 5 || snapshot.CPUThreshold != 85 || snapshot.MemThreshold != 60 {
|
||||
t.Fatalf("unexpected applied update")
|
||||
}
|
||||
}
|
||||
50
agent/internal/config/url.go
Normal file
50
agent/internal/config/url.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BuildWebSocketURL derives the agent WebSocket endpoint from the server URL.
|
||||
func BuildWebSocketURL(serverURL string) (string, error) {
|
||||
trimmed := strings.TrimSpace(serverURL)
|
||||
if trimmed == "" {
|
||||
return "", errors.New("server URL is required")
|
||||
}
|
||||
parsed, err := url.Parse(trimmed)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
switch strings.ToLower(parsed.Scheme) {
|
||||
case "http":
|
||||
parsed.Scheme = "ws"
|
||||
case "https":
|
||||
parsed.Scheme = "wss"
|
||||
case "ws", "wss":
|
||||
default:
|
||||
if parsed.Scheme == "" {
|
||||
return "", errors.New("server URL scheme is required")
|
||||
}
|
||||
return "", fmt.Errorf("unsupported server URL scheme: %s", parsed.Scheme)
|
||||
}
|
||||
|
||||
parsed.Path = buildWSPath(parsed.Path)
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func buildWSPath(path string) string {
|
||||
trimmed := strings.TrimRight(path, "/")
|
||||
if trimmed == "" {
|
||||
return "/api/agents/ws"
|
||||
}
|
||||
if strings.HasSuffix(trimmed, "/api") {
|
||||
return trimmed + "/agents/ws"
|
||||
}
|
||||
return trimmed + "/api/agents/ws"
|
||||
}
|
||||
38
agent/internal/config/url_test.go
Normal file
38
agent/internal/config/url_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildWebSocketURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"https://example.com", "wss://example.com/api/agents/ws"},
|
||||
{"http://example.com", "ws://example.com/api/agents/ws"},
|
||||
{"https://example.com/api", "wss://example.com/api/agents/ws"},
|
||||
{"https://example.com/base", "wss://example.com/base/api/agents/ws"},
|
||||
{"wss://example.com", "wss://example.com/api/agents/ws"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got, err := BuildWebSocketURL(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for %s: %v", tt.input, err)
|
||||
}
|
||||
if got != tt.expected {
|
||||
t.Fatalf("input %s expected %s got %s", tt.input, tt.expected, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWebSocketURLInvalid(t *testing.T) {
|
||||
if _, err := BuildWebSocketURL("example.com"); err == nil {
|
||||
t.Fatalf("expected error for missing scheme")
|
||||
}
|
||||
if _, err := BuildWebSocketURL(" "); err == nil {
|
||||
t.Fatalf("expected error for empty url")
|
||||
}
|
||||
if _, err := BuildWebSocketURL("ftp://example.com"); err == nil {
|
||||
t.Fatalf("expected error for unsupported scheme")
|
||||
}
|
||||
}
|
||||
23
agent/internal/docker/cleanup.go
Normal file
23
agent/internal/docker/cleanup.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
)
|
||||
|
||||
// Remove removes the container.
|
||||
func (c *Client) Remove(ctx context.Context, containerID string) error {
|
||||
return c.cli.ContainerRemove(ctx, containerID, container.RemoveOptions{
|
||||
Force: true,
|
||||
RemoveVolumes: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Stop stops a running container with a timeout.
|
||||
func (c *Client) Stop(ctx context.Context, containerID string) error {
|
||||
timeout := 10
|
||||
return c.cli.ContainerStop(ctx, containerID, container.StopOptions{
|
||||
Timeout: &timeout,
|
||||
})
|
||||
}
|
||||
46
agent/internal/docker/client.go
Normal file
46
agent/internal/docker/client.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
imagetypes "github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/docker/client"
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
)
|
||||
|
||||
// Client wraps the Docker SDK client.
|
||||
type Client struct {
|
||||
cli *client.Client
|
||||
}
|
||||
|
||||
// NewClient creates a Docker client using environment configuration.
|
||||
func NewClient() (*Client, error) {
|
||||
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Client{cli: cli}, nil
|
||||
}
|
||||
|
||||
// Close closes the Docker client.
|
||||
func (c *Client) Close() error {
|
||||
return c.cli.Close()
|
||||
}
|
||||
|
||||
// ImagePull pulls an image from the registry.
|
||||
func (c *Client) ImagePull(ctx context.Context, imageRef string) (io.ReadCloser, error) {
|
||||
return c.cli.ImagePull(ctx, imageRef, imagetypes.PullOptions{})
|
||||
}
|
||||
|
||||
// ContainerCreate creates a container.
|
||||
func (c *Client) ContainerCreate(ctx context.Context, config *container.Config, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig, platform *ocispec.Platform, name string) (container.CreateResponse, error) {
|
||||
return c.cli.ContainerCreate(ctx, config, hostConfig, networkingConfig, platform, name)
|
||||
}
|
||||
|
||||
// ContainerStart starts a container.
|
||||
func (c *Client) ContainerStart(ctx context.Context, containerID string, opts container.StartOptions) error {
|
||||
return c.cli.ContainerStart(ctx, containerID, opts)
|
||||
}
|
||||
49
agent/internal/docker/logs.go
Normal file
49
agent/internal/docker/logs.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
)
|
||||
|
||||
const (
|
||||
maxErrorBytes = 4096
|
||||
)
|
||||
|
||||
// TailLogs returns the last N lines of container logs, truncated to 4KB.
|
||||
func (c *Client) TailLogs(ctx context.Context, containerID string, lines int) (string, error) {
|
||||
reader, err := c.cli.ContainerLogs(ctx, containerID, container.LogsOptions{
|
||||
ShowStdout: true,
|
||||
ShowStderr: true,
|
||||
Timestamps: false,
|
||||
Tail: strconv.Itoa(lines),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
var buf bytes.Buffer
|
||||
if _, err := io.Copy(&buf, reader); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
out = strings.TrimSpace(out)
|
||||
if len(out) > maxErrorBytes {
|
||||
out = out[len(out)-maxErrorBytes:]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// TruncateErrorMessage clamps message length to 4KB.
|
||||
func TruncateErrorMessage(message string) string {
|
||||
if len(message) <= maxErrorBytes {
|
||||
return message
|
||||
}
|
||||
return message[:maxErrorBytes]
|
||||
}
|
||||
22
agent/internal/docker/logs_test.go
Normal file
22
agent/internal/docker/logs_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTruncateErrorMessage(t *testing.T) {
|
||||
short := "short message"
|
||||
if got := TruncateErrorMessage(short); got != short {
|
||||
t.Fatalf("expected message to stay unchanged")
|
||||
}
|
||||
|
||||
long := strings.Repeat("x", maxErrorBytes+10)
|
||||
got := TruncateErrorMessage(long)
|
||||
if len(got) != maxErrorBytes {
|
||||
t.Fatalf("expected length %d, got %d", maxErrorBytes, len(got))
|
||||
}
|
||||
if got != long[:maxErrorBytes] {
|
||||
t.Fatalf("unexpected truncation result")
|
||||
}
|
||||
}
|
||||
20
agent/internal/docker/monitor.go
Normal file
20
agent/internal/docker/monitor.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
)
|
||||
|
||||
// Wait waits for a container to stop and returns the exit code.
|
||||
func (c *Client) Wait(ctx context.Context, containerID string) (int64, error) {
|
||||
statusCh, errCh := c.cli.ContainerWait(ctx, containerID, container.WaitConditionNotRunning)
|
||||
select {
|
||||
case status := <-statusCh:
|
||||
return status.StatusCode, nil
|
||||
case err := <-errCh:
|
||||
return 0, err
|
||||
case <-ctx.Done():
|
||||
return 0, ctx.Err()
|
||||
}
|
||||
}
|
||||
76
agent/internal/docker/runner.go
Normal file
76
agent/internal/docker/runner.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/docker/api/types/strslice"
|
||||
"github.com/yyhuni/lunafox/agent/internal/domain"
|
||||
)
|
||||
|
||||
const workerImagePrefix = "yyhuni/lunafox-worker:"
|
||||
|
||||
// StartWorker starts a worker container for a task and returns the container ID.
|
||||
func (c *Client) StartWorker(ctx context.Context, t *domain.Task, serverURL, serverToken, agentVersion string) (string, error) {
|
||||
if t == nil {
|
||||
return "", fmt.Errorf("task is nil")
|
||||
}
|
||||
if err := os.MkdirAll(t.WorkspaceDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("prepare workspace: %w", err)
|
||||
}
|
||||
|
||||
image, err := resolveWorkerImage(agentVersion)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
env := buildWorkerEnv(t, serverURL, serverToken)
|
||||
|
||||
config := &container.Config{
|
||||
Image: image,
|
||||
Env: env,
|
||||
Cmd: strslice.StrSlice{},
|
||||
}
|
||||
|
||||
hostConfig := &container.HostConfig{
|
||||
Binds: []string{"/opt/lunafox:/opt/lunafox"},
|
||||
AutoRemove: false,
|
||||
OomScoreAdj: 500,
|
||||
}
|
||||
|
||||
resp, err := c.cli.ContainerCreate(ctx, config, hostConfig, &network.NetworkingConfig{}, nil, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := c.cli.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return resp.ID, nil
|
||||
}
|
||||
|
||||
func resolveWorkerImage(version string) (string, error) {
|
||||
version = strings.TrimSpace(version)
|
||||
if version == "" {
|
||||
return "", fmt.Errorf("worker version is required")
|
||||
}
|
||||
return workerImagePrefix + version, nil
|
||||
}
|
||||
|
||||
func buildWorkerEnv(t *domain.Task, serverURL, serverToken string) []string {
|
||||
return []string{
|
||||
fmt.Sprintf("SERVER_URL=%s", serverURL),
|
||||
fmt.Sprintf("SERVER_TOKEN=%s", serverToken),
|
||||
fmt.Sprintf("SCAN_ID=%d", t.ScanID),
|
||||
fmt.Sprintf("TARGET_ID=%d", t.TargetID),
|
||||
fmt.Sprintf("TARGET_NAME=%s", t.TargetName),
|
||||
fmt.Sprintf("TARGET_TYPE=%s", t.TargetType),
|
||||
fmt.Sprintf("WORKFLOW_NAME=%s", t.WorkflowName),
|
||||
fmt.Sprintf("WORKSPACE_DIR=%s", t.WorkspaceDir),
|
||||
fmt.Sprintf("CONFIG=%s", t.Config),
|
||||
}
|
||||
}
|
||||
50
agent/internal/docker/runner_test.go
Normal file
50
agent/internal/docker/runner_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/yyhuni/lunafox/agent/internal/domain"
|
||||
)
|
||||
|
||||
func TestResolveWorkerImage(t *testing.T) {
|
||||
if _, err := resolveWorkerImage(""); err == nil {
|
||||
t.Fatalf("expected error for empty version")
|
||||
}
|
||||
if got, err := resolveWorkerImage("v1.2.3"); err != nil || got != workerImagePrefix+"v1.2.3" {
|
||||
t.Fatalf("expected version image, got %s, err: %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWorkerEnv(t *testing.T) {
|
||||
spec := &domain.Task{
|
||||
ScanID: 1,
|
||||
TargetID: 2,
|
||||
TargetName: "example.com",
|
||||
TargetType: "domain",
|
||||
WorkflowName: "subdomain_discovery",
|
||||
WorkspaceDir: "/opt/lunafox/results",
|
||||
Config: "config-yaml",
|
||||
}
|
||||
|
||||
env := buildWorkerEnv(spec, "https://server", "token")
|
||||
expected := []string{
|
||||
"SERVER_URL=https://server",
|
||||
"SERVER_TOKEN=token",
|
||||
"SCAN_ID=1",
|
||||
"TARGET_ID=2",
|
||||
"TARGET_NAME=example.com",
|
||||
"TARGET_TYPE=domain",
|
||||
"WORKFLOW_NAME=subdomain_discovery",
|
||||
"WORKSPACE_DIR=/opt/lunafox/results",
|
||||
"CONFIG=config-yaml",
|
||||
}
|
||||
|
||||
if len(env) != len(expected) {
|
||||
t.Fatalf("expected %d env entries, got %d", len(expected), len(env))
|
||||
}
|
||||
for i, item := range expected {
|
||||
if env[i] != item {
|
||||
t.Fatalf("expected env[%d]=%s got %s", i, item, env[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
8
agent/internal/domain/config.go
Normal file
8
agent/internal/domain/config.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package domain
|
||||
|
||||
type ConfigUpdate struct {
|
||||
MaxTasks *int `json:"maxTasks"`
|
||||
CPUThreshold *int `json:"cpuThreshold"`
|
||||
MemThreshold *int `json:"memThreshold"`
|
||||
DiskThreshold *int `json:"diskThreshold"`
|
||||
}
|
||||
10
agent/internal/domain/health.go
Normal file
10
agent/internal/domain/health.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type HealthStatus struct {
|
||||
State string `json:"state"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Since *time.Time `json:"since,omitempty"`
|
||||
}
|
||||
13
agent/internal/domain/task.go
Normal file
13
agent/internal/domain/task.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package domain
|
||||
|
||||
type Task struct {
|
||||
ID int `json:"taskId"`
|
||||
ScanID int `json:"scanId"`
|
||||
Stage int `json:"stage"`
|
||||
WorkflowName string `json:"workflowName"`
|
||||
TargetID int `json:"targetId"`
|
||||
TargetName string `json:"targetName"`
|
||||
TargetType string `json:"targetType"`
|
||||
WorkspaceDir string `json:"workspaceDir"`
|
||||
Config string `json:"config"`
|
||||
}
|
||||
6
agent/internal/domain/update.go
Normal file
6
agent/internal/domain/update.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package domain
|
||||
|
||||
type UpdateRequiredPayload struct {
|
||||
Version string `json:"version"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
51
agent/internal/health/health.go
Normal file
51
agent/internal/health/health.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/yyhuni/lunafox/agent/internal/domain"
|
||||
)
|
||||
|
||||
// Status represents the agent health state reported in heartbeats.
|
||||
type Status = domain.HealthStatus
|
||||
|
||||
// Manager stores current health status.
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
status Status
|
||||
}
|
||||
|
||||
// NewManager initializes the manager with ok status.
|
||||
func NewManager() *Manager {
|
||||
return &Manager{
|
||||
status: Status{State: "ok"},
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns a snapshot of current status.
|
||||
func (m *Manager) Get() Status {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.status
|
||||
}
|
||||
|
||||
// Set updates health status and timestamps transitions.
|
||||
func (m *Manager) Set(state, reason, message string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.status.State != state {
|
||||
now := time.Now().UTC()
|
||||
m.status.Since = &now
|
||||
}
|
||||
|
||||
m.status.State = state
|
||||
m.status.Reason = reason
|
||||
m.status.Message = message
|
||||
if state == "ok" {
|
||||
m.status.Since = nil
|
||||
m.status.Reason = ""
|
||||
m.status.Message = ""
|
||||
}
|
||||
}
|
||||
33
agent/internal/health/health_test.go
Normal file
33
agent/internal/health/health_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package health
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestManagerSetTransitions(t *testing.T) {
|
||||
mgr := NewManager()
|
||||
initial := mgr.Get()
|
||||
if initial.State != "ok" || initial.Since != nil {
|
||||
t.Fatalf("expected initial ok status")
|
||||
}
|
||||
|
||||
mgr.Set("paused", "update", "waiting")
|
||||
status := mgr.Get()
|
||||
if status.State != "paused" || status.Since == nil {
|
||||
t.Fatalf("expected paused state with timestamp")
|
||||
}
|
||||
prevSince := status.Since
|
||||
|
||||
mgr.Set("paused", "still", "waiting more")
|
||||
status = mgr.Get()
|
||||
if status.Since == nil || !status.Since.Equal(*prevSince) {
|
||||
t.Fatalf("expected unchanged since on same state")
|
||||
}
|
||||
if status.Reason != "still" || status.Message != "waiting more" {
|
||||
t.Fatalf("expected updated reason/message")
|
||||
}
|
||||
|
||||
mgr.Set("ok", "ignored", "ignored")
|
||||
status = mgr.Get()
|
||||
if status.State != "ok" || status.Since != nil || status.Reason != "" || status.Message != "" {
|
||||
t.Fatalf("expected ok reset to clear fields")
|
||||
}
|
||||
}
|
||||
50
agent/internal/logger/logger.go
Normal file
50
agent/internal/logger/logger.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
// Log is the shared agent logger. Defaults to a no-op logger until initialized.
|
||||
var Log = zap.NewNop()
|
||||
|
||||
// Init configures the logger using the provided level and ENV.
|
||||
func Init(level string) error {
|
||||
level = strings.TrimSpace(level)
|
||||
if level == "" {
|
||||
level = "info"
|
||||
}
|
||||
|
||||
var zapLevel zapcore.Level
|
||||
if err := zapLevel.UnmarshalText([]byte(level)); err != nil {
|
||||
zapLevel = zapcore.InfoLevel
|
||||
}
|
||||
|
||||
isDev := strings.EqualFold(os.Getenv("ENV"), "development")
|
||||
var config zap.Config
|
||||
if isDev {
|
||||
config = zap.NewDevelopmentConfig()
|
||||
config.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
|
||||
} else {
|
||||
config = zap.NewProductionConfig()
|
||||
}
|
||||
config.Level = zap.NewAtomicLevelAt(zapLevel)
|
||||
|
||||
logger, err := config.Build()
|
||||
if err != nil {
|
||||
Log = zap.NewNop()
|
||||
return err
|
||||
}
|
||||
Log = logger
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sync flushes any buffered log entries.
|
||||
func Sync() {
|
||||
if Log != nil {
|
||||
_ = Log.Sync()
|
||||
}
|
||||
}
|
||||
58
agent/internal/metrics/collector.go
Normal file
58
agent/internal/metrics/collector.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"github.com/shirou/gopsutil/v3/cpu"
|
||||
"github.com/shirou/gopsutil/v3/disk"
|
||||
"github.com/shirou/gopsutil/v3/mem"
|
||||
"github.com/yyhuni/lunafox/agent/internal/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Collector gathers system metrics.
|
||||
type Collector struct{}
|
||||
|
||||
// NewCollector creates a new Collector.
|
||||
func NewCollector() *Collector {
|
||||
return &Collector{}
|
||||
}
|
||||
|
||||
// Sample returns CPU, memory, and disk usage percentages.
|
||||
func (c *Collector) Sample() (float64, float64, float64) {
|
||||
cpuPercent, err := cpuUsagePercent()
|
||||
if err != nil {
|
||||
logger.Log.Warn("metrics: cpu percent error", zap.Error(err))
|
||||
}
|
||||
memPercent, err := memUsagePercent()
|
||||
if err != nil {
|
||||
logger.Log.Warn("metrics: mem percent error", zap.Error(err))
|
||||
}
|
||||
diskPercent, err := diskUsagePercent("/")
|
||||
if err != nil {
|
||||
logger.Log.Warn("metrics: disk percent error", zap.Error(err))
|
||||
}
|
||||
return cpuPercent, memPercent, diskPercent
|
||||
}
|
||||
|
||||
func cpuUsagePercent() (float64, error) {
|
||||
values, err := cpu.Percent(0, false)
|
||||
if err != nil || len(values) == 0 {
|
||||
return 0, err
|
||||
}
|
||||
return values[0], nil
|
||||
}
|
||||
|
||||
func memUsagePercent() (float64, error) {
|
||||
info, err := mem.VirtualMemory()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return info.UsedPercent, nil
|
||||
}
|
||||
|
||||
func diskUsagePercent(path string) (float64, error) {
|
||||
info, err := disk.Usage(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return info.UsedPercent, nil
|
||||
}
|
||||
11
agent/internal/metrics/collector_test.go
Normal file
11
agent/internal/metrics/collector_test.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package metrics
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCollectorSample(t *testing.T) {
|
||||
c := NewCollector()
|
||||
cpu, mem, disk := c.Sample()
|
||||
if cpu < 0 || mem < 0 || disk < 0 {
|
||||
t.Fatalf("expected non-negative metrics")
|
||||
}
|
||||
}
|
||||
42
agent/internal/protocol/messages.go
Normal file
42
agent/internal/protocol/messages.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/yyhuni/lunafox/agent/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
MessageTypeHeartbeat = "heartbeat"
|
||||
MessageTypeTaskAvailable = "task_available"
|
||||
MessageTypeTaskCancel = "task_cancel"
|
||||
MessageTypeConfigUpdate = "config_update"
|
||||
MessageTypeUpdateRequired = "update_required"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
Type string `json:"type"`
|
||||
Payload interface{} `json:"payload"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
type HealthStatus = domain.HealthStatus
|
||||
|
||||
type HeartbeatPayload struct {
|
||||
CPU float64 `json:"cpu"`
|
||||
Mem float64 `json:"mem"`
|
||||
Disk float64 `json:"disk"`
|
||||
Tasks int `json:"tasks"`
|
||||
Version string `json:"version"`
|
||||
Hostname string `json:"hostname"`
|
||||
Uptime int64 `json:"uptime"`
|
||||
Health HealthStatus `json:"health"`
|
||||
}
|
||||
|
||||
type ConfigUpdatePayload = domain.ConfigUpdate
|
||||
|
||||
type UpdateRequiredPayload = domain.UpdateRequiredPayload
|
||||
|
||||
type TaskCancelPayload struct {
|
||||
TaskID int `json:"taskId"`
|
||||
}
|
||||
118
agent/internal/task/client.go
Normal file
118
agent/internal/task/client.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/yyhuni/lunafox/agent/internal/domain"
|
||||
)
|
||||
|
||||
// Client handles HTTP API requests to the server.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a new task client.
|
||||
func NewClient(serverURL, apiKey string) *Client {
|
||||
transport := http.DefaultTransport
|
||||
if base, ok := transport.(*http.Transport); ok {
|
||||
clone := base.Clone()
|
||||
clone.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
transport = clone
|
||||
}
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(serverURL, "/"),
|
||||
apiKey: apiKey,
|
||||
http: &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
Transport: transport,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// PullTask requests a task from the server. Returns nil when no task available.
|
||||
func (c *Client) PullTask(ctx context.Context) (*domain.Task, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/agent/tasks/pull", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("X-Agent-Key", c.apiKey)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNoContent {
|
||||
return nil, nil
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("pull task failed: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var task domain.Task
|
||||
if err := json.NewDecoder(resp.Body).Decode(&task); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
// UpdateStatus reports task status to the server with retry.
|
||||
func (c *Client) UpdateStatus(ctx context.Context, taskID int, status, errorMessage string) error {
|
||||
payload := map[string]string{
|
||||
"status": status,
|
||||
}
|
||||
if errorMessage != "" {
|
||||
payload["errorMessage"] = errorMessage
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
if attempt > 0 {
|
||||
backoff := time.Duration(5<<attempt) * time.Second // 5s, 10s, 20s
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, fmt.Sprintf("%s/api/agent/tasks/%d/status", c.baseURL, taskID), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Agent-Key", c.apiKey)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
lastErr = fmt.Errorf("update status failed: status %d", resp.StatusCode)
|
||||
|
||||
// Don't retry 4xx client errors (except 429)
|
||||
if resp.StatusCode >= 400 && resp.StatusCode < 500 && resp.StatusCode != 429 {
|
||||
return lastErr
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
187
agent/internal/task/client_test.go
Normal file
187
agent/internal/task/client_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/yyhuni/lunafox/agent/internal/domain"
|
||||
)
|
||||
|
||||
func TestClientPullTaskNoContent(t *testing.T) {
|
||||
client := &Client{
|
||||
baseURL: "http://example",
|
||||
apiKey: "key",
|
||||
http: &http.Client{
|
||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path != "/api/agent/tasks/pull" {
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
Header: http.Header{},
|
||||
}, nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
task, err := client.PullTask(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if task != nil {
|
||||
t.Fatalf("expected nil task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientPullTaskOK(t *testing.T) {
|
||||
client := &Client{
|
||||
baseURL: "http://example",
|
||||
apiKey: "key",
|
||||
http: &http.Client{
|
||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Header.Get("X-Agent-Key") == "" {
|
||||
t.Fatalf("missing api key header")
|
||||
}
|
||||
body, _ := json.Marshal(domain.Task{ID: 1})
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
Header: http.Header{},
|
||||
}, nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
task, err := client.PullTask(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if task == nil || task.ID != 1 {
|
||||
t.Fatalf("unexpected task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientUpdateStatus(t *testing.T) {
|
||||
client := &Client{
|
||||
baseURL: "http://example",
|
||||
apiKey: "key",
|
||||
http: &http.Client{
|
||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method != http.MethodPatch {
|
||||
t.Fatalf("expected PATCH")
|
||||
}
|
||||
if r.Header.Get("X-Agent-Key") == "" {
|
||||
t.Fatalf("missing api key header")
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
Header: http.Header{},
|
||||
}, nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
if err := client.UpdateStatus(context.Background(), 1, "completed", ""); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientPullTaskErrorStatus(t *testing.T) {
|
||||
client := &Client{
|
||||
baseURL: "http://example",
|
||||
apiKey: "key",
|
||||
http: &http.Client{
|
||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Body: io.NopCloser(strings.NewReader("bad")),
|
||||
Header: http.Header{},
|
||||
}, nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
if _, err := client.PullTask(context.Background()); err == nil {
|
||||
t.Fatalf("expected error for non-200 status")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientPullTaskBadJSON(t *testing.T) {
|
||||
client := &Client{
|
||||
baseURL: "http://example",
|
||||
apiKey: "key",
|
||||
http: &http.Client{
|
||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader("{bad json")),
|
||||
Header: http.Header{},
|
||||
}, nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
if _, err := client.PullTask(context.Background()); err == nil {
|
||||
t.Fatalf("expected error for invalid json")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientUpdateStatusIncludesErrorMessage(t *testing.T) {
|
||||
client := &Client{
|
||||
baseURL: "http://example",
|
||||
apiKey: "key",
|
||||
http: &http.Client{
|
||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
var payload map[string]string
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatalf("unmarshal body: %v", err)
|
||||
}
|
||||
if payload["status"] != "failed" {
|
||||
t.Fatalf("expected status failed")
|
||||
}
|
||||
if payload["errorMessage"] != "boom" {
|
||||
t.Fatalf("expected error message")
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
Header: http.Header{},
|
||||
}, nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
if err := client.UpdateStatus(context.Background(), 1, "failed", "boom"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientUpdateStatusErrorStatus(t *testing.T) {
|
||||
client := &Client{
|
||||
baseURL: "http://example",
|
||||
apiKey: "key",
|
||||
http: &http.Client{
|
||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
Header: http.Header{},
|
||||
}, nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
if err := client.UpdateStatus(context.Background(), 1, "completed", ""); err == nil {
|
||||
t.Fatalf("expected error for non-200 status")
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
return f(r)
|
||||
}
|
||||
23
agent/internal/task/counter.go
Normal file
23
agent/internal/task/counter.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package task
|
||||
|
||||
import "sync/atomic"
|
||||
|
||||
// Counter tracks running task count.
|
||||
type Counter struct {
|
||||
value int64
|
||||
}
|
||||
|
||||
// Inc increments the counter.
|
||||
func (c *Counter) Inc() {
|
||||
atomic.AddInt64(&c.value, 1)
|
||||
}
|
||||
|
||||
// Dec decrements the counter.
|
||||
func (c *Counter) Dec() {
|
||||
atomic.AddInt64(&c.value, -1)
|
||||
}
|
||||
|
||||
// Count returns current count.
|
||||
func (c *Counter) Count() int {
|
||||
return int(atomic.LoadInt64(&c.value))
|
||||
}
|
||||
18
agent/internal/task/counter_test.go
Normal file
18
agent/internal/task/counter_test.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package task
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCounterIncDec(t *testing.T) {
|
||||
var counter Counter
|
||||
|
||||
counter.Inc()
|
||||
counter.Inc()
|
||||
if got := counter.Count(); got != 2 {
|
||||
t.Fatalf("expected count 2, got %d", got)
|
||||
}
|
||||
|
||||
counter.Dec()
|
||||
if got := counter.Count(); got != 1 {
|
||||
t.Fatalf("expected count 1, got %d", got)
|
||||
}
|
||||
}
|
||||
258
agent/internal/task/executor.go
Normal file
258
agent/internal/task/executor.go
Normal file
@@ -0,0 +1,258 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/yyhuni/lunafox/agent/internal/docker"
|
||||
"github.com/yyhuni/lunafox/agent/internal/domain"
|
||||
)
|
||||
|
||||
const defaultMaxRuntime = 7 * 24 * time.Hour
|
||||
|
||||
// Executor runs tasks inside worker containers.
|
||||
type Executor struct {
|
||||
docker DockerRunner
|
||||
client statusReporter
|
||||
counter *Counter
|
||||
serverURL string
|
||||
workerToken string
|
||||
agentVersion string
|
||||
maxRuntime time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
running map[int]context.CancelFunc
|
||||
cancelMu sync.Mutex
|
||||
cancelled map[int]struct{}
|
||||
wg sync.WaitGroup
|
||||
|
||||
stopping atomic.Bool
|
||||
}
|
||||
|
||||
type statusReporter interface {
|
||||
UpdateStatus(ctx context.Context, taskID int, status, errorMessage string) error
|
||||
}
|
||||
|
||||
type DockerRunner interface {
|
||||
StartWorker(ctx context.Context, t *domain.Task, serverURL, serverToken, agentVersion string) (string, error)
|
||||
Wait(ctx context.Context, containerID string) (int64, error)
|
||||
Stop(ctx context.Context, containerID string) error
|
||||
Remove(ctx context.Context, containerID string) error
|
||||
TailLogs(ctx context.Context, containerID string, lines int) (string, error)
|
||||
}
|
||||
|
||||
// NewExecutor creates an Executor.
|
||||
func NewExecutor(dockerClient DockerRunner, taskClient statusReporter, counter *Counter, serverURL, workerToken, agentVersion string) *Executor {
|
||||
return &Executor{
|
||||
docker: dockerClient,
|
||||
client: taskClient,
|
||||
counter: counter,
|
||||
serverURL: serverURL,
|
||||
workerToken: workerToken,
|
||||
agentVersion: agentVersion,
|
||||
maxRuntime: defaultMaxRuntime,
|
||||
running: map[int]context.CancelFunc{},
|
||||
cancelled: map[int]struct{}{},
|
||||
}
|
||||
}
|
||||
|
||||
// Start processes tasks from the queue.
|
||||
func (e *Executor) Start(ctx context.Context, tasks <-chan *domain.Task) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case t, ok := <-tasks:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if t == nil {
|
||||
continue
|
||||
}
|
||||
if e.stopping.Load() {
|
||||
// During shutdown/update: drain the queue but don't start new work.
|
||||
continue
|
||||
}
|
||||
if e.isCancelled(t.ID) {
|
||||
e.reportStatus(ctx, t.ID, "cancelled", "")
|
||||
e.clearCancelled(t.ID)
|
||||
continue
|
||||
}
|
||||
go e.execute(ctx, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CancelTask requests cancellation of a running task.
|
||||
func (e *Executor) CancelTask(taskID int) {
|
||||
e.mu.Lock()
|
||||
cancel := e.running[taskID]
|
||||
e.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// MarkCancelled records a task as cancelled to prevent execution.
|
||||
func (e *Executor) MarkCancelled(taskID int) {
|
||||
e.cancelMu.Lock()
|
||||
e.cancelled[taskID] = struct{}{}
|
||||
e.cancelMu.Unlock()
|
||||
}
|
||||
|
||||
func (e *Executor) reportStatus(ctx context.Context, taskID int, status, errorMessage string) {
|
||||
if e.client == nil {
|
||||
return
|
||||
}
|
||||
statusCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
||||
defer cancel()
|
||||
_ = e.client.UpdateStatus(statusCtx, taskID, status, errorMessage)
|
||||
}
|
||||
|
||||
func (e *Executor) execute(ctx context.Context, t *domain.Task) {
|
||||
e.wg.Add(1)
|
||||
defer e.wg.Done()
|
||||
defer e.clearCancelled(t.ID)
|
||||
|
||||
if e.counter != nil {
|
||||
e.counter.Inc()
|
||||
defer e.counter.Dec()
|
||||
}
|
||||
|
||||
if e.workerToken == "" {
|
||||
e.reportStatus(ctx, t.ID, "failed", "missing worker token")
|
||||
return
|
||||
}
|
||||
if e.docker == nil {
|
||||
e.reportStatus(ctx, t.ID, "failed", "docker client unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithTimeout(ctx, e.maxRuntime)
|
||||
defer cancel()
|
||||
|
||||
containerID, err := e.docker.StartWorker(runCtx, t, e.serverURL, e.workerToken, e.agentVersion)
|
||||
if err != nil {
|
||||
message := docker.TruncateErrorMessage(err.Error())
|
||||
e.reportStatus(ctx, t.ID, "failed", message)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = e.docker.Remove(context.Background(), containerID)
|
||||
}()
|
||||
|
||||
e.trackCancel(t.ID, cancel)
|
||||
defer e.clearCancel(t.ID)
|
||||
|
||||
exitCode, waitErr := e.docker.Wait(runCtx, containerID)
|
||||
if waitErr != nil {
|
||||
if errors.Is(waitErr, context.DeadlineExceeded) || errors.Is(runCtx.Err(), context.DeadlineExceeded) {
|
||||
e.handleTimeout(ctx, t, containerID)
|
||||
return
|
||||
}
|
||||
if errors.Is(waitErr, context.Canceled) || errors.Is(runCtx.Err(), context.Canceled) {
|
||||
e.handleCancel(ctx, t, containerID)
|
||||
return
|
||||
}
|
||||
message := docker.TruncateErrorMessage(waitErr.Error())
|
||||
e.reportStatus(ctx, t.ID, "failed", message)
|
||||
return
|
||||
}
|
||||
|
||||
if runCtx.Err() != nil {
|
||||
if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
|
||||
e.handleTimeout(ctx, t, containerID)
|
||||
return
|
||||
}
|
||||
if errors.Is(runCtx.Err(), context.Canceled) {
|
||||
e.handleCancel(ctx, t, containerID)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if exitCode == 0 {
|
||||
e.reportStatus(ctx, t.ID, "completed", "")
|
||||
return
|
||||
}
|
||||
|
||||
logs, _ := e.docker.TailLogs(context.Background(), containerID, 100)
|
||||
message := logs
|
||||
if message == "" {
|
||||
message = fmt.Sprintf("container exited with code %d", exitCode)
|
||||
}
|
||||
message = docker.TruncateErrorMessage(message)
|
||||
e.reportStatus(ctx, t.ID, "failed", message)
|
||||
}
|
||||
|
||||
func (e *Executor) handleCancel(ctx context.Context, t *domain.Task, containerID string) {
|
||||
_ = e.docker.Stop(context.Background(), containerID)
|
||||
e.reportStatus(ctx, t.ID, "cancelled", "")
|
||||
}
|
||||
|
||||
func (e *Executor) handleTimeout(ctx context.Context, t *domain.Task, containerID string) {
|
||||
_ = e.docker.Stop(context.Background(), containerID)
|
||||
message := docker.TruncateErrorMessage("task timed out")
|
||||
e.reportStatus(ctx, t.ID, "failed", message)
|
||||
}
|
||||
|
||||
func (e *Executor) trackCancel(taskID int, cancel context.CancelFunc) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.running[taskID] = cancel
|
||||
}
|
||||
|
||||
func (e *Executor) clearCancel(taskID int) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
delete(e.running, taskID)
|
||||
}
|
||||
|
||||
func (e *Executor) isCancelled(taskID int) bool {
|
||||
e.cancelMu.Lock()
|
||||
defer e.cancelMu.Unlock()
|
||||
_, ok := e.cancelled[taskID]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (e *Executor) clearCancelled(taskID int) {
|
||||
e.cancelMu.Lock()
|
||||
delete(e.cancelled, taskID)
|
||||
e.cancelMu.Unlock()
|
||||
}
|
||||
|
||||
// CancelAll requests cancellation for all running tasks.
|
||||
func (e *Executor) CancelAll() {
|
||||
e.mu.Lock()
|
||||
cancels := make([]context.CancelFunc, 0, len(e.running))
|
||||
for _, cancel := range e.running {
|
||||
cancels = append(cancels, cancel)
|
||||
}
|
||||
e.mu.Unlock()
|
||||
|
||||
for _, cancel := range cancels {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown cancels running tasks and waits for completion.
|
||||
func (e *Executor) Shutdown(ctx context.Context) error {
|
||||
e.stopping.Store(true)
|
||||
e.CancelAll()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
e.wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-done:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
107
agent/internal/task/executor_test.go
Normal file
107
agent/internal/task/executor_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yyhuni/lunafox/agent/internal/domain"
|
||||
)
|
||||
|
||||
type fakeReporter struct {
|
||||
status string
|
||||
msg string
|
||||
}
|
||||
|
||||
func (f *fakeReporter) UpdateStatus(ctx context.Context, taskID int, status, errorMessage string) error {
|
||||
f.status = status
|
||||
f.msg = errorMessage
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestExecutorMissingWorkerToken(t *testing.T) {
|
||||
reporter := &fakeReporter{}
|
||||
exec := &Executor{
|
||||
client: reporter,
|
||||
serverURL: "https://server",
|
||||
workerToken: "",
|
||||
}
|
||||
|
||||
exec.execute(context.Background(), &domain.Task{ID: 1})
|
||||
if reporter.status != "failed" {
|
||||
t.Fatalf("expected failed status, got %s", reporter.status)
|
||||
}
|
||||
if reporter.msg == "" {
|
||||
t.Fatalf("expected error message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorDockerUnavailable(t *testing.T) {
|
||||
reporter := &fakeReporter{}
|
||||
exec := &Executor{
|
||||
client: reporter,
|
||||
serverURL: "https://server",
|
||||
workerToken: "token",
|
||||
}
|
||||
|
||||
exec.execute(context.Background(), &domain.Task{ID: 2})
|
||||
if reporter.status != "failed" {
|
||||
t.Fatalf("expected failed status, got %s", reporter.status)
|
||||
}
|
||||
if reporter.msg == "" {
|
||||
t.Fatalf("expected error message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorCancelAll(t *testing.T) {
|
||||
exec := &Executor{
|
||||
running: map[int]context.CancelFunc{},
|
||||
}
|
||||
calls := 0
|
||||
exec.running[1] = func() { calls++ }
|
||||
exec.running[2] = func() { calls++ }
|
||||
|
||||
exec.CancelAll()
|
||||
if calls != 2 {
|
||||
t.Fatalf("expected cancel calls, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorShutdownWaits(t *testing.T) {
|
||||
exec := &Executor{
|
||||
running: map[int]context.CancelFunc{},
|
||||
}
|
||||
calls := 0
|
||||
exec.running[1] = func() { calls++ }
|
||||
|
||||
exec.wg.Add(1)
|
||||
go func() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
exec.wg.Done()
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := exec.Shutdown(ctx); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("expected cancel call")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorShutdownTimeout(t *testing.T) {
|
||||
exec := &Executor{
|
||||
running: map[int]context.CancelFunc{},
|
||||
}
|
||||
exec.wg.Add(1)
|
||||
defer exec.wg.Done()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
if err := exec.Shutdown(ctx); err == nil {
|
||||
t.Fatalf("expected timeout error")
|
||||
}
|
||||
}
|
||||
252
agent/internal/task/puller.go
Normal file
252
agent/internal/task/puller.go
Normal file
@@ -0,0 +1,252 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/yyhuni/lunafox/agent/internal/domain"
|
||||
)
|
||||
|
||||
// Puller coordinates task pulling with load gating and backoff.
|
||||
type Puller struct {
|
||||
client TaskPuller
|
||||
collector MetricsSampler
|
||||
counter *Counter
|
||||
maxTasks int
|
||||
cpuThreshold int
|
||||
memThreshold int
|
||||
diskThreshold int
|
||||
|
||||
onTask func(*domain.Task)
|
||||
notifyCh chan struct{}
|
||||
emptyBackoff []time.Duration
|
||||
emptyIdx int
|
||||
errorBackoff time.Duration
|
||||
errorMax time.Duration
|
||||
randSrc *rand.Rand
|
||||
mu sync.RWMutex
|
||||
paused atomic.Bool
|
||||
}
|
||||
|
||||
type MetricsSampler interface {
|
||||
Sample() (float64, float64, float64)
|
||||
}
|
||||
|
||||
type TaskPuller interface {
|
||||
PullTask(ctx context.Context) (*domain.Task, error)
|
||||
}
|
||||
|
||||
// NewPuller creates a new Puller.
|
||||
func NewPuller(client TaskPuller, collector MetricsSampler, counter *Counter, maxTasks, cpuThreshold, memThreshold, diskThreshold int) *Puller {
|
||||
return &Puller{
|
||||
client: client,
|
||||
collector: collector,
|
||||
counter: counter,
|
||||
maxTasks: maxTasks,
|
||||
cpuThreshold: cpuThreshold,
|
||||
memThreshold: memThreshold,
|
||||
diskThreshold: diskThreshold,
|
||||
notifyCh: make(chan struct{}, 1),
|
||||
emptyBackoff: []time.Duration{5 * time.Second, 10 * time.Second, 30 * time.Second, 60 * time.Second},
|
||||
errorBackoff: 1 * time.Second,
|
||||
errorMax: 60 * time.Second,
|
||||
randSrc: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||
}
|
||||
}
|
||||
|
||||
// SetOnTask registers a callback invoked when a task is assigned.
|
||||
func (p *Puller) SetOnTask(fn func(*domain.Task)) {
|
||||
p.onTask = fn
|
||||
}
|
||||
|
||||
// NotifyTaskAvailable triggers an immediate pull attempt.
|
||||
func (p *Puller) NotifyTaskAvailable() {
|
||||
select {
|
||||
case p.notifyCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the pull loop.
|
||||
func (p *Puller) Run(ctx context.Context) error {
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
if p.paused.Load() {
|
||||
if !p.waitUntilCanceled(ctx) {
|
||||
return ctx.Err()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
loadInterval := p.loadInterval()
|
||||
if !p.canPull() {
|
||||
if !p.wait(ctx, loadInterval) {
|
||||
return ctx.Err()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
task, err := p.client.PullTask(ctx)
|
||||
if err != nil {
|
||||
delay := p.nextErrorBackoff()
|
||||
if !p.wait(ctx, delay) {
|
||||
return ctx.Err()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
p.resetErrorBackoff()
|
||||
if task == nil {
|
||||
delay := p.nextEmptyDelay(loadInterval)
|
||||
if !p.waitOrNotify(ctx, delay) {
|
||||
return ctx.Err()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
p.resetEmptyBackoff()
|
||||
if p.onTask != nil {
|
||||
p.onTask(task)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Puller) canPull() bool {
|
||||
maxTasks, cpuThreshold, memThreshold, diskThreshold := p.currentConfig()
|
||||
if p.counter != nil && p.counter.Count() >= maxTasks {
|
||||
return false
|
||||
}
|
||||
cpu, mem, disk := p.collector.Sample()
|
||||
return cpu < float64(cpuThreshold) &&
|
||||
mem < float64(memThreshold) &&
|
||||
disk < float64(diskThreshold)
|
||||
}
|
||||
|
||||
func (p *Puller) loadInterval() time.Duration {
|
||||
cpu, mem, disk := p.collector.Sample()
|
||||
load := math.Max(cpu, math.Max(mem, disk))
|
||||
switch {
|
||||
case load < 50:
|
||||
return 1 * time.Second
|
||||
case load < 80:
|
||||
return 3 * time.Second
|
||||
default:
|
||||
return 10 * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Puller) nextEmptyDelay(loadInterval time.Duration) time.Duration {
|
||||
var empty time.Duration
|
||||
if p.emptyIdx < len(p.emptyBackoff) {
|
||||
empty = p.emptyBackoff[p.emptyIdx]
|
||||
p.emptyIdx++
|
||||
} else {
|
||||
empty = p.emptyBackoff[len(p.emptyBackoff)-1]
|
||||
}
|
||||
if empty < loadInterval {
|
||||
return loadInterval
|
||||
}
|
||||
return empty
|
||||
}
|
||||
|
||||
func (p *Puller) resetEmptyBackoff() {
|
||||
p.emptyIdx = 0
|
||||
}
|
||||
|
||||
func (p *Puller) nextErrorBackoff() time.Duration {
|
||||
delay := p.errorBackoff
|
||||
next := delay * 2
|
||||
if next > p.errorMax {
|
||||
next = p.errorMax
|
||||
}
|
||||
p.errorBackoff = next
|
||||
return withJitter(delay, p.randSrc)
|
||||
}
|
||||
|
||||
func (p *Puller) resetErrorBackoff() {
|
||||
p.errorBackoff = 1 * time.Second
|
||||
}
|
||||
|
||||
func (p *Puller) wait(ctx context.Context, delay time.Duration) bool {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Puller) waitOrNotify(ctx context.Context, delay time.Duration) bool {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-p.notifyCh:
|
||||
return true
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func withJitter(delay time.Duration, src *rand.Rand) time.Duration {
|
||||
if delay <= 0 || src == nil {
|
||||
return delay
|
||||
}
|
||||
jitter := src.Float64() * 0.2
|
||||
return delay + time.Duration(float64(delay)*jitter)
|
||||
}
|
||||
|
||||
func (p *Puller) EnsureTaskHandler() error {
|
||||
if p.onTask == nil {
|
||||
return errors.New("task handler is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Pause stops pulling. Once paused, only context cancellation exits the loop.
|
||||
func (p *Puller) Pause() {
|
||||
p.paused.Store(true)
|
||||
}
|
||||
|
||||
// UpdateConfig updates puller thresholds and max tasks.
|
||||
func (p *Puller) UpdateConfig(maxTasks, cpuThreshold, memThreshold, diskThreshold *int) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if maxTasks != nil && *maxTasks > 0 {
|
||||
p.maxTasks = *maxTasks
|
||||
}
|
||||
if cpuThreshold != nil && *cpuThreshold > 0 {
|
||||
p.cpuThreshold = *cpuThreshold
|
||||
}
|
||||
if memThreshold != nil && *memThreshold > 0 {
|
||||
p.memThreshold = *memThreshold
|
||||
}
|
||||
if diskThreshold != nil && *diskThreshold > 0 {
|
||||
p.diskThreshold = *diskThreshold
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Puller) currentConfig() (int, int, int, int) {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.maxTasks, p.cpuThreshold, p.memThreshold, p.diskThreshold
|
||||
}
|
||||
|
||||
func (p *Puller) waitUntilCanceled(ctx context.Context) bool {
|
||||
<-ctx.Done()
|
||||
return false
|
||||
}
|
||||
101
agent/internal/task/puller_test.go
Normal file
101
agent/internal/task/puller_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yyhuni/lunafox/agent/internal/domain"
|
||||
)
|
||||
|
||||
func TestPullerUpdateConfig(t *testing.T) {
|
||||
p := NewPuller(nil, nil, nil, 5, 85, 86, 87)
|
||||
max, cpu, mem, disk := p.currentConfig()
|
||||
if max != 5 || cpu != 85 || mem != 86 || disk != 87 {
|
||||
t.Fatalf("unexpected initial config")
|
||||
}
|
||||
|
||||
maxUpdate := 8
|
||||
cpuUpdate := 70
|
||||
p.UpdateConfig(&maxUpdate, &cpuUpdate, nil, nil)
|
||||
max, cpu, mem, disk = p.currentConfig()
|
||||
if max != 8 || cpu != 70 || mem != 86 || disk != 87 {
|
||||
t.Fatalf("unexpected updated config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullerPause(t *testing.T) {
|
||||
p := NewPuller(nil, nil, nil, 1, 1, 1, 1)
|
||||
p.Pause()
|
||||
if !p.paused.Load() {
|
||||
t.Fatalf("expected paused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullerEnsureTaskHandler(t *testing.T) {
|
||||
p := NewPuller(nil, nil, nil, 1, 1, 1, 1)
|
||||
if err := p.EnsureTaskHandler(); err == nil {
|
||||
t.Fatalf("expected error when handler missing")
|
||||
}
|
||||
p.SetOnTask(func(*domain.Task) {})
|
||||
if err := p.EnsureTaskHandler(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullerNextEmptyDelay(t *testing.T) {
|
||||
p := NewPuller(nil, nil, nil, 1, 1, 1, 1)
|
||||
p.emptyBackoff = []time.Duration{5 * time.Second, 10 * time.Second}
|
||||
|
||||
if delay := p.nextEmptyDelay(8 * time.Second); delay != 8*time.Second {
|
||||
t.Fatalf("expected delay to honor load interval, got %v", delay)
|
||||
}
|
||||
if delay := p.nextEmptyDelay(1 * time.Second); delay != 10*time.Second {
|
||||
t.Fatalf("expected backoff delay, got %v", delay)
|
||||
}
|
||||
if p.emptyIdx != 2 {
|
||||
t.Fatalf("expected empty index to advance")
|
||||
}
|
||||
p.resetEmptyBackoff()
|
||||
if p.emptyIdx != 0 {
|
||||
t.Fatalf("expected empty index reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullerErrorBackoff(t *testing.T) {
|
||||
p := NewPuller(nil, nil, nil, 1, 1, 1, 1)
|
||||
p.randSrc = rand.New(rand.NewSource(1))
|
||||
|
||||
first := p.nextErrorBackoff()
|
||||
if first < time.Second || first > time.Second+(time.Second/5) {
|
||||
t.Fatalf("unexpected backoff %v", first)
|
||||
}
|
||||
if p.errorBackoff != 2*time.Second {
|
||||
t.Fatalf("expected backoff to double")
|
||||
}
|
||||
|
||||
second := p.nextErrorBackoff()
|
||||
if second < 2*time.Second || second > 2*time.Second+(2*time.Second/5) {
|
||||
t.Fatalf("unexpected backoff %v", second)
|
||||
}
|
||||
if p.errorBackoff != 4*time.Second {
|
||||
t.Fatalf("expected backoff to double")
|
||||
}
|
||||
|
||||
p.resetErrorBackoff()
|
||||
if p.errorBackoff != time.Second {
|
||||
t.Fatalf("expected error backoff reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithJitterRange(t *testing.T) {
|
||||
rng := rand.New(rand.NewSource(1))
|
||||
delay := 10 * time.Second
|
||||
got := withJitter(delay, rng)
|
||||
if got < delay {
|
||||
t.Fatalf("expected jitter >= delay")
|
||||
}
|
||||
if got > delay+(delay/5) {
|
||||
t.Fatalf("expected jitter <= 20%%")
|
||||
}
|
||||
}
|
||||
279
agent/internal/update/updater.go
Normal file
279
agent/internal/update/updater.go
Normal file
@@ -0,0 +1,279 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/docker/api/types/strslice"
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"github.com/yyhuni/lunafox/agent/internal/config"
|
||||
"github.com/yyhuni/lunafox/agent/internal/domain"
|
||||
"github.com/yyhuni/lunafox/agent/internal/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Updater handles agent self-update.
|
||||
type Updater struct {
|
||||
docker dockerClient
|
||||
health healthSetter
|
||||
puller pullerController
|
||||
executor executorController
|
||||
cfg configSnapshot
|
||||
apiKey string
|
||||
token string
|
||||
mu sync.Mutex
|
||||
updating bool
|
||||
randSrc *rand.Rand
|
||||
backoff time.Duration
|
||||
maxBackoff time.Duration
|
||||
}
|
||||
|
||||
type dockerClient interface {
|
||||
ImagePull(ctx context.Context, imageRef string) (io.ReadCloser, error)
|
||||
ContainerCreate(ctx context.Context, config *container.Config, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig, platform *ocispec.Platform, name string) (container.CreateResponse, error)
|
||||
ContainerStart(ctx context.Context, containerID string, opts container.StartOptions) error
|
||||
}
|
||||
|
||||
type healthSetter interface {
|
||||
Set(state, reason, message string)
|
||||
}
|
||||
|
||||
type pullerController interface {
|
||||
Pause()
|
||||
}
|
||||
|
||||
type executorController interface {
|
||||
Shutdown(ctx context.Context) error
|
||||
}
|
||||
|
||||
type configSnapshot interface {
|
||||
Snapshot() config.Config
|
||||
}
|
||||
|
||||
// NewUpdater creates a new updater.
|
||||
func NewUpdater(dockerClient dockerClient, healthManager healthSetter, puller pullerController, executor executorController, cfg configSnapshot, apiKey, token string) *Updater {
|
||||
return &Updater{
|
||||
docker: dockerClient,
|
||||
health: healthManager,
|
||||
puller: puller,
|
||||
executor: executor,
|
||||
cfg: cfg,
|
||||
apiKey: apiKey,
|
||||
token: token,
|
||||
randSrc: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||
backoff: 30 * time.Second,
|
||||
maxBackoff: 10 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleUpdateRequired triggers the update flow.
|
||||
func (u *Updater) HandleUpdateRequired(payload domain.UpdateRequiredPayload) {
|
||||
u.mu.Lock()
|
||||
if u.updating {
|
||||
u.mu.Unlock()
|
||||
return
|
||||
}
|
||||
u.updating = true
|
||||
u.mu.Unlock()
|
||||
|
||||
go u.run(payload)
|
||||
}
|
||||
|
||||
func (u *Updater) run(payload domain.UpdateRequiredPayload) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger.Log.Error("agent update panic", zap.Any("panic", r))
|
||||
u.health.Set("paused", "update_panic", fmt.Sprintf("%v", r))
|
||||
}
|
||||
u.mu.Lock()
|
||||
u.updating = false
|
||||
u.mu.Unlock()
|
||||
}()
|
||||
u.puller.Pause()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
_ = u.executor.Shutdown(ctx)
|
||||
cancel()
|
||||
|
||||
for {
|
||||
if err := u.updateOnce(payload); err == nil {
|
||||
u.health.Set("ok", "", "")
|
||||
os.Exit(0)
|
||||
} else {
|
||||
u.health.Set("paused", "update_failed", err.Error())
|
||||
}
|
||||
|
||||
delay := withJitter(u.backoff, u.randSrc)
|
||||
if u.backoff < u.maxBackoff {
|
||||
u.backoff *= 2
|
||||
if u.backoff > u.maxBackoff {
|
||||
u.backoff = u.maxBackoff
|
||||
}
|
||||
}
|
||||
time.Sleep(delay)
|
||||
}
|
||||
}
|
||||
|
||||
func (u *Updater) updateOnce(payload domain.UpdateRequiredPayload) error {
|
||||
if u.docker == nil {
|
||||
return fmt.Errorf("docker client unavailable")
|
||||
}
|
||||
image := strings.TrimSpace(payload.Image)
|
||||
version := strings.TrimSpace(payload.Version)
|
||||
if image == "" || version == "" {
|
||||
return fmt.Errorf("invalid update payload")
|
||||
}
|
||||
|
||||
// Strict validation: reject invalid data from server
|
||||
if err := validateImageName(image); err != nil {
|
||||
logger.Log.Warn("invalid image name from server", zap.String("image", image), zap.Error(err))
|
||||
return fmt.Errorf("invalid image name from server: %w", err)
|
||||
}
|
||||
if err := validateVersion(version); err != nil {
|
||||
logger.Log.Warn("invalid version from server", zap.String("version", version), zap.Error(err))
|
||||
return fmt.Errorf("invalid version from server: %w", err)
|
||||
}
|
||||
|
||||
fullImage := fmt.Sprintf("%s:%s", image, version)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
reader, err := u.docker.ImagePull(ctx, fullImage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, reader)
|
||||
_ = reader.Close()
|
||||
|
||||
if err := u.startNewContainer(ctx, image, version); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *Updater) startNewContainer(ctx context.Context, image, version string) error {
|
||||
env := []string{
|
||||
fmt.Sprintf("SERVER_URL=%s", u.cfg.Snapshot().ServerURL),
|
||||
fmt.Sprintf("API_KEY=%s", u.apiKey),
|
||||
fmt.Sprintf("MAX_TASKS=%d", u.cfg.Snapshot().MaxTasks),
|
||||
fmt.Sprintf("CPU_THRESHOLD=%d", u.cfg.Snapshot().CPUThreshold),
|
||||
fmt.Sprintf("MEM_THRESHOLD=%d", u.cfg.Snapshot().MemThreshold),
|
||||
fmt.Sprintf("DISK_THRESHOLD=%d", u.cfg.Snapshot().DiskThreshold),
|
||||
fmt.Sprintf("AGENT_VERSION=%s", version),
|
||||
}
|
||||
if u.token != "" {
|
||||
env = append(env, fmt.Sprintf("WORKER_TOKEN=%s", u.token))
|
||||
}
|
||||
|
||||
cfg := &container.Config{
|
||||
Image: fmt.Sprintf("%s:%s", image, version),
|
||||
Env: env,
|
||||
Cmd: strslice.StrSlice{},
|
||||
}
|
||||
|
||||
hostConfig := &container.HostConfig{
|
||||
Binds: []string{
|
||||
"/var/run/docker.sock:/var/run/docker.sock",
|
||||
"/opt/lunafox:/opt/lunafox",
|
||||
},
|
||||
RestartPolicy: container.RestartPolicy{Name: "unless-stopped"},
|
||||
OomScoreAdj: -500,
|
||||
}
|
||||
|
||||
// Version is already validated, just normalize to lowercase for container name
|
||||
name := fmt.Sprintf("lunafox-agent-%s", strings.ToLower(version))
|
||||
resp, err := u.docker.ContainerCreate(ctx, cfg, hostConfig, &network.NetworkingConfig{}, nil, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := u.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Log.Info("agent update started new container", zap.String("containerId", resp.ID))
|
||||
return nil
|
||||
}
|
||||
|
||||
func withJitter(delay time.Duration, src *rand.Rand) time.Duration {
|
||||
if delay <= 0 || src == nil {
|
||||
return delay
|
||||
}
|
||||
jitter := src.Float64() * 0.2
|
||||
return delay + time.Duration(float64(delay)*jitter)
|
||||
}
|
||||
|
||||
// validateImageName validates that the image name contains only safe characters.
|
||||
// Returns error if validation fails.
|
||||
func validateImageName(image string) error {
|
||||
if len(image) == 0 {
|
||||
return fmt.Errorf("image name cannot be empty")
|
||||
}
|
||||
if len(image) > 255 {
|
||||
return fmt.Errorf("image name too long: %d characters", len(image))
|
||||
}
|
||||
|
||||
// Allow: alphanumeric, dots, hyphens, underscores, slashes (for registry paths)
|
||||
for i, r := range image {
|
||||
if !((r >= 'a' && r <= 'z') ||
|
||||
(r >= 'A' && r <= 'Z') ||
|
||||
(r >= '0' && r <= '9') ||
|
||||
r == '.' || r == '-' || r == '_' || r == '/') {
|
||||
return fmt.Errorf("invalid character at position %d: %c", i, r)
|
||||
}
|
||||
}
|
||||
|
||||
// Must not start or end with special characters
|
||||
first := rune(image[0])
|
||||
last := rune(image[len(image)-1])
|
||||
if first == '.' || first == '-' || first == '/' {
|
||||
return fmt.Errorf("image name cannot start with special character: %c", first)
|
||||
}
|
||||
if last == '.' || last == '-' || last == '/' {
|
||||
return fmt.Errorf("image name cannot end with special character: %c", last)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateVersion validates that the version string contains only safe characters.
|
||||
// Returns error if validation fails.
|
||||
func validateVersion(version string) error {
|
||||
if len(version) == 0 {
|
||||
return fmt.Errorf("version cannot be empty")
|
||||
}
|
||||
if len(version) > 128 {
|
||||
return fmt.Errorf("version too long: %d characters", len(version))
|
||||
}
|
||||
|
||||
// Allow: alphanumeric, dots, hyphens, underscores
|
||||
for i, r := range version {
|
||||
if !((r >= 'a' && r <= 'z') ||
|
||||
(r >= 'A' && r <= 'Z') ||
|
||||
(r >= '0' && r <= '9') ||
|
||||
r == '.' || r == '-' || r == '_') {
|
||||
return fmt.Errorf("invalid character at position %d: %c", i, r)
|
||||
}
|
||||
}
|
||||
|
||||
// Must not start or end with special characters
|
||||
first := rune(version[0])
|
||||
last := rune(version[len(version)-1])
|
||||
if first == '.' || first == '-' || first == '_' {
|
||||
return fmt.Errorf("version cannot start with special character: %c", first)
|
||||
}
|
||||
if last == '.' || last == '-' || last == '_' {
|
||||
return fmt.Errorf("version cannot end with special character: %c", last)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
45
agent/internal/update/updater_test.go
Normal file
45
agent/internal/update/updater_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yyhuni/lunafox/agent/internal/domain"
|
||||
)
|
||||
|
||||
func TestSanitizeContainerName(t *testing.T) {
|
||||
got := sanitizeContainerName("v1.0.0+TEST")
|
||||
if got == "" {
|
||||
t.Fatalf("expected sanitized name")
|
||||
}
|
||||
if got == "v1.0.0+test" {
|
||||
t.Fatalf("expected sanitized to replace invalid chars")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithJitterRange(t *testing.T) {
|
||||
rng := rand.New(rand.NewSource(1))
|
||||
delay := 10 * time.Second
|
||||
got := withJitter(delay, rng)
|
||||
if got < delay {
|
||||
t.Fatalf("expected jitter >= delay")
|
||||
}
|
||||
if got > delay+(delay/5) {
|
||||
t.Fatalf("expected jitter <= 20%%")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateOnceDockerUnavailable(t *testing.T) {
|
||||
updater := &Updater{}
|
||||
payload := domain.UpdateRequiredPayload{Version: "v1.0.0", Image: "yyhuni/lunafox-agent"}
|
||||
|
||||
err := updater.updateOnce(payload)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when docker client is nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "docker client unavailable") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
37
agent/internal/websocket/backoff.go
Normal file
37
agent/internal/websocket/backoff.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package websocket
|
||||
|
||||
import "time"
|
||||
|
||||
// Backoff implements exponential backoff with a maximum cap.
|
||||
type Backoff struct {
|
||||
base time.Duration
|
||||
max time.Duration
|
||||
current time.Duration
|
||||
}
|
||||
|
||||
// NewBackoff creates a backoff with the given base and max delay.
|
||||
func NewBackoff(base, max time.Duration) Backoff {
|
||||
return Backoff{
|
||||
base: base,
|
||||
max: max,
|
||||
}
|
||||
}
|
||||
|
||||
// Next returns the next backoff duration.
|
||||
func (b *Backoff) Next() time.Duration {
|
||||
if b.current <= 0 {
|
||||
b.current = b.base
|
||||
return b.current
|
||||
}
|
||||
next := b.current * 2
|
||||
if next > b.max {
|
||||
next = b.max
|
||||
}
|
||||
b.current = next
|
||||
return b.current
|
||||
}
|
||||
|
||||
// Reset clears the backoff to start over.
|
||||
func (b *Backoff) Reset() {
|
||||
b.current = 0
|
||||
}
|
||||
32
agent/internal/websocket/backoff_test.go
Normal file
32
agent/internal/websocket/backoff_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBackoffSequence(t *testing.T) {
|
||||
b := NewBackoff(time.Second, 60*time.Second)
|
||||
|
||||
expected := []time.Duration{
|
||||
time.Second,
|
||||
2 * time.Second,
|
||||
4 * time.Second,
|
||||
8 * time.Second,
|
||||
16 * time.Second,
|
||||
32 * time.Second,
|
||||
60 * time.Second,
|
||||
60 * time.Second,
|
||||
}
|
||||
|
||||
for i, exp := range expected {
|
||||
if got := b.Next(); got != exp {
|
||||
t.Fatalf("step %d: expected %v, got %v", i, exp, got)
|
||||
}
|
||||
}
|
||||
|
||||
b.Reset()
|
||||
if got := b.Next(); got != time.Second {
|
||||
t.Fatalf("after reset expected %v, got %v", time.Second, got)
|
||||
}
|
||||
}
|
||||
177
agent/internal/websocket/client.go
Normal file
177
agent/internal/websocket/client.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/yyhuni/lunafox/agent/internal/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPingInterval = 30 * time.Second
|
||||
defaultPongWait = 60 * time.Second
|
||||
defaultWriteWait = 10 * time.Second
|
||||
)
|
||||
|
||||
// Client maintains a WebSocket connection to the server.
|
||||
type Client struct {
|
||||
wsURL string
|
||||
apiKey string
|
||||
dialer *websocket.Dialer
|
||||
send chan []byte
|
||||
onMessage func([]byte)
|
||||
backoff Backoff
|
||||
pingInterval time.Duration
|
||||
pongWait time.Duration
|
||||
writeWait time.Duration
|
||||
}
|
||||
|
||||
// NewClient creates a WebSocket client for the agent.
|
||||
func NewClient(wsURL, apiKey string) *Client {
|
||||
dialer := *websocket.DefaultDialer
|
||||
dialer.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
return &Client{
|
||||
wsURL: wsURL,
|
||||
apiKey: apiKey,
|
||||
dialer: &dialer,
|
||||
send: make(chan []byte, 256),
|
||||
backoff: NewBackoff(1*time.Second, 60*time.Second),
|
||||
pingInterval: defaultPingInterval,
|
||||
pongWait: defaultPongWait,
|
||||
writeWait: defaultWriteWait,
|
||||
}
|
||||
}
|
||||
|
||||
// SetOnMessage registers a callback for incoming messages.
|
||||
func (c *Client) SetOnMessage(fn func([]byte)) {
|
||||
c.onMessage = fn
|
||||
}
|
||||
|
||||
// Send queues a message for sending. It returns false if the buffer is full.
|
||||
func (c *Client) Send(payload []byte) bool {
|
||||
select {
|
||||
case c.send <- payload:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Run keeps the connection alive with reconnect backoff and keepalive pings.
|
||||
func (c *Client) Run(ctx context.Context) error {
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
logger.Log.Info("websocket connect attempt", zap.String("url", c.wsURL))
|
||||
conn, err := c.connect(ctx)
|
||||
if err != nil {
|
||||
logger.Log.Warn("websocket connect failed", zap.Error(err))
|
||||
if !sleepWithContext(ctx, c.backoff.Next()) {
|
||||
return ctx.Err()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
c.backoff.Reset()
|
||||
logger.Log.Info("websocket connected")
|
||||
err = c.runConn(ctx, conn)
|
||||
if err != nil && ctx.Err() == nil {
|
||||
logger.Log.Warn("websocket connection closed", zap.Error(err))
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if !sleepWithContext(ctx, c.backoff.Next()) {
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) connect(ctx context.Context) (*websocket.Conn, error) {
|
||||
header := http.Header{}
|
||||
if c.apiKey != "" {
|
||||
header.Set("X-Agent-Key", c.apiKey)
|
||||
}
|
||||
conn, _, err := c.dialer.DialContext(ctx, c.wsURL, header)
|
||||
return conn, err
|
||||
}
|
||||
|
||||
func (c *Client) runConn(ctx context.Context, conn *websocket.Conn) error {
|
||||
defer conn.Close()
|
||||
|
||||
conn.SetReadDeadline(time.Now().Add(c.pongWait))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
conn.SetReadDeadline(time.Now().Add(c.pongWait))
|
||||
return nil
|
||||
})
|
||||
|
||||
errCh := make(chan error, 2)
|
||||
go c.readLoop(conn, errCh)
|
||||
go c.writeLoop(ctx, conn, errCh)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case err := <-errCh:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) readLoop(conn *websocket.Conn, errCh chan<- error) {
|
||||
for {
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
if c.onMessage != nil {
|
||||
c.onMessage(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) writeLoop(ctx context.Context, conn *websocket.Conn, errCh chan<- error) {
|
||||
ticker := time.NewTicker(c.pingInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
errCh <- ctx.Err()
|
||||
return
|
||||
case payload := <-c.send:
|
||||
if err := c.writeMessage(conn, websocket.TextMessage, payload); err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
if err := c.writeMessage(conn, websocket.PingMessage, nil); err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) writeMessage(conn *websocket.Conn, msgType int, payload []byte) error {
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(c.writeWait))
|
||||
return conn.WriteMessage(msgType, payload)
|
||||
}
|
||||
|
||||
func sleepWithContext(ctx context.Context, delay time.Duration) bool {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
32
agent/internal/websocket/client_test.go
Normal file
32
agent/internal/websocket/client_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestClientSendBufferFull(t *testing.T) {
|
||||
client := &Client{send: make(chan []byte, 1)}
|
||||
if !client.Send([]byte("first")) {
|
||||
t.Fatalf("expected first send to succeed")
|
||||
}
|
||||
if client.Send([]byte("second")) {
|
||||
t.Fatalf("expected second send to fail when buffer is full")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSleepWithContextCancelled(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
if sleepWithContext(ctx, 50*time.Millisecond) {
|
||||
t.Fatalf("expected sleepWithContext to return false when canceled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSleepWithContextElapsed(t *testing.T) {
|
||||
if !sleepWithContext(context.Background(), 5*time.Millisecond) {
|
||||
t.Fatalf("expected sleepWithContext to return true after delay")
|
||||
}
|
||||
}
|
||||
90
agent/internal/websocket/handlers.go
Normal file
90
agent/internal/websocket/handlers.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/yyhuni/lunafox/agent/internal/protocol"
|
||||
)
|
||||
|
||||
// Handler routes incoming WebSocket messages.
|
||||
type Handler struct {
|
||||
onTaskAvailable func()
|
||||
onTaskCancel func(int)
|
||||
onConfigUpdate func(protocol.ConfigUpdatePayload)
|
||||
onUpdateReq func(protocol.UpdateRequiredPayload)
|
||||
}
|
||||
|
||||
// NewHandler creates a message handler.
|
||||
func NewHandler() *Handler {
|
||||
return &Handler{}
|
||||
}
|
||||
|
||||
// OnTaskAvailable registers a callback for task_available messages.
|
||||
func (h *Handler) OnTaskAvailable(fn func()) {
|
||||
h.onTaskAvailable = fn
|
||||
}
|
||||
|
||||
// OnTaskCancel registers a callback for task_cancel messages.
|
||||
func (h *Handler) OnTaskCancel(fn func(int)) {
|
||||
h.onTaskCancel = fn
|
||||
}
|
||||
|
||||
// OnConfigUpdate registers a callback for config_update messages.
|
||||
func (h *Handler) OnConfigUpdate(fn func(protocol.ConfigUpdatePayload)) {
|
||||
h.onConfigUpdate = fn
|
||||
}
|
||||
|
||||
// OnUpdateRequired registers a callback for update_required messages.
|
||||
func (h *Handler) OnUpdateRequired(fn func(protocol.UpdateRequiredPayload)) {
|
||||
h.onUpdateReq = fn
|
||||
}
|
||||
|
||||
// Handle processes a raw message.
|
||||
func (h *Handler) Handle(raw []byte) {
|
||||
var msg struct {
|
||||
Type string `json:"type"`
|
||||
Data json.RawMessage `json:"payload"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case protocol.MessageTypeTaskAvailable:
|
||||
if h.onTaskAvailable != nil {
|
||||
h.onTaskAvailable()
|
||||
}
|
||||
case protocol.MessageTypeTaskCancel:
|
||||
if h.onTaskCancel == nil {
|
||||
return
|
||||
}
|
||||
var payload protocol.TaskCancelPayload
|
||||
if err := json.Unmarshal(msg.Data, &payload); err != nil {
|
||||
return
|
||||
}
|
||||
if payload.TaskID > 0 {
|
||||
h.onTaskCancel(payload.TaskID)
|
||||
}
|
||||
case protocol.MessageTypeConfigUpdate:
|
||||
if h.onConfigUpdate == nil {
|
||||
return
|
||||
}
|
||||
var payload protocol.ConfigUpdatePayload
|
||||
if err := json.Unmarshal(msg.Data, &payload); err != nil {
|
||||
return
|
||||
}
|
||||
h.onConfigUpdate(payload)
|
||||
case protocol.MessageTypeUpdateRequired:
|
||||
if h.onUpdateReq == nil {
|
||||
return
|
||||
}
|
||||
var payload protocol.UpdateRequiredPayload
|
||||
if err := json.Unmarshal(msg.Data, &payload); err != nil {
|
||||
return
|
||||
}
|
||||
if payload.Version == "" || payload.Image == "" {
|
||||
return
|
||||
}
|
||||
h.onUpdateReq(payload)
|
||||
}
|
||||
}
|
||||
85
agent/internal/websocket/handlers_test.go
Normal file
85
agent/internal/websocket/handlers_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/yyhuni/lunafox/agent/internal/protocol"
|
||||
)
|
||||
|
||||
func TestHandlersTaskAvailable(t *testing.T) {
|
||||
h := NewHandler()
|
||||
called := 0
|
||||
h.OnTaskAvailable(func() { called++ })
|
||||
|
||||
message := fmt.Sprintf(`{"type":"%s","payload":{},"timestamp":"2026-01-01T00:00:00Z"}`, protocol.MessageTypeTaskAvailable)
|
||||
h.Handle([]byte(message))
|
||||
if called != 1 {
|
||||
t.Fatalf("expected callback to be called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlersTaskCancel(t *testing.T) {
|
||||
h := NewHandler()
|
||||
var got int
|
||||
h.OnTaskCancel(func(id int) { got = id })
|
||||
|
||||
message := fmt.Sprintf(`{"type":"%s","payload":{"taskId":123},"timestamp":"2026-01-01T00:00:00Z"}`, protocol.MessageTypeTaskCancel)
|
||||
h.Handle([]byte(message))
|
||||
if got != 123 {
|
||||
t.Fatalf("expected taskId 123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlersConfigUpdate(t *testing.T) {
|
||||
h := NewHandler()
|
||||
var maxTasks int
|
||||
h.OnConfigUpdate(func(payload protocol.ConfigUpdatePayload) {
|
||||
if payload.MaxTasks != nil {
|
||||
maxTasks = *payload.MaxTasks
|
||||
}
|
||||
})
|
||||
|
||||
message := fmt.Sprintf(`{"type":"%s","payload":{"maxTasks":8},"timestamp":"2026-01-01T00:00:00Z"}`, protocol.MessageTypeConfigUpdate)
|
||||
h.Handle([]byte(message))
|
||||
if maxTasks != 8 {
|
||||
t.Fatalf("expected maxTasks 8")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlersUpdateRequired(t *testing.T) {
|
||||
h := NewHandler()
|
||||
var version string
|
||||
h.OnUpdateRequired(func(payload protocol.UpdateRequiredPayload) { version = payload.Version })
|
||||
|
||||
message := fmt.Sprintf(`{"type":"%s","payload":{"version":"v1.0.1","image":"yyhuni/lunafox-agent"},"timestamp":"2026-01-01T00:00:00Z"}`, protocol.MessageTypeUpdateRequired)
|
||||
h.Handle([]byte(message))
|
||||
if version != "v1.0.1" {
|
||||
t.Fatalf("expected version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlersIgnoreInvalidJSON(t *testing.T) {
|
||||
h := NewHandler()
|
||||
called := 0
|
||||
h.OnTaskAvailable(func() { called++ })
|
||||
|
||||
h.Handle([]byte("{bad json"))
|
||||
if called != 0 {
|
||||
t.Fatalf("expected no callbacks on invalid json")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlersUpdateRequiredMissingFields(t *testing.T) {
|
||||
h := NewHandler()
|
||||
called := 0
|
||||
h.OnUpdateRequired(func(payload protocol.UpdateRequiredPayload) { called++ })
|
||||
|
||||
message := fmt.Sprintf(`{"type":"%s","payload":{"version":"","image":"yyhuni/lunafox-agent"}}`, protocol.MessageTypeUpdateRequired)
|
||||
h.Handle([]byte(message))
|
||||
message = fmt.Sprintf(`{"type":"%s","payload":{"version":"v1.2.3","image":""}}`, protocol.MessageTypeUpdateRequired)
|
||||
h.Handle([]byte(message))
|
||||
if called != 0 {
|
||||
t.Fatalf("expected no callbacks for invalid payload")
|
||||
}
|
||||
}
|
||||
97
agent/internal/websocket/heartbeat.go
Normal file
97
agent/internal/websocket/heartbeat.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/yyhuni/lunafox/agent/internal/health"
|
||||
"github.com/yyhuni/lunafox/agent/internal/logger"
|
||||
"github.com/yyhuni/lunafox/agent/internal/metrics"
|
||||
"github.com/yyhuni/lunafox/agent/internal/protocol"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// HeartbeatSender sends periodic heartbeat messages over WebSocket.
|
||||
type HeartbeatSender struct {
|
||||
client *Client
|
||||
collector *metrics.Collector
|
||||
health *health.Manager
|
||||
version string
|
||||
hostname string
|
||||
startedAt time.Time
|
||||
taskCount func() int
|
||||
interval time.Duration
|
||||
lastSentAt time.Time
|
||||
}
|
||||
|
||||
// NewHeartbeatSender creates a heartbeat sender.
|
||||
func NewHeartbeatSender(client *Client, collector *metrics.Collector, healthManager *health.Manager, version, hostname string, taskCount func() int) *HeartbeatSender {
|
||||
return &HeartbeatSender{
|
||||
client: client,
|
||||
collector: collector,
|
||||
health: healthManager,
|
||||
version: version,
|
||||
hostname: hostname,
|
||||
startedAt: time.Now(),
|
||||
taskCount: taskCount,
|
||||
interval: 5 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins sending heartbeats until context is canceled.
|
||||
func (h *HeartbeatSender) Start(ctx context.Context) {
|
||||
ticker := time.NewTicker(h.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
h.sendOnce()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
h.sendOnce()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HeartbeatSender) sendOnce() {
|
||||
cpu, mem, disk := h.collector.Sample()
|
||||
uptime := int64(time.Since(h.startedAt).Seconds())
|
||||
tasks := 0
|
||||
if h.taskCount != nil {
|
||||
tasks = h.taskCount()
|
||||
}
|
||||
|
||||
status := h.health.Get()
|
||||
payload := protocol.HeartbeatPayload{
|
||||
CPU: cpu,
|
||||
Mem: mem,
|
||||
Disk: disk,
|
||||
Tasks: tasks,
|
||||
Version: h.version,
|
||||
Hostname: h.hostname,
|
||||
Uptime: uptime,
|
||||
Health: protocol.HealthStatus{
|
||||
State: status.State,
|
||||
Reason: status.Reason,
|
||||
Message: status.Message,
|
||||
Since: status.Since,
|
||||
},
|
||||
}
|
||||
|
||||
msg := protocol.Message{
|
||||
Type: protocol.MessageTypeHeartbeat,
|
||||
Payload: payload,
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
logger.Log.Warn("failed to marshal heartbeat message", zap.Error(err))
|
||||
return
|
||||
}
|
||||
if !h.client.Send(data) {
|
||||
logger.Log.Warn("failed to send heartbeat: client not connected")
|
||||
}
|
||||
}
|
||||
57
agent/internal/websocket/heartbeat_test.go
Normal file
57
agent/internal/websocket/heartbeat_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yyhuni/lunafox/agent/internal/health"
|
||||
"github.com/yyhuni/lunafox/agent/internal/metrics"
|
||||
"github.com/yyhuni/lunafox/agent/internal/protocol"
|
||||
)
|
||||
|
||||
func TestHeartbeatSenderSendOnce(t *testing.T) {
|
||||
client := &Client{send: make(chan []byte, 1)}
|
||||
collector := metrics.NewCollector()
|
||||
healthManager := health.NewManager()
|
||||
healthManager.Set("paused", "maintenance", "waiting")
|
||||
|
||||
sender := NewHeartbeatSender(client, collector, healthManager, "v1.0.0", "agent-host", func() int { return 3 })
|
||||
sender.sendOnce()
|
||||
|
||||
select {
|
||||
case payload := <-client.send:
|
||||
var msg struct {
|
||||
Type string `json:"type"`
|
||||
Payload map[string]interface{} `json:"payload"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &msg); err != nil {
|
||||
t.Fatalf("unmarshal heartbeat: %v", err)
|
||||
}
|
||||
if msg.Type != protocol.MessageTypeHeartbeat {
|
||||
t.Fatalf("expected heartbeat type, got %s", msg.Type)
|
||||
}
|
||||
if msg.Timestamp.IsZero() {
|
||||
t.Fatalf("expected timestamp")
|
||||
}
|
||||
if msg.Payload["version"] != "v1.0.0" {
|
||||
t.Fatalf("expected version in payload")
|
||||
}
|
||||
if msg.Payload["hostname"] != "agent-host" {
|
||||
t.Fatalf("expected hostname in payload")
|
||||
}
|
||||
if tasks, ok := msg.Payload["tasks"].(float64); !ok || int(tasks) != 3 {
|
||||
t.Fatalf("expected tasks=3")
|
||||
}
|
||||
healthPayload, ok := msg.Payload["health"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected health payload")
|
||||
}
|
||||
if healthPayload["state"] != "paused" {
|
||||
t.Fatalf("expected health state paused")
|
||||
}
|
||||
default:
|
||||
t.Fatalf("expected heartbeat message")
|
||||
}
|
||||
}
|
||||
13
agent/test/integration/task_test.go
Normal file
13
agent/test/integration/task_test.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTaskExecutionFlow(t *testing.T) {
|
||||
if os.Getenv("AGENT_INTEGRATION") == "" {
|
||||
t.Skip("set AGENT_INTEGRATION=1 to run integration tests")
|
||||
}
|
||||
// TODO: wire up real server + docker environment for end-to-end validation.
|
||||
}
|
||||
@@ -1,65 +1,38 @@
|
||||
# ==================== 数据库配置(PostgreSQL) ====================
|
||||
# DB_HOST 决定使用本地容器还是远程数据库:
|
||||
# - postgres / localhost / 127.0.0.1 → 启动本地 PostgreSQL 容器
|
||||
# - 其他地址(如 192.168.1.100) → 使用远程数据库,不启动本地容器
|
||||
# ============================================
|
||||
# Docker Image Configuration
|
||||
# ============================================
|
||||
IMAGE_TAG=dev
|
||||
|
||||
# ============================================
|
||||
# Required: Security Configuration
|
||||
# MUST change these in production!
|
||||
# ============================================
|
||||
JWT_SECRET=change-me-in-production-use-a-long-random-string
|
||||
WORKER_TOKEN=change-me-worker-token
|
||||
|
||||
# ============================================
|
||||
# Required: Docker Service Hosts
|
||||
# ============================================
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_NAME=xingrin
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=123.com
|
||||
|
||||
# ==================== Redis 配置 ====================
|
||||
# Redis 仅在 Docker 内部网络使用,不暴露公网端口
|
||||
DB_PASSWORD=postgres
|
||||
REDIS_HOST=redis
|
||||
REDIS_DB=0
|
||||
|
||||
# ==================== 服务端口配置 ====================
|
||||
# SERVER_PORT 为 Django / uvicorn 容器内部端口(由 nginx 反代,对公网不直接暴露)
|
||||
SERVER_PORT=8888
|
||||
# ============================================
|
||||
# Optional: Override defaults if needed
|
||||
# ============================================
|
||||
# PUBLIC_URL=https://your-domain.com:8083
|
||||
# SERVER_PORT=8080
|
||||
# GIN_MODE=release
|
||||
# DB_PORT=5432
|
||||
# DB_USER=postgres
|
||||
# DB_NAME=lunafox
|
||||
# DB_SSLMODE=disable
|
||||
# DB_MAX_OPEN_CONNS=50
|
||||
# DB_MAX_IDLE_CONNS=10
|
||||
# REDIS_PORT=6379
|
||||
# REDIS_PASSWORD=
|
||||
# LOG_LEVEL=info
|
||||
# LOG_FORMAT=json
|
||||
# WORDLISTS_BASE_PATH=/opt/lunafox/wordlists
|
||||
|
||||
# ==================== 远程 Worker 配置 ====================
|
||||
# 供远程 Worker 访问主服务器的地址:
|
||||
# - 仅本地部署:server(Docker 内部服务名)
|
||||
# - 有远程 Worker:改为主服务器外网 IP 或域名(如 192.168.1.100 或 xingrin.example.com)
|
||||
# 注意:远程 Worker 会通过 https://{PUBLIC_HOST}:{PUBLIC_PORT} 访问(nginx 反代到后端 8888)
|
||||
PUBLIC_HOST=server
|
||||
# 对外 HTTPS 端口
|
||||
PUBLIC_PORT=8083
|
||||
|
||||
# ==================== Django 核心配置 ====================
|
||||
# 生产环境务必更换为随机强密钥
|
||||
DJANGO_SECRET_KEY=django-insecure-change-me-in-production
|
||||
# 是否开启调试模式(生产环境请保持 False)
|
||||
DEBUG=False
|
||||
# 允许的前端来源地址(用于 CORS)
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:3000
|
||||
|
||||
# ==================== 路径配置(容器内路径) ====================
|
||||
# 扫描结果保存目录
|
||||
SCAN_RESULTS_DIR=/opt/xingrin/results
|
||||
# Django 日志目录
|
||||
# 注意:如果留空或删除此变量,日志将只输出到 Docker 控制台(标准输出),不写入文件
|
||||
LOG_DIR=/opt/xingrin/logs
|
||||
# 扫描工具路径(容器内路径,符合 FHS 标准,已隔离避免命名冲突)
|
||||
# 默认值已在 settings.py 中设置,无需修改,除非需要回退到旧路径
|
||||
SCAN_TOOLS_PATH=/opt/xingrin-tools/bin
|
||||
|
||||
# ==================== 日志级别配置 ====================
|
||||
# 应用日志级别:DEBUG / INFO / WARNING / ERROR
|
||||
LOG_LEVEL=INFO
|
||||
# 是否记录命令执行日志(大量扫描时会增加磁盘占用)
|
||||
ENABLE_COMMAND_LOGGING=true
|
||||
|
||||
# ==================== Worker API Key 配置 ====================
|
||||
# Worker 节点认证密钥(用于 Worker 与主服务器之间的 API 认证)
|
||||
# 生产环境务必更换为随机强密钥(建议 32 位以上随机字符串)
|
||||
# 生成方法: openssl rand -hex 32
|
||||
WORKER_API_KEY=change-me-to-a-secure-random-key
|
||||
|
||||
# ==================== Docker Hub 配置(生产模式) ====================
|
||||
# 生产模式下从 Docker Hub 拉取镜像时使用
|
||||
DOCKER_USER=yyhuni
|
||||
# 镜像版本标签(安装时自动从 VERSION 文件读取)
|
||||
# VERSION 文件由 CI 自动更新,与 Git Tag 保持一致
|
||||
# 注意:此值由 install.sh 自动设置,请勿手动修改
|
||||
IMAGE_TAG=__WILL_BE_SET_BY_INSTALLER__
|
||||
|
||||
@@ -1,135 +1,122 @@
|
||||
services:
|
||||
# PostgreSQL(可选,使用远程数据库时不启动)
|
||||
# 本地模式: docker compose --profile local-db up -d
|
||||
# 远程模式: docker compose up -d(需配置 DB_HOST 为远程地址)
|
||||
# 使用自定义镜像,预装 pg_ivm 扩展
|
||||
# Agent 请通过安装脚本注册启动(/api/agents/install.sh)
|
||||
postgres:
|
||||
profiles: ["local-db"]
|
||||
build:
|
||||
context: ./postgres
|
||||
dockerfile: Dockerfile
|
||||
image: ${DOCKER_USER:-yyhuni}/xingrin-postgres:${IMAGE_TAG:-dev}
|
||||
restart: always
|
||||
image: postgres:16.3-alpine
|
||||
restart: "on-failure:3"
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_DB: ${DB_NAME:-lunafox}
|
||||
POSTGRES_USER: ${DB_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
|
||||
ports:
|
||||
- "${DB_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./postgres/init-user-db.sh:/docker-entrypoint-initdb.d/init-user-db.sh
|
||||
ports:
|
||||
- "${DB_PORT}:5432"
|
||||
command: >
|
||||
postgres
|
||||
-c shared_preload_libraries=pg_ivm
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: always
|
||||
image: redis:7.4.7-alpine
|
||||
restart: "on-failure:3"
|
||||
ports:
|
||||
- "${REDIS_PORT:-6379}:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
test: [CMD, redis-cli, ping]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
server:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/server/Dockerfile
|
||||
restart: always
|
||||
image: golang:1.25.6
|
||||
restart: "on-failure:3"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- IMAGE_TAG=${IMAGE_TAG:-dev}
|
||||
- PUBLIC_URL=${PUBLIC_URL:-}
|
||||
- GOMODCACHE=/go/pkg/mod
|
||||
- GOCACHE=/root/.cache/go-build
|
||||
- GO111MODULE=${GO111MODULE:-on}
|
||||
- GOPROXY=${GOPROXY:-https://goproxy.cn,direct}
|
||||
ports:
|
||||
- "8888:8888"
|
||||
- "8080:8080"
|
||||
working_dir: /workspace/server
|
||||
command: sh -c "go install github.com/air-verse/air@latest && air -c .air.toml"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
# 统一挂载数据目录
|
||||
- /opt/xingrin:/opt/xingrin
|
||||
- /opt/lunafox:/opt/lunafox
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# OOM 优先级:-500 保护核心服务
|
||||
oom_score_adj: -500
|
||||
healthcheck:
|
||||
# 使用专门的健康检查端点(无需认证)
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8888/api/health/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
# Agent:心跳上报 + 负载监控 + 版本检查
|
||||
agent:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/agent/Dockerfile
|
||||
args:
|
||||
IMAGE_TAG: ${IMAGE_TAG:-dev}
|
||||
restart: always
|
||||
environment:
|
||||
- SERVER_URL=http://server:8888
|
||||
- WORKER_NAME=Local-Worker
|
||||
- IS_LOCAL=true
|
||||
- IMAGE_TAG=${IMAGE_TAG:-dev}
|
||||
- WORKER_API_KEY=${WORKER_API_KEY}
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- /proc:/host/proc:ro
|
||||
- ../server:/workspace/server
|
||||
- go-mod-cache:/go/pkg/mod
|
||||
- go-build-cache:/root/.cache/go-build
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/frontend/Dockerfile
|
||||
args:
|
||||
IMAGE_TAG: ${IMAGE_TAG:-dev}
|
||||
restart: always
|
||||
# OOM 优先级:-500 保护 Web 界面
|
||||
oom_score_adj: -500
|
||||
image: node:20.20.0-alpine
|
||||
restart: "on-failure:3"
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- API_HOST=server
|
||||
- NEXT_PUBLIC_BACKEND_URL=${NEXT_PUBLIC_BACKEND_URL:-}
|
||||
- PORT=3000
|
||||
- HOSTNAME=0.0.0.0
|
||||
ports:
|
||||
- "3000:3000"
|
||||
working_dir: /app
|
||||
command: sh -c "corepack enable && corepack prepare pnpm@latest --activate && if [ ! -d node_modules/.pnpm ]; then pnpm install; fi && pnpm dev"
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
condition: service_started
|
||||
volumes:
|
||||
- ../frontend:/app
|
||||
- frontend_node_modules:/app/node_modules
|
||||
- frontend_pnpm_store:/root/.local/share/pnpm/store
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000',res=>process.exit(res.statusCode<500?0:1)).on('error',()=>process.exit(1))"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 20s
|
||||
|
||||
nginx:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/nginx/Dockerfile
|
||||
restart: always
|
||||
# OOM 优先级:-500 保护入口网关
|
||||
oom_score_adj: -500
|
||||
image: yyhuni/lunafox-nginx:${IMAGE_TAG:-dev}
|
||||
restart: "on-failure:3"
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
frontend:
|
||||
condition: service_started
|
||||
frontend:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8083:8083"
|
||||
volumes:
|
||||
# SSL 证书挂载(方便更新)
|
||||
- ./nginx/ssl:/etc/nginx/ssl:ro
|
||||
|
||||
# Worker:扫描任务执行容器(开发模式下构建)
|
||||
# Worker: build image for task execution (not run in dev by default).
|
||||
worker:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/worker/Dockerfile
|
||||
image: docker-worker:${IMAGE_TAG:-latest}-dev
|
||||
context: ../worker
|
||||
dockerfile: Dockerfile
|
||||
image: yyhuni/lunafox-worker:${IMAGE_TAG:-dev}
|
||||
restart: "no"
|
||||
volumes:
|
||||
- /opt/xingrin:/opt/xingrin
|
||||
- /opt/lunafox:/opt/lunafox
|
||||
command: echo "Worker image built for development"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
go-mod-cache:
|
||||
go-build-cache:
|
||||
frontend_node_modules:
|
||||
frontend_pnpm_store:
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: xingrin_network # 固定网络名,不随目录名变化
|
||||
name: lunafox_network # Fixed network name, independent of directory name
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
# ============================================
|
||||
# 生产环境配置 - 使用 Docker Hub 预构建镜像
|
||||
# ============================================
|
||||
# 用法: docker compose up -d
|
||||
#
|
||||
# 开发环境请使用: docker compose -f docker-compose.dev.yml up -d
|
||||
# ============================================
|
||||
|
||||
services:
|
||||
# PostgreSQL(可选,使用远程数据库时不启动)
|
||||
# 使用自定义镜像,预装 pg_ivm 扩展
|
||||
postgres:
|
||||
profiles: ["local-db"]
|
||||
build:
|
||||
context: ./postgres
|
||||
dockerfile: Dockerfile
|
||||
image: ${DOCKER_USER:-yyhuni}/xingrin-postgres:${IMAGE_TAG:?IMAGE_TAG is required}
|
||||
image: postgres:16.3-alpine
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
@@ -22,12 +14,8 @@ services:
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./postgres/init-user-db.sh:/docker-entrypoint-initdb.d/init-user-db.sh
|
||||
ports:
|
||||
- "${DB_PORT}:5432"
|
||||
command: >
|
||||
postgres
|
||||
-c shared_preload_libraries=pg_ivm
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
|
||||
interval: 5s
|
||||
@@ -35,7 +23,7 @@ services:
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
image: redis:7.4.7-alpine
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
@@ -44,70 +32,47 @@ services:
|
||||
retries: 5
|
||||
|
||||
server:
|
||||
image: ${DOCKER_USER:-yyhuni}/xingrin-server:${IMAGE_TAG:?IMAGE_TAG is required}
|
||||
image: yyhuni/lunafox-server:${IMAGE_TAG:?IMAGE_TAG is required}
|
||||
restart: always
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- IMAGE_TAG=${IMAGE_TAG}
|
||||
- PUBLIC_URL=${PUBLIC_URL:-}
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
# 统一挂载数据目录
|
||||
- /opt/xingrin:/opt/xingrin
|
||||
# Docker Socket 挂载:允许 Django 服务器执行本地 docker 命令(用于本地 Worker 任务分发)
|
||||
- /opt/lunafox:/opt/lunafox
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# OOM 优先级:-500 降低被 OOM Killer 选中的概率,保护核心服务
|
||||
oom_score_adj: -500
|
||||
healthcheck:
|
||||
# 使用专门的健康检查端点(无需认证)
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8888/api/health/"]
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
# ============================================
|
||||
# Agent:轻量心跳上报 + 负载监控(~10MB)
|
||||
# 扫描任务通过 task_distributor 分发到动态容器
|
||||
# ============================================
|
||||
|
||||
agent:
|
||||
image: ${DOCKER_USER:-yyhuni}/xingrin-agent:${IMAGE_TAG:?IMAGE_TAG is required}
|
||||
container_name: xingrin-agent
|
||||
restart: always
|
||||
environment:
|
||||
- SERVER_URL=http://server:8888
|
||||
- WORKER_NAME=Local-Worker
|
||||
- IS_LOCAL=true
|
||||
- IMAGE_TAG=${IMAGE_TAG}
|
||||
- WORKER_API_KEY=${WORKER_API_KEY}
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- /proc:/host/proc:ro
|
||||
|
||||
frontend:
|
||||
image: ${DOCKER_USER:-yyhuni}/xingrin-frontend:${IMAGE_TAG:?IMAGE_TAG is required}
|
||||
image: yyhuni/lunafox-frontend:${IMAGE_TAG:?IMAGE_TAG is required}
|
||||
restart: always
|
||||
# OOM 优先级:-500 保护 Web 界面
|
||||
oom_score_adj: -500
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000',res=>process.exit(res.statusCode<500?0:1)).on('error',()=>process.exit(1))"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 20s
|
||||
|
||||
nginx:
|
||||
image: ${DOCKER_USER:-yyhuni}/xingrin-nginx:${IMAGE_TAG:?IMAGE_TAG is required}
|
||||
image: yyhuni/lunafox-nginx:${IMAGE_TAG:?IMAGE_TAG is required}
|
||||
restart: always
|
||||
# OOM 优先级:-500 保护入口网关
|
||||
oom_score_adj: -500
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
frontend:
|
||||
condition: service_started
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8083:8083"
|
||||
volumes:
|
||||
@@ -118,4 +83,4 @@ volumes:
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: xingrin_network # 固定网络名,不随目录名变化
|
||||
name: lunafox_network # 固定网络名,不随目录名变化
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM nginx:1.27-alpine
|
||||
FROM nginx:1.28.1-alpine
|
||||
|
||||
# 复制 nginx 配置和证书
|
||||
COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
@@ -9,7 +9,7 @@ http {
|
||||
|
||||
# 上游服务
|
||||
upstream backend {
|
||||
server server:8888;
|
||||
server server:8080;
|
||||
}
|
||||
|
||||
upstream frontend {
|
||||
@@ -31,20 +31,11 @@ http {
|
||||
# HTTP 请求到 HTTPS 端口时自动跳转
|
||||
error_page 497 =301 https://$host:$server_port$request_uri;
|
||||
|
||||
# 指纹特征 - 用于 FOFA/Shodan 等搜索引擎识别
|
||||
add_header X-Powered-By "Xingrin ASM" always;
|
||||
# 指纹特征
|
||||
add_header X-Powered-By "LunaFox ASM" always;
|
||||
|
||||
location /api/ {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 300s; # 5分钟,支持大数据量导出
|
||||
proxy_send_timeout 300s;
|
||||
proxy_pass http://backend;
|
||||
}
|
||||
|
||||
# WebSocket 反代
|
||||
location /ws/ {
|
||||
# Agent WebSocket
|
||||
location /api/agents/ws {
|
||||
proxy_pass http://backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
@@ -52,9 +43,52 @@ http {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_read_timeout 86400; # 24小时,防止 WebSocket 超时
|
||||
}
|
||||
|
||||
# 健康检查
|
||||
location /health {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_pass http://backend;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_read_timeout 300s; # 5分钟,支持大数据量导出
|
||||
proxy_send_timeout 300s;
|
||||
proxy_pass http://backend;
|
||||
}
|
||||
|
||||
# Next.js HMR (dev)
|
||||
location /_next/webpack-hmr {
|
||||
proxy_pass http://frontend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
# 前端反代
|
||||
location / {
|
||||
proxy_set_header Host $host;
|
||||
|
||||
574
docs/redis-stream-queue-design.md
Normal file
574
docs/redis-stream-queue-design.md
Normal file
@@ -0,0 +1,574 @@
|
||||
# Redis Stream 队列方案设计文档
|
||||
|
||||
## 概述
|
||||
|
||||
本文档描述了使用 Redis Stream 作为消息队列来优化大规模数据写入的方案设计。
|
||||
|
||||
## 背景
|
||||
|
||||
### 当前问题
|
||||
|
||||
在扫描大量 Endpoint 数据(几十万条)时,当前的 HTTP 批量写入方案存在以下问题:
|
||||
|
||||
1. **性能瓶颈**:50 万 Endpoint(每个 15 KB)需要 83-166 分钟
|
||||
2. **数据库 I/O 压力**:20 个 Worker 同时写入导致数据库 I/O 满载
|
||||
3. **Worker 阻塞风险**:如果使用批量写入 + 背压机制,Worker 会阻塞等待
|
||||
|
||||
### 方案目标
|
||||
|
||||
- 性能提升 10 倍(83 分钟 → 8 分钟)
|
||||
- Worker 永不阻塞(扫描速度稳定)
|
||||
- 数据不丢失(持久化保证)
|
||||
- 无需部署新组件(利用现有 Redis)
|
||||
|
||||
## 架构设计
|
||||
|
||||
### 整体架构
|
||||
|
||||
```
|
||||
Worker 扫描 → Redis Stream → Server 消费 → PostgreSQL
|
||||
```
|
||||
|
||||
### 数据流
|
||||
|
||||
1. **Worker 端**:扫描到 Endpoint → 发布到 Redis Stream
|
||||
2. **Redis Stream**:缓冲消息(持久化到磁盘)
|
||||
3. **Server 端**:单线程消费 → 批量写入数据库
|
||||
|
||||
### 关键特性
|
||||
|
||||
- **解耦**:Worker 和数据库完全解耦
|
||||
- **背压**:Server 控制消费速度,保护数据库
|
||||
- **持久化**:Redis AOF 保证数据不丢失
|
||||
- **扩展性**:支持多 Worker 并发写入
|
||||
|
||||
## Redis Stream 配置
|
||||
|
||||
### 启用 AOF 持久化
|
||||
|
||||
```conf
|
||||
# redis.conf
|
||||
appendonly yes
|
||||
appendfsync everysec # 每秒同步一次(平衡性能和安全)
|
||||
```
|
||||
|
||||
**效果**:
|
||||
- 数据持久化到磁盘
|
||||
- Redis 崩溃最多丢失 1 秒数据
|
||||
- 性能影响小
|
||||
|
||||
### 内存配置
|
||||
|
||||
```conf
|
||||
# redis.conf
|
||||
maxmemory 2gb
|
||||
maxmemory-policy allkeys-lru # 内存不足时淘汰最少使用的 key
|
||||
```
|
||||
|
||||
## 实现方案
|
||||
|
||||
### 1. Worker 端:发布到 Redis Stream
|
||||
|
||||
#### 代码结构
|
||||
|
||||
```
|
||||
worker/internal/queue/
|
||||
├── redis_publisher.go # Redis 发布者
|
||||
└── types.go # 数据类型定义
|
||||
```
|
||||
|
||||
#### 核心实现
|
||||
|
||||
```go
|
||||
// worker/internal/queue/redis_publisher.go
|
||||
package queue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type RedisPublisher struct {
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
func NewRedisPublisher(redisURL string) (*RedisPublisher, error) {
|
||||
opt, err := redis.ParseURL(redisURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := redis.NewClient(opt)
|
||||
|
||||
// 测试连接
|
||||
if err := client.Ping(context.Background()).Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &RedisPublisher{client: client}, nil
|
||||
}
|
||||
|
||||
// PublishEndpoint 发布 Endpoint 到 Redis Stream
|
||||
func (p *RedisPublisher) PublishEndpoint(ctx context.Context, scanID int, endpoint Endpoint) error {
|
||||
data, err := json.Marshal(endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
streamName := fmt.Sprintf("endpoints:%d", scanID)
|
||||
|
||||
return p.client.XAdd(ctx, &redis.XAddArgs{
|
||||
Stream: streamName,
|
||||
MaxLen: 1000000, // 最多保留 100 万条消息(防止内存溢出)
|
||||
Approx: true, // 使用近似裁剪(性能更好)
|
||||
Values: map[string]interface{}{
|
||||
"data": data,
|
||||
},
|
||||
}).Err()
|
||||
}
|
||||
|
||||
// Close 关闭连接
|
||||
func (p *RedisPublisher) Close() error {
|
||||
return p.client.Close()
|
||||
}
|
||||
```
|
||||
|
||||
#### 使用示例
|
||||
|
||||
```go
|
||||
// Worker 扫描流程
|
||||
func (w *Worker) ScanEndpoints(ctx context.Context, scanID int) error {
|
||||
// 初始化 Redis 发布者
|
||||
publisher, err := queue.NewRedisPublisher(os.Getenv("REDIS_URL"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer publisher.Close()
|
||||
|
||||
// 扫描 Endpoint
|
||||
for endpoint := range w.scan() {
|
||||
// 发布到 Redis Stream(非阻塞,超快)
|
||||
if err := publisher.PublishEndpoint(ctx, scanID, endpoint); err != nil {
|
||||
log.Printf("Failed to publish endpoint: %v", err)
|
||||
// 可以选择重试或记录错误
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Server 端:消费 Redis Stream
|
||||
|
||||
#### 代码结构
|
||||
|
||||
```
|
||||
server/internal/queue/
|
||||
├── redis_consumer.go # Redis 消费者
|
||||
├── batch_writer.go # 批量写入器
|
||||
└── types.go # 数据类型定义
|
||||
```
|
||||
|
||||
#### 核心实现
|
||||
|
||||
```go
|
||||
// server/internal/queue/redis_consumer.go
|
||||
package queue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/yyhuni/lunafox/server/internal/repository"
|
||||
)
|
||||
|
||||
type EndpointConsumer struct {
|
||||
client *redis.Client
|
||||
repository *repository.EndpointRepository
|
||||
}
|
||||
|
||||
func NewEndpointConsumer(redisURL string, repo *repository.EndpointRepository) (*EndpointConsumer, error) {
|
||||
opt, err := redis.ParseURL(redisURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := redis.NewClient(opt)
|
||||
|
||||
return &EndpointConsumer{
|
||||
client: client,
|
||||
repository: repo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start 启动消费者(单线程,控制写入速度)
|
||||
func (c *EndpointConsumer) Start(ctx context.Context, scanID int) error {
|
||||
streamName := fmt.Sprintf("endpoints:%d", scanID)
|
||||
groupName := "endpoint-consumers"
|
||||
consumerName := fmt.Sprintf("server-%d", time.Now().Unix())
|
||||
|
||||
// 创建消费者组(如果不存在)
|
||||
c.client.XGroupCreateMkStream(ctx, streamName, groupName, "0")
|
||||
|
||||
// 批量写入器(每 5000 条批量写入)
|
||||
batchWriter := NewBatchWriter(c.repository, 5000)
|
||||
defer batchWriter.Flush()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
// 读取消息(批量)
|
||||
streams, err := c.client.XReadGroup(ctx, &redis.XReadGroupArgs{
|
||||
Group: groupName,
|
||||
Consumer: consumerName,
|
||||
Streams: []string{streamName, ">"},
|
||||
Count: 100, // 每次读取 100 条
|
||||
Block: 1000, // 阻塞 1 秒
|
||||
}).Result()
|
||||
|
||||
if err != nil {
|
||||
if err == redis.Nil {
|
||||
continue // 没有新消息
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 处理消息
|
||||
for _, stream := range streams {
|
||||
for _, message := range stream.Messages {
|
||||
// 解析消息
|
||||
var endpoint Endpoint
|
||||
if err := json.Unmarshal([]byte(message.Values["data"].(string)), &endpoint); err != nil {
|
||||
// 记录错误,继续处理下一条
|
||||
continue
|
||||
}
|
||||
|
||||
// 添加到批量写入器
|
||||
if err := batchWriter.Add(endpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 确认消息(ACK)
|
||||
c.client.XAck(ctx, streamName, groupName, message.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// 定期 Flush
|
||||
if batchWriter.ShouldFlush() {
|
||||
if err := batchWriter.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close 关闭连接
|
||||
func (c *EndpointConsumer) Close() error {
|
||||
return c.client.Close()
|
||||
}
|
||||
```
|
||||
|
||||
#### 批量写入器
|
||||
|
||||
```go
|
||||
// server/internal/queue/batch_writer.go
|
||||
package queue
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"github.com/yyhuni/lunafox/server/internal/model"
|
||||
"github.com/yyhuni/lunafox/server/internal/repository"
|
||||
)
|
||||
|
||||
type BatchWriter struct {
|
||||
repository *repository.EndpointRepository
|
||||
buffer []model.Endpoint
|
||||
batchSize int
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewBatchWriter(repo *repository.EndpointRepository, batchSize int) *BatchWriter {
|
||||
return &BatchWriter{
|
||||
repository: repo,
|
||||
batchSize: batchSize,
|
||||
buffer: make([]model.Endpoint, 0, batchSize),
|
||||
}
|
||||
}
|
||||
|
||||
// Add 添加到缓冲区
|
||||
func (w *BatchWriter) Add(endpoint model.Endpoint) error {
|
||||
w.mu.Lock()
|
||||
w.buffer = append(w.buffer, endpoint)
|
||||
shouldFlush := len(w.buffer) >= w.batchSize
|
||||
w.mu.Unlock()
|
||||
|
||||
if shouldFlush {
|
||||
return w.Flush()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ShouldFlush 是否应该 Flush
|
||||
func (w *BatchWriter) ShouldFlush() bool {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return len(w.buffer) >= w.batchSize
|
||||
}
|
||||
|
||||
// Flush 批量写入数据库
|
||||
func (w *BatchWriter) Flush() error {
|
||||
w.mu.Lock()
|
||||
if len(w.buffer) == 0 {
|
||||
w.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// 复制缓冲区
|
||||
toWrite := make([]model.Endpoint, len(w.buffer))
|
||||
copy(toWrite, w.buffer)
|
||||
w.buffer = w.buffer[:0]
|
||||
w.mu.Unlock()
|
||||
|
||||
// 批量写入(使用现有的 BulkUpsert 方法)
|
||||
_, err := w.repository.BulkUpsert(toWrite)
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Server 启动消费者
|
||||
|
||||
```go
|
||||
// server/internal/app/app.go
|
||||
func Run(ctx context.Context, cfg config.Config) error {
|
||||
// ... 现有代码
|
||||
|
||||
// 启动 Redis 消费者(后台运行)
|
||||
consumer, err := queue.NewEndpointConsumer(cfg.RedisURL, endpointRepo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
// 消费所有活跃的扫描任务
|
||||
for {
|
||||
// 获取活跃的扫描任务
|
||||
scans := scanRepo.GetActiveScans()
|
||||
for _, scan := range scans {
|
||||
go consumer.Start(ctx, scan.ID)
|
||||
}
|
||||
time.Sleep(10 * time.Second)
|
||||
}
|
||||
}()
|
||||
|
||||
// ... 现有代码
|
||||
}
|
||||
```
|
||||
|
||||
## 性能对比
|
||||
|
||||
### 50 万 Endpoint(每个 15 KB)
|
||||
|
||||
| 方案 | 写入速度 | 总时间 | 内存占用 | Worker 阻塞 |
|
||||
|------|---------|--------|---------|-----------|
|
||||
| **当前(HTTP 批量)** | 100 条/秒 | 83 分钟 | 1.5 MB | 否 |
|
||||
| **Redis Stream** | 1000 条/秒 | 8 分钟 | 75 MB | 否 |
|
||||
|
||||
**提升**:**10 倍性能!**
|
||||
|
||||
## 资源消耗
|
||||
|
||||
### Redis 资源消耗
|
||||
|
||||
| 项目 | 消耗 |
|
||||
|------|------|
|
||||
| 内存 | ~500 MB(缓冲 100 万条消息) |
|
||||
| CPU | ~10%(序列化/反序列化) |
|
||||
| 磁盘 | ~7.5 GB(AOF 持久化) |
|
||||
| 带宽 | ~50 MB/s |
|
||||
|
||||
### Server 资源消耗
|
||||
|
||||
| 项目 | 消耗 |
|
||||
|------|------|
|
||||
| 内存 | 75 MB(批量写入缓冲) |
|
||||
| CPU | 30%(反序列化 + 数据库写入) |
|
||||
| 数据库连接 | 1 个(单线程消费) |
|
||||
|
||||
## 可靠性保证
|
||||
|
||||
### 数据不丢失
|
||||
|
||||
1. **Redis AOF 持久化**:每秒同步到磁盘,最多丢失 1 秒数据
|
||||
2. **消息确认机制**:Server 处理成功后才 ACK
|
||||
3. **自动重试**:未 ACK 的消息会自动重新入队
|
||||
|
||||
### 故障恢复
|
||||
|
||||
| 故障场景 | 恢复机制 |
|
||||
|---------|---------|
|
||||
| Worker 崩溃 | 消息已发送到 Redis,不影响 |
|
||||
| Redis 崩溃 | AOF 恢复,最多丢失 1 秒数据 |
|
||||
| Server 崩溃 | 未 ACK 的消息重新入队 |
|
||||
| 数据库崩溃 | 消息保留在 Redis,恢复后继续消费 |
|
||||
|
||||
## 扩展性
|
||||
|
||||
### 多 Worker 支持
|
||||
|
||||
- Redis Stream 原生支持多个生产者
|
||||
- 无需额外配置
|
||||
|
||||
### 多 Server 消费者
|
||||
|
||||
```go
|
||||
// 启动多个消费者(负载均衡)
|
||||
for i := 0; i < 3; i++ {
|
||||
go consumer.Start(ctx, scanID)
|
||||
}
|
||||
```
|
||||
|
||||
Redis Stream 的消费者组会自动分配消息,实现负载均衡。
|
||||
|
||||
## 监控和运维
|
||||
|
||||
### 监控指标
|
||||
|
||||
```go
|
||||
// 获取队列长度
|
||||
func (c *EndpointConsumer) GetQueueLength(ctx context.Context, scanID int) (int64, error) {
|
||||
streamName := fmt.Sprintf("endpoints:%d", scanID)
|
||||
return c.client.XLen(ctx, streamName).Result()
|
||||
}
|
||||
|
||||
// 获取消费者组信息
|
||||
func (c *EndpointConsumer) GetConsumerGroupInfo(ctx context.Context, scanID int) ([]redis.XInfoGroup, error) {
|
||||
streamName := fmt.Sprintf("endpoints:%d", scanID)
|
||||
return c.client.XInfoGroups(ctx, streamName).Result()
|
||||
}
|
||||
```
|
||||
|
||||
### 清理策略
|
||||
|
||||
```go
|
||||
// 扫描完成后清理 Stream
|
||||
func (c *EndpointConsumer) CleanupStream(ctx context.Context, scanID int) error {
|
||||
streamName := fmt.Sprintf("endpoints:%d", scanID)
|
||||
return c.client.Del(ctx, streamName).Err()
|
||||
}
|
||||
```
|
||||
|
||||
## 配置建议
|
||||
|
||||
### Redis 配置
|
||||
|
||||
```conf
|
||||
# redis.conf
|
||||
|
||||
# 持久化
|
||||
appendonly yes
|
||||
appendfsync everysec
|
||||
|
||||
# 内存
|
||||
maxmemory 2gb
|
||||
maxmemory-policy allkeys-lru
|
||||
|
||||
# 性能
|
||||
tcp-backlog 511
|
||||
timeout 0
|
||||
tcp-keepalive 300
|
||||
```
|
||||
|
||||
### 环境变量
|
||||
|
||||
```bash
|
||||
# Worker 端
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# Server 端
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
```
|
||||
|
||||
## 迁移步骤
|
||||
|
||||
### 阶段 1:准备(1 天)
|
||||
|
||||
1. 启用 Redis AOF 持久化
|
||||
2. 实现 Worker 端 Redis 发布者
|
||||
3. 实现 Server 端 Redis 消费者
|
||||
|
||||
### 阶段 2:测试(2 天)
|
||||
|
||||
1. 单元测试
|
||||
2. 集成测试
|
||||
3. 性能测试(模拟 50 万数据)
|
||||
|
||||
### 阶段 3:灰度发布(3 天)
|
||||
|
||||
1. 10% 流量使用 Redis Stream
|
||||
2. 50% 流量使用 Redis Stream
|
||||
3. 100% 流量使用 Redis Stream
|
||||
|
||||
### 阶段 4:清理(1 天)
|
||||
|
||||
1. 移除旧的 HTTP 批量写入代码
|
||||
2. 更新文档
|
||||
|
||||
## 风险和缓解
|
||||
|
||||
### 风险 1:Redis 内存溢出
|
||||
|
||||
**缓解**:
|
||||
- 设置 `maxmemory` 限制
|
||||
- 使用 `MaxLen` 限制 Stream 长度
|
||||
- 监控 Redis 内存使用
|
||||
|
||||
### 风险 2:消息积压
|
||||
|
||||
**缓解**:
|
||||
- 增加 Server 消费者数量
|
||||
- 优化数据库写入性能
|
||||
- 监控队列长度
|
||||
|
||||
### 风险 3:数据丢失
|
||||
|
||||
**缓解**:
|
||||
- 启用 AOF 持久化
|
||||
- 使用消息确认机制
|
||||
- 定期备份 Redis
|
||||
|
||||
## 总结
|
||||
|
||||
### 优势
|
||||
|
||||
- ✅ 性能提升 10 倍
|
||||
- ✅ Worker 永不阻塞
|
||||
- ✅ 数据不丢失(AOF 持久化)
|
||||
- ✅ 无需部署新组件(利用现有 Redis)
|
||||
- ✅ 架构简单,易于维护
|
||||
|
||||
### 适用场景
|
||||
|
||||
- 数据量 > 10 万
|
||||
- 已有 Redis
|
||||
- 需要高性能写入
|
||||
- 不需要复杂的消息路由
|
||||
|
||||
### 不适用场景
|
||||
|
||||
- 数据量 < 10 万(当前方案足够)
|
||||
- 需要复杂的消息路由(考虑 RabbitMQ)
|
||||
- 数据量 > 1000 万(考虑 Kafka)
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [Redis Stream 官方文档](https://redis.io/docs/data-types/streams/)
|
||||
- [Redis 持久化](https://redis.io/docs/management/persistence/)
|
||||
- [go-redis 文档](https://redis.uptrace.dev/)
|
||||
1
frontend/.gitignore
vendored
1
frontend/.gitignore
vendored
@@ -9,6 +9,7 @@
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
.pnpm-store/
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
60
frontend/Dockerfile
Normal file
60
frontend/Dockerfile
Normal file
@@ -0,0 +1,60 @@
|
||||
# Frontend Next.js Dockerfile
|
||||
# Multi-stage build with BuildKit caching
|
||||
|
||||
# ==================== Dependencies stage ====================
|
||||
FROM node:20.20.0-alpine AS deps
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# Copy dependency manifests
|
||||
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
||||
|
||||
# Install dependencies (BuildKit cache)
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# ==================== Build stage ====================
|
||||
FROM node:20.20.0-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# Copy deps
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY frontend/ ./
|
||||
|
||||
# Build-time env
|
||||
ARG IMAGE_TAG=unknown
|
||||
ENV NEXT_PUBLIC_IMAGE_TAG=${IMAGE_TAG}
|
||||
# Use service name "server" inside Docker network
|
||||
ENV API_HOST=server
|
||||
|
||||
# Build (BuildKit cache)
|
||||
RUN --mount=type=cache,target=/app/.next/cache \
|
||||
pnpm build
|
||||
|
||||
# ==================== Runtime stage ====================
|
||||
FROM node:20.20.0-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy build output
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -3,7 +3,9 @@ import type { Metadata } from "next"
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import { getMessages, setRequestLocale, getTranslations } from 'next-intl/server'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { cookies } from "next/headers"
|
||||
import { locales, localeHtmlLang, type Locale } from '@/i18n/config'
|
||||
import { COLOR_THEME_COOKIE_KEY, isColorThemeId, DEFAULT_COLOR_THEME_ID, isDarkColorTheme } from "@/lib/color-themes"
|
||||
|
||||
// Import global style files
|
||||
import "../globals.css"
|
||||
@@ -25,6 +27,7 @@ import Script from "next/script"
|
||||
import { QueryProvider } from "@/components/providers/query-provider"
|
||||
import { ThemeProvider } from "@/components/providers/theme-provider"
|
||||
import { UiI18nProvider } from "@/components/providers/ui-i18n-provider"
|
||||
import { ColorThemeInit } from "@/components/color-theme-init"
|
||||
|
||||
// Import common layout components
|
||||
import { RoutePrefetch } from "@/components/route-prefetch"
|
||||
@@ -40,8 +43,15 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
|
||||
title: t('title'),
|
||||
description: t('description'),
|
||||
keywords: t('keywords').split(',').map(k => k.trim()),
|
||||
generator: "Xingrin ASM Platform",
|
||||
generator: "LunaFox ASM Platform",
|
||||
authors: [{ name: "yyhuni" }],
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/images/icon-64.png", sizes: "64x64", type: "image/png" },
|
||||
{ url: "/images/icon-256.png", sizes: "256x256", type: "image/png" },
|
||||
],
|
||||
apple: [{ url: "/images/icon-256.png", sizes: "256x256", type: "image/png" }],
|
||||
},
|
||||
openGraph: {
|
||||
title: t('ogTitle'),
|
||||
description: t('ogDescription'),
|
||||
@@ -94,9 +104,20 @@ export default async function LocaleLayout({
|
||||
// Load translation messages
|
||||
const messages = await getMessages()
|
||||
|
||||
const cookieStore = await cookies()
|
||||
const cookieTheme = cookieStore.get(COLOR_THEME_COOKIE_KEY)?.value
|
||||
const themeId = isColorThemeId(cookieTheme) ? cookieTheme : DEFAULT_COLOR_THEME_ID
|
||||
const isDark = isDarkColorTheme(themeId)
|
||||
|
||||
return (
|
||||
<html lang={localeHtmlLang[locale as Locale]} suppressHydrationWarning>
|
||||
<html
|
||||
lang={localeHtmlLang[locale as Locale]}
|
||||
data-theme={themeId}
|
||||
className={isDark ? "dark" : undefined}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body className={fontConfig.className} style={fontConfig.style}>
|
||||
<ColorThemeInit />
|
||||
{/* Load external scripts */}
|
||||
<Script
|
||||
src="https://tweakcn.com/live-preview.min.js"
|
||||
@@ -110,7 +131,7 @@ export default async function LocaleLayout({
|
||||
{/* ThemeProvider provides theme switching functionality */}
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
defaultTheme={isDark ? "dark" : "light"}
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
|
||||
@@ -24,5 +24,9 @@ export default function LoginLayout({
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return children
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,124 +2,351 @@
|
||||
|
||||
import React from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import Lottie from "lottie-react"
|
||||
import securityAnimation from "@/public/animations/Security000-Purple.json"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import {
|
||||
Field,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from "@/components/ui/field"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { useLocale, useTranslations } from "next-intl"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { TerminalLogin } from "@/components/ui/terminal-login"
|
||||
import { LoadingState } from "@/components/loading-spinner"
|
||||
import { useLogin, useAuth } from "@/hooks/use-auth"
|
||||
import { useRoutePrefetch } from "@/hooks/use-route-prefetch"
|
||||
import { vulnerabilityKeys } from "@/hooks/use-vulnerabilities"
|
||||
import { getAssetStatistics, getStatisticsHistory } from "@/services/dashboard.service"
|
||||
import { getScans } from "@/services/scan.service"
|
||||
import { VulnerabilityService } from "@/services/vulnerability.service"
|
||||
|
||||
export default function LoginPage() {
|
||||
// Preload all page components on login page
|
||||
useRoutePrefetch()
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: auth, isLoading: authLoading } = useAuth()
|
||||
const { mutate: login, isPending } = useLogin()
|
||||
const t = useTranslations("auth")
|
||||
|
||||
const [username, setUsername] = React.useState("")
|
||||
const [password, setPassword] = React.useState("")
|
||||
const { mutateAsync: login, isPending } = useLogin()
|
||||
const t = useTranslations("auth.terminal")
|
||||
const locale = useLocale()
|
||||
|
||||
// If already logged in, redirect to dashboard
|
||||
const loginStartedRef = React.useRef(false)
|
||||
const [loginReady, setLoginReady] = React.useState(false)
|
||||
|
||||
const [isReady, setIsReady] = React.useState(false)
|
||||
const [loginProcessing, setLoginProcessing] = React.useState(false)
|
||||
const [isExiting, setIsExiting] = React.useState(false)
|
||||
const exitStartedRef = React.useRef(false)
|
||||
const showLoading = !isReady || loginProcessing
|
||||
const showExitOverlay = isExiting
|
||||
|
||||
const withLocale = React.useCallback((path: string) => {
|
||||
if (path.startsWith(`/${locale}/`)) return path
|
||||
const normalized = path.startsWith("/") ? path : `/${path}`
|
||||
return `/${locale}${normalized}`
|
||||
}, [locale])
|
||||
|
||||
// Hide the inline boot splash and show login content
|
||||
React.useEffect(() => {
|
||||
if (auth?.authenticated) {
|
||||
router.push("/dashboard/")
|
||||
let cancelled = false
|
||||
|
||||
const waitForLoad = new Promise<void>((resolve) => {
|
||||
if (typeof document === "undefined") {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
if (document.readyState === "complete") {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
const handleLoad = () => resolve()
|
||||
window.addEventListener("load", handleLoad, { once: true })
|
||||
})
|
||||
|
||||
const waitForPrefetch = new Promise<void>((resolve) => {
|
||||
if (typeof window === "undefined") {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
const w = window as Window & { __lunafoxRoutePrefetchDone?: boolean }
|
||||
if (w.__lunafoxRoutePrefetchDone) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
const handlePrefetchDone = () => resolve()
|
||||
window.addEventListener("lunafox:route-prefetch-done", handlePrefetchDone, { once: true })
|
||||
})
|
||||
|
||||
const waitForPrefetchOrTimeout = Promise.race([
|
||||
waitForPrefetch,
|
||||
new Promise<void>((resolve) => setTimeout(resolve, 3000)),
|
||||
])
|
||||
|
||||
Promise.all([waitForLoad, waitForPrefetchOrTimeout]).then(() => {
|
||||
if (cancelled) return
|
||||
setIsReady(true)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [auth, router])
|
||||
}, [])
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
login({ username, password })
|
||||
}
|
||||
// 提取预加载逻辑为可复用函数
|
||||
const prefetchDashboardData = React.useCallback(async () => {
|
||||
const scansParams = { page: 1, pageSize: 10 }
|
||||
const vulnsParams = { page: 1, pageSize: 10 }
|
||||
|
||||
// Show spinner while loading
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full flex-col items-center justify-center gap-4 bg-background">
|
||||
<Spinner className="size-8 text-primary" />
|
||||
<p className="text-muted-foreground text-sm" suppressHydrationWarning>loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return Promise.allSettled([
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["asset", "statistics"],
|
||||
queryFn: getAssetStatistics,
|
||||
}),
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["asset", "statistics", "history", 7],
|
||||
queryFn: () => getStatisticsHistory(7),
|
||||
}),
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["scans", scansParams],
|
||||
queryFn: () => getScans(scansParams),
|
||||
}),
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: vulnerabilityKeys.list(vulnsParams),
|
||||
queryFn: () => VulnerabilityService.getAllVulnerabilities(vulnsParams),
|
||||
}),
|
||||
])
|
||||
}, [queryClient])
|
||||
|
||||
// Don't show login page if already logged in
|
||||
if (auth?.authenticated) {
|
||||
return null
|
||||
// Memoize translations object to avoid recreating on every render
|
||||
const translations = React.useMemo(() => ({
|
||||
title: t("title"),
|
||||
subtitle: t("subtitle"),
|
||||
usernamePrompt: t("usernamePrompt"),
|
||||
passwordPrompt: t("passwordPrompt"),
|
||||
authenticating: t("authenticating"),
|
||||
processing: t("processing"),
|
||||
accessGranted: t("accessGranted"),
|
||||
welcomeMessage: t("welcomeMessage"),
|
||||
authFailed: t("authFailed"),
|
||||
invalidCredentials: t("invalidCredentials"),
|
||||
shortcuts: t("shortcuts"),
|
||||
submit: t("submit"),
|
||||
cancel: t("cancel"),
|
||||
clear: t("clear"),
|
||||
startEnd: t("startEnd"),
|
||||
}), [t])
|
||||
|
||||
// If already logged in, warm up the dashboard, then redirect.
|
||||
React.useEffect(() => {
|
||||
if (authLoading) return
|
||||
if (!auth?.authenticated) return
|
||||
if (loginStartedRef.current) return
|
||||
|
||||
let cancelled = false
|
||||
let timer: number | undefined
|
||||
|
||||
void (async () => {
|
||||
setLoginProcessing(true)
|
||||
await prefetchDashboardData()
|
||||
|
||||
if (cancelled) return
|
||||
setLoginProcessing(false)
|
||||
if (!exitStartedRef.current) {
|
||||
exitStartedRef.current = true
|
||||
setIsExiting(true)
|
||||
timer = window.setTimeout(() => {
|
||||
router.replace(withLocale("/dashboard/"))
|
||||
}, 300)
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (timer) window.clearTimeout(timer)
|
||||
}
|
||||
}, [auth?.authenticated, authLoading, prefetchDashboardData, router, withLocale])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!loginReady) return
|
||||
if (exitStartedRef.current) return
|
||||
exitStartedRef.current = true
|
||||
setIsExiting(true)
|
||||
const timer = window.setTimeout(() => {
|
||||
router.replace(withLocale("/dashboard/"))
|
||||
}, 300)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [loginReady, router, withLocale])
|
||||
|
||||
const handleLogin = async (username: string, password: string) => {
|
||||
loginStartedRef.current = true
|
||||
setLoginReady(false)
|
||||
setLoginProcessing(true)
|
||||
|
||||
// 并行执行独立操作:登录验证 + 预加载 dashboard bundle
|
||||
const [loginRes] = await Promise.all([
|
||||
login({ username, password }),
|
||||
router.prefetch(withLocale("/dashboard/")),
|
||||
])
|
||||
|
||||
// 预加载 dashboard 数据
|
||||
await prefetchDashboardData()
|
||||
|
||||
// Prime auth cache so AuthLayout doesn't flash a full-screen loading state.
|
||||
queryClient.setQueryData(["auth", "me"], {
|
||||
authenticated: true,
|
||||
user: loginRes.user,
|
||||
})
|
||||
|
||||
setLoginProcessing(false)
|
||||
setLoginReady(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-bg flex min-h-svh flex-col p-6 md:p-10">
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="w-full max-w-sm md:max-w-4xl">
|
||||
<Card className="overflow-hidden p-0">
|
||||
<CardContent className="grid p-0 md:grid-cols-2">
|
||||
<form className="p-6 md:p-8" onSubmit={handleSubmit}>
|
||||
<FieldGroup>
|
||||
{/* Fingerprint identifier - for FOFA/Shodan and other search engines to identify */}
|
||||
<meta name="generator" content="Xingrin ASM Platform" />
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<h1 className="text-2xl font-bold">{t("title")}</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="username">{t("username")}</FieldLabel>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder={t("usernamePlaceholder")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="password">{t("password")}</FieldLabel>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<Button type="submit" className="w-full" disabled={isPending}>
|
||||
{isPending ? t("loggingIn") : t("login")}
|
||||
</Button>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
<div className="bg-primary/5 relative hidden md:flex md:items-center md:justify-center">
|
||||
<div className="text-center p-4">
|
||||
<Lottie
|
||||
animationData={securityAnimation}
|
||||
loop={true}
|
||||
className="w-96 h-96 mx-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="relative flex min-h-svh flex-col bg-background text-foreground">
|
||||
{showLoading && !showExitOverlay ? (
|
||||
<LoadingState
|
||||
active
|
||||
message="loading..."
|
||||
className="fixed inset-0 z-50 bg-background"
|
||||
/>
|
||||
) : null}
|
||||
{showExitOverlay ? (
|
||||
<div className="fixed inset-0 z-50 bg-background" />
|
||||
) : null}
|
||||
{/* Circuit Board Animation */}
|
||||
<div className={`fixed inset-0 z-0 transition-opacity duration-300 ${isReady ? "opacity-100" : "opacity-0"}`}>
|
||||
<div className="circuit-container">
|
||||
{/* Grid pattern */}
|
||||
<div className="circuit-grid" />
|
||||
|
||||
{/* === Main backbone traces === */}
|
||||
{/* Horizontal main lines - 6 lines */}
|
||||
<div className="trace trace-h" style={{ top: '12%', left: 0, width: '100%' }}>
|
||||
<div className="trace-glow" style={{ animationDuration: '6s' }} />
|
||||
</div>
|
||||
<div className="trace trace-h" style={{ top: '28%', left: 0, width: '100%' }}>
|
||||
<div className="trace-glow" style={{ animationDelay: '1s', animationDuration: '5s' }} />
|
||||
</div>
|
||||
<div className="trace trace-h" style={{ top: '44%', left: 0, width: '100%' }}>
|
||||
<div className="trace-glow" style={{ animationDelay: '2s', animationDuration: '5.5s' }} />
|
||||
</div>
|
||||
<div className="trace trace-h" style={{ top: '60%', left: 0, width: '100%' }}>
|
||||
<div className="trace-glow" style={{ animationDelay: '3s', animationDuration: '4.5s' }} />
|
||||
</div>
|
||||
<div className="trace trace-h" style={{ top: '76%', left: 0, width: '100%' }}>
|
||||
<div className="trace-glow" style={{ animationDelay: '4s', animationDuration: '5s' }} />
|
||||
</div>
|
||||
<div className="trace trace-h" style={{ top: '92%', left: 0, width: '100%' }}>
|
||||
<div className="trace-glow" style={{ animationDelay: '5s', animationDuration: '6s' }} />
|
||||
</div>
|
||||
|
||||
{/* Vertical main lines - 6 lines */}
|
||||
<div className="trace trace-v" style={{ left: '8%', top: 0, height: '100%' }}>
|
||||
<div className="trace-glow trace-glow-v" style={{ animationDelay: '0.5s', animationDuration: '7s' }} />
|
||||
</div>
|
||||
<div className="trace trace-v" style={{ left: '24%', top: 0, height: '100%' }}>
|
||||
<div className="trace-glow trace-glow-v" style={{ animationDelay: '1.5s', animationDuration: '6s' }} />
|
||||
</div>
|
||||
<div className="trace trace-v" style={{ left: '40%', top: 0, height: '100%' }}>
|
||||
<div className="trace-glow trace-glow-v" style={{ animationDelay: '2.5s', animationDuration: '5.5s' }} />
|
||||
</div>
|
||||
<div className="trace trace-v" style={{ left: '56%', top: 0, height: '100%' }}>
|
||||
<div className="trace-glow trace-glow-v" style={{ animationDelay: '3.5s', animationDuration: '6.5s' }} />
|
||||
</div>
|
||||
<div className="trace trace-v" style={{ left: '72%', top: 0, height: '100%' }}>
|
||||
<div className="trace-glow trace-glow-v" style={{ animationDelay: '4.5s', animationDuration: '5s' }} />
|
||||
</div>
|
||||
<div className="trace trace-v" style={{ left: '88%', top: 0, height: '100%' }}>
|
||||
<div className="trace-glow trace-glow-v" style={{ animationDelay: '5.5s', animationDuration: '6s' }} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.circuit-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--background);
|
||||
overflow: hidden;
|
||||
--login-grid: color-mix(in oklch, var(--foreground) 6%, transparent);
|
||||
--login-trace: color-mix(in oklch, var(--foreground) 16%, transparent);
|
||||
--login-glow: color-mix(in oklch, var(--primary) 65%, transparent);
|
||||
--login-glow-muted: color-mix(in oklch, var(--foreground) 45%, transparent);
|
||||
}
|
||||
|
||||
.circuit-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(var(--login-grid) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--login-grid) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
|
||||
.trace {
|
||||
position: absolute;
|
||||
background: var(--login-trace);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.trace-h {
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.trace-v {
|
||||
width: 2px;
|
||||
}
|
||||
|
||||
.trace-glow {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -20%;
|
||||
width: 30%;
|
||||
height: 6px;
|
||||
background: linear-gradient(90deg, transparent, var(--login-glow), var(--login-glow-muted), transparent);
|
||||
animation: traceFlow 3s linear infinite;
|
||||
filter: blur(2px);
|
||||
}
|
||||
|
||||
.trace-glow-v {
|
||||
top: -20%;
|
||||
left: -2px;
|
||||
width: 6px;
|
||||
height: 30%;
|
||||
background: linear-gradient(180deg, transparent, var(--login-glow), var(--login-glow-muted), transparent);
|
||||
animation: traceFlowV 3s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes traceFlow {
|
||||
0% { left: -30%; }
|
||||
100% { left: 100%; }
|
||||
}
|
||||
|
||||
@keyframes traceFlowV {
|
||||
0% { top: -30%; }
|
||||
100% { top: 100%; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Fingerprint identifier - for FOFA/Shodan and other search engines to identify */}
|
||||
<meta name="generator" content="LunaFox ASM Platform" />
|
||||
|
||||
{/* Main content area */}
|
||||
<div
|
||||
className={`relative z-10 flex-1 flex items-center justify-center p-6 transition-[opacity,transform] duration-300 ${
|
||||
isReady ? "opacity-100 translate-y-0" : "opacity-0 translate-y-2"
|
||||
}`}
|
||||
>
|
||||
<TerminalLogin
|
||||
onLogin={handleLogin}
|
||||
authDone={loginReady}
|
||||
isPending={isPending}
|
||||
translations={translations}
|
||||
className={`transition-[opacity,transform] duration-300 ${
|
||||
isExiting ? "opacity-0 scale-[0.98]" : "opacity-100 scale-100"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Version number - fixed at the bottom of the page */}
|
||||
<div className="flex-shrink-0 text-center py-4">
|
||||
<div
|
||||
className={`relative z-10 flex-shrink-0 text-center py-4 transition-opacity duration-300 ${
|
||||
isReady && !isExiting ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{process.env.NEXT_PUBLIC_VERSION || 'dev'}
|
||||
{process.env.NEXT_PUBLIC_IMAGE_TAG || "dev"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState, useMemo } from "react"
|
||||
import { Settings, Search, Pencil, Trash2, Check, X, Plus } from "lucide-react"
|
||||
import { Settings, Search, Pencil, Trash2, Check, Plus, Lock, AlertTriangle, ChevronDown, ChevronRight } from "lucide-react"
|
||||
import * as yaml from "js-yaml"
|
||||
import Editor from "@monaco-editor/react"
|
||||
import { useTranslations } from "next-intl"
|
||||
@@ -11,6 +11,11 @@ import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -22,9 +27,9 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { EngineEditDialog, EngineCreateDialog } from "@/components/scan/engine"
|
||||
import { useEngines, useCreateEngine, useUpdateEngine, useDeleteEngine } from "@/hooks/use-engines"
|
||||
import { useEngines, usePresetEngines, useCreateEngine, useUpdateEngine, useDeleteEngine } from "@/hooks/use-engines"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { ScanEngine } from "@/types/engine.types"
|
||||
import type { ScanEngine, PresetEngine } from "@/types/engine.types"
|
||||
import { MasterDetailSkeleton } from "@/components/ui/master-detail-skeleton"
|
||||
|
||||
/** Feature configuration item definition - corresponds to YAML configuration structure */
|
||||
@@ -42,7 +47,7 @@ const FEATURE_LIST = [
|
||||
type FeatureKey = typeof FEATURE_LIST[number]["key"]
|
||||
|
||||
/** Parse engine configuration to get enabled features */
|
||||
function parseEngineFeatures(engine: ScanEngine): Record<FeatureKey, boolean> {
|
||||
function parseEngineFeatures(configuration?: string): Record<FeatureKey, boolean> {
|
||||
const defaultFeatures: Record<FeatureKey, boolean> = {
|
||||
subdomain_discovery: false,
|
||||
port_scan: false,
|
||||
@@ -54,10 +59,10 @@ function parseEngineFeatures(engine: ScanEngine): Record<FeatureKey, boolean> {
|
||||
vuln_scan: false,
|
||||
}
|
||||
|
||||
if (!engine.configuration) return defaultFeatures
|
||||
if (!configuration) return defaultFeatures
|
||||
|
||||
try {
|
||||
const config = yaml.load(engine.configuration) as Record<string, unknown>
|
||||
const config = yaml.load(configuration) as Record<string, unknown>
|
||||
if (!config) return defaultFeatures
|
||||
|
||||
return {
|
||||
@@ -76,22 +81,31 @@ function parseEngineFeatures(engine: ScanEngine): Record<FeatureKey, boolean> {
|
||||
}
|
||||
|
||||
/** Calculate the number of enabled features */
|
||||
function countEnabledFeatures(engine: ScanEngine) {
|
||||
const features = parseEngineFeatures(engine)
|
||||
function countEnabledFeatures(configuration?: string) {
|
||||
const features = parseEngineFeatures(configuration)
|
||||
return Object.values(features).filter(Boolean).length
|
||||
}
|
||||
|
||||
/** Selection type for engine list */
|
||||
type EngineSelection =
|
||||
| { type: 'preset'; engine: PresetEngine }
|
||||
| { type: 'user'; engine: ScanEngine }
|
||||
| null
|
||||
|
||||
/**
|
||||
* Scan engine page
|
||||
*/
|
||||
export default function ScanEnginePage() {
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null)
|
||||
const [selection, setSelection] = useState<EngineSelection>(null)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [editingEngine, setEditingEngine] = useState<ScanEngine | null>(null)
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||
const [createFromPreset, setCreateFromPreset] = useState<PresetEngine | null>(null)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [engineToDelete, setEngineToDelete] = useState<ScanEngine | null>(null)
|
||||
const [presetsOpen, setPresetsOpen] = useState(true)
|
||||
const [myEnginesOpen, setMyEnginesOpen] = useState(true)
|
||||
|
||||
const { currentTheme } = useColorTheme()
|
||||
|
||||
@@ -102,29 +116,43 @@ export default function ScanEnginePage() {
|
||||
const tEngine = useTranslations("scan.engine")
|
||||
|
||||
// API Hooks
|
||||
const { data: engines = [], isLoading } = useEngines()
|
||||
const { data: presetEngines = [], isLoading: isLoadingPresets } = usePresetEngines()
|
||||
const { data: userEngines = [], isLoading: isLoadingEngines } = useEngines()
|
||||
const createEngineMutation = useCreateEngine()
|
||||
const updateEngineMutation = useUpdateEngine()
|
||||
const deleteEngineMutation = useDeleteEngine()
|
||||
|
||||
// Filter engine list
|
||||
const filteredEngines = useMemo(() => {
|
||||
if (!searchQuery.trim()) return engines
|
||||
const isLoading = isLoadingPresets || isLoadingEngines
|
||||
|
||||
// Filter engine lists based on search query
|
||||
const filteredPresetEngines = useMemo(() => {
|
||||
if (!searchQuery.trim()) return presetEngines
|
||||
const query = searchQuery.toLowerCase()
|
||||
return engines.filter((e) => e.name.toLowerCase().includes(query))
|
||||
}, [engines, searchQuery])
|
||||
return presetEngines.filter((e) => e.name.toLowerCase().includes(query))
|
||||
}, [presetEngines, searchQuery])
|
||||
|
||||
// Selected engine
|
||||
const selectedEngine = useMemo(() => {
|
||||
if (!selectedId) return null
|
||||
return engines.find((e) => e.id === selectedId) || null
|
||||
}, [selectedId, engines])
|
||||
const filteredUserEngines = useMemo(() => {
|
||||
if (!searchQuery.trim()) return userEngines
|
||||
const query = searchQuery.toLowerCase()
|
||||
return userEngines.filter((e) => e.name.toLowerCase().includes(query))
|
||||
}, [userEngines, searchQuery])
|
||||
|
||||
// Selected engine's feature status
|
||||
// Get selected features
|
||||
const selectedFeatures = useMemo(() => {
|
||||
if (!selectedEngine) return null
|
||||
return parseEngineFeatures(selectedEngine)
|
||||
}, [selectedEngine])
|
||||
if (!selection) return null
|
||||
const config = selection.type === 'preset'
|
||||
? selection.engine.configuration
|
||||
: selection.engine.configuration
|
||||
return parseEngineFeatures(config)
|
||||
}, [selection])
|
||||
|
||||
const handleSelectPreset = (preset: PresetEngine) => {
|
||||
setSelection({ type: 'preset', engine: preset })
|
||||
}
|
||||
|
||||
const handleSelectUserEngine = (engine: ScanEngine) => {
|
||||
setSelection({ type: 'user', engine })
|
||||
}
|
||||
|
||||
const handleEdit = (engine: ScanEngine) => {
|
||||
setEditingEngine(engine)
|
||||
@@ -147,8 +175,8 @@ export default function ScanEnginePage() {
|
||||
if (!engineToDelete) return
|
||||
deleteEngineMutation.mutate(engineToDelete.id, {
|
||||
onSuccess: () => {
|
||||
if (selectedId === engineToDelete.id) {
|
||||
setSelectedId(null)
|
||||
if (selection?.type === 'user' && selection.engine.id === engineToDelete.id) {
|
||||
setSelection(null)
|
||||
}
|
||||
setDeleteDialogOpen(false)
|
||||
setEngineToDelete(null)
|
||||
@@ -161,6 +189,12 @@ export default function ScanEnginePage() {
|
||||
name,
|
||||
configuration: yamlContent,
|
||||
})
|
||||
setCreateFromPreset(null)
|
||||
}
|
||||
|
||||
const handleOpenCreateDialog: React.MouseEventHandler<HTMLButtonElement> = () => {
|
||||
setCreateFromPreset(null)
|
||||
setIsCreateDialogOpen(true)
|
||||
}
|
||||
|
||||
// Loading state
|
||||
@@ -184,7 +218,7 @@ export default function ScanEnginePage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => setIsCreateDialogOpen(true)}>
|
||||
<Button onClick={handleOpenCreateDialog}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{tEngine("createEngine")}
|
||||
</Button>
|
||||
@@ -196,64 +230,155 @@ export default function ScanEnginePage() {
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Left: Engine list */}
|
||||
<div className="w-72 lg:w-80 border-r flex flex-col">
|
||||
<div className="px-4 py-3 border-b">
|
||||
<h2 className="text-sm font-medium text-muted-foreground">
|
||||
{tEngine("engineList")} ({filteredEngines.length})
|
||||
</h2>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
{isLoading ? (
|
||||
<div className="p-4 text-sm text-muted-foreground">{tCommon("loading")}</div>
|
||||
) : filteredEngines.length === 0 ? (
|
||||
<div className="p-4 text-sm text-muted-foreground">
|
||||
{searchQuery ? tEngine("noMatchingEngine") : tEngine("noEngines")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2">
|
||||
{filteredEngines.map((engine) => (
|
||||
<button
|
||||
key={engine.id}
|
||||
onClick={() => setSelectedId(engine.id)}
|
||||
className={cn(
|
||||
"w-full text-left rounded-lg px-3 py-2.5 transition-colors",
|
||||
selectedId === engine.id
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<div className="font-medium text-sm truncate">
|
||||
{engine.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
{tEngine("featuresEnabled", { count: countEnabledFeatures(engine) })}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Preset engines section */}
|
||||
<Collapsible open={presetsOpen} onOpenChange={setPresetsOpen} className="p-2">
|
||||
<CollapsibleTrigger className="flex items-center justify-between w-full px-2 py-2 hover:bg-muted rounded-lg transition-colors">
|
||||
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-1">
|
||||
{presetsOpen ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{tEngine("presetEngines")}
|
||||
</h2>
|
||||
<span className="text-xs text-muted-foreground">{filteredPresetEngines.length}</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-1">
|
||||
{filteredPresetEngines.length === 0 ? (
|
||||
<div className="px-3 py-4 text-sm text-muted-foreground text-center">
|
||||
{tEngine("noMatchingEngine")}
|
||||
</div>
|
||||
) : (
|
||||
filteredPresetEngines.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
onClick={() => handleSelectPreset(preset)}
|
||||
className={cn(
|
||||
"w-full text-left rounded-lg px-3 py-2.5 transition-colors",
|
||||
selection?.type === 'preset' && selection.engine.id === preset.id
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="font-medium text-sm truncate">{preset.name}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5 ml-5.5">
|
||||
{tEngine("featuresEnabled", { count: preset.enabledFeatures.length })}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<Separator className="my-2" />
|
||||
|
||||
{/* User engines section */}
|
||||
<Collapsible open={myEnginesOpen} onOpenChange={setMyEnginesOpen} className="p-2">
|
||||
<CollapsibleTrigger className="flex items-center justify-between w-full px-2 py-2 hover:bg-muted rounded-lg transition-colors">
|
||||
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-1">
|
||||
{myEnginesOpen ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{tEngine("myEngines")}
|
||||
</h2>
|
||||
<span className="text-xs text-muted-foreground">{filteredUserEngines.length}</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-1">
|
||||
{filteredUserEngines.length === 0 ? (
|
||||
<div className="px-3 py-4 text-sm text-muted-foreground text-center">
|
||||
{searchQuery ? tEngine("noMatchingEngine") : tEngine("noEngines")}
|
||||
</div>
|
||||
) : (
|
||||
filteredUserEngines.map((engine) => (
|
||||
<button
|
||||
key={engine.id}
|
||||
onClick={() => handleSelectUserEngine(engine)}
|
||||
className={cn(
|
||||
"w-full text-left rounded-lg px-3 py-2.5 transition-colors",
|
||||
selection?.type === 'user' && selection.engine.id === engine.id
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{engine.isValid === false ? (
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-amber-500 shrink-0" />
|
||||
) : (
|
||||
<Check className="h-3.5 w-3.5 text-green-500 shrink-0" />
|
||||
)}
|
||||
<span className="font-medium text-sm truncate">{engine.name}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5 ml-5.5">
|
||||
{engine.isValid === false ? (
|
||||
<span className="text-amber-500">{tEngine("configNeedsUpdate")}</span>
|
||||
) : (
|
||||
tEngine("featuresEnabled", { count: countEnabledFeatures(engine.configuration) })
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Right: Engine details */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{selectedEngine && selectedFeatures ? (
|
||||
{selection && selectedFeatures ? (
|
||||
<>
|
||||
{/* Details header */}
|
||||
<div className="px-6 py-4 border-b">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 shrink-0">
|
||||
<Settings className="h-5 w-5 text-primary" />
|
||||
<div className={cn(
|
||||
"flex h-10 w-10 items-center justify-center rounded-lg shrink-0",
|
||||
selection.type === 'preset' ? "bg-muted" : "bg-primary/10"
|
||||
)}>
|
||||
{selection.type === 'preset' ? (
|
||||
<Lock className="h-5 w-5 text-muted-foreground" />
|
||||
) : (
|
||||
<Settings className="h-5 w-5 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-lg font-semibold truncate">
|
||||
{selectedEngine.name}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{tEngine("updatedAt")} {new Date(selectedEngine.updatedAt).toLocaleString()}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold truncate">
|
||||
{selection.engine.name}
|
||||
</h2>
|
||||
{selection.type === 'preset' && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{tEngine("preset")}
|
||||
</Badge>
|
||||
)}
|
||||
{selection.type === 'user' && selection.engine.isValid === false && (
|
||||
<Badge variant="outline" className="text-amber-500 border-amber-500 text-xs">
|
||||
{tEngine("needsUpdate")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{selection.type === 'preset' && selection.engine.description && (
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{selection.engine.description}
|
||||
</p>
|
||||
)}
|
||||
{selection.type === 'user' && (
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{tEngine("updatedAt")} {new Date(selection.engine.updatedAt).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="outline">
|
||||
{tEngine("featuresCount", { count: countEnabledFeatures(selectedEngine) })}
|
||||
{tEngine("featuresCount", {
|
||||
count: selection.type === 'preset'
|
||||
? selection.engine.enabledFeatures.length
|
||||
: countEnabledFeatures(selection.engine.configuration)
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -263,40 +388,37 @@ export default function ScanEnginePage() {
|
||||
{/* Feature status */}
|
||||
<div className="shrink-0">
|
||||
<h3 className="text-sm font-medium mb-3">{tEngine("enabledFeatures")}</h3>
|
||||
<div className="rounded-lg border">
|
||||
<div className="grid grid-cols-3 gap-px bg-muted">
|
||||
{FEATURE_LIST.map((feature) => {
|
||||
const enabled = selectedFeatures[feature.key as keyof typeof selectedFeatures]
|
||||
return (
|
||||
<div
|
||||
key={feature.key}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2.5 bg-background",
|
||||
enabled ? "text-foreground" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{enabled ? (
|
||||
<Check className="h-4 w-4 text-green-600 shrink-0" />
|
||||
) : (
|
||||
<X className="h-4 w-4 text-muted-foreground/50 shrink-0" />
|
||||
)}
|
||||
<span className="text-sm truncate">{tEngine(`features.${feature.key}`)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{FEATURE_LIST.map((feature) => {
|
||||
const enabled = selectedFeatures[feature.key as keyof typeof selectedFeatures]
|
||||
return (
|
||||
<Badge
|
||||
key={feature.key}
|
||||
variant={enabled ? "default" : "outline"}
|
||||
className={cn(
|
||||
"text-xs",
|
||||
enabled
|
||||
? "bg-primary/10 text-primary hover:bg-primary/10"
|
||||
: "text-muted-foreground/50"
|
||||
)}
|
||||
>
|
||||
{enabled && <Check className="h-3 w-3 mr-1" />}
|
||||
{tEngine(`features.${feature.key}`)}
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration preview */}
|
||||
{selectedEngine.configuration && (
|
||||
{(selection.type === 'preset' ? selection.engine.configuration : selection.engine.configuration) && (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<h3 className="text-sm font-medium mb-3 shrink-0">{tEngine("configPreview")}</h3>
|
||||
<div className="flex-1 rounded-lg border overflow-hidden min-h-0">
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage="yaml"
|
||||
value={selectedEngine.configuration}
|
||||
value={selection.type === 'preset' ? selection.engine.configuration : selection.engine.configuration}
|
||||
options={{
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
@@ -315,28 +437,30 @@ export default function ScanEnginePage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="px-6 py-4 border-t flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(selectedEngine)}
|
||||
>
|
||||
<Pencil className="h-4 w-4 mr-1.5" />
|
||||
{tEngine("editConfig")}
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(selectedEngine)}
|
||||
disabled={deleteEngineMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1.5" />
|
||||
{tCommon("actions.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Action buttons - only show for user engines */}
|
||||
{selection.type === 'user' && (
|
||||
<div className="px-6 py-4 border-t flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(selection.engine)}
|
||||
>
|
||||
<Pencil className="h-4 w-4 mr-1.5" />
|
||||
{tEngine("editConfig")}
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(selection.engine)}
|
||||
disabled={deleteEngineMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1.5" />
|
||||
{tCommon("actions.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// Unselected state
|
||||
@@ -361,8 +485,12 @@ export default function ScanEnginePage() {
|
||||
{/* Create engine dialog */}
|
||||
<EngineCreateDialog
|
||||
open={isCreateDialogOpen}
|
||||
onOpenChange={setIsCreateDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsCreateDialogOpen(open)
|
||||
if (!open) setCreateFromPreset(null)
|
||||
}}
|
||||
onSave={handleCreateEngine}
|
||||
preSelectedPreset={createFromPreset || undefined}
|
||||
/>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
@@ -389,4 +517,3 @@ export default function ScanEnginePage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React from "react"
|
||||
import { usePathname, useParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { Target, LayoutDashboard, Package, Image, ShieldAlert } from "lucide-react"
|
||||
import { Target, LayoutDashboard, Package, FolderSearch, Image as ImageIcon, ShieldAlert } from "lucide-react"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
@@ -23,6 +23,7 @@ export default function ScanHistoryLayout({
|
||||
// Get primary navigation active tab
|
||||
const getPrimaryTab = () => {
|
||||
if (pathname.includes("/overview")) return "overview"
|
||||
if (pathname.includes("/directories")) return "directories"
|
||||
if (pathname.includes("/screenshots")) return "screenshots"
|
||||
if (pathname.includes("/vulnerabilities")) return "vulnerabilities"
|
||||
// All asset pages fall under "assets"
|
||||
@@ -30,8 +31,7 @@ export default function ScanHistoryLayout({
|
||||
pathname.includes("/websites") ||
|
||||
pathname.includes("/subdomain") ||
|
||||
pathname.includes("/ip-addresses") ||
|
||||
pathname.includes("/endpoints") ||
|
||||
pathname.includes("/directories")
|
||||
pathname.includes("/endpoints")
|
||||
) {
|
||||
return "assets"
|
||||
}
|
||||
@@ -44,7 +44,6 @@ export default function ScanHistoryLayout({
|
||||
if (pathname.includes("/subdomain")) return "subdomain"
|
||||
if (pathname.includes("/ip-addresses")) return "ip-addresses"
|
||||
if (pathname.includes("/endpoints")) return "endpoints"
|
||||
if (pathname.includes("/directories")) return "directories"
|
||||
return "websites"
|
||||
}
|
||||
|
||||
@@ -55,6 +54,7 @@ export default function ScanHistoryLayout({
|
||||
const primaryPaths = {
|
||||
overview: `${basePath}/overview/`,
|
||||
assets: `${basePath}/websites/`, // Default to websites when clicking assets
|
||||
directories: `${basePath}/directories/`,
|
||||
screenshots: `${basePath}/screenshots/`,
|
||||
vulnerabilities: `${basePath}/vulnerabilities/`,
|
||||
}
|
||||
@@ -64,23 +64,22 @@ export default function ScanHistoryLayout({
|
||||
subdomain: `${basePath}/subdomain/`,
|
||||
"ip-addresses": `${basePath}/ip-addresses/`,
|
||||
endpoints: `${basePath}/endpoints/`,
|
||||
directories: `${basePath}/directories/`,
|
||||
}
|
||||
|
||||
// Get counts for each tab from scan data
|
||||
const summary = scanData?.summary as any
|
||||
const stats = scanData?.cachedStats
|
||||
const counts = {
|
||||
subdomain: summary?.subdomains || 0,
|
||||
endpoints: summary?.endpoints || 0,
|
||||
websites: summary?.websites || 0,
|
||||
directories: summary?.directories || 0,
|
||||
screenshots: summary?.screenshots || 0,
|
||||
vulnerabilities: summary?.vulnerabilities?.total || 0,
|
||||
"ip-addresses": summary?.ips || 0,
|
||||
subdomain: stats?.subdomainsCount || 0,
|
||||
endpoints: stats?.endpointsCount || 0,
|
||||
websites: stats?.websitesCount || 0,
|
||||
directories: stats?.directoriesCount || 0,
|
||||
screenshots: stats?.screenshotsCount || 0,
|
||||
vulnerabilities: stats?.vulnsTotal || 0,
|
||||
"ip-addresses": stats?.ipsCount || 0,
|
||||
}
|
||||
|
||||
// Calculate total assets count
|
||||
const totalAssets = counts.websites + counts.subdomain + counts["ip-addresses"] + counts.endpoints + counts.directories
|
||||
const totalAssets = counts.websites + counts.subdomain + counts["ip-addresses"] + counts.endpoints
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
@@ -110,7 +109,7 @@ export default function ScanHistoryLayout({
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<span className="font-medium flex items-center gap-1.5">
|
||||
<Target className="h-4 w-4" />
|
||||
{(scanData?.target as any)?.name || t("taskId", { id })}
|
||||
{scanData?.target?.name || t("taskId", { id })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -135,9 +134,20 @@ export default function ScanHistoryLayout({
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="directories" asChild>
|
||||
<Link href={primaryPaths.directories} className="flex items-center gap-1.5">
|
||||
<FolderSearch className="h-4 w-4" />
|
||||
{t("tabs.directories")}
|
||||
{counts.directories > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
|
||||
{counts.directories}
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="screenshots" asChild>
|
||||
<Link href={primaryPaths.screenshots} className="flex items-center gap-1.5">
|
||||
<Image className="h-4 w-4" />
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
{t("tabs.screenshots")}
|
||||
{counts.screenshots > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
|
||||
@@ -168,7 +178,7 @@ export default function ScanHistoryLayout({
|
||||
<TabsList variant="underline">
|
||||
<TabsTrigger value="websites" variant="underline" asChild>
|
||||
<Link href={secondaryPaths.websites} className="flex items-center gap-0.5">
|
||||
Websites
|
||||
{t("tabs.websites")}
|
||||
{counts.websites > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
|
||||
{counts.websites}
|
||||
@@ -178,7 +188,7 @@ export default function ScanHistoryLayout({
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="subdomain" variant="underline" asChild>
|
||||
<Link href={secondaryPaths.subdomain} className="flex items-center gap-0.5">
|
||||
Subdomains
|
||||
{t("tabs.subdomains")}
|
||||
{counts.subdomain > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
|
||||
{counts.subdomain}
|
||||
@@ -188,7 +198,7 @@ export default function ScanHistoryLayout({
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="ip-addresses" variant="underline" asChild>
|
||||
<Link href={secondaryPaths["ip-addresses"]} className="flex items-center gap-0.5">
|
||||
IPs
|
||||
{t("tabs.ips")}
|
||||
{counts["ip-addresses"] > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
|
||||
{counts["ip-addresses"]}
|
||||
@@ -198,7 +208,7 @@ export default function ScanHistoryLayout({
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="endpoints" variant="underline" asChild>
|
||||
<Link href={secondaryPaths.endpoints} className="flex items-center gap-0.5">
|
||||
URLs
|
||||
{t("tabs.urls")}
|
||||
{counts.endpoints > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
|
||||
{counts.endpoints}
|
||||
@@ -206,16 +216,6 @@ export default function ScanHistoryLayout({
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="directories" variant="underline" asChild>
|
||||
<Link href={secondaryPaths.directories} className="flex items-center gap-0.5">
|
||||
Directories
|
||||
{counts.directories > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
|
||||
{counts.directories}
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,13 @@ import { Separator } from '@/components/ui/separator'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useApiKeySettings, useUpdateApiKeySettings } from '@/hooks/use-api-key-settings'
|
||||
import type { ApiKeySettings } from '@/types/api-key-settings.types'
|
||||
import type {
|
||||
ApiKeySettings,
|
||||
ProviderKey,
|
||||
FofaProviderConfig,
|
||||
CensysProviderConfig,
|
||||
SingleFieldProviderConfig,
|
||||
} from '@/types/api-key-settings.types'
|
||||
|
||||
// 密码输入框组件(带显示/隐藏切换)
|
||||
function PasswordInput({ value, onChange, placeholder, disabled }: {
|
||||
@@ -42,8 +48,31 @@ function PasswordInput({ value, onChange, placeholder, disabled }: {
|
||||
)
|
||||
}
|
||||
|
||||
type ProviderField = {
|
||||
name: ProviderFieldName
|
||||
label: string
|
||||
type: "text" | "password"
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
type ProviderFieldName =
|
||||
| keyof FofaProviderConfig
|
||||
| keyof CensysProviderConfig
|
||||
| keyof SingleFieldProviderConfig
|
||||
|
||||
type ProviderDefinition = {
|
||||
key: ProviderKey
|
||||
name: string
|
||||
description: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
color: string
|
||||
bgColor: string
|
||||
fields: ProviderField[]
|
||||
docUrl: string
|
||||
}
|
||||
|
||||
// Provider 配置定义
|
||||
const PROVIDERS = [
|
||||
const PROVIDERS: ProviderDefinition[] = [
|
||||
{
|
||||
key: 'fofa',
|
||||
name: 'FOFA',
|
||||
@@ -171,14 +200,22 @@ export default function ApiKeysSettingsPage() {
|
||||
}
|
||||
}, [settings])
|
||||
|
||||
const updateProvider = (providerKey: string, field: string, value: any) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[providerKey]: {
|
||||
...prev[providerKey as keyof ApiKeySettings],
|
||||
const updateProvider = (
|
||||
providerKey: ProviderKey,
|
||||
field: ProviderFieldName,
|
||||
value: string | boolean
|
||||
) => {
|
||||
setFormData((prev) => {
|
||||
const current = prev[providerKey]
|
||||
const updated = {
|
||||
...current,
|
||||
[field]: value,
|
||||
} as typeof current
|
||||
return {
|
||||
...prev,
|
||||
[providerKey]: updated,
|
||||
}
|
||||
}))
|
||||
})
|
||||
setHasChanges(true)
|
||||
}
|
||||
|
||||
@@ -187,7 +224,7 @@ export default function ApiKeysSettingsPage() {
|
||||
setHasChanges(false)
|
||||
}
|
||||
|
||||
const enabledCount = Object.values(formData).filter((p: any) => p?.enabled).length
|
||||
const enabledCount = Object.values(formData).filter((provider) => provider.enabled).length
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -223,8 +260,8 @@ export default function ApiKeysSettingsPage() {
|
||||
{/* Provider 卡片列表 */}
|
||||
<div className="grid gap-4">
|
||||
{PROVIDERS.map((provider) => {
|
||||
const data = formData[provider.key as keyof ApiKeySettings] || {}
|
||||
const isEnabled = (data as any)?.enabled || false
|
||||
const data = formData[provider.key]
|
||||
const isEnabled = data.enabled
|
||||
|
||||
return (
|
||||
<Card key={provider.key}>
|
||||
@@ -254,25 +291,28 @@ export default function ApiKeysSettingsPage() {
|
||||
<CardContent className="pt-0">
|
||||
<Separator className="mb-4" />
|
||||
<div className="space-y-4">
|
||||
{provider.fields.map((field) => (
|
||||
{provider.fields.map((field) => {
|
||||
const rawValue = (data as Record<ProviderFieldName, string | boolean>)[field.name]
|
||||
const fieldValue = typeof rawValue === "string" ? rawValue : ""
|
||||
return (
|
||||
<div key={field.name} className="space-y-2">
|
||||
<label className="text-sm font-medium">{field.label}</label>
|
||||
{field.type === 'password' ? (
|
||||
<PasswordInput
|
||||
value={(data as any)[field.name] || ''}
|
||||
value={fieldValue}
|
||||
onChange={(value) => updateProvider(provider.key, field.name, value)}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type="text"
|
||||
value={(data as any)[field.name] || ''}
|
||||
value={fieldValue}
|
||||
onChange={(e) => updateProvider(provider.key, field.name, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)})}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
获取 API Key:
|
||||
<a
|
||||
|
||||
7
frontend/app/[locale]/settings/database-health/page.tsx
Normal file
7
frontend/app/[locale]/settings/database-health/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { DatabaseHealthView } from "@/components/settings/database-health"
|
||||
|
||||
export default function DatabaseHealthPage() {
|
||||
return <DatabaseHealthView />
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { WorkerList } from "@/components/settings/workers"
|
||||
import { AgentList, ArchitectureDialog } from "@/components/settings/workers"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
export default function WorkersPage() {
|
||||
@@ -15,8 +15,9 @@ export default function WorkersPage() {
|
||||
{t("description")}
|
||||
</p>
|
||||
</div>
|
||||
<ArchitectureDialog />
|
||||
</div>
|
||||
<WorkerList />
|
||||
<AgentList />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,12 +3,19 @@
|
||||
import React from "react"
|
||||
import { usePathname, useParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { Target, LayoutDashboard, Package, Image, ShieldAlert, Settings } from "lucide-react"
|
||||
import { Target, LayoutDashboard, Package, FolderSearch, Image as ImageIcon, ShieldAlert, Settings, HelpCircle } from "lucide-react"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { useTarget } from "@/hooks/use-targets"
|
||||
import { useTranslations } from "next-intl"
|
||||
import type { TargetDetail } from "@/types/target.types"
|
||||
|
||||
/**
|
||||
* Target detail layout
|
||||
@@ -34,6 +41,7 @@ export default function TargetLayout({
|
||||
// Get primary navigation active tab
|
||||
const getPrimaryTab = () => {
|
||||
if (pathname.includes("/overview")) return "overview"
|
||||
if (pathname.includes("/directories")) return "directories"
|
||||
if (pathname.includes("/screenshots")) return "screenshots"
|
||||
if (pathname.includes("/vulnerabilities")) return "vulnerabilities"
|
||||
if (pathname.includes("/settings")) return "settings"
|
||||
@@ -42,8 +50,7 @@ export default function TargetLayout({
|
||||
pathname.includes("/websites") ||
|
||||
pathname.includes("/subdomain") ||
|
||||
pathname.includes("/ip-addresses") ||
|
||||
pathname.includes("/endpoints") ||
|
||||
pathname.includes("/directories")
|
||||
pathname.includes("/endpoints")
|
||||
) {
|
||||
return "assets"
|
||||
}
|
||||
@@ -56,7 +63,6 @@ export default function TargetLayout({
|
||||
if (pathname.includes("/subdomain")) return "subdomain"
|
||||
if (pathname.includes("/ip-addresses")) return "ip-addresses"
|
||||
if (pathname.includes("/endpoints")) return "endpoints"
|
||||
if (pathname.includes("/directories")) return "directories"
|
||||
return "websites"
|
||||
}
|
||||
|
||||
@@ -68,6 +74,7 @@ export default function TargetLayout({
|
||||
const primaryPaths = {
|
||||
overview: `${basePath}/overview/`,
|
||||
assets: `${basePath}/websites/`, // Default to websites when clicking assets
|
||||
directories: `${basePath}/directories/`,
|
||||
screenshots: `${basePath}/screenshots/`,
|
||||
vulnerabilities: `${basePath}/vulnerabilities/`,
|
||||
settings: `${basePath}/settings/`,
|
||||
@@ -78,22 +85,22 @@ export default function TargetLayout({
|
||||
subdomain: `${basePath}/subdomain/`,
|
||||
"ip-addresses": `${basePath}/ip-addresses/`,
|
||||
endpoints: `${basePath}/endpoints/`,
|
||||
directories: `${basePath}/directories/`,
|
||||
}
|
||||
|
||||
// Get counts for each tab from target data
|
||||
const targetSummary = (target as TargetDetail | undefined)?.summary
|
||||
const counts = {
|
||||
subdomain: (target as any)?.summary?.subdomains || 0,
|
||||
endpoints: (target as any)?.summary?.endpoints || 0,
|
||||
websites: (target as any)?.summary?.websites || 0,
|
||||
directories: (target as any)?.summary?.directories || 0,
|
||||
vulnerabilities: (target as any)?.summary?.vulnerabilities?.total || 0,
|
||||
"ip-addresses": (target as any)?.summary?.ips || 0,
|
||||
screenshots: (target as any)?.summary?.screenshots || 0,
|
||||
subdomain: targetSummary?.subdomains || 0,
|
||||
endpoints: targetSummary?.endpoints || 0,
|
||||
websites: targetSummary?.websites || 0,
|
||||
directories: targetSummary?.directories || 0,
|
||||
vulnerabilities: targetSummary?.vulnerabilities?.total || 0,
|
||||
"ip-addresses": targetSummary?.ips || 0,
|
||||
screenshots: targetSummary?.screenshots || 0,
|
||||
}
|
||||
|
||||
// Calculate total assets count
|
||||
const totalAssets = counts.websites + counts.subdomain + counts["ip-addresses"] + counts.endpoints + counts.directories
|
||||
const totalAssets = counts.websites + counts.subdomain + counts["ip-addresses"] + counts.endpoints
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
@@ -161,56 +168,82 @@ export default function TargetLayout({
|
||||
</div>
|
||||
|
||||
{/* Primary navigation */}
|
||||
<div className="px-4 lg:px-6">
|
||||
<Tabs value={getPrimaryTab()}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview" asChild>
|
||||
<Link href={primaryPaths.overview} className="flex items-center gap-1.5">
|
||||
<LayoutDashboard className="h-4 w-4" />
|
||||
{t("tabs.overview")}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="assets" asChild>
|
||||
<Link href={primaryPaths.assets} className="flex items-center gap-1.5">
|
||||
<Package className="h-4 w-4" />
|
||||
{t("tabs.assets")}
|
||||
{totalAssets > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
|
||||
{totalAssets}
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="screenshots" asChild>
|
||||
<Link href={primaryPaths.screenshots} className="flex items-center gap-1.5">
|
||||
<Image className="h-4 w-4" />
|
||||
{t("tabs.screenshots")}
|
||||
{counts.screenshots > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
|
||||
{counts.screenshots}
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="vulnerabilities" asChild>
|
||||
<Link href={primaryPaths.vulnerabilities} className="flex items-center gap-1.5">
|
||||
<ShieldAlert className="h-4 w-4" />
|
||||
{t("tabs.vulnerabilities")}
|
||||
{counts.vulnerabilities > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
|
||||
{counts.vulnerabilities}
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings" asChild>
|
||||
<Link href={primaryPaths.settings} className="flex items-center gap-1.5">
|
||||
<Settings className="h-4 w-4" />
|
||||
{t("tabs.settings")}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="flex items-center justify-between px-4 lg:px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Tabs value={getPrimaryTab()}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview" asChild>
|
||||
<Link href={primaryPaths.overview} className="flex items-center gap-1.5">
|
||||
<LayoutDashboard className="h-4 w-4" />
|
||||
{t("tabs.overview")}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="assets" asChild>
|
||||
<Link href={primaryPaths.assets} className="flex items-center gap-1.5">
|
||||
<Package className="h-4 w-4" />
|
||||
{t("tabs.assets")}
|
||||
{totalAssets > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
|
||||
{totalAssets}
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="directories" asChild>
|
||||
<Link href={primaryPaths.directories} className="flex items-center gap-1.5">
|
||||
<FolderSearch className="h-4 w-4" />
|
||||
{t("tabs.directories")}
|
||||
{counts.directories > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
|
||||
{counts.directories}
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="screenshots" asChild>
|
||||
<Link href={primaryPaths.screenshots} className="flex items-center gap-1.5">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
{t("tabs.screenshots")}
|
||||
{counts.screenshots > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
|
||||
{counts.screenshots}
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="vulnerabilities" asChild>
|
||||
<Link href={primaryPaths.vulnerabilities} className="flex items-center gap-1.5">
|
||||
<ShieldAlert className="h-4 w-4" />
|
||||
{t("tabs.vulnerabilities")}
|
||||
{counts.vulnerabilities > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
|
||||
{counts.vulnerabilities}
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings" asChild>
|
||||
<Link href={primaryPaths.settings} className="flex items-center gap-1.5">
|
||||
<Settings className="h-4 w-4" />
|
||||
{t("tabs.settings")}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{getPrimaryTab() === "directories" && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="h-4 w-4 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-sm">
|
||||
{t("directoriesHelp")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Secondary navigation (only for assets) */}
|
||||
@@ -220,7 +253,7 @@ export default function TargetLayout({
|
||||
<TabsList variant="underline">
|
||||
<TabsTrigger value="websites" variant="underline" asChild>
|
||||
<Link href={secondaryPaths.websites} className="flex items-center gap-0.5">
|
||||
Websites
|
||||
{t("tabs.websites")}
|
||||
{counts.websites > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
|
||||
{counts.websites}
|
||||
@@ -230,7 +263,7 @@ export default function TargetLayout({
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="subdomain" variant="underline" asChild>
|
||||
<Link href={secondaryPaths.subdomain} className="flex items-center gap-0.5">
|
||||
Subdomains
|
||||
{t("tabs.subdomains")}
|
||||
{counts.subdomain > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
|
||||
{counts.subdomain}
|
||||
@@ -240,7 +273,7 @@ export default function TargetLayout({
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="ip-addresses" variant="underline" asChild>
|
||||
<Link href={secondaryPaths["ip-addresses"]} className="flex items-center gap-0.5">
|
||||
IPs
|
||||
{t("tabs.ips")}
|
||||
{counts["ip-addresses"] > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
|
||||
{counts["ip-addresses"]}
|
||||
@@ -250,7 +283,7 @@ export default function TargetLayout({
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="endpoints" variant="underline" asChild>
|
||||
<Link href={secondaryPaths.endpoints} className="flex items-center gap-0.5">
|
||||
URLs
|
||||
{t("tabs.urls")}
|
||||
{counts.endpoints > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
|
||||
{counts.endpoints}
|
||||
@@ -258,16 +291,6 @@ export default function TargetLayout({
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="directories" variant="underline" asChild>
|
||||
<Link href={secondaryPaths.directories} className="flex items-center gap-0.5">
|
||||
Directories
|
||||
{counts.directories > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 min-w-5 rounded-full px-1.5 text-xs">
|
||||
{counts.directories}
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Editor from "@monaco-editor/react"
|
||||
import dynamic from "next/dynamic"
|
||||
import Link from "next/link"
|
||||
import { useParams } from "next/navigation"
|
||||
|
||||
// Dynamic import Monaco Editor to reduce bundle size (~2MB)
|
||||
const Editor = dynamic(() => import("@monaco-editor/react"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-sm text-muted-foreground">Loading editor...</div>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
@@ -12,7 +22,6 @@ import {
|
||||
ArrowLeft,
|
||||
Search,
|
||||
RefreshCw,
|
||||
AlertTriangle,
|
||||
Tag,
|
||||
User,
|
||||
} from "lucide-react"
|
||||
@@ -99,7 +108,7 @@ export default function NucleiRepoDetailPage() {
|
||||
const numericRepoId = repoId ? Number(repoId) : null
|
||||
|
||||
const { data: tree, isLoading, isError } = useNucleiRepoTree(numericRepoId)
|
||||
const { data: templateContent, isLoading: isLoadingContent } = useNucleiRepoContent(numericRepoId, selectedPath)
|
||||
const { data: templateContent } = useNucleiRepoContent(numericRepoId, selectedPath)
|
||||
const { data: repoDetail } = useNucleiRepo(numericRepoId)
|
||||
const refreshMutation = useRefreshNucleiRepo()
|
||||
|
||||
@@ -160,7 +169,7 @@ export default function NucleiRepoDetailPage() {
|
||||
} else {
|
||||
setEditorValue("")
|
||||
}
|
||||
}, [templateContent?.path])
|
||||
}, [templateContent])
|
||||
|
||||
const toggleFolder = (path: string) => {
|
||||
setExpandedPaths((prev) =>
|
||||
@@ -248,7 +257,7 @@ export default function NucleiRepoDetailPage() {
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
|
||||
"tree-node-item flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
|
||||
isFolder && "font-medium",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
|
||||
@@ -12,7 +12,6 @@ import { useTranslations } from "next-intl"
|
||||
*/
|
||||
export default function ToolsPage() {
|
||||
const t = useTranslations("pages.tools")
|
||||
const tCommon = useTranslations("common")
|
||||
|
||||
// Feature modules
|
||||
const modules = [
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { toast } from "sonner"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { Wordlist } from "@/types/wordlist.types"
|
||||
import { MasterDetailSkeleton } from "@/components/ui/master-detail-skeleton"
|
||||
@@ -67,11 +66,6 @@ export default function WordlistsPage() {
|
||||
setIsEditDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleCopyId = (id: number) => {
|
||||
navigator.clipboard.writeText(String(id))
|
||||
toast.success(t("idCopied"))
|
||||
}
|
||||
|
||||
const handleDelete = (wordlist: Wordlist) => {
|
||||
setWordlistToDelete(wordlist)
|
||||
setDeleteDialogOpen(true)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
@@ -1,4 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@import "@xyflow/react/dist/style.css";
|
||||
@import "tw-animate-css";
|
||||
@import "@xterm/xterm/css/xterm.css";
|
||||
@import "../styles/themes/index.css";
|
||||
@@ -183,6 +184,11 @@
|
||||
letter-spacing: var(--tracking-normal);
|
||||
}
|
||||
|
||||
img[src*="LunaFox_logo"] {
|
||||
background: #ffffff;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
/* 全局滚动条样式 - Webkit 浏览器 (Chrome, Safari, Edge) */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
@@ -245,6 +251,12 @@
|
||||
/* Chrome, Safari and Opera */
|
||||
}
|
||||
|
||||
/* 性能优化:长列表渲染优化 - content-visibility */
|
||||
.tree-node-item {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: 0 36px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* 登录页背景 - 使用主题色适配亮暗模式 */
|
||||
@@ -272,6 +284,43 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 终端光标闪烁动画 */
|
||||
@keyframes blink {
|
||||
0%, 50% {
|
||||
opacity: 1;
|
||||
}
|
||||
51%, 100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-blink {
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
/* 打字动画 - AuthBootLog */
|
||||
@keyframes typing {
|
||||
from {
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
1% {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
max-width: 500px;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-typing {
|
||||
display: block;
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
animation: typing 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
/* 通知铃铛摇晃动画 */
|
||||
@keyframes wiggle {
|
||||
0%, 100% {
|
||||
@@ -367,4 +416,315 @@
|
||||
|
||||
.animate-border-flow {
|
||||
animation: border-flow 2s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
/* 全局淡入动画 - 纯 CSS 实现,避免 hydration mismatch */
|
||||
@keyframes app-fade-in {
|
||||
from {
|
||||
opacity: 0.85;
|
||||
filter: blur(1px);
|
||||
transform: translateY(2px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-app-fade-in {
|
||||
animation: app-fade-in 280ms ease-out both;
|
||||
}
|
||||
|
||||
/* 登录页 - Glitch Reveal(全屏开场) - 增强版赛博朋克风格 */
|
||||
@keyframes lunafox-splash-jitter {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate3d(0, 0, 0);
|
||||
filter: none;
|
||||
}
|
||||
10% {
|
||||
transform: translate3d(-2px, 0, 0);
|
||||
}
|
||||
20% {
|
||||
transform: translate3d(2px, -1px, 0);
|
||||
filter: hue-rotate(10deg);
|
||||
}
|
||||
30% {
|
||||
transform: translate3d(-1px, 1px, 0);
|
||||
}
|
||||
45% {
|
||||
transform: translate3d(1px, 0, 0);
|
||||
filter: hue-rotate(-10deg);
|
||||
}
|
||||
60% {
|
||||
transform: translate3d(0, -1px, 0);
|
||||
}
|
||||
75% {
|
||||
transform: translate3d(1px, 1px, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lunafox-splash-noise {
|
||||
0% {
|
||||
transform: translate3d(-2%, -2%, 0);
|
||||
opacity: 0.22;
|
||||
}
|
||||
25% {
|
||||
transform: translate3d(2%, -1%, 0);
|
||||
opacity: 0.28;
|
||||
}
|
||||
50% {
|
||||
transform: translate3d(-1%, 2%, 0);
|
||||
opacity: 0.24;
|
||||
}
|
||||
75% {
|
||||
transform: translate3d(1%, 1%, 0);
|
||||
opacity: 0.30;
|
||||
}
|
||||
100% {
|
||||
transform: translate3d(-2%, -2%, 0);
|
||||
opacity: 0.22;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lunafox-splash-sweep {
|
||||
0% {
|
||||
transform: translate3d(0, -120%, 0);
|
||||
opacity: 0;
|
||||
}
|
||||
18% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
100% {
|
||||
transform: translate3d(0, 120%, 0);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lunafox-glitch-clip {
|
||||
0% {
|
||||
clip-path: inset(0 0 0 0);
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
16% {
|
||||
clip-path: inset(12% 0 72% 0);
|
||||
transform: translate3d(-2px, 0, 0);
|
||||
}
|
||||
32% {
|
||||
clip-path: inset(54% 0 18% 0);
|
||||
transform: translate3d(2px, 0, 0);
|
||||
}
|
||||
48% {
|
||||
clip-path: inset(78% 0 6% 0);
|
||||
transform: translate3d(-1px, 0, 0);
|
||||
}
|
||||
64% {
|
||||
clip-path: inset(30% 0 48% 0);
|
||||
transform: translate3d(1px, 0, 0);
|
||||
}
|
||||
80% {
|
||||
clip-path: inset(6% 0 86% 0);
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
100% {
|
||||
clip-path: inset(0 0 0 0);
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.lunafox-splash-glitch {
|
||||
isolation: isolate;
|
||||
animation: lunafox-splash-jitter 0.5s steps(2, end) infinite;
|
||||
}
|
||||
|
||||
.lunafox-splash-glitch::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -20%;
|
||||
pointer-events: none;
|
||||
z-index: 20;
|
||||
mix-blend-mode: screen;
|
||||
background-image:
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
rgba(255, 255, 255, 0.08) 0px,
|
||||
rgba(255, 255, 255, 0.08) 1px,
|
||||
transparent 1px,
|
||||
transparent 4px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 16, 240, 0.15) 0px,
|
||||
rgba(255, 16, 240, 0.15) 1px,
|
||||
transparent 1px,
|
||||
transparent 84px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
45deg,
|
||||
rgba(176, 38, 255, 0.08) 0px,
|
||||
rgba(176, 38, 255, 0.08) 1px,
|
||||
transparent 1px,
|
||||
transparent 9px
|
||||
);
|
||||
animation: lunafox-splash-noise 0.5s steps(2, end) infinite;
|
||||
}
|
||||
|
||||
.lunafox-splash-glitch::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 20;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
transparent 0%,
|
||||
rgba(255, 16, 240, 0.18) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
opacity: 0;
|
||||
animation: lunafox-splash-sweep 0.5s ease-out both;
|
||||
}
|
||||
|
||||
.lunafox-glitch-text {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
text-shadow: 0 0 20px rgba(255, 16, 240, 0.4), 0 0 40px rgba(255, 16, 240, 0.2);
|
||||
}
|
||||
|
||||
.lunafox-glitch-text::before,
|
||||
.lunafox-glitch-text::after {
|
||||
content: attr(data-text);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.lunafox-glitch-text::before {
|
||||
color: rgba(255, 16, 240, 0.85);
|
||||
transform: translate3d(-2px, 0, 0);
|
||||
animation: lunafox-glitch-clip 0.5s steps(2, end) infinite;
|
||||
}
|
||||
|
||||
.lunafox-glitch-text::after {
|
||||
color: rgba(176, 38, 255, 0.75);
|
||||
transform: translate3d(2px, 0, 0);
|
||||
animation: lunafox-glitch-clip 0.5s steps(2, end) infinite reverse;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.lunafox-splash-glitch,
|
||||
.lunafox-splash-glitch::before,
|
||||
.lunafox-splash-glitch::after,
|
||||
.lunafox-glitch-text::before,
|
||||
.lunafox-glitch-text::after {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.shield-loader {
|
||||
--shield-size: 180px;
|
||||
--shield-primary: var(--primary, #1e88e5);
|
||||
--shield-secondary: var(--accent, #f57c00);
|
||||
--shield-border-dim: color-mix(in oklab, var(--foreground, #000) 18%, transparent);
|
||||
--shield-border-light: color-mix(in oklab, var(--shield-primary) 18%, transparent);
|
||||
--shield-energy-bg: color-mix(in oklab, var(--shield-primary) 28%, transparent);
|
||||
--shield-shadow: color-mix(in oklab, var(--shield-primary) 32%, transparent);
|
||||
position: relative;
|
||||
width: var(--shield-size);
|
||||
height: var(--shield-size);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.shield-loader__energy,
|
||||
.shield-loader__ring {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.shield-loader__energy {
|
||||
width: calc(var(--shield-size) * 0.45);
|
||||
height: calc(var(--shield-size) * 0.45);
|
||||
background: radial-gradient(circle, var(--shield-energy-bg) 0%, transparent 70%);
|
||||
animation: shield-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.shield-loader__ring {
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.shield-loader__ring--s1 {
|
||||
width: calc(var(--shield-size) * 0.5);
|
||||
height: calc(var(--shield-size) * 0.5);
|
||||
border-top: 4px solid var(--shield-primary);
|
||||
border-bottom: 4px solid var(--shield-primary);
|
||||
animation: shield-rotate 4s linear infinite;
|
||||
filter: drop-shadow(0 0 8px var(--shield-primary));
|
||||
}
|
||||
|
||||
.shield-loader__ring--s2 {
|
||||
width: calc(var(--shield-size) * 0.6);
|
||||
height: calc(var(--shield-size) * 0.6);
|
||||
border-left: 2px solid var(--shield-secondary);
|
||||
border-right: 2px solid var(--shield-secondary);
|
||||
animation: shield-rotate 6s linear infinite reverse;
|
||||
filter: drop-shadow(0 0 8px var(--shield-secondary));
|
||||
}
|
||||
|
||||
.shield-loader__ring--s3 {
|
||||
width: calc(var(--shield-size) * 0.7);
|
||||
height: calc(var(--shield-size) * 0.7);
|
||||
border: 1px dashed var(--shield-border-dim);
|
||||
animation: shield-rotate 20s linear infinite;
|
||||
}
|
||||
|
||||
.shield-loader__ring--s4 {
|
||||
width: calc(var(--shield-size) * 0.8);
|
||||
height: calc(var(--shield-size) * 0.8);
|
||||
border-top: 8px solid var(--shield-border-light);
|
||||
border-right: 8px solid var(--shield-border-light);
|
||||
animation: shield-rotate 8s linear infinite;
|
||||
}
|
||||
|
||||
.shield-loader__logo {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: calc(var(--shield-size) * 0.4);
|
||||
height: calc(var(--shield-size) * 0.4);
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 28px var(--shield-shadow);
|
||||
}
|
||||
|
||||
@keyframes shield-rotate {
|
||||
from {
|
||||
transform: translate(-50%, -50%) rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: translate(-50%, -50%) rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shield-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.5;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.shield-loader__ring,
|
||||
.shield-loader__energy {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,5 +18,9 @@
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
"registries": {
|
||||
"@animate-ui": "https://animate-ui.com/r/{name}.json",
|
||||
"@magicui": "https://magicui.design/r/{name}.json",
|
||||
"@react-bits": "https://reactbits.dev/r/{name}.json"
|
||||
}
|
||||
}
|
||||
|
||||
6
frontend/components/FaultyTerminal.css
Normal file
6
frontend/components/FaultyTerminal.css
Normal file
@@ -0,0 +1,6 @@
|
||||
.faulty-terminal-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
423
frontend/components/FaultyTerminal.tsx
Normal file
423
frontend/components/FaultyTerminal.tsx
Normal file
@@ -0,0 +1,423 @@
|
||||
import { Renderer, Program, Mesh, Color, Triangle } from 'ogl';
|
||||
import { useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import './FaultyTerminal.css';
|
||||
|
||||
const vertexShader = `
|
||||
attribute vec2 position;
|
||||
attribute vec2 uv;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = `
|
||||
precision mediump float;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
uniform float iTime;
|
||||
uniform vec3 iResolution;
|
||||
uniform float uScale;
|
||||
|
||||
uniform vec2 uGridMul;
|
||||
uniform float uDigitSize;
|
||||
uniform float uScanlineIntensity;
|
||||
uniform float uGlitchAmount;
|
||||
uniform float uFlickerAmount;
|
||||
uniform float uNoiseAmp;
|
||||
uniform float uChromaticAberration;
|
||||
uniform float uDither;
|
||||
uniform float uCurvature;
|
||||
uniform vec3 uTint;
|
||||
uniform vec2 uMouse;
|
||||
uniform float uMouseStrength;
|
||||
uniform float uUseMouse;
|
||||
uniform float uPageLoadProgress;
|
||||
uniform float uUsePageLoadAnimation;
|
||||
uniform float uBrightness;
|
||||
|
||||
float time;
|
||||
|
||||
float hash21(vec2 p){
|
||||
p = fract(p * 234.56);
|
||||
p += dot(p, p + 34.56);
|
||||
return fract(p.x * p.y);
|
||||
}
|
||||
|
||||
float noise(vec2 p)
|
||||
{
|
||||
return sin(p.x * 10.0) * sin(p.y * (3.0 + sin(time * 0.090909))) + 0.2;
|
||||
}
|
||||
|
||||
mat2 rotate(float angle)
|
||||
{
|
||||
float c = cos(angle);
|
||||
float s = sin(angle);
|
||||
return mat2(c, -s, s, c);
|
||||
}
|
||||
|
||||
float fbm(vec2 p)
|
||||
{
|
||||
p *= 1.1;
|
||||
float f = 0.0;
|
||||
float amp = 0.5 * uNoiseAmp;
|
||||
|
||||
mat2 modify0 = rotate(time * 0.02);
|
||||
f += amp * noise(p);
|
||||
p = modify0 * p * 2.0;
|
||||
amp *= 0.454545;
|
||||
|
||||
mat2 modify1 = rotate(time * 0.02);
|
||||
f += amp * noise(p);
|
||||
p = modify1 * p * 2.0;
|
||||
amp *= 0.454545;
|
||||
|
||||
mat2 modify2 = rotate(time * 0.08);
|
||||
f += amp * noise(p);
|
||||
|
||||
return f;
|
||||
}
|
||||
|
||||
float pattern(vec2 p, out vec2 q, out vec2 r) {
|
||||
vec2 offset1 = vec2(1.0);
|
||||
vec2 offset0 = vec2(0.0);
|
||||
mat2 rot01 = rotate(0.1 * time);
|
||||
mat2 rot1 = rotate(0.1);
|
||||
|
||||
q = vec2(fbm(p + offset1), fbm(rot01 * p + offset1));
|
||||
r = vec2(fbm(rot1 * q + offset0), fbm(q + offset0));
|
||||
return fbm(p + r);
|
||||
}
|
||||
|
||||
float digit(vec2 p){
|
||||
vec2 grid = uGridMul * 15.0;
|
||||
vec2 s = floor(p * grid) / grid;
|
||||
p = p * grid;
|
||||
vec2 q, r;
|
||||
float intensity = pattern(s * 0.1, q, r) * 1.3 - 0.03;
|
||||
|
||||
if(uUseMouse > 0.5){
|
||||
vec2 mouseWorld = uMouse * uScale;
|
||||
float distToMouse = distance(s, mouseWorld);
|
||||
float mouseInfluence = exp(-distToMouse * 8.0) * uMouseStrength * 10.0;
|
||||
intensity += mouseInfluence;
|
||||
|
||||
float ripple = sin(distToMouse * 20.0 - iTime * 5.0) * 0.1 * mouseInfluence;
|
||||
intensity += ripple;
|
||||
}
|
||||
|
||||
if(uUsePageLoadAnimation > 0.5){
|
||||
float cellRandom = fract(sin(dot(s, vec2(12.9898, 78.233))) * 43758.5453);
|
||||
float cellDelay = cellRandom * 0.8;
|
||||
float cellProgress = clamp((uPageLoadProgress - cellDelay) / 0.2, 0.0, 1.0);
|
||||
|
||||
float fadeAlpha = smoothstep(0.0, 1.0, cellProgress);
|
||||
intensity *= fadeAlpha;
|
||||
}
|
||||
|
||||
p = fract(p);
|
||||
p *= uDigitSize;
|
||||
|
||||
float px5 = p.x * 5.0;
|
||||
float py5 = (1.0 - p.y) * 5.0;
|
||||
float x = fract(px5);
|
||||
float y = fract(py5);
|
||||
|
||||
float i = floor(py5) - 2.0;
|
||||
float j = floor(px5) - 2.0;
|
||||
float n = i * i + j * j;
|
||||
float f = n * 0.0625;
|
||||
|
||||
float isOn = step(0.1, intensity - f);
|
||||
float brightness = isOn * (0.2 + y * 0.8) * (0.75 + x * 0.25);
|
||||
|
||||
return step(0.0, p.x) * step(p.x, 1.0) * step(0.0, p.y) * step(p.y, 1.0) * brightness;
|
||||
}
|
||||
|
||||
float onOff(float a, float b, float c)
|
||||
{
|
||||
return step(c, sin(iTime + a * cos(iTime * b))) * uFlickerAmount;
|
||||
}
|
||||
|
||||
float displace(vec2 look)
|
||||
{
|
||||
float y = look.y - mod(iTime * 0.25, 1.0);
|
||||
float window = 1.0 / (1.0 + 50.0 * y * y);
|
||||
return sin(look.y * 20.0 + iTime) * 0.0125 * onOff(4.0, 2.0, 0.8) * (1.0 + cos(iTime * 60.0)) * window;
|
||||
}
|
||||
|
||||
vec3 getColor(vec2 p){
|
||||
|
||||
float bar = step(mod(p.y + time * 20.0, 1.0), 0.2) * 0.4 + 1.0;
|
||||
bar *= uScanlineIntensity;
|
||||
|
||||
float displacement = displace(p);
|
||||
p.x += displacement;
|
||||
|
||||
if (uGlitchAmount != 1.0) {
|
||||
float extra = displacement * (uGlitchAmount - 1.0);
|
||||
p.x += extra;
|
||||
}
|
||||
|
||||
float middle = digit(p);
|
||||
|
||||
const float off = 0.002;
|
||||
float sum = digit(p + vec2(-off, -off)) + digit(p + vec2(0.0, -off)) + digit(p + vec2(off, -off)) +
|
||||
digit(p + vec2(-off, 0.0)) + digit(p + vec2(0.0, 0.0)) + digit(p + vec2(off, 0.0)) +
|
||||
digit(p + vec2(-off, off)) + digit(p + vec2(0.0, off)) + digit(p + vec2(off, off));
|
||||
|
||||
vec3 baseColor = vec3(0.9) * middle + sum * 0.1 * vec3(1.0) * bar;
|
||||
return baseColor;
|
||||
}
|
||||
|
||||
vec2 barrel(vec2 uv){
|
||||
vec2 c = uv * 2.0 - 1.0;
|
||||
float r2 = dot(c, c);
|
||||
c *= 1.0 + uCurvature * r2;
|
||||
return c * 0.5 + 0.5;
|
||||
}
|
||||
|
||||
void main() {
|
||||
time = iTime * 0.333333;
|
||||
vec2 uv = vUv;
|
||||
|
||||
if(uCurvature != 0.0){
|
||||
uv = barrel(uv);
|
||||
}
|
||||
|
||||
vec2 p = uv * uScale;
|
||||
vec3 col = getColor(p);
|
||||
|
||||
if(uChromaticAberration != 0.0){
|
||||
vec2 ca = vec2(uChromaticAberration) / iResolution.xy;
|
||||
col.r = getColor(p + ca).r;
|
||||
col.b = getColor(p - ca).b;
|
||||
}
|
||||
|
||||
col *= uTint;
|
||||
col *= uBrightness;
|
||||
|
||||
if(uDither > 0.0){
|
||||
float rnd = hash21(gl_FragCoord.xy);
|
||||
col += (rnd - 0.5) * (uDither * 0.003922);
|
||||
}
|
||||
|
||||
gl_FragColor = vec4(col, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
function hexToRgb(hex: string) {
|
||||
let h = hex.replace('#', '').trim();
|
||||
if (h.length === 3)
|
||||
h = h
|
||||
.split('')
|
||||
.map(c => c + c)
|
||||
.join('');
|
||||
const num = parseInt(h, 16);
|
||||
return [((num >> 16) & 255) / 255, ((num >> 8) & 255) / 255, (num & 255) / 255];
|
||||
}
|
||||
|
||||
interface FaultyTerminalProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
scale?: number;
|
||||
gridMul?: [number, number];
|
||||
digitSize?: number;
|
||||
timeScale?: number;
|
||||
pause?: boolean;
|
||||
scanlineIntensity?: number;
|
||||
glitchAmount?: number;
|
||||
flickerAmount?: number;
|
||||
noiseAmp?: number;
|
||||
chromaticAberration?: number;
|
||||
dither?: number;
|
||||
curvature?: number;
|
||||
tint?: string;
|
||||
mouseReact?: boolean;
|
||||
mouseStrength?: number;
|
||||
dpr?: number;
|
||||
pageLoadAnimation?: boolean;
|
||||
brightness?: number;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export default function FaultyTerminal({
|
||||
scale = 1,
|
||||
gridMul = [2, 1],
|
||||
digitSize = 1.5,
|
||||
timeScale = 0.3,
|
||||
pause = false,
|
||||
scanlineIntensity = 0.3,
|
||||
glitchAmount = 1,
|
||||
flickerAmount = 1,
|
||||
noiseAmp = 0,
|
||||
chromaticAberration = 0,
|
||||
dither = 0,
|
||||
curvature = 0.2,
|
||||
tint = '#ffffff',
|
||||
mouseReact = true,
|
||||
mouseStrength = 0.2,
|
||||
dpr = Math.min(window.devicePixelRatio || 1, 2),
|
||||
pageLoadAnimation = true,
|
||||
brightness = 1,
|
||||
className,
|
||||
style,
|
||||
...rest
|
||||
}: FaultyTerminalProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const programRef = useRef<Program | null>(null);
|
||||
const rendererRef = useRef<Renderer | null>(null);
|
||||
const mouseRef = useRef({ x: 0.5, y: 0.5 });
|
||||
const smoothMouseRef = useRef({ x: 0.5, y: 0.5 });
|
||||
const frozenTimeRef = useRef(0);
|
||||
const rafRef = useRef(0);
|
||||
const loadAnimationStartRef = useRef(0);
|
||||
const timeOffsetRef = useRef(Math.random() * 100);
|
||||
|
||||
const tintVec = useMemo(() => hexToRgb(tint), [tint]);
|
||||
|
||||
const ditherValue = useMemo(() => (typeof dither === 'boolean' ? (dither ? 1 : 0) : dither), [dither]);
|
||||
|
||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||
const ctn = containerRef.current;
|
||||
if (!ctn) return;
|
||||
const rect = ctn.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / rect.width;
|
||||
const y = 1 - (e.clientY - rect.top) / rect.height;
|
||||
mouseRef.current = { x, y };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const ctn = containerRef.current;
|
||||
if (!ctn) return;
|
||||
|
||||
const renderer = new Renderer({ dpr });
|
||||
rendererRef.current = renderer;
|
||||
const gl = renderer.gl;
|
||||
gl.clearColor(0, 0, 0, 1);
|
||||
|
||||
const geometry = new Triangle(gl);
|
||||
|
||||
const program = new Program(gl, {
|
||||
vertex: vertexShader,
|
||||
fragment: fragmentShader,
|
||||
uniforms: {
|
||||
iTime: { value: 0 },
|
||||
iResolution: {
|
||||
value: new Color(gl.canvas.width, gl.canvas.height, gl.canvas.width / gl.canvas.height)
|
||||
},
|
||||
uScale: { value: scale },
|
||||
|
||||
uGridMul: { value: new Float32Array(gridMul) },
|
||||
uDigitSize: { value: digitSize },
|
||||
uScanlineIntensity: { value: scanlineIntensity },
|
||||
uGlitchAmount: { value: glitchAmount },
|
||||
uFlickerAmount: { value: flickerAmount },
|
||||
uNoiseAmp: { value: noiseAmp },
|
||||
uChromaticAberration: { value: chromaticAberration },
|
||||
uDither: { value: ditherValue },
|
||||
uCurvature: { value: curvature },
|
||||
uTint: { value: new Color(tintVec[0], tintVec[1], tintVec[2]) },
|
||||
uMouse: {
|
||||
value: new Float32Array([smoothMouseRef.current.x, smoothMouseRef.current.y])
|
||||
},
|
||||
uMouseStrength: { value: mouseStrength },
|
||||
uUseMouse: { value: mouseReact ? 1 : 0 },
|
||||
uPageLoadProgress: { value: pageLoadAnimation ? 0 : 1 },
|
||||
uUsePageLoadAnimation: { value: pageLoadAnimation ? 1 : 0 },
|
||||
uBrightness: { value: brightness }
|
||||
}
|
||||
});
|
||||
programRef.current = program;
|
||||
|
||||
const mesh = new Mesh(gl, { geometry, program });
|
||||
|
||||
function resize() {
|
||||
if (!ctn || !renderer) return;
|
||||
renderer.setSize(ctn.offsetWidth, ctn.offsetHeight);
|
||||
program.uniforms.iResolution.value = new Color(
|
||||
gl.canvas.width,
|
||||
gl.canvas.height,
|
||||
gl.canvas.width / gl.canvas.height
|
||||
);
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => resize());
|
||||
resizeObserver.observe(ctn);
|
||||
resize();
|
||||
|
||||
const update = (t: number) => {
|
||||
rafRef.current = requestAnimationFrame(update);
|
||||
|
||||
if (pageLoadAnimation && loadAnimationStartRef.current === 0) {
|
||||
loadAnimationStartRef.current = t;
|
||||
}
|
||||
|
||||
if (!pause) {
|
||||
const elapsed = (t * 0.001 + timeOffsetRef.current) * timeScale;
|
||||
program.uniforms.iTime.value = elapsed;
|
||||
frozenTimeRef.current = elapsed;
|
||||
} else {
|
||||
program.uniforms.iTime.value = frozenTimeRef.current;
|
||||
}
|
||||
|
||||
if (pageLoadAnimation && loadAnimationStartRef.current > 0) {
|
||||
const animationDuration = 2000;
|
||||
const animationElapsed = t - loadAnimationStartRef.current;
|
||||
const progress = Math.min(animationElapsed / animationDuration, 1);
|
||||
program.uniforms.uPageLoadProgress.value = progress;
|
||||
}
|
||||
|
||||
if (mouseReact) {
|
||||
const dampingFactor = 0.08;
|
||||
const smoothMouse = smoothMouseRef.current;
|
||||
const mouse = mouseRef.current;
|
||||
smoothMouse.x += (mouse.x - smoothMouse.x) * dampingFactor;
|
||||
smoothMouse.y += (mouse.y - smoothMouse.y) * dampingFactor;
|
||||
|
||||
const mouseUniform = program.uniforms.uMouse.value;
|
||||
mouseUniform[0] = smoothMouse.x;
|
||||
mouseUniform[1] = smoothMouse.y;
|
||||
}
|
||||
|
||||
renderer.render({ scene: mesh });
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(update);
|
||||
ctn.appendChild(gl.canvas);
|
||||
|
||||
if (mouseReact) window.addEventListener('mousemove', handleMouseMove);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
resizeObserver.disconnect();
|
||||
if (mouseReact) window.removeEventListener('mousemove', handleMouseMove);
|
||||
if (gl.canvas.parentElement === ctn) ctn.removeChild(gl.canvas);
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext();
|
||||
loadAnimationStartRef.current = 0;
|
||||
timeOffsetRef.current = Math.random() * 100;
|
||||
};
|
||||
}, [
|
||||
dpr,
|
||||
pause,
|
||||
timeScale,
|
||||
scale,
|
||||
gridMul,
|
||||
digitSize,
|
||||
scanlineIntensity,
|
||||
glitchAmount,
|
||||
flickerAmount,
|
||||
noiseAmp,
|
||||
chromaticAberration,
|
||||
ditherValue,
|
||||
curvature,
|
||||
tintVec,
|
||||
mouseReact,
|
||||
mouseStrength,
|
||||
pageLoadAnimation,
|
||||
brightness,
|
||||
handleMouseMove
|
||||
]);
|
||||
|
||||
return <div ref={containerRef} className={`faulty-terminal-container ${className}`} style={style} {...rest} />;
|
||||
}
|
||||
6
frontend/components/PixelBlast.css
Normal file
6
frontend/components/PixelBlast.css
Normal file
@@ -0,0 +1,6 @@
|
||||
.pixel-blast-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
844
frontend/components/PixelBlast.tsx
Normal file
844
frontend/components/PixelBlast.tsx
Normal file
@@ -0,0 +1,844 @@
|
||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { EffectComposer, EffectPass, RenderPass, Effect } from 'postprocessing';
|
||||
import './PixelBlast.css';
|
||||
|
||||
type TouchPoint = {
|
||||
x: number;
|
||||
y: number;
|
||||
age: number;
|
||||
force: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
};
|
||||
|
||||
type TouchTexture = {
|
||||
canvas: HTMLCanvasElement;
|
||||
texture: THREE.Texture;
|
||||
addTouch: (norm: { x: number; y: number }) => void;
|
||||
update: () => void;
|
||||
radiusScale: number;
|
||||
size: number;
|
||||
};
|
||||
|
||||
type LiquidOptions = {
|
||||
strength?: number;
|
||||
freq?: number;
|
||||
};
|
||||
|
||||
type ReinitConfig = {
|
||||
antialias: boolean;
|
||||
liquid: boolean;
|
||||
noiseAmount: number;
|
||||
};
|
||||
|
||||
type PixelBlastUniforms = {
|
||||
uResolution: THREE.IUniform<THREE.Vector2>;
|
||||
uTime: THREE.IUniform<number>;
|
||||
uColor: THREE.IUniform<THREE.Color>;
|
||||
uClickPos: THREE.IUniform<THREE.Vector2[]>;
|
||||
uClickTimes: THREE.IUniform<Float32Array>;
|
||||
uShapeType: THREE.IUniform<number>;
|
||||
uPixelSize: THREE.IUniform<number>;
|
||||
uScale: THREE.IUniform<number>;
|
||||
uDensity: THREE.IUniform<number>;
|
||||
uPixelJitter: THREE.IUniform<number>;
|
||||
uEnableRipples: THREE.IUniform<number>;
|
||||
uRippleSpeed: THREE.IUniform<number>;
|
||||
uRippleThickness: THREE.IUniform<number>;
|
||||
uRippleIntensity: THREE.IUniform<number>;
|
||||
uEdgeFade: THREE.IUniform<number>;
|
||||
};
|
||||
|
||||
type PixelBlastState = {
|
||||
renderer: THREE.WebGLRenderer;
|
||||
scene: THREE.Scene;
|
||||
camera: THREE.OrthographicCamera;
|
||||
material: THREE.ShaderMaterial;
|
||||
clock: THREE.Clock;
|
||||
clickIx: number;
|
||||
uniforms: PixelBlastUniforms;
|
||||
resizeObserver?: ResizeObserver;
|
||||
raf: number;
|
||||
quad: THREE.Mesh;
|
||||
timeOffset: number;
|
||||
composer?: EffectComposer;
|
||||
touch?: TouchTexture;
|
||||
liquidEffect?: Effect;
|
||||
onPointerMove?: (event: MouseEvent | PointerEvent) => void;
|
||||
domElement?: HTMLCanvasElement;
|
||||
};
|
||||
|
||||
const createTouchTexture = () => {
|
||||
const size = 64;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('2D context not available');
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
const texture = new THREE.Texture(canvas);
|
||||
texture.minFilter = THREE.LinearFilter;
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
texture.generateMipmaps = false;
|
||||
const trail: TouchPoint[] = [];
|
||||
let last: { x: number; y: number } | null = null;
|
||||
const maxAge = 64;
|
||||
let radius = 0.1 * size;
|
||||
const speed = 1 / maxAge;
|
||||
const clear = () => {
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
};
|
||||
const drawPoint = (p: TouchPoint) => {
|
||||
const pos = { x: p.x * size, y: (1 - p.y) * size };
|
||||
let intensity = 1;
|
||||
const easeOutSine = (t: number) => Math.sin((t * Math.PI) / 2);
|
||||
const easeOutQuad = (t: number) => -t * (t - 2);
|
||||
if (p.age < maxAge * 0.3) intensity = easeOutSine(p.age / (maxAge * 0.3));
|
||||
else intensity = easeOutQuad(1 - (p.age - maxAge * 0.3) / (maxAge * 0.7)) || 0;
|
||||
intensity *= p.force;
|
||||
const color = `${((p.vx + 1) / 2) * 255}, ${((p.vy + 1) / 2) * 255}, ${intensity * 255}`;
|
||||
const offset = size * 5;
|
||||
ctx.shadowOffsetX = offset;
|
||||
ctx.shadowOffsetY = offset;
|
||||
ctx.shadowBlur = radius;
|
||||
ctx.shadowColor = `rgba(${color},${0.22 * intensity})`;
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = 'rgba(255,0,0,1)';
|
||||
ctx.arc(pos.x - offset, pos.y - offset, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
};
|
||||
const addTouch = (norm: { x: number; y: number }) => {
|
||||
let force = 0;
|
||||
let vx = 0;
|
||||
let vy = 0;
|
||||
if (last) {
|
||||
const dx = norm.x - last.x;
|
||||
const dy = norm.y - last.y;
|
||||
if (dx === 0 && dy === 0) return;
|
||||
const dd = dx * dx + dy * dy;
|
||||
const d = Math.sqrt(dd);
|
||||
vx = dx / (d || 1);
|
||||
vy = dy / (d || 1);
|
||||
force = Math.min(dd * 10000, 1);
|
||||
}
|
||||
last = { x: norm.x, y: norm.y };
|
||||
trail.push({ x: norm.x, y: norm.y, age: 0, force, vx, vy });
|
||||
};
|
||||
const update = () => {
|
||||
clear();
|
||||
for (let i = trail.length - 1; i >= 0; i--) {
|
||||
const point = trail[i];
|
||||
const f = point.force * speed * (1 - point.age / maxAge);
|
||||
point.x += point.vx * f;
|
||||
point.y += point.vy * f;
|
||||
point.age++;
|
||||
if (point.age > maxAge) trail.splice(i, 1);
|
||||
}
|
||||
for (let i = 0; i < trail.length; i++) drawPoint(trail[i]);
|
||||
texture.needsUpdate = true;
|
||||
};
|
||||
return {
|
||||
canvas,
|
||||
texture,
|
||||
addTouch,
|
||||
update,
|
||||
set radiusScale(v) {
|
||||
radius = 0.1 * size * v;
|
||||
},
|
||||
get radiusScale() {
|
||||
return radius / (0.1 * size);
|
||||
},
|
||||
size
|
||||
} satisfies TouchTexture;
|
||||
};
|
||||
|
||||
const createLiquidEffect = (texture: THREE.Texture, opts?: LiquidOptions) => {
|
||||
const fragment = `
|
||||
uniform sampler2D uTexture;
|
||||
uniform float uStrength;
|
||||
uniform float uTime;
|
||||
uniform float uFreq;
|
||||
|
||||
void mainUv(inout vec2 uv) {
|
||||
vec4 tex = texture2D(uTexture, uv);
|
||||
float vx = tex.r * 2.0 - 1.0;
|
||||
float vy = tex.g * 2.0 - 1.0;
|
||||
float intensity = tex.b;
|
||||
|
||||
float wave = 0.5 + 0.5 * sin(uTime * uFreq + intensity * 6.2831853);
|
||||
|
||||
float amt = uStrength * intensity * wave;
|
||||
|
||||
uv += vec2(vx, vy) * amt;
|
||||
}
|
||||
`;
|
||||
const uniforms = new Map<string, THREE.Uniform>([
|
||||
['uTexture', new THREE.Uniform(texture)],
|
||||
['uStrength', new THREE.Uniform(opts?.strength ?? 0.025)],
|
||||
['uTime', new THREE.Uniform(0)],
|
||||
['uFreq', new THREE.Uniform(opts?.freq ?? 4.5)]
|
||||
]);
|
||||
return new Effect('LiquidEffect', fragment, { uniforms });
|
||||
};
|
||||
|
||||
const SHAPE_MAP = {
|
||||
square: 0,
|
||||
circle: 1,
|
||||
triangle: 2,
|
||||
diamond: 3
|
||||
};
|
||||
|
||||
const VERTEX_SRC = `
|
||||
void main() {
|
||||
gl_Position = vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const FRAGMENT_SRC = `
|
||||
precision highp float;
|
||||
|
||||
uniform vec3 uColor;
|
||||
uniform vec2 uResolution;
|
||||
uniform float uTime;
|
||||
uniform float uPixelSize;
|
||||
uniform float uScale;
|
||||
uniform float uDensity;
|
||||
uniform float uPixelJitter;
|
||||
uniform int uEnableRipples;
|
||||
uniform float uRippleSpeed;
|
||||
uniform float uRippleThickness;
|
||||
uniform float uRippleIntensity;
|
||||
uniform float uEdgeFade;
|
||||
|
||||
uniform int uShapeType;
|
||||
const int SHAPE_SQUARE = 0;
|
||||
const int SHAPE_CIRCLE = 1;
|
||||
const int SHAPE_TRIANGLE = 2;
|
||||
const int SHAPE_DIAMOND = 3;
|
||||
|
||||
const int MAX_CLICKS = 10;
|
||||
|
||||
uniform vec2 uClickPos [MAX_CLICKS];
|
||||
uniform float uClickTimes[MAX_CLICKS];
|
||||
|
||||
out vec4 fragColor;
|
||||
|
||||
float Bayer2(vec2 a) {
|
||||
a = floor(a);
|
||||
return fract(a.x / 2. + a.y * a.y * .75);
|
||||
}
|
||||
#define Bayer4(a) (Bayer2(.5*(a))*0.25 + Bayer2(a))
|
||||
#define Bayer8(a) (Bayer4(.5*(a))*0.25 + Bayer2(a))
|
||||
|
||||
#define FBM_OCTAVES 2
|
||||
#define FBM_LACUNARITY 1.25
|
||||
#define FBM_GAIN 1.0
|
||||
|
||||
float hash11(float n){ return fract(sin(n)*43758.5453); }
|
||||
|
||||
float vnoise(vec3 p){
|
||||
vec3 ip = floor(p);
|
||||
vec3 fp = fract(p);
|
||||
float n000 = hash11(dot(ip + vec3(0.0,0.0,0.0), vec3(1.0,57.0,113.0)));
|
||||
float n100 = hash11(dot(ip + vec3(1.0,0.0,0.0), vec3(1.0,57.0,113.0)));
|
||||
float n010 = hash11(dot(ip + vec3(0.0,1.0,0.0), vec3(1.0,57.0,113.0)));
|
||||
float n110 = hash11(dot(ip + vec3(1.0,1.0,0.0), vec3(1.0,57.0,113.0)));
|
||||
float n001 = hash11(dot(ip + vec3(0.0,0.0,1.0), vec3(1.0,57.0,113.0)));
|
||||
float n101 = hash11(dot(ip + vec3(1.0,0.0,1.0), vec3(1.0,57.0,113.0)));
|
||||
float n011 = hash11(dot(ip + vec3(0.0,1.0,1.0), vec3(1.0,57.0,113.0)));
|
||||
float n111 = hash11(dot(ip + vec3(1.0,1.0,1.0), vec3(1.0,57.0,113.0)));
|
||||
vec3 w = fp*fp*fp*(fp*(fp*6.0-15.0)+10.0);
|
||||
float x00 = mix(n000, n100, w.x);
|
||||
float x10 = mix(n010, n110, w.x);
|
||||
float x01 = mix(n001, n101, w.x);
|
||||
float x11 = mix(n011, n111, w.x);
|
||||
float y0 = mix(x00, x10, w.y);
|
||||
float y1 = mix(x01, x11, w.y);
|
||||
return mix(y0, y1, w.z) * 2.0 - 1.0;
|
||||
}
|
||||
|
||||
float fbm2(vec2 uv, float t){
|
||||
vec3 p = vec3(uv * uScale, t);
|
||||
float amp = 1.0;
|
||||
float freq = 1.0;
|
||||
float sum = 1.0;
|
||||
for (int i = 0; i < FBM_OCTAVES; ++i){
|
||||
sum += amp * vnoise(p * freq);
|
||||
freq *= FBM_LACUNARITY;
|
||||
amp *= FBM_GAIN;
|
||||
}
|
||||
return sum * 0.5 + 0.5;
|
||||
}
|
||||
|
||||
float maskCircle(vec2 p, float cov){
|
||||
float r = sqrt(cov) * .25;
|
||||
float d = length(p - 0.5) - r;
|
||||
float aa = 0.5 * fwidth(d);
|
||||
return cov * (1.0 - smoothstep(-aa, aa, d * 2.0));
|
||||
}
|
||||
|
||||
float maskTriangle(vec2 p, vec2 id, float cov){
|
||||
bool flip = mod(id.x + id.y, 2.0) > 0.5;
|
||||
if (flip) p.x = 1.0 - p.x;
|
||||
float r = sqrt(cov);
|
||||
float d = p.y - r*(1.0 - p.x);
|
||||
float aa = fwidth(d);
|
||||
return cov * clamp(0.5 - d/aa, 0.0, 1.0);
|
||||
}
|
||||
|
||||
float maskDiamond(vec2 p, float cov){
|
||||
float r = sqrt(cov) * 0.564;
|
||||
return step(abs(p.x - 0.49) + abs(p.y - 0.49), r);
|
||||
}
|
||||
|
||||
void main(){
|
||||
float pixelSize = uPixelSize;
|
||||
vec2 fragCoord = gl_FragCoord.xy - uResolution * .5;
|
||||
float aspectRatio = uResolution.x / uResolution.y;
|
||||
|
||||
vec2 pixelId = floor(fragCoord / pixelSize);
|
||||
vec2 pixelUV = fract(fragCoord / pixelSize);
|
||||
|
||||
float cellPixelSize = 8.0 * pixelSize;
|
||||
vec2 cellId = floor(fragCoord / cellPixelSize);
|
||||
vec2 cellCoord = cellId * cellPixelSize;
|
||||
vec2 uv = cellCoord / uResolution * vec2(aspectRatio, 1.0);
|
||||
|
||||
float base = fbm2(uv, uTime * 0.05);
|
||||
base = base * 0.5 - 0.65;
|
||||
|
||||
float feed = base + (uDensity - 0.5) * 0.3;
|
||||
|
||||
float speed = uRippleSpeed;
|
||||
float thickness = uRippleThickness;
|
||||
const float dampT = 1.0;
|
||||
const float dampR = 10.0;
|
||||
|
||||
if (uEnableRipples == 1) {
|
||||
for (int i = 0; i < MAX_CLICKS; ++i){
|
||||
vec2 pos = uClickPos[i];
|
||||
if (pos.x < 0.0) continue;
|
||||
float cellPixelSize = 8.0 * pixelSize;
|
||||
vec2 cuv = (((pos - uResolution * .5 - cellPixelSize * .5) / (uResolution))) * vec2(aspectRatio, 1.0);
|
||||
float t = max(uTime - uClickTimes[i], 0.0);
|
||||
float r = distance(uv, cuv);
|
||||
float waveR = speed * t;
|
||||
float ring = exp(-pow((r - waveR) / thickness, 2.0));
|
||||
float atten = exp(-dampT * t) * exp(-dampR * r);
|
||||
feed = max(feed, ring * atten * uRippleIntensity);
|
||||
}
|
||||
}
|
||||
|
||||
float bayer = Bayer8(fragCoord / uPixelSize) - 0.5;
|
||||
float bw = step(0.5, feed + bayer);
|
||||
|
||||
float h = fract(sin(dot(floor(fragCoord / uPixelSize), vec2(127.1, 311.7))) * 43758.5453);
|
||||
float jitterScale = 1.0 + (h - 0.5) * uPixelJitter;
|
||||
float coverage = bw * jitterScale;
|
||||
float M;
|
||||
if (uShapeType == SHAPE_CIRCLE) M = maskCircle (pixelUV, coverage);
|
||||
else if (uShapeType == SHAPE_TRIANGLE) M = maskTriangle(pixelUV, pixelId, coverage);
|
||||
else if (uShapeType == SHAPE_DIAMOND) M = maskDiamond(pixelUV, coverage);
|
||||
else M = coverage;
|
||||
|
||||
if (uEdgeFade > 0.0) {
|
||||
vec2 norm = gl_FragCoord.xy / uResolution;
|
||||
float edge = min(min(norm.x, norm.y), min(1.0 - norm.x, 1.0 - norm.y));
|
||||
float fade = smoothstep(0.0, uEdgeFade, edge);
|
||||
M *= fade;
|
||||
}
|
||||
|
||||
vec3 color = uColor;
|
||||
|
||||
// sRGB gamma correction - convert linear to sRGB for accurate color output
|
||||
vec3 srgbColor = mix(
|
||||
color * 12.92,
|
||||
1.055 * pow(color, vec3(1.0 / 2.4)) - 0.055,
|
||||
step(0.0031308, color)
|
||||
);
|
||||
|
||||
fragColor = vec4(srgbColor, M);
|
||||
}
|
||||
`;
|
||||
|
||||
const MAX_CLICKS = 10;
|
||||
|
||||
interface PixelBlastProps {
|
||||
variant?: string;
|
||||
pixelSize?: number;
|
||||
color?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
antialias?: boolean;
|
||||
patternScale?: number;
|
||||
patternDensity?: number;
|
||||
liquid?: boolean;
|
||||
liquidStrength?: number;
|
||||
liquidRadius?: number;
|
||||
pixelSizeJitter?: number;
|
||||
enableRipples?: boolean;
|
||||
rippleIntensityScale?: number;
|
||||
rippleThickness?: number;
|
||||
rippleSpeed?: number;
|
||||
liquidWobbleSpeed?: number;
|
||||
autoPauseOffscreen?: boolean;
|
||||
speed?: number;
|
||||
transparent?: boolean;
|
||||
edgeFade?: number;
|
||||
noiseAmount?: number;
|
||||
respectReducedMotion?: boolean;
|
||||
maxPixelRatio?: number;
|
||||
onFirstFrame?: () => void;
|
||||
}
|
||||
|
||||
const PixelBlast = ({
|
||||
variant = 'square',
|
||||
pixelSize = 3,
|
||||
color = '#B19EEF',
|
||||
className,
|
||||
style,
|
||||
antialias = true,
|
||||
patternScale = 2,
|
||||
patternDensity = 1,
|
||||
liquid = false,
|
||||
liquidStrength = 0.1,
|
||||
liquidRadius = 1,
|
||||
pixelSizeJitter = 0,
|
||||
enableRipples = true,
|
||||
rippleIntensityScale = 1,
|
||||
rippleThickness = 0.1,
|
||||
rippleSpeed = 0.3,
|
||||
liquidWobbleSpeed = 4.5,
|
||||
autoPauseOffscreen = true,
|
||||
speed = 0.5,
|
||||
transparent = true,
|
||||
edgeFade = 0.5,
|
||||
noiseAmount = 0,
|
||||
respectReducedMotion = true,
|
||||
maxPixelRatio = 2,
|
||||
onFirstFrame
|
||||
}: PixelBlastProps) => {
|
||||
const containerRef = useRef(null);
|
||||
const visibilityRef = useRef({ visible: true });
|
||||
const speedRef = useRef(speed);
|
||||
const threeRef = useRef<PixelBlastState | null>(null);
|
||||
const prevConfigRef = useRef<ReinitConfig | null>(null);
|
||||
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
|
||||
|
||||
const onFirstFrameRef = useRef<PixelBlastProps['onFirstFrame']>(onFirstFrame);
|
||||
onFirstFrameRef.current = onFirstFrame;
|
||||
|
||||
const firstFrameFiredRef = useRef(false);
|
||||
|
||||
// Limit pixel ratio for performance (lower on mobile)
|
||||
const effectivePixelRatio = useMemo(() => {
|
||||
if (typeof window === 'undefined') return 1;
|
||||
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
if (isMobile) return Math.min(dpr, 1.5, maxPixelRatio);
|
||||
return Math.min(dpr, maxPixelRatio);
|
||||
}, [maxPixelRatio]);
|
||||
|
||||
// Check for prefers-reduced-motion
|
||||
useEffect(() => {
|
||||
if (!respectReducedMotion) return;
|
||||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
setPrefersReducedMotion(mq.matches);
|
||||
const handler = (e: MediaQueryListEvent) => setPrefersReducedMotion(e.matches);
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
}, [respectReducedMotion]);
|
||||
|
||||
// If WebGL rendering is disabled (e.g. reduced motion), still signal readiness so
|
||||
// callers don't wait forever.
|
||||
useEffect(() => {
|
||||
if (!prefersReducedMotion) return;
|
||||
if (firstFrameFiredRef.current) return;
|
||||
firstFrameFiredRef.current = true;
|
||||
onFirstFrameRef.current?.();
|
||||
}, [prefersReducedMotion]);
|
||||
|
||||
// Pause animation when page is not visible or element is offscreen
|
||||
useEffect(() => {
|
||||
if (!autoPauseOffscreen || prefersReducedMotion) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// IntersectionObserver for offscreen detection
|
||||
const io = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
visibilityRef.current.visible = entry.isIntersecting;
|
||||
},
|
||||
{ threshold: 0 }
|
||||
);
|
||||
io.observe(container);
|
||||
|
||||
// Page Visibility API
|
||||
const handleVisibility = () => {
|
||||
if (document.hidden) {
|
||||
visibilityRef.current.visible = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
|
||||
return () => {
|
||||
io.disconnect();
|
||||
document.removeEventListener('visibilitychange', handleVisibility);
|
||||
};
|
||||
}, [autoPauseOffscreen, prefersReducedMotion]);
|
||||
|
||||
// Main WebGL setup effect
|
||||
useEffect(() => {
|
||||
// Skip WebGL setup if user prefers reduced motion
|
||||
if (prefersReducedMotion) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
speedRef.current = speed;
|
||||
const needsReinitKeys = ['antialias', 'liquid', 'noiseAmount'];
|
||||
const cfg: ReinitConfig = { antialias, liquid, noiseAmount };
|
||||
let mustReinit = false;
|
||||
if (!threeRef.current) mustReinit = true;
|
||||
else if (prevConfigRef.current) {
|
||||
for (const k of needsReinitKeys) {
|
||||
if (prevConfigRef.current[k as keyof ReinitConfig] !== cfg[k as keyof ReinitConfig]) {
|
||||
mustReinit = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mustReinit) {
|
||||
if (threeRef.current) {
|
||||
const t = threeRef.current;
|
||||
t.resizeObserver?.disconnect();
|
||||
cancelAnimationFrame(t.raf);
|
||||
t.quad?.geometry.dispose();
|
||||
t.material.dispose();
|
||||
t.composer?.dispose();
|
||||
t.renderer.dispose();
|
||||
if (t.renderer.domElement.parentElement === container) (container as HTMLDivElement).removeChild(t.renderer.domElement);
|
||||
threeRef.current = null;
|
||||
}
|
||||
|
||||
let renderer: THREE.WebGLRenderer | null = null;
|
||||
let canvas: HTMLCanvasElement | null = null;
|
||||
|
||||
try {
|
||||
canvas = document.createElement('canvas');
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
canvas,
|
||||
antialias,
|
||||
alpha: true,
|
||||
powerPreference: 'high-performance'
|
||||
});
|
||||
renderer.domElement.style.width = '100%';
|
||||
renderer.domElement.style.height = '100%';
|
||||
renderer.setPixelRatio(effectivePixelRatio);
|
||||
(container as HTMLDivElement).appendChild(renderer.domElement);
|
||||
if (transparent) renderer.setClearAlpha(0);
|
||||
else renderer.setClearColor(0x000000, 1);
|
||||
const uniforms: PixelBlastUniforms = {
|
||||
uResolution: { value: new THREE.Vector2(0, 0) },
|
||||
uTime: { value: 0 },
|
||||
uColor: { value: new THREE.Color(color) },
|
||||
uClickPos: {
|
||||
value: Array.from({ length: MAX_CLICKS }, () => new THREE.Vector2(-1, -1))
|
||||
},
|
||||
uClickTimes: { value: new Float32Array(MAX_CLICKS) },
|
||||
uShapeType: { value: SHAPE_MAP[variant as keyof typeof SHAPE_MAP] ?? 0 },
|
||||
uPixelSize: { value: pixelSize * renderer.getPixelRatio() },
|
||||
uScale: { value: patternScale },
|
||||
uDensity: { value: patternDensity },
|
||||
uPixelJitter: { value: pixelSizeJitter },
|
||||
uEnableRipples: { value: enableRipples ? 1 : 0 },
|
||||
uRippleSpeed: { value: rippleSpeed },
|
||||
uRippleThickness: { value: rippleThickness },
|
||||
uRippleIntensity: { value: rippleIntensityScale },
|
||||
uEdgeFade: { value: edgeFade }
|
||||
};
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
||||
const material = new THREE.ShaderMaterial({
|
||||
vertexShader: VERTEX_SRC,
|
||||
fragmentShader: FRAGMENT_SRC,
|
||||
uniforms,
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
glslVersion: THREE.GLSL3
|
||||
});
|
||||
const quadGeom = new THREE.PlaneGeometry(2, 2);
|
||||
const quad = new THREE.Mesh(quadGeom, material);
|
||||
scene.add(quad);
|
||||
const clock = new THREE.Clock();
|
||||
const setSize = () => {
|
||||
if (!renderer) return;
|
||||
const w = (container as HTMLDivElement).clientWidth || 1;
|
||||
const h = (container as HTMLDivElement).clientHeight || 1;
|
||||
renderer.setSize(w, h, false);
|
||||
uniforms.uResolution.value.set(renderer.domElement.width, renderer.domElement.height);
|
||||
if (threeRef.current?.composer)
|
||||
threeRef.current.composer.setSize(renderer.domElement.width, renderer.domElement.height);
|
||||
uniforms.uPixelSize.value = pixelSize * renderer.getPixelRatio();
|
||||
};
|
||||
setSize();
|
||||
const ro = new ResizeObserver(setSize);
|
||||
ro.observe(container);
|
||||
// Fixed seed for deterministic animation (no random)
|
||||
const timeOffset = 42;
|
||||
let composer: EffectComposer | undefined;
|
||||
let touch: ReturnType<typeof createTouchTexture> | undefined;
|
||||
let liquidEffect: Effect | undefined;
|
||||
if (liquid) {
|
||||
touch = createTouchTexture();
|
||||
touch.radiusScale = liquidRadius;
|
||||
composer = new EffectComposer(renderer);
|
||||
const renderPass = new RenderPass(scene, camera);
|
||||
liquidEffect = createLiquidEffect(touch.texture, {
|
||||
strength: liquidStrength,
|
||||
freq: liquidWobbleSpeed
|
||||
});
|
||||
const effectPass = new EffectPass(camera, liquidEffect);
|
||||
effectPass.renderToScreen = true;
|
||||
composer.addPass(renderPass);
|
||||
composer.addPass(effectPass);
|
||||
}
|
||||
if (noiseAmount > 0) {
|
||||
if (!composer) {
|
||||
composer = new EffectComposer(renderer);
|
||||
composer.addPass(new RenderPass(scene, camera));
|
||||
}
|
||||
const noiseEffect = new Effect(
|
||||
'NoiseEffect',
|
||||
`uniform float uTime; uniform float uAmount; float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7))) * 43758.5453);} void mainUv(inout vec2 uv){} void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor){ float n=hash(floor(uv*vec2(1920.0,1080.0))+floor(uTime*60.0)); float g=(n-0.5)*uAmount; outputColor=inputColor+vec4(vec3(g),0.0);} `,
|
||||
{
|
||||
uniforms: new Map([
|
||||
['uTime', new THREE.Uniform(0)],
|
||||
['uAmount', new THREE.Uniform(noiseAmount)]
|
||||
])
|
||||
}
|
||||
);
|
||||
const noisePass = new EffectPass(camera, noiseEffect);
|
||||
noisePass.renderToScreen = true;
|
||||
if (composer && composer.passes.length > 0) composer.passes.forEach(p => (p.renderToScreen = false));
|
||||
composer.addPass(noisePass);
|
||||
}
|
||||
if (composer && renderer) composer.setSize(renderer.domElement.width, renderer.domElement.height);
|
||||
const mapToPixels = (e: MouseEvent | PointerEvent) => {
|
||||
if (!renderer) return { fx: 0, fy: 0, w: 0, h: 0 };
|
||||
const rect = renderer.domElement.getBoundingClientRect();
|
||||
const scaleX = renderer.domElement.width / rect.width;
|
||||
const scaleY = renderer.domElement.height / rect.height;
|
||||
const fx = (e.clientX - rect.left) * scaleX;
|
||||
const fy = (rect.height - (e.clientY - rect.top)) * scaleY;
|
||||
return {
|
||||
fx,
|
||||
fy,
|
||||
w: renderer.domElement.width,
|
||||
h: renderer.domElement.height
|
||||
};
|
||||
};
|
||||
let lastRippleTime = 0;
|
||||
const rippleThrottle = 150; // ms between ripples
|
||||
const onPointerMove = (e: MouseEvent | PointerEvent) => {
|
||||
const { fx, fy, w, h } = mapToPixels(e);
|
||||
|
||||
// Trigger ripple on mouse move (throttled)
|
||||
const now = performance.now();
|
||||
if (now - lastRippleTime > rippleThrottle) {
|
||||
const ix = threeRef.current?.clickIx ?? 0;
|
||||
uniforms.uClickPos.value[ix].set(fx, fy);
|
||||
uniforms.uClickTimes.value[ix] = uniforms.uTime.value;
|
||||
if (threeRef.current) threeRef.current.clickIx = (ix + 1) % MAX_CLICKS;
|
||||
lastRippleTime = now;
|
||||
}
|
||||
|
||||
// Liquid touch effect
|
||||
if (touch) {
|
||||
touch.addTouch({ x: fx / w, y: fy / h });
|
||||
}
|
||||
};
|
||||
renderer.domElement.addEventListener('pointermove', onPointerMove, {
|
||||
passive: true
|
||||
});
|
||||
|
||||
// Store event handler for cleanup
|
||||
const domElement = renderer.domElement;
|
||||
let raf = 0;
|
||||
let lastFrameTime = 0;
|
||||
const targetDelta = 1000 / 10; // throttle to ~20fps
|
||||
const animate = (now?: number) => {
|
||||
const timeNow = now ?? performance.now();
|
||||
if (autoPauseOffscreen && !visibilityRef.current.visible) {
|
||||
raf = requestAnimationFrame(animate);
|
||||
if (threeRef.current) threeRef.current.raf = raf;
|
||||
return;
|
||||
}
|
||||
if (timeNow - lastFrameTime < targetDelta) {
|
||||
raf = requestAnimationFrame(animate);
|
||||
if (threeRef.current) threeRef.current.raf = raf;
|
||||
return;
|
||||
}
|
||||
lastFrameTime = timeNow;
|
||||
uniforms.uTime.value = timeOffset + clock.getElapsedTime() * speedRef.current;
|
||||
if (liquidEffect) liquidEffect.uniforms.get('uTime')!.value = uniforms.uTime.value;
|
||||
if (composer) {
|
||||
if (touch) touch.update();
|
||||
composer.passes.forEach(p => {
|
||||
if (!p || typeof p !== 'object') return;
|
||||
const effects = (p as { effects?: Effect[] }).effects;
|
||||
if (effects) {
|
||||
effects.forEach((eff) => {
|
||||
const u = eff.uniforms?.get('uTime');
|
||||
if (u) u.value = uniforms.uTime.value;
|
||||
});
|
||||
}
|
||||
});
|
||||
composer.render();
|
||||
} else if (renderer) renderer.render(scene, camera);
|
||||
|
||||
if (!firstFrameFiredRef.current) {
|
||||
firstFrameFiredRef.current = true;
|
||||
onFirstFrameRef.current?.();
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(animate);
|
||||
if (threeRef.current) threeRef.current.raf = raf;
|
||||
};
|
||||
raf = requestAnimationFrame(animate);
|
||||
threeRef.current = {
|
||||
renderer,
|
||||
scene,
|
||||
camera,
|
||||
material,
|
||||
clock,
|
||||
clickIx: 0,
|
||||
uniforms,
|
||||
resizeObserver: ro,
|
||||
raf,
|
||||
quad,
|
||||
timeOffset,
|
||||
composer,
|
||||
touch,
|
||||
liquidEffect,
|
||||
onPointerMove,
|
||||
domElement
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[PixelBlast] WebGL initialization failed', err);
|
||||
if (renderer) renderer.dispose();
|
||||
if (canvas && canvas.parentElement === container) {
|
||||
(container as HTMLDivElement).removeChild(canvas);
|
||||
}
|
||||
threeRef.current = null;
|
||||
|
||||
if (!firstFrameFiredRef.current) {
|
||||
firstFrameFiredRef.current = true;
|
||||
onFirstFrameRef.current?.();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const t = threeRef.current;
|
||||
if (!t) return;
|
||||
t.uniforms.uShapeType.value = SHAPE_MAP[variant as keyof typeof SHAPE_MAP] ?? 0;
|
||||
t.uniforms.uPixelSize.value = pixelSize * t.renderer.getPixelRatio();
|
||||
t.uniforms.uColor.value.set(color);
|
||||
t.uniforms.uScale.value = patternScale;
|
||||
t.uniforms.uDensity.value = patternDensity;
|
||||
t.uniforms.uPixelJitter.value = pixelSizeJitter;
|
||||
t.uniforms.uEnableRipples.value = enableRipples ? 1 : 0;
|
||||
t.uniforms.uRippleIntensity.value = rippleIntensityScale;
|
||||
t.uniforms.uRippleThickness.value = rippleThickness;
|
||||
t.uniforms.uRippleSpeed.value = rippleSpeed;
|
||||
t.uniforms.uEdgeFade.value = edgeFade;
|
||||
if (transparent) t.renderer.setClearAlpha(0);
|
||||
else t.renderer.setClearColor(0x000000, 1);
|
||||
if (t.liquidEffect) {
|
||||
const uStrength = t.liquidEffect.uniforms.get('uStrength');
|
||||
if (uStrength) uStrength.value = liquidStrength;
|
||||
const uFreq = t.liquidEffect.uniforms.get('uFreq');
|
||||
if (uFreq) uFreq.value = liquidWobbleSpeed;
|
||||
}
|
||||
if (t.touch) t.touch.radiusScale = liquidRadius;
|
||||
}
|
||||
prevConfigRef.current = cfg;
|
||||
return () => {
|
||||
if (!threeRef.current) return;
|
||||
const t = threeRef.current;
|
||||
|
||||
// Remove event listeners
|
||||
if (t.domElement && t.onPointerMove) {
|
||||
t.domElement.removeEventListener('pointermove', t.onPointerMove);
|
||||
}
|
||||
|
||||
t.resizeObserver?.disconnect();
|
||||
cancelAnimationFrame(t.raf);
|
||||
|
||||
// Dispose Three.js resources
|
||||
t.quad?.geometry.dispose();
|
||||
t.material.dispose();
|
||||
t.composer?.dispose();
|
||||
|
||||
// Dispose touch texture
|
||||
if (t.touch?.texture) {
|
||||
t.touch.texture.dispose();
|
||||
}
|
||||
|
||||
t.renderer.dispose();
|
||||
if (t.renderer.domElement.parentElement === container) {
|
||||
(container as HTMLDivElement).removeChild(t.renderer.domElement);
|
||||
}
|
||||
threeRef.current = null;
|
||||
};
|
||||
}, [
|
||||
antialias,
|
||||
liquid,
|
||||
noiseAmount,
|
||||
pixelSize,
|
||||
patternScale,
|
||||
patternDensity,
|
||||
enableRipples,
|
||||
rippleIntensityScale,
|
||||
rippleThickness,
|
||||
rippleSpeed,
|
||||
pixelSizeJitter,
|
||||
edgeFade,
|
||||
transparent,
|
||||
liquidStrength,
|
||||
liquidRadius,
|
||||
liquidWobbleSpeed,
|
||||
autoPauseOffscreen,
|
||||
variant,
|
||||
color,
|
||||
speed,
|
||||
prefersReducedMotion,
|
||||
effectivePixelRatio
|
||||
]);
|
||||
|
||||
// Render empty container if user prefers reduced motion
|
||||
if (prefersReducedMotion) {
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`pixel-blast-container ${className ?? ''}`}
|
||||
style={{ ...style, backgroundColor: 'transparent' }}
|
||||
aria-label="PixelBlast background (disabled for reduced motion)"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`pixel-blast-container ${className ?? ''}`}
|
||||
style={style}
|
||||
aria-label="PixelBlast interactive background"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PixelBlast;
|
||||
30
frontend/components/Shuffle.css
Normal file
30
frontend/components/Shuffle.css
Normal file
@@ -0,0 +1,30 @@
|
||||
.shuffle-parent {
|
||||
display: inline-block;
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
will-change: transform;
|
||||
line-height: 1.2;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.shuffle-parent.is-ready {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.shuffle-char-wrapper {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
vertical-align: baseline;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.shuffle-char-wrapper > span {
|
||||
display: inline-flex;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.shuffle-char {
|
||||
line-height: 1;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
}
|
||||
473
frontend/components/Shuffle.tsx
Normal file
473
frontend/components/Shuffle.tsx
Normal file
@@ -0,0 +1,473 @@
|
||||
import React, { useRef, useEffect, useState, useMemo, useImperativeHandle, forwardRef } from 'react';
|
||||
import { gsap } from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
import { SplitText as GSAPSplitText } from 'gsap/SplitText';
|
||||
import { useGSAP } from '@gsap/react';
|
||||
import './Shuffle.css';
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger, GSAPSplitText, useGSAP);
|
||||
|
||||
export interface ShuffleRef {
|
||||
play: () => void;
|
||||
}
|
||||
|
||||
interface ShuffleProps {
|
||||
text: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
shuffleDirection?: 'up' | 'down' | 'left' | 'right';
|
||||
duration?: number;
|
||||
maxDelay?: number;
|
||||
ease?: string;
|
||||
threshold?: number;
|
||||
rootMargin?: string;
|
||||
tag?: keyof React.JSX.IntrinsicElements;
|
||||
textAlign?: 'left' | 'center' | 'right';
|
||||
onShuffleComplete?: () => void;
|
||||
shuffleTimes?: number;
|
||||
animationMode?: 'evenodd' | 'random';
|
||||
loop?: boolean;
|
||||
loopDelay?: number;
|
||||
stagger?: number;
|
||||
scrambleCharset?: string;
|
||||
colorFrom?: string;
|
||||
colorTo?: string;
|
||||
triggerOnce?: boolean;
|
||||
respectReducedMotion?: boolean;
|
||||
triggerOnHover?: boolean;
|
||||
autoPlay?: boolean;
|
||||
}
|
||||
|
||||
const Shuffle = forwardRef<ShuffleRef, ShuffleProps>(({
|
||||
text,
|
||||
className = '',
|
||||
style = {},
|
||||
shuffleDirection = 'right',
|
||||
duration = 0.35,
|
||||
maxDelay = 0,
|
||||
ease = 'power3.out',
|
||||
threshold = 0.1,
|
||||
rootMargin = '-100px',
|
||||
tag = 'p',
|
||||
textAlign = 'center',
|
||||
onShuffleComplete,
|
||||
shuffleTimes = 1,
|
||||
animationMode = 'evenodd',
|
||||
loop = false,
|
||||
loopDelay = 0,
|
||||
stagger = 0.03,
|
||||
scrambleCharset = '',
|
||||
colorFrom,
|
||||
colorTo,
|
||||
triggerOnce = true,
|
||||
respectReducedMotion = true,
|
||||
triggerOnHover = true,
|
||||
autoPlay = true
|
||||
}, forwardedRef) => {
|
||||
const ref = useRef<HTMLElement | null>(null);
|
||||
const [fontsLoaded, setFontsLoaded] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
type SplitTextInstance = InstanceType<typeof GSAPSplitText>;
|
||||
const splitRef = useRef<SplitTextInstance | null>(null);
|
||||
const wrappersRef = useRef<HTMLSpanElement[]>([]);
|
||||
const tlRef = useRef<gsap.core.Timeline | null>(null);
|
||||
const playingRef = useRef(false);
|
||||
const hoverHandlerRef = useRef<((e: MouseEvent) => void) | null>(null);
|
||||
const playFnRef = useRef<(() => void) | null>(null);
|
||||
const buildFnRef = useRef<(() => void) | null>(null);
|
||||
const randomizeScramblesFnRef = useRef<(() => void) | null>(null);
|
||||
const isActiveRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
if ('fonts' in document) {
|
||||
if (document.fonts.status === 'loaded') setFontsLoaded(true);
|
||||
else document.fonts.ready.then(() => setFontsLoaded(true));
|
||||
} else setFontsLoaded(true);
|
||||
}, []);
|
||||
|
||||
const scrollTriggerStart = useMemo(() => {
|
||||
const startPct = (1 - threshold) * 100;
|
||||
const mm = /^(-?\d+(?:\.\d+)?)(px|em|rem|%)?$/.exec(rootMargin || '');
|
||||
const mv = mm ? parseFloat(mm[1]) : 0;
|
||||
const mu = mm ? mm[2] || 'px' : 'px';
|
||||
const sign = mv === 0 ? '' : mv < 0 ? `-=${Math.abs(mv)}${mu}` : `+=${mv}${mu}`;
|
||||
return `top ${startPct}%${sign}`;
|
||||
}, [threshold, rootMargin]);
|
||||
|
||||
useGSAP(
|
||||
() => {
|
||||
if (!ref.current || !text || !fontsLoaded) return;
|
||||
if (!ref.current.isConnected) return;
|
||||
isActiveRef.current = true;
|
||||
if (respectReducedMotion && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
setReady(true);
|
||||
onShuffleComplete?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const el = ref.current;
|
||||
|
||||
const start = scrollTriggerStart;
|
||||
|
||||
const removeHover = () => {
|
||||
if (hoverHandlerRef.current && ref.current) {
|
||||
ref.current.removeEventListener('mouseenter', hoverHandlerRef.current);
|
||||
hoverHandlerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const teardown = () => {
|
||||
if (tlRef.current) {
|
||||
tlRef.current.kill();
|
||||
tlRef.current = null;
|
||||
}
|
||||
if (wrappersRef.current.length) {
|
||||
wrappersRef.current.forEach(wrap => {
|
||||
const inner = wrap.firstElementChild;
|
||||
const orig = inner?.querySelector('[data-orig="1"]');
|
||||
if (orig && wrap.parentNode) wrap.parentNode.replaceChild(orig, wrap);
|
||||
});
|
||||
wrappersRef.current = [];
|
||||
}
|
||||
try {
|
||||
splitRef.current?.revert();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
splitRef.current = null;
|
||||
playingRef.current = false;
|
||||
};
|
||||
|
||||
const build = () => {
|
||||
if (!ref.current || !ref.current.isConnected || !isActiveRef.current) return;
|
||||
teardown();
|
||||
|
||||
try {
|
||||
splitRef.current = new GSAPSplitText(el, {
|
||||
type: 'chars',
|
||||
charsClass: 'shuffle-char',
|
||||
wordsClass: 'shuffle-word',
|
||||
linesClass: 'shuffle-line',
|
||||
smartWrap: true,
|
||||
reduceWhiteSpace: false
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const chars = (splitRef.current?.chars || []) as HTMLElement[];
|
||||
wrappersRef.current = [];
|
||||
|
||||
const rolls = Math.max(1, Math.floor(shuffleTimes));
|
||||
const rand = (set: string) => set.charAt(Math.floor(Math.random() * set.length)) || '';
|
||||
|
||||
chars.forEach((ch) => {
|
||||
const parent = ch.parentElement;
|
||||
if (!parent) return;
|
||||
|
||||
const w = ch.getBoundingClientRect().width;
|
||||
const h = ch.getBoundingClientRect().height;
|
||||
if (!w) return;
|
||||
|
||||
const wrap = document.createElement('span');
|
||||
Object.assign(wrap.style, {
|
||||
display: 'inline-block',
|
||||
overflow: 'hidden',
|
||||
width: w + 'px',
|
||||
height: shuffleDirection === 'up' || shuffleDirection === 'down' ? h + 'px' : 'auto',
|
||||
verticalAlign: 'bottom'
|
||||
});
|
||||
|
||||
const inner = document.createElement('span');
|
||||
Object.assign(inner.style, {
|
||||
display: 'inline-block',
|
||||
whiteSpace: shuffleDirection === 'up' || shuffleDirection === 'down' ? 'normal' : 'nowrap',
|
||||
willChange: 'transform'
|
||||
});
|
||||
|
||||
// Verify ch is still a child of parent before inserting
|
||||
try {
|
||||
if (!parent.contains(ch)) return;
|
||||
parent.insertBefore(wrap, ch);
|
||||
wrap.appendChild(inner);
|
||||
} catch {
|
||||
// Silently skip if DOM operation fails
|
||||
return;
|
||||
}
|
||||
|
||||
const firstOrig = ch.cloneNode(true) as HTMLElement;
|
||||
Object.assign(firstOrig.style, {
|
||||
display: shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block',
|
||||
width: w + 'px',
|
||||
textAlign: 'center'
|
||||
});
|
||||
|
||||
ch.setAttribute('data-orig', '1');
|
||||
Object.assign(ch.style, {
|
||||
display: shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block',
|
||||
width: w + 'px',
|
||||
textAlign: 'center'
|
||||
});
|
||||
|
||||
inner.appendChild(firstOrig);
|
||||
for (let k = 0; k < rolls; k++) {
|
||||
const c = ch.cloneNode(true) as HTMLElement;
|
||||
if (scrambleCharset) c.textContent = rand(scrambleCharset);
|
||||
Object.assign(c.style, {
|
||||
display: shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block',
|
||||
width: w + 'px',
|
||||
textAlign: 'center'
|
||||
});
|
||||
inner.appendChild(c);
|
||||
}
|
||||
inner.appendChild(ch);
|
||||
|
||||
const steps = rolls + 1;
|
||||
|
||||
if (shuffleDirection === 'right' || shuffleDirection === 'down') {
|
||||
try {
|
||||
const firstCopy = inner.firstElementChild;
|
||||
const real = inner.lastElementChild;
|
||||
// Only proceed if we have both elements and they're different
|
||||
if (real && firstCopy && real !== firstCopy && inner.contains(real) && inner.contains(firstCopy)) {
|
||||
inner.insertBefore(real, firstCopy);
|
||||
inner.appendChild(firstCopy);
|
||||
}
|
||||
} catch {
|
||||
// Silently skip if DOM operation fails
|
||||
}
|
||||
}
|
||||
|
||||
let startX = 0;
|
||||
let finalX = 0;
|
||||
let startY = 0;
|
||||
let finalY = 0;
|
||||
|
||||
if (shuffleDirection === 'right') {
|
||||
startX = -steps * w;
|
||||
finalX = 0;
|
||||
} else if (shuffleDirection === 'left') {
|
||||
startX = 0;
|
||||
finalX = -steps * w;
|
||||
} else if (shuffleDirection === 'down') {
|
||||
startY = -steps * h;
|
||||
finalY = 0;
|
||||
} else if (shuffleDirection === 'up') {
|
||||
startY = 0;
|
||||
finalY = -steps * h;
|
||||
}
|
||||
|
||||
if (shuffleDirection === 'left' || shuffleDirection === 'right') {
|
||||
gsap.set(inner, { x: startX, y: 0, force3D: true });
|
||||
inner.setAttribute('data-start-x', String(startX));
|
||||
inner.setAttribute('data-final-x', String(finalX));
|
||||
} else {
|
||||
gsap.set(inner, { x: 0, y: startY, force3D: true });
|
||||
inner.setAttribute('data-start-y', String(startY));
|
||||
inner.setAttribute('data-final-y', String(finalY));
|
||||
}
|
||||
|
||||
if (colorFrom) inner.style.color = colorFrom;
|
||||
wrappersRef.current.push(wrap);
|
||||
});
|
||||
};
|
||||
|
||||
const inners = () =>
|
||||
wrappersRef.current
|
||||
.map(w => w.firstElementChild)
|
||||
.filter((el): el is HTMLElement => !!el);
|
||||
|
||||
const randomizeScrambles = () => {
|
||||
if (!scrambleCharset) return;
|
||||
wrappersRef.current.forEach(w => {
|
||||
const strip = w.firstElementChild as HTMLElement | null;
|
||||
if (!strip) return;
|
||||
const kids = Array.from(strip.children) as HTMLElement[];
|
||||
for (let i = 1; i < kids.length - 1; i++) {
|
||||
kids[i].textContent = scrambleCharset.charAt(Math.floor(Math.random() * scrambleCharset.length));
|
||||
}
|
||||
});
|
||||
};
|
||||
randomizeScramblesFnRef.current = randomizeScrambles;
|
||||
|
||||
const cleanupToStill = () => {
|
||||
wrappersRef.current.forEach(w => {
|
||||
const strip = w.firstElementChild as HTMLElement | null;
|
||||
if (!strip) return;
|
||||
const real = strip.querySelector<HTMLElement>('[data-orig="1"]');
|
||||
if (!real) return;
|
||||
strip.replaceChildren(real);
|
||||
strip.style.transform = 'none';
|
||||
strip.style.willChange = 'auto';
|
||||
});
|
||||
};
|
||||
|
||||
const play = () => {
|
||||
const strips = inners();
|
||||
if (!strips.length) return;
|
||||
|
||||
playingRef.current = true;
|
||||
const isVertical = shuffleDirection === 'up' || shuffleDirection === 'down';
|
||||
|
||||
const tl = gsap.timeline({
|
||||
smoothChildTiming: true,
|
||||
repeat: loop ? -1 : 0,
|
||||
repeatDelay: loop ? loopDelay : 0,
|
||||
onRepeat: () => {
|
||||
if (scrambleCharset) randomizeScrambles();
|
||||
if (isVertical) {
|
||||
gsap.set(strips, { y: (_i, target) => parseFloat(target.getAttribute('data-start-y') || '0') });
|
||||
} else {
|
||||
gsap.set(strips, { x: (_i, target) => parseFloat(target.getAttribute('data-start-x') || '0') });
|
||||
}
|
||||
onShuffleComplete?.();
|
||||
},
|
||||
onComplete: () => {
|
||||
playingRef.current = false;
|
||||
if (!loop) {
|
||||
cleanupToStill();
|
||||
if (colorTo) gsap.set(strips, { color: colorTo });
|
||||
onShuffleComplete?.();
|
||||
armHover();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const addTween = (targets: gsap.TweenTarget, at: gsap.Position) => {
|
||||
const vars: gsap.TweenVars = {
|
||||
duration,
|
||||
ease,
|
||||
force3D: true,
|
||||
stagger: animationMode === 'evenodd' ? stagger : 0
|
||||
};
|
||||
if (isVertical) {
|
||||
vars.y = (_i: number, target: Element) => parseFloat(target.getAttribute('data-final-y') || '0');
|
||||
} else {
|
||||
vars.x = (_i: number, target: Element) => parseFloat(target.getAttribute('data-final-x') || '0');
|
||||
}
|
||||
|
||||
tl.to(targets, vars, at);
|
||||
|
||||
if (colorFrom && colorTo) {
|
||||
tl.to(targets, { color: colorTo, duration, ease }, at);
|
||||
}
|
||||
};
|
||||
|
||||
if (animationMode === 'evenodd') {
|
||||
const odd = strips.filter((_, i) => i % 2 === 1);
|
||||
const even = strips.filter((_, i) => i % 2 === 0);
|
||||
const oddTotal = duration + Math.max(0, odd.length - 1) * stagger;
|
||||
const evenStart = odd.length ? oddTotal * 0.7 : 0;
|
||||
if (odd.length) addTween(odd, 0);
|
||||
if (even.length) addTween(even, evenStart);
|
||||
} else {
|
||||
strips.forEach(strip => {
|
||||
const d = Math.random() * maxDelay;
|
||||
const vars: gsap.TweenVars = {
|
||||
duration,
|
||||
ease,
|
||||
force3D: true
|
||||
};
|
||||
if (isVertical) {
|
||||
vars.y = parseFloat(strip.getAttribute('data-final-y') || '0');
|
||||
} else {
|
||||
vars.x = parseFloat(strip.getAttribute('data-final-x') || '0');
|
||||
}
|
||||
tl.to(strip, vars, d);
|
||||
if (colorFrom && colorTo) tl.fromTo(strip, { color: colorFrom }, { color: colorTo, duration, ease }, d);
|
||||
});
|
||||
}
|
||||
|
||||
tlRef.current = tl;
|
||||
};
|
||||
playFnRef.current = play;
|
||||
buildFnRef.current = build;
|
||||
|
||||
const armHover = () => {
|
||||
if (!triggerOnHover || !ref.current) return;
|
||||
removeHover();
|
||||
const handler = () => {
|
||||
if (playingRef.current) return;
|
||||
build();
|
||||
if (scrambleCharset) randomizeScrambles();
|
||||
play();
|
||||
};
|
||||
hoverHandlerRef.current = handler;
|
||||
ref.current.addEventListener('mouseenter', handler);
|
||||
};
|
||||
|
||||
const create = () => {
|
||||
if (!ref.current || !ref.current.isConnected || !isActiveRef.current) return;
|
||||
build();
|
||||
if (scrambleCharset) randomizeScrambles();
|
||||
if (autoPlay) {
|
||||
play();
|
||||
}
|
||||
armHover();
|
||||
setReady(true);
|
||||
};
|
||||
|
||||
const st = ScrollTrigger.create({
|
||||
trigger: el,
|
||||
start,
|
||||
once: triggerOnce,
|
||||
onEnter: create
|
||||
});
|
||||
|
||||
return () => {
|
||||
isActiveRef.current = false;
|
||||
st.kill();
|
||||
removeHover();
|
||||
teardown();
|
||||
setReady(false);
|
||||
};
|
||||
},
|
||||
{
|
||||
dependencies: [
|
||||
text,
|
||||
duration,
|
||||
maxDelay,
|
||||
ease,
|
||||
scrollTriggerStart,
|
||||
fontsLoaded,
|
||||
shuffleDirection,
|
||||
shuffleTimes,
|
||||
animationMode,
|
||||
loop,
|
||||
loopDelay,
|
||||
stagger,
|
||||
scrambleCharset,
|
||||
colorFrom,
|
||||
colorTo,
|
||||
triggerOnce,
|
||||
respectReducedMotion,
|
||||
triggerOnHover,
|
||||
onShuffleComplete,
|
||||
autoPlay
|
||||
],
|
||||
scope: ref
|
||||
}
|
||||
);
|
||||
|
||||
// Expose play method via ref
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
play: () => {
|
||||
if (playingRef.current) return;
|
||||
buildFnRef.current?.();
|
||||
randomizeScramblesFnRef.current?.();
|
||||
playFnRef.current?.();
|
||||
}
|
||||
}), []);
|
||||
|
||||
const commonStyle = useMemo(() => ({ textAlign, ...style }), [textAlign, style]);
|
||||
|
||||
const classes = useMemo(() => `shuffle-parent ${ready ? 'is-ready' : ''} ${className}`, [ready, className]);
|
||||
|
||||
const Tag = tag || 'p';
|
||||
return React.createElement(Tag, { ref, className: classes, style: commonStyle }, text);
|
||||
});
|
||||
|
||||
Shuffle.displayName = 'Shuffle';
|
||||
|
||||
export default Shuffle;
|
||||
@@ -4,7 +4,6 @@ import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
IconRadar,
|
||||
IconRefresh,
|
||||
IconExternalLink,
|
||||
IconBrandGithub,
|
||||
@@ -59,6 +58,7 @@ export function AboutDialog({ children }: AboutDialogProps) {
|
||||
const currentVersion = updateResult?.currentVersion || versionData?.version || '-'
|
||||
const latestVersion = updateResult?.latestVersion
|
||||
const hasUpdate = updateResult?.hasUpdate
|
||||
const logoSrc = "/images/icon-64.png"
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
@@ -74,9 +74,10 @@ export function AboutDialog({ children }: AboutDialogProps) {
|
||||
{/* Logo and name */}
|
||||
<div className="flex flex-col items-center py-4">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10 mb-3">
|
||||
<IconRadar className="h-8 w-8 text-primary" />
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={logoSrc} alt="LunaFox Logo" className="h-12 w-12" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold">XingRin</h2>
|
||||
<h2 className="text-xl font-semibold">{t('productName')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('description')}</p>
|
||||
</div>
|
||||
|
||||
@@ -180,7 +181,7 @@ export function AboutDialog({ children }: AboutDialogProps) {
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
© 2025 XingRin · MIT License
|
||||
© 2026 {t('productName')} · GPL-3.0
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type MouseGravity = 'attract' | 'repel';
|
||||
type GlowAnimation = 'instant' | 'ease' | 'spring';
|
||||
type StarsInteractionType = 'bounce' | 'merge';
|
||||
|
||||
type GravityStarsProps = {
|
||||
starsCount?: number;
|
||||
starsSize?: number;
|
||||
starsOpacity?: number;
|
||||
glowIntensity?: number;
|
||||
glowAnimation?: GlowAnimation;
|
||||
movementSpeed?: number;
|
||||
mouseInfluence?: number;
|
||||
mouseGravity?: MouseGravity;
|
||||
gravityStrength?: number;
|
||||
starsInteraction?: boolean;
|
||||
starsInteractionType?: StarsInteractionType;
|
||||
} & React.ComponentProps<'div'>;
|
||||
|
||||
type Particle = {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
size: number;
|
||||
opacity: number;
|
||||
baseOpacity: number;
|
||||
mass: number;
|
||||
glowMultiplier?: number;
|
||||
glowVelocity?: number;
|
||||
};
|
||||
|
||||
function GravityStarsBackground({
|
||||
starsCount = 75,
|
||||
starsSize = 2,
|
||||
starsOpacity = 0.75,
|
||||
glowIntensity = 15,
|
||||
glowAnimation = 'ease',
|
||||
movementSpeed = 0.3,
|
||||
mouseInfluence = 100,
|
||||
mouseGravity = 'attract',
|
||||
gravityStrength = 75,
|
||||
starsInteraction = false,
|
||||
starsInteractionType = 'bounce',
|
||||
className,
|
||||
...props
|
||||
}: GravityStarsProps) {
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
|
||||
const animRef = React.useRef<number | null>(null);
|
||||
const starsRef = React.useRef<Particle[]>([]);
|
||||
const mouseRef = React.useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
const [dpr, setDpr] = React.useState(1);
|
||||
const [canvasSize, setCanvasSize] = React.useState({
|
||||
width: 800,
|
||||
height: 600,
|
||||
});
|
||||
|
||||
const readColor = React.useCallback(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return '#ffffff';
|
||||
const cs = getComputedStyle(el);
|
||||
return cs.color || '#ffffff';
|
||||
}, []);
|
||||
|
||||
const initStars = React.useCallback(
|
||||
(w: number, h: number) => {
|
||||
starsRef.current = Array.from({ length: starsCount }).map(() => {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const speed = movementSpeed * (0.5 + Math.random() * 0.5);
|
||||
return {
|
||||
x: Math.random() * w,
|
||||
y: Math.random() * h,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
size: Math.random() * starsSize + 1,
|
||||
opacity: starsOpacity,
|
||||
baseOpacity: starsOpacity,
|
||||
mass: Math.random() * 0.5 + 0.5,
|
||||
glowMultiplier: 1,
|
||||
glowVelocity: 0,
|
||||
};
|
||||
});
|
||||
},
|
||||
[starsCount, movementSpeed, starsOpacity, starsSize],
|
||||
);
|
||||
|
||||
const redistributeStars = React.useCallback((w: number, h: number) => {
|
||||
starsRef.current.forEach((p) => {
|
||||
p.x = Math.random() * w;
|
||||
p.y = Math.random() * h;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const resizeCanvas = React.useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const nextDpr = Math.max(1, Math.min(window.devicePixelRatio || 1, 2));
|
||||
setDpr(nextDpr);
|
||||
canvas.width = Math.max(1, Math.floor(rect.width * nextDpr));
|
||||
canvas.height = Math.max(1, Math.floor(rect.height * nextDpr));
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${rect.height}px`;
|
||||
setCanvasSize({ width: rect.width, height: rect.height });
|
||||
if (starsRef.current.length === 0) {
|
||||
initStars(rect.width, rect.height);
|
||||
} else {
|
||||
redistributeStars(rect.width, rect.height);
|
||||
}
|
||||
}, [initStars, redistributeStars]);
|
||||
|
||||
const handlePointerMove = React.useCallback(
|
||||
(e: React.MouseEvent | React.TouchEvent) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
let clientX = 0;
|
||||
let clientY = 0;
|
||||
if ('touches' in e) {
|
||||
const t = e.touches[0];
|
||||
if (!t) return;
|
||||
clientX = t.clientX;
|
||||
clientY = t.clientY;
|
||||
} else {
|
||||
clientX = e.clientX;
|
||||
clientY = e.clientY;
|
||||
}
|
||||
mouseRef.current = { x: clientX - rect.left, y: clientY - rect.top };
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const updateStars = React.useCallback(() => {
|
||||
const w = canvasSize.width;
|
||||
const h = canvasSize.height;
|
||||
const mouse = mouseRef.current;
|
||||
|
||||
for (let i = 0; i < starsRef.current.length; i++) {
|
||||
const p = starsRef.current[i];
|
||||
|
||||
const dx = mouse.x - p.x;
|
||||
const dy = mouse.y - p.y;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
|
||||
if (dist < mouseInfluence && dist > 0) {
|
||||
const force = (mouseInfluence - dist) / mouseInfluence;
|
||||
const nx = dx / dist;
|
||||
const ny = dy / dist;
|
||||
const g = force * (gravityStrength * 0.001);
|
||||
|
||||
if (mouseGravity === 'attract') {
|
||||
p.vx += nx * g;
|
||||
p.vy += ny * g;
|
||||
} else if (mouseGravity === 'repel') {
|
||||
p.vx -= nx * g;
|
||||
p.vy -= ny * g;
|
||||
}
|
||||
|
||||
p.opacity = Math.min(1, p.baseOpacity + force * 0.4);
|
||||
|
||||
const targetGlow = 1 + force * 2;
|
||||
const currentGlow = p.glowMultiplier || 1;
|
||||
|
||||
if (glowAnimation === 'instant') {
|
||||
p.glowMultiplier = targetGlow;
|
||||
} else if (glowAnimation === 'ease') {
|
||||
const ease = 0.15;
|
||||
p.glowMultiplier = currentGlow + (targetGlow - currentGlow) * ease;
|
||||
} else {
|
||||
const spring = (targetGlow - currentGlow) * 0.2;
|
||||
const damping = 0.85;
|
||||
p.glowVelocity = (p.glowVelocity || 0) * damping + spring;
|
||||
p.glowMultiplier = currentGlow + (p.glowVelocity || 0);
|
||||
}
|
||||
} else {
|
||||
p.opacity = Math.max(p.baseOpacity * 0.3, p.opacity - 0.02);
|
||||
const targetGlow = 1;
|
||||
const currentGlow = p.glowMultiplier || 1;
|
||||
if (glowAnimation === 'instant') {
|
||||
p.glowMultiplier = targetGlow;
|
||||
} else if (glowAnimation === 'ease') {
|
||||
const ease = 0.08;
|
||||
p.glowMultiplier = Math.max(
|
||||
1,
|
||||
currentGlow + (targetGlow - currentGlow) * ease,
|
||||
);
|
||||
} else {
|
||||
const spring = (targetGlow - currentGlow) * 0.15;
|
||||
const damping = 0.9;
|
||||
p.glowVelocity = (p.glowVelocity || 0) * damping + spring;
|
||||
p.glowMultiplier = Math.max(1, currentGlow + (p.glowVelocity || 0));
|
||||
}
|
||||
}
|
||||
|
||||
if (starsInteraction) {
|
||||
for (let j = i + 1; j < starsRef.current.length; j++) {
|
||||
const o = starsRef.current[j];
|
||||
const dx2 = o.x - p.x;
|
||||
const dy2 = o.y - p.y;
|
||||
const d = Math.hypot(dx2, dy2);
|
||||
const minD = p.size + o.size + 5;
|
||||
if (d < minD && d > 0) {
|
||||
if (starsInteractionType === 'bounce') {
|
||||
const nx = dx2 / d;
|
||||
const ny = dy2 / d;
|
||||
const rvx = p.vx - o.vx;
|
||||
const rvy = p.vy - o.vy;
|
||||
const speed = rvx * nx + rvy * ny;
|
||||
if (speed < 0) continue;
|
||||
const impulse = (2 * speed) / (p.mass + o.mass);
|
||||
p.vx -= impulse * o.mass * nx;
|
||||
p.vy -= impulse * o.mass * ny;
|
||||
o.vx += impulse * p.mass * nx;
|
||||
o.vy += impulse * p.mass * ny;
|
||||
const overlap = minD - d;
|
||||
const sx = nx * overlap * 0.5;
|
||||
const sy = ny * overlap * 0.5;
|
||||
p.x -= sx;
|
||||
p.y -= sy;
|
||||
o.x += sx;
|
||||
o.y += sy;
|
||||
} else {
|
||||
const mergeForce = (minD - d) / minD;
|
||||
p.glowMultiplier = (p.glowMultiplier || 1) + mergeForce * 0.5;
|
||||
o.glowMultiplier = (o.glowMultiplier || 1) + mergeForce * 0.5;
|
||||
const af = mergeForce * 0.01;
|
||||
p.vx += dx2 * af;
|
||||
p.vy += dy2 * af;
|
||||
o.vx -= dx2 * af;
|
||||
o.vy -= dy2 * af;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
|
||||
p.vx += (Math.random() - 0.5) * 0.001;
|
||||
p.vy += (Math.random() - 0.5) * 0.001;
|
||||
|
||||
p.vx *= 0.999;
|
||||
p.vy *= 0.999;
|
||||
|
||||
if (p.x < 0) p.x = w;
|
||||
if (p.x > w) p.x = 0;
|
||||
if (p.y < 0) p.y = h;
|
||||
if (p.y > h) p.y = 0;
|
||||
}
|
||||
}, [
|
||||
canvasSize.width,
|
||||
canvasSize.height,
|
||||
mouseInfluence,
|
||||
mouseGravity,
|
||||
gravityStrength,
|
||||
glowAnimation,
|
||||
starsInteraction,
|
||||
starsInteractionType,
|
||||
]);
|
||||
|
||||
const drawStars = React.useCallback(
|
||||
(ctx: CanvasRenderingContext2D) => {
|
||||
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
const color = readColor();
|
||||
for (const p of starsRef.current) {
|
||||
ctx.save();
|
||||
ctx.shadowColor = color;
|
||||
ctx.shadowBlur = glowIntensity * (p.glowMultiplier || 1) * 2;
|
||||
ctx.globalAlpha = p.opacity;
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x * dpr, p.y * dpr, p.size * dpr, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
},
|
||||
[dpr, glowIntensity, readColor],
|
||||
);
|
||||
|
||||
const animate = React.useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
updateStars();
|
||||
drawStars(ctx);
|
||||
animRef.current = requestAnimationFrame(animate);
|
||||
}, [updateStars, drawStars]);
|
||||
|
||||
React.useEffect(() => {
|
||||
resizeCanvas();
|
||||
const container = containerRef.current;
|
||||
const ro =
|
||||
typeof ResizeObserver !== 'undefined'
|
||||
? new ResizeObserver(resizeCanvas)
|
||||
: null;
|
||||
if (container && ro) ro.observe(container);
|
||||
const onResize = () => resizeCanvas();
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
if (ro && container) ro.disconnect();
|
||||
};
|
||||
}, [resizeCanvas]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (starsRef.current.length === 0) {
|
||||
initStars(canvasSize.width, canvasSize.height);
|
||||
} else {
|
||||
starsRef.current.forEach((p) => {
|
||||
p.baseOpacity = starsOpacity;
|
||||
p.opacity = starsOpacity;
|
||||
const spd = Math.hypot(p.vx, p.vy);
|
||||
if (spd > 0) {
|
||||
const ratio = movementSpeed / spd;
|
||||
p.vx *= ratio;
|
||||
p.vy *= ratio;
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [
|
||||
starsCount,
|
||||
starsOpacity,
|
||||
movementSpeed,
|
||||
canvasSize.width,
|
||||
canvasSize.height,
|
||||
initStars,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (animRef.current) cancelAnimationFrame(animRef.current);
|
||||
animRef.current = requestAnimationFrame(animate);
|
||||
return () => {
|
||||
if (animRef.current) cancelAnimationFrame(animRef.current);
|
||||
animRef.current = null;
|
||||
};
|
||||
}, [animate]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-slot="gravity-stars-background"
|
||||
className={cn('relative size-full overflow-hidden', className)}
|
||||
onMouseMove={(e) => handlePointerMove(e)}
|
||||
onTouchMove={(e) => handlePointerMove(e)}
|
||||
{...props}
|
||||
>
|
||||
<canvas ref={canvasRef} className="block w-full h-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { GravityStarsBackground, type GravityStarsProps };
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client" // Mark as client component, can use browser APIs and interactive features
|
||||
|
||||
// Import React library
|
||||
import type * as React from "react"
|
||||
import React from "react"
|
||||
// Import various icons from Tabler Icons library
|
||||
import {
|
||||
IconDashboard, // Dashboard icon
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
IconRadar, // Radar scan icon
|
||||
IconTool, // Tool icon
|
||||
IconServer, // Server icon
|
||||
IconDatabase, // Database icon
|
||||
IconTerminal2, // Terminal icon
|
||||
IconBug, // Vulnerability icon
|
||||
IconSearch, // Search icon
|
||||
@@ -63,12 +64,19 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const pathname = usePathname()
|
||||
const normalize = (p: string) => (p !== "/" && p.endsWith("/") ? p.slice(0, -1) : p)
|
||||
const current = normalize(pathname)
|
||||
const handleNavClick = React.useCallback(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new Event("lunafox:route-progress-start"))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const logoSrc = "/images/icon-64.png"
|
||||
|
||||
// User information
|
||||
const user = {
|
||||
name: "admin",
|
||||
email: "admin@admin.com",
|
||||
avatar: "",
|
||||
avatar: logoSrc,
|
||||
}
|
||||
|
||||
// Main navigation menu items - using translations
|
||||
@@ -150,6 +158,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
url: "/settings/system-logs/",
|
||||
icon: IconTerminal2,
|
||||
},
|
||||
{
|
||||
name: t('databaseHealth'),
|
||||
url: "/settings/database-health/",
|
||||
icon: IconDatabase,
|
||||
},
|
||||
{
|
||||
name: t('notifications'),
|
||||
url: "/settings/notifications/",
|
||||
@@ -178,9 +191,10 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
asChild
|
||||
className="data-[slot=sidebar-menu-button]:!p-1.5"
|
||||
>
|
||||
<Link href="/">
|
||||
<IconRadar className="!size-5" />
|
||||
<span className="text-base font-semibold">XingRin</span>
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={logoSrc} alt="Logo" className="!size-5" />
|
||||
<span className="text-base font-semibold">{t('appName')}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
@@ -203,7 +217,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
return (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild isActive={isActive}>
|
||||
<Link href={item.url}>
|
||||
<Link href={item.url} onClick={handleNavClick}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
@@ -237,7 +251,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
asChild
|
||||
isActive={isSubActive}
|
||||
>
|
||||
<Link href={subItem.url}>
|
||||
<Link href={subItem.url} onClick={handleNavClick}>
|
||||
<span>{subItem.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React from "react"
|
||||
import { useRouter, usePathname } from "next/navigation"
|
||||
import { useLocale } from "next-intl"
|
||||
import { useAuth } from "@/hooks/use-auth"
|
||||
import { LoadingState } from "@/components/loading-spinner"
|
||||
|
||||
@@ -22,10 +23,12 @@ export function AuthGuard({ children }: AuthGuardProps) {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const { data: auth, isLoading } = useAuth()
|
||||
const locale = useLocale()
|
||||
|
||||
// Check if it's a public route
|
||||
const pathWithoutLocale = pathname.replace(/^\/[a-z]{2}(?=\/|$)/, '')
|
||||
const isPublicRoute = PUBLIC_ROUTES.some((route) =>
|
||||
pathname.startsWith(route)
|
||||
pathWithoutLocale === route || pathWithoutLocale.startsWith(`${route}/`)
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -36,9 +39,11 @@ export function AuthGuard({ children }: AuthGuardProps) {
|
||||
|
||||
// Redirect to login page if not authenticated
|
||||
if (!auth?.authenticated) {
|
||||
router.push("/login/")
|
||||
const normalized = "/login/"
|
||||
const loginPath = `/${locale}${normalized}`
|
||||
router.push(loginPath)
|
||||
}
|
||||
}, [auth, isLoading, isPublicRoute, router])
|
||||
}, [auth, isLoading, isPublicRoute, router, locale])
|
||||
|
||||
// Skip auth mode
|
||||
if (SKIP_AUTH) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React from "react"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useLocale, useTranslations } from "next-intl"
|
||||
import { AppSidebar } from "@/components/app-sidebar"
|
||||
import { SiteHeader } from "@/components/site-header"
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||
@@ -40,6 +40,7 @@ export function AuthLayout({ children }: AuthLayoutProps) {
|
||||
const router = useRouter()
|
||||
const { data: auth, isLoading } = useAuth()
|
||||
const tCommon = useTranslations("common")
|
||||
const locale = useLocale()
|
||||
|
||||
// Check if it's a public route (login page)
|
||||
const isPublicRoute = isPublicPath(pathname)
|
||||
@@ -47,9 +48,11 @@ export function AuthLayout({ children }: AuthLayoutProps) {
|
||||
// Redirect to login page if not authenticated (useEffect must be before all conditional returns)
|
||||
React.useEffect(() => {
|
||||
if (!isLoading && !auth?.authenticated && !isPublicRoute) {
|
||||
router.push("/login/")
|
||||
const normalized = "/login/"
|
||||
const loginPath = `/${locale}${normalized}`
|
||||
router.push(loginPath)
|
||||
}
|
||||
}, [auth, isLoading, isPublicRoute, router])
|
||||
}, [auth, isLoading, isPublicRoute, router, locale])
|
||||
|
||||
// If it's login page, render content directly (without sidebar)
|
||||
if (isPublicRoute) {
|
||||
@@ -61,33 +64,37 @@ export function AuthLayout({ children }: AuthLayoutProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// Loading or not authenticated
|
||||
if (isLoading || !auth?.authenticated) {
|
||||
return <LoadingState message="loading..." />
|
||||
}
|
||||
const showLoading = isLoading || !auth?.authenticated
|
||||
const canRenderApp = !isLoading && !!auth?.authenticated
|
||||
|
||||
// Authenticated - show full layout (with sidebar)
|
||||
return (
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": "calc(var(--spacing) * 70)",
|
||||
"--header-height": "calc(var(--spacing) * 11)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<AppSidebar />
|
||||
<SidebarInset className="flex min-h-0 flex-col h-svh">
|
||||
<SiteHeader />
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="@container/main flex-1 min-h-0 flex flex-col gap-2">
|
||||
<Suspense fallback={<LoadingState message={tCommon("status.pageLoading")} />}>
|
||||
{children}
|
||||
</Suspense>
|
||||
<Toaster />
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
<>
|
||||
<LoadingState active={showLoading} message="loading..." />
|
||||
{canRenderApp ? (
|
||||
<SidebarProvider
|
||||
className="animate-app-fade-in"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": "calc(var(--spacing) * 62)",
|
||||
"--header-height": "calc(var(--spacing) * 11)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<AppSidebar />
|
||||
<SidebarInset className="flex min-h-0 flex-col h-svh">
|
||||
<SiteHeader />
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="@container/main flex-1 min-h-0 flex flex-col gap-2">
|
||||
<Suspense fallback={<LoadingState message={tCommon("status.pageLoading")} />}>
|
||||
{children}
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
) : null}
|
||||
<Toaster />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,8 +40,8 @@ export function ChangePasswordDialog({ open, onOpenChange }: ChangePasswordDialo
|
||||
return
|
||||
}
|
||||
|
||||
if (newPassword.length < 4) {
|
||||
setError(t("passwordTooShort", { min: 4 }))
|
||||
if (newPassword.length < 6) {
|
||||
setError(t("passwordTooShort", { min: 6 }))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
209
frontend/components/auth/login-boot-screen.tsx
Normal file
209
frontend/components/auth/login-boot-screen.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import Image from "next/image"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const STATUS_MESSAGES = [
|
||||
"Initializing security modules...",
|
||||
"Loading vulnerability database...",
|
||||
"Connecting to scan engine...",
|
||||
"Preparing templates...",
|
||||
"Almost ready...",
|
||||
]
|
||||
|
||||
export function LoginBootScreen({ className }: { className?: string; success?: boolean }) {
|
||||
const [statusIndex, setStatusIndex] = React.useState(0)
|
||||
const [statusVisible, setStatusVisible] = React.useState(true)
|
||||
const logoSrc = "/images/icon-256.png"
|
||||
|
||||
// Status text rotation
|
||||
React.useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setStatusVisible(false)
|
||||
setTimeout(() => {
|
||||
setStatusIndex((prev) => (prev + 1) % STATUS_MESSAGES.length)
|
||||
setStatusVisible(true)
|
||||
}, 200)
|
||||
}, 3000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={cn("relative flex min-h-svh flex-col bg-[#0a0a0f] overflow-hidden", className)}>
|
||||
{/* Animated Gradient Background */}
|
||||
<div className="fixed inset-0 z-0 overflow-hidden">
|
||||
<div
|
||||
className="absolute top-[-50%] left-[-50%] w-[200%] h-[200%] opacity-30 animate-blob"
|
||||
style={{
|
||||
background: "conic-gradient(from 0deg at 50% 50%, #0a0a0f 0deg, #3f3f46 60deg, #0a0a0f 120deg, #52525b 180deg, #0a0a0f 240deg, #3f3f46 300deg, #0a0a0f 360deg)",
|
||||
filter: "blur(80px)",
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[#0a0a0f]/80" /> {/* Overlay to darken */}
|
||||
</div>
|
||||
|
||||
{/* Background grid */}
|
||||
<div
|
||||
className="fixed inset-0 z-0 opacity-40"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(255, 255, 255, 0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.1) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: "50px 50px",
|
||||
maskImage: "radial-gradient(circle at center, black, transparent 80%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="relative z-10 flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
{/* Logo container */}
|
||||
<div className="relative w-[200px] h-[200px] mx-auto mb-10">
|
||||
{/* Spinning rings */}
|
||||
<div className="logo-spinner" />
|
||||
{/* Logo image */}
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt="LunaFox Logo"
|
||||
width={120}
|
||||
height={120}
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10"
|
||||
unoptimized
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-[32px] font-bold tracking-tight mb-2">
|
||||
<span className="bg-gradient-to-br from-[#d4d4d8] to-[#f4f4f5] bg-clip-text text-transparent">Luna</span>
|
||||
<span className="bg-gradient-to-br from-[#a1a1aa] to-[#e4e4e7] bg-clip-text text-transparent">Fox</span>
|
||||
</h1>
|
||||
|
||||
{/* Loading status */}
|
||||
<div className="flex items-center justify-center gap-3 mt-6">
|
||||
<div className="status-spinner" />
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm text-gray-500 font-medium transition-opacity duration-200",
|
||||
statusVisible ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
>
|
||||
{STATUS_MESSAGES[statusIndex]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="w-60 h-1 bg-[rgba(255,255,255,0.1)] rounded mx-auto mt-6 overflow-hidden">
|
||||
<div className="progress-fill h-full rounded" />
|
||||
</div>
|
||||
|
||||
{/* Dots */}
|
||||
<div className="flex justify-center gap-1.5 mt-8">
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="dot-pulse"
|
||||
style={{ animationDelay: `${i * 0.2}s` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Styles */}
|
||||
<style jsx>{`
|
||||
.logo-spinner {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
border: 4px solid transparent;
|
||||
border-top-color: rgba(255, 255, 255, 0.8);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.logo-spinner::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: -4px;
|
||||
right: -4px;
|
||||
bottom: -4px;
|
||||
border-radius: 50%;
|
||||
border: 4px solid transparent;
|
||||
border-top-color: rgba(200, 200, 200, 0.6);
|
||||
animation: spin 1.5s linear infinite reverse;
|
||||
}
|
||||
|
||||
.logo-spinner::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
border-bottom-color: rgba(255, 255, 255, 0.3);
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
|
||||
.status-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
border-top-color: rgba(255, 255, 255, 0.8);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
background: linear-gradient(90deg, #a1a1aa, #f4f4f5);
|
||||
animation: progress 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.dot-pulse {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
animation: dot-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes progress {
|
||||
0% { width: 0%; }
|
||||
50% { width: 70%; }
|
||||
100% { width: 100%; }
|
||||
}
|
||||
|
||||
@keyframes dot-pulse {
|
||||
0%, 100% {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
transform: scale(1.3);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blob {
|
||||
0% { transform: translate(0, 0) rotate(0deg); }
|
||||
33% { transform: translate(2%, 2%) rotate(120deg); }
|
||||
66% { transform: translate(-2%, 2%) rotate(240deg); }
|
||||
100% { transform: translate(0, 0) rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
88
frontend/components/base-node.tsx
Normal file
88
frontend/components/base-node.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { ComponentProps } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function BaseNode({ className, ...props }: ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-card text-card-foreground relative rounded-md border",
|
||||
"hover:ring-1",
|
||||
// React Flow displays node elements inside of a `NodeWrapper` component,
|
||||
// which compiles down to a div with the class `react-flow__node`.
|
||||
// When a node is selected, the class `selected` is added to the
|
||||
// `react-flow__node` element. This allows us to style the node when it
|
||||
// is selected, using Tailwind's `&` selector.
|
||||
"[.react-flow\\_\\_node.selected_&]:border-muted-foreground",
|
||||
"[.react-flow\\_\\_node.selected_&]:shadow-lg",
|
||||
className
|
||||
)}
|
||||
tabIndex={0}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A container for a consistent header layout intended to be used inside the
|
||||
* `<BaseNode />` component.
|
||||
*/
|
||||
export function BaseNodeHeader({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<"header">) {
|
||||
return (
|
||||
<header
|
||||
{...props}
|
||||
className={cn(
|
||||
"mx-0 my-0 -mb-1 flex flex-row items-center justify-between gap-2 px-3 py-2",
|
||||
// Remove or modify these classes if you modify the padding in the
|
||||
// `<BaseNode />` component.
|
||||
className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The title text for the node. To maintain a native application feel, the title
|
||||
* text is not selectable.
|
||||
*/
|
||||
export function BaseNodeHeaderTitle({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<"h3">) {
|
||||
return (
|
||||
<h3
|
||||
data-slot="base-node-title"
|
||||
className={cn("user-select-none flex-1 font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function BaseNodeContent({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="base-node-content"
|
||||
className={cn("flex flex-col gap-y-2 p-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function BaseNodeFooter({ className, ...props }: ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="base-node-footer"
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-y-2 border-t px-3 pt-2 pb-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
35
frontend/components/color-theme-init.tsx
Normal file
35
frontend/components/color-theme-init.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/* eslint-disable @next/next/no-before-interactive-script-outside-document */
|
||||
import Script from "next/script"
|
||||
import {
|
||||
COLOR_THEMES,
|
||||
COLOR_THEME_COOKIE_KEY,
|
||||
DEFAULT_COLOR_THEME_ID,
|
||||
} from "@/lib/color-themes"
|
||||
|
||||
const themeIds = COLOR_THEMES.map((theme) => theme.id)
|
||||
const darkThemeIds = COLOR_THEMES.filter((theme) => theme.isDark).map((theme) => theme.id)
|
||||
|
||||
const themeInitScript = `(function(){try{
|
||||
var key=${JSON.stringify(COLOR_THEME_COOKIE_KEY)};
|
||||
var theme=null;
|
||||
var match=document.cookie.match(new RegExp('(?:^|; )'+key+'=([^;]*)'));
|
||||
if(match){theme=decodeURIComponent(match[1]);}
|
||||
if(!theme){theme=localStorage.getItem(key);}
|
||||
var valid=${JSON.stringify(themeIds)};
|
||||
if(valid.indexOf(theme)===-1){theme=${JSON.stringify(DEFAULT_COLOR_THEME_ID)};}
|
||||
try{
|
||||
document.cookie=key+'='+encodeURIComponent(theme)+'; Path=/; Max-Age=${60 * 60 * 24 * 365 * 2}; SameSite=Lax';
|
||||
}catch(e){}
|
||||
var dark=${JSON.stringify(darkThemeIds)};
|
||||
var root=document.documentElement;
|
||||
root.setAttribute('data-theme',theme);
|
||||
if(dark.indexOf(theme)!==-1){root.classList.add('dark');}else{root.classList.remove('dark');}
|
||||
}catch(e){}})();`
|
||||
|
||||
export function ColorThemeInit() {
|
||||
return (
|
||||
<Script id="color-theme-init" strategy="beforeInteractive">
|
||||
{themeInitScript}
|
||||
</Script>
|
||||
)
|
||||
}
|
||||
@@ -34,36 +34,6 @@ interface BulkAddUrlsDialogProps {
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
const ASSET_TYPE_LABELS: Record<AssetType, { title: string; description: string; placeholder: string }> = {
|
||||
endpoint: {
|
||||
title: 'Bulk Add Endpoints',
|
||||
description: 'Enter endpoint URL list, one per line.',
|
||||
placeholder: `Please enter endpoint URLs, one per line
|
||||
Example:
|
||||
https://example.com/api/v1
|
||||
https://example.com/api/v2
|
||||
https://example.com/login`,
|
||||
},
|
||||
website: {
|
||||
title: 'Bulk Add Websites',
|
||||
description: 'Enter website URL list, one per line.',
|
||||
placeholder: `Please enter website URLs, one per line
|
||||
Example:
|
||||
https://example.com
|
||||
https://www.example.com
|
||||
https://api.example.com`,
|
||||
},
|
||||
directory: {
|
||||
title: 'Bulk Add Directories',
|
||||
description: 'Enter directory URL list, one per line.',
|
||||
placeholder: `Please enter directory URLs, one per line
|
||||
Example:
|
||||
https://example.com/admin
|
||||
https://example.com/api
|
||||
https://example.com/uploads`,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk add URLs dialog component
|
||||
*
|
||||
@@ -80,6 +50,14 @@ export function BulkAddUrlsDialog({
|
||||
onSuccess,
|
||||
}: BulkAddUrlsDialogProps) {
|
||||
const tBulkAdd = useTranslations("bulkAdd.common")
|
||||
const tUrl = useTranslations("bulkAdd.url")
|
||||
|
||||
// Get translated labels based on asset type
|
||||
const labels = {
|
||||
title: tUrl(`${assetType}.title`),
|
||||
description: tUrl(`${assetType}.description`),
|
||||
placeholder: tUrl(`${assetType}.placeholder`),
|
||||
}
|
||||
|
||||
// Dialog open/close state
|
||||
const [internalOpen, setInternalOpen] = useState(false)
|
||||
@@ -121,7 +99,6 @@ export function BulkAddUrlsDialog({
|
||||
}
|
||||
|
||||
const mutation = getMutation()
|
||||
const labels = ASSET_TYPE_LABELS[assetType]
|
||||
|
||||
// Handle input changes
|
||||
const handleInputChange = (value: string) => {
|
||||
@@ -222,7 +199,7 @@ export function BulkAddUrlsDialog({
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant="outline">
|
||||
<Plus className="h-4 w-4" />
|
||||
Bulk Add
|
||||
{tBulkAdd("bulkAdd")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
@@ -242,7 +219,7 @@ export function BulkAddUrlsDialog({
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="urls">
|
||||
URL List <span className="text-destructive">*</span>
|
||||
{tUrl("label")} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="flex border rounded-md overflow-hidden h-[220px]">
|
||||
{/* Line number column */}
|
||||
@@ -278,39 +255,43 @@ export function BulkAddUrlsDialog({
|
||||
{validationResult && (
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="text-muted-foreground">
|
||||
Valid: {validationResult.validCount} items
|
||||
{tUrl("valid", { count: validationResult.validCount })}
|
||||
{validationResult.duplicateCount > 0 && (
|
||||
<span className="text-yellow-600 ml-2">
|
||||
Duplicate: {validationResult.duplicateCount} items
|
||||
{tUrl("duplicate", { count: validationResult.duplicateCount })}
|
||||
</span>
|
||||
)}
|
||||
{validationResult.invalidCount > 0 && (
|
||||
<span className="text-destructive ml-2">
|
||||
Invalid: {validationResult.invalidCount} items
|
||||
{tUrl("invalid", { count: validationResult.invalidCount })}
|
||||
</span>
|
||||
)}
|
||||
{validationResult.mismatchedCount > 0 && (
|
||||
<span className="text-destructive ml-2">
|
||||
Mismatched: {validationResult.mismatchedCount} items
|
||||
{tUrl("mismatched", { count: validationResult.mismatchedCount })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{validationResult.firstError && (
|
||||
<div className="text-destructive">
|
||||
Line {validationResult.firstError.index + 1}: "
|
||||
{validationResult.firstError.url.length > 50
|
||||
? validationResult.firstError.url.substring(0, 50) + '...'
|
||||
: validationResult.firstError.url}" -{" "}
|
||||
{validationResult.firstError.error}
|
||||
{tUrl("lineError", {
|
||||
line: validationResult.firstError.index + 1,
|
||||
value: validationResult.firstError.url.length > 50
|
||||
? validationResult.firstError.url.substring(0, 50) + '...'
|
||||
: validationResult.firstError.url,
|
||||
error: validationResult.firstError.error,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{validationResult.firstMismatch && !validationResult.firstError && (
|
||||
<div className="text-destructive">
|
||||
Line {validationResult.firstMismatch.index + 1}: "
|
||||
{validationResult.firstMismatch.url.length > 50
|
||||
? validationResult.firstMismatch.url.substring(0, 50) + '...'
|
||||
: validationResult.firstMismatch.url}" -
|
||||
URL does not belong to target {targetName}, please remove before submitting
|
||||
{tUrl("mismatchError", {
|
||||
line: validationResult.firstMismatch.index + 1,
|
||||
value: validationResult.firstMismatch.url.length > 50
|
||||
? validationResult.firstMismatch.url.substring(0, 50) + '...'
|
||||
: validationResult.firstMismatch.url,
|
||||
target: targetName || '',
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -325,7 +306,7 @@ export function BulkAddUrlsDialog({
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
{tBulkAdd("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -334,12 +315,12 @@ export function BulkAddUrlsDialog({
|
||||
{mutation.isPending ? (
|
||||
<>
|
||||
<LoadingSpinner />
|
||||
Creating...
|
||||
{tUrl("creating")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4" />
|
||||
Bulk Add
|
||||
{tBulkAdd("bulkAdd")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -94,7 +94,7 @@ export function AssetTrendChart() {
|
||||
} satisfies ChartConfig), [t])
|
||||
|
||||
// Visible series state (show all by default)
|
||||
const [visibleSeries, setVisibleSeries] = useState<Set<SeriesKey>>(new Set(ALL_SERIES))
|
||||
const [visibleSeries, setVisibleSeries] = useState<Set<SeriesKey>>(() => new Set(ALL_SERIES))
|
||||
|
||||
// Currently hovered line
|
||||
const [hoveredLine, setHoveredLine] = useState<SeriesKey | null>(null)
|
||||
@@ -136,10 +136,13 @@ export function AssetTrendChart() {
|
||||
}
|
||||
|
||||
// Get latest data (use latest value from raw data)
|
||||
const latest = rawData && rawData.length > 0 ? rawData[rawData.length - 1] : null
|
||||
|
||||
const latest = useMemo(() =>
|
||||
rawData && rawData.length > 0 ? rawData[rawData.length - 1] : null,
|
||||
[rawData]
|
||||
)
|
||||
|
||||
// Display data: show hovered data when hovering, otherwise show latest data
|
||||
const displayData = activeData || latest
|
||||
const displayData = useMemo(() => activeData || latest, [activeData, latest])
|
||||
|
||||
return (
|
||||
<Card>
|
||||
|
||||
@@ -91,7 +91,7 @@ export function DashboardDataTable() {
|
||||
const scans = scanQuery.data?.results ?? []
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString: string): string => {
|
||||
const formatDate = React.useCallback((dateString: string): string => {
|
||||
return new Date(dateString).toLocaleString(getDateLocale(locale), {
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
@@ -101,7 +101,7 @@ export function DashboardDataTable() {
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
}
|
||||
}, [locale])
|
||||
|
||||
// 点击漏洞行
|
||||
const handleVulnRowClick = React.useCallback((vuln: Vulnerability) => {
|
||||
@@ -129,6 +129,8 @@ export function DashboardDataTable() {
|
||||
},
|
||||
tooltips: {
|
||||
vulnDetails: t('tooltips.vulnDetails'),
|
||||
reviewed: t('tooltips.reviewed'),
|
||||
pending: t('tooltips.pending'),
|
||||
},
|
||||
severity: {
|
||||
critical: t('severity.critical'),
|
||||
@@ -139,7 +141,7 @@ export function DashboardDataTable() {
|
||||
},
|
||||
},
|
||||
}),
|
||||
[handleVulnRowClick, t]
|
||||
[formatDate, handleVulnRowClick, t]
|
||||
)
|
||||
|
||||
// 扫描进度查看
|
||||
@@ -230,7 +232,7 @@ export function DashboardDataTable() {
|
||||
cancelled: t('common.status.cancelled'),
|
||||
completed: t('common.status.completed'),
|
||||
failed: t('common.status.failed'),
|
||||
initiated: t('common.status.pending'),
|
||||
pending: t('common.status.pending'),
|
||||
running: t('common.status.running'),
|
||||
},
|
||||
summary: {
|
||||
@@ -242,7 +244,7 @@ export function DashboardDataTable() {
|
||||
},
|
||||
},
|
||||
}),
|
||||
[router, handleViewProgress, handleDelete, handleStop, t]
|
||||
[formatDate, router, handleViewProgress, handleDelete, handleStop, t]
|
||||
)
|
||||
|
||||
// 漏洞分页信息
|
||||
|
||||
@@ -49,7 +49,7 @@ export function DashboardScanHistory() {
|
||||
cancelled: tCommon("status.cancelled"),
|
||||
completed: tCommon("status.completed"),
|
||||
failed: tCommon("status.failed"),
|
||||
initiated: tCommon("status.pending"),
|
||||
pending: tCommon("status.pending"),
|
||||
running: tCommon("status.running"),
|
||||
},
|
||||
summary: {
|
||||
@@ -70,7 +70,7 @@ export function DashboardScanHistory() {
|
||||
const formatDate = React.useCallback((dateString: string) => new Date(dateString).toLocaleString(getDateLocale(locale), { hour12: false }), [locale])
|
||||
const navigate = React.useCallback((path: string) => router.push(path), [router])
|
||||
const handleDelete = React.useCallback(() => {}, [])
|
||||
const handleStop = React.useCallback((scan: ScanRecord) => {
|
||||
const handleStop = React.useCallback(() => {
|
||||
// 仪表盘列表暂时不提供停止逻辑,实现时可在此调用对应的停止扫描接口
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -17,9 +17,9 @@ export function DashboardScheduledScans() {
|
||||
const locale = useLocale()
|
||||
|
||||
// Internationalization
|
||||
const tColumns = React.useMemo(() => useTranslations("columns"), [])
|
||||
const tCommon = React.useMemo(() => useTranslations("common"), [])
|
||||
const tScan = React.useMemo(() => useTranslations("scan"), [])
|
||||
const tColumns = useTranslations("columns")
|
||||
const tCommon = useTranslations("common")
|
||||
const tScan = useTranslations("scan")
|
||||
|
||||
// Build translation object
|
||||
const translations = React.useMemo(() => ({
|
||||
@@ -72,10 +72,13 @@ export function DashboardScheduledScans() {
|
||||
}
|
||||
}, [isFetching, isSearching])
|
||||
|
||||
const formatDate = (dateString: string) => new Date(dateString).toLocaleString(getDateLocale(locale), { hour12: false })
|
||||
const handleEdit = () => router.push(`/scan/scheduled/`)
|
||||
const handleDelete = () => {}
|
||||
const handleToggleStatus = () => {}
|
||||
const formatDate = React.useCallback(
|
||||
(dateString: string) => new Date(dateString).toLocaleString(getDateLocale(locale), { hour12: false }),
|
||||
[locale]
|
||||
)
|
||||
const handleEdit = React.useCallback(() => router.push(`/scan/scheduled/`), [router])
|
||||
const handleDelete = React.useCallback(() => {}, [])
|
||||
const handleToggleStatus = React.useCallback(() => {}, [])
|
||||
|
||||
const columns = React.useMemo(
|
||||
() =>
|
||||
@@ -86,7 +89,7 @@ export function DashboardScheduledScans() {
|
||||
handleToggleStatus,
|
||||
t: translations,
|
||||
}),
|
||||
[formatDate, handleEdit, translations]
|
||||
[formatDate, handleEdit, handleDelete, handleToggleStatus, translations]
|
||||
)
|
||||
|
||||
if (isLoading && !data) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { memo } from "react"
|
||||
import { useAssetStatistics } from "@/hooks/use-dashboard"
|
||||
import { Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
@@ -8,7 +9,7 @@ import { IconTarget, IconStack2, IconBug, IconPlayerPlay, IconTrendingUp, IconTr
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useLocale } from "next-intl"
|
||||
|
||||
function TrendBadge({ change }: { change: number }) {
|
||||
const TrendBadge = memo(function TrendBadge({ change }: { change: number }) {
|
||||
if (change === 0) return null
|
||||
|
||||
const isPositive = change > 0
|
||||
@@ -24,9 +25,9 @@ function TrendBadge({ change }: { change: number }) {
|
||||
{isPositive ? '+' : ''}{change}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function StatCard({
|
||||
const StatCard = memo(function StatCard({
|
||||
title,
|
||||
value,
|
||||
change,
|
||||
@@ -66,7 +67,7 @@ function StatCard({
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function formatUpdateTime(dateStr: string | null, locale: string, noDataText: string) {
|
||||
if (!dateStr) return noDataText
|
||||
|
||||
@@ -23,24 +23,18 @@ import {
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { IconExternalLink } from "@tabler/icons-react"
|
||||
import type { VulnerabilitySeverity } from "@/types/vulnerability.types"
|
||||
import { Circle, CheckCircle2 } from "lucide-react"
|
||||
import type { Vulnerability, VulnerabilitySeverity } from "@/types/vulnerability.types"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useLocale } from "next-intl"
|
||||
|
||||
// Unified vulnerability severity color configuration (consistent with charts)
|
||||
const severityStyles: Record<VulnerabilitySeverity, string> = {
|
||||
critical: "bg-[#da3633]/10 text-[#da3633] border border-[#da3633]/20 dark:text-[#f85149]",
|
||||
high: "bg-[#d29922]/10 text-[#d29922] border border-[#d29922]/20",
|
||||
medium: "bg-[#d4a72c]/10 text-[#d4a72c] border border-[#d4a72c]/20",
|
||||
low: "bg-[#238636]/10 text-[#238636] border border-[#238636]/20 dark:text-[#3fb950]",
|
||||
info: "bg-[#848d97]/10 text-[#848d97] border border-[#848d97]/20",
|
||||
}
|
||||
import { SEVERITY_STYLES } from "@/lib/severity-config"
|
||||
|
||||
export function RecentVulnerabilities() {
|
||||
const router = useRouter()
|
||||
const t = useTranslations("dashboard.recentVulns")
|
||||
const tSeverity = useTranslations("severity")
|
||||
const tColumns = useTranslations("columns")
|
||||
const tTooltips = useTranslations("tooltips")
|
||||
const locale = useLocale()
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
@@ -54,11 +48,11 @@ export function RecentVulnerabilities() {
|
||||
}
|
||||
|
||||
const severityConfig = useMemo(() => ({
|
||||
critical: { label: tSeverity("critical"), className: severityStyles.critical },
|
||||
high: { label: tSeverity("high"), className: severityStyles.high },
|
||||
medium: { label: tSeverity("medium"), className: severityStyles.medium },
|
||||
low: { label: tSeverity("low"), className: severityStyles.low },
|
||||
info: { label: tSeverity("info"), className: severityStyles.info },
|
||||
critical: { label: tSeverity("critical"), className: SEVERITY_STYLES.critical.className },
|
||||
high: { label: tSeverity("high"), className: SEVERITY_STYLES.high.className },
|
||||
medium: { label: tSeverity("medium"), className: SEVERITY_STYLES.medium.className },
|
||||
low: { label: tSeverity("low"), className: SEVERITY_STYLES.low.className },
|
||||
info: { label: tSeverity("info"), className: SEVERITY_STYLES.info.className },
|
||||
}), [tSeverity])
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
@@ -100,6 +94,7 @@ export function RecentVulnerabilities() {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{tColumns("common.status")}</TableHead>
|
||||
<TableHead>{tColumns("vulnerability.severity")}</TableHead>
|
||||
<TableHead>{tColumns("vulnerability.source")}</TableHead>
|
||||
<TableHead>{tColumns("common.type")}</TableHead>
|
||||
<TableHead>{tColumns("common.url")}</TableHead>
|
||||
@@ -107,31 +102,52 @@ export function RecentVulnerabilities() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{vulnerabilities.map((vuln: any) => (
|
||||
<TableRow
|
||||
key={vuln.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => router.push(`/vulnerabilities/?id=${vuln.id}`)}
|
||||
>
|
||||
<TableCell>
|
||||
<Badge className={severityConfig[vuln.severity as VulnerabilitySeverity]?.className}>
|
||||
{severityConfig[vuln.severity as VulnerabilitySeverity]?.label ?? vuln.severity}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{vuln.source}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium max-w-[120px] truncate">
|
||||
{vuln.vulnType}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-xs max-w-[200px] truncate">
|
||||
{vuln.url}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{formatTime(vuln.createdAt)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{vulnerabilities.map((vuln: Vulnerability) => {
|
||||
const isReviewed = vuln.isReviewed
|
||||
const isPending = !isReviewed
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={vuln.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => router.push(`/vulnerabilities/?id=${vuln.id}`)}
|
||||
>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`transition-all gap-1.5 cursor-default ${isPending
|
||||
? "bg-blue-500/10 text-blue-600 border-blue-500/30 dark:text-blue-400 dark:border-blue-400/30"
|
||||
: "bg-muted/50 text-muted-foreground border-muted-foreground/20"
|
||||
}`}
|
||||
>
|
||||
{isPending ? (
|
||||
<Circle className="h-3 w-3" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
)}
|
||||
{isPending ? tTooltips("pending") : tTooltips("reviewed")}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={severityConfig[vuln.severity as VulnerabilitySeverity]?.className}>
|
||||
{severityConfig[vuln.severity as VulnerabilitySeverity]?.label ?? vuln.severity}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{vuln.source}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium max-w-[120px] truncate">
|
||||
{vuln.vulnType}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-xs max-w-[200px] truncate">
|
||||
{vuln.url}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{formatTime(vuln.createdAt)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
@@ -18,15 +18,7 @@ import {
|
||||
} from "@/components/ui/chart"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
// 漏洞严重程度使用固定语义化颜色
|
||||
const SEVERITY_COLORS = {
|
||||
critical: "#dc2626", // 红色
|
||||
high: "#f97316", // 橙色
|
||||
medium: "#eab308", // 黄色
|
||||
low: "#3b82f6", // 蓝色
|
||||
info: "#6b7280", // 灰色
|
||||
}
|
||||
import { SEVERITY_COLORS } from "@/lib/severity-config"
|
||||
|
||||
export function VulnSeverityChart() {
|
||||
const { data, isLoading } = useAssetStatistics()
|
||||
|
||||
@@ -14,10 +14,7 @@ export interface DirectoryTranslations {
|
||||
url: string
|
||||
status: string
|
||||
length: string
|
||||
words: string
|
||||
lines: string
|
||||
contentType: string
|
||||
duration: string
|
||||
createdAt: string
|
||||
}
|
||||
actions: {
|
||||
@@ -56,15 +53,6 @@ function StatusBadge({ status }: { status: number | null }) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration (nanoseconds to milliseconds)
|
||||
*/
|
||||
function formatDuration(nanoseconds: number | null): string {
|
||||
if (nanoseconds === null) return "-"
|
||||
const milliseconds = nanoseconds / 1000000
|
||||
return `${milliseconds.toFixed(2)} ms`
|
||||
}
|
||||
|
||||
/**
|
||||
* Create directory table column definitions
|
||||
*/
|
||||
@@ -138,34 +126,6 @@ export function createDirectoryColumns({
|
||||
return <span>{length !== null ? length.toLocaleString() : "-"}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "words",
|
||||
size: 80,
|
||||
minSize: 60,
|
||||
maxSize: 120,
|
||||
meta: { title: t.columns.words },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t.columns.words} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const words = row.getValue("words") as number | null
|
||||
return <span>{words !== null ? words.toLocaleString() : "-"}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "lines",
|
||||
size: 80,
|
||||
minSize: 60,
|
||||
maxSize: 120,
|
||||
meta: { title: t.columns.lines },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t.columns.lines} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const lines = row.getValue("lines") as number | null
|
||||
return <span>{lines !== null ? lines.toLocaleString() : "-"}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "contentType",
|
||||
size: 120,
|
||||
@@ -185,20 +145,6 @@ export function createDirectoryColumns({
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "duration",
|
||||
size: 100,
|
||||
minSize: 80,
|
||||
maxSize: 150,
|
||||
meta: { title: t.columns.duration },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t.columns.duration} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const duration = row.getValue("duration") as number | null
|
||||
return <span className="text-muted-foreground">{formatDuration(duration)}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
size: 150,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user