Compare commits

...

82 Commits

Author SHA1 Message Date
yyhuni
7fd832ce22 chore(ci): update CI configuration and Makefile for consistency checks
- Fixed the runner version in the CI workflow to ubuntu-22.04 to prevent changes in CI behavior due to runner updates.
- Updated Go version in the CI setup to 1.24 to align with go.mod.
- Added a new test step in the Makefile to check version consistency across all workflows.
2026-02-01 19:26:49 +08:00
yyhuni
e76ecaac15 refactor(flow): 优化节点连线样式和边处理逻辑
- 提取公共 className 和样式函数,统一处理 source 端 Handle 的位置偏移
- 替换箭头标记为闭合箭头并调整大小和样式,提升视觉效果
- 重构 edges 生成函数,支持双向连线添加两个边对象
- 禁用节点拖拽,防止误操作时节点移动
- 移除多余的 markerStart 属性,简化边配置
- 统一边的动画、样式、标签渲染逻辑,提升代码复用性和可维护性
2026-02-01 16:44:05 +08:00
yyhuni
08e6c7fbe3 refactor(agent): 使用统一日志系统替换打印实现
- 新增logger模块,提供基于zap的日志管理
- agent主程序及内部模块改为使用zap日志记录信息和错误
- agent内部关键事件增加详细日志输出
- 配置日志级别和环境变量控制日志格式和输出
- websocket和task客户端启用TLS跳过验证并记录连接日志
- 任务接收、取消和配置更新过程中增加结构化日志记录
- 更新过程中添加panic捕获日志及状态更新
- 移除.vscode/settings.json配置文件
- 更新Dockerfile基础镜像版本和环境变量
- .gitignore添加SSL证书相关忽略规则
- 调整Go模块依赖,新增多个日志和相关库依赖
2026-02-01 12:52:14 +08:00
yyhuni
5adb239547 feat(core): 优化主题与路由预加载及引导加载体验
- 前端主布局根据主题 Cookie 设置 data-theme 和暗模式 class
- 移除不必要的内联引导加载 CSS,改用主题初始化组件注入关键样式
- 登录布局新增相同内联引导加载样式,实现页面加载前显示效果
- 登录页中添加等待页面加载和路由预加载完成后隐藏引导加载的逻辑
- 侧边栏点击导航时派发自定义事件以触发路由进度条显示
- 路由进度条支持手动启动和超时自动完成,完善加载状态管理
- useColorTheme hook 增加 Cookie 支持并统一主题缓存和更新逻辑
- useRoutePrefetch hook 加强多语言支持,自动带上 locale 前缀预加载路由
- 验证组件中加载状态根据认证及加载情况动态显示,避免未认证闪烁
- LoadingState 组件新增渐隐动画和可控激活状态,提升加载体验
2026-01-30 16:10:57 +08:00
yyhuni
896ae7743d chore: rename backend references to LunaFox 2026-01-30 13:01:02 +08:00
yyhuni
d5c363294b chore(frontend): rebrand to LunaFox and cleanup 2026-01-30 12:52:22 +08:00
yyhuni
4734f7a576 feat(ui): 优化登录页启动动画与启动画面实现
- 移除LoginBootScreen组件,改为在全局布局内以内联样式呈现启动画面
- 登录页移除PixelBlast动画,替换为电路板风格的背景动画,简化加载逻辑
- 登录页启动页改为通过DOM操作隐藏并移除内联的启动画元素,保证平滑过渡
- global.css新增基础加载动画与盾牌加载器的样式支持
- AppSidebar和AboutDialog组件替换logo图标,改用优化后的PNG格式logo资源
- auth-layout新增启动页自动隐藏逻辑,防止启动页干扰应用渲染
- PixelBlast组件去除随机时间种子,改为固定值实现确定性动画效果
- Shuffle组件增加forwardRef支持,暴露play接口,便于调用动画播放
- 删除废弃的icon.svg文件,改用PNG图标资源以提升兼容性和性能
- 优化启动动画文字内容和节奏,增强用户体验感知
- 细节优化包括CSS动画关键帧、颜色和布局微调,提升视觉表现一致性
2026-01-29 18:08:04 +08:00
yyhuni
46b1d5a1d1 refactor(workers): 移除代理密钥再生功能及相关代码
- 从 Agent 相关组件中移除密钥再生的 UI 和回调
- 删除 AgentList 组件中密钥再生相关的状态和处理函数
- 移除 useRegenerateAgentKey 钩子及其调用
- 更新 WebSocket 默认地址端口为 8080,替代原有 8888
- 调整环境变量默认后端地址端口为 8080
- 优化并简化前端组件导入,删除无用图标和组件依赖
2026-01-28 15:47:46 +08:00
yyhuni
66fa60c415 feat(agent): 实现任务执行与管理模块
- 新增 Executor 结构体,支持容器中运行任务并管理生命周期
- 实现任务启动、监控、取消和超时处理
- 增加任务取消标记机制,避免重复执行已取消任务
- Puller 添加负载感知的任务拉取逻辑及指数退避策略
- Updater 实现自动更新流程,包括镜像拉取和新容器启动
- WebSocket 客户端支持自动重连、心跳检测及消息处理机制
- 新增消息处理器,支持任务可用、任务取消、配置更新和更新请求的回调
- 调整前端 AgentCardCompact 组件样式,优化间距
- 删除无用的 frontend/logo-gallery.html 文件
2026-01-27 21:02:09 +08:00
yyhuni
3d54d26c7e feat(agent): 实现基础Agent功能及配置加载
- 添加agent主程序入口,支持信号中断优雅退出
- 实现Agent运行逻辑,包括WebSocket客户端、任务拉取、执行器及心跳发送
- 添加配置模块,支持环境变量及命令行参数解析和验证
- 实现配置实时更新机制,支持动态调整任务并发数及资源阈值
- 完成Docker客户端封装,支持容器创建、启动、停止及日志获取
- 实现任务拉取客户端及状态上报,包含重试机制
- 添加健康管理模块,管理Agent健康状态及状态变更时间
- 完成WebSocket消息处理,支持任务通知、任务取消及配置更新
- 添加指标采集,监控CPU、内存及磁盘使用率
- 各模块单位测试补充,保证基本逻辑正确性和异常处理覆盖
2026-01-27 16:47:58 +08:00
yyhuni
b4a289b198 feat(router): 新增多个模块的路由注册功能
- 新增 auth 认证相关路由,支持登录和刷新token接口
- 新增 directory 相关路由,支持批量操作及导出接口
- 新增 endpoint 相关路由,支持列表、导出、批量操作等功能
- 新增 engine 相关路由,支持增删改查接口
- 新增 health 健康检查路由,支持基本和状态检查接口
- 新增 host-port 相关路由,支持批量上报和删除功能
- 新增 organization 相关路由,支持组织管理及关联目标操作
- 新增 public 公开路由,支持截图图片访问接口
- 新增 scan-log 扫描日志路由,支持列表和批量创建
- 新增 scan 扫描路由,支持扫描管理及批量删除功能
- 新增 screenshot 截图路由,支持列表和批量操作
- 新增 snapshot 快照相关路由,支持多种资源批量操作和导出功能
- 新增 subdomain 子域名路由,支持导出和批量操作
- 新增 target 目标路由,支持批量创建、删除及管理功能
- 新增 user 用户路由,支持创建、列表和密码修改功能
- 新增 vulnerability 漏洞路由,支持统计、标记及批量操作
- 新增 website 网站路由,支持批量导入和删除等管理操作
- 新增 wordlist 字典路由,支持字典内容管理和下载
- 新增 worker 工作流相关路由,加入工作认证中间件保护,支持批量上报和字典下载
- 在配置中加入 PublicURL 支持,默认值为空字符串
- 更新 go.mod 和 go.sum,添加多个新的依赖包
- 修改.gitignore,新增.opencode忽略规则
- 新增 VERSION 文件,版本号设为v1.5.12-dev
2026-01-24 16:59:28 +08:00
yyhuni
b727b2d001 test: add and update tests for agent, server and worker components 2026-01-23 22:33:58 +08:00
yyhuni
b859fc9062 refactor(modules): 更新模块路径为新的github用户命名空间
- 修改所有import路径,从github.com/orbit/server改为github.com/yyhuni/orbit/server
- 更新go.mod模块名为github.com/yyhuni/orbit/server
- 调整内部引用路径,确保包导入一致性
- 修改.gitignore,新增AGENTS.md和WARP.md忽略规则
- 更新Scan请求中engineNames字段的绑定规则,改为必须且仅能包含一个元素
2026-01-23 18:31:54 +08:00
yyhuni
49b5fbef28 chore(docs): 删除冗余项目文档文件
- 移除了 AGENTS.md 文件,简化项目文档结构
- 移除了 WARP.md 文件,删除重复的操作指南
- 清理文档目录,减少维护负担
- 优化项目根目录内容,提升整体整洁性
2026-01-23 09:39:46 +08:00
yyhuni
11112a68f6 Remove .hypothesis, .DS_Store and log files from version control 2026-01-23 09:31:47 +08:00
yyhuni
9049b096ba Remove .venv and .kiro directories from version control 2026-01-23 09:29:51 +08:00
yyhuni
ca6c0eb082 Remove .kiro directory from version control 2026-01-23 09:28:09 +08:00
yyhuni
64bcd9a6f5 忽略 2026-01-23 09:20:32 +08:00
yyhuni
443e2172e4 忽略ai文件 2026-01-23 09:17:26 +08:00
yyhuni
c6dcfb0a5b Remove specs directory from version control 2026-01-23 09:14:46 +08:00
yyhuni
25ae325c69 Remove AI assistant directories from version
control
2026-01-23 09:12:21 +08:00
yyhuni
cab83d89cf chore(.agent,.gemini,.github): remove duplicate vercel-react-best-practices skills
- Remove vercel-react-best-practices skill directory from .agent/skills
- Remove vercel-react-best-practices skill directory from .gemini/skills
- Remove vercel-react-best-practices skill directory from .github/skills
- Eliminate redundant skill definitions across multiple agent configurations
- Consolidate skill management to reduce maintenance overhead
2026-01-22 22:46:59 +08:00
yyhuni
0f8fff2dc4 chore(.claude): reorganize Claude commands and skills structure
- Add speckit command suite (.claude/commands/) for workflow automation
- Reorganize Vercel React best practices skills with improved structure
- Add Hypothesis testing constants database
- Remove .dockerignore and .gitignore from repository
- Add .DS_Store to tracked files
- Consolidate development tooling and AI assistant configuration for improved project workflow
2026-01-22 22:46:31 +08:00
yyhuni
6e48b97dc2 chore(.specify): add project constitution and development workflow scripts
- Add constitution.md template for documenting core principles and governance
- Add check-prerequisites.sh script for unified prerequisite validation
- Add common.sh utility functions for bash scripts
- Add create-new-feature.sh script for feature scaffolding
- Add setup-plan.sh script for implementation planning
- Add update-agent-context.sh script for agent context management
- Add agent-file-template.md for standardized agent documentation
- Add checklist-template.md for task tracking
- Add plan-template.md for implementation planning
- Add spec-template.md for feature specifications
- Add tasks-template.md for task breakdown
- Update scan history components with improved data handling and UI consistency
- Update scan types and mock data for enhanced scan tracking
- Update i18n messages for scan history localization
- Establish standardized development workflow and documentation structure
2026-01-22 08:56:22 +08:00
yyhuni
ed757d6e14 feat(engineschema): add JSON schema validation and migrate subdomain discovery schema
- Add new engineschema package with schema validation utilities for engine configs
- Implement Validate() function to validate config maps against JSON schemas
- Implement ValidateYAML() function to validate YAML blobs with nested engine support
- Add schema caching with mutex synchronization for performance
- Migrate subdomain_discovery.schema.json from server/configs/engines to server/internal/engineschema
- Enhance schema with $id, x-engine, and x-engine-version metadata fields
- Add conditional validation (if/then) for bruteforce tool enabled state
- Add additionalProperties: false constraints to enforce strict schema validation
- Add jsonschema/v5 dependency to server and worker modules
- Update schema-gen tool to generate schemas in new location
- Regenerate subdomain discovery schema with enhanced validation rules
- Update documentation generation timestamp
2026-01-21 22:00:23 +08:00
yyhuni
2aa1afbabf chore(docker): add server Dockerfile and update subdomain discovery paths
- Add new server/Dockerfile for Go backend containerization with multi-stage build
- Update docker-compose.dev.yml to include server service with database and Redis dependencies
- Migrate Sublist3r tool path from /usr/local/share to /opt/orbit-tools/share for consistency
- Add legacy notice to docker/worker/Dockerfile clarifying it's for old Python executor
- Update subdomain discovery documentation with RFC3339 timestamp format
- Update template parsing test to reflect new tool path location
- Consolidate development environment configuration with all required services
2026-01-21 10:38:57 +08:00
yyhuni
35ac64db57 Merge branch 'feature/directory-sorting-demo' into feature/go-backend
Integrate frontend refactoring changes including:
- Dashboard animation optimizations
- Login flow enhancements
- Orbit rebranding updates
2026-01-20 21:24:52 +08:00
yyhuni
b4bfab92e3 fix(doc-gen): update timestamp format to RFC3339 standard
- Change timestamp format from "2006-01-02" to time.RFC3339 constant
- Ensures generated documentation includes full ISO 8601 timestamp with timezone
- Improves consistency with standard time formatting practices
2026-01-20 21:18:53 +08:00
yyhuni
72210c42d0 style(worker): format subdomain discovery constants and reorder tool definitions
- Align constant assignments with consistent spacing for improved readability
- Reorder tool name constants alphabetically for better maintainability
- Move toolSubfinder constant to end of tool definitions list
- Standardize formatting across stage and tool constant declarations
2026-01-20 21:15:08 +08:00
yyhuni
91aaf7997f feat(worker): implement workflow code generation and enhance subdomain discovery
- Add code generation tools (const-gen, doc-gen, schema-gen) to automate workflow metadata and documentation
- Implement config key mapper for dynamic template parameter mapping and validation
- Add comprehensive test coverage for command builder, template loader, and runner components
- Enhance subdomain discovery workflow with recon stage replacing passive stage for better reconnaissance
- Add subdomain result parsing and writing utilities for output handling
- Implement batch sender tests and improve server client reliability
- Add CI workflow to validate generated files are up to date before builds
- Convert YAML engine config to JSON schema for better validation and IDE support
- Add extensive test data fixtures for template validation edge cases
- Update Makefile and development scripts for improved build and test workflows
- Generate auto-documentation for subdomain discovery configuration reference
- Improve code maintainability through automated generation of constants and schemas
2026-01-20 21:09:55 +08:00
yyhuni
32e3179d58 refactor(frontend): optimize dashboard animations and extract dashboard data prefetch logic
- Remove "use client" directive from dashboard page and convert to server component
- Replace manual fade-in animation state with CSS animation class `animate-dashboard-fade-in`
- Extract dashboard data prefetch logic into reusable `prefetchDashboardData` callback in login page
- Parallelize login verification and bundle prefetch operations for faster execution
- Implement dynamic import for Monaco Editor with loading state to reduce bundle size (~2MB)
- Fix dependency array in template content effect to include full `templateContent` object
- Add `tree-node-item` class with `content-visibility: auto` for long list rendering optimization
- Simplify login flow by reusing extracted prefetch function to reduce code duplication
- Improves perceived performance by reducing animation overhead and optimizing bundle loading
2026-01-20 08:42:02 +08:00
yyhuni
487f7c84b5 fix(frontend): add null checks to PixelBlast renderer initialization
- Add renderer null check in setSize function to prevent errors during initialization
- Add renderer validation before composer.setSize call to ensure renderer exists
- Add null check in mapToPixels function to return safe default values when renderer is unavailable
- Add renderer existence check before calling renderer.render in animation loop
- Improve robustness of Three.js renderer lifecycle management to prevent runtime errors
2026-01-20 08:02:01 +08:00
yyhuni
b2cc83f569 feat(frontend): optimize login flow with dashboard data preloading and enhanced animations
- Implement dashboard data prefetching on successful login to eliminate loading delays
- Add blur transition effect to dashboard fade-in animation for smoother visual experience
- Replace login success splash screen logic with efficient data warming strategy
- Prefetch critical dashboard queries (asset statistics, scans, vulnerabilities) before navigation
- Prime auth cache to prevent full-screen loading state on dashboard entry
- Add pixel animation first-frame detection to coordinate boot splash timing
- Refactor login state management to use refs and callbacks for better control flow
- Update dashboard page transition to use will-change optimization for better performance
- Remove hardcoded login success delay constants in favor of data-driven navigation
- Improve user experience by seamlessly transitioning from login to fully-loaded dashboard
2026-01-19 23:49:16 +08:00
yyhuni
f854cf09be feat(frontend): add login success splash screen and dashboard fade-in animation
- Add "use client" directive to dashboard page for client-side state management
- Implement fade-in animation on dashboard page load using opacity transition
- Add success state tracking to login page with configurable delay timers
- Create separate boot screen output for successful authentication flow
- Add success prop to LoginBootScreen component to display auth success messages
- Define new constants for login success delay (1200ms) and fade duration (500ms)
- Update boot screen to conditionally render success or standard boot lines
- Enhance user experience with visual feedback during authentication completion
2026-01-19 22:31:16 +08:00
yyhuni
7e1c2c187a chore(skills): add Vercel React best practices guidelines for agents
- Add comprehensive Vercel React best practices skill documentation across .agent, .codex, and .gemini directories
- Include 50+ rule files covering async patterns, bundle optimization, client-side performance, and server-side rendering
- Add SKILL.md and AGENTS.md metadata files for skill configuration and agent integration
- Organize rules into categories: advanced patterns, async handling, bundle optimization, client optimization, JavaScript performance, rendering optimization, re-render prevention, and server-side caching
- Provide standardized guidelines for performance optimization and best practices across multiple AI agent platforms
2026-01-19 20:14:08 +08:00
yyhuni
4abb259ca0 feat(frontend): rebrand to Orbit and add login boot splash screen
- Replace "Star Patrol ASM Platform" branding with "Orbit ASM Platform" throughout
- Add SVG icon support and remove favicon.ico in favor of icon.svg
- Create new LoginBootScreen component for boot splash animation
- Implement boot phase state management (entering, visible, leaving, hidden)
- Add smooth transitions and animations for login page overlay
- Update metadata icons configuration to use SVG format
- Add glitch reveal animations and jitter effects to globals.css
- Enhance login page UX with minimum splash duration and fade transitions
- Update English and Chinese translations for new branding
- Improve system logs mock data structure
- Update package.json dependencies and configuration
- Ensure splash screen displays before auth check completes and redirect occurs
2026-01-19 11:10:02 +08:00
yyhuni
bbef6af000 fix(frontend): update filter examples to use correct wildcard syntax
- Replace wildcard patterns with asterisks (*) with trailing slash notation
- Update directories filter example from "/api/*" to "/api/"
- Update endpoints filter example from "/api/*" to "/api/"
- Update IP addresses filter example from "192.168.1.*" to "192.168.1."
- Update subdomains filter example from "*.test.com" to ".test.com"
- Update vulnerabilities filter example from "/api/*" to "/api/"
- Update websites filter example from "/api/*" to "/api/"
- Standardize filter syntax across all data table components for consistency
2026-01-18 21:41:30 +08:00
yyhuni
ba0864ed16 feat(target): add help tooltip for directories tab and update translations
- Import HelpCircle icon from lucide-react for help indicator
- Import Tooltip components for displaying contextual help information
- Restructure navigation layout to support help tooltip alongside tabs
- Add conditional tooltip display when directories tab is active
- Add directoriesHelp translation key to English messages
- Add directoriesHelp translation key to Chinese messages
- Improve UX by providing contextual guidance for directories functionality
2026-01-18 10:23:33 +08:00
yyhuni
f54827829a feat(dashboard): add vulnerability review status tracking and severity column
- Add review status indicator (pending/reviewed) to recent vulnerabilities table with visual badges
- Display severity column in vulnerability table for better visibility
- Import Circle and CheckCircle2 icons from lucide-react for status indicators
- Add tooltip translations for "reviewed" and "pending" status labels
- Update mock vulnerability data with isReviewed property for all entries
- Implement conditional styling for pending (blue) and reviewed (muted) status badges
- Enhance table layout to show vulnerability severity alongside review status
2026-01-18 08:58:18 +08:00
yyhuni
170021130c feat(worker): implement subdomain discovery workflow stages with wildcard detection
- Add stage_bruteforce.go with bruteforce subdomain enumeration logic
- Add stage_passive.go with passive reconnaissance stage implementation
- Add stage_merge.go with file merging and wildcard domain detection
- Add stages.go with stage orchestration and utility functions
- Update workflow.go to integrate new stages into discovery pipeline
- Implement wildcard detection with sampling and expansion ratio analysis
- Add deduplication logic during file merging to reduce redundant entries
- Implement parallel command execution for bruteforce operations
- Add wordlist management with local caching from server
- Include comprehensive logging and error handling throughout stages
2026-01-17 23:18:28 +08:00
yyhuni
b540f69152 feat(worker): implement subdomain discovery workflow and enhance validation
- Rename IsSubdomainMatchTarget to IsSubdomainOfTarget for clarity
- Add subdomain discovery workflow with template loader and helpers
- Implement workflow registry for managing scan workflows
- Add domain validator package for input validation
- Create wordlist server component for managing DNS resolver lists
- Add template loader activity for dynamic template management
- Implement worker configuration module with environment setup
- Update worker dependencies to include projectdiscovery/utils and govalidator
- Consolidate workspace directory configuration (WORKSPACE_DIR replaces RESULTS_BASE_PATH)
- Update seed generator to use standardized bulk-create API endpoint
- Update all service layer calls to use renamed validation function
2026-01-17 21:15:02 +08:00
yyhuni
d7f1e04855 chore: add server/.env to .gitignore and remove from git tracking 2026-01-17 08:25:45 +08:00
yyhuni
68ad18e6da 更名orbit 2026-01-16 09:03:20 +08:00
yyhuni
a7542d4a34 改名后端成server 2026-01-15 16:19:00 +08:00
yyhuni
6f02d9f3c5 feat(api): standardize API endpoints and update data generation logic
- Rename IP address endpoints from `/ip-addresses/` to `/host-ports` for consistency
- Update vulnerability endpoints from `/assets/vulnerabilities/` to `/vulnerabilities/`
- Remove trailing slashes from API endpoint paths for standardization
- Remove explicit `type` field from target generation in seed data
- Enhance website generation with deduplication logic and attempt limiting
- Add default admin user seed data to database initialization migration
- Improve data generator to prevent infinite loops and handle unique URL combinations
- Align frontend service calls with updated backend API structure
2026-01-15 13:02:26 +08:00
yyhuni
794846ca7a feat(backend): enhance vulnerability schema and add target validation for snapshots
- Expand vulnerability and vulnerability_snapshot table column sizes for better data handling
* Change url column from VARCHAR(2000) to TEXT for unlimited length
* Increase vuln_type from VARCHAR(100) to VARCHAR(200)
* Increase source from VARCHAR(50) to VARCHAR(100)
- Add input validation constraints to vulnerability DTOs
* Add max=200 binding constraint to VulnType field
* Add max=100 binding constraint to Source field
- Implement consistent target ID validation across snapshot handlers
* Add ErrTargetMismatch error handling in subdomain_snapshot handler
* Add ErrTargetMismatch error handling in website_snapshot handler
* Replace generic error strings with ErrTargetMismatch constant in services
- Improve error handling consistency by using defined error types instead of generic error messages
2026-01-15 12:33:19 +08:00
yyhuni
5eea7b2621 feat(backend): add input validation and default value initialization for models
- Add Content-Type validation in BindJSON to enforce application/json requests
- Implement BeforeCreate hooks for array and JSONB field initialization across models
* Endpoint and EndpointSnapshot: initialize Tech and MatchedGFPatterns arrays
* Scan: initialize EngineIDs, ContainerIDs arrays and StageProgress JSONB
* Vulnerability and VulnerabilitySnapshot: initialize RawOutput JSONB
* Website and WebsiteSnapshot: initialize Tech array
- Add ErrTargetMismatch error handling in snapshot handlers
* DirectorySnapshot, HostPortSnapshot, ScreenshotSnapshot handlers now validate targetId matches scan's target
- Enhance target validation in filter and validator packages
- Improve service layer validation for subdomain, website, and host port snapshots
- Prevent null/nil values in database by ensuring proper default initialization
2026-01-15 12:21:35 +08:00
yyhuni
069527a7f1 feat(backend): implement vulnerability and screenshot snapshot APIs with directories tab reorganization
- Add vulnerability snapshot DTO, handler, repository, and service layer with comprehensive test coverage
- Add screenshot snapshot DTO, handler, repository, and service layer for snapshot management
- Reorganize directories tab from secondary assets navigation to primary navigation in scan history and target layouts
- Update frontend navigation to include FolderSearch icon for directories tab with badge count display
- Add i18n translations for directories tab in English and Chinese messages
- Implement seed data generation tools with Python API client for testing and data population
- Add data generator, error handler, and progress tracking utilities for seed API
- Update target validator to support new snapshot-related validations
- Refactor organization and vulnerability handlers to support snapshot operations
- Add integration tests and property-based tests for vulnerability snapshot functionality
- Update Go module dependencies to support new snapshot features
2026-01-15 10:25:34 +08:00
yyhuni
e542633ad3 refactor(backend): consolidate migration files and restructure host port entities
- Remove seed data generation command (cmd/seed/main.go)
- Consolidate database migrations into single init schema file
- Rename ip_address DTO to host_port for consistency
- Add host_port_snapshot DTO and model for snapshot tracking
- Rename host_port handler and repository files for clarity
- Implement host_port_snapshot service layer with CRUD operations
- Update website_snapshot service to work with new host_port structure
- Enhance terminal login UI with focus state tracking and Tab key navigation
- Update docker-compose configuration for development environment
- Refactor directory and website snapshot DTOs for improved data structure
- Add comprehensive test coverage for model and handler changes
- Simplify database schema by consolidating related migrations into single initialization file
2026-01-14 18:04:16 +08:00
yyhuni
e8a9606d3b 优化性能 2026-01-14 16:41:35 +08:00
yyhuni
dc2e1e027d 完成endpoint website subdomain directory快照表api 2026-01-14 16:38:20 +08:00
yyhuni
b1847faa3a feat(frontend): add throttled ripple effect on mouse move to PixelBlast
- Add enableRipples prop to PixelBlast component for conditional ripple control
- Implement throttled ripple triggering on pointer move events (150ms interval)
- Remove separate pointerdown event listener and consolidate ripple logic
- Refactor onPointerMove to handle both ripple effects and liquid touch separately
- Improve performance by preventing excessive ripple calculations on rapid movements
2026-01-14 11:42:12 +08:00
yyhuni
e699842492 perf(frontend): optimize login page and animations with memoization and accessibility
- Memoize translations object in login page to prevent unnecessary re-renders
- Add support for prefers-reduced-motion media query in PixelBlast component
- Implement IntersectionObserver and Page Visibility API for intelligent animation pausing
- Limit device pixel ratio based on device type (mobile vs desktop) for better performance
- Add maxPixelRatio parameter to PixelBlast for fine-grained performance control
- Add autoPlay prop to Shuffle component for flexible animation control
- Disable autoPlay on Shuffle text animations in terminal login for better UX
- Add accessibility label to PixelBlast container when reduced motion is enabled
- Improve mobile performance by capping pixel ratio to 1.5 on mobile devices
- Respect user accessibility preferences while maintaining visual quality on desktop
2026-01-14 11:33:11 +08:00
yyhuni
08a4807bef feat(frontend): enhance terminal login UI with improved styling and i18n shortcuts
- Update PixelBlast animation with increased pixel size (6.5) and speed (0.35)
- Replace semantic color tokens with explicit zinc color palette for better visual consistency
- Add keyboard shortcuts translations to support multiple languages (en, zh)
- Implement i18n for all terminal UI labels: submit, cancel, clear, start/end actions
- Update terminal header and content styling with zinc-700 borders and zinc-100 text
- Enhance keyboard shortcuts hint display with localized action labels
- Improve text color hierarchy using zinc-400, zinc-500, and zinc-600 variants
2026-01-14 10:58:12 +08:00
yyhuni
191ff9837b feat(frontend): redesign login page with terminal UI and pixel blast animation
- Replace traditional card-based login form with immersive terminal-style interface
- Add PixelBlast animated background component for cyberpunk aesthetic
- Implement TerminalLogin component with typewriter and terminal effects
- Add new animation components: FaultyTerminal, PixelBlast, Shuffle with CSS modules
- Add gravity-stars background animation component from animate-ui
- Add terminal cursor blink animation to global styles
- Update login page translations to support terminal UI prompts and messages
- Replace Lottie animation with dynamic WebGL-based PixelBlast component
- Add dynamic imports to prevent SSR issues with WebGL rendering
- Update component registry to include @magicui and @react-bits registries
- Refactor login form state management to use async/await pattern
- Add fingerprint meta tag for search engine identification (FOFA/Shodan)
- Improve visual hierarchy with relative z-index layering for background and content
2026-01-14 10:48:41 +08:00
yyhuni
679dff9037 refactor(frontend): unify filter UI components and enhance smart filtering
- Replace DropdownMenu with Select component for severity filtering across data tables
- Add Filter icon from lucide-react to filter triggers for consistent visual design
- Update SelectTrigger width from fixed pixels to auto for responsive layout
- Integrate SmartFilterInput component into vulnerabilities data table
- Refactor severity filter options to use object structure with translated labels
- Consolidate filter UI patterns across organization targets, scan history, and vulnerabilities tables
- Register @animate-ui component registry in components.json
- Improve filter UX with consistent icon usage and flexible sizing
2026-01-14 09:51:35 +08:00
yyhuni
ce4330b628 refactor(frontend): centralize severity styling configuration
- Extract severity color and style definitions into dedicated severity-config module
- Create SEVERITY_STYLES constant with unified badge styling for all severity levels
- Create SEVERITY_COLORS constant for chart visualization consistency
- Add getSeverityStyle() helper function for dynamic severity badge generation
- Add SEVERITY_CARD_STYLES and SEVERITY_ICON_BG constants for notification styling
- Update dashboard components to use centralized severity configuration
- Update fingerprint columns to use getSeverityStyle() helper
- Update notification drawer to reference centralized severity styles
- Update search result cards to use centralized configuration
- Update vulnerability components to import from severity-config module
- Eliminate duplicate severity styling definitions across multiple components
- Improve maintainability by having single source of truth for severity styling
2026-01-14 09:05:14 +08:00
yyhuni
4ce6b148f8 feat(frontend): enhance vulnerability review status display with icons
- Add Circle and CheckCircle2 icons from lucide-react for visual status indicators
- Update reviewStatus column sizing (100px size, 90-110px range) for better layout
- Implement icon rendering: Circle for pending status, CheckCircle2 for reviewed
- Enhance Badge styling with improved hover states and ring effects
- Add gap spacing between icon and text in status badge
- Refactor status logic to use isPending variable for clearer code
- Update Chinese translations for review action labels to be more descriptive
- Improve visual feedback with conditional styling based on review status
- Maintain cursor pointer behavior only when onToggleReview callback is available
2026-01-14 08:43:47 +08:00
yyhuni
a89f775ee9 完成漏洞的review,scan的基本curd 2026-01-14 08:21:46 +08:00
yyhuni
e3003f33f9 完成漏洞的review,scan的基本curd 2026-01-14 08:21:34 +08:00
yyhuni
3760684b64 feat: add vulnerability review status feature
- Add is_reviewed and reviewed_at fields to vulnerability table
- Add PATCH /api/vulnerabilities/:id/review and /unreview endpoints
- Add POST /api/vulnerabilities/bulk-review and /bulk-unreview endpoints
- Add isReviewed filter parameter to list APIs
- Update frontend with review status indicator, filter tabs, and bulk actions
- Add i18n translations for review status
2026-01-13 19:53:12 +08:00
yyhuni
bfd7e11d09 perf(backend): optimize database seeding with batch inserts
- Replace individual Create() calls with CreateInBatches() for organizations, targets, and websites to reduce database round trips
- Build all records in memory before batch insertion instead of inserting one-by-one
- Implement chunked batch insert for organization-target links to handle large datasets efficiently
- Add ON CONFLICT DO NOTHING clause for website creation to handle duplicates gracefully
- Use strings.Join() for efficient SQL query construction in bulk insert operations
- Improve seeding performance by reducing database transactions from O(n) to O(n/batch_size)
- Add missing imports (strings, clause) required for batch operations
2026-01-13 18:57:18 +08:00
yyhuni
f758feb0d0 完善漏洞api 2026-01-13 18:46:43 +08:00
yyhuni
8798eed337 feat(backend,frontend): implement wordlist management and engine patch endpoint
- Add wordlist management system with create, list, delete, and content operations
- Implement wordlist repository, service, and handler layers
- Add wordlist DTO models for API requests and responses
- Create wordlist storage configuration with base path setting
- Add PATCH endpoint for partial engine updates alongside existing PUT endpoint
- Implement PatchEngineRequest DTO for optional field updates
- Add wordlist routes: POST/GET/DELETE for management, GET/PUT for content operations
- Remove redundant toast notifications from engine edit dialog (handled by hook)
- Configure storage settings in application config with environment variable support
- Initialize wordlist service and handler in main server setup
2026-01-13 18:03:36 +08:00
yyhuni
bd1e25cfd5 添加截图创建校验 2026-01-13 17:42:19 +08:00
yyhuni
d775055572 完成截图api 2026-01-13 17:35:57 +08:00
yyhuni
00dfad60b8 完成target资产统计count 2026-01-13 16:55:37 +08:00
yyhuni
a5c48fe4d4 feat(frontend,backend): implement IP address management and export functionality
- Add IP address DTO, handler, service, and repository layers in Go backend
- Implement IP address bulk delete endpoint at /ip-addresses/bulk-delete/
- Add IP address export endpoint with optional IP filtering by target
- Simplify IP address hosts column display using ExpandableCell component
- Update IP address export to support filtering selected IPs for download
- Add error handling and toast notifications for export operations
- Internationalize IP address column labels and tooltips in Chinese
- Update IP address service to support filtered exports with comma-separated IPs
- Add host-port mapping seeding for test data generation
- Refactor scope filter and repository queries to support IP address operations
2026-01-13 16:42:57 +08:00
yyhuni
85c880731c feat(frontend): internationalize data table and website columns
- Add Chinese translations for common column labels (name, description, status, actions, type)
- Translate vulnerability column headers (severity, source, vulnType, url, createdAt)
- Translate organization and target column headers to Chinese
- Translate subdomain and endpoint column headers with full Chinese localization
- Add comprehensive website column translations including statusCode, technologies, contentLength
- Translate directory and scheduledScan column headers to Chinese
- Update UnifiedDataTable to use i18n for "Columns" button text via tDataTable("showColumns")
- Fix websites-view to use correct translation key "website.statusCode" instead of "common.status"
- Ensure consistent terminology across all data table views for better user experience
2026-01-13 10:16:43 +08:00
yyhuni
c6b6507412 feat(frontend): internationalize tab labels in scan history and target layouts
- Replace hardcoded tab labels with i18n translation keys in scan history layout
- "Websites" → {t("tabs.websites")}
- "Subdomains" → {t("tabs.subdomains")}
- "IPs" → {t("tabs.ips")}
- "URLs" → {t("tabs.urls")}
- "Directories" → {t("tabs.directories")}
- Replace hardcoded tab labels with i18n translation keys in target layout
- Apply same translation key replacements across all tab triggers
- Add new tab translation keys to English messages (en.json)
- tabs.websites, tabs.subdomains, tabs.ips, tabs.urls, tabs.directories
- Add new tab translation keys to Chinese messages (zh.json)
- Standardize terminology: "网站" → "站点", "端点" → "URL"
- Update related dashboard and stat card translations for consistency
- Ensures consistent multilingual support across scan history and target management interfaces
2026-01-13 09:58:34 +08:00
yyhuni
af457dc44c feat(frontend,backend): implement directory, endpoint, and subdomain management APIs
- Remove words, lines, and duration fields from directory model and UI components
- Simplify directory columns by removing unnecessary metrics from table display
- Add directory, endpoint, and subdomain DTOs with proper validation and pagination
- Implement handlers for directory, endpoint, and subdomain CRUD operations
- Create repository layer for directory, endpoint, and subdomain data access
- Add service layer for directory, endpoint, and subdomain business logic
- Update API routes to use standalone endpoints (/directories, /endpoints, /subdomains)
- Fix subdomain bulk-create payload to use 'names' field instead of 'subdomains'
- Add database migration to drop unused directory_words and directory_lines tables
- Update seed data generation to support websites, endpoints, and directories per target
- Add target validator tests for improved test coverage
- Refactor subdomain service to support new API structure
2026-01-13 09:47:34 +08:00
yyhuni
9e01a6aa5e fix(frontend,backend): move bulk-delete endpoint to standalone websites route
- Move bulk-delete endpoint from `/targets/:id/websites/bulk-delete` to `/websites/bulk-delete`
- Update frontend WebsiteService to use new standalone endpoint path
- Update Go backend router configuration to register bulk-delete under standalone websites routes
- Update handler documentation to reflect correct endpoint path
- Simplifies API structure by treating bulk operations as standalone website operations rather than target-scoped
2026-01-12 22:16:34 +08:00
yyhuni
ed80772e6f feat(frontend,backend): implement website management and i18n for bulk operations
- Add website service layer with CRUD operations and filtering support
- Implement website handler with complete API endpoints
- Add website repository with database operations and query optimization
- Create website DTO for API request/response serialization
- Implement CSV export functionality for asset data
- Add scope filtering package for dynamic query building with tests
- Create database migrations for schema initialization and GIN indexes
- Migrate bulk add dialog to use i18n translations instead of hardcoded strings
- Update all frontend hooks to support pagination and filtering parameters
- Refactor organization and target services with improved error handling
- Add seed command for database initialization with sample data
- Update frontend messages (en.json, zh.json) with bulk operation translations
- Improve API client with better error handling and request formatting
- Add database migration runner to backend initialization
- Update go.mod and go.sum with new dependencies
2026-01-12 22:10:08 +08:00
yyhuni
a22af21dcb feat(frontend,backend): optimize data fetching and add database seeding
- Add database seeding utility (cmd/seed/main.go) to generate test data for organizations and targets
- Implement conditional query execution in useTargets hook with enabled option to prevent unnecessary requests
- Reduce page size from 50 to 20 in scheduled scan dialog for better performance
- Update target DTO and handler to support improved query filtering
- Enhance target repository with optimized database queries
- Replace generic "Add" button text with localized "Add Target" text in target views
- Remove redundant addButtonText prop from organization detail view
- Improve code formatting and add explanatory comments for data fetching logic
- These changes reduce unnecessary API calls on page load and provide better test data management for development
2026-01-12 18:43:16 +08:00
yyhuni
8de950a7a5 feat(organization): refactor target creation flow and fix target count queries
- Replace useBatchCreateTargets hook with direct service call in AddOrganizationDialog to avoid double toast notifications
- Simplify dialog state management by using isCreatingTargets boolean instead of mutation pending state
- Consolidate form reset and dialog close logic to execute after both organization and targets are created
- Fix target count queries in OrganizationRepository to exclude soft-deleted targets using INNER JOIN with deleted_at check
- Update FindByIDWithCount and FindAll methods to properly filter out deleted targets from count calculations
- Handle 204 No Content responses in batchCreateTargets service by returning default success response
2026-01-12 18:17:44 +08:00
yyhuni
9db84221e9 完成部分组织,目标相关后端api
前端改名项目为星巡
2026-01-12 17:59:37 +08:00
yyhuni
0728f3c01d feat(go-backend): add database auto-migration and fix Website model naming
- Add comprehensive database auto-migration in main.go with all models organized by dependency order
- Include core models (Organization, User, Target, ScanEngine, WorkerNode, etc.)
- Include scan-related models (Scan, ScanInputTarget, ScanLog, ScheduledScan)
- Include asset models (Subdomain, HostPortMapping, Website, Endpoint, Directory, Screenshot, Vulnerability)
- Include snapshot models for all asset types
- Include statistics and authentication models
- Rename WebSite struct to Website for consistency with Go naming conventions
- Update TableName method to reflect Website naming
- Add migration logging for debugging and monitoring purposes
2026-01-11 22:30:36 +08:00
yyhuni
4aa7b3d68a feat(go-backend): implement complete API layer with handlers, services, and repositories
- Add DTOs for user, organization, target, engine, pagination, and response handling
- Implement repository layer for user, organization, target, and engine entities
- Implement service layer with business logic for all core modules
- Implement HTTP handlers for user, organization, target, and engine endpoints
- Add complete CRUD API routes with soft delete support for organizations and targets
- Add environment configuration file with database, Redis, and logging settings
- Add docker-compose.dev.yml for PostgreSQL and Redis development dependencies
- Add comprehensive README.md with migration progress, API endpoints, and tech stack
- Update main.go to wire repositories, services, and handlers with dependency injection
- Update config.go to support .env file loading with environment variable priority
- Update database.go to initialize all repositories and services
2026-01-11 22:07:27 +08:00
yyhuni
3946a53337 refactor(go-backend): switch from Django pbkdf2 to bcrypt
- Simplify password.go to use bcrypt (standard Go approach)
- Remove Django password compatibility (not needed for fresh deployment)
- Update auth_handler to use VerifyPassword()
- All tests passing
2026-01-11 20:58:53 +08:00
yyhuni
c94fe1ec4b feat(go-backend): implement JWT authentication
- Add JWT token generation and validation (internal/auth/jwt.go)
- Add Django-compatible password verification (internal/auth/password.go)
- Add auth middleware for protected routes (internal/middleware/auth.go)
- Add auth handler with login, refresh, me endpoints (internal/handler/auth_handler.go)
- Add JWT config (secret, access/refresh expire times)
- Register auth routes in main.go
- All tests passing

API endpoints:
- POST /api/auth/login - User login
- POST /api/auth/refresh - Refresh access token
- GET /api/auth/me - Get current user (protected)
2026-01-11 20:55:59 +08:00
yyhuni
6dea525527 feat(go-backend): add indexes and unique constraints to all models
- Add index tags for query optimization (idx_xxx)
- Add uniqueIndex tags for unique constraints
- Add composite unique indexes (e.g., unique_subdomain_name_target)
- Update Organization/Target to many-to-many relationship
- All models now ready for GORM AutoMigrate
- All tests passing
2026-01-11 20:47:25 +08:00
yyhuni
5b0416972a feat(go-backend): complete all Go models
- Add scan-related models: ScanLog, ScanInputTarget, ScheduledScan, SubfinderProviderSettings
- Add engine models: Wordlist, NucleiTemplateRepo
- Add notification models: Notification, NotificationSettings
- Add config model: BlacklistRule
- Add statistics models: AssetStatistics, StatisticsHistory
- Add auth models: User (auth_user), Session (django_session)
- Add shopspring/decimal dependency for Vulnerability model
- Update model_test.go with all 33 model table name tests
- All tests passing
2026-01-11 20:29:11 +08:00
999 changed files with 194389 additions and 4895 deletions

View 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
View 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
View File

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

@@ -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 消息推送
- **通知推送** - 实时企业微信tgdiscard消息推送服务
---
## 快速开始
### 环境要求
- **操作系统**: 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 支持一下!
[![Star History Chart](https://api.star-history.com/svg?repos=yyhuni/xingrin&type=Date)](https://star-history.com/#yyhuni/xingrin&Date)
## 许可证
本项目采用 [GNU General Public License v3.0](LICENSE) 许可证。
### 允许的用途
- 个人学习和研究
- 商业和非商业使用
- 修改和分发
- 专利使用
- 私人使用
### 义务和限制
- **开源义务**:分发时必须提供源代码
- **相同许可**:衍生作品必须使用相同许可证
- **版权声明**:必须保留原始版权和许可证声明
- **责任免除**:不提供任何担保
- 未经授权的渗透测试
- 任何违法行为

13
agent/.air.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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)
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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"`
}

View 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"`
}

View 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"`
}

View File

@@ -0,0 +1,6 @@
package domain
type UpdateRequiredPayload struct {
Version string `json:"version"`
Image string `json:"image"`
}

View 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 = ""
}
}

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

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

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

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

View 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"`
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -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 访问主服务器的地址:
# - 仅本地部署serverDocker 内部服务名)
# - 有远程 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__

View File

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

View File

@@ -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 # 固定网络名,不随目录名变化

View File

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

View File

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

View 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 GBAOF 持久化) |
| 带宽 | ~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. 更新文档
## 风险和缓解
### 风险 1Redis 内存溢出
**缓解**
- 设置 `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
View File

@@ -9,6 +9,7 @@
!.yarn/plugins
!.yarn/releases
!.yarn/versions
.pnpm-store/
# testing
/coverage

60
frontend/Dockerfile Normal file
View 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"]

View File

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

View File

@@ -24,5 +24,9 @@ export default function LoginLayout({
}: {
children: React.ReactNode
}) {
return children
return (
<>
{children}
</>
)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
"use client"
import { DatabaseHealthView } from "@/components/settings/database-health"
export default function DatabaseHealthPage() {
return <DatabaseHealthView />
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
.faulty-terminal-container {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}

View 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} />;
}

View File

@@ -0,0 +1,6 @@
.pixel-blast-container {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}

View 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;

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

View 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

@@ -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}: &quot;
{validationResult.firstError.url.length > 50
? validationResult.firstError.url.substring(0, 50) + '...'
: validationResult.firstError.url}&quot; -{" "}
{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}: &quot;
{validationResult.firstMismatch.url.length > 50
? validationResult.firstMismatch.url.substring(0, 50) + '...'
: validationResult.firstMismatch.url}&quot; -
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>

View File

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

View File

@@ -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]
)
// 漏洞分页信息

View File

@@ -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(() => {
// 仪表盘列表暂时不提供停止逻辑,实现时可在此调用对应的停止扫描接口
}, [])

View File

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

View File

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

View File

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

View File

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

View File

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