Compare commits

...

22 Commits

Author SHA1 Message Date
yyhuni
872c118acf feat(tools): switch nudge proxy to Deno Deploy (free tier friendly) 2026-02-05 11:35:33 +08:00
yyhuni
8afbd4fee1 feat(tools): add lightweight AI nudge proxy server for Zeabur deployment 2026-02-05 11:27:59 +08:00
yyhuni
6301db1526 fix(docker): 改进服务健康检查和依赖配置
- 为 server 服务添加 HTTP 健康检查配置,增强启动稳定性
- frontend 和其他服务改用 service_healthy 作为依赖条件,避免提前启动
- 修改 docker-compose.dev.yml 文件,优化容器启动流程
- 删除 preset 配置中的已弃用 sublist3r 相关条目
- 更新安装脚本,增加环境变量支持资源阈值配置(CPU、内存、磁盘使用率)
- 调整 server/.air.toml,修正构建配置中的入口文件字段名称
2026-02-05 08:55:05 +08:00
yyhuni
ebeb239a55 feat(dashboard): 提供新的高级资产脉冲仪表盘原型组件
- 重构仪表盘,移除以前的静态统计卡片和图表,使用 DashboardLazySections 懒加载组件
- 在组织页面引入动态加载 OrganizationList 组件,SSR 关闭并添加加载骨架
- 新增 advanced-asset-pulse 目录和页面,包含多种数据可视化图表组件:
  - 透视时间线(混合图表)
  - 交互式擦除器(堆叠面积图)
  - 系统脉搏(脉冲和发光效果折线图)
  - 资产雷达扫描(雷达图)
  - 指标地震仪(山脊图)
  - 数字马赛克(热力图)
  - 轨道仪表盘(径向条形图)
  - DNA 条形码(堆叠百分比条形图)
  - 流量流(轮廓堆叠面积图)
  - 二元打卡(散点图)
- 各组件实现了数据填充逻辑、加载态处理及多样化交互功能
- 使用 Recharts 库实现所有图表的响应式及动画效果,提供丰富的资产数据可视化体验
2026-02-05 08:36:45 +08:00
yyhuni
a13e84df82 feat: add component demos page and update sidebar navigation
- Introduced ComponentDemosIndexPage and ComponentDemoPage for showcasing UI and business component demos.
- Updated AppSidebar to include a link to the new component demos index.
- Created demo registry files to manage UI and business demo items.
- Enhanced UI components with consistent styling and improved layout for demo pages.
2026-02-04 10:05:41 +08:00
yyhuni
00da1d231c feat: add component gallery page and update sidebar navigation
- Introduced a new ComponentGalleryPage to display component groups.
- Updated AppSidebar to include a link to the new component gallery.
- Refactored ComponentGallery to enhance UI elements and improve layout.
- Adjusted CSS for Bauhaus theme to ensure consistent styling across components.
2026-02-04 09:38:55 +08:00
yyhuni
e6cd5914fd refactor: streamline layout and enhance UI components with Bauhaus theme
- Removed deprecated script loading from layout component for cleaner code.
- Replaced static header elements with a new reusable PageHeader component for better consistency.
- Introduced a new BauhausPageHeader component to standardize header styles across the application.
- Updated CSS variables in the Bauhaus theme for improved visual coherence and modern aesthetics.
2026-02-04 09:32:32 +08:00
yyhuni
d08b4c03c5 feat: enhance frontend structure with new Bauhaus theme and UI improvements
- Added Bauhaus theme support with new CSS variables for consistent styling.
- Updated dashboard components to reflect the new theme, including headers and stat cards.
- Introduced new dashboard header component for enhanced visual appeal.
- Removed deprecated theme switcher and related components for cleaner codebase.
- Improved overall UI consistency and responsiveness across various components.
2026-02-03 12:52:05 +08:00
yyhuni
50c907084a feat(dashboard): enhance dashboard UI with new theme variables and metadata
- Updated theme variables for improved visual consistency and aesthetics.
- Added metadata and slot information to stats for better context.
- Introduced new UI elements for displaying operational metadata.
- Enhanced background and panel styles for a more modern look.
2026-02-02 23:58:25 +08:00
yyhuni
3a6d8775a4 chore: update CI workflows and add new test configurations
- Added a new test workflow to run comprehensive tests for the worker, server, and agent components.
- Updated the main CI configuration to include the new test workflow.
- Added a new Makefile for the agent to manage build, run, test, and lint tasks.
- Updated .gitignore to exclude additional IDE files.
- Removed redundant test steps from the CI configuration for cleaner execution.
2026-02-02 23:25:09 +08:00
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
645 changed files with 48672 additions and 8230 deletions

View File

@@ -8,14 +8,14 @@ permissions:
jobs:
check:
runs-on: ubuntu-latest
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.21
go-version: '1.24' # 与 go.mod 保持一致
- name: Generate files for all workflows
working-directory: worker
@@ -39,7 +39,3 @@ jobs:
- name: Run metadata consistency tests
working-directory: worker
run: make test-metadata
- name: Run all tests
working-directory: worker
run: make test

16
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,16 @@
name: CI
on:
push:
branches: [main, develop]
pull_request:
permissions:
contents: read
jobs:
check-generated:
uses: ./.github/workflows/check-generated-files.yml
test:
uses: ./.github/workflows/test.yml

30
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: Run Tests
on:
workflow_call: # 只在被其他 workflow 调用时运行
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
- name: Run worker tests
working-directory: worker
run: make test
- name: Run server tests
working-directory: server
run: make test
- name: Run agent tests
working-directory: agent
run: make test

13
.gitignore vendored
View File

@@ -17,6 +17,7 @@ bin/
# IDE
.vscode/
.idea/
.cursor/
*.swp
*.swo
*~
@@ -26,6 +27,9 @@ bin/
.env
.env.local
.env.*.local
**/.env
**/.env.local
**/.env.*.local
*.log
.venv/
@@ -49,3 +53,12 @@ openspec/
specs/
AGENTS.md
WARP.md
.opencode/
# Playwright MCP screenshots
.playwright-mcp/
# SSL certificates
docker/nginx/ssl/*.pem
docker/nginx/ssl/*.key
docker/nginx/ssl/*.crt

View File

@@ -1,4 +0,0 @@
{
"typescript.autoClosingTags": false,
"kiroAgent.configureMCP": "Enabled"
}

1
VERSION Normal file
View File

@@ -0,0 +1 @@
v1.5.12-dev

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

36
agent/Makefile Normal file
View File

@@ -0,0 +1,36 @@
.PHONY: build run test lint clean deps fmt
# Build the agent binary
build:
go build -o bin/agent ./cmd/agent
# Run the agent
run:
go run ./cmd/agent
# Run tests
test:
go test -v ./...
# Run tests with coverage
test-coverage:
go test -v -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
# Run linter
lint:
golangci-lint run ./...
# Clean build artifacts
clean:
rm -rf bin/
rm -f coverage.out coverage.html
# Download dependencies
deps:
go mod download
go mod tidy
# Format code
fmt:
go fmt ./...

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

View File

@@ -1,11 +1,13 @@
module github.com/yyhuni/orbit/agent
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 (
@@ -13,20 +15,34 @@ require (
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/opencontainers/image-spec v1.1.1 // 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
)

View File

@@ -1,16 +1,21 @@
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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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 v24.0.7+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
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=
@@ -24,55 +29,103 @@ 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.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM=
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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
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.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
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.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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,35 @@
package update
import (
"math/rand"
"strings"
"testing"
"time"
"github.com/yyhuni/lunafox/agent/internal/domain"
)
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.
}

38
docker/.env.example Normal file
View File

@@ -0,0 +1,38 @@
# ============================================
# 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_PASSWORD=postgres
REDIS_HOST=redis
# ============================================
# 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

View File

@@ -0,0 +1,130 @@
name: lunafox
services:
# Agent 请通过安装脚本注册启动(/api/agents/install.sh
postgres:
image: postgres:16.3-alpine
restart: "on-failure:3"
environment:
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
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres}"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7.4.7-alpine
restart: "on-failure:3"
ports:
- "${REDIS_PORT:-6379}:6379"
healthcheck:
test: [CMD, redis-cli, ping]
interval: 5s
timeout: 5s
retries: 5
server:
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:
- "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/lunafox:/opt/lunafox
- /var/run/docker.sock:/var/run/docker.sock
- ../server:/workspace/server
- go-mod-cache:/go/pkg/mod
- go-build-cache:/root/.cache/go-build
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:8080/health || exit 1"]
interval: 5s
timeout: 5s
retries: 30
start_period: 60s
frontend:
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
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
image: yyhuni/lunafox-nginx:${IMAGE_TAG:-dev}
restart: "on-failure:3"
depends_on:
server:
condition: service_healthy
frontend:
condition: service_healthy
ports:
- "8083:8083"
volumes:
- ./nginx/ssl:/etc/nginx/ssl:ro
# Worker: build image for task execution (not run in dev by default).
worker:
build:
context: ../worker
dockerfile: Dockerfile
image: yyhuni/lunafox-worker:${IMAGE_TAG:-dev}
restart: "no"
volumes:
- /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: lunafox_network # Fixed network name, independent of directory name

87
docker/docker-compose.yml Normal file
View File

@@ -0,0 +1,87 @@
# ============================================
# 生产环境配置 - 使用 Docker Hub 预构建镜像
# ============================================
name: lunafox
services:
# PostgreSQL可选使用远程数据库时不启动
postgres:
profiles: ["local-db"]
image: postgres:16.3-alpine
restart: always
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "${DB_PORT}:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7.4.7-alpine
restart: always
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
server:
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/lunafox:/opt/lunafox
- /var/run/docker.sock:/var/run/docker.sock
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
frontend:
image: yyhuni/lunafox-frontend:${IMAGE_TAG:?IMAGE_TAG is required}
restart: always
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: yyhuni/lunafox-nginx:${IMAGE_TAG:?IMAGE_TAG is required}
restart: always
depends_on:
server:
condition: service_healthy
frontend:
condition: service_healthy
ports:
- "8083:8083"
volumes:
- ./nginx/ssl:/etc/nginx/ssl:ro
volumes:
postgres_data:
networks:
default:
name: lunafox_network # 固定网络名,不随目录名变化

5
docker/nginx/Dockerfile Normal file
View File

@@ -0,0 +1,5 @@
FROM nginx:1.28.1-alpine
# 复制 nginx 配置和证书
COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf
COPY docker/nginx/ssl /etc/nginx/ssl

101
docker/nginx/nginx.conf Normal file
View File

@@ -0,0 +1,101 @@
worker_processes auto;
events { worker_connections 1024; }
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
# 上游服务
upstream backend {
server server:8080;
}
upstream frontend {
server frontend:3000;
}
# HTTPS 反代(将证书放在 /docker/nginx/ssl 下映射到 /etc/nginx/ssl
server {
listen 8083 ssl http2;
server_name _;
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
client_max_body_size 50m;
# HTTP 请求到 HTTPS 端口时自动跳转
error_page 497 =301 https://$host:$server_port$request_uri;
# 指纹特征
add_header X-Powered-By "LunaFox ASM" always;
# Agent WebSocket
location /api/agents/ws {
proxy_pass http://backend;
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; # 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;
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_pass http://frontend;
}
}
}

View File

@@ -0,0 +1,348 @@
<!-- This file is auto-generated by go generate. DO NOT EDIT. -->
<!-- Source: worker/internal/workflow/subdomain_discovery/templates.yaml -->
<!-- Generate: go generate ./internal/workflow/subdomain_discovery/ -->
<!-- Generated At (UTC): 2026-02-02T12:11:49Z -->
# Subdomain Discovery
> Discover all subdomains of target domains through reconnaissance, dictionary bruteforce, and permutation
**Workflow Name**: subdomain_discovery
**Version**: 1.0.0
**Target Types**: domain
## Scan Workflow
This workflow includes the following stages:
### 1. Reconnaissance (`recon`)
Collect subdomains from multiple data sources without directly probing the target
**Properties**:
- Required stage
- Supports parallel execution
### 2. Dictionary Bruteforce (`bruteforce`)
Bruteforce domains using dictionaries to discover unpublished subdomains
**Properties**:
- Optional stage
### 3. Permutation (`permutation`)
Generate new possible subdomains by permuting discovered subdomains
**Stage Notes**:
- Permutation performs a wildcard sampling check before execution and may skip if wildcard is detected.
**Properties**:
- Optional stage
### 4. Resolution (`resolve`)
Resolve all discovered subdomains to IP addresses and verify they are alive
**Properties**:
- Optional stage
## Tool Configuration
### Reconnaissance Stage Tools
#### Assetfinder
**Tool Name**: `assetfinder`
**Description**: Find domain-related assets from multiple data sources
**Base Command**:
```shell
assetfinder --subs-only {{.Domain}} > {{quote .OutputFile}}
```
**Runtime Parameters**:
| Parameter | Type | Default | Required | Description |
|-----------|------|---------|----------|-------------|
| `timeout-runtime` | integer | - | Yes | Scan timeout in seconds |
**Configuration Example**:
```yaml
recon:
enabled: true
tools:
assetfinder:
enabled: true
timeout-runtime: 3600 # Scan timeout in seconds
```
#### Subfinder
**Tool Name**: `subfinder`
**Description**: Collect subdomains using multiple data sources (Shodan, Censys, VirusTotal, etc.)
**Base Command**:
```shell
subfinder -d {{.Domain}} -all -o {{quote .OutputFile}} -v{{if .ProviderConfig}} -pc {{quote .ProviderConfig}}{{end}}
```
**Runtime Parameters**:
| Parameter | Type | Default | Required | Description |
|-----------|------|---------|----------|-------------|
| `timeout-runtime` | integer | - | Yes | Scan timeout in seconds |
**CLI Parameters**:
| Parameter | Type | Default | Required | Description |
|-----------|------|---------|----------|-------------|
| `threads-cli` | integer | - | Yes | Number of concurrent threads |
**Configuration Example**:
```yaml
recon:
enabled: true
tools:
subfinder:
enabled: true
timeout-runtime: 3600 # Scan timeout in seconds
threads-cli: 10 # Number of concurrent threads
```
### Dictionary Bruteforce Stage Tools
#### Subdomain Bruteforce
**Tool Name**: `subdomain-bruteforce`
**Description**: DNS bruteforce domains using dictionaries
**Base Command**:
```shell
puredns bruteforce {{quote .Wordlist}} {{.Domain}} -r {{quote .Resolvers}} --write {{quote .OutputFile}} --quiet
```
**Internal Parameters**:
- `resolvers-path-cli`: /opt/lunafox/wordlists/resolvers.txt
- `subdomain-wordlist-base-path-runtime`: /opt/lunafox/wordlists
**Runtime Parameters**:
| Parameter | Type | Default | Required | Description |
|-----------|------|---------|----------|-------------|
| `timeout-runtime` | integer | - | Yes | Scan timeout in seconds |
| `subdomain-wordlist-name-runtime` | string | - | Yes | Subdomain wordlist name (stored on server) |
**CLI Parameters**:
| Parameter | Type | Default | Required | Description |
|-----------|------|---------|----------|-------------|
| `threads-cli` | integer | - | Yes | Number of concurrent threads |
| `rate-limit-cli` | integer | - | Yes | Rate limit per second |
| `wildcard-tests-cli` | integer | - | Yes | Number of wildcard detection tests |
| `wildcard-batch-cli` | integer | - | Yes | Wildcard batch processing size |
**Configuration Example**:
```yaml
bruteforce:
enabled: true
tools:
subdomain-bruteforce:
enabled: true
timeout-runtime: 3600 # Scan timeout in seconds
subdomain-wordlist-name-runtime: subdomains-top1million-110000.txt # Subdomain wordlist name (stored on server)
threads-cli: 100 # Number of concurrent threads
rate-limit-cli: 150 # Rate limit per second
wildcard-tests-cli: 50 # Number of wildcard detection tests
wildcard-batch-cli: 1000000 # Wildcard batch processing size
```
### Permutation Stage Tools
#### Subdomain Permutation Resolve
**Tool Name**: `subdomain-permutation-resolve`
**Description**: Generate new possible subdomains by permuting discovered subdomains and resolve them
**Base Command**:
```shell
cat {{quote .InputFile}} | dnsgen - | puredns resolve -r {{quote .Resolvers}} --write {{quote .OutputFile}} --quiet
```
**Internal Parameters**:
- `resolvers-path-cli`: /opt/lunafox/wordlists/resolvers.txt
**Runtime Parameters**:
| Parameter | Type | Default | Required | Description |
|-----------|------|---------|----------|-------------|
| `timeout-runtime` | integer | - | Yes | Scan timeout in seconds |
| `wildcard-sample-timeout-runtime` | integer | - | Yes | Wildcard sample timeout in seconds |
| `wildcard-sample-multiplier-runtime` | integer | - | Yes | Wildcard sample size multiplier |
| `wildcard-expansion-threshold-runtime` | integer | - | Yes | Wildcard expansion ratio threshold |
**CLI Parameters**:
| Parameter | Type | Default | Required | Description |
|-----------|------|---------|----------|-------------|
| `threads-cli` | integer | - | Yes | Number of concurrent threads |
| `rate-limit-cli` | integer | - | Yes | Rate limit per second |
| `wildcard-tests-cli` | integer | - | Yes | Number of wildcard detection tests |
| `wildcard-batch-cli` | integer | - | Yes | Wildcard batch processing size |
**Configuration Example**:
```yaml
permutation:
enabled: true
tools:
subdomain-permutation-resolve:
enabled: true
timeout-runtime: 3600 # Scan timeout in seconds
wildcard-sample-timeout-runtime: 7200 # Wildcard sample timeout in seconds
wildcard-sample-multiplier-runtime: 100 # Wildcard sample size multiplier
wildcard-expansion-threshold-runtime: 50 # Wildcard expansion ratio threshold
threads-cli: 100 # Number of concurrent threads
rate-limit-cli: 150 # Rate limit per second
wildcard-tests-cli: 50 # Number of wildcard detection tests
wildcard-batch-cli: 1000000 # Wildcard batch processing size
```
### Resolution Stage Tools
#### Subdomain Resolve
**Tool Name**: `subdomain-resolve`
**Description**: Resolve subdomain list to verify their validity
**Base Command**:
```shell
puredns resolve {{quote .InputFile}} -r {{quote .Resolvers}} --write {{quote .OutputFile}} --quiet
```
**Internal Parameters**:
- `resolvers-path-cli`: /opt/lunafox/wordlists/resolvers.txt
**Runtime Parameters**:
| Parameter | Type | Default | Required | Description |
|-----------|------|---------|----------|-------------|
| `timeout-runtime` | integer | - | Yes | Scan timeout in seconds |
**CLI Parameters**:
| Parameter | Type | Default | Required | Description |
|-----------|------|---------|----------|-------------|
| `threads-cli` | integer | - | Yes | Number of concurrent threads |
| `rate-limit-cli` | integer | - | Yes | Rate limit per second |
**Configuration Example**:
```yaml
resolve:
enabled: true
tools:
subdomain-resolve:
enabled: true
timeout-runtime: 3600 # Scan timeout in seconds
threads-cli: 100 # Number of concurrent threads
rate-limit-cli: 150 # Rate limit per second
```
## Configuration Examples
```yaml
# Subdomain Discovery
# Discover all subdomains of target domains through reconnaissance, dictionary bruteforce, and permutation
# Configuration
# This file is auto-generated from templates.yaml
# DO NOT EDIT manually - run: go generate ./internal/workflow/subdomain_discovery/
#
# Stage 1: Reconnaissance (parallel execution)
# Collect subdomains from multiple data sources without directly probing the target
recon:
enabled: true
tools:
# Find domain-related assets from multiple data sources
assetfinder:
enabled: true
timeout-runtime: 3600 # Scan timeout in seconds
# Collect subdomains using multiple data sources (Shodan, Censys, VirusTotal, etc.)
subfinder:
enabled: true
timeout-runtime: 3600 # Scan timeout in seconds
threads-cli: 10 # Number of concurrent threads
# Stage 2: Dictionary Bruteforce
# Bruteforce domains using dictionaries to discover unpublished subdomains
bruteforce:
enabled: false
tools:
# DNS bruteforce domains using dictionaries
subdomain-bruteforce:
enabled: true
timeout-runtime: 3600 # Scan timeout in seconds
subdomain-wordlist-name-runtime: subdomains-top1million-110000.txt # Subdomain wordlist name (stored on server)
threads-cli: 100 # Number of concurrent threads
rate-limit-cli: 150 # Rate limit per second
wildcard-tests-cli: 50 # Number of wildcard detection tests
wildcard-batch-cli: 1000000 # Wildcard batch processing size
# Stage 3: Permutation
# Generate new possible subdomains by permuting discovered subdomains
permutation:
enabled: false
tools:
# Generate new possible subdomains by permuting discovered subdomains and resolve them
subdomain-permutation-resolve:
enabled: true
timeout-runtime: 3600 # Scan timeout in seconds
wildcard-sample-timeout-runtime: 7200 # Wildcard sample timeout in seconds
wildcard-sample-multiplier-runtime: 100 # Wildcard sample size multiplier
wildcard-expansion-threshold-runtime: 50 # Wildcard expansion ratio threshold
threads-cli: 100 # Number of concurrent threads
rate-limit-cli: 150 # Rate limit per second
wildcard-tests-cli: 50 # Number of wildcard detection tests
wildcard-batch-cli: 1000000 # Wildcard batch processing size
# Stage 4: Resolution
# Resolve all discovered subdomains to IP addresses and verify they are alive
resolve:
enabled: false
tools:
# Resolve subdomain list to verify their validity
subdomain-resolve:
enabled: true
timeout-runtime: 3600 # Scan timeout in seconds
threads-cli: 100 # Number of concurrent threads
rate-limit-cli: 150 # Rate limit per second
```
## Notes
### Global Notes
- This workflow may generate active scanning traffic. Use with caution.
### Config Notes
- Parameters use kebab-case (e.g., timeout-runtime, rate-limit-cli).
- internal_params are fixed and cannot be overridden in config.

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

View File

@@ -0,0 +1 @@
../../.agents/skills/vercel-composition-patterns

View File

@@ -0,0 +1 @@
../../.agents/skills/vercel-react-best-practices

View File

@@ -0,0 +1 @@
../../.agents/skills/vercel-react-native-skills

View File

@@ -0,0 +1 @@
../../.agents/skills/web-design-guidelines

12
frontend/.gitignore vendored
View File

@@ -9,6 +9,7 @@
!.yarn/plugins
!.yarn/releases
!.yarn/versions
.pnpm-store/
# testing
/coverage
@@ -40,4 +41,13 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts
certificates
certificates
# agent skills & tool configs
.agent/skills
.agents/skills
.codebuddy
.codex
.gemini
.qoder
.trae
.windsurf

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

@@ -1,7 +1,5 @@
import { DashboardStatCards } from "@/components/dashboard/dashboard-stat-cards"
import { AssetTrendChart } from "@/components/dashboard/asset-trend-chart"
import { VulnSeverityChart } from "@/components/dashboard/vuln-severity-chart"
import { DashboardDataTable } from "@/components/dashboard/dashboard-data-table"
import { BauhausDashboardHeader } from "@/components/dashboard/bauhaus-dashboard-header"
import { DashboardLazySections } from "@/components/dashboard/dashboard-lazy-sections"
/**
* Dashboard page component
@@ -11,23 +9,11 @@ import { DashboardDataTable } from "@/components/dashboard/dashboard-data-table"
export default function Page() {
return (
// Content area containing cards, charts and data tables
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6 animate-dashboard-fade-in">
{/* Top statistics cards */}
<DashboardStatCards />
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
{/* Bauhaus 风格 Dashboard Header - 仅在 Bauhaus 主题下显示 */}
<BauhausDashboardHeader />
{/* Chart area - Trend chart + Vulnerability distribution */}
<div className="grid gap-4 px-4 lg:px-6 @xl/main:grid-cols-2">
{/* Asset trend line chart */}
<AssetTrendChart />
{/* Vulnerability severity distribution */}
<VulnSeverityChart />
</div>
{/* Vulnerabilities / Scan history tab */}
<div className="px-4 lg:px-6">
<DashboardDataTable />
</div>
<DashboardLazySections />
</div>
)
}

View File

@@ -4,27 +4,18 @@ import { NextIntlClientProvider } from 'next-intl'
import { getMessages, setRequestLocale, getTranslations } from 'next-intl/server'
import { notFound } from 'next/navigation'
import { locales, localeHtmlLang, type Locale } from '@/i18n/config'
import { DEFAULT_COLOR_THEME_ID } from "@/lib/color-themes"
// Import global style files
import "../globals.css"
// Import Noto Sans SC local font
import "@fontsource/noto-sans-sc/400.css"
import "@fontsource/noto-sans-sc/500.css"
import "@fontsource/noto-sans-sc/700.css"
// Font faces are declared in globals.css
// Import color themes
import "@/styles/themes/bubblegum.css"
import "@/styles/themes/quantum-rose.css"
import "@/styles/themes/clean-slate.css"
import "@/styles/themes/cosmic-night.css"
import "@/styles/themes/vercel.css"
import "@/styles/themes/vercel-dark.css"
import "@/styles/themes/violet-bloom.css"
import "@/styles/themes/cyberpunk-1.css"
import "@/styles/themes/bauhaus.css"
import { Suspense } from "react"
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,10 +31,14 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
title: t('title'),
description: t('description'),
keywords: t('keywords').split(',').map(k => k.trim()),
generator: "Orbit ASM Platform",
generator: "LunaFox ASM Platform",
authors: [{ name: "yyhuni" }],
icons: {
icon: [{ url: "/icon.svg", type: "image/svg+xml" }],
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'),
@@ -58,11 +53,11 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
}
}
// Use Noto Sans SC + system font fallback, fully loaded locally
// Use MiSans + system font fallback, fully loaded locally
const fontConfig = {
className: "font-sans",
style: {
fontFamily: "'Noto Sans SC', system-ui, -apple-system, PingFang SC, Hiragino Sans GB, Microsoft YaHei, sans-serif"
fontFamily: "'MiSans', system-ui, -apple-system, PingFang SC, Hiragino Sans GB, Microsoft YaHei, sans-serif"
}
}
@@ -97,26 +92,22 @@ export default async function LocaleLayout({
// Load translation messages
const messages = await getMessages()
const themeId = DEFAULT_COLOR_THEME_ID
return (
<html lang={localeHtmlLang[locale as Locale]} suppressHydrationWarning>
<html
lang={localeHtmlLang[locale as Locale]}
data-theme={themeId}
suppressHydrationWarning
>
<body className={fontConfig.className} style={fontConfig.style}>
{/* Load external scripts */}
<Script
src="https://tweakcn.com/live-preview.min.js"
strategy="beforeInteractive"
crossOrigin="anonymous"
/>
<ColorThemeInit />
{/* Route loading progress bar */}
<Suspense fallback={null}>
<RouteProgress />
</Suspense>
{/* ThemeProvider provides theme switching functionality */}
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem
disableTransitionOnChange
>
<ThemeProvider>
{/* NextIntlClientProvider provides internationalization context */}
<NextIntlClientProvider messages={messages}>
{/* QueryProvider provides React Query functionality */}

View File

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

View File

@@ -2,41 +2,84 @@
import React from "react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { useLocale, useTranslations } from "next-intl"
import { useQueryClient } from "@tanstack/react-query"
import dynamic from "next/dynamic"
import { LoginBootScreen } from "@/components/auth/login-boot-screen"
import { TerminalLogin } from "@/components/ui/terminal-login"
import { LoadingState } from "@/components/loading-spinner"
import { useLogin, useAuth } from "@/hooks/use-auth"
import { vulnerabilityKeys } from "@/hooks/use-vulnerabilities"
import { useRoutePrefetch } from "@/hooks/use-route-prefetch"
import { getAssetStatistics, getStatisticsHistory } from "@/services/dashboard.service"
import { getScans } from "@/services/scan.service"
import { VulnerabilityService } from "@/services/vulnerability.service"
// Dynamic import to avoid SSR issues with WebGL
const PixelBlast = dynamic(() => import("@/components/PixelBlast"), { ssr: false })
const BOOT_SPLASH_MS = 600
const BOOT_FADE_MS = 200
type BootOverlayPhase = "entering" | "visible" | "leaving" | "hidden"
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 { mutateAsync: login, isPending } = useLogin()
const t = useTranslations("auth.terminal")
const locale = useLocale()
const loginStartedRef = React.useRef(false)
const [loginReady, setLoginReady] = React.useState(false)
const [pixelFirstFrame, setPixelFirstFrame] = React.useState(false)
const handlePixelFirstFrame = React.useCallback(() => {
setPixelFirstFrame(true)
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(() => {
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
}
}, [])
// 提取预加载逻辑为可复用函数
@@ -64,43 +107,6 @@ export default function LoginPage() {
])
}, [queryClient])
// Always show a short splash on entering the login page.
const [bootMinDone, setBootMinDone] = React.useState(false)
const [bootPhase, setBootPhase] = React.useState<BootOverlayPhase>("entering")
React.useEffect(() => {
setBootMinDone(false)
setBootPhase("entering")
const bootTimer = setTimeout(() => setBootMinDone(true), BOOT_SPLASH_MS)
const raf = requestAnimationFrame(() => setBootPhase("visible"))
return () => {
clearTimeout(bootTimer)
cancelAnimationFrame(raf)
}
}, [])
// Start hiding the splash after the minimum time AND auth check completes.
// Note: don't schedule the fade-out timer in the same effect where we set `bootPhase`,
// otherwise the effect cleanup will cancel the timer when `bootPhase` changes.
React.useEffect(() => {
if (bootPhase !== "visible") return
if (!bootMinDone) return
if (authLoading) return
if (!pixelFirstFrame) return
setBootPhase("leaving")
}, [authLoading, bootMinDone, bootPhase, pixelFirstFrame])
React.useEffect(() => {
if (bootPhase !== "leaving") return
const timer = setTimeout(() => setBootPhase("hidden"), BOOT_FADE_MS)
return () => clearTimeout(timer)
}, [bootPhase])
// Memoize translations object to avoid recreating on every render
const translations = React.useMemo(() => ({
title: t("title"),
@@ -127,32 +133,49 @@ export default function LoginPage() {
if (loginStartedRef.current) return
let cancelled = false
let timer: number | undefined
void (async () => {
setLoginProcessing(true)
await prefetchDashboardData()
if (cancelled) return
router.replace("/dashboard/")
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])
}, [auth?.authenticated, authLoading, prefetchDashboardData, router, withLocale])
React.useEffect(() => {
if (!loginReady) return
router.replace("/dashboard/")
}, [loginReady, router])
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("/dashboard/"),
router.prefetch(withLocale("/dashboard/")),
])
// 预加载 dashboard 数据
@@ -164,33 +187,145 @@ export default function LoginPage() {
user: loginRes.user,
})
setLoginProcessing(false)
setLoginReady(true)
}
const loginVisible = bootPhase === "leaving" || bootPhase === "hidden"
return (
<div className="relative flex min-h-svh flex-col bg-black">
<div className={`fixed inset-0 z-0 transition-opacity duration-300 ${loginVisible ? "opacity-100" : "opacity-0"}`}>
<PixelBlast
onFirstFrame={handlePixelFirstFrame}
className=""
style={{}}
pixelSize={6.5}
patternScale={4.5}
color="#FF10F0"
speed={0.35}
enableRipples={false}
<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="Orbit ASM Platform" />
<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 ${
loginVisible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-2"
isReady ? "opacity-100 translate-y-0" : "opacity-0 translate-y-2"
}`}
>
<TerminalLogin
@@ -198,31 +333,22 @@ export default function LoginPage() {
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={`relative z-10 flex-shrink-0 text-center py-4 transition-opacity duration-300 ${
loginVisible ? "opacity-100" : "opacity-0"
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>
{/* Full-page splash overlay */}
{bootPhase !== "hidden" && (
<div
className={`fixed inset-0 z-50 transition-opacity ease-out ${
bootPhase === "visible" ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
style={{ transitionDuration: `${BOOT_FADE_MS}ms` }}
>
<LoginBootScreen />
</div>
)}
</div>
)
}

View File

@@ -1,11 +1,18 @@
"use client"
// Import organization management component
import { OrganizationList } from "@/components/organization/organization-list"
// Import icons
import { Building2 } from "lucide-react"
import dynamic from "next/dynamic"
import { DataTableSkeleton } from "@/components/ui/data-table-skeleton"
import { PageHeader } from "@/components/common/page-header"
import { useTranslations } from "next-intl"
const OrganizationList = dynamic(
() => import("@/components/organization/organization-list").then((mod) => mod.OrganizationList),
{
ssr: false,
loading: () => <DataTableSkeleton rows={6} columns={4} withPadding />,
}
)
/**
* Organization management page
* Sub-page under asset management that displays organization list and related operations
@@ -14,20 +21,12 @@ export default function OrganizationPage() {
const t = useTranslations("pages.organization")
return (
// Content area containing organization management features
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
{/* Page header */}
<div className="flex items-center justify-between px-4 lg:px-6">
<div>
<h2 className="text-2xl font-bold tracking-tight flex items-center gap-2">
<Building2 />
{t("title")}
</h2>
<p className="text-muted-foreground">
{t("description")}
</p>
</div>
</div>
<PageHeader
code="ORG-01"
title={t("title")}
description={t("description")}
/>
{/* Organization list component */}
<div className="px-4 lg:px-6">

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,493 @@
"use client"
import type { CSSProperties } from "react"
import {
Activity,
AlarmClock,
AlignLeft,
Crosshair,
Database,
Hexagon,
Radar,
Shield,
Target,
Zap,
} from "@/components/icons"
const themeVars = {
"--ark-bg": "#FFFFFF",
"--ark-bg-2": "#FFFFFF",
"--ark-panel": "#FFFFFF",
"--ark-panel-2": "#F6F7F9",
"--ark-border": "#E4E8ED",
"--ark-border-strong": "#CCD3DC",
"--ark-text": "#20252B",
"--ark-text-2": "#6E7682",
"--ark-muted": "#A0A7B2",
"--ark-accent": "#20252B",
"--ark-accent-2": "#20252B",
"--ark-warn": "#20252B",
"--ark-danger": "#20252B",
fontFamily: "\"MiSans\", \"IBM Plex Sans\", \"Segoe UI\", sans-serif",
} as CSSProperties
const quickStats = [
{ label: "Signal Integrity", value: "98.2%", delta: "+1.4%" },
{ label: "Relay Latency", value: "19 ms", delta: "-4 ms" },
{ label: "Threat Index", value: "0.32", delta: "Stable" },
]
const modules = [
{
title: "Operation Overview",
desc: "Live telemetry for core objectives and readiness.",
status: "Active",
accent: "var(--ark-accent)",
},
{
title: "Asset Recon",
desc: "27 assets scanned · 6 elevated risk vectors.",
status: "Recon",
accent: "var(--ark-accent-2)",
},
{
title: "Defense Matrix",
desc: "Critical services hardened · 2 patches pending.",
status: "Shield",
accent: "var(--ark-muted)",
},
]
const queue = [
{ id: "RX-102", name: "Perimeter Sweep", status: "Queued" },
{ id: "LF-551", name: "Payload Trace", status: "Executing" },
{ id: "LX-209", name: "Credential Drift", status: "Hold" },
]
const alerts = [
{
title: "Outbound anomaly on Node A-3",
meta: "Packet ratio +18% · 2m ago",
level: "High",
},
{
title: "New fingerprint detected",
meta: "Signature: ALB-17 · 11m ago",
level: "Medium",
},
{
title: "Backup cycle complete",
meta: "Snapshot #311 · 28m ago",
level: "Low",
},
]
const assets = [
{ id: "S-14", type: "Web Gateway", status: "Stable", score: "A" },
{ id: "K-07", type: "Data Relay", status: "Monitor", score: "B" },
{ id: "P-21", type: "API Node", status: "Harden", score: "A-" },
{ id: "R-02", type: "Edge Cache", status: "Observe", score: "C+" },
]
const navItems = [
{ id: "radar", Icon: Radar },
{ id: "target", Icon: Target },
{ id: "crosshair", Icon: Crosshair },
{ id: "shield", Icon: Shield },
{ id: "database", Icon: Database },
]
export default function ArknightsUiPrototypePage() {
return (
<main
className="relative min-h-screen overflow-hidden bg-[var(--ark-bg)] text-[var(--ark-text)]"
style={themeVars}
>
<div className="pointer-events-none absolute inset-0 ark-grid" aria-hidden />
<div className="pointer-events-none absolute left-8 top-14 h-40 w-40 industrial-ring" aria-hidden />
<div className="pointer-events-none absolute right-12 top-24 h-20 w-64 industrial-slab" aria-hidden />
<div className="pointer-events-none absolute left-16 bottom-16 h-14 w-72 industrial-slab thin" aria-hidden />
<div className="pointer-events-none absolute left-12 top-40 h-[3px] w-[260px] industrial-beam" aria-hidden />
<div className="relative mx-auto flex w-full max-w-6xl flex-col gap-6 px-6 py-8 lg:px-10">
<header className="ark-panel ark-hero flex flex-col gap-4 p-5 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center border border-[var(--ark-border)] bg-[var(--ark-panel-2)]">
<Hexagon className="h-6 w-6 text-[var(--ark-accent)]" />
</div>
<div>
<p className="text-xs uppercase tracking-[0.3em] text-[var(--ark-text-2)]">LunaFox Bauhaus Console</p>
<h1 className="text-xl font-semibold tracking-wide">Bauhaus / Industrial UI</h1>
</div>
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-[var(--ark-text-2)]">
<span className="ark-tag">ZONE-09</span>
<span className="ark-tag">OPERATOR READY</span>
<span className="ark-tag">
<AlarmClock className="h-3.5 w-3.5 text-[var(--ark-accent)]" />
21:06:44 UTC
</span>
</div>
</header>
<section className="grid grid-cols-12 gap-4">
<aside className="col-span-12 flex gap-3 lg:col-span-2 lg:flex-col">
{navItems.map(({ id, Icon }) => (
<button
key={id}
className="ark-nav"
type="button"
>
<Icon className="h-5 w-5" />
</button>
))}
</aside>
<div className="col-span-12 grid gap-4 lg:col-span-7">
<div className="ark-panel">
<div className="flex flex-col gap-4 border-b border-[var(--ark-border)] p-4">
<div className="flex items-center justify-between">
<div>
<p className="ark-kicker">PRIMARY STATUS</p>
<h2 className="text-lg font-semibold">Operation Mercury</h2>
</div>
<span className="ark-tag">STAGE 3 · ACTIVE</span>
</div>
<div className="grid gap-3 sm:grid-cols-3">
{quickStats.map((stat) => (
<div key={stat.label} className="ark-slab p-3">
<p className="text-xs text-[var(--ark-text-2)]">{stat.label}</p>
<div className="mt-2 flex items-center justify-between">
<span className="text-lg font-semibold text-[var(--ark-accent)]">{stat.value}</span>
<span className="text-xs text-[var(--ark-text-2)]">{stat.delta}</span>
</div>
</div>
))}
</div>
</div>
<div className="grid gap-4 p-4 lg:grid-cols-[1.2fr_0.8fr]">
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="ark-kicker">TACTICAL FEED</p>
<button className="ark-button" type="button">Deploy</button>
</div>
<div className="ark-slab ark-graph h-36 p-3">
<div className="flex h-full items-end gap-2">
{Array.from({ length: 18 }).map((_, index) => (
<span
key={`bar-${index}`}
className="w-full rounded-sm"
style={{
height: `${20 + (index % 6) * 12}%`,
background: "linear-gradient(180deg, rgba(60,66,74,0.85), rgba(60,66,74,0.2))",
}}
/>
))}
</div>
</div>
<div className="flex items-center justify-between text-xs text-[var(--ark-text-2)]">
<span>Last sync 42s ago</span>
<span className="flex items-center gap-2 text-[var(--ark-text-2)]">
<Activity className="h-3.5 w-3.5" />
Live streaming
</span>
</div>
</div>
<div className="space-y-3">
<p className="ark-kicker">QUEUE</p>
<div className="space-y-2">
{queue.map((item) => (
<div key={item.id} className="ark-slab flex items-center justify-between px-3 py-2">
<div>
<p className="text-sm font-medium">{item.name}</p>
<p className="text-xs text-[var(--ark-text-2)]">{item.id}</p>
</div>
<span className="ark-chip">{item.status}</span>
</div>
))}
</div>
<div className="ark-slab p-3 text-xs text-[var(--ark-text-2)]">
Next dispatch in 06:12 · Priority window open.
</div>
</div>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-3">
{modules.map((module) => (
<div key={module.title} className="ark-panel">
<div className="border-b border-[var(--ark-border)] p-4">
<div className="flex items-center justify-between">
<p className="ark-kicker">MODULE</p>
<span className="ark-dot" style={{ background: module.accent }} />
</div>
<h3 className="mt-2 text-base font-semibold">{module.title}</h3>
</div>
<div className="space-y-3 p-4 text-sm text-[var(--ark-text-2)]">
<p>{module.desc}</p>
<div className="flex items-center justify-between text-xs">
<span>Status</span>
<span className="text-[var(--ark-text)]">{module.status}</span>
</div>
</div>
</div>
))}
</div>
</div>
<aside className="col-span-12 grid gap-4 lg:col-span-3">
<div className="ark-panel">
<div className="border-b border-[var(--ark-border)] p-4">
<div className="flex items-center justify-between">
<p className="ark-kicker">SYSTEM ALERTS</p>
<Zap className="h-4 w-4 text-[var(--ark-text-2)]" />
</div>
</div>
<div className="space-y-3 p-4">
{alerts.map((alert) => (
<div key={alert.title} className="ark-slab p-3">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">{alert.title}</p>
<span className="ark-chip">{alert.level}</span>
</div>
<p className="mt-2 text-xs text-[var(--ark-text-2)]">{alert.meta}</p>
</div>
))}
</div>
</div>
<div className="ark-panel">
<div className="border-b border-[var(--ark-border)] p-4">
<div className="flex items-center justify-between">
<p className="ark-kicker">ASSET MATRIX</p>
<AlignLeft className="h-4 w-4 text-[var(--ark-accent)]" />
</div>
</div>
<div className="space-y-2 p-4 text-xs">
{assets.map((asset) => (
<div key={asset.id} className="ark-slab grid grid-cols-[0.6fr_1.4fr_0.8fr_0.4fr] items-center gap-2 px-2 py-2">
<span className="text-[var(--ark-accent)]">{asset.id}</span>
<span>{asset.type}</span>
<span className="text-[var(--ark-text-2)]">{asset.status}</span>
<span className="text-right text-[var(--ark-text)]">{asset.score}</span>
</div>
))}
</div>
</div>
</aside>
</section>
<section className="ark-panel">
<div className="flex flex-col gap-3 border-b border-[var(--ark-border)] p-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="ark-kicker">TACTICAL NOTES</p>
<h2 className="text-lg font-semibold">Operator Briefing</h2>
</div>
<button className="ark-button ghost" type="button">View Log</button>
</div>
<div className="grid gap-4 p-4 lg:grid-cols-[1.3fr_0.7fr]">
<div className="space-y-3 text-sm text-[var(--ark-text-2)]">
<p>
Signal mesh stabilized. Maintain perimeter scans and isolate anomalous traffic.
Deploy countermeasures on request from Sector-7 control.
</p>
<div className="flex flex-wrap gap-2 text-xs">
{[
"Clearance: Sigma",
"Auto-patch enabled",
"Fallback route locked",
"Ops window: 03:00",
].map((tag) => (
<span key={tag} className="ark-tag">{tag}</span>
))}
</div>
</div>
<div className="ark-slab space-y-2 p-4 text-xs text-[var(--ark-text-2)]">
<p className="flex items-center justify-between">
<span>Resupply ETA</span>
<span className="text-[var(--ark-text)]">00:28:15</span>
</p>
<p className="flex items-center justify-between">
<span>Shield sync</span>
<span className="text-[var(--ark-text)]">96.4%</span>
</p>
<p className="flex items-center justify-between">
<span>Command link</span>
<span className="text-[var(--ark-accent)]">Stable</span>
</p>
</div>
</div>
</section>
</div>
<style jsx>{`
.ark-grid {
background-image:
linear-gradient(rgba(32, 37, 43, 0.045) 1px, transparent 1px),
linear-gradient(90deg, rgba(32, 37, 43, 0.045) 1px, transparent 1px),
linear-gradient(rgba(32, 37, 43, 0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(32, 37, 43, 0.02) 1px, transparent 1px);
background-size: 120px 120px, 120px 120px, 24px 24px, 24px 24px;
mask-image: linear-gradient(180deg, transparent 0%, black 8%, black 92%, transparent 100%);
}
.industrial-ring {
border-radius: 999px;
border: 2px solid var(--ark-border-strong);
background: rgba(32, 37, 43, 0.015);
box-shadow: inset 0 0 0 10px rgba(32, 37, 43, 0.01);
opacity: 0.7;
}
.industrial-slab {
border: 2px solid var(--ark-border);
background: var(--ark-panel);
box-shadow: 0 4px 10px rgba(21, 26, 32, 0.025);
opacity: 0.85;
}
.industrial-slab.thin {
border-color: var(--ark-border-strong);
background: var(--ark-panel-2);
box-shadow: 0 4px 8px rgba(21, 26, 32, 0.02);
}
.industrial-beam {
background: linear-gradient(90deg, rgba(32, 37, 43, 0.25), transparent);
opacity: 0.7;
}
.ark-panel {
position: relative;
background: var(--ark-panel);
border: 1px solid var(--ark-border);
border-top: 2px solid var(--ark-border-strong);
box-shadow: 0 6px 12px rgba(21, 26, 32, 0.035);
}
.ark-panel::after {
content: "";
position: absolute;
inset: 10px;
border: 1px solid rgba(32, 37, 43, 0.03);
pointer-events: none;
}
.ark-panel.ark-hero::before {
content: "";
position: absolute;
left: 0;
top: 0;
width: 4px;
height: 100%;
background: var(--ark-border-strong);
pointer-events: none;
}
.ark-slab {
position: relative;
background: var(--ark-panel-2);
border: 1px solid var(--ark-border);
border-top: 2px solid rgba(32, 37, 43, 0.12);
}
.ark-slab::after {
content: "";
position: absolute;
inset: 6px;
border: 1px solid rgba(32, 37, 43, 0.025);
pointer-events: none;
}
.ark-graph {
background-image:
linear-gradient(90deg, rgba(32, 37, 43, 0.07) 1px, transparent 1px),
linear-gradient(180deg, rgba(32, 37, 43, 0.07) 1px, transparent 1px);
background-size: 16px 16px;
}
.ark-nav {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
height: 52px;
border: 1px solid var(--ark-border);
border-left: 3px solid var(--ark-border-strong);
background: var(--ark-panel);
color: var(--ark-text-2);
transition: border-color 0.2s ease, color 0.2s ease;
}
.ark-nav:hover {
color: var(--ark-text);
border-color: var(--ark-border-strong);
}
.ark-kicker {
font-size: 10px;
letter-spacing: 0.3em;
text-transform: uppercase;
color: var(--ark-text-2);
font-weight: 600;
font-family: "IBM Plex Mono", "MiSans", sans-serif;
}
.ark-tag {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid var(--ark-border);
padding: 3px 10px;
font-size: 10px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--ark-text-2);
background: var(--ark-bg-2);
font-family: "IBM Plex Mono", "MiSans", sans-serif;
box-shadow: inset 0 0 0 1px rgba(29, 36, 44, 0.04);
}
.ark-chip {
border: 1px solid var(--ark-border);
padding: 2px 8px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.2em;
color: var(--ark-text-2);
background: transparent;
font-family: "IBM Plex Mono", "MiSans", sans-serif;
}
.ark-button {
border: 2px solid var(--ark-border-strong);
background: var(--ark-accent);
color: #ffffff;
padding: 6px 14px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.22em;
font-family: "IBM Plex Mono", "MiSans", sans-serif;
transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease;
box-shadow: 0 4px 8px rgba(21, 26, 32, 0.08);
}
.ark-button:hover {
background: #14181e;
}
.ark-button.ghost {
border-color: var(--ark-border-strong);
background: transparent;
color: var(--ark-text-2);
}
.ark-dot {
width: 10px;
height: 10px;
border-radius: 999px;
}
`}</style>
</main>
)
}

View File

@@ -0,0 +1,543 @@
"use client"
import React from "react"
import {
Globe,
Network,
Server,
Link2,
FolderOpen,
MoreHorizontal,
ArrowUpRight,
ArrowDownRight,
Activity,
Scan
} from "@/components/icons"
import { Button } from "@/components/ui/button"
const assetCards = [
{ label: "Websites", value: 1284, icon: Globe, trend: 12, status: "healthy" },
{ label: "Subdomains", value: 3921, icon: Network, trend: 5, status: "warning" },
{ label: "IPs", value: 264, icon: Server, trend: -2, status: "critical" },
{ label: "URLs", value: 8421, icon: Link2, trend: 8, status: "healthy" },
{ label: "Directories", value: 1560, icon: FolderOpen, trend: 0, status: "healthy" },
]
function SectionHeader({
title,
description,
}: {
title: string
description: string
}) {
return (
<div className="border-b bg-muted/40 px-4 py-3">
<div className="text-sm font-semibold">{title}</div>
<div className="text-xs text-muted-foreground mt-1">{description}</div>
</div>
)
}
function Grid({ children }: { children: React.ReactNode }) {
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
{children}
</div>
)
}
export default function AssetCardVariantsPage() {
return (
<div className="asset-card-variants flex flex-col gap-8 p-8 max-w-6xl mx-auto">
<div>
<h1 className="text-2xl font-bold mb-2"></h1>
<p className="text-muted-foreground"></p>
</div>
{/* 方案 A */}
<div className="border rounded-xl overflow-hidden shadow-sm bg-background">
<SectionHeader
title="方案 ABauhaus Index"
description="强调编号与工业感,适合与仪表盘风格统一"
/>
<div className="p-4 bg-muted/10">
<Grid>
{assetCards.map((card, index) => (
<div
key={card.label}
className="relative border border-border bg-card pt-8 pb-4 px-3 hover:border-primary/40 transition-colors cursor-pointer motion-lift"
>
<span className="absolute top-2 left-3 text-[10px] font-mono text-muted-foreground">
{String(index + 1).padStart(2, "0")}{" //"}
</span>
<div className="flex items-center justify-between text-xs uppercase tracking-[0.2em] text-muted-foreground">
<span>{card.label}</span>
<card.icon className="h-4 w-4" />
</div>
<div className="mt-3 text-2xl font-semibold tabular-nums">
{card.value.toLocaleString()}
</div>
<div className="mt-3 h-1.5 w-full bg-border/60">
<div className="h-full w-[55%] bg-primary/60" />
</div>
</div>
))}
</Grid>
</div>
</div>
{/* 方案 B */}
<div className="border rounded-xl overflow-hidden shadow-sm bg-background">
<SectionHeader
title="方案 BRail Tag"
description="左侧警戒线 + 结构化信息块,强调可扫描的工业节奏"
/>
<div className="p-4 bg-muted/10">
<Grid>
{assetCards.map((card) => (
<div
key={card.label}
className="relative border border-border bg-card px-3 py-4 hover:border-primary/40 transition-colors cursor-pointer motion-scan"
>
<span className="absolute inset-y-0 left-0 w-[3px] bg-primary/70" />
<div className="flex items-center justify-between text-xs uppercase tracking-[0.2em] text-muted-foreground">
<span>{card.label}</span>
<card.icon className="h-4 w-4" />
</div>
<div className="mt-3 text-2xl font-semibold tabular-nums">
{card.value.toLocaleString()}
</div>
<div className="mt-2 text-xs text-muted-foreground">Last 24h</div>
</div>
))}
</Grid>
</div>
</div>
{/* 方案 C */}
<div className="border rounded-xl overflow-hidden shadow-sm bg-background">
<SectionHeader
title="方案 CSplit Panel"
description="右侧功能舱,清晰区隔信息与图标"
/>
<div className="p-4 bg-muted/10" data-motion="stagger">
<Grid>
{assetCards.map((card, index) => (
<div
key={card.label}
className="flex border border-border bg-card hover:border-primary/40 transition-colors cursor-pointer motion-rise"
style={{ ["--delay" as string]: `${index * 70}ms` } as React.CSSProperties}
>
<div className="flex flex-1 flex-col gap-1 p-3">
<div className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
{card.label}
</div>
<div className="text-2xl font-semibold tabular-nums">
{card.value.toLocaleString()}
</div>
<div className="text-[11px] text-muted-foreground">Assets</div>
</div>
<div className="w-12 border-l border-border bg-secondary/50 flex items-center justify-center">
<card.icon className="h-4 w-4 text-muted-foreground" />
</div>
</div>
))}
</Grid>
</div>
</div>
{/* 方案 D 系列 - Tech Schematic Variations */}
<div className="space-y-6">
<h2 className="text-xl font-bold"> D Tech Schematic ()</h2>
{/* D1: Standard Schematic (Base) */}
<div className="border rounded-xl overflow-hidden shadow-sm bg-background">
<SectionHeader
title="D1: Standard Schematic"
description="基础版:修复了边框颜色,保留虚线与角标,强调静态的工业图纸感"
/>
<div className="p-4 bg-muted/10">
<Grid>
{assetCards.map((card) => (
<div
key={card.label}
className="group relative bg-card p-4 hover:bg-accent/5 transition-all duration-300 cursor-pointer"
>
{/* Schematic Borders - 使用 border-border/40 减轻视觉重量 */}
<div className="absolute inset-0 border border-border/40 border-t-0 group-hover:border-primary/30 transition-colors" />
{/* Corners */}
<div className="absolute top-0 right-0 h-2 w-2 border-r border-t border-primary/50" />
<div className="absolute bottom-0 left-0 h-2 w-2 border-l border-b border-primary/50" />
<div className="flex justify-between items-start mb-2">
<div className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded-sm">
DAT-{card.label.substring(0, 3).toUpperCase()}
</div>
<card.icon className="h-4 w-4 text-muted-foreground/70 group-hover:text-primary transition-colors" />
</div>
<div className="text-3xl font-light tracking-tight text-foreground group-hover:translate-x-1 transition-transform duration-300">
{card.value.toLocaleString()}
</div>
<div className="mt-2 flex items-center gap-2">
<div className="h-px flex-1 bg-border border-t border-dashed border-muted-foreground/20" />
<span className="text-[10px] text-muted-foreground font-mono uppercase tracking-wider">{card.label}</span>
</div>
</div>
))}
</Grid>
</div>
</div>
{/* D2: Active Scan (Animated) */}
<div className="border rounded-xl overflow-hidden shadow-sm bg-background">
<SectionHeader
title="D2: Active Scan"
description="动态版:增加扫描线光效,模拟实时监控状态"
/>
<div className="p-4 bg-muted/10">
<Grid>
{assetCards.map((card) => (
<div
key={card.label}
className="group relative bg-card p-4 overflow-hidden cursor-pointer hover:shadow-lg transition-all duration-300"
>
{/* Base Border */}
<div className="absolute inset-0 border border-border/40 border-t-0" />
{/* Animated Scan Line - Only visible on hover or active */}
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none">
<div className="absolute inset-0 animate-scan-y bg-gradient-to-b from-transparent via-primary/5 to-transparent" />
<div className="absolute top-0 left-0 w-full h-[1px] bg-primary/20 shadow-[0_0_10px_rgba(59,130,246,0.5)] animate-scan-line" />
</div>
{/* Corners that glow on hover */}
<div className="absolute top-0 right-0 h-2 w-2 border-r border-t border-primary/30 group-hover:border-primary group-hover:shadow-[0_0_8px_rgba(59,130,246,0.6)] transition-all duration-300" />
<div className="absolute bottom-0 left-0 h-2 w-2 border-l border-b border-primary/30 group-hover:border-primary group-hover:shadow-[0_0_8px_rgba(59,130,246,0.6)] transition-all duration-300" />
<div className="relative z-10">
<div className="flex justify-between items-start mb-2">
<div className="text-[10px] font-mono text-muted-foreground group-hover:text-primary/80 transition-colors">
S-{card.label.substring(0, 1)}00{card.value % 10}
</div>
<card.icon className="h-4 w-4 text-muted-foreground/70 group-hover:text-primary transition-colors" />
</div>
<div className="text-3xl font-light tracking-tight text-foreground">
{card.value.toLocaleString()}
</div>
<div className="mt-2 flex items-center justify-between border-t border-dashed border-border/50 pt-2">
<span className="text-[10px] text-muted-foreground font-mono uppercase tracking-wider">{card.label}</span>
<div className="h-1.5 w-1.5 rounded-full bg-emerald-500/50 animate-pulse" />
</div>
</div>
</div>
))}
</Grid>
</div>
</div>
{/* D3: Corner Pulse (Focus) */}
<div className="border rounded-xl overflow-hidden shadow-sm bg-background">
<SectionHeader
title="D3: Corner Pulse"
description="聚焦版Hover 时边角向内收缩并高亮,强调选中感"
/>
<div className="p-4 bg-muted/10">
<Grid>
{assetCards.map((card) => (
<div
key={card.label}
className="group relative bg-card p-4 cursor-pointer"
>
{/* Background Grid Pattern (Subtle) */}
<div className="absolute inset-0 bg-[linear-gradient(90deg,rgba(0,0,0,0.02)_1px,transparent_1px)] bg-[size:20px_20px] [mask-image:linear-gradient(to_bottom,white,transparent)]" />
{/* Dynamic Corners */}
<div className="absolute top-0 left-0 h-3 w-3 border-l-2 border-t-2 border-border group-hover:border-primary group-hover:w-full group-hover:h-full transition-all duration-300 ease-out" />
<div className="absolute bottom-0 right-0 h-3 w-3 border-r-2 border-b-2 border-border group-hover:border-primary group-hover:w-full group-hover:h-full transition-all duration-300 ease-out" />
<div className="relative z-10 flex flex-col h-full justify-between">
<div className="flex justify-between items-start">
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground group-hover:text-primary transition-colors">{card.label}</span>
<card.icon className="h-4 w-4 text-muted-foreground group-hover:scale-110 transition-transform" />
</div>
<div className="mt-4">
<div className="text-3xl font-bold text-foreground">
{card.value.toLocaleString()}
</div>
<div className="text-[10px] text-muted-foreground mt-1 font-mono">
UPDATED: NOW
</div>
</div>
</div>
</div>
))}
</Grid>
</div>
</div>
{/* D4: Data Stream (Glitch) */}
<div className="border rounded-xl overflow-hidden shadow-sm bg-background">
<SectionHeader
title="D4: Data Stream"
description="数据流版背景带有微弱的数字流噪点Hover 时产生轻微故障效果"
/>
<div className="p-4 bg-muted/10">
<Grid>
{assetCards.map((card) => (
<div
key={card.label}
className="group relative bg-card p-4 overflow-hidden border border-border/40 border-t-0 cursor-pointer"
>
{/* Glitch Overlay */}
<div className="absolute inset-0 bg-primary/5 translate-x-[-100%] group-hover:translate-x-0 transition-transform duration-300 skew-x-12" />
{/* Decorative Side Bar */}
<div className="absolute left-0 top-0 bottom-0 w-[2px] bg-border group-hover:bg-primary transition-colors" />
<div className="relative z-10 pl-3">
<div className="flex items-center gap-2 mb-3">
<div className="p-1 rounded bg-muted/50 group-hover:bg-background/80 transition-colors">
<card.icon className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<div className="h-px flex-1 bg-border/60" />
<div className="text-[9px] font-mono text-muted-foreground/60">
0x{card.value.toString(16).toUpperCase()}
</div>
</div>
<div className="text-3xl font-light tabular-nums tracking-tighter group-hover:text-primary transition-colors">
{card.value.toLocaleString()}
</div>
<div className="mt-2 text-xs text-muted-foreground font-medium uppercase tracking-wider group-hover:translate-x-1 transition-transform">
{card.label}
</div>
</div>
</div>
))}
</Grid>
</div>
</div>
</div>
{/* 方案 E: Status Indicator */}
<div className="border rounded-xl overflow-hidden shadow-sm bg-background">
<SectionHeader
title="方案 EStatus Context"
description="带有状态颜色指示和趋势数据"
/>
<div className="p-4 bg-muted/10">
<Grid>
{assetCards.map((card) => {
const statusColor =
card.status === 'critical' ? 'bg-red-500' :
card.status === 'warning' ? 'bg-amber-500' :
'bg-emerald-500';
return (
<div
key={card.label}
className="relative overflow-hidden bg-card rounded-lg border shadow-sm hover:shadow-md transition-all cursor-pointer group"
>
<div className={`absolute top-0 left-0 w-full h-1 ${statusColor} opacity-80`} />
<div className="p-4">
<div className="flex justify-between items-start">
<div className="p-2 rounded-md bg-secondary/50 group-hover:bg-primary/10 transition-colors">
<card.icon className="h-4 w-4 text-foreground/70 group-hover:text-primary" />
</div>
{card.trend !== 0 && (
<div className={`flex items-center text-xs font-medium ${card.trend > 0 ? 'text-green-600' : 'text-red-600'}`}>
{card.trend > 0 ? <ArrowUpRight className="h-3 w-3 mr-0.5" /> : <ArrowDownRight className="h-3 w-3 mr-0.5" />}
{Math.abs(card.trend)}%
</div>
)}
</div>
<div className="mt-3">
<div className="text-2xl font-bold">{card.value.toLocaleString()}</div>
<div className="text-xs text-muted-foreground mt-0.5 font-medium">{card.label}</div>
</div>
</div>
</div>
)
})}
</Grid>
</div>
</div>
{/* 方案 F: Hover Actions */}
<div className="border rounded-xl overflow-hidden shadow-sm bg-background">
<SectionHeader
title="方案 FHover Actions"
description="悬停时显示操作按钮,强调交互性"
/>
<div className="p-4 bg-muted/10">
<Grid>
{assetCards.map((card) => (
<div
key={card.label}
className="group relative bg-card rounded-lg border p-4 transition-all hover:border-primary/50"
>
<div className="flex flex-col h-full justify-between gap-4">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold uppercase text-muted-foreground tracking-wider">{card.label}</span>
<card.icon className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<div className="text-3xl font-bold tracking-tight">{card.value.toLocaleString()}</div>
<div className="flex items-center gap-1.5 mt-2">
<Activity className="h-3 w-3 text-emerald-500" />
<span className="text-xs text-muted-foreground">Active monitoring</span>
</div>
</div>
{/* Hover Overlay Actions */}
<div className="absolute inset-0 bg-card/95 backdrop-blur-[1px] opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-2 p-2">
<Button size="sm" variant="outline" className="w-full h-8 text-xs gap-1.5">
<Scan className="h-3.5 w-3.5" />
Quick Scan
</Button>
<Button size="sm" variant="ghost" className="w-full h-8 text-xs gap-1.5">
<MoreHorizontal className="h-3.5 w-3.5" />
Details
</Button>
</div>
</div>
</div>
))}
</Grid>
</div>
</div>
{/* 方案 G: Cyber HUD */}
<div className="border rounded-xl overflow-hidden shadow-sm bg-black text-white">
<SectionHeader
title="方案 GCyber HUD (Dark Mode Exclusive)"
description="深色模式专用的高对比度、荧光风格"
/>
<div className="p-4 bg-neutral-950">
<Grid>
{assetCards.map((card) => (
<div
key={card.label}
className="relative bg-neutral-900 border border-neutral-800 p-4 overflow-hidden group hover:border-cyan-500/50 transition-colors"
>
{/* Glow effect */}
<div className="absolute -top-10 -right-10 w-20 h-20 bg-cyan-500/10 blur-xl rounded-full group-hover:bg-cyan-500/20 transition-all" />
<div className="relative z-10">
<div className="flex justify-between items-start mb-3">
<card.icon className="h-5 w-5 text-neutral-500 group-hover:text-cyan-400 transition-colors" />
<div className="h-1.5 w-1.5 bg-neutral-700 rounded-full group-hover:bg-cyan-400 group-hover:shadow-[0_0_8px_rgba(34,211,238,0.8)] transition-all" />
</div>
<div className="text-3xl font-mono text-neutral-100 tabular-nums">
{card.value.toLocaleString()}
</div>
<div className="mt-2 text-[10px] uppercase tracking-[0.2em] text-neutral-500 group-hover:text-cyan-200/70 transition-colors">
{card.label}
</div>
</div>
{/* Decorative corner */}
<div className="absolute bottom-0 right-0 p-1">
<div className="w-2 h-2 border-r border-b border-neutral-700 group-hover:border-cyan-500/50" />
</div>
</div>
))}
</Grid>
</div>
</div>
<style jsx>{`
:global([data-theme="bauhaus"] .asset-card-variants .bg-card) {
border-top: 1px solid var(--border);
}
.motion-lift {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.motion-lift:hover {
transform: translateY(-4px);
box-shadow: 0 10px 20px rgba(15, 23, 42, 0.08);
}
.motion-scan {
overflow: hidden;
}
.motion-scan::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(120deg, transparent 0%, rgba(59, 130, 246, 0.12) 45%, transparent 70%);
transform: translateX(-120%);
transition: transform 0.6s ease;
pointer-events: none;
}
.motion-scan:hover::after {
transform: translateX(120%);
}
.animate-scan-line {
animation: scanLine 2s linear infinite;
}
@keyframes scanLine {
0% { transform: translateY(-100%); opacity: 0; }
10% { opacity: 1; }
90% { opacity: 1; }
100% { transform: translateY(400%); opacity: 0; }
}
.animate-scan-y {
animation: scanY 3s ease-in-out infinite;
}
@keyframes scanY {
0%, 100% { transform: translateY(-10%); opacity: 0; }
50% { transform: translateY(10%); opacity: 0.2; }
}
[data-motion="stagger"] .motion-rise {
animation: cardRise 0.45s ease forwards;
opacity: 0;
transform: translateY(6px);
animation-delay: var(--delay, 0ms);
}
@keyframes cardRise {
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.motion-lift,
.motion-scan,
[data-motion="stagger"] .motion-rise {
transition: none;
animation: none;
}
.motion-scan::after {
display: none;
}
}
`}</style>
</div>
)
}

View File

@@ -0,0 +1,478 @@
"use client"
import type { CSSProperties } from "react"
import {
Activity,
Crosshair,
Database,
Radar,
Shield,
Target,
Zap,
} from "@/components/icons"
const themeVars = {
"--ark-bg": "#FFFFFF",
"--ark-bg-2": "#FFFFFF",
"--ark-panel": "#FFFFFF",
"--ark-panel-2": "#F6F7F9",
"--ark-border": "#E4E8ED",
"--ark-border-strong": "#CCD3DC",
"--ark-text": "#20252B",
"--ark-text-2": "#6E7682",
"--ark-muted": "#A0A7B2",
"--ark-accent": "#20252B",
"--ark-accent-2": "#20252B",
fontFamily: "\"MiSans\", \"IBM Plex Sans\", \"Segoe UI\", sans-serif",
} as CSSProperties
const pulseSeries = [182, 196, 210, 204, 228, 246, 260]
const dayLabels = ["M", "T", "W", "T", "F", "S", "S"]
const laneSeries = {
subdomains: [76, 82, 88, 90, 96, 101, 108],
websites: [28, 32, 36, 35, 39, 43, 47],
ips: [22, 24, 25, 27, 30, 33, 36],
endpoints: [54, 58, 63, 66, 70, 74, 79],
}
function sparkPath(data: number[], width: number, height: number, padding = 2) {
const min = Math.min(...data)
const max = Math.max(...data)
const range = Math.max(1, max - min)
const step = (width - padding * 2) / (data.length - 1)
return data
.map((value, index) => {
const x = padding + step * index
const y = height - padding - ((value - min) / range) * (height - padding * 2)
return `${index === 0 ? "M" : "L"}${x} ${y}`
})
.join(" ")
}
function Sparkline({
data,
width = 180,
height = 46,
}: {
data: number[]
width?: number
height?: number
}) {
return (
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden>
<path
d={sparkPath(data, width, height, 2)}
fill="none"
stroke="var(--ark-accent)"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
)
}
export default function AssetPulseArknightsPage() {
const stackTotals = dayLabels.map((_, i) =>
laneSeries.subdomains[i] + laneSeries.websites[i] + laneSeries.ips[i] + laneSeries.endpoints[i]
)
const maxStack = Math.max(...stackTotals)
const trendDelta = pulseSeries.at(-1)! - pulseSeries[0]
return (
<main
className="relative min-h-screen overflow-hidden bg-[var(--ark-bg)] text-[var(--ark-text)]"
style={themeVars}
>
<div className="pointer-events-none absolute inset-0 ark-grid" aria-hidden />
<div className="pointer-events-none absolute left-10 top-16 h-32 w-32 industrial-ring" aria-hidden />
<div className="pointer-events-none absolute right-12 top-20 h-16 w-56 industrial-slab" aria-hidden />
<div className="pointer-events-none absolute left-10 top-40 h-[3px] w-[240px] industrial-beam" aria-hidden />
<div className="relative mx-auto flex w-full max-w-6xl flex-col gap-6 px-6 py-8 lg:px-10">
<header className="ark-panel ark-hero flex flex-col gap-4 p-5 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center border border-[var(--ark-border)] bg-[var(--ark-panel-2)]">
<Target className="h-6 w-6 text-[var(--ark-accent)]" />
</div>
<div>
<p className="text-xs uppercase tracking-[0.3em] text-[var(--ark-text-2)]">ASSET PULSE</p>
<h1 className="text-xl font-semibold tracking-wide">Arknights Style Variants</h1>
</div>
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-[var(--ark-text-2)]">
<span className="ark-tag">ZONE-09</span>
<span className="ark-tag">SIGNAL STABLE</span>
<span className="ark-tag">
<Activity className="h-3.5 w-3.5 text-[var(--ark-accent)]" />
LIVE FEED
</span>
</div>
</header>
<section className="grid grid-cols-12 gap-4">
{/* Demo 1: Pulse Rail */}
<div className="ark-panel col-span-12 lg:col-span-7">
<div className="border-b border-[var(--ark-border)] p-4">
<div className="flex items-center justify-between">
<p className="ark-kicker">PULSE RAIL</p>
<span className="ark-chip">ACTIVE</span>
</div>
<div className="mt-3 flex items-end justify-between">
<div>
<p className="text-xs text-[var(--ark-text-2)]">Total Assets</p>
<div className="text-3xl font-semibold">{pulseSeries.at(-1)}</div>
<p className="mt-1 text-xs text-[var(--ark-text-2)]">Δ 7D +{pulseSeries.at(-1)! - pulseSeries[0]}</p>
</div>
<div className="ark-slab px-3 py-2">
<Sparkline data={pulseSeries} width={200} height={50} />
</div>
</div>
</div>
<div className="grid gap-3 p-4 sm:grid-cols-3">
{[
{ label: "Subdomains", value: laneSeries.subdomains.at(-1) },
{ label: "Websites", value: laneSeries.websites.at(-1) },
{ label: "Endpoints", value: laneSeries.endpoints.at(-1) },
].map((stat) => (
<div key={stat.label} className="ark-slab p-3">
<p className="text-xs text-[var(--ark-text-2)]">{stat.label}</p>
<div className="mt-2 text-lg font-semibold">{stat.value}</div>
</div>
))}
</div>
</div>
{/* Demo 2: Signal Matrix */}
<div className="ark-panel col-span-12 lg:col-span-5">
<div className="border-b border-[var(--ark-border)] p-4">
<div className="flex items-center justify-between">
<p className="ark-kicker">SIGNAL MATRIX</p>
<Radar className="h-4 w-4 text-[var(--ark-text-2)]" />
</div>
</div>
<div className="grid gap-3 p-4 sm:grid-cols-2">
{Object.entries(laneSeries).map(([label, series]) => (
<div key={label} className="ark-slab p-3">
<div className="flex items-center justify-between text-xs text-[var(--ark-text-2)]">
<span className="capitalize">{label}</span>
<span className="text-[var(--ark-text)]">{series.at(-1)}</span>
</div>
<div className="mt-2">
<Sparkline data={series} width={140} height={34} />
</div>
</div>
))}
</div>
</div>
{/* Demo 3: Radar Sweep */}
<div className="ark-panel col-span-12 lg:col-span-4">
<div className="border-b border-[var(--ark-border)] p-4">
<div className="flex items-center justify-between">
<p className="ark-kicker">RADAR SWEEP</p>
<Crosshair className="h-4 w-4 text-[var(--ark-text-2)]" />
</div>
</div>
<div className="p-4">
<div className="relative h-44 rounded-md border border-[var(--ark-border)] bg-[var(--ark-panel-2)]">
<div className="absolute inset-4 rounded-full border border-[var(--ark-border)]" />
<div className="absolute inset-10 rounded-full border border-[var(--ark-border)]" />
<div className="absolute inset-16 rounded-full border border-[var(--ark-border)]" />
<div className="absolute inset-0 arkp-radar" />
<div className="absolute left-[25%] top-[30%] h-2 w-2 rounded-full bg-[var(--ark-accent)]/70 animate-ping" />
<div className="absolute left-[60%] top-[55%] h-1.5 w-1.5 rounded-full bg-[var(--ark-accent)]/70 animate-ping [animation-delay:0.8s]" />
<div className="absolute left-[50%] top-[70%] h-2 w-2 rounded-full bg-[var(--ark-accent)]/70 animate-ping [animation-delay:1.6s]" />
</div>
<div className="mt-3 flex items-center justify-between text-xs text-[var(--ark-text-2)]">
<span>Contacts: 18</span>
<span className="ark-chip">ACTIVE</span>
</div>
</div>
</div>
{/* Demo 4: Ops Queue */}
<div className="ark-panel col-span-12 lg:col-span-4">
<div className="border-b border-[var(--ark-border)] p-4">
<div className="flex items-center justify-between">
<p className="ark-kicker">OPS QUEUE</p>
<Shield className="h-4 w-4 text-[var(--ark-text-2)]" />
</div>
</div>
<div className="space-y-2 p-4 text-sm">
{[
{ id: "RX-112", name: "Perimeter Sweep", status: "Queued" },
{ id: "LF-551", name: "Ingress Trace", status: "Executing" },
{ id: "LK-044", name: "Key Rotation", status: "Hold" },
].map((item) => (
<div key={item.id} className="ark-slab flex items-center justify-between px-3 py-2">
<div>
<p className="text-sm font-medium">{item.name}</p>
<p className="text-xs text-[var(--ark-text-2)]">{item.id}</p>
</div>
<span className="ark-chip">{item.status}</span>
</div>
))}
</div>
</div>
{/* Demo 5: Asset Lattice */}
<div className="ark-panel col-span-12 lg:col-span-4">
<div className="border-b border-[var(--ark-border)] p-4">
<div className="flex items-center justify-between">
<p className="ark-kicker">ASSET LATTICE</p>
<Database className="h-4 w-4 text-[var(--ark-text-2)]" />
</div>
</div>
<div className="p-4">
<div className="grid grid-cols-8 gap-1">
{Array.from({ length: 40 }).map((_, index) => (
<div
key={index}
className={`h-3 rounded-sm ${index % 6 === 0 ? "bg-[var(--ark-accent)]/40" : "bg-[var(--ark-border)]"}`}
/>
))}
</div>
<div className="mt-3 flex items-center justify-between text-xs text-[var(--ark-text-2)]">
<span>Nodes: 40</span>
<span className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--ark-accent)] animate-pulse" />
SYNCED
</span>
</div>
</div>
</div>
</section>
<section className="grid grid-cols-12 gap-4">
{/* Demo 6: Telemetry Strip */}
<div className="ark-panel col-span-12 lg:col-span-5">
<div className="border-b border-[var(--ark-border)] p-4">
<div className="flex items-center justify-between">
<p className="ark-kicker">TELEMETRY STRIP</p>
<Zap className="h-4 w-4 text-[var(--ark-text-2)]" />
</div>
</div>
<div className="p-4">
<div className="relative overflow-hidden rounded-md border border-[var(--ark-border)] bg-[var(--ark-panel-2)] px-3 py-4">
<div className="absolute inset-y-0 left-0 arkp-strip-line" />
<div className="flex items-end justify-between gap-2">
{pulseSeries.map((value, index) => (
<div key={`strip-${index}`} className="flex flex-col items-center gap-2">
<div
className="w-2 rounded-sm bg-[var(--ark-accent)]/40"
style={{ height: `${12 + (value / pulseSeries.at(-1)!) * 26}px` }}
/>
<span className="text-[10px] text-[var(--ark-text-2)]">{dayLabels[index]}</span>
</div>
))}
</div>
</div>
<div className="mt-3 flex items-center justify-between text-xs text-[var(--ark-text-2)]">
<span>Trend Δ 7D +{trendDelta}</span>
<span className="ark-chip">SYNCED</span>
</div>
</div>
</div>
{/* Demo 7: Pulse Dial */}
<div className="ark-panel col-span-12 lg:col-span-3">
<div className="border-b border-[var(--ark-border)] p-4">
<div className="flex items-center justify-between">
<p className="ark-kicker">PULSE DIAL</p>
<Target className="h-4 w-4 text-[var(--ark-text-2)]" />
</div>
</div>
<div className="p-4 flex flex-col items-center gap-3">
<div className="relative h-36 w-36">
<div className="absolute inset-0 rounded-full arkp-dial" />
<div className="absolute inset-4 rounded-full bg-[var(--ark-panel)] border border-[var(--ark-border)]" />
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<div className="text-2xl font-semibold">74%</div>
<div className="text-[10px] text-[var(--ark-text-2)]">Integrity</div>
</div>
</div>
</div>
<div className="text-xs text-[var(--ark-text-2)]">Index stable · 4.2%</div>
</div>
</div>
{/* Demo 8: Delta Stack */}
<div className="ark-panel col-span-12 lg:col-span-4">
<div className="border-b border-[var(--ark-border)] p-4">
<div className="flex items-center justify-between">
<p className="ark-kicker">DELTA STACK</p>
<Database className="h-4 w-4 text-[var(--ark-text-2)]" />
</div>
</div>
<div className="p-4">
<div className="flex items-end justify-between gap-3">
{dayLabels.map((day, index) => {
const total = stackTotals[index]
const height = Math.max(36, Math.round((total / maxStack) * 90))
return (
<div key={`stack-${day}-${index}`} className="flex flex-col items-center gap-2">
<div className="flex w-6 flex-col justify-end rounded-md border border-[var(--ark-border)] bg-[var(--ark-panel-2)] overflow-hidden" style={{ height }}>
<div style={{ height: `${(laneSeries.subdomains[index] / total) * 100}%` }} className="bg-[var(--ark-accent)]/35" />
<div style={{ height: `${(laneSeries.websites[index] / total) * 100}%` }} className="bg-[var(--ark-accent)]/25" />
<div style={{ height: `${(laneSeries.ips[index] / total) * 100}%` }} className="bg-[var(--ark-accent)]/2" />
<div style={{ height: `${(laneSeries.endpoints[index] / total) * 100}%` }} className="bg-[var(--ark-accent)]/45" />
</div>
<span className="text-[10px] text-[var(--ark-text-2)]">{day}</span>
</div>
)
})}
</div>
<div className="mt-3 flex items-center justify-between text-xs text-[var(--ark-text-2)]">
<span>Mix stability 0.94</span>
<span className="ark-chip">LOCKED</span>
</div>
</div>
</div>
</section>
</div>
<style jsx>{`
.ark-grid {
background-image:
linear-gradient(rgba(32, 37, 43, 0.045) 1px, transparent 1px),
linear-gradient(90deg, rgba(32, 37, 43, 0.045) 1px, transparent 1px),
linear-gradient(rgba(32, 37, 43, 0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(32, 37, 43, 0.02) 1px, transparent 1px);
background-size: 120px 120px, 120px 120px, 24px 24px, 24px 24px;
mask-image: linear-gradient(180deg, transparent 0%, black 8%, black 92%, transparent 100%);
}
.industrial-ring {
border-radius: 999px;
border: 2px solid var(--ark-border-strong);
background: rgba(32, 37, 43, 0.015);
box-shadow: inset 0 0 0 10px rgba(32, 37, 43, 0.01);
opacity: 0.7;
}
.industrial-slab {
border: 2px solid var(--ark-border);
background: var(--ark-panel);
box-shadow: 0 4px 10px rgba(21, 26, 32, 0.025);
opacity: 0.85;
}
.industrial-beam {
background: linear-gradient(90deg, rgba(32, 37, 43, 0.25), transparent);
opacity: 0.7;
}
.ark-panel {
position: relative;
background: var(--ark-panel);
border: 1px solid var(--ark-border);
border-top: 2px solid var(--ark-border-strong);
box-shadow: 0 6px 12px rgba(21, 26, 32, 0.035);
}
.ark-panel::after {
content: "";
position: absolute;
inset: 10px;
border: 1px solid rgba(32, 37, 43, 0.03);
pointer-events: none;
}
.ark-panel.ark-hero::before {
content: "";
position: absolute;
left: 0;
top: 0;
width: 4px;
height: 100%;
background: var(--ark-border-strong);
pointer-events: none;
}
.ark-slab {
position: relative;
background: var(--ark-panel-2);
border: 1px solid var(--ark-border);
border-top: 2px solid rgba(32, 37, 43, 0.12);
}
.ark-slab::after {
content: "";
position: absolute;
inset: 6px;
border: 1px solid rgba(32, 37, 43, 0.025);
pointer-events: none;
}
.ark-kicker {
font-size: 10px;
letter-spacing: 0.3em;
text-transform: uppercase;
color: var(--ark-text-2);
font-weight: 600;
font-family: "IBM Plex Mono", "MiSans", sans-serif;
}
.ark-tag {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid var(--ark-border);
padding: 3px 10px;
font-size: 10px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--ark-text-2);
background: var(--ark-bg-2);
font-family: "IBM Plex Mono", "MiSans", sans-serif;
box-shadow: inset 0 0 0 1px rgba(29, 36, 44, 0.04);
}
.ark-chip {
border: 1px solid var(--ark-border);
padding: 2px 8px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.2em;
color: var(--ark-text-2);
background: transparent;
font-family: "IBM Plex Mono", "MiSans", sans-serif;
}
.arkp-radar {
background: conic-gradient(from 180deg, rgba(32, 37, 43, 0), rgba(32, 37, 43, 0.25), rgba(32, 37, 43, 0));
animation: radar 7.5s linear infinite;
}
@keyframes radar {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.arkp-strip-line {
width: 28%;
background: linear-gradient(90deg, transparent, rgba(32, 37, 43, 0.35), transparent);
animation: strip 3.6s linear infinite;
opacity: 0.55;
}
@keyframes strip {
from { transform: translateX(-70%); }
to { transform: translateX(240%); }
}
.arkp-dial {
background: conic-gradient(
from -90deg,
rgba(32, 37, 43, 0.8) 0deg,
rgba(32, 37, 43, 0.8) 266deg,
rgba(32, 37, 43, 0.15) 266deg,
rgba(32, 37, 43, 0.15) 360deg
);
border-radius: 999px;
}
`}</style>
</main>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,559 @@
"use client"
import React, { useState } from "react"
import { CartesianGrid, Line, LineChart, XAxis, YAxis, ResponsiveContainer, Area, AreaChart, Bar, BarChart, Tooltip } from "recharts"
import {
IconActivity, IconServer, IconWorld,
Monitor as IconDeviceDesktop, Signal as IconWifi
} from "@/components/icons"
import { cn } from "@/lib/utils"
import { motion } from "framer-motion"
// --- Mock Data ---
const generateData = (days: number) => {
const data = []
const baseDate = new Date()
baseDate.setDate(baseDate.getDate() - days)
for (let i = 0; i < days; i++) {
const date = new Date(baseDate)
date.setDate(date.getDate() + i)
data.push({
date: date.toISOString().split('T')[0],
subdomains: Math.floor(Math.random() * 50) + 120,
ips: Math.floor(Math.random() * 30) + 80,
endpoints: Math.floor(Math.random() * 80) + 200,
websites: Math.floor(Math.random() * 20) + 40,
})
}
return data
}
const MOCK_DATA = generateData(14)
// --- Shared Types ---
type SeriesKey = 'subdomains' | 'ips' | 'endpoints' | 'websites'
const ALL_SERIES: SeriesKey[] = ['subdomains', 'ips', 'endpoints', 'websites']
const SERIES_CONFIG = {
subdomains: { label: 'SUBDOMAINS', color: '#3b82f6', icon: IconWorld },
ips: { label: 'IP ADDRESSES', color: '#f97316', icon: IconWifi },
endpoints: { label: 'ENDPOINTS', color: '#eab308', icon: IconServer },
websites: { label: 'WEBSITES', color: '#22c55e', icon: IconDeviceDesktop },
}
// --- Variant 1: Bauhaus Tactical (The Default Refined) ---
function VariantBauhausTactical() {
const [activeSeries, setActiveSeries] = useState<Set<SeriesKey>>(new Set(ALL_SERIES))
const toggleSeries = (key: SeriesKey) => {
const next = new Set(activeSeries)
if (next.has(key)) next.delete(key)
else next.add(key)
setActiveSeries(next)
}
return (
<div className="w-full border border-border bg-card overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between border-b border-border bg-secondary/30 px-4 py-3">
<div className="flex items-center gap-2 text-muted-foreground">
<IconActivity className="h-4 w-4" />
<span className="text-[11px] font-mono tracking-[0.2em] font-bold">ASSET PULSE // TACTICAL</span>
</div>
<div className="flex items-center gap-2">
<div className="h-1.5 w-1.5 rounded-full bg-success animate-pulse" />
<span className="text-[10px] font-mono text-success tracking-widest">LIVE</span>
</div>
</div>
{/* Chart Area */}
<div className="relative h-[240px] w-full p-4 bg-[linear-gradient(var(--border)_1px,transparent_1px),linear-gradient(90deg,var(--border)_1px,transparent_1px)] bg-[size:40px_40px]">
{/* Scanline Effect */}
<motion.div
className="absolute inset-0 z-0 pointer-events-none border-r border-primary/20 bg-gradient-to-r from-transparent to-primary/5"
animate={{ x: ['0%', '100%'] }}
transition={{ duration: 3, repeat: Infinity, ease: "linear" }}
/>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={MOCK_DATA}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--border)" />
<XAxis dataKey="date" tickLine={false} axisLine={false} tick={{fontSize: 10, fill: 'var(--muted-foreground)'}} tickFormatter={d => d.slice(5)} />
<YAxis tickLine={false} axisLine={false} tick={{fontSize: 10, fill: 'var(--muted-foreground)'}} />
<Tooltip
contentStyle={{ borderRadius: 0, border: '1px solid var(--border)', background: 'var(--card)' }}
itemStyle={{ fontSize: 12, fontFamily: 'var(--font-mono)' }}
labelStyle={{ fontSize: 10, marginBottom: 8, color: 'var(--muted-foreground)' }}
/>
{ALL_SERIES.map(key => activeSeries.has(key) && (
<Line
key={key}
type="step"
dataKey={key}
stroke={SERIES_CONFIG[key].color}
strokeWidth={2}
dot={false}
activeDot={{ r: 4, strokeWidth: 0, fill: SERIES_CONFIG[key].color }}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
{/* Controls */}
<div className="grid grid-cols-4 border-t border-border divide-x divide-border">
{ALL_SERIES.map(key => {
const isActive = activeSeries.has(key)
const config = SERIES_CONFIG[key]
const Icon = config.icon
return (
<button
key={key}
onClick={() => toggleSeries(key)}
className={cn(
"group relative flex flex-col gap-2 p-3 text-left transition-colors hover:bg-secondary/50",
isActive && "bg-secondary/30"
)}
>
{isActive && (
<div
className="absolute left-0 top-0 bottom-0 w-[3px]"
style={{ backgroundColor: config.color }}
/>
)}
<div className="flex items-center justify-between w-full">
<span className={cn(
"text-[10px] tracking-widest font-bold",
isActive ? "text-foreground" : "text-muted-foreground"
)}>
{config.label}
</span>
<Icon className={cn("h-3 w-3", isActive ? "text-foreground" : "text-muted-foreground")} />
</div>
<span className={cn(
"text-lg font-mono font-medium",
isActive ? "text-foreground" : "text-muted-foreground"
)}>
{MOCK_DATA[MOCK_DATA.length-1][key]}
</span>
</button>
)
})}
</div>
</div>
)
}
// --- Variant 2: Cyber Terminal ---
function VariantCyberTerminal() {
return (
<div className="w-full bg-black border border-primary/30 p-1 relative overflow-hidden group">
{/* Corner Accents */}
<div className="absolute top-0 left-0 w-4 h-4 border-t-2 border-l-2 border-primary z-10" />
<div className="absolute top-0 right-0 w-4 h-4 border-t-2 border-r-2 border-primary z-10" />
<div className="absolute bottom-0 left-0 w-4 h-4 border-b-2 border-l-2 border-primary z-10" />
<div className="absolute bottom-0 right-0 w-4 h-4 border-b-2 border-r-2 border-primary z-10" />
<div className="bg-primary/5 border border-primary/20 h-full p-4 relative">
<div className="flex justify-between items-end mb-4 border-b border-primary/30 pb-2">
<div>
<div className="text-primary text-xs font-mono mb-1 animate-pulse">SYSTEM_MONITOR_V2.0</div>
<div className="text-primary/70 text-[10px] font-mono">TARGET: LUNAFOX_MAIN_NODE</div>
</div>
<div className="text-right">
<div className="text-primary/50 text-[10px] font-mono">REFRESH_RATE: 60Hz</div>
<div className="text-primary font-bold font-mono">ONLINE</div>
</div>
</div>
<div className="h-[200px] w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={MOCK_DATA}>
<defs>
<linearGradient id="cyberGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--primary)" stopOpacity={0.3}/>
<stop offset="95%" stopColor="var(--primary)" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid stroke="var(--primary)" strokeOpacity={0.1} vertical={false} />
<XAxis dataKey="date" hide />
<Tooltip
contentStyle={{ backgroundColor: '#000', borderColor: 'var(--primary)', color: 'var(--primary)' }}
itemStyle={{ color: 'var(--primary)' }}
/>
<Area
type="monotone"
dataKey="endpoints"
stroke="var(--primary)"
fillOpacity={1}
fill="url(#cyberGradient)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
{/* Glitch text decoration */}
<div className="absolute bottom-2 right-4 text-[10px] text-primary/40 font-mono">
0x3F2A...BC91
</div>
</div>
</div>
)
}
// --- Variant 3: Minimalist Spark ---
function VariantMinimalistSpark() {
const [hovered, setHovered] = useState<number | null>(null)
return (
<div className="w-full bg-card p-6 rounded-xl border border-border/50 shadow-sm">
<div className="flex items-center justify-between mb-8">
<div>
<h3 className="text-sm font-medium text-muted-foreground tracking-wide">ASSET GROWTH</h3>
<div className="text-3xl font-light mt-1 text-foreground">
{MOCK_DATA.reduce((acc, curr) => acc + curr.endpoints, 0).toLocaleString()}
<span className="text-sm text-muted-foreground ml-2 font-normal">total endpoints</span>
</div>
</div>
<div className="flex gap-2">
<span className="h-2 w-2 rounded-full bg-foreground"></span>
<span className="h-2 w-2 rounded-full bg-muted"></span>
</div>
</div>
<div className="h-[120px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={MOCK_DATA} barGap={2} onMouseMove={(state) => {
if (state.activeTooltipIndex !== undefined) setHovered(state.activeTooltipIndex)
}} onMouseLeave={() => setHovered(null)}>
<Bar
dataKey="endpoints"
fill="var(--foreground)"
radius={[2, 2, 0, 0]}
fillOpacity={0.2}
shape={(props: React.SVGProps<SVGRectElement> & { index?: number }) => {
const isHovered = props.index === hovered;
return (
<rect
{...props}
fill={isHovered ? "var(--foreground)" : "var(--muted)"}
className="transition-all duration-300"
/>
)
}}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
)
}
// --- Variant 4: Radar Scope ---
function VariantRadarScope() {
return (
<div className="w-full aspect-[2/1] bg-black rounded-lg overflow-hidden relative border border-emerald-900/50 shadow-[0_0_30px_rgba(16,185,129,0.1)]">
{/* Grid */}
<div className="absolute inset-0 bg-[radial-gradient(circle,rgba(16,185,129,0.2)_1px,transparent_1px)] bg-[size:20px_20px] opacity-20"></div>
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-[80%] h-[80%] border border-emerald-500/20 rounded-full"></div>
<div className="w-[60%] h-[60%] border border-emerald-500/20 rounded-full absolute"></div>
<div className="w-[40%] h-[40%] border border-emerald-500/20 rounded-full absolute"></div>
<div className="w-[20%] h-[20%] border border-emerald-500/20 rounded-full absolute"></div>
</div>
{/* Radar Line */}
<motion.div
className="absolute top-1/2 left-1/2 w-[50%] h-[2px] bg-emerald-500/50 origin-left"
style={{ boxShadow: '0 0 10px 2px rgba(16,185,129,0.5)' }}
animate={{ rotate: 360 }}
transition={{ duration: 4, repeat: Infinity, ease: "linear" }}
/>
<div className="absolute inset-0 p-6 flex flex-col justify-between pointer-events-none">
<div className="flex justify-between text-emerald-500 font-mono text-xs">
<span>R-SCAN: ACTIVE</span>
<span>SEC-LVL: 4</span>
</div>
<div className="text-emerald-500/50 font-mono text-[10px]">
DETECTED: 1,240 ASSETS<br/>
LAST PING: 4ms
</div>
</div>
{/* Chart Overlay */}
<div className="absolute bottom-0 left-0 right-0 h-[100px] opacity-60">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={MOCK_DATA}>
<Line type="monotone" dataKey="ips" stroke="#10b981" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
)
}
// --- Variant 5: Industrial Panel ---
function VariantIndustrialPanel() {
return (
<div className="w-full bg-[#e5e5e5] p-2 rounded-sm border-t border-l border-white border-b border-r border-gray-400 shadow-md">
<div className="bg-[#d4d4d4] border border-gray-500 p-3">
<div className="flex justify-between items-center mb-3">
<div className="text-xs font-bold text-gray-700 uppercase bg-gray-300 px-2 py-1 border border-gray-400 shadow-inner">Panel A-12</div>
<div className="h-3 w-3 rounded-full bg-red-500 border border-red-700 shadow-[0_0_5px_rgba(239,68,68,0.8)] animate-pulse"></div>
</div>
<div className="bg-black border-4 border-gray-600 rounded-lg p-2 h-[180px] relative shadow-inner">
<div className="absolute inset-0 bg-[linear-gradient(rgba(0,255,0,0.05)_1px,transparent_1px),linear-gradient(90deg,rgba(0,255,0,0.05)_1px,transparent_1px)] bg-[size:10px_10px]"></div>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={MOCK_DATA}>
<CartesianGrid stroke="#333" strokeDasharray="2 2" />
<Line type="stepAfter" dataKey="endpoints" stroke="#00ff00" strokeWidth={2} dot={false} />
<Tooltip contentStyle={{ backgroundColor: '#000', border: '1px solid #00ff00', color: '#00ff00', fontFamily: 'monospace' }} />
</LineChart>
</ResponsiveContainer>
</div>
<div className="flex gap-4 mt-3">
{['PWR', 'NET', 'IO'].map(label => (
<div key={label} className="flex flex-col items-center gap-1">
<div className="w-8 h-12 bg-gradient-to-b from-gray-200 to-gray-400 rounded-sm border border-gray-500 shadow-md flex items-center justify-center active:translate-y-0.5 cursor-pointer">
<div className="w-4 h-8 bg-gray-300 border border-gray-400 rounded-sm"></div>
</div>
<span className="text-[10px] font-bold text-gray-600">{label}</span>
</div>
))}
</div>
</div>
</div>
)
}
// --- Variant 6: Data Stream ---
function VariantDataStream() {
return (
<div className="w-full flex h-[260px] border border-border bg-card">
<div className="w-1/3 border-r border-border p-4 flex flex-col justify-between bg-muted/20">
<div>
<div className="text-xs text-muted-foreground mb-1 uppercase tracking-wider">Total Assets</div>
<div className="text-3xl font-bold text-foreground font-mono">2,491</div>
</div>
<div className="space-y-4">
{ALL_SERIES.map(key => (
<div key={key} className="flex justify-between items-center text-sm">
<span className="text-muted-foreground uppercase text-[10px] tracking-wide">{key}</span>
<span className="font-mono">{MOCK_DATA[MOCK_DATA.length-1][key]}</span>
</div>
))}
</div>
<div className="text-[10px] text-muted-foreground mt-4">
Updated: 20s ago
</div>
</div>
<div className="w-2/3 p-4 relative">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={MOCK_DATA}>
<defs>
<linearGradient id="streamGradient" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="var(--primary)" stopOpacity={0.4}/>
<stop offset="100%" stopColor="var(--secondary)" stopOpacity={0.1}/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} strokeDasharray="3 3" stroke="var(--border)" />
<Area type="natural" dataKey="endpoints" stroke="var(--primary)" fill="url(#streamGradient)" strokeWidth={3} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
)
}
// --- Variant 7: Split Cards ---
function VariantSplitCards() {
return (
<div className="grid grid-cols-2 gap-3 w-full">
{ALL_SERIES.map(key => {
const config = SERIES_CONFIG[key]
return (
<div key={key} className="bg-card border border-border p-3 flex flex-col justify-between h-[120px]">
<div className="flex justify-between items-start">
<span className="text-[10px] font-bold text-muted-foreground uppercase">{config.label}</span>
<config.icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="h-[50px] w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={MOCK_DATA}>
<Line type="monotone" dataKey={key} stroke={config.color} strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
<div className="text-xl font-mono font-medium">{MOCK_DATA[MOCK_DATA.length-1][key]}</div>
</div>
)
})}
</div>
)
}
// --- Variant 8: Holographic ---
function VariantHolographic() {
return (
<div className="w-full h-[280px] rounded-2xl bg-gradient-to-br from-indigo-900/90 to-purple-900/90 p-1 relative overflow-hidden backdrop-blur-xl shadow-2xl">
<div className="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-20"></div>
<div className="h-full w-full bg-white/5 rounded-xl border border-white/10 p-6 flex flex-col relative z-10">
<div className="flex justify-between items-center mb-6">
<h3 className="text-white/80 font-light tracking-widest text-sm">HOLOGRAM_VIEW</h3>
<div className="px-3 py-1 bg-white/10 rounded-full text-xs text-white/90 backdrop-blur-md">Live</div>
</div>
<div className="flex-1 relative">
<div className="absolute inset-0 bg-gradient-to-t from-indigo-500/20 to-transparent blur-2xl"></div>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={MOCK_DATA}>
<Line type="monotone" dataKey="endpoints" stroke="#a78bfa" strokeWidth={4} dot={false} style={{ filter: 'drop-shadow(0 0 10px rgba(167, 139, 250, 0.7))' }} />
<Line type="monotone" dataKey="subdomains" stroke="#60a5fa" strokeWidth={2} dot={false} strokeDasharray="5 5" style={{ filter: 'drop-shadow(0 0 8px rgba(96, 165, 250, 0.5))' }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
)
}
// --- Variant 9: High Contrast (E-Ink) ---
function VariantHighContrast() {
return (
<div className="w-full bg-white border-4 border-black p-4 font-mono text-black">
<div className="border-b-4 border-black pb-2 mb-4 flex justify-between items-end">
<h1 className="text-2xl font-black uppercase italic">ASSET_LOG</h1>
<div className="text-xs font-bold">VOL. 24</div>
</div>
<div className="h-[180px] w-full border-2 border-black border-dashed p-2">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={MOCK_DATA}>
<XAxis dataKey="date" tickLine={false} axisLine={false} tick={{fill: 'black', fontSize: 10}} tickFormatter={d => d.slice(8)} />
<Bar dataKey="endpoints" fill="black" />
</BarChart>
</ResponsiveContainer>
</div>
<div className="mt-4 grid grid-cols-2 gap-4">
<div className="bg-black text-white p-2 text-center font-bold text-xl">
{MOCK_DATA[MOCK_DATA.length-1].endpoints}
</div>
<div className="border-2 border-black p-2 text-center font-bold text-xl">
{MOCK_DATA[MOCK_DATA.length-1].subdomains}
</div>
</div>
</div>
)
}
// --- Variant 10: Vertical Stack ---
function VariantVerticalStack() {
return (
<div className="w-full flex gap-4 bg-background">
<div className="w-[120px] flex flex-col gap-2">
{ALL_SERIES.map(key => (
<div key={key} className="bg-secondary p-3 rounded-none border border-border border-l-4 border-l-transparent hover:border-l-primary transition-all cursor-pointer group">
<div className="text-[10px] text-muted-foreground uppercase mb-1">{key.slice(0,3)}</div>
<div className="text-lg font-bold group-hover:text-primary transition-colors">{MOCK_DATA[MOCK_DATA.length-1][key]}</div>
</div>
))}
</div>
<div className="flex-1 border border-border bg-card p-4 relative">
<div className="absolute top-2 right-2 flex gap-1">
{[1,2,3].map(i => <div key={i} className="w-1 h-1 bg-muted-foreground/30 rounded-full"></div>)}
</div>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={MOCK_DATA}>
<defs>
<pattern id="pattern-lines" x="0" y="0" width="10" height="10" patternUnits="userSpaceOnUse">
<path d="M0 10L10 0" stroke="currentColor" strokeWidth="0.5" className="text-muted-foreground/20"/>
</pattern>
</defs>
<CartesianGrid vertical={false} stroke="var(--border)" />
<Area type="step" dataKey="endpoints" stroke="var(--primary)" fill="url(#pattern-lines)" strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
)
}
// --- Main Page ---
export default function AssetPulseDesignsPage() {
return (
<div className="p-8 max-w-7xl mx-auto space-y-12 pb-32">
<div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tight">Asset Pulse Design Studies</h1>
<p className="text-muted-foreground">Exploration of 10 different visualization styles for the dashboard asset monitor.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
<div className="space-y-4">
<h2 className="text-lg font-semibold text-muted-foreground">01 // Bauhaus Tactical (Recommended)</h2>
<p className="text-sm text-muted-foreground">Refined version of current design. Strict grid, modular controls, tactical aesthetic.</p>
<VariantBauhausTactical />
</div>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-muted-foreground">02 // Cyber Terminal</h2>
<p className="text-sm text-muted-foreground">High-immersion sci-fi interface. Glitch effects, heavy borders.</p>
<VariantCyberTerminal />
</div>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-muted-foreground">03 // Minimalist Spark</h2>
<p className="text-sm text-muted-foreground">Clean, airy, focus on data shape. Subtle interactions.</p>
<VariantMinimalistSpark />
</div>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-muted-foreground">04 // Radar Scope</h2>
<p className="text-sm text-muted-foreground">Circular/Scanning metaphor. Monochrome green phosphorus style.</p>
<VariantRadarScope />
</div>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-muted-foreground">05 // Industrial Panel</h2>
<p className="text-sm text-muted-foreground">Skeuomorphic touches. Physical buttons, matte finishes.</p>
<VariantIndustrialPanel />
</div>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-muted-foreground">06 // Data Stream</h2>
<p className="text-sm text-muted-foreground">Layout emphasizing the flow of data. Metrics on side.</p>
<VariantDataStream />
</div>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-muted-foreground">07 // Split Cards</h2>
<p className="text-sm text-muted-foreground">Deconstructed view. Good for comparing distinct metrics.</p>
<VariantSplitCards />
</div>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-muted-foreground">08 // Holographic</h2>
<p className="text-sm text-muted-foreground">Modern glassmorphism. Depth, glow, gradients.</p>
<VariantHolographic />
</div>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-muted-foreground">09 // High Contrast (E-Ink)</h2>
<p className="text-sm text-muted-foreground">Brutalist / E-Reader aesthetic. Pure black and white.</p>
<VariantHighContrast />
</div>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-muted-foreground">10 // Vertical Stack</h2>
<p className="text-sm text-muted-foreground">Side navigation metaphor applied to charts.</p>
<VariantVerticalStack />
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,710 @@
"use client"
import React, { useMemo } from "react"
import { CartesianGrid, Line, LineChart, XAxis, YAxis, ResponsiveContainer, Area, AreaChart, Bar, BarChart, ReferenceLine, ComposedChart } from "recharts"
import {
IconActivity, IconWorld,
IconAlertTriangle,
Zap as IconSun, IconCircleDot as IconDroplet
} from "@/components/icons"
import { cn } from "@/lib/utils"
import { useStatisticsHistory } from "@/hooks/use-dashboard"
import type { StatisticsHistoryItem } from "@/types/dashboard.types"
// --- Helper: Fill Missing Dates ---
function fillMissingDates(data: StatisticsHistoryItem[] | undefined, days: number): StatisticsHistoryItem[] {
if (!data || data.length === 0) return []
const dataMap = new Map(data.map(item => [item.date, item]))
const earliestDate = new Date(data[0].date)
const result: StatisticsHistoryItem[] = []
const startDate = new Date(earliestDate)
startDate.setDate(startDate.getDate() - (days - data.length))
for (let i = 0; i < days; i++) {
const currentDate = new Date(startDate)
currentDate.setDate(startDate.getDate() + i)
const dateStr = currentDate.toISOString().split('T')[0]
const existing = dataMap.get(dateStr)
if (existing) {
result.push(existing)
} else {
result.push({
date: dateStr,
totalTargets: 0,
totalSubdomains: 0,
totalIps: 0,
totalEndpoints: 0,
totalWebsites: 0,
totalVulns: 0,
totalAssets: 0,
})
}
}
return result
}
// --- Helper Types ---
// --- Helper Layout ---
function DesignCard({ title, description, children, className }: { title: string, description: string, children: React.ReactNode, className?: string }) {
return (
<div className={cn("flex flex-col gap-3 group", className)}>
<div className="flex flex-col gap-1">
<h3 className="text-sm font-bold text-slate-800 uppercase tracking-widest">{title}</h3>
<p className="text-xs text-slate-500">{description}</p>
</div>
{/* Force Light Mode Container */}
<div className="bg-white text-slate-900 rounded-lg overflow-hidden border border-slate-200 shadow-sm transition-shadow hover:shadow-md relative" data-theme="light">
{children}
</div>
</div>
)
}
// --- Data Provider Wrapper ---
// This HOC fetches the real data and passes it to the chart components
function withRealData<P extends object>(Component: React.ComponentType<P & { data: StatisticsHistoryItem[] }>) {
return function WithRealData(props: P) {
const { data: rawData, isLoading } = useStatisticsHistory(14)
// Fill missing dates to ensure we have data to display, even if empty
const data = useMemo(() => {
if (!rawData || rawData.length === 0) return []
return fillMissingDates(rawData, 14)
}, [rawData])
if (isLoading) {
return <div className="w-full h-[240px] flex items-center justify-center text-slate-400 text-xs animate-pulse">LOADING DATA...</div>
}
if (data.length === 0) {
// Fallback mock data if real API returns nothing (for development preview)
const fallbackData = []
const now = new Date()
for(let i=0; i<14; i++) {
const d = new Date(now)
d.setDate(d.getDate() - (13-i))
fallbackData.push({
date: d.toISOString().split('T')[0],
totalSubdomains: Math.floor(Math.random() * 50) + 100,
totalIps: Math.floor(Math.random() * 30) + 50,
totalEndpoints: Math.floor(Math.random() * 80) + 200,
totalWebsites: Math.floor(Math.random() * 20) + 40,
totalVulns: 0, totalTargets: 0, totalAssets: 0
})
}
return <Component {...props} data={fallbackData} />
}
return <Component {...props} data={data} />
}
}
// ==========================================
// 1. Clinical Clean (无菌实验室)
// ==========================================
const VariantClinical = withRealData(({ data }) => {
return (
<div className="p-6 bg-white w-full h-[240px] relative">
<div className="flex justify-between items-center mb-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-teal-400 shadow-[0_0_8px_rgba(45,212,191,0.5)]"></div>
<span className="text-xs font-medium text-slate-400 tracking-wide uppercase">System Vitals</span>
</div>
<span className="text-2xl font-light text-slate-700 font-mono tracking-tighter">98.4%</span>
</div>
<ResponsiveContainer width="100%" height={160}>
<AreaChart data={data}>
<defs>
<linearGradient id="clinicalGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#2dd4bf" stopOpacity={0.1}/>
<stop offset="95%" stopColor="#2dd4bf" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
<XAxis dataKey="date" hide />
<Area type="monotone" dataKey="totalEndpoints" stroke="#2dd4bf" strokeWidth={2} fillOpacity={1} fill="url(#clinicalGradient)" />
<Area type="monotone" dataKey="totalSubdomains" stroke="#94a3b8" strokeWidth={1} strokeDasharray="4 4" fill="transparent" />
</AreaChart>
</ResponsiveContainer>
</div>
)
})
// ==========================================
// 2. Swiss Grid (瑞士网格)
// ==========================================
const VariantSwiss = withRealData(({ data }) => {
return (
<div className="bg-[#f0f0f0] w-full h-[240px] relative font-sans">
<div className="absolute top-0 left-0 p-3 bg-red-600 text-white font-bold text-xs uppercase">
Figure 02
</div>
<div className="pt-12 px-6 pb-6 h-full flex flex-col">
<h3 className="text-3xl font-black text-black tracking-tight leading-none mb-4">ASSET<br/>DISTRIBUTION</h3>
<div className="flex-1 border-t-4 border-black pt-4">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data.slice(-7)} barGap={0}>
<Bar dataKey="totalEndpoints" fill="#000000" />
<Bar dataKey="totalIps" fill="#ff3e00" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
)
})
// ==========================================
// 3. Architect Blueprint (建筑蓝图)
// ==========================================
const VariantBlueprint = withRealData(({ data }) => {
return (
<div className="bg-[#f4f7fa] w-full h-[240px] relative overflow-hidden p-4 border border-blue-200">
{/* Grid Background */}
<div className="absolute inset-0 bg-[linear-gradient(#e0e7ff_1px,transparent_1px),linear-gradient(90deg,#e0e7ff_1px,transparent_1px)] bg-[size:20px_20px]"></div>
<div className="relative z-10 h-full flex flex-col">
<div className="flex justify-between border-b-2 border-blue-900/10 pb-2 mb-2">
<span className="font-mono text-[10px] text-blue-900/60">DWG. NO. A-702</span>
<span className="font-mono text-[10px] text-blue-900/60">SCALE: 1:100</span>
</div>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data}>
<CartesianGrid strokeDasharray="5 5" stroke="#cbd5e1" />
<Line type="step" dataKey="totalEndpoints" stroke="#1e3a8a" strokeWidth={2} dot={{r: 3, fill: '#fff', stroke: '#1e3a8a'}} />
<ReferenceLine y={250} label={{ position: 'right', value: 'MAX_CAP', fontSize: 8, fill: '#1e3a8a' }} stroke="#1e3a8a" strokeDasharray="3 3" />
</LineChart>
</ResponsiveContainer>
</div>
</div>
)
})
// ==========================================
// 4. E-Ink Paper (电子墨水)
// ==========================================
const VariantEInk = withRealData(({ data }) => {
return (
<div className="bg-[#e4e4e4] w-full h-[240px] relative p-4 font-mono">
{/* Noise Texture */}
<div className="absolute inset-0 opacity-10 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] mix-blend-multiply"></div>
<div className="relative z-10 border-2 border-black h-full p-2 bg-[#f0f0f0] shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]">
<div className="border-b border-black mb-2 pb-1 flex justify-between items-baseline">
<span className="font-bold text-sm">READER_MODE</span>
<span className="text-[10px]">PAGE 1/1</span>
</div>
<ResponsiveContainer width="100%" height={160}>
<LineChart data={data}>
<XAxis dataKey="date" tickLine={false} axisLine={true} stroke="#000" tick={{fontSize: 9, fill: '#000'}} tickFormatter={d => d.slice(8)} />
<Line type="linear" dataKey="totalEndpoints" stroke="#000" strokeWidth={1.5} dot={false} />
<Line type="linear" dataKey="totalSubdomains" stroke="#000" strokeWidth={1.5} strokeDasharray="2 2" dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
)
})
// ==========================================
// 5. Financial Times (金融报表)
// ==========================================
const VariantFinancial = withRealData(({ data }) => {
return (
<div className="bg-[#fff1e5] w-full h-[240px] p-5 font-serif relative">
<div className="border-t-4 border-black w-8 mb-4"></div>
<h3 className="text-xl font-bold text-slate-900 mb-1">Market Overview</h3>
<p className="text-xs text-slate-600 mb-4 italic">Daily asset fluctuation index</p>
<ResponsiveContainer width="100%" height={120}>
<AreaChart data={data}>
<CartesianGrid vertical={false} stroke="#e2d6cc" />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{fontSize: 10, fill: '#555'}} tickFormatter={d => d.slice(8)} />
<Area type="monotone" dataKey="totalEndpoints" stroke="#991b1b" fill="#fca5a5" fillOpacity={0.2} strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
</div>
)
})
// ==========================================
// 6. Soft Neumorphism (软拟态)
// ==========================================
const VariantNeumorphism = withRealData(({ data }) => {
return (
<div className="bg-[#efeff2] w-full h-[240px] p-6 flex flex-col justify-center items-center">
<div className="w-full h-full rounded-2xl bg-[#efeff2] shadow-[10px_10px_20px_#cdcdd0,-10px_-10px_20px_#ffffff] p-4 flex flex-col">
<div className="flex justify-between items-center mb-4 px-2">
<span className="text-slate-500 font-medium text-xs">Activity</span>
<div className="w-8 h-8 rounded-full bg-[#efeff2] shadow-[5px_5px_10px_#cdcdd0,-5px_-5px_10px_#ffffff] flex items-center justify-center text-slate-400">
<IconActivity className="w-4 h-4" />
</div>
</div>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data}>
<Line type="natural" dataKey="totalEndpoints" stroke="#6366f1" strokeWidth={3} dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
)
})
// ==========================================
// 7. Frosted Glass Light (浅色毛玻璃)
// ==========================================
const VariantFrosted = withRealData(({ data }) => {
return (
<div className="bg-gradient-to-br from-blue-50 to-purple-50 w-full h-[240px] relative p-4 flex items-center justify-center overflow-hidden">
{/* Orbs */}
<div className="absolute top-[-20%] right-[-10%] w-40 h-40 bg-blue-300 rounded-full blur-3xl opacity-60"></div>
<div className="absolute bottom-[-10%] left-[-10%] w-40 h-40 bg-purple-300 rounded-full blur-3xl opacity-60"></div>
<div className="w-full h-full bg-white/40 backdrop-blur-xl border border-white/50 rounded-xl p-4 shadow-xl relative z-10 flex flex-col">
<div className="text-slate-600 font-semibold text-sm mb-2">Glass Metrics</div>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data.slice(-10)} barSize={8}>
<Bar dataKey="totalEndpoints" fill="rgba(255,255,255,0.8)" radius={[4,4,4,4]} />
<Bar dataKey="totalIps" fill="rgba(99, 102, 241, 0.6)" radius={[4,4,4,4]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
)
})
// ==========================================
// 8. Technical Manual (技术手册)
// ==========================================
const VariantManual = withRealData(({ data }) => {
return (
<div className="bg-white w-full h-[240px] p-4 border-2 border-slate-800 relative">
<div className="absolute top-2 right-2 bg-yellow-400 text-black text-[10px] font-bold px-1 border border-black">
FIG 1.4
</div>
<div className="flex flex-col h-full">
<div className="border-b border-slate-800 pb-2 mb-2">
<h4 className="font-bold text-slate-900 text-sm uppercase">Endpoint Variance</h4>
<p className="text-[10px] text-slate-600 font-mono">See reference pg. 42</p>
</div>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data}>
<CartesianGrid stroke="#e2e8f0" />
<Line type="monotone" dataKey="totalEndpoints" stroke="#0f172a" strokeWidth={1} dot={{r: 2, fill: '#000'}} />
<Line type="monotone" dataKey="totalWebsites" stroke="#64748b" strokeWidth={1} strokeDasharray="3 3" dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
)
})
// ==========================================
// 9. Ceramic Glaze (陶瓷釉面)
// ==========================================
const VariantCeramic = withRealData(({ data }) => {
return (
<div className="bg-[#fdfbf7] w-full h-[240px] p-6">
<div className="w-full h-full bg-white rounded-[2rem] shadow-[inset_0_2px_15px_rgba(0,0,0,0.05),0_10px_20px_rgba(0,0,0,0.02)] p-5 border border-white flex flex-col items-center">
<span className="text-slate-400 font-serif italic text-sm mb-2">organic growth</span>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data}>
<Line type="basis" dataKey="totalEndpoints" stroke="#d4b996" strokeWidth={4} strokeLinecap="round" dot={false} />
<Line type="basis" dataKey="totalIps" stroke="#a5b4fc" strokeWidth={4} strokeLinecap="round" dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
)
})
// ==========================================
// 10. Retro OS Light (复古系统)
// ==========================================
const VariantRetroOS = withRealData(({ data }) => {
return (
<div className="bg-[#008080] w-full h-[240px] p-4 flex items-center justify-center">
<div className="bg-[#c0c0c0] border-t-2 border-l-2 border-white border-b-2 border-r-2 border-black w-full h-full flex flex-col p-1 shadow-md">
<div className="bg-[#000080] text-white px-2 py-0.5 text-xs font-bold flex justify-between items-center bg-gradient-to-r from-[#000080] to-[#1084d0]">
<span>Performance.exe</span>
<div className="bg-[#c0c0c0] text-black w-3 h-3 flex items-center justify-center text-[8px] border border-white border-b-black border-r-black">x</div>
</div>
<div className="flex-1 p-2 bg-white border border-gray-500 m-1">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data.slice(-8)}>
<CartesianGrid strokeDasharray="2 2" />
<Bar dataKey="totalEndpoints" fill="#000080" />
<Bar dataKey="totalSubdomains" fill="#008000" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
)
})
// ==========================================
// 11. Grid Paper (方格纸)
// ==========================================
const VariantGridPaper = withRealData(({ data }) => {
return (
<div className="bg-white w-full h-[240px] relative p-6 border border-slate-200">
<div className="absolute inset-0 bg-[linear-gradient(#e5e5f7_1px,transparent_1px),linear-gradient(90deg,#e5e5f7_1px,transparent_1px)] bg-[size:20px_20px]"></div>
<div className="absolute left-6 top-0 w-[1px] h-full bg-red-300 opacity-50"></div>
<div className="relative z-10 h-full">
<h3 className="font-handwriting text-slate-500 text-lg mb-2 rotate-[-2deg] origin-left">Weekly Stats</h3>
<ResponsiveContainer width="100%" height="80%">
<LineChart data={data}>
<Line type="monotone" dataKey="totalEndpoints" stroke="#3b82f6" strokeWidth={2} dot={false} style={{opacity: 0.8}} />
<Line type="monotone" dataKey="totalIps" stroke="#ef4444" strokeWidth={2} dot={false} style={{opacity: 0.8}} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
)
})
// ==========================================
// 12. Corporate Clean (企业极简)
// ==========================================
const VariantCorporate = withRealData(({ data }) => {
return (
<div className="bg-white w-full h-[240px] p-6">
<div className="flex justify-between items-end mb-6">
<div>
<div className="text-sm font-semibold text-slate-900">Total Requests</div>
<div className="text-3xl font-bold text-slate-900 tracking-tight">
{data.length > 0 ? (data[data.length-1].totalEndpoints / 1000).toFixed(1) + 'K' : '0'}
</div>
</div>
<div className="bg-green-100 text-green-700 px-2 py-1 rounded-full text-xs font-semibold">
+12.5%
</div>
</div>
<ResponsiveContainer width="100%" height={100}>
<BarChart data={data}>
<Bar dataKey="totalEndpoints" fill="#6366f1" radius={[2,2,0,0]} />
</BarChart>
</ResponsiveContainer>
</div>
)
})
// ==========================================
// 13. Solar Flare (日耀)
// ==========================================
const VariantSolar = withRealData(({ data }) => {
return (
<div className="bg-[#fffbf0] w-full h-[240px] p-6 relative overflow-hidden">
<div className="absolute top-[-50%] left-[-20%] w-[100%] h-[100%] bg-orange-200/20 blur-3xl rounded-full"></div>
<div className="relative z-10 h-full flex flex-col justify-end">
<div className="mb-auto text-orange-900/80 font-medium uppercase tracking-wider text-xs flex items-center gap-2">
<IconSun className="w-4 h-4" /> Solar Metrics
</div>
<ResponsiveContainer width="100%" height="70%">
<AreaChart data={data}>
<defs>
<linearGradient id="solarGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.4}/>
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0}/>
</linearGradient>
</defs>
<Area type="monotone" dataKey="totalEndpoints" stroke="#d97706" strokeWidth={2} fill="url(#solarGradient)" />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
)
})
// ==========================================
// 14. Ice Sheet (冰层)
// ==========================================
const VariantIce = withRealData(({ data }) => {
return (
<div className="bg-[#f0f9ff] w-full h-[240px] p-0 relative border-t-4 border-sky-500">
<div className="p-4 flex justify-between items-center bg-sky-50">
<span className="font-bold text-sky-900 text-sm">Cold Storage</span>
<IconDroplet className="w-4 h-4 text-sky-500" />
</div>
<div className="h-[180px] w-full p-4">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data}>
<CartesianGrid stroke="#e0f2fe" vertical={false} />
<Line type="linear" dataKey="totalEndpoints" stroke="#0ea5e9" strokeWidth={3} dot={{r: 4, fill: '#fff', strokeWidth: 2}} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
)
})
// ==========================================
// 15. Code Editor Light (代码编辑器)
// ==========================================
const VariantCodeEditor = withRealData(({ data }) => {
return (
<div className="bg-[#fdfdfd] w-full h-[240px] font-mono text-xs border border-slate-200 flex flex-col">
<div className="bg-[#f3f3f3] px-4 py-2 text-slate-500 text-[10px] flex gap-4 border-b border-slate-200">
<span>metrics.json</span>
<span className="text-slate-300">|</span>
<span>UTF-8</span>
</div>
<div className="flex-1 p-4 relative overflow-hidden">
<div className="absolute left-0 top-4 bottom-0 w-8 text-right pr-2 text-slate-300 border-r border-slate-100 select-none leading-5">
{Array.from({length: 8}).map((_, i) => <div key={i}>{i+1}</div>)}
</div>
<div className="ml-8 h-full">
<div className="text-purple-600 mb-2">import <span className="text-blue-600">Stats</span> from <span className="text-orange-600">&apos;./core&apos;</span>;</div>
<div className="h-[100px] w-full mt-4">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data}>
<Line type="step" dataKey="totalEndpoints" stroke="#059669" strokeWidth={2} dot={false} />
<Line type="step" dataKey="totalIps" stroke="#d97706" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
</div>
)
})
// ==========================================
// 16. Scientific Journal (科学期刊)
// ==========================================
const VariantJournal = withRealData(({ data }) => {
return (
<div className="bg-white w-full h-[240px] p-5 font-serif border border-slate-200">
<div className="text-center mb-4 border-b-2 border-black pb-2">
<h4 className="font-bold text-lg uppercase">Table III</h4>
<p className="text-[10px] italic">Longitudinal analysis of subdomain propagation</p>
</div>
<div className="flex gap-4 h-[120px]">
<div className="w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={data}>
<CartesianGrid strokeDasharray="1 1" stroke="#000" strokeOpacity={0.1} />
<XAxis dataKey="date" tick={{fontSize: 8, fontFamily: 'serif'}} tickFormatter={d => d.slice(8)} />
<YAxis tick={{fontSize: 8, fontFamily: 'serif'}} />
<Bar dataKey="totalEndpoints" fill="#ccc" barSize={10} />
<Line type="monotone" dataKey="totalSubdomains" stroke="#000" strokeWidth={1.5} dot={{r: 2, fill: '#000'}} />
</ComposedChart>
</ResponsiveContainer>
</div>
</div>
</div>
)
})
// ==========================================
// 17. Metro UI (磁贴风格)
// ==========================================
const VariantMetro = withRealData(({ data }) => {
const latest = data.length > 0 ? data[data.length-1] : { totalSubdomains: 0, totalVulns: 0 }
return (
<div className="w-full h-[240px] grid grid-cols-2 grid-rows-2 gap-1 bg-white">
<div className="bg-[#00a4ef] p-3 text-white flex flex-col justify-between">
<IconWorld className="w-6 h-6" />
<div>
<div className="text-2xl font-bold">{latest.totalSubdomains}</div>
<div className="text-xs opacity-80">Domains</div>
</div>
</div>
<div className="bg-[#ffb900] p-3 text-white flex flex-col justify-between">
<IconAlertTriangle className="w-6 h-6" />
<div>
<div className="text-2xl font-bold">{latest.totalVulns}</div>
<div className="text-xs opacity-80">Alerts</div>
</div>
</div>
<div className="col-span-2 bg-[#f25022] p-3 relative">
<div className="absolute top-3 left-3 text-white text-xs font-bold uppercase">Activity Trend</div>
<div className="absolute bottom-0 left-0 w-full h-[60%] px-2">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data}>
<Area type="monotone" dataKey="totalEndpoints" stroke="white" fill="rgba(255,255,255,0.3)" strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
</div>
)
})
// ==========================================
// 18. Japanese Minimal (日式极简)
// ==========================================
const VariantJapanese = withRealData(({ data }) => {
return (
<div className="bg-[#fcfaf5] w-full h-[240px] p-6 relative">
<div className="absolute right-4 top-4 bottom-4 w-8 border-l border-stone-300 flex flex-col items-center py-2 text-stone-400 text-xs font-serif writing-vertical-rl tracking-widest">
</div>
<div className="mr-12 h-full flex flex-col justify-end">
<div className="mb-auto">
<div className="w-8 h-8 rounded-full bg-red-600 mb-2"></div>
<h3 className="text-stone-800 font-bold">Zen Metrics</h3>
</div>
<div className="h-[100px] border-b border-stone-800 pb-1">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data.slice(-8)}>
<Bar dataKey="totalEndpoints" fill="#292524" radius={[2,2,0,0]} barSize={4} />
<Bar dataKey="totalSubdomains" fill="#a8a29e" radius={[2,2,0,0]} barSize={4} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
)
})
// ==========================================
// 19. Isometric Light (等轴测 - 伪3D)
// ==========================================
const VariantIsometric = withRealData(({ data }) => {
return (
<div className="bg-slate-100 w-full h-[240px] flex items-center justify-center overflow-hidden perspective-1000">
<div className="transform rotate-x-12 rotate-z-[-4deg] skew-y-12 bg-white shadow-2xl p-4 w-[80%] h-[60%] border border-slate-200 rounded-lg">
<div className="flex justify-between items-center mb-4 border-b border-slate-100 pb-2">
<div className="flex gap-1">
<div className="w-2 h-2 rounded-full bg-red-400"></div>
<div className="w-2 h-2 rounded-full bg-yellow-400"></div>
<div className="w-2 h-2 rounded-full bg-green-400"></div>
</div>
</div>
<ResponsiveContainer width="100%" height="80%">
<LineChart data={data}>
<Line type="monotone" dataKey="totalEndpoints" stroke="#3b82f6" strokeWidth={3} dot={false} />
<Area type="monotone" dataKey="totalEndpoints" fill="#dbeafe" stroke="none" />
</LineChart>
</ResponsiveContainer>
</div>
</div>
)
})
// ==========================================
// 20. Wireframe (线框图)
// ==========================================
const VariantWireframe = withRealData(({ data }) => {
return (
<div className="bg-white w-full h-[240px] p-4 relative">
{/* Crosshairs */}
<div className="absolute top-4 left-4 w-4 h-4 border-t border-l border-blue-500"></div>
<div className="absolute top-4 right-4 w-4 h-4 border-t border-r border-blue-500"></div>
<div className="absolute bottom-4 left-4 w-4 h-4 border-b border-l border-blue-500"></div>
<div className="absolute bottom-4 right-4 w-4 h-4 border-b border-r border-blue-500"></div>
<div className="h-full w-full border border-dashed border-blue-200 p-2 flex items-center justify-center">
<div className="w-full h-full relative">
<div className="absolute inset-0 flex items-center justify-center text-blue-100 text-[100px] font-bold opacity-20 pointer-events-none">X</div>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data}>
<CartesianGrid stroke="#e2e8f0" />
<Line type="linear" dataKey="totalEndpoints" stroke="#3b82f6" strokeWidth={1} dot={{r: 3, stroke: '#3b82f6', fill: 'white'}} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
)
})
export default function AssetPulseLightDesignsPage() {
return (
<div className="p-8 max-w-7xl mx-auto space-y-12 pb-32 bg-slate-50 min-h-screen text-slate-900" data-theme="light">
<div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tight text-slate-900">Asset Pulse: Light Mode Collection (Live Data)</h1>
<p className="text-slate-500">20 distinct high-fidelity visualization styles connected to real asset history data.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8">
<DesignCard title="01 // Clinical Clean" description="Sterile, teal accents, precise data.">
<VariantClinical />
</DesignCard>
<DesignCard title="02 // Swiss Grid" description="Helvetica, high contrast, international style.">
<VariantSwiss />
</DesignCard>
<DesignCard title="03 // Architect Blueprint" description="Construction aesthetic, grid lines, scale.">
<VariantBlueprint />
</DesignCard>
<DesignCard title="04 // E-Ink Paper" description="High legibility, noise texture, monochrome.">
<VariantEInk />
</DesignCard>
<DesignCard title="05 // Financial Times" description="Serif fonts, salmon background, classic.">
<VariantFinancial />
</DesignCard>
<DesignCard title="06 // Soft Neumorphism" description="Subtle shadows, depth, soft tactile feel.">
<VariantNeumorphism />
</DesignCard>
<DesignCard title="07 // Frosted Glass" description="Translucent layers, colorful blur.">
<VariantFrosted />
</DesignCard>
<DesignCard title="08 // Technical Manual" description="Instructional design, heavy strokes.">
<VariantManual />
</DesignCard>
<DesignCard title="09 // Ceramic Glaze" description="Organic curves, warm whites, soft finish.">
<VariantCeramic />
</DesignCard>
<DesignCard title="10 // Retro OS" description="Windows 95/System 7 nostalgia.">
<VariantRetroOS />
</DesignCard>
<DesignCard title="11 // Grid Paper" description="Hand-drawn feel on graph paper.">
<VariantGridPaper />
</DesignCard>
<DesignCard title="12 // Corporate Clean" description="Standard SaaS metric card.">
<VariantCorporate />
</DesignCard>
<DesignCard title="13 // Solar Flare" description="Warm gradients, energetic.">
<VariantSolar />
</DesignCard>
<DesignCard title="14 // Ice Sheet" description="Cold storage, sharp, blue tones.">
<VariantIce />
</DesignCard>
<DesignCard title="15 // Code Editor" description="IDE inspired syntax highlighting.">
<VariantCodeEditor />
</DesignCard>
<DesignCard title="16 // Scientific Journal" description="Academic rigor, Times New Roman.">
<VariantJournal />
</DesignCard>
<DesignCard title="17 // Metro UI" description="Flat blocks, vibrant colors.">
<VariantMetro />
</DesignCard>
<DesignCard title="18 // Japanese Minimal" description="Zen garden aesthetic, vertical text.">
<VariantJapanese />
</DesignCard>
<DesignCard title="19 // Isometric" description="3D perspective, floating cards.">
<VariantIsometric />
</DesignCard>
<DesignCard title="20 // Wireframe" description="Blue pencil structure, prototyping look.">
<VariantWireframe />
</DesignCard>
</div>
</div>
)
}

View File

@@ -0,0 +1,260 @@
"use client"
import { useMemo, useState } from "react"
import { Area, CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
import { useTranslations } from "next-intl"
import { PageHeader } from "@/components/common/page-header"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { ChartConfig, ChartContainer } from "@/components/ui/chart"
import { useStatisticsHistory } from "@/hooks/use-dashboard"
import type { StatisticsHistoryItem } from "@/types/dashboard.types"
type SeriesKey = "totalSubdomains" | "totalIps" | "totalEndpoints" | "totalWebsites"
type TrendVariant = "layered" | "mono" | "band" | "focus" | "grid"
const VARIANTS: Array<{ value: TrendVariant; label: string; desc: string }> = [
{ value: "layered", label: "叠层折线", desc: "多线并行,线宽随 hover 强调" },
{ value: "mono", label: "单线主脉冲", desc: "仅显示总资产主线" },
{ value: "band", label: "波段趋势", desc: "总资产面积 + 线形轮廓" },
{ value: "focus", label: "聚焦模式", desc: "悬停高亮,其他线降透明" },
{ value: "grid", label: "网格脉冲", desc: "工业网格背景增强秩序感" },
]
function fillMissingDates(data: StatisticsHistoryItem[] | undefined, days: number): StatisticsHistoryItem[] {
if (!data || data.length === 0) return []
const dataMap = new Map(data.map(item => [item.date, item]))
const earliestDate = new Date(data[0].date)
const result: StatisticsHistoryItem[] = []
const startDate = new Date(earliestDate)
startDate.setDate(startDate.getDate() - (days - data.length))
for (let i = 0; i < days; i++) {
const currentDate = new Date(startDate)
currentDate.setDate(startDate.getDate() + i)
const dateStr = currentDate.toISOString().split("T")[0]
const existing = dataMap.get(dateStr)
result.push(existing ?? {
date: dateStr,
totalTargets: 0,
totalSubdomains: 0,
totalIps: 0,
totalEndpoints: 0,
totalWebsites: 0,
totalVulns: 0,
totalAssets: 0,
})
}
return result
}
function formatDate(dateStr: string) {
const date = new Date(dateStr)
return `${date.getMonth() + 1}/${date.getDate()}`
}
function formatNumber(value: number) {
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`
if (value >= 1000) return `${(value / 1000).toFixed(1)}K`
return value.toString()
}
function LineVariantCard({
variant,
data,
chartConfig,
title,
description,
totals,
}: {
variant: TrendVariant
data: StatisticsHistoryItem[]
chartConfig: ChartConfig
title: string
description: string
totals: StatisticsHistoryItem | null
}) {
const [hoveredLine, setHoveredLine] = useState<SeriesKey | null>(null)
const showTotalOnly = variant === "mono" || variant === "band"
const showGrid = variant === "grid"
return (
<Card className="overflow-hidden">
<CardHeader className="space-y-1">
<CardTitle className="text-sm">{title}</CardTitle>
<p className="text-xs text-muted-foreground">{description}</p>
</CardHeader>
<CardContent className="space-y-3">
<div className={showGrid ? "rounded-md border border-border/60 bg-muted/10 p-2" : undefined}>
<ChartContainer config={chartConfig} className="aspect-auto h-[160px] w-full">
<LineChart
accessibilityLayer
data={data}
margin={{ left: 0, right: 12, top: 12, bottom: 0 }}
onMouseLeave={() => setHoveredLine(null)}
>
<CartesianGrid vertical={showGrid} strokeDasharray="3 3" />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={formatDate}
fontSize={12}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
width={45}
fontSize={12}
tickFormatter={formatNumber}
/>
{variant === "band" && (
<Area
type="monotone"
dataKey="totalAssets"
stroke="var(--color-totalAssets)"
fill="var(--color-totalAssets)"
fillOpacity={0.18}
strokeWidth={2.5}
dot={{ r: 3, fill: "var(--color-totalAssets)" }}
/>
)}
{showTotalOnly && variant !== "band" && (
<Line
dataKey="totalAssets"
type="monotone"
stroke="var(--color-totalAssets)"
strokeWidth={2.5}
dot={{ r: 3, fill: "var(--color-totalAssets)" }}
/>
)}
{!showTotalOnly && (
<>
<Line
dataKey="totalSubdomains"
type="monotone"
stroke="var(--color-totalSubdomains)"
strokeWidth={hoveredLine === "totalSubdomains" ? 4 : 2}
strokeOpacity={variant === "focus" && hoveredLine && hoveredLine !== "totalSubdomains" ? 0.18 : 1}
dot={{ r: 3, fill: "var(--color-totalSubdomains)" }}
style={{ cursor: "pointer", transition: "stroke-width 0.15s" }}
onMouseEnter={() => setHoveredLine("totalSubdomains")}
/>
<Line
dataKey="totalIps"
type="monotone"
stroke="var(--color-totalIps)"
strokeWidth={hoveredLine === "totalIps" ? 4 : 2}
strokeOpacity={variant === "focus" && hoveredLine && hoveredLine !== "totalIps" ? 0.18 : 1}
dot={{ r: 3, fill: "var(--color-totalIps)" }}
style={{ cursor: "pointer", transition: "stroke-width 0.15s" }}
onMouseEnter={() => setHoveredLine("totalIps")}
/>
<Line
dataKey="totalEndpoints"
type="monotone"
stroke="var(--color-totalEndpoints)"
strokeWidth={hoveredLine === "totalEndpoints" ? 4 : 2}
strokeOpacity={variant === "focus" && hoveredLine && hoveredLine !== "totalEndpoints" ? 0.18 : 1}
dot={{ r: 3, fill: "var(--color-totalEndpoints)" }}
style={{ cursor: "pointer", transition: "stroke-width 0.15s" }}
onMouseEnter={() => setHoveredLine("totalEndpoints")}
/>
<Line
dataKey="totalWebsites"
type="monotone"
stroke="var(--color-totalWebsites)"
strokeWidth={hoveredLine === "totalWebsites" ? 4 : 2}
strokeOpacity={variant === "focus" && hoveredLine && hoveredLine !== "totalWebsites" ? 0.18 : 1}
dot={{ r: 3, fill: "var(--color-totalWebsites)" }}
style={{ cursor: "pointer", transition: "stroke-width 0.15s" }}
onMouseEnter={() => setHoveredLine("totalWebsites")}
/>
</>
)}
</LineChart>
</ChartContainer>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
<span>{totals ? formatDate(totals.date) : "—"}</span>
{showTotalOnly ? (
<span className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-primary/70" />
<span></span>
<span className="font-medium text-foreground">{totals?.totalAssets ?? 0}</span>
</span>
) : (
<span className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-blue-500" />
<span className="font-medium text-foreground">{totals?.totalSubdomains ?? 0}</span>
<span className="h-2.5 w-2.5 rounded-full bg-emerald-500" />
<span className="font-medium text-foreground">{totals?.totalWebsites ?? 0}</span>
<span className="h-2.5 w-2.5 rounded-full bg-orange-500" />
<span className="font-medium text-foreground">{totals?.totalIps ?? 0}</span>
</span>
)}
</div>
</CardContent>
</Card>
)
}
export default function AssetPulseLineDemosPage() {
const t = useTranslations("dashboard.assetTrend")
const tDist = useTranslations("dashboard.assetDistribution")
const { data: rawData } = useStatisticsHistory(7)
const chartConfig = useMemo(() => ({
totalAssets: {
label: tDist("totalAssets"),
color: "var(--primary)",
},
totalSubdomains: {
label: t("subdomains"),
color: "#3b82f6",
},
totalIps: {
label: t("ips"),
color: "#f97316",
},
totalEndpoints: {
label: t("endpoints"),
color: "#eab308",
},
totalWebsites: {
label: t("websites"),
color: "#22c55e",
},
} satisfies ChartConfig), [t, tDist])
const data = useMemo(() => fillMissingDates(rawData, 7), [rawData])
const latest = useMemo(
() => (data.length > 0 ? data[data.length - 1] : null),
[data]
)
return (
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<PageHeader
code="DASH-PULSE"
title="Asset Pulse · Line Variants"
description="折线图为核心的 ASSET PULSE 方案5 套)"
/>
<div className="grid gap-6 px-4 lg:px-6 md:grid-cols-2">
{VARIANTS.map((item, index) => (
<LineVariantCard
key={item.value}
variant={item.value}
data={data}
chartConfig={chartConfig}
title={`${index + 1}. ${item.label}`}
description={item.desc}
totals={latest}
/>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,323 @@
"use client"
import React from "react"
type BadgeType = 'info' | 'success' | 'warning' | 'error'
type BadgeProps = { label: string; value: string | number; type: BadgeType }
export default function BadgeVariantsPage() {
const stats: BadgeProps[] = [
{ label: "SUBDOMAIN", value: "156", type: "info" },
{ label: "WEBSITE", value: "89", type: "success" },
{ label: "IP", value: "45", type: "warning" },
{ label: "VULN", value: "23", type: "error" },
]
return (
<div className="flex flex-col gap-12 p-8 max-w-5xl mx-auto">
<div>
<h1 className="text-2xl font-bold mb-2"> (Badge) </h1>
<p className="text-muted-foreground"></p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
{/* 方案 A: 极简工业 (当前) */}
<div className="space-y-4">
<div className="border-b pb-2">
<h3 className="font-medium"> A ()</h3>
<p className="text-xs text-muted-foreground"> + + </p>
</div>
<div className="p-6 bg-zinc-50 border rounded-lg">
<div className="bg-white border rounded shadow-sm p-4">
<div className="text-xs text-muted-foreground mb-2"></div>
<div className="flex flex-wrap gap-2">
{stats.map((s, i) => (
<BadgeA key={i} {...s} />
))}
</div>
</div>
</div>
</div>
{/* 方案 B: 胶囊实心 */}
<div className="space-y-4">
<div className="border-b pb-2">
<h3 className="font-medium"> B</h3>
<p className="text-xs text-muted-foreground"></p>
</div>
<div className="p-6 bg-zinc-50 border rounded-lg">
<div className="bg-white border rounded shadow-sm p-4">
<div className="text-xs text-muted-foreground mb-2"></div>
<div className="flex flex-wrap gap-2">
{stats.map((s, i) => (
<BadgeB key={i} {...s} />
))}
</div>
</div>
</div>
</div>
{/* 方案 C: 点缀圆点 */}
<div className="space-y-4">
<div className="border-b pb-2">
<h3 className="font-medium"> C</h3>
<p className="text-xs text-muted-foreground"></p>
</div>
<div className="p-6 bg-zinc-50 border rounded-lg">
<div className="bg-white border rounded shadow-sm p-4">
<div className="text-xs text-muted-foreground mb-2"></div>
<div className="flex flex-wrap gap-3">
{stats.map((s, i) => (
<BadgeC key={i} {...s} />
))}
</div>
</div>
</div>
</div>
{/* 方案 D: 科技描边 */}
<div className="space-y-4">
<div className="border-b pb-2">
<h3 className="font-medium"> D</h3>
<p className="text-xs text-muted-foreground"></p>
</div>
<div className="p-6 bg-zinc-50 border rounded-lg">
<div className="bg-white border rounded shadow-sm p-4">
<div className="text-xs text-muted-foreground mb-2"></div>
<div className="flex flex-wrap gap-2">
{stats.map((s, i) => (
<BadgeD key={i} {...s} />
))}
</div>
</div>
</div>
</div>
{/* 方案 E: 微型卡片 */}
<div className="space-y-4">
<div className="border-b pb-2">
<h3 className="font-medium"> E</h3>
<p className="text-xs text-muted-foreground">便</p>
</div>
<div className="p-6 bg-zinc-50 border rounded-lg">
<div className="bg-white border rounded shadow-sm p-4">
<div className="text-xs text-muted-foreground mb-2"></div>
<div className="flex flex-wrap gap-2">
{stats.map((s, i) => (
<BadgeE key={i} {...s} />
))}
</div>
</div>
</div>
</div>
{/* 方案 F: 色块拼接 (Bauhaus 风格) */}
<div className="space-y-4">
<div className="border-b pb-2">
<h3 className="font-medium"> F (Bauhaus)</h3>
<p className="text-xs text-muted-foreground"></p>
</div>
<div className="p-6 bg-zinc-50 border rounded-lg">
<div className="bg-white border rounded shadow-sm p-4">
<div className="text-xs text-muted-foreground mb-2"></div>
<div className="flex flex-wrap gap-3">
{stats.map((s, i) => (
<BadgeF key={i} {...s} />
))}
</div>
</div>
</div>
</div>
{/* 方案 G: 粗下划线 */}
<div className="space-y-4">
<div className="border-b pb-2">
<h3 className="font-medium"> G线</h3>
<p className="text-xs text-muted-foreground">线</p>
</div>
<div className="p-6 bg-zinc-50 border rounded-lg">
<div className="bg-white border rounded shadow-sm p-4">
<div className="text-xs text-muted-foreground mb-2"></div>
<div className="flex flex-wrap gap-4">
{stats.map((s, i) => (
<BadgeG key={i} {...s} />
))}
</div>
</div>
</div>
</div>
{/* 方案 H: 工业铭牌 (黑白) */}
<div className="space-y-4">
<div className="border-b pb-2">
<h3 className="font-medium"> H</h3>
<p className="text-xs text-muted-foreground"></p>
</div>
<div className="p-6 bg-zinc-50 border rounded-lg">
<div className="bg-white border rounded shadow-sm p-4">
<div className="text-xs text-muted-foreground mb-2"></div>
<div className="flex flex-wrap gap-2">
{stats.map((s, i) => (
<BadgeH key={i} {...s} />
))}
</div>
</div>
</div>
</div>
</div>
</div>
)
}
// 辅助函数
const getColor = (type: BadgeType) => {
switch (type) {
case 'info': return 'text-sky-600 bg-sky-50 border-sky-200';
case 'success': return 'text-emerald-600 bg-emerald-50 border-emerald-200';
case 'warning': return 'text-amber-600 bg-amber-50 border-amber-200';
case 'error': return 'text-rose-600 bg-rose-50 border-rose-200';
default: return 'text-zinc-600 bg-zinc-50 border-zinc-200';
}
}
const getSolidBg = (type: BadgeType) => {
switch (type) {
case 'info': return 'bg-sky-600';
case 'success': return 'bg-emerald-600';
case 'warning': return 'bg-amber-500';
case 'error': return 'bg-rose-600';
default: return 'bg-zinc-600';
}
}
const getDotColor = (type: BadgeType) => {
switch (type) {
case 'info': return 'bg-sky-500';
case 'success': return 'bg-emerald-500';
case 'warning': return 'bg-amber-500';
case 'error': return 'bg-rose-500';
default: return 'bg-zinc-500';
}
}
const getLineColor = (type: BadgeType) => {
switch (type) {
case 'info': return 'border-sky-500';
case 'success': return 'border-emerald-500';
case 'warning': return 'border-amber-500';
case 'error': return 'border-rose-500';
default: return 'border-zinc-500';
}
}
const getBorderColor = (type: BadgeType) => {
switch (type) {
case 'info': return 'border-sky-500 text-sky-600';
case 'success': return 'border-emerald-500 text-emerald-600';
case 'warning': return 'border-amber-500 text-amber-600';
case 'error': return 'border-rose-500 text-rose-600';
default: return 'border-zinc-500 text-zinc-600';
}
}
// 方案组件
function BadgeA({ label, value, type }: BadgeProps) {
const colors = getColor(type);
return (
<span className={`inline-flex items-center rounded-none border px-2 py-1 text-[10px] font-mono font-medium tracking-wider uppercase ${colors}`}>
{value} {label}
</span>
)
}
function BadgeB({ label, value, type }: BadgeProps) {
let customClass = "";
if (type === 'info') customClass = "bg-sky-100 text-sky-700 border-transparent";
if (type === 'success') customClass = "bg-emerald-100 text-emerald-700 border-transparent";
if (type === 'warning') customClass = "bg-amber-100 text-amber-700 border-transparent";
if (type === 'error') customClass = "bg-rose-100 text-rose-700 border-transparent";
return (
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-[10px] font-bold tracking-wide uppercase ${customClass}`}>
{value} {label}
</span>
)
}
function BadgeC({ label, value, type }: BadgeProps) {
const dotColor = getDotColor(type);
return (
<span className="inline-flex items-center text-[11px] font-medium text-zinc-600">
<span className={`w-1.5 h-1.5 rounded-full mr-1.5 ${dotColor}`}></span>
<span className="font-mono font-bold mr-1 text-zinc-900">{value}</span>
<span className="text-[9px] text-zinc-400 uppercase tracking-wider">{label}</span>
</span>
)
}
function BadgeD({ label, value, type }: BadgeProps) {
const colors = getBorderColor(type);
return (
<span className={`inline-flex items-center rounded-sm border px-1.5 py-0.5 text-[10px] font-mono tracking-wider uppercase bg-white ${colors}`}>
<span className="font-bold mr-1">{value}</span>
<span className="opacity-70 text-[9px]">{label}</span>
</span>
)
}
function BadgeE({ label, value, type }: BadgeProps) {
const dotColor = getDotColor(type);
return (
<span className="inline-flex items-center rounded border border-zinc-200 bg-white shadow-sm px-2 py-0.5 text-[10px] overflow-hidden relative pl-2.5">
{/* 左侧色条 */}
<span className={`absolute left-0 top-0 bottom-0 w-1 ${dotColor}`}></span>
<span className="font-mono font-bold text-zinc-800 mr-1.5">{value}</span>
<span className="text-zinc-500 font-medium tracking-wide uppercase text-[9px]">{label}</span>
</span>
)
}
function BadgeF({ label, value, type }: BadgeProps) {
const solidBg = getSolidBg(type);
// 对于 F我们需要边框颜色与实心背景匹配
let borderClass = "border-zinc-200";
if (type === 'info') borderClass = "border-sky-600";
if (type === 'success') borderClass = "border-emerald-600";
if (type === 'warning') borderClass = "border-amber-500";
if (type === 'error') borderClass = "border-rose-600";
return (
<span className={`inline-flex items-stretch border ${borderClass} text-[10px] uppercase font-mono tracking-wider overflow-hidden rounded-none`}>
<span className={`px-1.5 py-0.5 text-white font-bold flex items-center justify-center ${solidBg}`}>
{value}
</span>
<span className="px-1.5 py-0.5 bg-white text-zinc-700 flex items-center font-medium">
{label}
</span>
</span>
)
}
function BadgeG({ label, value, type }: BadgeProps) {
const lineColor = getLineColor(type);
return (
<span className={`inline-flex items-baseline gap-1.5 text-[10px] uppercase tracking-wider border-b-2 ${lineColor} pb-0.5 px-0.5`}>
<span className="font-mono font-bold text-base leading-none text-zinc-900">{value}</span>
<span className="font-medium text-zinc-500">{label}</span>
</span>
)
}
function BadgeH({ label, value, type }: BadgeProps) {
const solidBg = getSolidBg(type);
return (
<span className="inline-flex items-center bg-zinc-900 text-white text-[10px] font-mono tracking-wider uppercase rounded-sm overflow-hidden pr-2">
<span className={`w-1 self-stretch mr-2 ${solidBg}`}></span>
<span className="font-bold mr-1.5">{value}</span>
<span className="opacity-60 font-medium">{label}</span>
</span>
)
}

View File

@@ -0,0 +1,458 @@
"use client"
import type { CSSProperties } from "react"
import {
ArrowUpRight,
BarChart3,
LayoutGrid,
Layers,
Sliders,
ShieldCheck,
ShieldAlert,
Activity,
Waves,
} from "@/components/icons"
const themeVars = {
"--bg": "#0F1114",
"--panel": "#161A1F",
"--panel-muted": "#1D232B",
"--border": "#2B3138",
"--border-strong": "#3E4753",
"--text": "#E8EDF2",
"--text-2": "#A4ADBA",
"--muted": "#6B7481",
"--shadow": "rgba(0, 0, 0, 0.35)",
"--cut": "10px",
} as CSSProperties
const stats = [
{ label: "Active Assets", value: "1,284", delta: "+4.2%" },
{ label: "Scan Throughput", value: "82%", delta: "+1.1%" },
{ label: "Critical Alerts", value: "12", delta: "-3" },
{ label: "Coverage", value: "96.8%", delta: "+0.6%" },
]
const navItems = [
{ label: "Overview", icon: LayoutGrid },
{ label: "Assets", icon: Layers },
{ label: "Scans", icon: Activity },
{ label: "Threats", icon: ShieldAlert },
{ label: "Settings", icon: Sliders },
]
const timeline = [
{ label: "Ingestion", value: "02:14" },
{ label: "Correlation", value: "04:32" },
{ label: "Mitigation", value: "08:09" },
]
const tableRows = [
{ id: "OP-291", name: "Gateway Sweep", owner: "Ops-7", status: "Stable", score: "A" },
{ id: "OP-377", name: "Credential Drift", owner: "Ops-3", status: "Observe", score: "B" },
{ id: "OP-408", name: "Outbound Flux", owner: "Ops-2", status: "Investigate", score: "A-" },
{ id: "OP-512", name: "Shadow Asset", owner: "Ops-1", status: "Monitor", score: "B+" },
]
const barSeries = [48, 62, 54, 68, 76, 58, 64]
const lineSeries = [12, 24, 18, 36, 30, 44, 38, 52]
export default function DashboardDemoPage() {
return (
<main className="relative min-h-screen bg-[var(--bg)] text-[var(--text)]" style={themeVars}>
<div className="pointer-events-none absolute inset-0 demo-grid" aria-hidden />
<div className="pointer-events-none absolute left-6 top-10 h-24 w-24 demo-ring" aria-hidden />
<div className="pointer-events-none absolute right-10 top-16 h-10 w-56 demo-beam" aria-hidden />
<div className="relative mx-auto w-full max-w-7xl px-6 py-8">
<div className="grid gap-6 lg:grid-cols-[220px_1fr]">
<aside className="panel demo-sidebar p-4 lg:sticky lg:top-8">
<div className="space-y-1 border-b border-[var(--border)] pb-4">
<p className="kicker">LunaFox</p>
<p className="text-sm font-semibold">Control Hub</p>
</div>
<nav className="mt-4 space-y-2">
{navItems.map((item, index) => (
<button
key={item.label}
className={`sidebar-item ${index === 0 ? "active" : ""}`}
type="button"
>
<item.icon className="h-4 w-4" />
<span>{item.label}</span>
</button>
))}
</nav>
<div className="mt-6 panel slab px-3 py-3 text-xs text-[var(--text-2)]">
<p className="text-[var(--text)]">System Health</p>
<p className="mt-2">Nominal · 99.1%</p>
</div>
</aside>
<div className="flex flex-col gap-6">
<header className="panel panel-hero flex flex-col gap-4 p-5 md:flex-row md:items-center md:justify-between">
<div className="space-y-2">
<p className="kicker">Operations Dashboard</p>
<h1 className="text-xl font-semibold tracking-wide">System Overview</h1>
<p className="text-sm text-[var(--text-2)]">Dark industrial minimal demo based on the current dashboard layout.</p>
</div>
<div className="flex flex-wrap gap-3 text-xs text-[var(--text-2)]">
<span className="tag">Cycle · 14:32</span>
<span className="tag">Nodes · 48</span>
<span className="tag">Runtime · Stable</span>
</div>
<span className="panel-ornament" aria-hidden />
<span className="panel-seam" aria-hidden />
</header>
<section className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{stats.map((item) => (
<div key={item.label} className="panel slab p-4">
<p className="text-xs text-[var(--text-2)]">{item.label}</p>
<div className="mt-3 flex items-end justify-between">
<span className="text-2xl font-semibold">{item.value}</span>
<span className="text-xs text-[var(--text-2)]">{item.delta}</span>
</div>
</div>
))}
</section>
<section className="grid gap-4 lg:grid-cols-[1.4fr_1fr]">
<div className="panel p-5">
<span className="panel-ornament" aria-hidden />
<span className="panel-seam" aria-hidden />
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="kicker">Signal Trend</p>
<h2 className="text-base font-semibold">Asset Pulse</h2>
</div>
<span className="tag">Last 24h</span>
</div>
<div className="mt-4 chart-frame">
<span className="chart-notch" aria-hidden />
<svg viewBox="0 0 400 140" className="h-full w-full">
<polyline
fill="none"
stroke="var(--text)"
strokeWidth="2"
points={lineSeries
.map((value, index) => `${index * 55},${140 - value}`)
.join(" ")}
/>
</svg>
</div>
<div className="mt-4 flex items-center gap-3 text-xs text-[var(--text-2)]">
<Waves className="h-4 w-4" />
<span>Variance within expected band.</span>
</div>
</div>
<div className="panel p-5">
<span className="panel-ornament" aria-hidden />
<span className="panel-seam" aria-hidden />
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="kicker">Distribution</p>
<h2 className="text-base font-semibold">Severity Mix</h2>
</div>
<span className="tag">Weekly</span>
</div>
<div className="mt-4 flex h-36 items-end gap-2">
{barSeries.map((value, index) => (
<span
key={`bar-${index}`}
className="bar"
style={{ height: `${value}%` }}
/>
))}
</div>
<div className="mt-4 grid grid-cols-3 gap-2 text-xs text-[var(--text-2)]">
{[
{ label: "High", value: "12" },
{ label: "Medium", value: "38" },
{ label: "Low", value: "74" },
].map((item) => (
<div key={item.label} className="panel slab flex items-center justify-between px-3 py-2">
<span>{item.label}</span>
<span className="text-[var(--text)]">{item.value}</span>
</div>
))}
</div>
</div>
</section>
<section className="grid gap-4 lg:grid-cols-[1.4fr_0.8fr]">
<div className="panel p-5">
<span className="panel-ornament" aria-hidden />
<span className="panel-seam" aria-hidden />
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="kicker">Operations</p>
<h2 className="text-base font-semibold">Recent Activity</h2>
</div>
<span className="tag">Updated 2m ago</span>
</div>
<div className="mt-4 overflow-hidden rounded-none border border-[var(--border)]">
<table className="w-full text-sm">
<thead className="bg-[var(--panel-muted)] text-xs text-[var(--text-2)]">
<tr>
<th className="px-4 py-3 text-left">ID</th>
<th className="px-4 py-3 text-left">Task</th>
<th className="px-4 py-3 text-left">Owner</th>
<th className="px-4 py-3 text-left">Status</th>
<th className="px-4 py-3 text-right">Score</th>
</tr>
</thead>
<tbody>
{tableRows.map((row) => (
<tr key={row.id} className="border-t border-[var(--border)]">
<td className="px-4 py-3 text-xs text-[var(--text-2)]">{row.id}</td>
<td className="px-4 py-3">{row.name}</td>
<td className="px-4 py-3 text-xs text-[var(--text-2)]">{row.owner}</td>
<td className="px-4 py-3 text-xs">
<span className="tag muted">{row.status}</span>
</td>
<td className="px-4 py-3 text-right">{row.score}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="panel p-5">
<span className="panel-ornament" aria-hidden />
<span className="panel-seam" aria-hidden />
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="kicker">Response Loop</p>
<h2 className="text-base font-semibold">Cycle Timing</h2>
</div>
<ShieldCheck className="h-5 w-5" />
</div>
<div className="mt-4 space-y-3">
{timeline.map((item) => (
<div key={item.label} className="panel slab flex items-center justify-between px-4 py-3">
<span className="text-sm">{item.label}</span>
<span className="text-sm text-[var(--text-2)]">{item.value}</span>
</div>
))}
</div>
<div className="mt-4 panel slab flex items-center justify-between px-4 py-3 text-xs text-[var(--text-2)]">
<span className="flex items-center gap-2">
<BarChart3 className="h-4 w-4" />
Output band stable
</span>
<ArrowUpRight className="h-4 w-4" />
</div>
</div>
</section>
</div>
</div>
</div>
<style jsx>{`
.demo-grid {
background-image: none;
}
.demo-ring {
border-radius: 999px;
border: 2px solid var(--border-strong);
background: rgba(232, 237, 242, 0.04);
}
.demo-beam {
border-top: 2px solid var(--border-strong);
border-bottom: 1px solid var(--border);
opacity: 0.7;
}
.panel {
position: relative;
background: var(--panel);
border: 1px solid var(--border);
box-shadow: 0 6px 12px var(--shadow);
clip-path: polygon(0 0, calc(100% - var(--cut)) 0, 100% var(--cut), 100% 100%, var(--cut) 100%, 0 calc(100% - var(--cut)));
}
.panel::before {
content: "";
position: absolute;
left: 0;
top: 0;
width: 6px;
height: 100%;
background: rgba(232, 237, 242, 0.12);
pointer-events: none;
}
.panel-hero::before {
width: 8px;
background: rgba(232, 237, 242, 0.18);
}
.panel-ornament {
position: absolute;
right: 14px;
top: 14px;
width: 28px;
height: 14px;
border-top: 2px solid var(--border-strong);
border-right: 2px solid var(--border-strong);
opacity: 0.6;
pointer-events: none;
}
.panel-ornament::after {
content: "";
position: absolute;
right: -6px;
top: 6px;
width: 12px;
height: 2px;
background: var(--border-strong);
}
.panel-seam {
position: absolute;
left: 16px;
bottom: 12px;
width: 140px;
height: 0;
border-top: 2px dashed rgba(232, 237, 242, 0.14);
opacity: 0.6;
}
.panel-seam::after {
content: "";
position: absolute;
right: -10px;
top: -3px;
width: 2px;
height: 8px;
background: rgba(232, 237, 242, 0.18);
}
.demo-sidebar {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 520px;
}
.sidebar-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 10px;
border: 1px solid transparent;
color: var(--text-2);
background: transparent;
font-size: 12px;
letter-spacing: 0.12em;
text-transform: uppercase;
font-family: "IBM Plex Mono", "MiSans", sans-serif;
transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease;
clip-path: polygon(0 0, calc(100% - 12px) 0, 100% 12px, 100% 100%, 12px 100%, 0 calc(100% - 12px));
position: relative;
}
.sidebar-item::after {
content: "";
position: absolute;
right: 10px;
top: 50%;
width: 6px;
height: 6px;
background: var(--border-strong);
transform: translateY(-50%) rotate(45deg);
}
.sidebar-item:hover {
border-color: var(--border);
color: var(--text);
background: var(--panel-muted);
}
.sidebar-item.active {
border-color: var(--border-strong);
color: var(--text);
background: var(--panel-muted);
}
.sidebar-item.active::after {
background: var(--text);
}
.panel::after {
content: "";
position: absolute;
inset: 10px;
border: 1px solid rgba(232, 237, 242, 0.06);
pointer-events: none;
clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 8px 100%, 0 calc(100% - 8px));
}
.slab {
border-top: 2px solid var(--border-strong);
box-shadow: none;
clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 8px 100%, 0 calc(100% - 8px));
}
.kicker {
font-size: 10px;
letter-spacing: 0.3em;
text-transform: uppercase;
color: var(--text-2);
font-weight: 600;
font-family: "IBM Plex Mono", "MiSans", sans-serif;
}
.tag {
display: inline-flex;
align-items: center;
border: 1px solid var(--border);
padding: 2px 10px;
font-size: 10px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--text-2);
background: var(--panel-muted);
font-family: "IBM Plex Mono", "MiSans", sans-serif;
clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 8px 100%, 0 calc(100% - 8px));
}
.tag.muted {
background: transparent;
border-color: var(--border-strong);
}
.chart-frame {
height: 160px;
border: 1px solid var(--border);
background: var(--panel-muted);
position: relative;
}
.chart-notch {
position: absolute;
right: 10px;
top: 10px;
width: 14px;
height: 14px;
border-top: 2px solid var(--border-strong);
border-right: 2px solid var(--border-strong);
opacity: 0.6;
}
.bar {
flex: 1;
background: var(--text);
opacity: 0.18;
}
`}</style>
</main>
)
}

View File

@@ -0,0 +1,399 @@
"use client"
import type { CSSProperties } from "react"
import {
Activity,
Box,
Hexagon,
LayoutGrid,
Layers,
Settings,
Shield,
ShieldAlert,
Signal,
Target,
Terminal,
Zap,
} from "@/components/icons"
// 复用 Arknights UI 的主题变量
const themeVars = {
"--ark-bg": "#F4F5F7",
"--ark-bg-2": "#FFFFFF",
"--ark-panel": "#FFFFFF",
"--ark-panel-2": "#F8F9FA",
"--ark-border": "#E1E5EA",
"--ark-border-strong": "#C5CBD3",
"--ark-text": "#1A1D21",
"--ark-text-2": "#6F7681",
"--ark-muted": "#9CA3AF",
"--ark-accent": "#1A1D21", // 深黑作为主强调色
"--ark-accent-2": "#4C5159", // 次级强调
"--ark-active": "#2563EB", // 激活状态(可选蓝)
fontFamily: "\"MiSans\", \"IBM Plex Sans\", \"Segoe UI\", sans-serif",
} as CSSProperties
const stats = [
{ id: "01", label: "Active Assets", value: "1,284", delta: "+4.2%", icon: Box },
{ id: "02", label: "Scan Throughput", value: "82%", delta: "+1.1%", icon: Activity },
{ id: "03", label: "Critical Alerts", value: "12", delta: "-3", icon: Zap },
{ id: "04", label: "Coverage", value: "96.8%", delta: "+0.6%", icon: Target },
]
const navItems = [
{ label: "Overview", icon: LayoutGrid, active: true },
{ label: "Assets", icon: Layers },
{ label: "Scans", icon: Activity },
{ label: "Threats", icon: ShieldAlert },
{ label: "Settings", icon: Settings },
]
const timeline = [
{ label: "Ingestion", value: "02:14", status: "Done" },
{ label: "Correlation", value: "04:32", status: "Done" },
{ label: "Mitigation", value: "08:09", status: "Active" },
]
const tableRows = [
{ id: "OP-291", name: "Gateway Sweep", owner: "Ops-7", status: "STABLE", score: "A" },
{ id: "OP-377", name: "Credential Drift", owner: "Ops-3", status: "OBSERVE", score: "B" },
{ id: "OP-408", name: "Outbound Flux", owner: "Ops-2", status: "INVESTIGATE", score: "A-" },
{ id: "OP-512", name: "Shadow Asset", owner: "Ops-1", status: "MONITOR", score: "B+" },
]
const barSeries = [48, 62, 54, 68, 76, 58, 64]
const lineSeries = [12, 24, 18, 36, 30, 44, 38, 52]
export default function DashboardDemoPage() {
return (
<main className="relative min-h-screen bg-[var(--ark-bg)] text-[var(--ark-text)] overflow-hidden" style={themeVars}>
{/* 工业风背景网格与装饰 */}
<div className="pointer-events-none absolute inset-0 ark-grid" aria-hidden />
{/* 顶部装饰条 */}
<div className="fixed top-0 left-0 right-0 h-1 bg-[var(--ark-accent)] z-50 opacity-80" />
<div className="relative mx-auto w-full max-w-[1600px] p-6 lg:p-8">
<div className="grid gap-6 lg:grid-cols-[240px_1fr]">
{/* 侧边栏 */}
<aside className="flex flex-col gap-6 lg:sticky lg:top-8 lg:h-[calc(100vh-4rem)]">
<div className="ark-panel p-4 flex items-center gap-3">
<div className="h-10 w-10 flex items-center justify-center bg-[var(--ark-text)] text-white">
<Hexagon className="h-6 w-6" />
</div>
<div>
<div className="text-[10px] tracking-widest text-[var(--ark-text-2)] uppercase">Console</div>
<div className="font-bold tracking-tight">LUNAFOX</div>
</div>
</div>
<nav className="flex-1 space-y-2">
{navItems.map((item) => (
<button
key={item.label}
className={`ark-nav-item w-full ${item.active ? "active" : ""}`}
>
<item.icon className="h-4 w-4" />
<span>{item.label}</span>
{item.active && <span className="ml-auto w-1.5 h-1.5 bg-[var(--ark-active)] rounded-full" />}
</button>
))}
</nav>
<div className="ark-panel mt-auto p-4 space-y-3">
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--ark-text-2)]">SYSTEM STATUS</span>
<span className="h-2 w-2 rounded-full bg-[var(--success)] animate-pulse" />
</div>
<div className="space-y-1">
<div className="flex justify-between text-xs font-mono">
<span>CPU</span>
<span>12%</span>
</div>
<div className="h-1 w-full bg-[var(--ark-border)] overflow-hidden">
<div className="h-full bg-[var(--ark-text)] w-[12%]" />
</div>
</div>
<div className="space-y-1">
<div className="flex justify-between text-xs font-mono">
<span>MEM</span>
<span>48%</span>
</div>
<div className="h-1 w-full bg-[var(--ark-border)] overflow-hidden">
<div className="h-full bg-[var(--ark-text)] w-[48%]" />
</div>
</div>
</div>
</aside>
{/* 主内容区 */}
<div className="flex flex-col gap-6">
{/* Header */}
<header className="ark-panel p-5 flex flex-col md:flex-row md:items-end justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="px-1.5 py-0.5 text-[10px] font-mono bg-[var(--ark-text)] text-white">DASH-01</span>
<p className="text-xs tracking-[0.2em] text-[var(--ark-text-2)] uppercase">Operations Command</p>
</div>
<h1 className="text-2xl font-bold tracking-tight uppercase">System Overview</h1>
</div>
<div className="flex gap-2">
<div className="ark-slab px-3 py-1.5 flex items-center gap-2 text-xs font-mono">
<span className="w-1.5 h-1.5 bg-[var(--success)] rounded-sm" />
NETWORK: ONLINE
</div>
<div className="ark-slab px-3 py-1.5 flex items-center gap-2 text-xs font-mono">
<span className="text-[var(--ark-text-2)]">CYCLE:</span>
14:32:09
</div>
</div>
</header>
{/* Stats Grid */}
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{stats.map((item) => (
<div key={item.label} className="ark-panel group relative overflow-hidden transition-all hover:-translate-y-1">
<div className="absolute top-0 left-0 p-2 text-[10px] font-mono text-[var(--ark-muted)] opacity-50">
{item.id} {"//"}
</div>
<div className="p-5 pt-8 space-y-4">
<div className="flex justify-between items-start">
<div className="p-2 bg-[var(--ark-panel-2)] border border-[var(--ark-border)]">
<item.icon className="h-5 w-5 text-[var(--ark-text-2)]" />
</div>
<span className={`text-xs font-mono px-1.5 py-0.5 border ${item.delta.startsWith('+') ? 'border-[var(--success)]/30 bg-[var(--success)]/5 text-[var(--success)]' : 'border-[var(--error)]/30 bg-[var(--error)]/5 text-[var(--error)]'}`}>
{item.delta}
</span>
</div>
<div>
<div className="text-2xl font-bold tracking-tight">{item.value}</div>
<div className="text-xs text-[var(--ark-text-2)] uppercase tracking-wider mt-1">{item.label}</div>
</div>
</div>
<div className="absolute bottom-0 right-0 h-8 w-8 bg-[linear-gradient(135deg,transparent_50%,var(--ark-border)_50%)] opacity-20" />
</div>
))}
</section>
{/* Charts Section */}
<section className="grid gap-6 lg:grid-cols-[1.6fr_1fr]">
<div className="ark-panel flex flex-col">
<div className="border-b border-[var(--ark-border)] p-4 flex items-center justify-between bg-[var(--ark-panel-2)]">
<div className="flex items-center gap-2">
<Signal className="h-4 w-4" />
<span className="ark-kicker">ASSET PULSE</span>
</div>
<div className="flex gap-2">
<button className="text-xs px-2 py-1 bg-white border border-[var(--ark-border)] hover:border-[var(--ark-text)] transition-colors">24H</button>
<button className="text-xs px-2 py-1 bg-transparent border border-transparent text-[var(--ark-text-2)] hover:text-[var(--ark-text)]">7D</button>
</div>
</div>
<div className="p-6 flex-1 min-h-[240px] relative">
{/* 网格背景装饰 */}
<div className="absolute inset-6 border-l border-b border-[var(--ark-border)] opacity-50 pointer-events-none" />
<div className="absolute inset-0 flex items-center justify-center">
<svg viewBox="0 0 400 140" className="h-[70%] w-full max-w-2xl overflow-visible">
{/* 装饰性网格线 */}
<pattern id="grid" width="40" height="140" patternUnits="userSpaceOnUse">
<line x1="0" y1="0" x2="0" y2="140" stroke="var(--ark-border)" strokeWidth="1" strokeDasharray="2 2" />
</pattern>
<rect width="400" height="140" fill="url(#grid)" opacity="0.3" />
<polyline
fill="none"
stroke="var(--ark-text)"
strokeWidth="2"
points={lineSeries
.map((value, index) => `${index * 55},${140 - value}`)
.join(" ")}
vectorEffect="non-scaling-stroke"
/>
{/* 数据点 */}
{lineSeries.map((value, index) => (
<g key={index}>
<circle cx={index * 55} cy={140 - value} r="3" fill="var(--ark-bg)" stroke="var(--ark-text)" strokeWidth="2" />
</g>
))}
</svg>
</div>
</div>
<div className="border-t border-[var(--ark-border)] p-3 px-6 flex items-center gap-4 text-xs font-mono text-[var(--ark-text-2)]">
<span>MIN: 12ms</span>
<span>MAX: 52ms</span>
<span>AVG: 33ms</span>
<span className="ml-auto text-[var(--success)] flex items-center gap-1">
<div className="w-1.5 h-1.5 bg-[var(--success)] rounded-full animate-pulse" />
SIGNAL STABLE
</span>
</div>
</div>
<div className="ark-panel flex flex-col">
<div className="border-b border-[var(--ark-border)] p-4 flex items-center justify-between bg-[var(--ark-panel-2)]">
<div className="flex items-center gap-2">
<Shield className="h-4 w-4" />
<span className="ark-kicker">SEVERITY MIX</span>
</div>
</div>
<div className="p-6 flex-1 flex flex-col justify-end gap-4 min-h-[240px]">
<div className="flex items-end gap-2 h-40">
{barSeries.map((value, index) => (
<div key={index} className="flex-1 flex flex-col justify-end group h-full gap-2">
<div
className="w-full bg-[var(--ark-text-2)] opacity-20 group-hover:opacity-40 transition-opacity relative"
style={{ height: `${value}%` }}
>
{/* 顶部装饰线 */}
<div className="absolute top-0 left-0 right-0 h-[2px] bg-[var(--ark-text)] opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</div>
))}
</div>
<div className="grid grid-cols-3 gap-2 pt-4 border-t border-[var(--ark-border)]">
{[
{ l: "HIGH", v: 12, c: "text-[var(--error)]" },
{ l: "MED", v: 38, c: "text-[var(--warning)]" },
{ l: "LOW", v: 74, c: "text-[var(--info)]" }
].map((item) => (
<div key={item.l} className="ark-slab p-2 text-center">
<div className={`text-xs font-bold ${item.c}`}>{item.v}</div>
<div className="text-[10px] text-[var(--ark-text-2)]">{item.l}</div>
</div>
))}
</div>
</div>
</div>
</section>
{/* Bottom Section */}
<section className="grid gap-6 lg:grid-cols-[2fr_1fr]">
<div className="ark-panel p-0">
<div className="p-4 border-b border-[var(--ark-border)] flex items-center justify-between">
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4" />
<span className="ark-kicker">RECENT OPERATIONS</span>
</div>
<div className="flex items-center gap-2 text-xs text-[var(--ark-text-2)]">
<span className="w-2 h-2 border border-[var(--ark-text-2)] opacity-50" />
LIVE FEED
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="bg-[var(--ark-panel-2)] text-xs text-[var(--ark-text-2)] font-mono border-b border-[var(--ark-border)]">
<tr>
<th className="px-6 py-3 font-medium">ID</th>
<th className="px-6 py-3 font-medium">TASK</th>
<th className="px-6 py-3 font-medium">OWNER</th>
<th className="px-6 py-3 font-medium">STATUS</th>
<th className="px-6 py-3 text-right font-medium">SCORE</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--ark-border)]">
{tableRows.map((row) => (
<tr key={row.id} className="hover:bg-[var(--ark-bg)] transition-colors group">
<td className="px-6 py-3 font-mono text-xs text-[var(--ark-text-2)] group-hover:text-[var(--ark-active)] transition-colors">{row.id}</td>
<td className="px-6 py-3 font-medium">{row.name}</td>
<td className="px-6 py-3 text-xs text-[var(--ark-text-2)]">{row.owner}</td>
<td className="px-6 py-3">
<span className="ark-chip text-[10px]">{row.status}</span>
</td>
<td className="px-6 py-3 text-right font-mono">{row.score}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="ark-panel flex flex-col">
<div className="p-4 border-b border-[var(--ark-border)]">
<span className="ark-kicker">CYCLE TIMING</span>
</div>
<div className="p-4 flex-1 space-y-4">
{timeline.map((item, i) => (
<div key={item.label} className="relative pl-6 pb-4 last:pb-0 border-l border-[var(--ark-border)] last:border-0">
<div className={`absolute left-[-5px] top-0 h-2.5 w-2.5 rounded-full border-2 border-[var(--ark-bg)] ${i===2 ? 'bg-[var(--info)] animate-pulse' : 'bg-[var(--ark-text-2)]'}`} />
<div className="ark-slab p-3 flex justify-between items-center -mt-2">
<span className="text-sm font-medium">{item.label}</span>
<span className="text-xs font-mono text-[var(--ark-text-2)]">{item.value}</span>
</div>
</div>
))}
</div>
<div className="p-3 border-t border-[var(--ark-border)] text-xs text-center text-[var(--ark-text-2)] font-mono">
ALL SYSTEMS NOMINAL
</div>
</div>
</section>
</div>
</div>
</div>
<style jsx>{`
.ark-grid {
background-image:
linear-gradient(var(--ark-border) 1px, transparent 1px),
linear-gradient(90deg, var(--ark-border) 1px, transparent 1px);
background-size: 40px 40px;
opacity: 0.15;
}
.ark-panel {
background: var(--ark-panel);
border: 1px solid var(--ark-border);
border-top: 2px solid var(--ark-text); /* Top Accent Border */
box-shadow: 0 1px 3px rgba(0,0,0,0.02);
}
.ark-slab {
background: var(--ark-panel-2);
border: 1px solid var(--ark-border);
}
.ark-kicker {
font-size: 11px;
letter-spacing: 0.15em;
font-weight: 600;
color: var(--ark-text-2);
text-transform: uppercase;
}
.ark-nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
font-size: 13px;
font-weight: 500;
color: var(--ark-text-2);
transition: all 0.2s;
border-left: 2px solid transparent;
}
.ark-nav-item:hover {
color: var(--ark-text);
background: var(--ark-panel-2);
}
.ark-nav-item.active {
color: var(--ark-text);
background: var(--ark-panel-2);
border-left-color: var(--ark-active);
}
.ark-chip {
display: inline-block;
padding: 2px 8px;
border: 1px solid var(--ark-border);
background: var(--ark-panel-2);
color: var(--ark-text);
font-family: var(--font-mono);
letter-spacing: 0.05em;
}
`}</style>
</main>
)
}

View File

@@ -0,0 +1,445 @@
"use client"
import type { CSSProperties } from "react"
import {
Activity,
BarChart3,
ChevronRight,
Hexagon,
LayoutGrid,
Layers,
Radar,
Search,
Settings,
ShieldAlert,
Sliders,
Terminal,
Zap,
} from "@/components/icons"
// 终末地/明日方舟风格主题变量
const themeVars = {
"--ark-bg": "#F2F3F5", // 浅灰背景,更有质感
"--ark-bg-2": "#FFFFFF",
"--ark-panel": "#FFFFFF",
"--ark-panel-2": "#F8F9FB", // 稍微深一点的面板背景
"--ark-border": "#DCDFE6",
"--ark-border-strong": "#181A1F", // 强烈的深色边框
"--ark-text": "#181A1F", // 深黑文字
"--ark-text-2": "#6B7280", // 次级文字
"--ark-accent": "#181A1F", // 强调色使用深黑
"--ark-accent-light": "rgba(24, 26, 31, 0.05)",
"--ark-highlight": "#FF4C24", // 橙红色作为点缀类似终末地Logo色
"--font-mono": "\"IBM Plex Mono\", \"JetBrains Mono\", monospace",
"--font-sans": "\"MiSans\", \"Inter\", sans-serif",
} as CSSProperties
const stats = [
{ label: "Active Assets", value: "1,284", delta: "+4.2%", id: "01" },
{ label: "Scan Throughput", value: "82%", delta: "+1.1%", id: "02" },
{ label: "Critical Alerts", value: "12", delta: "-3", id: "03", alert: true },
{ label: "Coverage", value: "96.8%", delta: "+0.6%", id: "04" },
]
const navItems = [
{ label: "Overview", icon: LayoutGrid, active: true },
{ label: "Assets", icon: Layers },
{ label: "Scans", icon: Radar },
{ label: "Threats", icon: ShieldAlert },
{ label: "Settings", icon: Sliders },
]
const timeline = [
{ label: "Ingestion", value: "02:14", status: "Done" },
{ label: "Correlation", value: "04:32", status: "Done" },
{ label: "Mitigation", value: "08:09", status: "Active" },
]
const tableRows = [
{ id: "OP-291", name: "Gateway Sweep", owner: "Ops-7", status: "Stable", score: "A" },
{ id: "OP-377", name: "Credential Drift", owner: "Ops-3", status: "Observe", score: "B" },
{ id: "OP-408", name: "Outbound Flux", owner: "Ops-2", status: "Investigate", score: "A-" },
{ id: "OP-512", name: "Shadow Asset", owner: "Ops-1", status: "Monitor", score: "B+" },
]
const barSeries = [48, 62, 54, 68, 76, 58, 64]
const lineSeries = [12, 24, 18, 36, 30, 44, 38, 52]
export default function DashboardReworkPage() {
return (
<main className="relative min-h-screen bg-[var(--ark-bg)] text-[var(--ark-text)] font-sans" style={themeVars}>
{/* 背景网格装饰 */}
<div className="pointer-events-none absolute inset-0 ark-grid" aria-hidden />
{/* 顶部装饰条 */}
<div className="fixed top-0 left-0 right-0 h-1 bg-[var(--ark-border-strong)] z-50" />
<div className="relative mx-auto w-full max-w-[1600px] p-6 lg:p-10">
<div className="grid gap-8 lg:grid-cols-[260px_1fr]">
{/* 侧边栏 */}
<aside className="flex flex-col gap-6 lg:sticky lg:top-10 h-fit">
<div className="ark-panel p-6 flex flex-col gap-4">
<div className="flex items-center gap-3 border-b-2 border-[var(--ark-border-strong)] pb-4">
<div className="bg-[var(--ark-accent)] text-white p-1.5">
<Hexagon className="h-5 w-5 fill-current" />
</div>
<div>
<h1 className="font-bold text-lg leading-none tracking-tight">LUNAFOX</h1>
<p className="text-[10px] text-[var(--ark-text-2)] font-mono mt-1 tracking-widest">CONTROL HUB</p>
</div>
</div>
<nav className="flex flex-col gap-1">
{navItems.map((item) => (
<button
key={item.label}
className={`ark-nav-item ${item.active ? "active" : ""}`}
type="button"
>
<span className="w-1 h-full absolute left-0 top-0 bg-[var(--ark-accent)] opacity-0 transition-opacity indicator" />
<item.icon className="h-4 w-4" />
<span className="font-medium tracking-wide text-sm">{item.label}</span>
{item.active && <ChevronRight className="h-3 w-3 ml-auto opacity-50" />}
</button>
))}
</nav>
</div>
<div className="ark-panel p-5 bg-[var(--ark-accent)] text-white relative overflow-hidden">
<div className="relative z-10">
<p className="text-[10px] font-mono opacity-60 mb-1">SYSTEM STATUS</p>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-[#00FF94] animate-pulse" />
<span className="font-bold tracking-wider">NOMINAL</span>
</div>
<p className="text-xs mt-3 font-mono opacity-80">UPTIME: 99.98%</p>
</div>
<Activity className="absolute -right-4 -bottom-4 w-24 h-24 opacity-10" />
</div>
</aside>
{/* 主内容区 */}
<div className="flex flex-col gap-6">
{/* 顶部 Header */}
<header className="ark-panel p-6 flex flex-col md:flex-row md:items-end justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-[var(--ark-text-2)]">
<Terminal className="h-4 w-4" />
<span className="text-xs font-mono">/ DASHBOARD / OVERVIEW</span>
</div>
<h2 className="text-2xl font-bold uppercase tracking-tight">System Operations</h2>
</div>
<div className="flex gap-4 font-mono text-xs">
<div className="ark-tag">
<span className="opacity-50">CYCLE:</span>
<span className="font-bold">14:32</span>
</div>
<div className="ark-tag">
<span className="opacity-50">NODES:</span>
<span className="font-bold">48</span>
</div>
</div>
</header>
{/* 统计卡片 */}
<section className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{stats.map((item) => (
<div key={item.label} className="ark-card p-5 group">
<div className="flex justify-between items-start mb-4">
<p className="text-xs font-mono text-[var(--ark-text-2)] uppercase">{item.label}</p>
<span className="text-[10px] font-mono opacity-30 group-hover:opacity-100 transition-opacity">
{item.id}
</span>
</div>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold tracking-tight">{item.value}</span>
</div>
<div className={`mt-2 text-xs font-mono inline-flex px-1.5 py-0.5 border ${
item.alert
? "border-[var(--ark-highlight)] text-[var(--ark-highlight)] bg-[rgba(255,76,36,0.05)]"
: "border-[var(--ark-border)] text-[var(--ark-text-2)]"
}`}>
{item.delta}
</div>
{/* 装饰角标 */}
<div className="absolute bottom-0 right-0 w-3 h-3 border-b-2 border-r-2 border-[var(--ark-border-strong)] opacity-20" />
</div>
))}
</section>
{/* 图表区域 */}
<section className="grid gap-6 lg:grid-cols-[1.6fr_1fr]">
{/* 折线图 */}
<div className="ark-panel p-6 relative">
<div className="flex justify-between items-center mb-6 border-b border-[var(--ark-border)] pb-4">
<div>
<h3 className="font-bold text-sm uppercase flex items-center gap-2">
<Activity className="h-4 w-4" />
Asset Pulse
</h3>
</div>
<span className="text-xs font-mono bg-[var(--ark-accent)] text-white px-2 py-1">LIVE</span>
</div>
<div className="h-[200px] w-full relative border border-[var(--ark-border)] bg-[var(--ark-panel-2)] p-4">
{/* 网格背景 */}
<div className="absolute inset-0 grid grid-cols-6 grid-rows-4 pointer-events-none">
{Array.from({ length: 24 }).map((_, i) => (
<div key={i} className="border-r border-b border-[var(--ark-border)] opacity-30" />
))}
</div>
{/* SVG 图表 */}
<svg viewBox="0 0 400 140" className="h-full w-full relative z-10 overflow-visible">
<defs>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="currentColor" strokeOpacity="0.1" />
</pattern>
</defs>
<polyline
fill="none"
stroke="var(--ark-accent)"
strokeWidth="2"
points={lineSeries
.map((value, index) => `${index * 55},${140 - value}`)
.join(" ")}
vectorEffect="non-scaling-stroke"
/>
{/* 数据点 */}
{lineSeries.map((value, index) => (
<circle
key={index}
cx={index * 55}
cy={140 - value}
r="3"
fill="var(--ark-bg)"
stroke="var(--ark-accent)"
strokeWidth="2"
/>
))}
</svg>
</div>
<div className="mt-4 flex gap-4 text-xs font-mono text-[var(--ark-text-2)]">
<span>MIN: 12ms</span>
<span>MAX: 52ms</span>
<span>AVG: 34ms</span>
</div>
</div>
{/* 柱状图 */}
<div className="ark-panel p-6">
<div className="flex justify-between items-center mb-6 border-b border-[var(--ark-border)] pb-4">
<h3 className="font-bold text-sm uppercase flex items-center gap-2">
<BarChart3 className="h-4 w-4" />
Severity Mix
</h3>
<Sliders className="h-4 w-4 text-[var(--ark-text-2)]" />
</div>
<div className="flex h-[180px] items-end gap-3 px-2">
{barSeries.map((value, index) => (
<div key={index} className="flex-1 flex flex-col gap-2 group">
<div
className="w-full bg-[var(--ark-accent)] opacity-80 group-hover:opacity-100 transition-opacity relative"
style={{ height: `${value}%` }}
>
{/* 顶部高亮线 */}
<div className="absolute top-0 left-0 right-0 h-[2px] bg-[var(--ark-highlight)] opacity-0 group-hover:opacity-100" />
</div>
<span className="text-[10px] font-mono text-center text-[var(--ark-text-2)]">0{index + 1}</span>
</div>
))}
</div>
<div className="mt-6 grid grid-cols-3 gap-2">
{[
{ label: "HIGH", count: 12, color: "var(--ark-highlight)" },
{ label: "MED", count: 38, color: "var(--ark-text)" },
{ label: "LOW", count: 74, color: "var(--ark-text-2)" },
].map(stat => (
<div key={stat.label} className="border border-[var(--ark-border)] p-2 text-center">
<div className="text-[10px] font-mono mb-1" style={{ color: stat.color }}>{stat.label}</div>
<div className="font-bold text-lg">{stat.count}</div>
</div>
))}
</div>
</div>
</section>
{/* 底部区域 */}
<section className="grid gap-6 lg:grid-cols-[1.6fr_1fr]">
{/* 表格 */}
<div className="ark-panel p-0 overflow-hidden">
<div className="p-4 border-b border-[var(--ark-border)] bg-[var(--ark-panel-2)] flex justify-between items-center">
<h3 className="font-bold text-sm uppercase">Recent Activity</h3>
<div className="flex gap-2">
<button className="p-1 hover:bg-white border border-transparent hover:border-[var(--ark-border)]">
<Search className="h-4 w-4" />
</button>
<button className="p-1 hover:bg-white border border-transparent hover:border-[var(--ark-border)]">
<Settings className="h-4 w-4" />
</button>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="text-xs text-[var(--ark-text-2)] font-mono uppercase bg-[var(--ark-bg)] border-b border-[var(--ark-border)]">
<tr>
<th className="px-6 py-3 font-medium">ID</th>
<th className="px-6 py-3 font-medium">Task Name</th>
<th className="px-6 py-3 font-medium">Owner</th>
<th className="px-6 py-3 font-medium">Status</th>
<th className="px-6 py-3 font-medium text-right">Score</th>
</tr>
</thead>
<tbody>
{tableRows.map((row, i) => (
<tr key={row.id} className="border-b border-[var(--ark-border)] hover:bg-[var(--ark-panel-2)] transition-colors group">
<td className="px-6 py-4 font-mono text-xs">{row.id}</td>
<td className="px-6 py-4 font-medium">{row.name}</td>
<td className="px-6 py-4 text-[var(--ark-text-2)] text-xs">{row.owner}</td>
<td className="px-6 py-4">
<span className="inline-flex items-center gap-1.5 px-2 py-1 border border-[var(--ark-border)] text-xs font-mono uppercase bg-white">
<span className={`w-1.5 h-1.5 rounded-full ${i === 0 ? 'bg-green-500' : 'bg-yellow-500'}`} />
{row.status}
</span>
</td>
<td className="px-6 py-4 text-right font-mono font-bold">{row.score}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 时间线/状态 */}
<div className="ark-panel p-6 flex flex-col h-full">
<div className="flex justify-between items-center mb-6">
<h3 className="font-bold text-sm uppercase">Cycle Timing</h3>
<div className="w-2 h-2 bg-[var(--ark-accent)] animate-pulse" />
</div>
<div className="flex-1 relative pl-4 space-y-8 before:absolute before:left-0 before:top-2 before:bottom-2 before:w-[2px] before:bg-[var(--ark-border)]">
{timeline.map((item, index) => (
<div key={item.label} className="relative pl-6">
{/* 时间轴点 */}
<div className={`absolute left-[-5px] top-1.5 w-2.5 h-2.5 border-2 border-[var(--ark-bg)] outline outline-1 outline-[var(--ark-border-strong)] ${
index === 2 ? 'bg-[var(--ark-highlight)]' : 'bg-[var(--ark-accent)]'
}`} />
<div className="flex justify-between items-baseline">
<span className="font-bold text-sm">{item.label}</span>
<span className="font-mono text-xs text-[var(--ark-text-2)]">{item.value}</span>
</div>
<div className="mt-1 text-xs text-[var(--ark-text-2)] font-mono uppercase tracking-wider">
STATUS: {item.status}
</div>
</div>
))}
</div>
<div className="mt-6 pt-4 border-t border-[var(--ark-border)]">
<div className="flex items-center gap-3">
<div className="w-10 h-10 border border-[var(--ark-border)] flex items-center justify-center bg-[var(--ark-panel-2)]">
<Zap className="h-5 w-5" />
</div>
<div>
<div className="text-xs font-mono text-[var(--ark-text-2)]">POWER OUTPUT</div>
<div className="font-bold">STABLE / 98%</div>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
</div>
<style jsx>{`
.ark-grid {
background-image:
linear-gradient(var(--ark-border) 1px, transparent 1px),
linear-gradient(90deg, var(--ark-border) 1px, transparent 1px);
background-size: 40px 40px;
opacity: 0.3;
}
.ark-panel {
background: var(--ark-panel);
border: 1px solid var(--ark-border);
box-shadow: 0 2px 4px rgba(0,0,0,0.02);
position: relative;
}
/* 顶部加粗边框装饰 */
.ark-panel::before {
content: "";
position: absolute;
top: -1px;
left: -1px;
right: -1px;
height: 3px;
background: var(--ark-border-strong);
opacity: 0;
transition: opacity 0.3s;
}
.ark-panel:hover::before {
opacity: 1;
}
.ark-card {
background: var(--ark-panel);
border: 1px solid var(--ark-border);
position: relative;
transition: all 0.2s;
}
.ark-card:hover {
border-color: var(--ark-border-strong);
transform: translateY(-2px);
box-shadow: 4px 4px 0 rgba(24, 26, 31, 0.05);
}
.ark-nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
color: var(--ark-text-2);
transition: all 0.2s;
position: relative;
border: 1px solid transparent;
}
.ark-nav-item:hover {
background: var(--ark-panel-2);
color: var(--ark-text);
}
.ark-nav-item.active {
background: var(--ark-accent-light);
color: var(--ark-text);
border-color: var(--ark-border);
}
.ark-nav-item.active .indicator {
opacity: 1;
}
.ark-tag {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: var(--ark-panel-2);
border: 1px solid var(--ark-border);
font-family: var(--font-mono);
}
`}</style>
</main>
)
}

View File

@@ -0,0 +1,55 @@
"use client"
import { AllTargetsDetailView } from "@/components/target/all-targets-detail-view"
import { Button } from "@/components/ui/button"
import { Plus } from "@/components/icons"
/**
* Demo A一体化卡片
* 设计理念:把 Header 和 Table 装进同一个容器,只有外框有边框
* 关键 CSS移除表格自带的边框和圆角
*/
export default function DemoPageA() {
return (
<div className="flex flex-col h-full">
{/* 说明区域 */}
<div className="p-6 border-b bg-muted/30">
<h1 className="text-xl font-bold"> A</h1>
<p className="text-sm text-muted-foreground mt-1">
Header Table
</p>
</div>
{/* 核心 Demo 区域 */}
<div className="flex-1 p-6">
{/* 统一容器 - 外层唯一边框 */}
<div className="border-2 border-primary bg-card h-full flex flex-col overflow-hidden">
{/* Header 区域 */}
<div className="flex flex-col md:flex-row md:items-center justify-between px-2 py-3 border-b border-border bg-muted/10 shrink-0">
<div className="flex items-center gap-3 mb-3 md:mb-0">
<div className="bg-primary text-primary-foreground px-2 py-1 text-[10px] font-mono font-bold tracking-wider">
TGT-01
</div>
<div>
<h2 className="text-lg font-bold tracking-tight"></h2>
<p className="text-xs text-muted-foreground">Manage all scan targets</p>
</div>
</div>
<Button size="sm">
<Plus className="h-3.5 w-3.5 mr-1"/> Add Target
</Button>
</div>
{/* 表格区域 - 移除表格自带边框 */}
<div className="flex-1 overflow-auto">
<AllTargetsDetailView
className="space-y-3 px-2 pb-3"
tableClassName="border-0 rounded-none"
/>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,54 @@
"use client"
import { AllTargetsDetailView } from "@/components/target/all-targets-detail-view"
import { Button } from "@/components/ui/button"
/**
* Demo B隐形标题
* 设计理念Header 完全无边框,只靠字号和间距建立层次;表格保留自己的边框
* 关键 CSSHeader 无任何边框装饰
*/
export default function DemoPageB() {
return (
<div className="flex flex-col h-full">
{/* 说明区域 */}
<div className="p-6 border-b bg-muted/30">
<h1 className="text-xl font-bold"> B</h1>
<p className="text-sm text-muted-foreground mt-1">
Header
</p>
</div>
{/* 核心 Demo 区域 */}
<div className="flex-1 p-6 flex flex-col min-h-0">
{/* Header 区域 - 纯文字,无任何边框 */}
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4 pb-4 shrink-0 px-2">
<div className="space-y-1">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight text-foreground"></h1>
<span className="font-mono text-xs text-muted-foreground bg-muted px-2 py-1">
/TGT-01
</span>
</div>
<p className="text-muted-foreground text-sm">
Manage and monitor all your scan targets here.
</p>
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm">Export</Button>
<Button size="sm">New Scan</Button>
</div>
</div>
{/* 表格区域 - 保留表格自带边框,但加粗顶部边框以强调 */}
<div className="flex-1 overflow-auto">
<AllTargetsDetailView
className="space-y-3"
tableClassName="border-2 border-primary rounded-none"
/>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,69 @@
"use client"
import { AllTargetsDetailView } from "@/components/target/all-targets-detail-view"
import { Button } from "@/components/ui/button"
import { Settings, Search } from "@/components/icons"
/**
* Demo C对接式界面
* 设计理念Header 底边框与表格顶边框合为一条线,形成无缝对接
* 关键 CSSHeader border-b-2表格 border-t-0紧贴无间隙
*/
export default function DemoPageC() {
return (
<div className="flex flex-col h-full">
{/* 说明区域 */}
<div className="p-6 border-b bg-muted/30">
<h1 className="text-xl font-bold"> C</h1>
<p className="text-sm text-muted-foreground mt-1">
Header
</p>
</div>
{/* 核心 Demo 区域 */}
<div className="flex-1 p-6 flex flex-col min-h-0">
{/* Header 区域 - 底部粗边框,作为分割线 */}
<div className="flex items-end justify-between border-b-2 border-primary pb-3 shrink-0 px-2">
<div className="flex flex-col">
<span className="text-[10px] font-mono text-muted-foreground mb-1 uppercase tracking-wider">
Context: TGT-01
</span>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold leading-none"></h1>
<span className="bg-[var(--success)]/10 text-[var(--success)] px-1.5 py-0.5 text-[10px] font-bold border border-[var(--success)]/20">
ACTIVE
</span>
</div>
</div>
{/* Tab 风格的按钮 - 贴底对齐,制造对接效果 */}
<div className="flex gap-0.5 translate-y-[2px]">
<Button
variant="secondary"
size="sm"
className="rounded-none rounded-t border-x border-t border-b-0 border-border bg-muted text-muted-foreground h-8"
>
<Settings className="h-3.5 w-3.5 mr-1"/> Config
</Button>
<Button
variant="default"
size="sm"
className="rounded-none rounded-t border-x border-t border-b-2 border-b-primary shadow-none h-8"
>
<Search className="h-3.5 w-3.5 mr-1"/> List View
</Button>
</div>
</div>
{/* 表格区域 - 移除顶部边框,紧贴 Header */}
<div className="flex-1 overflow-auto border-x-2 border-b-2 border-primary bg-card">
<AllTargetsDetailView
className="space-y-0 px-2 pb-2"
tableClassName="border-0 rounded-none"
/>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,364 @@
import type { ReactNode } from "react"
import {
AlertTriangle,
CheckCircle2,
ChevronRight,
Play,
Settings,
Zap,
} from "@/components/icons"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
import { cn } from "@/lib/utils"
const yamlSample = `subdomain_discovery:
depth: 2
dns: true
port_scan:
ports: top-1000
rate: 80
site_scan:
crawler_depth: 2
vuln_scan:
enable: true`
const presets = [
{
id: "full",
title: "全量扫描",
desc: "覆盖资产发现到漏洞检测的全流程,适合首次评估",
engines: 3,
caps: 6,
tag: "推荐",
active: true,
},
{
id: "recon",
title: "信息收集",
desc: "快速摸清资产结构,生成扫描基线",
engines: 2,
caps: 4,
},
{
id: "vuln",
title: "漏洞扫描",
desc: "对已知资产进行漏洞核验",
engines: 1,
caps: 2,
},
{
id: "custom",
title: "自定义",
desc: "手动选择引擎并编辑配置",
engines: 0,
caps: 0,
},
]
const engineChips = ["子域名发现", "端口扫描", "站点探测", "漏洞扫描"]
function DialogShell({ children }: { children: ReactNode }) {
return (
<div className="rounded-xl border bg-background shadow-sm overflow-hidden">
{children}
</div>
)
}
function Header() {
return (
<div className="flex flex-col gap-4 border-b px-6 py-5 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Play className="h-5 w-5" />
</div>
<div>
<h2 className="text-lg font-semibold"></h2>
<p className="text-sm text-muted-foreground"> <span className="text-foreground font-medium">acme.com</span> </p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary"></Badge>
<Button variant="outline" size="sm"></Button>
<Button size="sm"></Button>
</div>
</div>
)
}
function PresetGrid() {
return (
<div className="grid gap-3 sm:grid-cols-2">
{presets.map((preset) => (
<div
key={preset.id}
className={cn(
"rounded-lg border p-4 transition",
preset.active
? "border-primary/60 bg-primary/5"
: "border-border bg-muted/20 hover:border-primary/40"
)}
>
<div className="flex items-center justify-between">
<p className="font-medium">{preset.title}</p>
{preset.tag && <Badge variant="secondary">{preset.tag}</Badge>}
</div>
<p className="text-xs text-muted-foreground mt-2 leading-relaxed">{preset.desc}</p>
<p className="text-xs text-muted-foreground mt-3">{preset.engines} · {preset.caps} </p>
</div>
))}
</div>
)
}
export default function ScanDialogPrototypesPage() {
return (
<div className="space-y-10 p-6">
<div className="space-y-2">
<h1 className="text-xl font-semibold"> · </h1>
<p className="text-sm text-muted-foreground">/// UI </p>
</div>
{/* Variant A */}
<section className="space-y-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Badge variant="secondary"> A</Badge>
<span></span>
</div>
<DialogShell>
<Header />
<div className="grid gap-6 p-6 lg:grid-cols-[1.2fr_0.8fr]">
<div className="space-y-5">
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide"></p>
<PresetGrid />
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">使</p>
<div className="flex flex-wrap gap-2">
{engineChips.map((chip) => (
<Badge key={chip} variant="outline">{chip}</Badge>
))}
</div>
</div>
<div className="rounded-lg border bg-muted/20">
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-2 text-sm">
<Settings className="h-4 w-4 text-muted-foreground" />
</div>
<Badge variant="outline"></Badge>
</div>
<Separator />
<pre className="px-4 py-3 text-xs leading-relaxed text-muted-foreground whitespace-pre-wrap">{yamlSample}</pre>
</div>
</div>
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground"></span>
<span> 18 </span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground"></span>
<span>3 </span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground"></span>
<span>6 </span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground"></span>
<span> / / </span>
</div>
</CardContent>
</Card>
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-700 dark:text-amber-300">
<AlertTriangle className="h-4 w-4 mt-0.5" />
YAML
</div>
</div>
</div>
<div className="flex flex-col gap-3 border-t px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
<span className="text-sm text-muted-foreground"> 3 · YAML </span>
<div className="flex gap-2">
<Button variant="outline"></Button>
<Button></Button>
</div>
</div>
</DialogShell>
</section>
{/* Variant B */}
<section className="space-y-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Badge variant="secondary"> B</Badge>
<span></span>
</div>
<DialogShell>
<Header />
<div className="space-y-4 p-6">
<div className="flex flex-wrap items-center gap-3 text-sm">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-primary/40 bg-primary/10 text-primary">1</span>
<span></span>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-primary/40 bg-primary/10 text-primary">2</span>
<span className="text-primary"></span>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full border">3</span>
<span></span>
</div>
<div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
<div className="space-y-3">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide"></p>
<div className="rounded-lg border bg-muted/20">
<div className="flex items-center justify-between px-4 py-3 text-sm">
<span>YAML </span>
<Badge variant="outline"></Badge>
</div>
<Separator />
<pre className="px-4 py-3 text-xs leading-relaxed text-muted-foreground whitespace-pre-wrap">{yamlSample}</pre>
</div>
<div className="flex flex-wrap gap-2">
{engineChips.map((chip) => (
<Badge key={chip} variant="outline">{chip}</Badge>
))}
</div>
</div>
<div className="space-y-3">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide"></p>
<div className="rounded-lg border bg-muted/20 p-4 space-y-3 text-sm">
<div className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 text-emerald-500 mt-0.5" />
<div>
<p className="font-medium"></p>
<p className="text-xs text-muted-foreground">YAML key</p>
</div>
</div>
<div className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 text-emerald-500 mt-0.5" />
<div>
<p className="font-medium"> 3 </p>
<p className="text-xs text-muted-foreground"> 6 </p>
</div>
</div>
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-500 mt-0.5" />
<div>
<p className="font-medium"> 1 </p>
<p className="text-xs text-muted-foreground"></p>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-3 border-t px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
<span className="text-sm text-muted-foreground"> 2/3 · </span>
<div className="flex gap-2">
<Button variant="outline"></Button>
<Button></Button>
</div>
</div>
</DialogShell>
</section>
{/* Variant C */}
<section className="space-y-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Badge variant="secondary"> C</Badge>
<span></span>
</div>
<DialogShell>
<div className="flex flex-col gap-4 border-b px-6 py-5 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Zap className="h-5 w-5" />
</div>
<div>
<h2 className="text-lg font-semibold"></h2>
<p className="text-sm text-muted-foreground"> <span className="text-foreground font-medium">api.redfox.io</span> · P1</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm"></Button>
<Button size="sm"></Button>
</div>
</div>
<div className="grid gap-6 p-6 lg:grid-cols-[1.1fr_0.9fr]">
<div className="space-y-5">
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide"></p>
<div className="flex flex-wrap gap-2">
{presets.map((preset) => (
<Button
key={preset.id}
variant={preset.active ? "default" : "outline"}
size="sm"
>
{preset.title}
</Button>
))}
</div>
</div>
<div className="rounded-lg border bg-muted/20 p-4 space-y-3">
<div className="flex items-center justify-between text-sm">
<span></span>
<Badge variant="outline"></Badge>
</div>
<p className="text-xs text-muted-foreground">使 YAML </p>
</div>
<div className="rounded-lg border bg-muted/20">
<div className="flex items-center justify-between px-4 py-3 text-sm">
<span></span>
<Badge variant="outline"></Badge>
</div>
<Separator />
<pre className="px-4 py-3 text-xs leading-relaxed text-muted-foreground whitespace-pre-wrap">{yamlSample}</pre>
</div>
</div>
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground"></span>
<span>2 </span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground"></span>
<span></span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground"></span>
<span></span>
</div>
</CardContent>
</Card>
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-700 dark:text-amber-300 flex items-start gap-2">
<AlertTriangle className="h-4 w-4 mt-0.5" />
YAML 使
</div>
</div>
</div>
<div className="flex flex-col gap-3 border-t px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
<span className="text-sm text-muted-foreground"> · 2 </span>
<div className="flex gap-2">
<Button variant="outline"></Button>
<Button></Button>
</div>
</div>
</DialogShell>
</section>
</div>
)
}

View File

@@ -0,0 +1,470 @@
"use client"
import React, { useState, useEffect, useRef } from "react"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import {
Eye,
Trash2,
MoreHorizontal,
IconCircleX,
IconCheck,
IconX
} from "@/components/icons"
export default function ScanHistoryActionsDemo() {
// State for Inline Confirmation Demo
const [isDeleting, setIsDeleting] = useState(false)
// State for Custom Context Menu Demo
const [contextMenu, setContextMenu] = useState<{
x: number
y: number
isOpen: boolean
}>({ x: 0, y: 0, isOpen: false })
const contextMenuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (contextMenu.isOpen && contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
setContextMenu(prev => ({ ...prev, isOpen: false }))
}
}
document.addEventListener("click", handleClick)
return () => document.removeEventListener("click", handleClick)
}, [contextMenu.isOpen])
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault()
setContextMenu({
x: e.clientX,
y: e.clientY,
isOpen: true,
})
}
return (
<div className="container mx-auto py-10 space-y-8">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">Scan History Action Variants</h1>
<p className="text-muted-foreground">
Exploration of different interaction patterns for table actions.
</p>
</div>
<div className="grid gap-6 md:grid-cols-2">
{/* Variant 1: Dropdown Menu */}
<Card>
<CardHeader>
<CardTitle>Variant 1: Dropdown Menu</CardTitle>
<CardDescription>
Cleanest. Reduces visual clutter.
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border p-4">
<div className="flex items-center justify-between py-2 border-b last:border-0">
<span className="text-sm font-medium">Scan #1024 (Running)</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Eye className="mr-2 h-4 w-4" />
View Details
</DropdownMenuItem>
<DropdownMenuItem>
<IconCircleX className="mr-2 h-4 w-4 text-orange-500" />
Stop Scan
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardContent>
</Card>
{/* Variant 2: Hybrid */}
<Card>
<CardHeader>
<CardTitle>Variant 2: Hybrid (View + Menu)</CardTitle>
<CardDescription>
Prioritizes &quot;View&quot;. Other actions tucked away.
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border p-4">
<div className="flex items-center justify-between py-2 border-b last:border-0">
<span className="text-sm font-medium">Scan #1024 (Running)</span>
<div className="flex items-center gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Eye className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>View Details</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<IconCircleX className="mr-2 h-4 w-4 text-orange-500" />
Stop Scan
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Variant 3: Inline Refined */}
<Card>
<CardHeader>
<CardTitle>Variant 3: Inline (Colored)</CardTitle>
<CardDescription>
High visibility. Uses semantic colors.
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border p-4">
<div className="flex items-center justify-between py-2 border-b last:border-0">
<span className="text-sm font-medium">Scan #1024 (Running)</span>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-primary hover:bg-primary/10">
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-amber-500 hover:text-amber-600 hover:bg-amber-50">
<IconCircleX className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:bg-destructive/10">
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Variant 4: Hover Reveal */}
<Card>
<CardHeader>
<CardTitle>Variant 4: Hover Reveal</CardTitle>
<CardDescription>
Minimal noise. Actions appear on row hover.
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border p-4">
<div className="group flex items-center justify-between py-2 border-b last:border-0 relative cursor-default hover:bg-muted/50 -mx-2 px-2 transition-colors rounded-sm">
<span className="text-sm font-medium">Scan #1024 (Running)</span>
{/* Placeholder to keep height/layout stable if needed, or just absolutely position */}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-all duration-200">
<Button variant="ghost" size="icon" className="h-8 w-8 bg-background shadow-sm border">
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 bg-background shadow-sm border text-destructive">
<Trash2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 bg-background shadow-sm border">
<MoreHorizontal className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Variant 5: Dynamic / Smart Action */}
<Card>
<CardHeader>
<CardTitle>Variant 5: Smart Action</CardTitle>
<CardDescription>
Context-aware. Primary action changes based on status.
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border p-4 space-y-4">
{/* Scenario A: Running */}
<div className="flex items-center justify-between py-2 border-b">
<span className="text-sm font-medium">Scan #1024 (Running)</span>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" className="h-8 border-orange-200 text-orange-700 hover:bg-orange-50 hover:text-orange-800">
<IconCircleX className="mr-2 h-3.5 w-3.5" />
Stop
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</div>
</div>
{/* Scenario B: Completed */}
<div className="flex items-center justify-between py-2">
<span className="text-sm font-medium">Scan #1023 (Completed)</span>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" className="h-8">
<Eye className="mr-2 h-3.5 w-3.5" />
View
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Variant 6: Split Button / Minimal */}
<Card>
<CardHeader>
<CardTitle>Variant 6: Split Action</CardTitle>
<CardDescription>
One click for main action, dropdown for rest.
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border p-4">
<div className="flex items-center justify-between py-2 border-b last:border-0">
<span className="text-sm font-medium">Scan #1024 (Running)</span>
<div className="flex items-center -space-x-px">
<Button
variant="outline"
className="h-8 rounded-r-none border-r-0 px-3 hover:bg-muted"
>
View
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="h-8 w-8 rounded-l-none px-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<IconCircleX className="mr-2 h-4 w-4" />
Stop
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Variant 7: Inline Confirmation */}
<Card>
<CardHeader>
<CardTitle>Variant 7: Inline Confirmation</CardTitle>
<CardDescription>
Smoother workflow. Replaces &quot;Are you sure?&quot; modal with in-place confirmation.
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border p-4">
<div className="flex items-center justify-between py-2 border-b last:border-0">
<span className="text-sm font-medium">Scan #1024 (Completed)</span>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8">
<Eye className="h-4 w-4" />
</Button>
{isDeleting ? (
<div className="flex items-center bg-destructive/10 rounded-md animate-in fade-in slide-in-from-right-4 duration-200">
<span className="text-[10px] font-medium text-destructive px-2">Sure?</span>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:bg-destructive hover:text-white rounded-l-none"
onClick={() => {
setIsDeleting(false)
// Perform delete
}}
>
<IconCheck className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground rounded-r-none"
onClick={() => setIsDeleting(false)}
>
<IconX className="h-3.5 w-3.5" />
</Button>
</div>
) : (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:bg-destructive/10"
onClick={() => setIsDeleting(true)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
</div>
</CardContent>
</Card>
{/* Variant 8: Badge Integrated */}
<Card>
<CardHeader>
<CardTitle>Variant 8: Badge Integrated</CardTitle>
<CardDescription>
Actions attached to the status. Highly contextual.
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border p-4 space-y-4">
<div className="flex items-center justify-between py-2 border-b">
<span className="text-sm font-medium">Scan #1024</span>
<div className="flex items-center gap-4">
<div className="flex items-center rounded-full border bg-secondary/50 pr-1 pl-3 py-0.5 gap-2">
<div className="flex items-center gap-1.5">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500"></span>
</span>
<span className="text-xs font-medium">Running</span>
</div>
<div className="h-4 w-px bg-border mx-1" />
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button className="rounded-full hover:bg-background p-1 text-muted-foreground hover:text-orange-600 transition-colors">
<IconCircleX className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Stop Scan</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-sm font-medium">Scan #1023</span>
<div className="flex items-center gap-4">
<Badge variant="outline" className="bg-green-500/10 text-green-700 border-green-200">
Completed
</Badge>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Variant 9: Context Menu (Right Click) */}
<Card>
<CardHeader>
<CardTitle>Variant 9: Context Menu</CardTitle>
<CardDescription>
Pro-user interface. Right-click the row to see actions.
(Try right-clicking the row below)
</CardDescription>
</CardHeader>
<CardContent>
<div
onContextMenu={handleContextMenu}
className="rounded-md border p-4 bg-muted/10 hover:bg-muted/30 transition-colors cursor-context-menu select-none"
>
<div className="flex items-center justify-between py-2">
<span className="text-sm font-medium">Scan #1024 (Running) - Right Click Me</span>
<span className="text-xs text-muted-foreground">Right click for actions</span>
</div>
</div>
{/* Custom Context Menu Portal/Overlay */}
{contextMenu.isOpen && (
<div
ref={contextMenuRef}
style={{
position: 'fixed',
top: contextMenu.y,
left: contextMenu.x,
zIndex: 50
}}
className="min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
>
<div className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50">
<Eye className="mr-2 h-4 w-4" />
View Details
</div>
<div className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50">
<IconCircleX className="mr-2 h-4 w-4 text-orange-500" />
Stop Scan
</div>
<div className="-mx-1 my-1 h-px bg-muted" />
<div className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 text-destructive focus:text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,791 @@
"use client"
import React from "react"
import { IconBug, IconRadar, IconTool } from "@/components/icons"
export default function SidebarVariantsPage() {
return (
<div className="flex flex-col gap-8 p-8 max-w-6xl mx-auto">
<div>
<h1 className="text-2xl font-bold mb-2"></h1>
<p className="text-muted-foreground"></p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{/* 方案 A: 经典几何 */}
<div className="border rounded-xl overflow-hidden shadow-sm bg-background">
<div className="bg-muted/50 p-3 border-b text-center font-medium">
A ()
<div className="text-xs text-muted-foreground font-normal mt-1">
线 (3px)
</div>
</div>
<div className="p-4 bg-zinc-50/50 min-h-[300px]">
{/* 模拟侧边栏 */}
<div className="w-64 bg-white border rounded-lg shadow-sm overflow-hidden mx-auto">
<div className="p-2 space-y-1">
<MenuButton icon={IconRadar} label="扫描管理" active />
<div className="pl-0 space-y-1 mt-1">
{/* 模拟展开的子菜单 */}
<div className="flex items-center gap-2 px-2 py-1.5 text-sm text-muted-foreground hover:bg-zinc-100 rounded-md ml-4 border-l-2 border-transparent">
<span></span>
</div>
{/* 选中项 - 方案 A */}
<div className="flex items-center gap-2 px-2 py-1.5 text-sm font-medium bg-zinc-100 text-foreground ml-0 pl-[26px] border-l-[4px] border-[#FF4C00] relative">
<span></span>
{/* 无右侧圆点 */}
</div>
<div className="flex items-center gap-2 px-2 py-1.5 text-sm text-muted-foreground hover:bg-zinc-100 rounded-md ml-4 border-l-2 border-transparent">
<span></span>
</div>
</div>
<MenuButton icon={IconBug} label="漏洞管理" />
<MenuButton icon={IconTool} label="工具箱" />
</div>
</div>
</div>
</div>
{/* 方案 B: 前置方点 */}
<div className="border rounded-xl overflow-hidden shadow-sm bg-background">
<div className="bg-muted/50 p-3 border-b text-center font-medium">
B
<div className="text-xs text-muted-foreground font-normal mt-1">
</div>
</div>
<div className="p-4 bg-zinc-50/50 min-h-[300px]">
<div className="w-64 bg-white border rounded-lg shadow-sm overflow-hidden mx-auto">
<div className="p-2 space-y-1">
<MenuButton icon={IconRadar} label="扫描管理" active />
<div className="pl-0 space-y-1 mt-1">
<div className="flex items-center gap-2 px-2 py-1.5 text-sm text-muted-foreground hover:bg-zinc-100 rounded-md ml-9">
<span></span>
</div>
{/* 选中项 - 方案 B */}
<div className="flex items-center gap-2 px-2 py-1.5 text-sm font-medium bg-zinc-100 text-foreground ml-4 rounded-md">
<div className="w-1.5 h-1.5 bg-[#FF4C00] shrink-0" />
<span></span>
</div>
<div className="flex items-center gap-2 px-2 py-1.5 text-sm text-muted-foreground hover:bg-zinc-100 rounded-md ml-9">
<span></span>
</div>
</div>
<MenuButton icon={IconBug} label="漏洞管理" />
<MenuButton icon={IconTool} label="工具箱" />
</div>
</div>
</div>
</div>
{/* 方案 C: 高亮色块 */}
<div className="border rounded-xl overflow-hidden shadow-sm bg-background">
<div className="bg-muted/50 p-3 border-b text-center font-medium">
C
<div className="text-xs text-muted-foreground font-normal mt-1">
</div>
</div>
<div className="p-4 bg-zinc-50/50 min-h-[300px]">
<div className="w-64 bg-white border rounded-lg shadow-sm overflow-hidden mx-auto">
<div className="p-2 space-y-1">
<MenuButton icon={IconRadar} label="扫描管理" active />
<div className="pl-0 space-y-1 mt-1">
<div className="flex items-center gap-2 px-2 py-1.5 text-sm text-muted-foreground hover:bg-zinc-100 rounded-md ml-4">
<span></span>
</div>
{/* 选中项 - 方案 C */}
<div className="flex items-center gap-2 px-2 py-1.5 text-sm font-medium bg-zinc-800 text-white ml-4 rounded-md shadow-sm">
<span></span>
</div>
<div className="flex items-center gap-2 px-2 py-1.5 text-sm text-muted-foreground hover:bg-zinc-100 rounded-md ml-4">
<span></span>
</div>
</div>
<MenuButton icon={IconBug} label="漏洞管理" />
<MenuButton icon={IconTool} label="工具箱" />
</div>
</div>
</div>
</div>
{/* 方案 D: 右侧指示条 */}
<div className="border rounded-xl overflow-hidden shadow-sm bg-background">
<div className="bg-muted/50 p-3 border-b text-center font-medium">
D
<div className="text-xs text-muted-foreground font-normal mt-1">
</div>
</div>
<div className="p-4 bg-zinc-50/50 min-h-[300px]">
<div className="w-64 bg-white border rounded-lg shadow-sm overflow-hidden mx-auto">
<div className="p-2 space-y-1">
<MenuButton icon={IconRadar} label="扫描管理" active />
<div className="pl-0 space-y-1 mt-1">
<div className="flex items-center gap-2 px-2 py-1.5 text-sm text-muted-foreground hover:bg-zinc-100 rounded-md ml-4">
<span></span>
</div>
{/* 选中项 - 方案 D */}
<div className="flex items-center justify-between px-2 py-1.5 text-sm font-medium bg-zinc-50 text-foreground ml-4 rounded-md relative overflow-hidden">
<span></span>
<div className="absolute right-0 top-0 bottom-0 w-1 bg-[#FF4C00]"></div>
</div>
<div className="flex items-center gap-2 px-2 py-1.5 text-sm text-muted-foreground hover:bg-zinc-100 rounded-md ml-4">
<span></span>
</div>
</div>
<MenuButton icon={IconBug} label="漏洞管理" />
<MenuButton icon={IconTool} label="工具箱" />
</div>
</div>
</div>
</div>
{/* 方案 E: 文字变色 */}
<div className="border rounded-xl overflow-hidden shadow-sm bg-background">
<div className="bg-muted/50 p-3 border-b text-center font-medium">
E
<div className="text-xs text-muted-foreground font-normal mt-1">
</div>
</div>
<div className="p-4 bg-zinc-50/50 min-h-[300px]">
<div className="w-64 bg-white border rounded-lg shadow-sm overflow-hidden mx-auto">
<div className="p-2 space-y-1">
<MenuButton icon={IconRadar} label="扫描管理" active />
<div className="pl-0 space-y-1 mt-1">
<div className="flex items-center gap-2 px-2 py-1.5 text-sm text-muted-foreground hover:bg-zinc-100 rounded-md ml-4">
<span></span>
</div>
{/* 选中项 - 方案 E */}
<div className="flex items-center gap-2 px-2 py-1.5 text-sm font-semibold text-[#FF4C00] ml-4 rounded-md">
<span></span>
</div>
<div className="flex items-center gap-2 px-2 py-1.5 text-sm text-muted-foreground hover:bg-zinc-100 rounded-md ml-4">
<span></span>
</div>
</div>
<MenuButton icon={IconBug} label="漏洞管理" />
<MenuButton icon={IconTool} label="工具箱" />
</div>
</div>
</div>
</div>
{/* 方案 F: 橙色胶囊 */}
<div className="border rounded-xl overflow-hidden shadow-sm bg-background">
<div className="bg-muted/50 p-3 border-b text-center font-medium">
F
<div className="text-xs text-muted-foreground font-normal mt-1">
</div>
</div>
<div className="p-4 bg-zinc-50/50 min-h-[300px]">
<div className="w-64 bg-white border rounded-lg shadow-sm overflow-hidden mx-auto">
<div className="p-2 space-y-1">
<MenuButton icon={IconRadar} label="扫描管理" active />
<div className="pl-0 space-y-1 mt-1">
<div className="flex items-center gap-2 px-2 py-1.5 text-sm text-muted-foreground hover:bg-zinc-100 rounded-md ml-4">
<span></span>
</div>
{/* 选中项 - 方案 F */}
<div className="flex items-center gap-2 px-2 py-1.5 text-sm font-medium bg-[#FF4C00] text-white ml-4 rounded-full shadow-sm">
<span></span>
</div>
<div className="flex items-center gap-2 px-2 py-1.5 text-sm text-muted-foreground hover:bg-zinc-100 rounded-md ml-4">
<span></span>
</div>
</div>
<MenuButton icon={IconBug} label="漏洞管理" />
<MenuButton icon={IconTool} label="工具箱" />
</div>
</div>
</div>
</div>
{/* 方案 G: 树形连接线 */}
<div className="border rounded-xl overflow-hidden shadow-sm bg-background">
<div className="bg-muted/50 p-3 border-b text-center font-medium">
G: 树形连接线
<div className="text-xs text-muted-foreground font-normal mt-1">
线
</div>
</div>
<div className="p-4 bg-zinc-50/50 min-h-[300px]">
<div className="w-64 bg-white border rounded-lg shadow-sm overflow-hidden mx-auto">
<div className="p-2 space-y-1">
<MenuButton icon={IconRadar} label="扫描管理" active />
{/* 树形结构容器 */}
<div className="relative ml-4 pl-3 space-y-1 mt-1">
{/* 垂直连接线 */}
<div className="absolute left-0 top-0 bottom-2 w-px bg-zinc-200"></div>
<div className="relative flex items-center gap-2 px-2 py-1.5 text-sm text-muted-foreground hover:bg-zinc-100 rounded-md">
{/* 水平连接线 */}
<div className="absolute left-[-12px] top-1/2 w-3 h-px bg-zinc-200"></div>
<span></span>
</div>
{/* 选中项 - 方案 G */}
<div className="relative flex items-center gap-2 px-2 py-1.5 text-sm font-medium text-[#FF4C00] bg-zinc-50 rounded-md">
<div className="absolute left-[-12px] top-1/2 w-3 h-px bg-zinc-200"></div>
<div className="w-1.5 h-1.5 rounded-full bg-[#FF4C00] mr-1"></div>
<span></span>
</div>
<div className="relative flex items-center gap-2 px-2 py-1.5 text-sm text-muted-foreground hover:bg-zinc-100 rounded-md">
<div className="absolute left-[-12px] top-1/2 w-3 h-px bg-zinc-200"></div>
<span></span>
</div>
</div>
<MenuButton icon={IconBug} label="漏洞管理" />
<MenuButton icon={IconTool} label="工具箱" />
</div>
</div>
</div>
</div>
{/* 方案 H: 时间轴节点 */}
<div className="border rounded-xl overflow-hidden shadow-sm bg-background">
<div className="bg-muted/50 p-3 border-b text-center font-medium">
H: 时间轴节点
<div className="text-xs text-muted-foreground font-normal mt-1">
穿线 +
</div>
</div>
<div className="p-4 bg-zinc-50/50 min-h-[300px]">
<div className="w-64 bg-white border rounded-lg shadow-sm overflow-hidden mx-auto">
<div className="p-2 space-y-1">
<MenuButton icon={IconRadar} label="扫描管理" active />
<div className="relative ml-5 space-y-4 mt-2 mb-2">
{/* 贯穿线 */}
<div className="absolute left-0 top-0 bottom-0 w-px bg-zinc-200 transform -translate-x-1/2"></div>
<div className="relative flex items-center gap-2 pl-4 text-sm text-muted-foreground">
{/* 未选中节点 */}
<div className="absolute left-0 top-1/2 w-1.5 h-1.5 bg-zinc-300 rounded-full transform -translate-x-1/2 -translate-y-1/2 border border-white ring-2 ring-white"></div>
<span></span>
</div>
{/* 选中项 - 方案 H */}
<div className="relative flex items-center gap-2 pl-4 text-sm font-semibold text-foreground">
{/* 选中节点 */}
<div className="absolute left-0 top-1/2 w-2.5 h-2.5 bg-[#FF4C00] rounded-full transform -translate-x-1/2 -translate-y-1/2 ring-4 ring-zinc-50"></div>
<span></span>
</div>
<div className="relative flex items-center gap-2 pl-4 text-sm text-muted-foreground">
<div className="absolute left-0 top-1/2 w-1.5 h-1.5 bg-zinc-300 rounded-full transform -translate-x-1/2 -translate-y-1/2 border border-white ring-2 ring-white"></div>
<span></span>
</div>
</div>
<MenuButton icon={IconBug} label="漏洞管理" />
<MenuButton icon={IconTool} label="工具箱" />
</div>
</div>
</div>
</div>
{/* 方案 I: 幽灵缩进线 */}
<div className="border rounded-xl overflow-hidden shadow-sm bg-background">
<div className="bg-muted/50 p-3 border-b text-center font-medium">
I: 幽灵缩进线
<div className="text-xs text-muted-foreground font-normal mt-1">
Hover/
</div>
</div>
<div className="p-4 bg-zinc-50/50 min-h-[300px]">
<div className="w-64 bg-white border rounded-lg shadow-sm overflow-hidden mx-auto">
<div className="p-2 space-y-1">
<MenuButton icon={IconRadar} label="扫描管理" active />
<div className="space-y-1 mt-1">
<div className="flex items-center gap-2 px-2 py-1.5 text-sm text-muted-foreground hover:bg-zinc-50 hover:text-foreground ml-4 border-l border-transparent hover:border-zinc-300 transition-colors pl-3">
<span></span>
</div>
{/* 选中项 - 方案 I */}
<div className="flex items-center gap-2 px-2 py-1.5 text-sm font-medium text-foreground ml-4 border-l border-[#FF4C00] pl-3 bg-zinc-50/50">
<span></span>
</div>
<div className="flex items-center gap-2 px-2 py-1.5 text-sm text-muted-foreground hover:bg-zinc-50 hover:text-foreground ml-4 border-l border-transparent hover:border-zinc-300 transition-colors pl-3">
<span></span>
</div>
</div>
<MenuButton icon={IconBug} label="漏洞管理" />
<MenuButton icon={IconTool} label="工具箱" />
</div>
</div>
</div>
</div>
{/* 方案 J: 块级连接线 */}
<div className="border rounded-xl overflow-hidden shadow-sm bg-background">
<div className="bg-muted/50 p-3 border-b text-center font-medium">
J: 块级连接线
<div className="text-xs text-muted-foreground font-normal mt-1">
线GitHub文件树
</div>
</div>
<div className="p-4 bg-zinc-50/50 min-h-[300px]">
<div className="w-64 bg-white border rounded-lg shadow-sm overflow-hidden mx-auto">
<div className="p-2 space-y-1">
<MenuButton icon={IconRadar} label="扫描管理" active />
<div className="relative ml-2 space-y-0.5 mt-1">
{/* 粗灰线背景 */}
<div className="absolute left-2 top-0 bottom-2 w-0.5 bg-zinc-100"></div>
<div className="relative flex items-center gap-2 px-2 py-1.5 text-sm text-muted-foreground hover:bg-zinc-100 rounded-md ml-4">
<span></span>
</div>
{/* 选中项 - 方案 J */}
<div className="relative flex items-center gap-2 px-2 py-1.5 text-sm font-medium bg-zinc-100 text-foreground ml-4 rounded-md">
{/* 选中时左侧覆盖一条橙色线 */}
<div className="absolute left-[-10px] top-1/2 -translate-y-1/2 h-full w-0.5 bg-[#FF4C00]"></div>
<span></span>
</div>
<div className="relative flex items-center gap-2 px-2 py-1.5 text-sm text-muted-foreground hover:bg-zinc-100 rounded-md ml-4">
<span></span>
</div>
</div>
<MenuButton icon={IconBug} label="漏洞管理" />
<MenuButton icon={IconTool} label="工具箱" />
</div>
</div>
</div>
</div>
{/* 方案 K: 滑动背景动画 */}
<div className="border rounded-xl overflow-hidden shadow-sm bg-background">
<div className="bg-muted/50 p-3 border-b text-center font-medium">
K: 滑动背景动画
<div className="text-xs text-muted-foreground font-normal mt-1">
(Hover查看效果)
</div>
</div>
<div className="p-4 bg-zinc-50/50 min-h-[300px]">
<div className="w-64 bg-white border rounded-lg shadow-sm overflow-hidden mx-auto">
<div className="p-2 space-y-1">
<MenuButton icon={IconRadar} label="扫描管理" active />
<div className="space-y-1 mt-1 pl-4">
<div className="group relative flex items-center gap-2 px-3 py-1.5 text-sm text-muted-foreground overflow-hidden rounded-md cursor-pointer">
<div className="absolute inset-0 bg-zinc-100 -translate-x-full group-hover:translate-x-0 transition-transform duration-300 ease-out"></div>
<span className="relative transition-transform duration-300 group-hover:translate-x-1"></span>
</div>
{/* 选中项 - 方案 K */}
<div className="group relative flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-foreground overflow-hidden rounded-md cursor-pointer">
<div className="absolute inset-0 bg-zinc-100 border-l-2 border-[#FF4C00]"></div>
<span className="relative translate-x-1"></span>
</div>
<div className="group relative flex items-center gap-2 px-3 py-1.5 text-sm text-muted-foreground overflow-hidden rounded-md cursor-pointer">
<div className="absolute inset-0 bg-zinc-100 -translate-x-full group-hover:translate-x-0 transition-transform duration-300 ease-out"></div>
<span className="relative transition-transform duration-300 group-hover:translate-x-1"></span>
</div>
</div>
<MenuButton icon={IconBug} label="漏洞管理" />
<MenuButton icon={IconTool} label="工具箱" />
</div>
</div>
</div>
</div>
{/* 方案 L: 霓虹辉光 (Glow) */}
<div className="border rounded-xl overflow-hidden shadow-sm bg-background">
<div className="bg-muted/50 p-3 border-b text-center font-medium">
L: 霓虹辉光
<div className="text-xs text-muted-foreground font-normal mt-1">
+
</div>
</div>
<div className="p-4 bg-zinc-900 min-h-[300px]">
{/* 深色模式下效果更好 */}
<div className="w-64 bg-zinc-950 border border-zinc-800 rounded-lg shadow-sm overflow-hidden mx-auto text-zinc-400">
<div className="p-2 space-y-1">
<div className="flex items-center gap-2 px-2 py-2 text-sm rounded-md bg-zinc-900 text-zinc-100">
<IconRadar className="w-4 h-4" />
<span></span>
</div>
<div className="space-y-1 mt-1 pl-4">
<div className="flex items-center gap-2 px-3 py-1.5 text-sm hover:text-zinc-200 transition-colors cursor-pointer">
<span></span>
</div>
{/* 选中项 - 方案 L */}
<div className="relative flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-white rounded-r-md cursor-pointer">
{/* 发光条 */}
<div className="absolute left-0 top-1 bottom-1 w-0.5 bg-[#FF4C00] shadow-[0_0_8px_rgba(255,76,0,0.8)] rounded-full"></div>
{/* 背景光晕 */}
<div className="absolute inset-0 bg-gradient-to-r from-[#FF4C00]/10 to-transparent opacity-50"></div>
<span className="relative"></span>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 text-sm hover:text-zinc-200 transition-colors cursor-pointer">
<span></span>
</div>
</div>
<div className="flex items-center gap-2 px-2 py-2 text-sm rounded-md hover:bg-zinc-900 transition-colors cursor-pointer">
<IconBug className="w-4 h-4" />
<span></span>
</div>
<div className="flex items-center gap-2 px-2 py-2 text-sm rounded-md hover:bg-zinc-900 transition-colors cursor-pointer">
<IconTool className="w-4 h-4" />
<span></span>
</div>
</div>
</div>
</div>
</div>
{/* 方案 M: 脉冲圆点 */}
<div className="border rounded-xl overflow-hidden shadow-sm bg-background">
<div className="bg-muted/50 p-3 border-b text-center font-medium">
M: 脉冲圆点
<div className="text-xs text-muted-foreground font-normal mt-1">
</div>
</div>
<div className="p-4 bg-zinc-50/50 min-h-[300px]">
<div className="w-64 bg-white border rounded-lg shadow-sm overflow-hidden mx-auto">
<div className="p-2 space-y-1">
<MenuButton icon={IconRadar} label="扫描管理" active />
<div className="relative ml-4 pl-3 space-y-1 mt-1">
<div className="absolute left-0 top-0 bottom-2 w-px bg-zinc-200"></div>
<div className="relative flex items-center gap-2 px-2 py-1.5 text-sm text-muted-foreground hover:bg-zinc-100 rounded-md group cursor-pointer">
<div className="absolute left-[-12px] top-1/2 w-3 h-px bg-zinc-200 group-hover:bg-zinc-400 transition-colors"></div>
<span></span>
</div>
{/* 选中项 - 方案 M */}
<div className="relative flex items-center gap-2 px-2 py-1.5 text-sm font-medium text-foreground bg-zinc-50 rounded-md cursor-pointer">
<div className="absolute left-[-12px] top-1/2 w-3 h-px bg-[#FF4C00]"></div>
{/* 脉冲圆点结构 */}
<div className="relative flex items-center justify-center w-2 h-2 mr-1">
<span className="absolute inline-flex h-full w-full rounded-full bg-[#FF4C00] opacity-75 animate-ping"></span>
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-[#FF4C00]"></span>
</div>
<span></span>
</div>
<div className="relative flex items-center gap-2 px-2 py-1.5 text-sm text-muted-foreground hover:bg-zinc-100 rounded-md group cursor-pointer">
<div className="absolute left-[-12px] top-1/2 w-3 h-px bg-zinc-200 group-hover:bg-zinc-400 transition-colors"></div>
<span></span>
</div>
</div>
<MenuButton icon={IconBug} label="漏洞管理" />
<MenuButton icon={IconTool} label="工具箱" />
</div>
</div>
</div>
</div>
{/* 方案 N: K(滑动) + J(粗线) 融合 */}
<div className="border rounded-xl overflow-hidden shadow-sm bg-background">
<div className="bg-muted/50 p-3 border-b text-center font-medium">
N: 滑动块级线 (K+J)
<div className="text-xs text-muted-foreground font-normal mt-1">
线 +
</div>
</div>
<div className="p-4 bg-zinc-50/50 min-h-[300px]">
<div className="w-64 bg-white border rounded-lg shadow-sm overflow-hidden mx-auto">
<div className="p-2 space-y-1">
<MenuButton icon={IconRadar} label="扫描管理" active />
<div className="relative ml-2 space-y-0.5 mt-1">
{/* J的背景线 */}
<div className="absolute left-2 top-0 bottom-2 w-0.5 bg-zinc-100"></div>
<div className="group relative flex items-center gap-2 px-2 py-1.5 text-sm text-muted-foreground overflow-hidden rounded-md ml-4 cursor-pointer">
{/* K的滑动背景 */}
<div className="absolute inset-0 bg-zinc-100 -translate-x-full group-hover:translate-x-0 transition-transform duration-300 ease-out"></div>
<span className="relative transition-transform duration-300 group-hover:translate-x-1"></span>
</div>
{/* 选中项 - 方案 N */}
<div className="group relative flex items-center gap-2 px-2 py-1.5 text-sm font-medium text-foreground overflow-hidden rounded-md ml-4 cursor-pointer">
{/* 背景常驻 */}
<div className="absolute inset-0 bg-zinc-100"></div>
{/* J的橙色粗线 */}
<div className="absolute left-[-10px] top-1/2 -translate-y-1/2 h-full w-0.5 bg-[#FF4C00]"></div>
<span className="relative translate-x-1"></span>
</div>
<div className="group relative flex items-center gap-2 px-2 py-1.5 text-sm text-muted-foreground overflow-hidden rounded-md ml-4 cursor-pointer">
<div className="absolute inset-0 bg-zinc-100 -translate-x-full group-hover:translate-x-0 transition-transform duration-300 ease-out"></div>
<span className="relative transition-transform duration-300 group-hover:translate-x-1"></span>
</div>
</div>
<MenuButton icon={IconBug} label="漏洞管理" />
<MenuButton icon={IconTool} label="工具箱" />
</div>
</div>
</div>
</div>
{/* 方案 O: K(滑动) + M(脉冲) 融合 */}
<div className="border rounded-xl overflow-hidden shadow-sm bg-background">
<div className="bg-muted/50 p-3 border-b text-center font-medium">
O: 滑动脉冲 (K+M)
<div className="text-xs text-muted-foreground font-normal mt-1">
+
</div>
</div>
<div className="p-4 bg-zinc-50/50 min-h-[300px]">
<div className="w-64 bg-white border rounded-lg shadow-sm overflow-hidden mx-auto">
<div className="p-2 space-y-1">
<MenuButton icon={IconRadar} label="扫描管理" active />
<div className="relative ml-4 pl-3 space-y-1 mt-1">
{/* M的细线 */}
<div className="absolute left-0 top-0 bottom-2 w-px bg-zinc-200"></div>
<div className="group relative flex items-center gap-2 px-2 py-1.5 text-sm text-muted-foreground overflow-hidden rounded-md cursor-pointer">
{/* M的横线 */}
<div className="absolute left-[-12px] top-1/2 w-3 h-px bg-zinc-200 z-10"></div>
{/* K的滑动背景 */}
<div className="absolute inset-0 bg-zinc-100 -translate-x-full group-hover:translate-x-0 transition-transform duration-300 ease-out"></div>
<span className="relative transition-transform duration-300 group-hover:translate-x-1"></span>
</div>
{/* 选中项 - 方案 O */}
<div className="group relative flex items-center gap-2 px-2 py-1.5 text-sm font-medium text-foreground overflow-hidden rounded-md cursor-pointer">
<div className="absolute inset-0 bg-zinc-50"></div>
<div className="absolute left-[-12px] top-1/2 w-3 h-px bg-[#FF4C00] z-10"></div>
{/* M的脉冲圆点 */}
<div className="relative flex items-center justify-center w-2 h-2 mr-1 z-10">
<span className="absolute inline-flex h-full w-full rounded-full bg-[#FF4C00] opacity-75 animate-ping"></span>
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-[#FF4C00]"></span>
</div>
<span className="relative z-10"></span>
</div>
<div className="group relative flex items-center gap-2 px-2 py-1.5 text-sm text-muted-foreground overflow-hidden rounded-md cursor-pointer">
<div className="absolute left-[-12px] top-1/2 w-3 h-px bg-zinc-200 z-10"></div>
<div className="absolute inset-0 bg-zinc-100 -translate-x-full group-hover:translate-x-0 transition-transform duration-300 ease-out"></div>
<span className="relative transition-transform duration-300 group-hover:translate-x-1"></span>
</div>
</div>
<MenuButton icon={IconBug} label="漏洞管理" />
<MenuButton icon={IconTool} label="工具箱" />
</div>
</div>
</div>
</div>
{/* 方案 P: H(时间轴) + K(滑动) 融合 */}
<div className="border rounded-xl overflow-hidden shadow-sm bg-background">
<div className="bg-muted/50 p-3 border-b text-center font-medium">
P: 时间轴滑块 (H+K)
<div className="text-xs text-muted-foreground font-normal mt-1">
+
</div>
</div>
<div className="p-4 bg-zinc-50/50 min-h-[300px]">
<div className="w-64 bg-white border rounded-lg shadow-sm overflow-hidden mx-auto">
<div className="p-2 space-y-1">
<MenuButton icon={IconRadar} label="扫描管理" active />
<div className="relative ml-5 space-y-1 mt-2 mb-2">
{/* H的贯穿线 */}
<div className="absolute left-0 top-0 bottom-0 w-px bg-zinc-200 transform -translate-x-1/2"></div>
<div className="group relative flex items-center gap-2 pl-4 py-1.5 text-sm text-muted-foreground overflow-hidden rounded-md cursor-pointer">
{/* K的滑动背景 (注意这里不需要覆盖节点所以margin-left调整) */}
<div className="absolute inset-y-0 right-0 left-2 bg-zinc-100 -translate-x-full group-hover:translate-x-0 transition-transform duration-300 ease-out rounded-md"></div>
{/* H的节点 */}
<div className="absolute left-0 top-1/2 w-1.5 h-1.5 bg-zinc-300 rounded-full transform -translate-x-1/2 -translate-y-1/2 border border-white ring-2 ring-white z-10"></div>
<span className="relative z-10 transition-transform duration-300 group-hover:translate-x-1"></span>
</div>
{/* 选中项 - 方案 P */}
<div className="group relative flex items-center gap-2 pl-4 py-1.5 text-sm font-semibold text-foreground overflow-hidden rounded-md cursor-pointer">
<div className="absolute inset-y-0 right-0 left-2 bg-zinc-100 rounded-md"></div>
{/* H的选中节点 */}
<div className="absolute left-0 top-1/2 w-2.5 h-2.5 bg-[#FF4C00] rounded-full transform -translate-x-1/2 -translate-y-1/2 ring-4 ring-zinc-50 z-10"></div>
<span className="relative z-10"></span>
</div>
<div className="group relative flex items-center gap-2 pl-4 py-1.5 text-sm text-muted-foreground overflow-hidden rounded-md cursor-pointer">
<div className="absolute inset-y-0 right-0 left-2 bg-zinc-100 -translate-x-full group-hover:translate-x-0 transition-transform duration-300 ease-out rounded-md"></div>
<div className="absolute left-0 top-1/2 w-1.5 h-1.5 bg-zinc-300 rounded-full transform -translate-x-1/2 -translate-y-1/2 border border-white ring-2 ring-white z-10"></div>
<span className="relative z-10 transition-transform duration-300 group-hover:translate-x-1"></span>
</div>
</div>
<MenuButton icon={IconBug} label="漏洞管理" />
<MenuButton icon={IconTool} label="工具箱" />
</div>
</div>
</div>
</div>
{/* 方案 N: 动感融合 (J+K+M) */}
<div className="border rounded-xl overflow-hidden shadow-sm bg-background">
<div className="bg-muted/50 p-3 border-b text-center font-medium">
N: 动感融合 (J+K+M)
<div className="text-xs text-muted-foreground font-normal mt-1">
+ 线 +
</div>
</div>
<div className="p-4 bg-zinc-50/50 min-h-[300px]">
<div className="w-64 bg-white border rounded-lg shadow-sm overflow-hidden mx-auto">
<div className="p-2 space-y-1">
<MenuButton icon={IconRadar} label="扫描管理" active />
<div className="relative space-y-1 mt-1">
{/* 背景粗灰线 */}
<div className="absolute left-3 top-0 bottom-2 w-0.5 bg-zinc-100"></div>
{/* 普通项K 的滑入效果 */}
<div className="group relative flex items-center gap-2 px-3 py-1.5 ml-2 text-sm text-muted-foreground overflow-hidden rounded-md cursor-pointer">
<div className="absolute inset-0 bg-zinc-100 -translate-x-full group-hover:translate-x-0 transition-transform duration-300 ease-out"></div>
<span className="relative transition-transform duration-300 group-hover:translate-x-1"></span>
</div>
{/* 选中项J+M 结合 */}
<div className="group relative flex items-center gap-2 px-3 py-1.5 ml-2 text-sm font-medium text-foreground bg-zinc-50/50 overflow-hidden rounded-md cursor-pointer">
{/* K: 静态背景 */}
<div className="absolute inset-0 bg-zinc-100 opacity-50"></div>
{/* J+M: 左侧粗线 + 呼吸光晕 */}
<div className="absolute left-0 top-0 bottom-0 w-1 bg-[#FF4C00]">
{/* 模拟光效流动 */}
<div className="absolute inset-0 bg-white/30 animate-pulse"></div>
</div>
<span className="relative pl-1"></span>
</div>
<div className="group relative flex items-center gap-2 px-3 py-1.5 ml-2 text-sm text-muted-foreground overflow-hidden rounded-md cursor-pointer">
<div className="absolute inset-0 bg-zinc-100 -translate-x-full group-hover:translate-x-0 transition-transform duration-300 ease-out"></div>
<span className="relative transition-transform duration-300 group-hover:translate-x-1"></span>
</div>
</div>
<MenuButton icon={IconBug} label="漏洞管理" />
<MenuButton icon={IconTool} label="工具箱" />
</div>
</div>
</div>
</div>
{/* 方案 O: 磁性滑块 + 活跃点 (K+M) */}
<div className="border rounded-xl overflow-hidden shadow-sm bg-background">
<div className="bg-muted/50 p-3 border-b text-center font-medium">
O: 磁性滑块 +
<div className="text-xs text-muted-foreground font-normal mt-1">
</div>
</div>
<div className="p-4 bg-zinc-50/50 min-h-[300px]">
<div className="w-64 bg-white border rounded-lg shadow-sm overflow-hidden mx-auto">
<div className="p-2 space-y-1">
<MenuButton icon={IconRadar} label="扫描管理" active />
<div className="relative ml-4 pl-3 space-y-1 mt-1">
<div className="absolute left-0 top-0 bottom-2 w-px bg-zinc-200"></div>
<div className="group relative flex items-center gap-2 px-2 py-1.5 text-sm text-muted-foreground rounded-md cursor-pointer overflow-hidden">
{/* K: 滑入背景 */}
<div className="absolute inset-0 bg-zinc-100 -translate-x-full group-hover:translate-x-0 transition-transform duration-300 ease-out"></div>
{/* G: 连接线 */}
<div className="absolute left-[-12px] top-1/2 w-3 h-px bg-zinc-200 group-hover:bg-zinc-300 transition-colors z-10"></div>
<span className="relative z-10 transition-transform duration-300 group-hover:translate-x-1"></span>
</div>
{/* 选中项 - 方案 O */}
<div className="group relative flex items-center gap-2 px-2 py-1.5 text-sm font-medium text-foreground rounded-md cursor-pointer overflow-hidden">
{/* 背景常驻 */}
<div className="absolute inset-0 bg-zinc-100"></div>
{/* 连接线变色 */}
<div className="absolute left-[-12px] top-1/2 w-3 h-px bg-[#FF4C00] z-10"></div>
{/* M: 脉冲点 */}
<div className="relative z-10 flex items-center justify-center w-2 h-2 mr-1">
<span className="absolute inline-flex h-full w-full rounded-full bg-[#FF4C00] opacity-75 animate-ping"></span>
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-[#FF4C00]"></span>
</div>
<span className="relative z-10"></span>
</div>
<div className="group relative flex items-center gap-2 px-2 py-1.5 text-sm text-muted-foreground rounded-md cursor-pointer overflow-hidden">
<div className="absolute inset-0 bg-zinc-100 -translate-x-full group-hover:translate-x-0 transition-transform duration-300 ease-out"></div>
<div className="absolute left-[-12px] top-1/2 w-3 h-px bg-zinc-200 group-hover:bg-zinc-300 transition-colors z-10"></div>
<span className="relative z-10 transition-transform duration-300 group-hover:translate-x-1"></span>
</div>
</div>
<MenuButton icon={IconBug} label="漏洞管理" />
<MenuButton icon={IconTool} label="工具箱" />
</div>
</div>
</div>
</div>
</div>
</div>
)
}
function MenuButton({ icon: Icon, label, active }: { icon: React.ComponentType<{ className?: string }>, label: string, active?: boolean }) {
return (
<div className={`flex items-center gap-2 px-2 py-2 text-sm rounded-md ${active ? 'bg-zinc-100 font-medium' : 'text-zinc-600 hover:bg-zinc-50'}`}>
<Icon className="w-4 h-4" />
<span>{label}</span>
</div>
)
}

View File

@@ -0,0 +1,20 @@
import { StatusProgressDemos } from "@/components/prototypes/status-progress-demos"
import { BauhausPageHeader } from "@/components/common/bauhaus-page-header"
export default function StatusProgressVariantsPage() {
return (
<div className="flex h-full flex-col">
<BauhausPageHeader
code="PRT-STATUS"
subtitle="Prototypes"
title="Status & Progress Variants"
description="Exploration of combined status and progress indicators"
showDescription
/>
<div className="flex-1 overflow-auto p-6">
<StatusProgressDemos />
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,137 @@
"use client"
import React, { useState } from "react"
import { MOCK_ENGINES, FEATURE_LIST } from "../data"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Search, Plus, IconDotsVertical, Clock, Activity, CheckCircle2, AlertTriangle } from "@/components/icons"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { ScrollArea } from "@/components/ui/scroll-area"
import Link from "next/link"
export default function CardGridDemo() {
const [search, setSearch] = useState("")
const filteredEngines = MOCK_ENGINES.filter(e =>
e.name.toLowerCase().includes(search.toLowerCase()) ||
e.description?.toLowerCase().includes(search.toLowerCase())
)
const getFeatureIcon = (key: string) => {
return FEATURE_LIST.find(f => f.key === key)?.icon || "•"
}
return (
<div className="flex flex-col h-full bg-muted/20">
{/* Header */}
<div className="border-b bg-background px-6 py-4 flex items-center justify-between sticky top-0 z-10">
<div className="flex items-center gap-4">
<Link href="../demo" className="text-muted-foreground hover:text-foreground"> Back</Link>
<h1 className="text-xl font-semibold">Scan Engines (Grid View)</h1>
</div>
<div className="flex items-center gap-3">
<div className="relative w-64">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search engines..."
className="pl-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Button>
<Plus className="h-4 w-4 mr-2" />
New Engine
</Button>
</div>
</div>
<ScrollArea className="flex-1 p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 max-w-[1600px] mx-auto">
{filteredEngines.map((engine) => (
<Card key={engine.id} className="group hover:shadow-md transition-all duration-200 border-muted hover:border-primary/50 flex flex-col">
<CardHeader className="pb-3">
<div className="flex justify-between items-start">
<div className="space-y-1">
<CardTitle className="text-lg flex items-center gap-2">
{engine.name}
{engine.type === 'preset' && (
<Badge variant="secondary" className="text-[10px] h-5 px-1.5">PRESET</Badge>
)}
</CardTitle>
<CardDescription className="line-clamp-2 min-h-[40px]">
{engine.description}
</CardDescription>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 -mr-2 opacity-0 group-hover:opacity-100 transition-opacity">
<IconDotsVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>Edit Configuration</DropdownMenuItem>
<DropdownMenuItem>Duplicate</DropdownMenuItem>
<DropdownMenuItem className="text-destructive">Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent className="pb-3 flex-1">
<div className="flex flex-wrap gap-1.5 mb-4">
{engine.features.map(f => (
<Badge key={f} variant="outline" className="bg-background/50 font-normal">
<span className="mr-1.5">{getFeatureIcon(f)}</span>
{FEATURE_LIST.find(item => item.key === f)?.label || f}
</Badge>
))}
{engine.features.length === 0 && (
<span className="text-sm text-muted-foreground italic">No features enabled</span>
)}
</div>
</CardContent>
<CardFooter className="pt-3 border-t bg-muted/10 flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1" title="Last Updated">
<Clock className="h-3.5 w-3.5" />
<span>{new Date(engine.updatedAt).toLocaleDateString()}</span>
</div>
{engine.isValid ? (
<div className="flex items-center gap-1 text-green-600 dark:text-green-500">
<CheckCircle2 className="h-3.5 w-3.5" />
<span>Valid</span>
</div>
) : (
<div className="flex items-center gap-1 text-amber-600 dark:text-amber-500">
<AlertTriangle className="h-3.5 w-3.5" />
<span>Invalid</span>
</div>
)}
</div>
{engine.type === 'user' && (
<div className="flex items-center gap-1" title="Usage Stats">
<Activity className="h-3.5 w-3.5" />
<span>{engine.stats.runs} runs</span>
</div>
)}
</CardFooter>
</Card>
))}
{/* Add New Card Placeholder */}
<button className="border-2 border-dashed rounded-xl p-6 flex flex-col items-center justify-center text-muted-foreground hover:text-primary hover:border-primary/50 hover:bg-primary/5 transition-all min-h-[250px] gap-3">
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center">
<Plus className="h-6 w-6" />
</div>
<span className="font-medium">Create New Engine</span>
</button>
</div>
</ScrollArea>
</div>
)
}

View File

@@ -0,0 +1,64 @@
export const FEATURE_LIST = [
{ key: "subdomain_discovery", label: "Subdomain Discovery", icon: "🌐" },
{ key: "port_scan", label: "Port Scan", icon: "🔌" },
{ key: "site_scan", label: "Site Scan", icon: "📄" },
{ key: "fingerprint_detect", label: "Fingerprint", icon: "🔍" },
{ key: "directory_scan", label: "Directory Scan", icon: "📂" },
{ key: "screenshot", label: "Screenshot", icon: "📸" },
{ key: "url_fetch", label: "URL Fetch", icon: "🔗" },
{ key: "vuln_scan", label: "Vuln Scan", icon: "🛡️" },
] as const
export const MOCK_ENGINES = [
{
id: 1,
name: "Full Scan Profile",
description: "Comprehensive scanning with all features enabled",
type: "preset",
updatedAt: "2023-10-01T12:00:00Z",
isValid: true,
features: ["subdomain_discovery", "port_scan", "site_scan", "fingerprint_detect", "vuln_scan"],
stats: { runs: 120, avgTime: "45m" }
},
{
id: 2,
name: "Quick Discovery",
description: "Fast reconnaissance for subdomains and ports",
type: "preset",
updatedAt: "2023-10-05T09:30:00Z",
isValid: true,
features: ["subdomain_discovery", "port_scan"],
stats: { runs: 850, avgTime: "5m" }
},
{
id: 3,
name: "Web Vulnerability Check",
description: "Focus on web application vulnerabilities",
type: "user",
updatedAt: "2024-01-15T14:20:00Z",
isValid: true,
features: ["site_scan", "url_fetch", "vuln_scan", "screenshot"],
stats: { runs: 45, avgTime: "25m" }
},
{
id: 4,
name: "Custom Asset Audit",
description: "Legacy configuration for quarterly audits",
type: "user",
updatedAt: "2023-11-20T10:15:00Z",
isValid: false, // Needs update
features: ["subdomain_discovery", "fingerprint_detect"],
stats: { runs: 12, avgTime: "15m" }
},
{
id: 5,
name: "Nightly Monitor",
description: "Automated low-impact monitoring scan",
type: "user",
updatedAt: "2024-02-01T02:00:00Z",
isValid: true,
features: ["port_scan", "screenshot"],
stats: { runs: 300, avgTime: "8m" }
}
]

View File

@@ -0,0 +1,228 @@
"use client"
import React, { useMemo, useState } from "react"
import { MOCK_ENGINES, FEATURE_LIST } from "../data"
import { ReactFlow, Background, Controls, Handle, Position, NodeProps, Edge, Node } from "@xyflow/react"
import '@xyflow/react/dist/style.css'
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Check, Play, AlertCircle } from "@/components/icons"
import Link from "next/link"
// Custom Node Component
const CustomFeatureNode = ({ data }: NodeProps) => {
return (
<div className={`px-4 py-3 shadow-lg rounded-xl bg-card border-2 min-w-[180px] ${data.enabled ? 'border-primary' : 'border-muted opacity-50'}`}>
<Handle type="target" position={Position.Top} className="!bg-muted-foreground" />
<div className="flex items-center gap-2 mb-1">
<span className="text-xl">{data.icon as string}</span>
<div className="font-semibold text-sm">{data.label as string}</div>
</div>
<div className="text-[10px] text-muted-foreground">
{data.enabled ? "Active" : "Skipped"}
</div>
<Handle type="source" position={Position.Bottom} className="!bg-muted-foreground" />
</div>
)
}
const nodeTypes = {
feature: CustomFeatureNode,
}
export default function FeatureFlowDemo() {
const [selectedEngineId, setSelectedEngineId] = useState(MOCK_ENGINES[0].id)
const selectedEngine = MOCK_ENGINES.find(e => e.id === selectedEngineId) || MOCK_ENGINES[0]
const { nodes, edges } = useMemo(() => {
const newNodes: Node[] = []
const newEdges: Edge[] = []
// Start Node
newNodes.push({
id: 'start',
type: 'input',
data: { label: 'Start Scan' },
position: { x: 250, y: 0 },
style: { background: '#10b981', color: 'white', border: 'none', borderRadius: '20px', width: '100px', textAlign: 'center' }
})
// Layout calculation vars
let yPos = 100
let lastNodeId = 'start'
// Define a logical flow order
const flowOrder = [
'subdomain_discovery',
'port_scan',
['site_scan', 'fingerprint_detect'], // Parallel group
'directory_scan',
['screenshot', 'url_fetch'], // Parallel group
'vuln_scan'
]
flowOrder.forEach((step) => {
if (Array.isArray(step)) {
// Parallel nodes
const enabledInGroup = step.filter(key => selectedEngine.features.includes(key))
if (enabledInGroup.length > 0) {
const totalWidth = enabledInGroup.length * 200
const startX = 250 - (totalWidth / 2) + 100 // center align
enabledInGroup.forEach((key, i) => {
const feature = FEATURE_LIST.find(f => f.key === key)
const nodeId = key
newNodes.push({
id: nodeId,
type: 'feature',
position: { x: startX + (i * 220) - 100, y: yPos },
data: {
label: feature?.label,
icon: feature?.icon,
enabled: true
}
})
newEdges.push({
id: `e-${lastNodeId}-${nodeId}`,
source: lastNodeId,
target: nodeId,
animated: true,
style: { stroke: '#2563eb' }
})
})
// Re-converge point (virtual or next node)
// For simplicity in this demo, all parallel nodes connect to the next step's first node
// Or we update lastNodeId to be an array? simplified: just take the first one or create a merge node
lastNodeId = enabledInGroup[0] // Simplified linking
yPos += 120
}
} else {
// Single node step
if (selectedEngine.features.includes(step)) {
const feature = FEATURE_LIST.find(f => f.key === step)
const nodeId = step
newNodes.push({
id: nodeId,
type: 'feature',
position: { x: 200, y: yPos }, // centered roughly
data: {
label: feature?.label,
icon: feature?.icon,
enabled: true
}
})
newEdges.push({
id: `e-${lastNodeId}-${nodeId}`,
source: lastNodeId,
target: nodeId,
animated: true,
style: { stroke: '#2563eb' }
})
lastNodeId = nodeId
yPos += 120
}
}
})
// End Node
newNodes.push({
id: 'end',
type: 'output',
data: { label: 'Report Generated' },
position: { x: 250, y: yPos },
style: { background: '#6366f1', color: 'white', border: 'none', borderRadius: '20px', width: '120px', textAlign: 'center' }
})
// Connect all leaf nodes to end if not already connected
// Simplified: just connect last added node
newEdges.push({
id: `e-${lastNodeId}-end`,
source: lastNodeId,
target: 'end',
animated: true,
style: { stroke: '#2563eb' }
})
return { nodes: newNodes, edges: newEdges }
}, [selectedEngine])
return (
<div className="flex h-full bg-background">
{/* Sidebar List */}
<div className="w-80 border-r flex flex-col bg-muted/10">
<div className="p-4 border-b">
<Link href="../demo" className="text-sm text-muted-foreground hover:text-foreground mb-4 block"> Back to Demos</Link>
<h2 className="font-semibold text-lg">Scan Pipelines</h2>
<p className="text-xs text-muted-foreground">Select an engine to visualize its workflow</p>
</div>
<ScrollArea className="flex-1">
<div className="p-3 space-y-2">
{MOCK_ENGINES.map(engine => (
<button
key={engine.id}
onClick={() => setSelectedEngineId(engine.id)}
className={`w-full text-left p-3 rounded-lg border transition-all ${
selectedEngineId === engine.id
? 'bg-background border-primary shadow-sm ring-1 ring-primary/20'
: 'bg-transparent border-transparent hover:bg-muted'
}`}
>
<div className="flex justify-between items-start mb-1">
<span className="font-medium text-sm">{engine.name}</span>
{engine.type === 'preset' && <Badge variant="secondary" className="text-[10px] px-1 h-4">Preset</Badge>}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{engine.features.length} steps</span>
<span></span>
<span>{engine.stats.avgTime}</span>
</div>
</button>
))}
</div>
</ScrollArea>
</div>
{/* Main Flow Area */}
<div className="flex-1 relative">
<div className="absolute top-4 left-4 z-10 bg-background/80 backdrop-blur p-2 rounded-lg border shadow-sm">
<h3 className="font-semibold flex items-center gap-2">
{selectedEngine.name}
{selectedEngine.isValid ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<AlertCircle className="h-4 w-4 text-amber-500" />
)}
</h3>
<p className="text-xs text-muted-foreground max-w-md">{selectedEngine.description}</p>
</div>
<div className="absolute top-4 right-4 z-10">
<Button size="sm" className="shadow-lg">
<Play className="h-4 w-4 mr-2" />
Run Pipeline
</Button>
</div>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
fitView
attributionPosition="bottom-right"
>
<Background color="#999" gap={16} size={1} />
<Controls />
</ReactFlow>
</div>
</div>
)
}

View File

@@ -0,0 +1,283 @@
"use client"
import React, { useState } from "react"
import { MOCK_ENGINES, FEATURE_LIST } from "../data"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
Plus, IconDotsVertical, Clock,
CheckCircle2, AlertTriangle, IconSearch, IconSettings,
IconChevronRight, IconDatabase, IconServer
} from "@/components/icons"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import Link from "next/link"
import { cn } from "@/lib/utils"
export default function HybridDemo() {
const [selectedId, setSelectedId] = useState<number>(MOCK_ENGINES[0].id)
const [search, setSearch] = useState("")
const filteredEngines = MOCK_ENGINES.filter(e =>
e.name.toLowerCase().includes(search.toLowerCase())
)
const selectedEngine = MOCK_ENGINES.find(e => e.id === selectedId) || MOCK_ENGINES[0]
return (
<div className="flex flex-col h-full bg-background overflow-hidden">
{/* Top Navigation / Header */}
<header className="border-b h-14 flex items-center justify-between px-4 shrink-0 bg-muted/5">
<div className="flex items-center gap-4">
<Link href="../demo" className="text-muted-foreground hover:text-foreground text-sm flex items-center gap-1">
<IconChevronRight className="rotate-180 h-4 w-4" /> Back
</Link>
<Separator orientation="vertical" className="h-6" />
<h1 className="font-semibold text-sm">Scan Engine Management</h1>
</div>
<div className="flex items-center gap-2">
<Button variant="default" size="sm" className="h-8">
<Plus className="h-3.5 w-3.5 mr-1.5" /> New Engine
</Button>
</div>
</header>
<div className="flex flex-1 overflow-hidden">
{/* Left Sidebar / Menu List */}
<aside className="w-[300px] flex flex-col border-r bg-muted/10">
<div className="p-3">
<div className="relative">
<IconSearch className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search engines..."
className="pl-8 h-9 text-sm"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<ScrollArea className="flex-1">
<div className="p-3 space-y-1">
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Presets
</div>
{filteredEngines.filter(e => e.type === 'preset').map(engine => (
<button
key={engine.id}
onClick={() => setSelectedId(engine.id)}
className={cn(
"w-full text-left px-3 py-2 rounded-md text-sm transition-colors flex items-center gap-3",
selectedId === engine.id
? "bg-primary/10 text-primary font-medium"
: "hover:bg-muted text-muted-foreground hover:text-foreground"
)}
>
<IconDatabase className="h-4 w-4 opacity-70" />
<span className="truncate flex-1">{engine.name}</span>
{selectedId === engine.id && <div className="w-1 h-1 rounded-full bg-primary" />}
</button>
))}
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider mt-4">
My Engines
</div>
{filteredEngines.filter(e => e.type === 'user').map(engine => (
<button
key={engine.id}
onClick={() => setSelectedId(engine.id)}
className={cn(
"w-full text-left px-3 py-2 rounded-md text-sm transition-colors flex items-center gap-3",
selectedId === engine.id
? "bg-primary/10 text-primary font-medium"
: "hover:bg-muted text-muted-foreground hover:text-foreground"
)}
>
<IconServer className="h-4 w-4 opacity-70" />
<span className="truncate flex-1">{engine.name}</span>
{!engine.isValid && <AlertTriangle className="h-3.5 w-3.5 text-amber-500" />}
</button>
))}
</div>
</ScrollArea>
<div className="p-3 border-t bg-muted/5">
<div className="flex items-center justify-between text-xs text-muted-foreground px-2">
<span>{filteredEngines.length} engines total</span>
<IconSettings className="h-3.5 w-3.5 cursor-pointer hover:text-foreground" />
</div>
</div>
</aside>
{/* Right Content / Detail View */}
<main className="flex-1 flex flex-col min-w-0 bg-background">
{selectedEngine ? (
<>
<div className="px-8 py-6 border-b">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-3 mb-2">
<h2 className="text-2xl font-bold tracking-tight">{selectedEngine.name}</h2>
{selectedEngine.type === 'preset' ? (
<Badge variant="secondary">System Preset</Badge>
) : (
<Badge variant="outline">Custom Engine</Badge>
)}
{selectedEngine.isValid ? (
<Badge className="bg-green-500/15 text-green-600 hover:bg-green-500/25 border-green-200 shadow-none">
<CheckCircle2 className="h-3 w-3 mr-1" /> Ready
</Badge>
) : (
<Badge variant="destructive" className="bg-amber-500/15 text-amber-600 hover:bg-amber-500/25 border-amber-200 shadow-none">
<AlertTriangle className="h-3 w-3 mr-1" /> Config Error
</Badge>
)}
</div>
<p className="text-muted-foreground max-w-2xl">
{selectedEngine.description}
</p>
</div>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<IconDotsVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>Duplicate</DropdownMenuItem>
<DropdownMenuItem>Export Configuration</DropdownMenuItem>
<DropdownMenuItem className="text-destructive">Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button>Edit Configuration</Button>
</div>
</div>
<div className="flex items-center gap-6 mt-6 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4" />
<span>Updated {new Date(selectedEngine.updatedAt).toLocaleDateString()}</span>
</div>
<div className="flex items-center gap-2">
<IconSettings className="h-4 w-4" />
<span>v1.2.0</span>
</div>
</div>
</div>
<div className="flex-1 min-h-0">
<Tabs defaultValue="overview" className="h-full flex flex-col">
<div className="px-8 pt-4 border-b">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="configuration">Configuration (YAML)</TabsTrigger>
</TabsList>
</div>
<TabsContent value="overview" className="flex-1 min-h-0 m-0">
<ScrollArea className="h-full">
<div className="p-8 max-w-5xl space-y-8">
<section>
<h3 className="text-lg font-semibold mb-4">Enabled Capabilities</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{selectedEngine.features.map(featureKey => {
const feature = FEATURE_LIST.find(f => f.key === featureKey)
return (
<div key={featureKey} className="flex items-start gap-3 p-4 rounded-lg border bg-card text-card-foreground shadow-sm">
<div className="text-2xl">{feature?.icon}</div>
<div>
<div className="font-medium">{feature?.label}</div>
<div className="text-xs text-muted-foreground mt-1">
Active module enabled
</div>
</div>
</div>
)
})}
</div>
</section>
<Separator />
<section>
<h3 className="text-lg font-semibold mb-4">Description & Metadata</h3>
<div className="bg-muted/30 rounded-lg p-6 border">
<h4 className="text-sm font-medium mb-2 text-muted-foreground">Detailed Description</h4>
<p className="text-sm leading-relaxed max-w-3xl mb-6">
{selectedEngine.description || "No description provided."}
This engine is configured to perform specific security checks based on the enabled modules above.
</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">Type</div>
<div className="text-sm">{selectedEngine.type === 'preset' ? 'System Preset' : 'User Custom'}</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">Created At</div>
<div className="text-sm">2023-10-01</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">Last Modifier</div>
<div className="text-sm">System Admin</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">Version</div>
<div className="text-sm">1.2.0</div>
</div>
</div>
</div>
</section>
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="configuration" className="flex-1 min-h-0 m-0">
<div className="h-full bg-muted/20 p-0 relative group">
<div className="absolute top-4 right-4 z-10 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="secondary" size="sm">Copy YAML</Button>
</div>
<ScrollArea className="h-full">
<div className="p-6">
<pre className="font-mono text-sm bg-background p-6 rounded-lg border shadow-sm">
{`# ${selectedEngine.name} Configuration
version: 1.0.0
enabled_features:
${selectedEngine.features.map(f => ` - ${f}`).join('\n')}
# Advanced Settings
concurrency: 5
timeout: 30s
retries: 3
user_agent: "LunaFox-Scanner/1.0"
exclude_paths:
- "/logout"
- "/admin"
`}</pre>
</div>
</ScrollArea>
</div>
</TabsContent>
</Tabs>
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Select an engine to view details
</div>
)}
</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,76 @@
"use client"
import Link from "next/link"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { ArrowRight, LayoutGrid, IconListDetails, GitBranch, IconLayoutColumns } from "@/components/icons"
export default function DemoIndexPage() {
const demos = [
{
title: "Design A: Card Grid",
description: "Visual-first layout focusing on quick identification and status overview. Best for small to medium number of engines.",
path: "scan/engine/demo/card-grid",
icon: <LayoutGrid className="h-8 w-8 text-primary" />,
color: "bg-blue-500/10"
},
{
title: "Design B: Data Table",
description: "Information-dense layout for managing many engines with sorting and filtering capabilities. Best for power users.",
path: "scan/engine/demo/table-list",
icon: <IconListDetails className="h-8 w-8 text-primary" />,
color: "bg-green-500/10"
},
{
title: "Design C: Hybrid View",
description: "Balanced approach with a side menu for navigation and detailed view for configuration. Offers best context retention.",
path: "scan/engine/demo/hybrid",
icon: <IconLayoutColumns className="h-8 w-8 text-primary" />,
color: "bg-orange-500/10"
},
{
title: "Design D: Feature Flow",
description: "Conceptual layout visualizing engines as processing pipelines. Best for understanding complex scan logic.",
path: "scan/engine/demo/feature-flow",
icon: <GitBranch className="h-8 w-8 text-primary" />,
color: "bg-purple-500/10"
}
]
return (
<div className="container mx-auto py-10 max-w-5xl">
<div className="mb-10 text-center">
<h1 className="text-3xl font-bold tracking-tight mb-3">Scan Engine Design Concepts</h1>
<p className="text-muted-foreground max-w-2xl mx-auto">
Exploring different UX patterns for managing scan engine configurations.
Select a demo below to interact with the design prototype.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{demos.map((demo) => (
<Link href={`/${demo.path}`} key={demo.path} className="block group h-full">
<Card className="h-full transition-all duration-300 hover:shadow-lg hover:-translate-y-1 border-muted hover:border-primary/50">
<CardHeader>
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center mb-4 ${demo.color}`}>
{demo.icon}
</div>
<CardTitle className="group-hover:text-primary transition-colors">
{demo.title}
</CardTitle>
</CardHeader>
<CardContent>
<CardDescription className="mb-6 min-h-[80px]">
{demo.description}
</CardDescription>
<div className="flex items-center text-sm font-medium text-primary">
View Demo <ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
</div>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,176 @@
"use client"
import React, { useState } from "react"
import { MOCK_ENGINES, FEATURE_LIST } from "../data"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Search, Plus, Filter, MoreHorizontal,
Download, Upload, Check, AlertTriangle
} from "@/components/icons"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import Link from "next/link"
export default function TableListDemo() {
const [search, setSearch] = useState("")
const filteredEngines = MOCK_ENGINES.filter(e =>
e.name.toLowerCase().includes(search.toLowerCase()) ||
e.type.includes(search.toLowerCase())
)
return (
<div className="flex flex-col h-full bg-background">
<div className="border-b px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="../demo" className="text-muted-foreground hover:text-foreground"> Back</Link>
<h1 className="text-xl font-semibold">Scan Engines (Table View)</h1>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm">
<Upload className="h-4 w-4 mr-2" />
Import
</Button>
<Button variant="outline" size="sm">
<Download className="h-4 w-4 mr-2" />
Export
</Button>
<Button size="sm">
<Plus className="h-4 w-4 mr-2" />
Add Engine
</Button>
</div>
</div>
<div className="p-6 space-y-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2 flex-1 max-w-sm">
<div className="relative w-full">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Filter engines..."
className="pl-9 h-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="h-9 border-dashed">
<Filter className="h-4 w-4 mr-2" />
Type
</Button>
<Button variant="outline" size="sm" className="h-9 border-dashed">
<Filter className="h-4 w-4 mr-2" />
Status
</Button>
</div>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[300px]">Engine Name</TableHead>
<TableHead>Features</TableHead>
<TableHead className="w-[100px]">Type</TableHead>
<TableHead className="w-[100px]">Status</TableHead>
<TableHead className="w-[150px]">Last Updated</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredEngines.map((engine) => (
<TableRow key={engine.id}>
<TableCell className="font-medium">
<div className="flex flex-col">
<span>{engine.name}</span>
<span className="text-xs text-muted-foreground truncate max-w-[280px]">
{engine.description}
</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1 flex-wrap">
{engine.features.slice(0, 3).map(f => (
<Badge key={f} variant="secondary" className="text-[10px] h-5 px-1 font-normal">
{FEATURE_LIST.find(item => item.key === f)?.label || f}
</Badge>
))}
{engine.features.length > 3 && (
<Badge variant="outline" className="text-[10px] h-5 px-1 text-muted-foreground">
+{engine.features.length - 3}
</Badge>
)}
</div>
</TableCell>
<TableCell>
{engine.type === 'preset' ? (
<Badge variant="secondary" className="font-normal">Preset</Badge>
) : (
<Badge variant="outline" className="font-normal">Custom</Badge>
)}
</TableCell>
<TableCell>
{engine.isValid ? (
<div className="flex items-center text-xs text-green-600 dark:text-green-500 font-medium">
<Check className="h-3.5 w-3.5 mr-1" />
Valid
</div>
) : (
<div className="flex items-center text-xs text-amber-600 dark:text-amber-500 font-medium">
<AlertTriangle className="h-3.5 w-3.5 mr-1" />
Issue
</div>
)}
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{new Date(engine.updatedAt).toLocaleDateString()}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem>Edit engine</DropdownMenuItem>
<DropdownMenuItem>View details</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive">Delete engine</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="text-xs text-muted-foreground text-center">
Showing {filteredEngines.length} engines
</div>
</div>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More