mirror of
https://github.com/yyhuni/xingrin.git
synced 2026-02-05 14:13:25 +08:00
Compare commits
22 Commits
001-websoc
...
002-server
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
872c118acf | ||
|
|
8afbd4fee1 | ||
|
|
6301db1526 | ||
|
|
ebeb239a55 | ||
|
|
a13e84df82 | ||
|
|
00da1d231c | ||
|
|
e6cd5914fd | ||
|
|
d08b4c03c5 | ||
|
|
50c907084a | ||
|
|
3a6d8775a4 | ||
|
|
7fd832ce22 | ||
|
|
e76ecaac15 | ||
|
|
08e6c7fbe3 | ||
|
|
5adb239547 | ||
|
|
896ae7743d | ||
|
|
d5c363294b | ||
|
|
4734f7a576 | ||
|
|
46b1d5a1d1 | ||
|
|
66fa60c415 | ||
|
|
3d54d26c7e | ||
|
|
b4a289b198 | ||
|
|
b727b2d001 |
8
.github/workflows/check-generated-files.yml
vendored
8
.github/workflows/check-generated-files.yml
vendored
@@ -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
16
.github/workflows/ci.yml
vendored
Normal 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
30
.github/workflows/test.yml
vendored
Normal 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
13
.gitignore
vendored
@@ -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
|
||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"typescript.autoClosingTags": false,
|
||||
"kiroAgent.configureMCP": "Enabled"
|
||||
}
|
||||
13
agent/.air.toml
Normal file
13
agent/.air.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
root = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
cmd = "go build -o ./tmp/agent ./cmd/agent"
|
||||
bin = "./tmp/agent"
|
||||
delay = 1000
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
exclude_dir = ["tmp", "vendor", ".git"]
|
||||
exclude_regex = ["_test\\.go"]
|
||||
|
||||
[log]
|
||||
time = true
|
||||
41
agent/Dockerfile
Normal file
41
agent/Dockerfile
Normal file
@@ -0,0 +1,41 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ============================================
|
||||
# Go Agent - build
|
||||
# ============================================
|
||||
FROM golang:1.25.6 AS builder
|
||||
|
||||
ARG GO111MODULE=on
|
||||
ARG GOPROXY=https://goproxy.cn,direct
|
||||
|
||||
ENV GO111MODULE=$GO111MODULE
|
||||
ENV GOPROXY=$GOPROXY
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Cache dependencies
|
||||
COPY agent/go.mod agent/go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source
|
||||
COPY agent ./agent
|
||||
|
||||
WORKDIR /src/agent
|
||||
|
||||
# Build (static where possible)
|
||||
RUN CGO_ENABLED=0 go build -o /out/agent ./cmd/agent
|
||||
|
||||
# ============================================
|
||||
# Go Agent - runtime
|
||||
# ============================================
|
||||
FROM debian:bookworm-20260112-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /out/agent /usr/local/bin/agent
|
||||
|
||||
CMD ["agent"]
|
||||
36
agent/Makefile
Normal file
36
agent/Makefile
Normal 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
37
agent/cmd/agent/main.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/yyhuni/lunafox/agent/internal/app"
|
||||
"github.com/yyhuni/lunafox/agent/internal/config"
|
||||
"github.com/yyhuni/lunafox/agent/internal/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := logger.Init(os.Getenv("LOG_LEVEL")); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "logger init failed: %v\n", err)
|
||||
}
|
||||
defer logger.Sync()
|
||||
|
||||
cfg, err := config.Load(os.Args[1:])
|
||||
if err != nil {
|
||||
logger.Log.Fatal("failed to load config", zap.Error(err))
|
||||
}
|
||||
wsURL, err := config.BuildWebSocketURL(cfg.ServerURL)
|
||||
if err != nil {
|
||||
logger.Log.Fatal("invalid server URL", zap.Error(err))
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if err := app.Run(ctx, *cfg, wsURL); err != nil {
|
||||
logger.Log.Fatal("agent stopped", zap.Error(err))
|
||||
}
|
||||
}
|
||||
20
agent/go.mod
20
agent/go.mod
@@ -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
|
||||
)
|
||||
|
||||
83
agent/go.sum
83
agent/go.sum
@@ -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
139
agent/internal/app/agent.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/yyhuni/lunafox/agent/internal/config"
|
||||
"github.com/yyhuni/lunafox/agent/internal/docker"
|
||||
"github.com/yyhuni/lunafox/agent/internal/domain"
|
||||
"github.com/yyhuni/lunafox/agent/internal/health"
|
||||
"github.com/yyhuni/lunafox/agent/internal/logger"
|
||||
"github.com/yyhuni/lunafox/agent/internal/metrics"
|
||||
"github.com/yyhuni/lunafox/agent/internal/protocol"
|
||||
"github.com/yyhuni/lunafox/agent/internal/task"
|
||||
"github.com/yyhuni/lunafox/agent/internal/update"
|
||||
agentws "github.com/yyhuni/lunafox/agent/internal/websocket"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func Run(ctx context.Context, cfg config.Config, wsURL string) error {
|
||||
configUpdater := config.NewUpdater(cfg)
|
||||
|
||||
version := cfg.AgentVersion
|
||||
hostname := os.Getenv("AGENT_HOSTNAME")
|
||||
if hostname == "" {
|
||||
var err error
|
||||
hostname, err = os.Hostname()
|
||||
if err != nil || hostname == "" {
|
||||
hostname = "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
logger.Log.Info("agent starting",
|
||||
zap.String("version", version),
|
||||
zap.String("hostname", hostname),
|
||||
zap.String("server", cfg.ServerURL),
|
||||
zap.String("ws", wsURL),
|
||||
zap.Int("maxTasks", cfg.MaxTasks),
|
||||
zap.Int("cpuThreshold", cfg.CPUThreshold),
|
||||
zap.Int("memThreshold", cfg.MemThreshold),
|
||||
zap.Int("diskThreshold", cfg.DiskThreshold),
|
||||
)
|
||||
|
||||
client := agentws.NewClient(wsURL, cfg.APIKey)
|
||||
collector := metrics.NewCollector()
|
||||
healthManager := health.NewManager()
|
||||
taskCounter := &task.Counter{}
|
||||
heartbeat := agentws.NewHeartbeatSender(client, collector, healthManager, version, hostname, taskCounter.Count)
|
||||
|
||||
taskClient := task.NewClient(cfg.ServerURL, cfg.APIKey)
|
||||
puller := task.NewPuller(taskClient, collector, taskCounter, cfg.MaxTasks, cfg.CPUThreshold, cfg.MemThreshold, cfg.DiskThreshold)
|
||||
|
||||
taskQueue := make(chan *domain.Task, cfg.MaxTasks)
|
||||
puller.SetOnTask(func(t *domain.Task) {
|
||||
logger.Log.Info("task received",
|
||||
zap.Int("taskId", t.ID),
|
||||
zap.Int("scanId", t.ScanID),
|
||||
zap.String("workflow", t.WorkflowName),
|
||||
zap.Int("stage", t.Stage),
|
||||
zap.String("target", t.TargetName),
|
||||
)
|
||||
taskQueue <- t
|
||||
})
|
||||
|
||||
dockerClient, err := docker.NewClient()
|
||||
if err != nil {
|
||||
logger.Log.Warn("docker client unavailable", zap.Error(err))
|
||||
} else {
|
||||
logger.Log.Info("docker client ready")
|
||||
}
|
||||
|
||||
workerToken := os.Getenv("WORKER_TOKEN")
|
||||
if workerToken == "" {
|
||||
return errors.New("WORKER_TOKEN environment variable is required")
|
||||
}
|
||||
logger.Log.Info("worker token loaded")
|
||||
|
||||
executor := task.NewExecutor(dockerClient, taskClient, taskCounter, cfg.ServerURL, workerToken, version)
|
||||
defer func() {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := executor.Shutdown(shutdownCtx); err != nil && !errors.Is(err, context.DeadlineExceeded) {
|
||||
logger.Log.Error("executor shutdown error", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
updater := update.NewUpdater(dockerClient, healthManager, puller, executor, configUpdater, cfg.APIKey, workerToken)
|
||||
|
||||
handler := agentws.NewHandler()
|
||||
handler.OnTaskAvailable(puller.NotifyTaskAvailable)
|
||||
handler.OnTaskCancel(func(taskID int) {
|
||||
logger.Log.Info("task cancel requested", zap.Int("taskId", taskID))
|
||||
executor.MarkCancelled(taskID)
|
||||
executor.CancelTask(taskID)
|
||||
})
|
||||
handler.OnConfigUpdate(func(payload protocol.ConfigUpdatePayload) {
|
||||
logger.Log.Info("config update received",
|
||||
zap.String("maxTasks", formatOptionalInt(payload.MaxTasks)),
|
||||
zap.String("cpuThreshold", formatOptionalInt(payload.CPUThreshold)),
|
||||
zap.String("memThreshold", formatOptionalInt(payload.MemThreshold)),
|
||||
zap.String("diskThreshold", formatOptionalInt(payload.DiskThreshold)),
|
||||
)
|
||||
cfgUpdate := config.Update{
|
||||
MaxTasks: payload.MaxTasks,
|
||||
CPUThreshold: payload.CPUThreshold,
|
||||
MemThreshold: payload.MemThreshold,
|
||||
DiskThreshold: payload.DiskThreshold,
|
||||
}
|
||||
configUpdater.Apply(cfgUpdate)
|
||||
puller.UpdateConfig(cfgUpdate.MaxTasks, cfgUpdate.CPUThreshold, cfgUpdate.MemThreshold, cfgUpdate.DiskThreshold)
|
||||
})
|
||||
handler.OnUpdateRequired(updater.HandleUpdateRequired)
|
||||
client.SetOnMessage(handler.Handle)
|
||||
|
||||
logger.Log.Info("starting heartbeat sender")
|
||||
go heartbeat.Start(ctx)
|
||||
logger.Log.Info("starting task puller")
|
||||
go func() {
|
||||
_ = puller.Run(ctx)
|
||||
}()
|
||||
logger.Log.Info("starting task executor")
|
||||
go executor.Start(ctx, taskQueue)
|
||||
|
||||
logger.Log.Info("connecting to server websocket")
|
||||
if err := client.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatOptionalInt(value *int) string {
|
||||
if value == nil {
|
||||
return "nil"
|
||||
}
|
||||
return strconv.Itoa(*value)
|
||||
}
|
||||
53
agent/internal/config/config.go
Normal file
53
agent/internal/config/config.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Config represents runtime settings for the agent.
|
||||
type Config struct {
|
||||
ServerURL string
|
||||
APIKey string
|
||||
AgentVersion string
|
||||
MaxTasks int
|
||||
CPUThreshold int
|
||||
MemThreshold int
|
||||
DiskThreshold int
|
||||
}
|
||||
|
||||
// Validate ensures config values are usable.
|
||||
func (c *Config) Validate() error {
|
||||
if c.ServerURL == "" {
|
||||
return errors.New("server URL is required")
|
||||
}
|
||||
if c.APIKey == "" {
|
||||
return errors.New("api key is required")
|
||||
}
|
||||
if c.AgentVersion == "" {
|
||||
return errors.New("AGENT_VERSION environment variable is required")
|
||||
}
|
||||
if c.MaxTasks < 1 {
|
||||
return errors.New("max tasks must be at least 1")
|
||||
}
|
||||
if err := validatePercent("cpu threshold", c.CPUThreshold); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validatePercent("mem threshold", c.MemThreshold); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validatePercent("disk threshold", c.DiskThreshold); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := BuildWebSocketURL(c.ServerURL); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePercent(name string, value int) error {
|
||||
if value < 1 || value > 100 {
|
||||
return fmt.Errorf("%s must be between 1 and 100", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
87
agent/internal/config/loader.go
Normal file
87
agent/internal/config/loader.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMaxTasks = 5
|
||||
defaultCPUThreshold = 85
|
||||
defaultMemThreshold = 85
|
||||
defaultDiskThreshold = 90
|
||||
)
|
||||
|
||||
// Load parses configuration from environment variables and CLI flags.
|
||||
func Load(args []string) (*Config, error) {
|
||||
maxTasks, err := readEnvInt("MAX_TASKS", defaultMaxTasks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cpuThreshold, err := readEnvInt("CPU_THRESHOLD", defaultCPUThreshold)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
memThreshold, err := readEnvInt("MEM_THRESHOLD", defaultMemThreshold)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
diskThreshold, err := readEnvInt("DISK_THRESHOLD", defaultDiskThreshold)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
ServerURL: strings.TrimSpace(os.Getenv("SERVER_URL")),
|
||||
APIKey: strings.TrimSpace(os.Getenv("API_KEY")),
|
||||
AgentVersion: strings.TrimSpace(os.Getenv("AGENT_VERSION")),
|
||||
MaxTasks: maxTasks,
|
||||
CPUThreshold: cpuThreshold,
|
||||
MemThreshold: memThreshold,
|
||||
DiskThreshold: diskThreshold,
|
||||
}
|
||||
|
||||
fs := flag.NewFlagSet("agent", flag.ContinueOnError)
|
||||
serverURL := fs.String("server-url", cfg.ServerURL, "Server base URL (e.g. https://1.1.1.1:8080)")
|
||||
apiKey := fs.String("api-key", cfg.APIKey, "Agent API key")
|
||||
maxTasksFlag := fs.Int("max-tasks", cfg.MaxTasks, "Maximum concurrent tasks")
|
||||
cpuThresholdFlag := fs.Int("cpu-threshold", cfg.CPUThreshold, "CPU threshold percentage")
|
||||
memThresholdFlag := fs.Int("mem-threshold", cfg.MemThreshold, "Memory threshold percentage")
|
||||
diskThresholdFlag := fs.Int("disk-threshold", cfg.DiskThreshold, "Disk threshold percentage")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg.ServerURL = strings.TrimSpace(*serverURL)
|
||||
cfg.APIKey = strings.TrimSpace(*apiKey)
|
||||
cfg.MaxTasks = *maxTasksFlag
|
||||
cfg.CPUThreshold = *cpuThresholdFlag
|
||||
cfg.MemThreshold = *memThresholdFlag
|
||||
cfg.DiskThreshold = *diskThresholdFlag
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func readEnvInt(key string, fallback int) (int, error) {
|
||||
val, ok := os.LookupEnv(key)
|
||||
if !ok {
|
||||
return fallback, nil
|
||||
}
|
||||
val = strings.TrimSpace(val)
|
||||
if val == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
parsed, err := strconv.Atoi(val)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid %s: %w", key, err)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
75
agent/internal/config/loader_test.go
Normal file
75
agent/internal/config/loader_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadConfigFromEnvAndFlags(t *testing.T) {
|
||||
t.Setenv("SERVER_URL", "https://example.com")
|
||||
t.Setenv("API_KEY", "abc12345")
|
||||
t.Setenv("AGENT_VERSION", "v1.2.3")
|
||||
t.Setenv("MAX_TASKS", "5")
|
||||
t.Setenv("CPU_THRESHOLD", "80")
|
||||
t.Setenv("MEM_THRESHOLD", "81")
|
||||
t.Setenv("DISK_THRESHOLD", "82")
|
||||
|
||||
cfg, err := Load([]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("load failed: %v", err)
|
||||
}
|
||||
if cfg.ServerURL != "https://example.com" {
|
||||
t.Fatalf("expected server url from env")
|
||||
}
|
||||
if cfg.MaxTasks != 5 {
|
||||
t.Fatalf("expected max tasks from env")
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--server-url=https://override.example.com",
|
||||
"--api-key=deadbeef",
|
||||
"--max-tasks=9",
|
||||
"--cpu-threshold=70",
|
||||
"--mem-threshold=71",
|
||||
"--disk-threshold=72",
|
||||
}
|
||||
cfg, err = Load(args)
|
||||
if err != nil {
|
||||
t.Fatalf("load failed: %v", err)
|
||||
}
|
||||
if cfg.ServerURL != "https://override.example.com" {
|
||||
t.Fatalf("expected server url from args")
|
||||
}
|
||||
if cfg.APIKey != "deadbeef" {
|
||||
t.Fatalf("expected api key from args")
|
||||
}
|
||||
if cfg.MaxTasks != 9 {
|
||||
t.Fatalf("expected max tasks from args")
|
||||
}
|
||||
if cfg.CPUThreshold != 70 || cfg.MemThreshold != 71 || cfg.DiskThreshold != 72 {
|
||||
t.Fatalf("expected thresholds from args")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigMissingRequired(t *testing.T) {
|
||||
t.Setenv("SERVER_URL", "")
|
||||
t.Setenv("API_KEY", "")
|
||||
t.Setenv("AGENT_VERSION", "v1.2.3")
|
||||
|
||||
_, err := Load([]string{})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when required values missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigInvalidEnvValue(t *testing.T) {
|
||||
t.Setenv("SERVER_URL", "https://example.com")
|
||||
t.Setenv("API_KEY", "abc")
|
||||
t.Setenv("AGENT_VERSION", "v1.2.3")
|
||||
t.Setenv("MAX_TASKS", "nope")
|
||||
|
||||
_, err := Load([]string{})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for invalid MAX_TASKS")
|
||||
}
|
||||
}
|
||||
|
||||
49
agent/internal/config/updater.go
Normal file
49
agent/internal/config/updater.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/yyhuni/lunafox/agent/internal/domain"
|
||||
)
|
||||
|
||||
// Update holds optional configuration updates.
|
||||
type Update = domain.ConfigUpdate
|
||||
|
||||
// Updater manages runtime configuration changes.
|
||||
type Updater struct {
|
||||
mu sync.RWMutex
|
||||
cfg Config
|
||||
}
|
||||
|
||||
// NewUpdater creates an updater with initial config.
|
||||
func NewUpdater(cfg Config) *Updater {
|
||||
return &Updater{cfg: cfg}
|
||||
}
|
||||
|
||||
// Apply updates the configuration and returns the new snapshot.
|
||||
func (u *Updater) Apply(update Update) Config {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
|
||||
if update.MaxTasks != nil && *update.MaxTasks > 0 {
|
||||
u.cfg.MaxTasks = *update.MaxTasks
|
||||
}
|
||||
if update.CPUThreshold != nil && *update.CPUThreshold > 0 {
|
||||
u.cfg.CPUThreshold = *update.CPUThreshold
|
||||
}
|
||||
if update.MemThreshold != nil && *update.MemThreshold > 0 {
|
||||
u.cfg.MemThreshold = *update.MemThreshold
|
||||
}
|
||||
if update.DiskThreshold != nil && *update.DiskThreshold > 0 {
|
||||
u.cfg.DiskThreshold = *update.DiskThreshold
|
||||
}
|
||||
|
||||
return u.cfg
|
||||
}
|
||||
|
||||
// Snapshot returns a copy of current config.
|
||||
func (u *Updater) Snapshot() Config {
|
||||
u.mu.RLock()
|
||||
defer u.mu.RUnlock()
|
||||
return u.cfg
|
||||
}
|
||||
39
agent/internal/config/updater_test.go
Normal file
39
agent/internal/config/updater_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestUpdaterApplyAndSnapshot(t *testing.T) {
|
||||
cfg := Config{
|
||||
ServerURL: "https://example.com",
|
||||
APIKey: "key",
|
||||
MaxTasks: 2,
|
||||
CPUThreshold: 70,
|
||||
MemThreshold: 80,
|
||||
DiskThreshold: 90,
|
||||
}
|
||||
|
||||
updater := NewUpdater(cfg)
|
||||
snapshot := updater.Snapshot()
|
||||
if snapshot.MaxTasks != 2 || snapshot.CPUThreshold != 70 {
|
||||
t.Fatalf("unexpected snapshot values")
|
||||
}
|
||||
|
||||
invalid := 0
|
||||
update := Update{MaxTasks: &invalid, CPUThreshold: &invalid}
|
||||
snapshot = updater.Apply(update)
|
||||
if snapshot.MaxTasks != 2 || snapshot.CPUThreshold != 70 {
|
||||
t.Fatalf("expected invalid update to be ignored")
|
||||
}
|
||||
|
||||
maxTasks := 5
|
||||
cpu := 85
|
||||
mem := 60
|
||||
snapshot = updater.Apply(Update{
|
||||
MaxTasks: &maxTasks,
|
||||
CPUThreshold: &cpu,
|
||||
MemThreshold: &mem,
|
||||
})
|
||||
if snapshot.MaxTasks != 5 || snapshot.CPUThreshold != 85 || snapshot.MemThreshold != 60 {
|
||||
t.Fatalf("unexpected applied update")
|
||||
}
|
||||
}
|
||||
50
agent/internal/config/url.go
Normal file
50
agent/internal/config/url.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BuildWebSocketURL derives the agent WebSocket endpoint from the server URL.
|
||||
func BuildWebSocketURL(serverURL string) (string, error) {
|
||||
trimmed := strings.TrimSpace(serverURL)
|
||||
if trimmed == "" {
|
||||
return "", errors.New("server URL is required")
|
||||
}
|
||||
parsed, err := url.Parse(trimmed)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
switch strings.ToLower(parsed.Scheme) {
|
||||
case "http":
|
||||
parsed.Scheme = "ws"
|
||||
case "https":
|
||||
parsed.Scheme = "wss"
|
||||
case "ws", "wss":
|
||||
default:
|
||||
if parsed.Scheme == "" {
|
||||
return "", errors.New("server URL scheme is required")
|
||||
}
|
||||
return "", fmt.Errorf("unsupported server URL scheme: %s", parsed.Scheme)
|
||||
}
|
||||
|
||||
parsed.Path = buildWSPath(parsed.Path)
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func buildWSPath(path string) string {
|
||||
trimmed := strings.TrimRight(path, "/")
|
||||
if trimmed == "" {
|
||||
return "/api/agents/ws"
|
||||
}
|
||||
if strings.HasSuffix(trimmed, "/api") {
|
||||
return trimmed + "/agents/ws"
|
||||
}
|
||||
return trimmed + "/api/agents/ws"
|
||||
}
|
||||
38
agent/internal/config/url_test.go
Normal file
38
agent/internal/config/url_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildWebSocketURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"https://example.com", "wss://example.com/api/agents/ws"},
|
||||
{"http://example.com", "ws://example.com/api/agents/ws"},
|
||||
{"https://example.com/api", "wss://example.com/api/agents/ws"},
|
||||
{"https://example.com/base", "wss://example.com/base/api/agents/ws"},
|
||||
{"wss://example.com", "wss://example.com/api/agents/ws"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got, err := BuildWebSocketURL(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for %s: %v", tt.input, err)
|
||||
}
|
||||
if got != tt.expected {
|
||||
t.Fatalf("input %s expected %s got %s", tt.input, tt.expected, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWebSocketURLInvalid(t *testing.T) {
|
||||
if _, err := BuildWebSocketURL("example.com"); err == nil {
|
||||
t.Fatalf("expected error for missing scheme")
|
||||
}
|
||||
if _, err := BuildWebSocketURL(" "); err == nil {
|
||||
t.Fatalf("expected error for empty url")
|
||||
}
|
||||
if _, err := BuildWebSocketURL("ftp://example.com"); err == nil {
|
||||
t.Fatalf("expected error for unsupported scheme")
|
||||
}
|
||||
}
|
||||
23
agent/internal/docker/cleanup.go
Normal file
23
agent/internal/docker/cleanup.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
)
|
||||
|
||||
// Remove removes the container.
|
||||
func (c *Client) Remove(ctx context.Context, containerID string) error {
|
||||
return c.cli.ContainerRemove(ctx, containerID, container.RemoveOptions{
|
||||
Force: true,
|
||||
RemoveVolumes: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Stop stops a running container with a timeout.
|
||||
func (c *Client) Stop(ctx context.Context, containerID string) error {
|
||||
timeout := 10
|
||||
return c.cli.ContainerStop(ctx, containerID, container.StopOptions{
|
||||
Timeout: &timeout,
|
||||
})
|
||||
}
|
||||
46
agent/internal/docker/client.go
Normal file
46
agent/internal/docker/client.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
imagetypes "github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/docker/client"
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
)
|
||||
|
||||
// Client wraps the Docker SDK client.
|
||||
type Client struct {
|
||||
cli *client.Client
|
||||
}
|
||||
|
||||
// NewClient creates a Docker client using environment configuration.
|
||||
func NewClient() (*Client, error) {
|
||||
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Client{cli: cli}, nil
|
||||
}
|
||||
|
||||
// Close closes the Docker client.
|
||||
func (c *Client) Close() error {
|
||||
return c.cli.Close()
|
||||
}
|
||||
|
||||
// ImagePull pulls an image from the registry.
|
||||
func (c *Client) ImagePull(ctx context.Context, imageRef string) (io.ReadCloser, error) {
|
||||
return c.cli.ImagePull(ctx, imageRef, imagetypes.PullOptions{})
|
||||
}
|
||||
|
||||
// ContainerCreate creates a container.
|
||||
func (c *Client) ContainerCreate(ctx context.Context, config *container.Config, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig, platform *ocispec.Platform, name string) (container.CreateResponse, error) {
|
||||
return c.cli.ContainerCreate(ctx, config, hostConfig, networkingConfig, platform, name)
|
||||
}
|
||||
|
||||
// ContainerStart starts a container.
|
||||
func (c *Client) ContainerStart(ctx context.Context, containerID string, opts container.StartOptions) error {
|
||||
return c.cli.ContainerStart(ctx, containerID, opts)
|
||||
}
|
||||
49
agent/internal/docker/logs.go
Normal file
49
agent/internal/docker/logs.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
)
|
||||
|
||||
const (
|
||||
maxErrorBytes = 4096
|
||||
)
|
||||
|
||||
// TailLogs returns the last N lines of container logs, truncated to 4KB.
|
||||
func (c *Client) TailLogs(ctx context.Context, containerID string, lines int) (string, error) {
|
||||
reader, err := c.cli.ContainerLogs(ctx, containerID, container.LogsOptions{
|
||||
ShowStdout: true,
|
||||
ShowStderr: true,
|
||||
Timestamps: false,
|
||||
Tail: strconv.Itoa(lines),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
var buf bytes.Buffer
|
||||
if _, err := io.Copy(&buf, reader); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
out = strings.TrimSpace(out)
|
||||
if len(out) > maxErrorBytes {
|
||||
out = out[len(out)-maxErrorBytes:]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// TruncateErrorMessage clamps message length to 4KB.
|
||||
func TruncateErrorMessage(message string) string {
|
||||
if len(message) <= maxErrorBytes {
|
||||
return message
|
||||
}
|
||||
return message[:maxErrorBytes]
|
||||
}
|
||||
22
agent/internal/docker/logs_test.go
Normal file
22
agent/internal/docker/logs_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTruncateErrorMessage(t *testing.T) {
|
||||
short := "short message"
|
||||
if got := TruncateErrorMessage(short); got != short {
|
||||
t.Fatalf("expected message to stay unchanged")
|
||||
}
|
||||
|
||||
long := strings.Repeat("x", maxErrorBytes+10)
|
||||
got := TruncateErrorMessage(long)
|
||||
if len(got) != maxErrorBytes {
|
||||
t.Fatalf("expected length %d, got %d", maxErrorBytes, len(got))
|
||||
}
|
||||
if got != long[:maxErrorBytes] {
|
||||
t.Fatalf("unexpected truncation result")
|
||||
}
|
||||
}
|
||||
20
agent/internal/docker/monitor.go
Normal file
20
agent/internal/docker/monitor.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
)
|
||||
|
||||
// Wait waits for a container to stop and returns the exit code.
|
||||
func (c *Client) Wait(ctx context.Context, containerID string) (int64, error) {
|
||||
statusCh, errCh := c.cli.ContainerWait(ctx, containerID, container.WaitConditionNotRunning)
|
||||
select {
|
||||
case status := <-statusCh:
|
||||
return status.StatusCode, nil
|
||||
case err := <-errCh:
|
||||
return 0, err
|
||||
case <-ctx.Done():
|
||||
return 0, ctx.Err()
|
||||
}
|
||||
}
|
||||
76
agent/internal/docker/runner.go
Normal file
76
agent/internal/docker/runner.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/docker/api/types/strslice"
|
||||
"github.com/yyhuni/lunafox/agent/internal/domain"
|
||||
)
|
||||
|
||||
const workerImagePrefix = "yyhuni/lunafox-worker:"
|
||||
|
||||
// StartWorker starts a worker container for a task and returns the container ID.
|
||||
func (c *Client) StartWorker(ctx context.Context, t *domain.Task, serverURL, serverToken, agentVersion string) (string, error) {
|
||||
if t == nil {
|
||||
return "", fmt.Errorf("task is nil")
|
||||
}
|
||||
if err := os.MkdirAll(t.WorkspaceDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("prepare workspace: %w", err)
|
||||
}
|
||||
|
||||
image, err := resolveWorkerImage(agentVersion)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
env := buildWorkerEnv(t, serverURL, serverToken)
|
||||
|
||||
config := &container.Config{
|
||||
Image: image,
|
||||
Env: env,
|
||||
Cmd: strslice.StrSlice{},
|
||||
}
|
||||
|
||||
hostConfig := &container.HostConfig{
|
||||
Binds: []string{"/opt/lunafox:/opt/lunafox"},
|
||||
AutoRemove: false,
|
||||
OomScoreAdj: 500,
|
||||
}
|
||||
|
||||
resp, err := c.cli.ContainerCreate(ctx, config, hostConfig, &network.NetworkingConfig{}, nil, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := c.cli.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return resp.ID, nil
|
||||
}
|
||||
|
||||
func resolveWorkerImage(version string) (string, error) {
|
||||
version = strings.TrimSpace(version)
|
||||
if version == "" {
|
||||
return "", fmt.Errorf("worker version is required")
|
||||
}
|
||||
return workerImagePrefix + version, nil
|
||||
}
|
||||
|
||||
func buildWorkerEnv(t *domain.Task, serverURL, serverToken string) []string {
|
||||
return []string{
|
||||
fmt.Sprintf("SERVER_URL=%s", serverURL),
|
||||
fmt.Sprintf("SERVER_TOKEN=%s", serverToken),
|
||||
fmt.Sprintf("SCAN_ID=%d", t.ScanID),
|
||||
fmt.Sprintf("TARGET_ID=%d", t.TargetID),
|
||||
fmt.Sprintf("TARGET_NAME=%s", t.TargetName),
|
||||
fmt.Sprintf("TARGET_TYPE=%s", t.TargetType),
|
||||
fmt.Sprintf("WORKFLOW_NAME=%s", t.WorkflowName),
|
||||
fmt.Sprintf("WORKSPACE_DIR=%s", t.WorkspaceDir),
|
||||
fmt.Sprintf("CONFIG=%s", t.Config),
|
||||
}
|
||||
}
|
||||
50
agent/internal/docker/runner_test.go
Normal file
50
agent/internal/docker/runner_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/yyhuni/lunafox/agent/internal/domain"
|
||||
)
|
||||
|
||||
func TestResolveWorkerImage(t *testing.T) {
|
||||
if _, err := resolveWorkerImage(""); err == nil {
|
||||
t.Fatalf("expected error for empty version")
|
||||
}
|
||||
if got, err := resolveWorkerImage("v1.2.3"); err != nil || got != workerImagePrefix+"v1.2.3" {
|
||||
t.Fatalf("expected version image, got %s, err: %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWorkerEnv(t *testing.T) {
|
||||
spec := &domain.Task{
|
||||
ScanID: 1,
|
||||
TargetID: 2,
|
||||
TargetName: "example.com",
|
||||
TargetType: "domain",
|
||||
WorkflowName: "subdomain_discovery",
|
||||
WorkspaceDir: "/opt/lunafox/results",
|
||||
Config: "config-yaml",
|
||||
}
|
||||
|
||||
env := buildWorkerEnv(spec, "https://server", "token")
|
||||
expected := []string{
|
||||
"SERVER_URL=https://server",
|
||||
"SERVER_TOKEN=token",
|
||||
"SCAN_ID=1",
|
||||
"TARGET_ID=2",
|
||||
"TARGET_NAME=example.com",
|
||||
"TARGET_TYPE=domain",
|
||||
"WORKFLOW_NAME=subdomain_discovery",
|
||||
"WORKSPACE_DIR=/opt/lunafox/results",
|
||||
"CONFIG=config-yaml",
|
||||
}
|
||||
|
||||
if len(env) != len(expected) {
|
||||
t.Fatalf("expected %d env entries, got %d", len(expected), len(env))
|
||||
}
|
||||
for i, item := range expected {
|
||||
if env[i] != item {
|
||||
t.Fatalf("expected env[%d]=%s got %s", i, item, env[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
8
agent/internal/domain/config.go
Normal file
8
agent/internal/domain/config.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package domain
|
||||
|
||||
type ConfigUpdate struct {
|
||||
MaxTasks *int `json:"maxTasks"`
|
||||
CPUThreshold *int `json:"cpuThreshold"`
|
||||
MemThreshold *int `json:"memThreshold"`
|
||||
DiskThreshold *int `json:"diskThreshold"`
|
||||
}
|
||||
10
agent/internal/domain/health.go
Normal file
10
agent/internal/domain/health.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type HealthStatus struct {
|
||||
State string `json:"state"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Since *time.Time `json:"since,omitempty"`
|
||||
}
|
||||
13
agent/internal/domain/task.go
Normal file
13
agent/internal/domain/task.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package domain
|
||||
|
||||
type Task struct {
|
||||
ID int `json:"taskId"`
|
||||
ScanID int `json:"scanId"`
|
||||
Stage int `json:"stage"`
|
||||
WorkflowName string `json:"workflowName"`
|
||||
TargetID int `json:"targetId"`
|
||||
TargetName string `json:"targetName"`
|
||||
TargetType string `json:"targetType"`
|
||||
WorkspaceDir string `json:"workspaceDir"`
|
||||
Config string `json:"config"`
|
||||
}
|
||||
6
agent/internal/domain/update.go
Normal file
6
agent/internal/domain/update.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package domain
|
||||
|
||||
type UpdateRequiredPayload struct {
|
||||
Version string `json:"version"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
51
agent/internal/health/health.go
Normal file
51
agent/internal/health/health.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/yyhuni/lunafox/agent/internal/domain"
|
||||
)
|
||||
|
||||
// Status represents the agent health state reported in heartbeats.
|
||||
type Status = domain.HealthStatus
|
||||
|
||||
// Manager stores current health status.
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
status Status
|
||||
}
|
||||
|
||||
// NewManager initializes the manager with ok status.
|
||||
func NewManager() *Manager {
|
||||
return &Manager{
|
||||
status: Status{State: "ok"},
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns a snapshot of current status.
|
||||
func (m *Manager) Get() Status {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.status
|
||||
}
|
||||
|
||||
// Set updates health status and timestamps transitions.
|
||||
func (m *Manager) Set(state, reason, message string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.status.State != state {
|
||||
now := time.Now().UTC()
|
||||
m.status.Since = &now
|
||||
}
|
||||
|
||||
m.status.State = state
|
||||
m.status.Reason = reason
|
||||
m.status.Message = message
|
||||
if state == "ok" {
|
||||
m.status.Since = nil
|
||||
m.status.Reason = ""
|
||||
m.status.Message = ""
|
||||
}
|
||||
}
|
||||
33
agent/internal/health/health_test.go
Normal file
33
agent/internal/health/health_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package health
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestManagerSetTransitions(t *testing.T) {
|
||||
mgr := NewManager()
|
||||
initial := mgr.Get()
|
||||
if initial.State != "ok" || initial.Since != nil {
|
||||
t.Fatalf("expected initial ok status")
|
||||
}
|
||||
|
||||
mgr.Set("paused", "update", "waiting")
|
||||
status := mgr.Get()
|
||||
if status.State != "paused" || status.Since == nil {
|
||||
t.Fatalf("expected paused state with timestamp")
|
||||
}
|
||||
prevSince := status.Since
|
||||
|
||||
mgr.Set("paused", "still", "waiting more")
|
||||
status = mgr.Get()
|
||||
if status.Since == nil || !status.Since.Equal(*prevSince) {
|
||||
t.Fatalf("expected unchanged since on same state")
|
||||
}
|
||||
if status.Reason != "still" || status.Message != "waiting more" {
|
||||
t.Fatalf("expected updated reason/message")
|
||||
}
|
||||
|
||||
mgr.Set("ok", "ignored", "ignored")
|
||||
status = mgr.Get()
|
||||
if status.State != "ok" || status.Since != nil || status.Reason != "" || status.Message != "" {
|
||||
t.Fatalf("expected ok reset to clear fields")
|
||||
}
|
||||
}
|
||||
50
agent/internal/logger/logger.go
Normal file
50
agent/internal/logger/logger.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
// Log is the shared agent logger. Defaults to a no-op logger until initialized.
|
||||
var Log = zap.NewNop()
|
||||
|
||||
// Init configures the logger using the provided level and ENV.
|
||||
func Init(level string) error {
|
||||
level = strings.TrimSpace(level)
|
||||
if level == "" {
|
||||
level = "info"
|
||||
}
|
||||
|
||||
var zapLevel zapcore.Level
|
||||
if err := zapLevel.UnmarshalText([]byte(level)); err != nil {
|
||||
zapLevel = zapcore.InfoLevel
|
||||
}
|
||||
|
||||
isDev := strings.EqualFold(os.Getenv("ENV"), "development")
|
||||
var config zap.Config
|
||||
if isDev {
|
||||
config = zap.NewDevelopmentConfig()
|
||||
config.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
|
||||
} else {
|
||||
config = zap.NewProductionConfig()
|
||||
}
|
||||
config.Level = zap.NewAtomicLevelAt(zapLevel)
|
||||
|
||||
logger, err := config.Build()
|
||||
if err != nil {
|
||||
Log = zap.NewNop()
|
||||
return err
|
||||
}
|
||||
Log = logger
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sync flushes any buffered log entries.
|
||||
func Sync() {
|
||||
if Log != nil {
|
||||
_ = Log.Sync()
|
||||
}
|
||||
}
|
||||
58
agent/internal/metrics/collector.go
Normal file
58
agent/internal/metrics/collector.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"github.com/shirou/gopsutil/v3/cpu"
|
||||
"github.com/shirou/gopsutil/v3/disk"
|
||||
"github.com/shirou/gopsutil/v3/mem"
|
||||
"github.com/yyhuni/lunafox/agent/internal/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Collector gathers system metrics.
|
||||
type Collector struct{}
|
||||
|
||||
// NewCollector creates a new Collector.
|
||||
func NewCollector() *Collector {
|
||||
return &Collector{}
|
||||
}
|
||||
|
||||
// Sample returns CPU, memory, and disk usage percentages.
|
||||
func (c *Collector) Sample() (float64, float64, float64) {
|
||||
cpuPercent, err := cpuUsagePercent()
|
||||
if err != nil {
|
||||
logger.Log.Warn("metrics: cpu percent error", zap.Error(err))
|
||||
}
|
||||
memPercent, err := memUsagePercent()
|
||||
if err != nil {
|
||||
logger.Log.Warn("metrics: mem percent error", zap.Error(err))
|
||||
}
|
||||
diskPercent, err := diskUsagePercent("/")
|
||||
if err != nil {
|
||||
logger.Log.Warn("metrics: disk percent error", zap.Error(err))
|
||||
}
|
||||
return cpuPercent, memPercent, diskPercent
|
||||
}
|
||||
|
||||
func cpuUsagePercent() (float64, error) {
|
||||
values, err := cpu.Percent(0, false)
|
||||
if err != nil || len(values) == 0 {
|
||||
return 0, err
|
||||
}
|
||||
return values[0], nil
|
||||
}
|
||||
|
||||
func memUsagePercent() (float64, error) {
|
||||
info, err := mem.VirtualMemory()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return info.UsedPercent, nil
|
||||
}
|
||||
|
||||
func diskUsagePercent(path string) (float64, error) {
|
||||
info, err := disk.Usage(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return info.UsedPercent, nil
|
||||
}
|
||||
11
agent/internal/metrics/collector_test.go
Normal file
11
agent/internal/metrics/collector_test.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package metrics
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCollectorSample(t *testing.T) {
|
||||
c := NewCollector()
|
||||
cpu, mem, disk := c.Sample()
|
||||
if cpu < 0 || mem < 0 || disk < 0 {
|
||||
t.Fatalf("expected non-negative metrics")
|
||||
}
|
||||
}
|
||||
42
agent/internal/protocol/messages.go
Normal file
42
agent/internal/protocol/messages.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/yyhuni/lunafox/agent/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
MessageTypeHeartbeat = "heartbeat"
|
||||
MessageTypeTaskAvailable = "task_available"
|
||||
MessageTypeTaskCancel = "task_cancel"
|
||||
MessageTypeConfigUpdate = "config_update"
|
||||
MessageTypeUpdateRequired = "update_required"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
Type string `json:"type"`
|
||||
Payload interface{} `json:"payload"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
type HealthStatus = domain.HealthStatus
|
||||
|
||||
type HeartbeatPayload struct {
|
||||
CPU float64 `json:"cpu"`
|
||||
Mem float64 `json:"mem"`
|
||||
Disk float64 `json:"disk"`
|
||||
Tasks int `json:"tasks"`
|
||||
Version string `json:"version"`
|
||||
Hostname string `json:"hostname"`
|
||||
Uptime int64 `json:"uptime"`
|
||||
Health HealthStatus `json:"health"`
|
||||
}
|
||||
|
||||
type ConfigUpdatePayload = domain.ConfigUpdate
|
||||
|
||||
type UpdateRequiredPayload = domain.UpdateRequiredPayload
|
||||
|
||||
type TaskCancelPayload struct {
|
||||
TaskID int `json:"taskId"`
|
||||
}
|
||||
118
agent/internal/task/client.go
Normal file
118
agent/internal/task/client.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/yyhuni/lunafox/agent/internal/domain"
|
||||
)
|
||||
|
||||
// Client handles HTTP API requests to the server.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a new task client.
|
||||
func NewClient(serverURL, apiKey string) *Client {
|
||||
transport := http.DefaultTransport
|
||||
if base, ok := transport.(*http.Transport); ok {
|
||||
clone := base.Clone()
|
||||
clone.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
transport = clone
|
||||
}
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(serverURL, "/"),
|
||||
apiKey: apiKey,
|
||||
http: &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
Transport: transport,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// PullTask requests a task from the server. Returns nil when no task available.
|
||||
func (c *Client) PullTask(ctx context.Context) (*domain.Task, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/agent/tasks/pull", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("X-Agent-Key", c.apiKey)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNoContent {
|
||||
return nil, nil
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("pull task failed: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var task domain.Task
|
||||
if err := json.NewDecoder(resp.Body).Decode(&task); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
// UpdateStatus reports task status to the server with retry.
|
||||
func (c *Client) UpdateStatus(ctx context.Context, taskID int, status, errorMessage string) error {
|
||||
payload := map[string]string{
|
||||
"status": status,
|
||||
}
|
||||
if errorMessage != "" {
|
||||
payload["errorMessage"] = errorMessage
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
if attempt > 0 {
|
||||
backoff := time.Duration(5<<attempt) * time.Second // 5s, 10s, 20s
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, fmt.Sprintf("%s/api/agent/tasks/%d/status", c.baseURL, taskID), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Agent-Key", c.apiKey)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
lastErr = fmt.Errorf("update status failed: status %d", resp.StatusCode)
|
||||
|
||||
// Don't retry 4xx client errors (except 429)
|
||||
if resp.StatusCode >= 400 && resp.StatusCode < 500 && resp.StatusCode != 429 {
|
||||
return lastErr
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
187
agent/internal/task/client_test.go
Normal file
187
agent/internal/task/client_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/yyhuni/lunafox/agent/internal/domain"
|
||||
)
|
||||
|
||||
func TestClientPullTaskNoContent(t *testing.T) {
|
||||
client := &Client{
|
||||
baseURL: "http://example",
|
||||
apiKey: "key",
|
||||
http: &http.Client{
|
||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path != "/api/agent/tasks/pull" {
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
Header: http.Header{},
|
||||
}, nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
task, err := client.PullTask(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if task != nil {
|
||||
t.Fatalf("expected nil task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientPullTaskOK(t *testing.T) {
|
||||
client := &Client{
|
||||
baseURL: "http://example",
|
||||
apiKey: "key",
|
||||
http: &http.Client{
|
||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Header.Get("X-Agent-Key") == "" {
|
||||
t.Fatalf("missing api key header")
|
||||
}
|
||||
body, _ := json.Marshal(domain.Task{ID: 1})
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
Header: http.Header{},
|
||||
}, nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
task, err := client.PullTask(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if task == nil || task.ID != 1 {
|
||||
t.Fatalf("unexpected task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientUpdateStatus(t *testing.T) {
|
||||
client := &Client{
|
||||
baseURL: "http://example",
|
||||
apiKey: "key",
|
||||
http: &http.Client{
|
||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method != http.MethodPatch {
|
||||
t.Fatalf("expected PATCH")
|
||||
}
|
||||
if r.Header.Get("X-Agent-Key") == "" {
|
||||
t.Fatalf("missing api key header")
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
Header: http.Header{},
|
||||
}, nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
if err := client.UpdateStatus(context.Background(), 1, "completed", ""); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientPullTaskErrorStatus(t *testing.T) {
|
||||
client := &Client{
|
||||
baseURL: "http://example",
|
||||
apiKey: "key",
|
||||
http: &http.Client{
|
||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Body: io.NopCloser(strings.NewReader("bad")),
|
||||
Header: http.Header{},
|
||||
}, nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
if _, err := client.PullTask(context.Background()); err == nil {
|
||||
t.Fatalf("expected error for non-200 status")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientPullTaskBadJSON(t *testing.T) {
|
||||
client := &Client{
|
||||
baseURL: "http://example",
|
||||
apiKey: "key",
|
||||
http: &http.Client{
|
||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader("{bad json")),
|
||||
Header: http.Header{},
|
||||
}, nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
if _, err := client.PullTask(context.Background()); err == nil {
|
||||
t.Fatalf("expected error for invalid json")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientUpdateStatusIncludesErrorMessage(t *testing.T) {
|
||||
client := &Client{
|
||||
baseURL: "http://example",
|
||||
apiKey: "key",
|
||||
http: &http.Client{
|
||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
var payload map[string]string
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatalf("unmarshal body: %v", err)
|
||||
}
|
||||
if payload["status"] != "failed" {
|
||||
t.Fatalf("expected status failed")
|
||||
}
|
||||
if payload["errorMessage"] != "boom" {
|
||||
t.Fatalf("expected error message")
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
Header: http.Header{},
|
||||
}, nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
if err := client.UpdateStatus(context.Background(), 1, "failed", "boom"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientUpdateStatusErrorStatus(t *testing.T) {
|
||||
client := &Client{
|
||||
baseURL: "http://example",
|
||||
apiKey: "key",
|
||||
http: &http.Client{
|
||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
Header: http.Header{},
|
||||
}, nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
if err := client.UpdateStatus(context.Background(), 1, "completed", ""); err == nil {
|
||||
t.Fatalf("expected error for non-200 status")
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
return f(r)
|
||||
}
|
||||
23
agent/internal/task/counter.go
Normal file
23
agent/internal/task/counter.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package task
|
||||
|
||||
import "sync/atomic"
|
||||
|
||||
// Counter tracks running task count.
|
||||
type Counter struct {
|
||||
value int64
|
||||
}
|
||||
|
||||
// Inc increments the counter.
|
||||
func (c *Counter) Inc() {
|
||||
atomic.AddInt64(&c.value, 1)
|
||||
}
|
||||
|
||||
// Dec decrements the counter.
|
||||
func (c *Counter) Dec() {
|
||||
atomic.AddInt64(&c.value, -1)
|
||||
}
|
||||
|
||||
// Count returns current count.
|
||||
func (c *Counter) Count() int {
|
||||
return int(atomic.LoadInt64(&c.value))
|
||||
}
|
||||
18
agent/internal/task/counter_test.go
Normal file
18
agent/internal/task/counter_test.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package task
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCounterIncDec(t *testing.T) {
|
||||
var counter Counter
|
||||
|
||||
counter.Inc()
|
||||
counter.Inc()
|
||||
if got := counter.Count(); got != 2 {
|
||||
t.Fatalf("expected count 2, got %d", got)
|
||||
}
|
||||
|
||||
counter.Dec()
|
||||
if got := counter.Count(); got != 1 {
|
||||
t.Fatalf("expected count 1, got %d", got)
|
||||
}
|
||||
}
|
||||
258
agent/internal/task/executor.go
Normal file
258
agent/internal/task/executor.go
Normal file
@@ -0,0 +1,258 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/yyhuni/lunafox/agent/internal/docker"
|
||||
"github.com/yyhuni/lunafox/agent/internal/domain"
|
||||
)
|
||||
|
||||
const defaultMaxRuntime = 7 * 24 * time.Hour
|
||||
|
||||
// Executor runs tasks inside worker containers.
|
||||
type Executor struct {
|
||||
docker DockerRunner
|
||||
client statusReporter
|
||||
counter *Counter
|
||||
serverURL string
|
||||
workerToken string
|
||||
agentVersion string
|
||||
maxRuntime time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
running map[int]context.CancelFunc
|
||||
cancelMu sync.Mutex
|
||||
cancelled map[int]struct{}
|
||||
wg sync.WaitGroup
|
||||
|
||||
stopping atomic.Bool
|
||||
}
|
||||
|
||||
type statusReporter interface {
|
||||
UpdateStatus(ctx context.Context, taskID int, status, errorMessage string) error
|
||||
}
|
||||
|
||||
type DockerRunner interface {
|
||||
StartWorker(ctx context.Context, t *domain.Task, serverURL, serverToken, agentVersion string) (string, error)
|
||||
Wait(ctx context.Context, containerID string) (int64, error)
|
||||
Stop(ctx context.Context, containerID string) error
|
||||
Remove(ctx context.Context, containerID string) error
|
||||
TailLogs(ctx context.Context, containerID string, lines int) (string, error)
|
||||
}
|
||||
|
||||
// NewExecutor creates an Executor.
|
||||
func NewExecutor(dockerClient DockerRunner, taskClient statusReporter, counter *Counter, serverURL, workerToken, agentVersion string) *Executor {
|
||||
return &Executor{
|
||||
docker: dockerClient,
|
||||
client: taskClient,
|
||||
counter: counter,
|
||||
serverURL: serverURL,
|
||||
workerToken: workerToken,
|
||||
agentVersion: agentVersion,
|
||||
maxRuntime: defaultMaxRuntime,
|
||||
running: map[int]context.CancelFunc{},
|
||||
cancelled: map[int]struct{}{},
|
||||
}
|
||||
}
|
||||
|
||||
// Start processes tasks from the queue.
|
||||
func (e *Executor) Start(ctx context.Context, tasks <-chan *domain.Task) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case t, ok := <-tasks:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if t == nil {
|
||||
continue
|
||||
}
|
||||
if e.stopping.Load() {
|
||||
// During shutdown/update: drain the queue but don't start new work.
|
||||
continue
|
||||
}
|
||||
if e.isCancelled(t.ID) {
|
||||
e.reportStatus(ctx, t.ID, "cancelled", "")
|
||||
e.clearCancelled(t.ID)
|
||||
continue
|
||||
}
|
||||
go e.execute(ctx, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CancelTask requests cancellation of a running task.
|
||||
func (e *Executor) CancelTask(taskID int) {
|
||||
e.mu.Lock()
|
||||
cancel := e.running[taskID]
|
||||
e.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// MarkCancelled records a task as cancelled to prevent execution.
|
||||
func (e *Executor) MarkCancelled(taskID int) {
|
||||
e.cancelMu.Lock()
|
||||
e.cancelled[taskID] = struct{}{}
|
||||
e.cancelMu.Unlock()
|
||||
}
|
||||
|
||||
func (e *Executor) reportStatus(ctx context.Context, taskID int, status, errorMessage string) {
|
||||
if e.client == nil {
|
||||
return
|
||||
}
|
||||
statusCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
||||
defer cancel()
|
||||
_ = e.client.UpdateStatus(statusCtx, taskID, status, errorMessage)
|
||||
}
|
||||
|
||||
func (e *Executor) execute(ctx context.Context, t *domain.Task) {
|
||||
e.wg.Add(1)
|
||||
defer e.wg.Done()
|
||||
defer e.clearCancelled(t.ID)
|
||||
|
||||
if e.counter != nil {
|
||||
e.counter.Inc()
|
||||
defer e.counter.Dec()
|
||||
}
|
||||
|
||||
if e.workerToken == "" {
|
||||
e.reportStatus(ctx, t.ID, "failed", "missing worker token")
|
||||
return
|
||||
}
|
||||
if e.docker == nil {
|
||||
e.reportStatus(ctx, t.ID, "failed", "docker client unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithTimeout(ctx, e.maxRuntime)
|
||||
defer cancel()
|
||||
|
||||
containerID, err := e.docker.StartWorker(runCtx, t, e.serverURL, e.workerToken, e.agentVersion)
|
||||
if err != nil {
|
||||
message := docker.TruncateErrorMessage(err.Error())
|
||||
e.reportStatus(ctx, t.ID, "failed", message)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = e.docker.Remove(context.Background(), containerID)
|
||||
}()
|
||||
|
||||
e.trackCancel(t.ID, cancel)
|
||||
defer e.clearCancel(t.ID)
|
||||
|
||||
exitCode, waitErr := e.docker.Wait(runCtx, containerID)
|
||||
if waitErr != nil {
|
||||
if errors.Is(waitErr, context.DeadlineExceeded) || errors.Is(runCtx.Err(), context.DeadlineExceeded) {
|
||||
e.handleTimeout(ctx, t, containerID)
|
||||
return
|
||||
}
|
||||
if errors.Is(waitErr, context.Canceled) || errors.Is(runCtx.Err(), context.Canceled) {
|
||||
e.handleCancel(ctx, t, containerID)
|
||||
return
|
||||
}
|
||||
message := docker.TruncateErrorMessage(waitErr.Error())
|
||||
e.reportStatus(ctx, t.ID, "failed", message)
|
||||
return
|
||||
}
|
||||
|
||||
if runCtx.Err() != nil {
|
||||
if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
|
||||
e.handleTimeout(ctx, t, containerID)
|
||||
return
|
||||
}
|
||||
if errors.Is(runCtx.Err(), context.Canceled) {
|
||||
e.handleCancel(ctx, t, containerID)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if exitCode == 0 {
|
||||
e.reportStatus(ctx, t.ID, "completed", "")
|
||||
return
|
||||
}
|
||||
|
||||
logs, _ := e.docker.TailLogs(context.Background(), containerID, 100)
|
||||
message := logs
|
||||
if message == "" {
|
||||
message = fmt.Sprintf("container exited with code %d", exitCode)
|
||||
}
|
||||
message = docker.TruncateErrorMessage(message)
|
||||
e.reportStatus(ctx, t.ID, "failed", message)
|
||||
}
|
||||
|
||||
func (e *Executor) handleCancel(ctx context.Context, t *domain.Task, containerID string) {
|
||||
_ = e.docker.Stop(context.Background(), containerID)
|
||||
e.reportStatus(ctx, t.ID, "cancelled", "")
|
||||
}
|
||||
|
||||
func (e *Executor) handleTimeout(ctx context.Context, t *domain.Task, containerID string) {
|
||||
_ = e.docker.Stop(context.Background(), containerID)
|
||||
message := docker.TruncateErrorMessage("task timed out")
|
||||
e.reportStatus(ctx, t.ID, "failed", message)
|
||||
}
|
||||
|
||||
func (e *Executor) trackCancel(taskID int, cancel context.CancelFunc) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.running[taskID] = cancel
|
||||
}
|
||||
|
||||
func (e *Executor) clearCancel(taskID int) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
delete(e.running, taskID)
|
||||
}
|
||||
|
||||
func (e *Executor) isCancelled(taskID int) bool {
|
||||
e.cancelMu.Lock()
|
||||
defer e.cancelMu.Unlock()
|
||||
_, ok := e.cancelled[taskID]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (e *Executor) clearCancelled(taskID int) {
|
||||
e.cancelMu.Lock()
|
||||
delete(e.cancelled, taskID)
|
||||
e.cancelMu.Unlock()
|
||||
}
|
||||
|
||||
// CancelAll requests cancellation for all running tasks.
|
||||
func (e *Executor) CancelAll() {
|
||||
e.mu.Lock()
|
||||
cancels := make([]context.CancelFunc, 0, len(e.running))
|
||||
for _, cancel := range e.running {
|
||||
cancels = append(cancels, cancel)
|
||||
}
|
||||
e.mu.Unlock()
|
||||
|
||||
for _, cancel := range cancels {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown cancels running tasks and waits for completion.
|
||||
func (e *Executor) Shutdown(ctx context.Context) error {
|
||||
e.stopping.Store(true)
|
||||
e.CancelAll()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
e.wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-done:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
107
agent/internal/task/executor_test.go
Normal file
107
agent/internal/task/executor_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yyhuni/lunafox/agent/internal/domain"
|
||||
)
|
||||
|
||||
type fakeReporter struct {
|
||||
status string
|
||||
msg string
|
||||
}
|
||||
|
||||
func (f *fakeReporter) UpdateStatus(ctx context.Context, taskID int, status, errorMessage string) error {
|
||||
f.status = status
|
||||
f.msg = errorMessage
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestExecutorMissingWorkerToken(t *testing.T) {
|
||||
reporter := &fakeReporter{}
|
||||
exec := &Executor{
|
||||
client: reporter,
|
||||
serverURL: "https://server",
|
||||
workerToken: "",
|
||||
}
|
||||
|
||||
exec.execute(context.Background(), &domain.Task{ID: 1})
|
||||
if reporter.status != "failed" {
|
||||
t.Fatalf("expected failed status, got %s", reporter.status)
|
||||
}
|
||||
if reporter.msg == "" {
|
||||
t.Fatalf("expected error message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorDockerUnavailable(t *testing.T) {
|
||||
reporter := &fakeReporter{}
|
||||
exec := &Executor{
|
||||
client: reporter,
|
||||
serverURL: "https://server",
|
||||
workerToken: "token",
|
||||
}
|
||||
|
||||
exec.execute(context.Background(), &domain.Task{ID: 2})
|
||||
if reporter.status != "failed" {
|
||||
t.Fatalf("expected failed status, got %s", reporter.status)
|
||||
}
|
||||
if reporter.msg == "" {
|
||||
t.Fatalf("expected error message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorCancelAll(t *testing.T) {
|
||||
exec := &Executor{
|
||||
running: map[int]context.CancelFunc{},
|
||||
}
|
||||
calls := 0
|
||||
exec.running[1] = func() { calls++ }
|
||||
exec.running[2] = func() { calls++ }
|
||||
|
||||
exec.CancelAll()
|
||||
if calls != 2 {
|
||||
t.Fatalf("expected cancel calls, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorShutdownWaits(t *testing.T) {
|
||||
exec := &Executor{
|
||||
running: map[int]context.CancelFunc{},
|
||||
}
|
||||
calls := 0
|
||||
exec.running[1] = func() { calls++ }
|
||||
|
||||
exec.wg.Add(1)
|
||||
go func() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
exec.wg.Done()
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := exec.Shutdown(ctx); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("expected cancel call")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorShutdownTimeout(t *testing.T) {
|
||||
exec := &Executor{
|
||||
running: map[int]context.CancelFunc{},
|
||||
}
|
||||
exec.wg.Add(1)
|
||||
defer exec.wg.Done()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
if err := exec.Shutdown(ctx); err == nil {
|
||||
t.Fatalf("expected timeout error")
|
||||
}
|
||||
}
|
||||
252
agent/internal/task/puller.go
Normal file
252
agent/internal/task/puller.go
Normal file
@@ -0,0 +1,252 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/yyhuni/lunafox/agent/internal/domain"
|
||||
)
|
||||
|
||||
// Puller coordinates task pulling with load gating and backoff.
|
||||
type Puller struct {
|
||||
client TaskPuller
|
||||
collector MetricsSampler
|
||||
counter *Counter
|
||||
maxTasks int
|
||||
cpuThreshold int
|
||||
memThreshold int
|
||||
diskThreshold int
|
||||
|
||||
onTask func(*domain.Task)
|
||||
notifyCh chan struct{}
|
||||
emptyBackoff []time.Duration
|
||||
emptyIdx int
|
||||
errorBackoff time.Duration
|
||||
errorMax time.Duration
|
||||
randSrc *rand.Rand
|
||||
mu sync.RWMutex
|
||||
paused atomic.Bool
|
||||
}
|
||||
|
||||
type MetricsSampler interface {
|
||||
Sample() (float64, float64, float64)
|
||||
}
|
||||
|
||||
type TaskPuller interface {
|
||||
PullTask(ctx context.Context) (*domain.Task, error)
|
||||
}
|
||||
|
||||
// NewPuller creates a new Puller.
|
||||
func NewPuller(client TaskPuller, collector MetricsSampler, counter *Counter, maxTasks, cpuThreshold, memThreshold, diskThreshold int) *Puller {
|
||||
return &Puller{
|
||||
client: client,
|
||||
collector: collector,
|
||||
counter: counter,
|
||||
maxTasks: maxTasks,
|
||||
cpuThreshold: cpuThreshold,
|
||||
memThreshold: memThreshold,
|
||||
diskThreshold: diskThreshold,
|
||||
notifyCh: make(chan struct{}, 1),
|
||||
emptyBackoff: []time.Duration{5 * time.Second, 10 * time.Second, 30 * time.Second, 60 * time.Second},
|
||||
errorBackoff: 1 * time.Second,
|
||||
errorMax: 60 * time.Second,
|
||||
randSrc: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||
}
|
||||
}
|
||||
|
||||
// SetOnTask registers a callback invoked when a task is assigned.
|
||||
func (p *Puller) SetOnTask(fn func(*domain.Task)) {
|
||||
p.onTask = fn
|
||||
}
|
||||
|
||||
// NotifyTaskAvailable triggers an immediate pull attempt.
|
||||
func (p *Puller) NotifyTaskAvailable() {
|
||||
select {
|
||||
case p.notifyCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the pull loop.
|
||||
func (p *Puller) Run(ctx context.Context) error {
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
if p.paused.Load() {
|
||||
if !p.waitUntilCanceled(ctx) {
|
||||
return ctx.Err()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
loadInterval := p.loadInterval()
|
||||
if !p.canPull() {
|
||||
if !p.wait(ctx, loadInterval) {
|
||||
return ctx.Err()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
task, err := p.client.PullTask(ctx)
|
||||
if err != nil {
|
||||
delay := p.nextErrorBackoff()
|
||||
if !p.wait(ctx, delay) {
|
||||
return ctx.Err()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
p.resetErrorBackoff()
|
||||
if task == nil {
|
||||
delay := p.nextEmptyDelay(loadInterval)
|
||||
if !p.waitOrNotify(ctx, delay) {
|
||||
return ctx.Err()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
p.resetEmptyBackoff()
|
||||
if p.onTask != nil {
|
||||
p.onTask(task)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Puller) canPull() bool {
|
||||
maxTasks, cpuThreshold, memThreshold, diskThreshold := p.currentConfig()
|
||||
if p.counter != nil && p.counter.Count() >= maxTasks {
|
||||
return false
|
||||
}
|
||||
cpu, mem, disk := p.collector.Sample()
|
||||
return cpu < float64(cpuThreshold) &&
|
||||
mem < float64(memThreshold) &&
|
||||
disk < float64(diskThreshold)
|
||||
}
|
||||
|
||||
func (p *Puller) loadInterval() time.Duration {
|
||||
cpu, mem, disk := p.collector.Sample()
|
||||
load := math.Max(cpu, math.Max(mem, disk))
|
||||
switch {
|
||||
case load < 50:
|
||||
return 1 * time.Second
|
||||
case load < 80:
|
||||
return 3 * time.Second
|
||||
default:
|
||||
return 10 * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Puller) nextEmptyDelay(loadInterval time.Duration) time.Duration {
|
||||
var empty time.Duration
|
||||
if p.emptyIdx < len(p.emptyBackoff) {
|
||||
empty = p.emptyBackoff[p.emptyIdx]
|
||||
p.emptyIdx++
|
||||
} else {
|
||||
empty = p.emptyBackoff[len(p.emptyBackoff)-1]
|
||||
}
|
||||
if empty < loadInterval {
|
||||
return loadInterval
|
||||
}
|
||||
return empty
|
||||
}
|
||||
|
||||
func (p *Puller) resetEmptyBackoff() {
|
||||
p.emptyIdx = 0
|
||||
}
|
||||
|
||||
func (p *Puller) nextErrorBackoff() time.Duration {
|
||||
delay := p.errorBackoff
|
||||
next := delay * 2
|
||||
if next > p.errorMax {
|
||||
next = p.errorMax
|
||||
}
|
||||
p.errorBackoff = next
|
||||
return withJitter(delay, p.randSrc)
|
||||
}
|
||||
|
||||
func (p *Puller) resetErrorBackoff() {
|
||||
p.errorBackoff = 1 * time.Second
|
||||
}
|
||||
|
||||
func (p *Puller) wait(ctx context.Context, delay time.Duration) bool {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Puller) waitOrNotify(ctx context.Context, delay time.Duration) bool {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-p.notifyCh:
|
||||
return true
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func withJitter(delay time.Duration, src *rand.Rand) time.Duration {
|
||||
if delay <= 0 || src == nil {
|
||||
return delay
|
||||
}
|
||||
jitter := src.Float64() * 0.2
|
||||
return delay + time.Duration(float64(delay)*jitter)
|
||||
}
|
||||
|
||||
func (p *Puller) EnsureTaskHandler() error {
|
||||
if p.onTask == nil {
|
||||
return errors.New("task handler is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Pause stops pulling. Once paused, only context cancellation exits the loop.
|
||||
func (p *Puller) Pause() {
|
||||
p.paused.Store(true)
|
||||
}
|
||||
|
||||
// UpdateConfig updates puller thresholds and max tasks.
|
||||
func (p *Puller) UpdateConfig(maxTasks, cpuThreshold, memThreshold, diskThreshold *int) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if maxTasks != nil && *maxTasks > 0 {
|
||||
p.maxTasks = *maxTasks
|
||||
}
|
||||
if cpuThreshold != nil && *cpuThreshold > 0 {
|
||||
p.cpuThreshold = *cpuThreshold
|
||||
}
|
||||
if memThreshold != nil && *memThreshold > 0 {
|
||||
p.memThreshold = *memThreshold
|
||||
}
|
||||
if diskThreshold != nil && *diskThreshold > 0 {
|
||||
p.diskThreshold = *diskThreshold
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Puller) currentConfig() (int, int, int, int) {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.maxTasks, p.cpuThreshold, p.memThreshold, p.diskThreshold
|
||||
}
|
||||
|
||||
func (p *Puller) waitUntilCanceled(ctx context.Context) bool {
|
||||
<-ctx.Done()
|
||||
return false
|
||||
}
|
||||
101
agent/internal/task/puller_test.go
Normal file
101
agent/internal/task/puller_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yyhuni/lunafox/agent/internal/domain"
|
||||
)
|
||||
|
||||
func TestPullerUpdateConfig(t *testing.T) {
|
||||
p := NewPuller(nil, nil, nil, 5, 85, 86, 87)
|
||||
max, cpu, mem, disk := p.currentConfig()
|
||||
if max != 5 || cpu != 85 || mem != 86 || disk != 87 {
|
||||
t.Fatalf("unexpected initial config")
|
||||
}
|
||||
|
||||
maxUpdate := 8
|
||||
cpuUpdate := 70
|
||||
p.UpdateConfig(&maxUpdate, &cpuUpdate, nil, nil)
|
||||
max, cpu, mem, disk = p.currentConfig()
|
||||
if max != 8 || cpu != 70 || mem != 86 || disk != 87 {
|
||||
t.Fatalf("unexpected updated config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullerPause(t *testing.T) {
|
||||
p := NewPuller(nil, nil, nil, 1, 1, 1, 1)
|
||||
p.Pause()
|
||||
if !p.paused.Load() {
|
||||
t.Fatalf("expected paused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullerEnsureTaskHandler(t *testing.T) {
|
||||
p := NewPuller(nil, nil, nil, 1, 1, 1, 1)
|
||||
if err := p.EnsureTaskHandler(); err == nil {
|
||||
t.Fatalf("expected error when handler missing")
|
||||
}
|
||||
p.SetOnTask(func(*domain.Task) {})
|
||||
if err := p.EnsureTaskHandler(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullerNextEmptyDelay(t *testing.T) {
|
||||
p := NewPuller(nil, nil, nil, 1, 1, 1, 1)
|
||||
p.emptyBackoff = []time.Duration{5 * time.Second, 10 * time.Second}
|
||||
|
||||
if delay := p.nextEmptyDelay(8 * time.Second); delay != 8*time.Second {
|
||||
t.Fatalf("expected delay to honor load interval, got %v", delay)
|
||||
}
|
||||
if delay := p.nextEmptyDelay(1 * time.Second); delay != 10*time.Second {
|
||||
t.Fatalf("expected backoff delay, got %v", delay)
|
||||
}
|
||||
if p.emptyIdx != 2 {
|
||||
t.Fatalf("expected empty index to advance")
|
||||
}
|
||||
p.resetEmptyBackoff()
|
||||
if p.emptyIdx != 0 {
|
||||
t.Fatalf("expected empty index reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullerErrorBackoff(t *testing.T) {
|
||||
p := NewPuller(nil, nil, nil, 1, 1, 1, 1)
|
||||
p.randSrc = rand.New(rand.NewSource(1))
|
||||
|
||||
first := p.nextErrorBackoff()
|
||||
if first < time.Second || first > time.Second+(time.Second/5) {
|
||||
t.Fatalf("unexpected backoff %v", first)
|
||||
}
|
||||
if p.errorBackoff != 2*time.Second {
|
||||
t.Fatalf("expected backoff to double")
|
||||
}
|
||||
|
||||
second := p.nextErrorBackoff()
|
||||
if second < 2*time.Second || second > 2*time.Second+(2*time.Second/5) {
|
||||
t.Fatalf("unexpected backoff %v", second)
|
||||
}
|
||||
if p.errorBackoff != 4*time.Second {
|
||||
t.Fatalf("expected backoff to double")
|
||||
}
|
||||
|
||||
p.resetErrorBackoff()
|
||||
if p.errorBackoff != time.Second {
|
||||
t.Fatalf("expected error backoff reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithJitterRange(t *testing.T) {
|
||||
rng := rand.New(rand.NewSource(1))
|
||||
delay := 10 * time.Second
|
||||
got := withJitter(delay, rng)
|
||||
if got < delay {
|
||||
t.Fatalf("expected jitter >= delay")
|
||||
}
|
||||
if got > delay+(delay/5) {
|
||||
t.Fatalf("expected jitter <= 20%%")
|
||||
}
|
||||
}
|
||||
279
agent/internal/update/updater.go
Normal file
279
agent/internal/update/updater.go
Normal file
@@ -0,0 +1,279 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/docker/api/types/strslice"
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"github.com/yyhuni/lunafox/agent/internal/config"
|
||||
"github.com/yyhuni/lunafox/agent/internal/domain"
|
||||
"github.com/yyhuni/lunafox/agent/internal/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Updater handles agent self-update.
|
||||
type Updater struct {
|
||||
docker dockerClient
|
||||
health healthSetter
|
||||
puller pullerController
|
||||
executor executorController
|
||||
cfg configSnapshot
|
||||
apiKey string
|
||||
token string
|
||||
mu sync.Mutex
|
||||
updating bool
|
||||
randSrc *rand.Rand
|
||||
backoff time.Duration
|
||||
maxBackoff time.Duration
|
||||
}
|
||||
|
||||
type dockerClient interface {
|
||||
ImagePull(ctx context.Context, imageRef string) (io.ReadCloser, error)
|
||||
ContainerCreate(ctx context.Context, config *container.Config, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig, platform *ocispec.Platform, name string) (container.CreateResponse, error)
|
||||
ContainerStart(ctx context.Context, containerID string, opts container.StartOptions) error
|
||||
}
|
||||
|
||||
type healthSetter interface {
|
||||
Set(state, reason, message string)
|
||||
}
|
||||
|
||||
type pullerController interface {
|
||||
Pause()
|
||||
}
|
||||
|
||||
type executorController interface {
|
||||
Shutdown(ctx context.Context) error
|
||||
}
|
||||
|
||||
type configSnapshot interface {
|
||||
Snapshot() config.Config
|
||||
}
|
||||
|
||||
// NewUpdater creates a new updater.
|
||||
func NewUpdater(dockerClient dockerClient, healthManager healthSetter, puller pullerController, executor executorController, cfg configSnapshot, apiKey, token string) *Updater {
|
||||
return &Updater{
|
||||
docker: dockerClient,
|
||||
health: healthManager,
|
||||
puller: puller,
|
||||
executor: executor,
|
||||
cfg: cfg,
|
||||
apiKey: apiKey,
|
||||
token: token,
|
||||
randSrc: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||
backoff: 30 * time.Second,
|
||||
maxBackoff: 10 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleUpdateRequired triggers the update flow.
|
||||
func (u *Updater) HandleUpdateRequired(payload domain.UpdateRequiredPayload) {
|
||||
u.mu.Lock()
|
||||
if u.updating {
|
||||
u.mu.Unlock()
|
||||
return
|
||||
}
|
||||
u.updating = true
|
||||
u.mu.Unlock()
|
||||
|
||||
go u.run(payload)
|
||||
}
|
||||
|
||||
func (u *Updater) run(payload domain.UpdateRequiredPayload) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger.Log.Error("agent update panic", zap.Any("panic", r))
|
||||
u.health.Set("paused", "update_panic", fmt.Sprintf("%v", r))
|
||||
}
|
||||
u.mu.Lock()
|
||||
u.updating = false
|
||||
u.mu.Unlock()
|
||||
}()
|
||||
u.puller.Pause()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
_ = u.executor.Shutdown(ctx)
|
||||
cancel()
|
||||
|
||||
for {
|
||||
if err := u.updateOnce(payload); err == nil {
|
||||
u.health.Set("ok", "", "")
|
||||
os.Exit(0)
|
||||
} else {
|
||||
u.health.Set("paused", "update_failed", err.Error())
|
||||
}
|
||||
|
||||
delay := withJitter(u.backoff, u.randSrc)
|
||||
if u.backoff < u.maxBackoff {
|
||||
u.backoff *= 2
|
||||
if u.backoff > u.maxBackoff {
|
||||
u.backoff = u.maxBackoff
|
||||
}
|
||||
}
|
||||
time.Sleep(delay)
|
||||
}
|
||||
}
|
||||
|
||||
func (u *Updater) updateOnce(payload domain.UpdateRequiredPayload) error {
|
||||
if u.docker == nil {
|
||||
return fmt.Errorf("docker client unavailable")
|
||||
}
|
||||
image := strings.TrimSpace(payload.Image)
|
||||
version := strings.TrimSpace(payload.Version)
|
||||
if image == "" || version == "" {
|
||||
return fmt.Errorf("invalid update payload")
|
||||
}
|
||||
|
||||
// Strict validation: reject invalid data from server
|
||||
if err := validateImageName(image); err != nil {
|
||||
logger.Log.Warn("invalid image name from server", zap.String("image", image), zap.Error(err))
|
||||
return fmt.Errorf("invalid image name from server: %w", err)
|
||||
}
|
||||
if err := validateVersion(version); err != nil {
|
||||
logger.Log.Warn("invalid version from server", zap.String("version", version), zap.Error(err))
|
||||
return fmt.Errorf("invalid version from server: %w", err)
|
||||
}
|
||||
|
||||
fullImage := fmt.Sprintf("%s:%s", image, version)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
reader, err := u.docker.ImagePull(ctx, fullImage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, reader)
|
||||
_ = reader.Close()
|
||||
|
||||
if err := u.startNewContainer(ctx, image, version); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *Updater) startNewContainer(ctx context.Context, image, version string) error {
|
||||
env := []string{
|
||||
fmt.Sprintf("SERVER_URL=%s", u.cfg.Snapshot().ServerURL),
|
||||
fmt.Sprintf("API_KEY=%s", u.apiKey),
|
||||
fmt.Sprintf("MAX_TASKS=%d", u.cfg.Snapshot().MaxTasks),
|
||||
fmt.Sprintf("CPU_THRESHOLD=%d", u.cfg.Snapshot().CPUThreshold),
|
||||
fmt.Sprintf("MEM_THRESHOLD=%d", u.cfg.Snapshot().MemThreshold),
|
||||
fmt.Sprintf("DISK_THRESHOLD=%d", u.cfg.Snapshot().DiskThreshold),
|
||||
fmt.Sprintf("AGENT_VERSION=%s", version),
|
||||
}
|
||||
if u.token != "" {
|
||||
env = append(env, fmt.Sprintf("WORKER_TOKEN=%s", u.token))
|
||||
}
|
||||
|
||||
cfg := &container.Config{
|
||||
Image: fmt.Sprintf("%s:%s", image, version),
|
||||
Env: env,
|
||||
Cmd: strslice.StrSlice{},
|
||||
}
|
||||
|
||||
hostConfig := &container.HostConfig{
|
||||
Binds: []string{
|
||||
"/var/run/docker.sock:/var/run/docker.sock",
|
||||
"/opt/lunafox:/opt/lunafox",
|
||||
},
|
||||
RestartPolicy: container.RestartPolicy{Name: "unless-stopped"},
|
||||
OomScoreAdj: -500,
|
||||
}
|
||||
|
||||
// Version is already validated, just normalize to lowercase for container name
|
||||
name := fmt.Sprintf("lunafox-agent-%s", strings.ToLower(version))
|
||||
resp, err := u.docker.ContainerCreate(ctx, cfg, hostConfig, &network.NetworkingConfig{}, nil, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := u.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Log.Info("agent update started new container", zap.String("containerId", resp.ID))
|
||||
return nil
|
||||
}
|
||||
|
||||
func withJitter(delay time.Duration, src *rand.Rand) time.Duration {
|
||||
if delay <= 0 || src == nil {
|
||||
return delay
|
||||
}
|
||||
jitter := src.Float64() * 0.2
|
||||
return delay + time.Duration(float64(delay)*jitter)
|
||||
}
|
||||
|
||||
// validateImageName validates that the image name contains only safe characters.
|
||||
// Returns error if validation fails.
|
||||
func validateImageName(image string) error {
|
||||
if len(image) == 0 {
|
||||
return fmt.Errorf("image name cannot be empty")
|
||||
}
|
||||
if len(image) > 255 {
|
||||
return fmt.Errorf("image name too long: %d characters", len(image))
|
||||
}
|
||||
|
||||
// Allow: alphanumeric, dots, hyphens, underscores, slashes (for registry paths)
|
||||
for i, r := range image {
|
||||
if !((r >= 'a' && r <= 'z') ||
|
||||
(r >= 'A' && r <= 'Z') ||
|
||||
(r >= '0' && r <= '9') ||
|
||||
r == '.' || r == '-' || r == '_' || r == '/') {
|
||||
return fmt.Errorf("invalid character at position %d: %c", i, r)
|
||||
}
|
||||
}
|
||||
|
||||
// Must not start or end with special characters
|
||||
first := rune(image[0])
|
||||
last := rune(image[len(image)-1])
|
||||
if first == '.' || first == '-' || first == '/' {
|
||||
return fmt.Errorf("image name cannot start with special character: %c", first)
|
||||
}
|
||||
if last == '.' || last == '-' || last == '/' {
|
||||
return fmt.Errorf("image name cannot end with special character: %c", last)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateVersion validates that the version string contains only safe characters.
|
||||
// Returns error if validation fails.
|
||||
func validateVersion(version string) error {
|
||||
if len(version) == 0 {
|
||||
return fmt.Errorf("version cannot be empty")
|
||||
}
|
||||
if len(version) > 128 {
|
||||
return fmt.Errorf("version too long: %d characters", len(version))
|
||||
}
|
||||
|
||||
// Allow: alphanumeric, dots, hyphens, underscores
|
||||
for i, r := range version {
|
||||
if !((r >= 'a' && r <= 'z') ||
|
||||
(r >= 'A' && r <= 'Z') ||
|
||||
(r >= '0' && r <= '9') ||
|
||||
r == '.' || r == '-' || r == '_') {
|
||||
return fmt.Errorf("invalid character at position %d: %c", i, r)
|
||||
}
|
||||
}
|
||||
|
||||
// Must not start or end with special characters
|
||||
first := rune(version[0])
|
||||
last := rune(version[len(version)-1])
|
||||
if first == '.' || first == '-' || first == '_' {
|
||||
return fmt.Errorf("version cannot start with special character: %c", first)
|
||||
}
|
||||
if last == '.' || last == '-' || last == '_' {
|
||||
return fmt.Errorf("version cannot end with special character: %c", last)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
35
agent/internal/update/updater_test.go
Normal file
35
agent/internal/update/updater_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
37
agent/internal/websocket/backoff.go
Normal file
37
agent/internal/websocket/backoff.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package websocket
|
||||
|
||||
import "time"
|
||||
|
||||
// Backoff implements exponential backoff with a maximum cap.
|
||||
type Backoff struct {
|
||||
base time.Duration
|
||||
max time.Duration
|
||||
current time.Duration
|
||||
}
|
||||
|
||||
// NewBackoff creates a backoff with the given base and max delay.
|
||||
func NewBackoff(base, max time.Duration) Backoff {
|
||||
return Backoff{
|
||||
base: base,
|
||||
max: max,
|
||||
}
|
||||
}
|
||||
|
||||
// Next returns the next backoff duration.
|
||||
func (b *Backoff) Next() time.Duration {
|
||||
if b.current <= 0 {
|
||||
b.current = b.base
|
||||
return b.current
|
||||
}
|
||||
next := b.current * 2
|
||||
if next > b.max {
|
||||
next = b.max
|
||||
}
|
||||
b.current = next
|
||||
return b.current
|
||||
}
|
||||
|
||||
// Reset clears the backoff to start over.
|
||||
func (b *Backoff) Reset() {
|
||||
b.current = 0
|
||||
}
|
||||
32
agent/internal/websocket/backoff_test.go
Normal file
32
agent/internal/websocket/backoff_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBackoffSequence(t *testing.T) {
|
||||
b := NewBackoff(time.Second, 60*time.Second)
|
||||
|
||||
expected := []time.Duration{
|
||||
time.Second,
|
||||
2 * time.Second,
|
||||
4 * time.Second,
|
||||
8 * time.Second,
|
||||
16 * time.Second,
|
||||
32 * time.Second,
|
||||
60 * time.Second,
|
||||
60 * time.Second,
|
||||
}
|
||||
|
||||
for i, exp := range expected {
|
||||
if got := b.Next(); got != exp {
|
||||
t.Fatalf("step %d: expected %v, got %v", i, exp, got)
|
||||
}
|
||||
}
|
||||
|
||||
b.Reset()
|
||||
if got := b.Next(); got != time.Second {
|
||||
t.Fatalf("after reset expected %v, got %v", time.Second, got)
|
||||
}
|
||||
}
|
||||
177
agent/internal/websocket/client.go
Normal file
177
agent/internal/websocket/client.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/yyhuni/lunafox/agent/internal/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPingInterval = 30 * time.Second
|
||||
defaultPongWait = 60 * time.Second
|
||||
defaultWriteWait = 10 * time.Second
|
||||
)
|
||||
|
||||
// Client maintains a WebSocket connection to the server.
|
||||
type Client struct {
|
||||
wsURL string
|
||||
apiKey string
|
||||
dialer *websocket.Dialer
|
||||
send chan []byte
|
||||
onMessage func([]byte)
|
||||
backoff Backoff
|
||||
pingInterval time.Duration
|
||||
pongWait time.Duration
|
||||
writeWait time.Duration
|
||||
}
|
||||
|
||||
// NewClient creates a WebSocket client for the agent.
|
||||
func NewClient(wsURL, apiKey string) *Client {
|
||||
dialer := *websocket.DefaultDialer
|
||||
dialer.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
return &Client{
|
||||
wsURL: wsURL,
|
||||
apiKey: apiKey,
|
||||
dialer: &dialer,
|
||||
send: make(chan []byte, 256),
|
||||
backoff: NewBackoff(1*time.Second, 60*time.Second),
|
||||
pingInterval: defaultPingInterval,
|
||||
pongWait: defaultPongWait,
|
||||
writeWait: defaultWriteWait,
|
||||
}
|
||||
}
|
||||
|
||||
// SetOnMessage registers a callback for incoming messages.
|
||||
func (c *Client) SetOnMessage(fn func([]byte)) {
|
||||
c.onMessage = fn
|
||||
}
|
||||
|
||||
// Send queues a message for sending. It returns false if the buffer is full.
|
||||
func (c *Client) Send(payload []byte) bool {
|
||||
select {
|
||||
case c.send <- payload:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Run keeps the connection alive with reconnect backoff and keepalive pings.
|
||||
func (c *Client) Run(ctx context.Context) error {
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
logger.Log.Info("websocket connect attempt", zap.String("url", c.wsURL))
|
||||
conn, err := c.connect(ctx)
|
||||
if err != nil {
|
||||
logger.Log.Warn("websocket connect failed", zap.Error(err))
|
||||
if !sleepWithContext(ctx, c.backoff.Next()) {
|
||||
return ctx.Err()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
c.backoff.Reset()
|
||||
logger.Log.Info("websocket connected")
|
||||
err = c.runConn(ctx, conn)
|
||||
if err != nil && ctx.Err() == nil {
|
||||
logger.Log.Warn("websocket connection closed", zap.Error(err))
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if !sleepWithContext(ctx, c.backoff.Next()) {
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) connect(ctx context.Context) (*websocket.Conn, error) {
|
||||
header := http.Header{}
|
||||
if c.apiKey != "" {
|
||||
header.Set("X-Agent-Key", c.apiKey)
|
||||
}
|
||||
conn, _, err := c.dialer.DialContext(ctx, c.wsURL, header)
|
||||
return conn, err
|
||||
}
|
||||
|
||||
func (c *Client) runConn(ctx context.Context, conn *websocket.Conn) error {
|
||||
defer conn.Close()
|
||||
|
||||
conn.SetReadDeadline(time.Now().Add(c.pongWait))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
conn.SetReadDeadline(time.Now().Add(c.pongWait))
|
||||
return nil
|
||||
})
|
||||
|
||||
errCh := make(chan error, 2)
|
||||
go c.readLoop(conn, errCh)
|
||||
go c.writeLoop(ctx, conn, errCh)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case err := <-errCh:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) readLoop(conn *websocket.Conn, errCh chan<- error) {
|
||||
for {
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
if c.onMessage != nil {
|
||||
c.onMessage(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) writeLoop(ctx context.Context, conn *websocket.Conn, errCh chan<- error) {
|
||||
ticker := time.NewTicker(c.pingInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
errCh <- ctx.Err()
|
||||
return
|
||||
case payload := <-c.send:
|
||||
if err := c.writeMessage(conn, websocket.TextMessage, payload); err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
if err := c.writeMessage(conn, websocket.PingMessage, nil); err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) writeMessage(conn *websocket.Conn, msgType int, payload []byte) error {
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(c.writeWait))
|
||||
return conn.WriteMessage(msgType, payload)
|
||||
}
|
||||
|
||||
func sleepWithContext(ctx context.Context, delay time.Duration) bool {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
32
agent/internal/websocket/client_test.go
Normal file
32
agent/internal/websocket/client_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestClientSendBufferFull(t *testing.T) {
|
||||
client := &Client{send: make(chan []byte, 1)}
|
||||
if !client.Send([]byte("first")) {
|
||||
t.Fatalf("expected first send to succeed")
|
||||
}
|
||||
if client.Send([]byte("second")) {
|
||||
t.Fatalf("expected second send to fail when buffer is full")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSleepWithContextCancelled(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
if sleepWithContext(ctx, 50*time.Millisecond) {
|
||||
t.Fatalf("expected sleepWithContext to return false when canceled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSleepWithContextElapsed(t *testing.T) {
|
||||
if !sleepWithContext(context.Background(), 5*time.Millisecond) {
|
||||
t.Fatalf("expected sleepWithContext to return true after delay")
|
||||
}
|
||||
}
|
||||
90
agent/internal/websocket/handlers.go
Normal file
90
agent/internal/websocket/handlers.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/yyhuni/lunafox/agent/internal/protocol"
|
||||
)
|
||||
|
||||
// Handler routes incoming WebSocket messages.
|
||||
type Handler struct {
|
||||
onTaskAvailable func()
|
||||
onTaskCancel func(int)
|
||||
onConfigUpdate func(protocol.ConfigUpdatePayload)
|
||||
onUpdateReq func(protocol.UpdateRequiredPayload)
|
||||
}
|
||||
|
||||
// NewHandler creates a message handler.
|
||||
func NewHandler() *Handler {
|
||||
return &Handler{}
|
||||
}
|
||||
|
||||
// OnTaskAvailable registers a callback for task_available messages.
|
||||
func (h *Handler) OnTaskAvailable(fn func()) {
|
||||
h.onTaskAvailable = fn
|
||||
}
|
||||
|
||||
// OnTaskCancel registers a callback for task_cancel messages.
|
||||
func (h *Handler) OnTaskCancel(fn func(int)) {
|
||||
h.onTaskCancel = fn
|
||||
}
|
||||
|
||||
// OnConfigUpdate registers a callback for config_update messages.
|
||||
func (h *Handler) OnConfigUpdate(fn func(protocol.ConfigUpdatePayload)) {
|
||||
h.onConfigUpdate = fn
|
||||
}
|
||||
|
||||
// OnUpdateRequired registers a callback for update_required messages.
|
||||
func (h *Handler) OnUpdateRequired(fn func(protocol.UpdateRequiredPayload)) {
|
||||
h.onUpdateReq = fn
|
||||
}
|
||||
|
||||
// Handle processes a raw message.
|
||||
func (h *Handler) Handle(raw []byte) {
|
||||
var msg struct {
|
||||
Type string `json:"type"`
|
||||
Data json.RawMessage `json:"payload"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case protocol.MessageTypeTaskAvailable:
|
||||
if h.onTaskAvailable != nil {
|
||||
h.onTaskAvailable()
|
||||
}
|
||||
case protocol.MessageTypeTaskCancel:
|
||||
if h.onTaskCancel == nil {
|
||||
return
|
||||
}
|
||||
var payload protocol.TaskCancelPayload
|
||||
if err := json.Unmarshal(msg.Data, &payload); err != nil {
|
||||
return
|
||||
}
|
||||
if payload.TaskID > 0 {
|
||||
h.onTaskCancel(payload.TaskID)
|
||||
}
|
||||
case protocol.MessageTypeConfigUpdate:
|
||||
if h.onConfigUpdate == nil {
|
||||
return
|
||||
}
|
||||
var payload protocol.ConfigUpdatePayload
|
||||
if err := json.Unmarshal(msg.Data, &payload); err != nil {
|
||||
return
|
||||
}
|
||||
h.onConfigUpdate(payload)
|
||||
case protocol.MessageTypeUpdateRequired:
|
||||
if h.onUpdateReq == nil {
|
||||
return
|
||||
}
|
||||
var payload protocol.UpdateRequiredPayload
|
||||
if err := json.Unmarshal(msg.Data, &payload); err != nil {
|
||||
return
|
||||
}
|
||||
if payload.Version == "" || payload.Image == "" {
|
||||
return
|
||||
}
|
||||
h.onUpdateReq(payload)
|
||||
}
|
||||
}
|
||||
85
agent/internal/websocket/handlers_test.go
Normal file
85
agent/internal/websocket/handlers_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/yyhuni/lunafox/agent/internal/protocol"
|
||||
)
|
||||
|
||||
func TestHandlersTaskAvailable(t *testing.T) {
|
||||
h := NewHandler()
|
||||
called := 0
|
||||
h.OnTaskAvailable(func() { called++ })
|
||||
|
||||
message := fmt.Sprintf(`{"type":"%s","payload":{},"timestamp":"2026-01-01T00:00:00Z"}`, protocol.MessageTypeTaskAvailable)
|
||||
h.Handle([]byte(message))
|
||||
if called != 1 {
|
||||
t.Fatalf("expected callback to be called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlersTaskCancel(t *testing.T) {
|
||||
h := NewHandler()
|
||||
var got int
|
||||
h.OnTaskCancel(func(id int) { got = id })
|
||||
|
||||
message := fmt.Sprintf(`{"type":"%s","payload":{"taskId":123},"timestamp":"2026-01-01T00:00:00Z"}`, protocol.MessageTypeTaskCancel)
|
||||
h.Handle([]byte(message))
|
||||
if got != 123 {
|
||||
t.Fatalf("expected taskId 123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlersConfigUpdate(t *testing.T) {
|
||||
h := NewHandler()
|
||||
var maxTasks int
|
||||
h.OnConfigUpdate(func(payload protocol.ConfigUpdatePayload) {
|
||||
if payload.MaxTasks != nil {
|
||||
maxTasks = *payload.MaxTasks
|
||||
}
|
||||
})
|
||||
|
||||
message := fmt.Sprintf(`{"type":"%s","payload":{"maxTasks":8},"timestamp":"2026-01-01T00:00:00Z"}`, protocol.MessageTypeConfigUpdate)
|
||||
h.Handle([]byte(message))
|
||||
if maxTasks != 8 {
|
||||
t.Fatalf("expected maxTasks 8")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlersUpdateRequired(t *testing.T) {
|
||||
h := NewHandler()
|
||||
var version string
|
||||
h.OnUpdateRequired(func(payload protocol.UpdateRequiredPayload) { version = payload.Version })
|
||||
|
||||
message := fmt.Sprintf(`{"type":"%s","payload":{"version":"v1.0.1","image":"yyhuni/lunafox-agent"},"timestamp":"2026-01-01T00:00:00Z"}`, protocol.MessageTypeUpdateRequired)
|
||||
h.Handle([]byte(message))
|
||||
if version != "v1.0.1" {
|
||||
t.Fatalf("expected version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlersIgnoreInvalidJSON(t *testing.T) {
|
||||
h := NewHandler()
|
||||
called := 0
|
||||
h.OnTaskAvailable(func() { called++ })
|
||||
|
||||
h.Handle([]byte("{bad json"))
|
||||
if called != 0 {
|
||||
t.Fatalf("expected no callbacks on invalid json")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlersUpdateRequiredMissingFields(t *testing.T) {
|
||||
h := NewHandler()
|
||||
called := 0
|
||||
h.OnUpdateRequired(func(payload protocol.UpdateRequiredPayload) { called++ })
|
||||
|
||||
message := fmt.Sprintf(`{"type":"%s","payload":{"version":"","image":"yyhuni/lunafox-agent"}}`, protocol.MessageTypeUpdateRequired)
|
||||
h.Handle([]byte(message))
|
||||
message = fmt.Sprintf(`{"type":"%s","payload":{"version":"v1.2.3","image":""}}`, protocol.MessageTypeUpdateRequired)
|
||||
h.Handle([]byte(message))
|
||||
if called != 0 {
|
||||
t.Fatalf("expected no callbacks for invalid payload")
|
||||
}
|
||||
}
|
||||
97
agent/internal/websocket/heartbeat.go
Normal file
97
agent/internal/websocket/heartbeat.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/yyhuni/lunafox/agent/internal/health"
|
||||
"github.com/yyhuni/lunafox/agent/internal/logger"
|
||||
"github.com/yyhuni/lunafox/agent/internal/metrics"
|
||||
"github.com/yyhuni/lunafox/agent/internal/protocol"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// HeartbeatSender sends periodic heartbeat messages over WebSocket.
|
||||
type HeartbeatSender struct {
|
||||
client *Client
|
||||
collector *metrics.Collector
|
||||
health *health.Manager
|
||||
version string
|
||||
hostname string
|
||||
startedAt time.Time
|
||||
taskCount func() int
|
||||
interval time.Duration
|
||||
lastSentAt time.Time
|
||||
}
|
||||
|
||||
// NewHeartbeatSender creates a heartbeat sender.
|
||||
func NewHeartbeatSender(client *Client, collector *metrics.Collector, healthManager *health.Manager, version, hostname string, taskCount func() int) *HeartbeatSender {
|
||||
return &HeartbeatSender{
|
||||
client: client,
|
||||
collector: collector,
|
||||
health: healthManager,
|
||||
version: version,
|
||||
hostname: hostname,
|
||||
startedAt: time.Now(),
|
||||
taskCount: taskCount,
|
||||
interval: 5 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins sending heartbeats until context is canceled.
|
||||
func (h *HeartbeatSender) Start(ctx context.Context) {
|
||||
ticker := time.NewTicker(h.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
h.sendOnce()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
h.sendOnce()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HeartbeatSender) sendOnce() {
|
||||
cpu, mem, disk := h.collector.Sample()
|
||||
uptime := int64(time.Since(h.startedAt).Seconds())
|
||||
tasks := 0
|
||||
if h.taskCount != nil {
|
||||
tasks = h.taskCount()
|
||||
}
|
||||
|
||||
status := h.health.Get()
|
||||
payload := protocol.HeartbeatPayload{
|
||||
CPU: cpu,
|
||||
Mem: mem,
|
||||
Disk: disk,
|
||||
Tasks: tasks,
|
||||
Version: h.version,
|
||||
Hostname: h.hostname,
|
||||
Uptime: uptime,
|
||||
Health: protocol.HealthStatus{
|
||||
State: status.State,
|
||||
Reason: status.Reason,
|
||||
Message: status.Message,
|
||||
Since: status.Since,
|
||||
},
|
||||
}
|
||||
|
||||
msg := protocol.Message{
|
||||
Type: protocol.MessageTypeHeartbeat,
|
||||
Payload: payload,
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
logger.Log.Warn("failed to marshal heartbeat message", zap.Error(err))
|
||||
return
|
||||
}
|
||||
if !h.client.Send(data) {
|
||||
logger.Log.Warn("failed to send heartbeat: client not connected")
|
||||
}
|
||||
}
|
||||
57
agent/internal/websocket/heartbeat_test.go
Normal file
57
agent/internal/websocket/heartbeat_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yyhuni/lunafox/agent/internal/health"
|
||||
"github.com/yyhuni/lunafox/agent/internal/metrics"
|
||||
"github.com/yyhuni/lunafox/agent/internal/protocol"
|
||||
)
|
||||
|
||||
func TestHeartbeatSenderSendOnce(t *testing.T) {
|
||||
client := &Client{send: make(chan []byte, 1)}
|
||||
collector := metrics.NewCollector()
|
||||
healthManager := health.NewManager()
|
||||
healthManager.Set("paused", "maintenance", "waiting")
|
||||
|
||||
sender := NewHeartbeatSender(client, collector, healthManager, "v1.0.0", "agent-host", func() int { return 3 })
|
||||
sender.sendOnce()
|
||||
|
||||
select {
|
||||
case payload := <-client.send:
|
||||
var msg struct {
|
||||
Type string `json:"type"`
|
||||
Payload map[string]interface{} `json:"payload"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &msg); err != nil {
|
||||
t.Fatalf("unmarshal heartbeat: %v", err)
|
||||
}
|
||||
if msg.Type != protocol.MessageTypeHeartbeat {
|
||||
t.Fatalf("expected heartbeat type, got %s", msg.Type)
|
||||
}
|
||||
if msg.Timestamp.IsZero() {
|
||||
t.Fatalf("expected timestamp")
|
||||
}
|
||||
if msg.Payload["version"] != "v1.0.0" {
|
||||
t.Fatalf("expected version in payload")
|
||||
}
|
||||
if msg.Payload["hostname"] != "agent-host" {
|
||||
t.Fatalf("expected hostname in payload")
|
||||
}
|
||||
if tasks, ok := msg.Payload["tasks"].(float64); !ok || int(tasks) != 3 {
|
||||
t.Fatalf("expected tasks=3")
|
||||
}
|
||||
healthPayload, ok := msg.Payload["health"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected health payload")
|
||||
}
|
||||
if healthPayload["state"] != "paused" {
|
||||
t.Fatalf("expected health state paused")
|
||||
}
|
||||
default:
|
||||
t.Fatalf("expected heartbeat message")
|
||||
}
|
||||
}
|
||||
13
agent/test/integration/task_test.go
Normal file
13
agent/test/integration/task_test.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTaskExecutionFlow(t *testing.T) {
|
||||
if os.Getenv("AGENT_INTEGRATION") == "" {
|
||||
t.Skip("set AGENT_INTEGRATION=1 to run integration tests")
|
||||
}
|
||||
// TODO: wire up real server + docker environment for end-to-end validation.
|
||||
}
|
||||
38
docker/.env.example
Normal file
38
docker/.env.example
Normal 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
|
||||
|
||||
|
||||
130
docker/docker-compose.dev.yml
Normal file
130
docker/docker-compose.dev.yml
Normal 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
87
docker/docker-compose.yml
Normal 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
5
docker/nginx/Dockerfile
Normal 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
101
docker/nginx/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
348
docs/config-reference/subdomain_discovery.md
Normal file
348
docs/config-reference/subdomain_discovery.md
Normal 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.
|
||||
|
||||
574
docs/redis-stream-queue-design.md
Normal file
574
docs/redis-stream-queue-design.md
Normal file
@@ -0,0 +1,574 @@
|
||||
# Redis Stream 队列方案设计文档
|
||||
|
||||
## 概述
|
||||
|
||||
本文档描述了使用 Redis Stream 作为消息队列来优化大规模数据写入的方案设计。
|
||||
|
||||
## 背景
|
||||
|
||||
### 当前问题
|
||||
|
||||
在扫描大量 Endpoint 数据(几十万条)时,当前的 HTTP 批量写入方案存在以下问题:
|
||||
|
||||
1. **性能瓶颈**:50 万 Endpoint(每个 15 KB)需要 83-166 分钟
|
||||
2. **数据库 I/O 压力**:20 个 Worker 同时写入导致数据库 I/O 满载
|
||||
3. **Worker 阻塞风险**:如果使用批量写入 + 背压机制,Worker 会阻塞等待
|
||||
|
||||
### 方案目标
|
||||
|
||||
- 性能提升 10 倍(83 分钟 → 8 分钟)
|
||||
- Worker 永不阻塞(扫描速度稳定)
|
||||
- 数据不丢失(持久化保证)
|
||||
- 无需部署新组件(利用现有 Redis)
|
||||
|
||||
## 架构设计
|
||||
|
||||
### 整体架构
|
||||
|
||||
```
|
||||
Worker 扫描 → Redis Stream → Server 消费 → PostgreSQL
|
||||
```
|
||||
|
||||
### 数据流
|
||||
|
||||
1. **Worker 端**:扫描到 Endpoint → 发布到 Redis Stream
|
||||
2. **Redis Stream**:缓冲消息(持久化到磁盘)
|
||||
3. **Server 端**:单线程消费 → 批量写入数据库
|
||||
|
||||
### 关键特性
|
||||
|
||||
- **解耦**:Worker 和数据库完全解耦
|
||||
- **背压**:Server 控制消费速度,保护数据库
|
||||
- **持久化**:Redis AOF 保证数据不丢失
|
||||
- **扩展性**:支持多 Worker 并发写入
|
||||
|
||||
## Redis Stream 配置
|
||||
|
||||
### 启用 AOF 持久化
|
||||
|
||||
```conf
|
||||
# redis.conf
|
||||
appendonly yes
|
||||
appendfsync everysec # 每秒同步一次(平衡性能和安全)
|
||||
```
|
||||
|
||||
**效果**:
|
||||
- 数据持久化到磁盘
|
||||
- Redis 崩溃最多丢失 1 秒数据
|
||||
- 性能影响小
|
||||
|
||||
### 内存配置
|
||||
|
||||
```conf
|
||||
# redis.conf
|
||||
maxmemory 2gb
|
||||
maxmemory-policy allkeys-lru # 内存不足时淘汰最少使用的 key
|
||||
```
|
||||
|
||||
## 实现方案
|
||||
|
||||
### 1. Worker 端:发布到 Redis Stream
|
||||
|
||||
#### 代码结构
|
||||
|
||||
```
|
||||
worker/internal/queue/
|
||||
├── redis_publisher.go # Redis 发布者
|
||||
└── types.go # 数据类型定义
|
||||
```
|
||||
|
||||
#### 核心实现
|
||||
|
||||
```go
|
||||
// worker/internal/queue/redis_publisher.go
|
||||
package queue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type RedisPublisher struct {
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
func NewRedisPublisher(redisURL string) (*RedisPublisher, error) {
|
||||
opt, err := redis.ParseURL(redisURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := redis.NewClient(opt)
|
||||
|
||||
// 测试连接
|
||||
if err := client.Ping(context.Background()).Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &RedisPublisher{client: client}, nil
|
||||
}
|
||||
|
||||
// PublishEndpoint 发布 Endpoint 到 Redis Stream
|
||||
func (p *RedisPublisher) PublishEndpoint(ctx context.Context, scanID int, endpoint Endpoint) error {
|
||||
data, err := json.Marshal(endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
streamName := fmt.Sprintf("endpoints:%d", scanID)
|
||||
|
||||
return p.client.XAdd(ctx, &redis.XAddArgs{
|
||||
Stream: streamName,
|
||||
MaxLen: 1000000, // 最多保留 100 万条消息(防止内存溢出)
|
||||
Approx: true, // 使用近似裁剪(性能更好)
|
||||
Values: map[string]interface{}{
|
||||
"data": data,
|
||||
},
|
||||
}).Err()
|
||||
}
|
||||
|
||||
// Close 关闭连接
|
||||
func (p *RedisPublisher) Close() error {
|
||||
return p.client.Close()
|
||||
}
|
||||
```
|
||||
|
||||
#### 使用示例
|
||||
|
||||
```go
|
||||
// Worker 扫描流程
|
||||
func (w *Worker) ScanEndpoints(ctx context.Context, scanID int) error {
|
||||
// 初始化 Redis 发布者
|
||||
publisher, err := queue.NewRedisPublisher(os.Getenv("REDIS_URL"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer publisher.Close()
|
||||
|
||||
// 扫描 Endpoint
|
||||
for endpoint := range w.scan() {
|
||||
// 发布到 Redis Stream(非阻塞,超快)
|
||||
if err := publisher.PublishEndpoint(ctx, scanID, endpoint); err != nil {
|
||||
log.Printf("Failed to publish endpoint: %v", err)
|
||||
// 可以选择重试或记录错误
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Server 端:消费 Redis Stream
|
||||
|
||||
#### 代码结构
|
||||
|
||||
```
|
||||
server/internal/queue/
|
||||
├── redis_consumer.go # Redis 消费者
|
||||
├── batch_writer.go # 批量写入器
|
||||
└── types.go # 数据类型定义
|
||||
```
|
||||
|
||||
#### 核心实现
|
||||
|
||||
```go
|
||||
// server/internal/queue/redis_consumer.go
|
||||
package queue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/yyhuni/lunafox/server/internal/repository"
|
||||
)
|
||||
|
||||
type EndpointConsumer struct {
|
||||
client *redis.Client
|
||||
repository *repository.EndpointRepository
|
||||
}
|
||||
|
||||
func NewEndpointConsumer(redisURL string, repo *repository.EndpointRepository) (*EndpointConsumer, error) {
|
||||
opt, err := redis.ParseURL(redisURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := redis.NewClient(opt)
|
||||
|
||||
return &EndpointConsumer{
|
||||
client: client,
|
||||
repository: repo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start 启动消费者(单线程,控制写入速度)
|
||||
func (c *EndpointConsumer) Start(ctx context.Context, scanID int) error {
|
||||
streamName := fmt.Sprintf("endpoints:%d", scanID)
|
||||
groupName := "endpoint-consumers"
|
||||
consumerName := fmt.Sprintf("server-%d", time.Now().Unix())
|
||||
|
||||
// 创建消费者组(如果不存在)
|
||||
c.client.XGroupCreateMkStream(ctx, streamName, groupName, "0")
|
||||
|
||||
// 批量写入器(每 5000 条批量写入)
|
||||
batchWriter := NewBatchWriter(c.repository, 5000)
|
||||
defer batchWriter.Flush()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
// 读取消息(批量)
|
||||
streams, err := c.client.XReadGroup(ctx, &redis.XReadGroupArgs{
|
||||
Group: groupName,
|
||||
Consumer: consumerName,
|
||||
Streams: []string{streamName, ">"},
|
||||
Count: 100, // 每次读取 100 条
|
||||
Block: 1000, // 阻塞 1 秒
|
||||
}).Result()
|
||||
|
||||
if err != nil {
|
||||
if err == redis.Nil {
|
||||
continue // 没有新消息
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 处理消息
|
||||
for _, stream := range streams {
|
||||
for _, message := range stream.Messages {
|
||||
// 解析消息
|
||||
var endpoint Endpoint
|
||||
if err := json.Unmarshal([]byte(message.Values["data"].(string)), &endpoint); err != nil {
|
||||
// 记录错误,继续处理下一条
|
||||
continue
|
||||
}
|
||||
|
||||
// 添加到批量写入器
|
||||
if err := batchWriter.Add(endpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 确认消息(ACK)
|
||||
c.client.XAck(ctx, streamName, groupName, message.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// 定期 Flush
|
||||
if batchWriter.ShouldFlush() {
|
||||
if err := batchWriter.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close 关闭连接
|
||||
func (c *EndpointConsumer) Close() error {
|
||||
return c.client.Close()
|
||||
}
|
||||
```
|
||||
|
||||
#### 批量写入器
|
||||
|
||||
```go
|
||||
// server/internal/queue/batch_writer.go
|
||||
package queue
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"github.com/yyhuni/lunafox/server/internal/model"
|
||||
"github.com/yyhuni/lunafox/server/internal/repository"
|
||||
)
|
||||
|
||||
type BatchWriter struct {
|
||||
repository *repository.EndpointRepository
|
||||
buffer []model.Endpoint
|
||||
batchSize int
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewBatchWriter(repo *repository.EndpointRepository, batchSize int) *BatchWriter {
|
||||
return &BatchWriter{
|
||||
repository: repo,
|
||||
batchSize: batchSize,
|
||||
buffer: make([]model.Endpoint, 0, batchSize),
|
||||
}
|
||||
}
|
||||
|
||||
// Add 添加到缓冲区
|
||||
func (w *BatchWriter) Add(endpoint model.Endpoint) error {
|
||||
w.mu.Lock()
|
||||
w.buffer = append(w.buffer, endpoint)
|
||||
shouldFlush := len(w.buffer) >= w.batchSize
|
||||
w.mu.Unlock()
|
||||
|
||||
if shouldFlush {
|
||||
return w.Flush()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ShouldFlush 是否应该 Flush
|
||||
func (w *BatchWriter) ShouldFlush() bool {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return len(w.buffer) >= w.batchSize
|
||||
}
|
||||
|
||||
// Flush 批量写入数据库
|
||||
func (w *BatchWriter) Flush() error {
|
||||
w.mu.Lock()
|
||||
if len(w.buffer) == 0 {
|
||||
w.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// 复制缓冲区
|
||||
toWrite := make([]model.Endpoint, len(w.buffer))
|
||||
copy(toWrite, w.buffer)
|
||||
w.buffer = w.buffer[:0]
|
||||
w.mu.Unlock()
|
||||
|
||||
// 批量写入(使用现有的 BulkUpsert 方法)
|
||||
_, err := w.repository.BulkUpsert(toWrite)
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Server 启动消费者
|
||||
|
||||
```go
|
||||
// server/internal/app/app.go
|
||||
func Run(ctx context.Context, cfg config.Config) error {
|
||||
// ... 现有代码
|
||||
|
||||
// 启动 Redis 消费者(后台运行)
|
||||
consumer, err := queue.NewEndpointConsumer(cfg.RedisURL, endpointRepo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
// 消费所有活跃的扫描任务
|
||||
for {
|
||||
// 获取活跃的扫描任务
|
||||
scans := scanRepo.GetActiveScans()
|
||||
for _, scan := range scans {
|
||||
go consumer.Start(ctx, scan.ID)
|
||||
}
|
||||
time.Sleep(10 * time.Second)
|
||||
}
|
||||
}()
|
||||
|
||||
// ... 现有代码
|
||||
}
|
||||
```
|
||||
|
||||
## 性能对比
|
||||
|
||||
### 50 万 Endpoint(每个 15 KB)
|
||||
|
||||
| 方案 | 写入速度 | 总时间 | 内存占用 | Worker 阻塞 |
|
||||
|------|---------|--------|---------|-----------|
|
||||
| **当前(HTTP 批量)** | 100 条/秒 | 83 分钟 | 1.5 MB | 否 |
|
||||
| **Redis Stream** | 1000 条/秒 | 8 分钟 | 75 MB | 否 |
|
||||
|
||||
**提升**:**10 倍性能!**
|
||||
|
||||
## 资源消耗
|
||||
|
||||
### Redis 资源消耗
|
||||
|
||||
| 项目 | 消耗 |
|
||||
|------|------|
|
||||
| 内存 | ~500 MB(缓冲 100 万条消息) |
|
||||
| CPU | ~10%(序列化/反序列化) |
|
||||
| 磁盘 | ~7.5 GB(AOF 持久化) |
|
||||
| 带宽 | ~50 MB/s |
|
||||
|
||||
### Server 资源消耗
|
||||
|
||||
| 项目 | 消耗 |
|
||||
|------|------|
|
||||
| 内存 | 75 MB(批量写入缓冲) |
|
||||
| CPU | 30%(反序列化 + 数据库写入) |
|
||||
| 数据库连接 | 1 个(单线程消费) |
|
||||
|
||||
## 可靠性保证
|
||||
|
||||
### 数据不丢失
|
||||
|
||||
1. **Redis AOF 持久化**:每秒同步到磁盘,最多丢失 1 秒数据
|
||||
2. **消息确认机制**:Server 处理成功后才 ACK
|
||||
3. **自动重试**:未 ACK 的消息会自动重新入队
|
||||
|
||||
### 故障恢复
|
||||
|
||||
| 故障场景 | 恢复机制 |
|
||||
|---------|---------|
|
||||
| Worker 崩溃 | 消息已发送到 Redis,不影响 |
|
||||
| Redis 崩溃 | AOF 恢复,最多丢失 1 秒数据 |
|
||||
| Server 崩溃 | 未 ACK 的消息重新入队 |
|
||||
| 数据库崩溃 | 消息保留在 Redis,恢复后继续消费 |
|
||||
|
||||
## 扩展性
|
||||
|
||||
### 多 Worker 支持
|
||||
|
||||
- Redis Stream 原生支持多个生产者
|
||||
- 无需额外配置
|
||||
|
||||
### 多 Server 消费者
|
||||
|
||||
```go
|
||||
// 启动多个消费者(负载均衡)
|
||||
for i := 0; i < 3; i++ {
|
||||
go consumer.Start(ctx, scanID)
|
||||
}
|
||||
```
|
||||
|
||||
Redis Stream 的消费者组会自动分配消息,实现负载均衡。
|
||||
|
||||
## 监控和运维
|
||||
|
||||
### 监控指标
|
||||
|
||||
```go
|
||||
// 获取队列长度
|
||||
func (c *EndpointConsumer) GetQueueLength(ctx context.Context, scanID int) (int64, error) {
|
||||
streamName := fmt.Sprintf("endpoints:%d", scanID)
|
||||
return c.client.XLen(ctx, streamName).Result()
|
||||
}
|
||||
|
||||
// 获取消费者组信息
|
||||
func (c *EndpointConsumer) GetConsumerGroupInfo(ctx context.Context, scanID int) ([]redis.XInfoGroup, error) {
|
||||
streamName := fmt.Sprintf("endpoints:%d", scanID)
|
||||
return c.client.XInfoGroups(ctx, streamName).Result()
|
||||
}
|
||||
```
|
||||
|
||||
### 清理策略
|
||||
|
||||
```go
|
||||
// 扫描完成后清理 Stream
|
||||
func (c *EndpointConsumer) CleanupStream(ctx context.Context, scanID int) error {
|
||||
streamName := fmt.Sprintf("endpoints:%d", scanID)
|
||||
return c.client.Del(ctx, streamName).Err()
|
||||
}
|
||||
```
|
||||
|
||||
## 配置建议
|
||||
|
||||
### Redis 配置
|
||||
|
||||
```conf
|
||||
# redis.conf
|
||||
|
||||
# 持久化
|
||||
appendonly yes
|
||||
appendfsync everysec
|
||||
|
||||
# 内存
|
||||
maxmemory 2gb
|
||||
maxmemory-policy allkeys-lru
|
||||
|
||||
# 性能
|
||||
tcp-backlog 511
|
||||
timeout 0
|
||||
tcp-keepalive 300
|
||||
```
|
||||
|
||||
### 环境变量
|
||||
|
||||
```bash
|
||||
# Worker 端
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# Server 端
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
```
|
||||
|
||||
## 迁移步骤
|
||||
|
||||
### 阶段 1:准备(1 天)
|
||||
|
||||
1. 启用 Redis AOF 持久化
|
||||
2. 实现 Worker 端 Redis 发布者
|
||||
3. 实现 Server 端 Redis 消费者
|
||||
|
||||
### 阶段 2:测试(2 天)
|
||||
|
||||
1. 单元测试
|
||||
2. 集成测试
|
||||
3. 性能测试(模拟 50 万数据)
|
||||
|
||||
### 阶段 3:灰度发布(3 天)
|
||||
|
||||
1. 10% 流量使用 Redis Stream
|
||||
2. 50% 流量使用 Redis Stream
|
||||
3. 100% 流量使用 Redis Stream
|
||||
|
||||
### 阶段 4:清理(1 天)
|
||||
|
||||
1. 移除旧的 HTTP 批量写入代码
|
||||
2. 更新文档
|
||||
|
||||
## 风险和缓解
|
||||
|
||||
### 风险 1:Redis 内存溢出
|
||||
|
||||
**缓解**:
|
||||
- 设置 `maxmemory` 限制
|
||||
- 使用 `MaxLen` 限制 Stream 长度
|
||||
- 监控 Redis 内存使用
|
||||
|
||||
### 风险 2:消息积压
|
||||
|
||||
**缓解**:
|
||||
- 增加 Server 消费者数量
|
||||
- 优化数据库写入性能
|
||||
- 监控队列长度
|
||||
|
||||
### 风险 3:数据丢失
|
||||
|
||||
**缓解**:
|
||||
- 启用 AOF 持久化
|
||||
- 使用消息确认机制
|
||||
- 定期备份 Redis
|
||||
|
||||
## 总结
|
||||
|
||||
### 优势
|
||||
|
||||
- ✅ 性能提升 10 倍
|
||||
- ✅ Worker 永不阻塞
|
||||
- ✅ 数据不丢失(AOF 持久化)
|
||||
- ✅ 无需部署新组件(利用现有 Redis)
|
||||
- ✅ 架构简单,易于维护
|
||||
|
||||
### 适用场景
|
||||
|
||||
- 数据量 > 10 万
|
||||
- 已有 Redis
|
||||
- 需要高性能写入
|
||||
- 不需要复杂的消息路由
|
||||
|
||||
### 不适用场景
|
||||
|
||||
- 数据量 < 10 万(当前方案足够)
|
||||
- 需要复杂的消息路由(考虑 RabbitMQ)
|
||||
- 数据量 > 1000 万(考虑 Kafka)
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [Redis Stream 官方文档](https://redis.io/docs/data-types/streams/)
|
||||
- [Redis 持久化](https://redis.io/docs/management/persistence/)
|
||||
- [go-redis 文档](https://redis.uptrace.dev/)
|
||||
1
frontend/.factory/skills/vercel-composition-patterns
Symbolic link
1
frontend/.factory/skills/vercel-composition-patterns
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/vercel-composition-patterns
|
||||
1
frontend/.factory/skills/vercel-react-best-practices
Symbolic link
1
frontend/.factory/skills/vercel-react-best-practices
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/vercel-react-best-practices
|
||||
1
frontend/.factory/skills/vercel-react-native-skills
Symbolic link
1
frontend/.factory/skills/vercel-react-native-skills
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/vercel-react-native-skills
|
||||
1
frontend/.factory/skills/web-design-guidelines
Symbolic link
1
frontend/.factory/skills/web-design-guidelines
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/web-design-guidelines
|
||||
12
frontend/.gitignore
vendored
12
frontend/.gitignore
vendored
@@ -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
60
frontend/Dockerfile
Normal file
@@ -0,0 +1,60 @@
|
||||
# Frontend Next.js Dockerfile
|
||||
# Multi-stage build with BuildKit caching
|
||||
|
||||
# ==================== Dependencies stage ====================
|
||||
FROM node:20.20.0-alpine AS deps
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# Copy dependency manifests
|
||||
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
||||
|
||||
# Install dependencies (BuildKit cache)
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# ==================== Build stage ====================
|
||||
FROM node:20.20.0-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# Copy deps
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY frontend/ ./
|
||||
|
||||
# Build-time env
|
||||
ARG IMAGE_TAG=unknown
|
||||
ENV NEXT_PUBLIC_IMAGE_TAG=${IMAGE_TAG}
|
||||
# Use service name "server" inside Docker network
|
||||
ENV API_HOST=server
|
||||
|
||||
# Build (BuildKit cache)
|
||||
RUN --mount=type=cache,target=/app/.next/cache \
|
||||
pnpm build
|
||||
|
||||
# ==================== Runtime stage ====================
|
||||
FROM node:20.20.0-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy build output
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -24,5 +24,9 @@ export default function LoginLayout({
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return children
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
4669
frontend/app/[locale]/prototypes/advanced-asset-pulse/page.tsx
Normal file
4669
frontend/app/[locale]/prototypes/advanced-asset-pulse/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
493
frontend/app/[locale]/prototypes/arknights-ui/page.tsx
Normal file
493
frontend/app/[locale]/prototypes/arknights-ui/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
543
frontend/app/[locale]/prototypes/asset-card-variants/page.tsx
Normal file
543
frontend/app/[locale]/prototypes/asset-card-variants/page.tsx
Normal 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="方案 A:Bauhaus 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="方案 B:Rail 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="方案 C:Split 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="方案 E:Status 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="方案 F:Hover 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="方案 G:Cyber 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>
|
||||
)
|
||||
}
|
||||
478
frontend/app/[locale]/prototypes/asset-pulse-arknights/page.tsx
Normal file
478
frontend/app/[locale]/prototypes/asset-pulse-arknights/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1223
frontend/app/[locale]/prototypes/asset-pulse-demos/page.tsx
Normal file
1223
frontend/app/[locale]/prototypes/asset-pulse-demos/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
559
frontend/app/[locale]/prototypes/asset-pulse-designs/page.tsx
Normal file
559
frontend/app/[locale]/prototypes/asset-pulse-designs/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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">'./core'</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>
|
||||
)
|
||||
}
|
||||
260
frontend/app/[locale]/prototypes/asset-pulse-line-demos/page.tsx
Normal file
260
frontend/app/[locale]/prototypes/asset-pulse-line-demos/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
323
frontend/app/[locale]/prototypes/badge-variants/page.tsx
Normal file
323
frontend/app/[locale]/prototypes/badge-variants/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
458
frontend/app/[locale]/prototypes/dashboard-demo-dark/page.tsx
Normal file
458
frontend/app/[locale]/prototypes/dashboard-demo-dark/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
399
frontend/app/[locale]/prototypes/dashboard-demo/page.tsx
Normal file
399
frontend/app/[locale]/prototypes/dashboard-demo/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
445
frontend/app/[locale]/prototypes/dashboard-rework/page.tsx
Normal file
445
frontend/app/[locale]/prototypes/dashboard-rework/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
55
frontend/app/[locale]/prototypes/header-demo-a/page.tsx
Normal file
55
frontend/app/[locale]/prototypes/header-demo-a/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
54
frontend/app/[locale]/prototypes/header-demo-b/page.tsx
Normal file
54
frontend/app/[locale]/prototypes/header-demo-b/page.tsx
Normal 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 完全无边框,只靠字号和间距建立层次;表格保留自己的边框
|
||||
* 关键 CSS:Header 无任何边框装饰
|
||||
*/
|
||||
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>
|
||||
)
|
||||
}
|
||||
69
frontend/app/[locale]/prototypes/header-demo-c/page.tsx
Normal file
69
frontend/app/[locale]/prototypes/header-demo-c/page.tsx
Normal 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 底边框与表格顶边框合为一条线,形成无缝对接
|
||||
* 关键 CSS:Header 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>
|
||||
)
|
||||
}
|
||||
364
frontend/app/[locale]/prototypes/scan-dialogs/page.tsx
Normal file
364
frontend/app/[locale]/prototypes/scan-dialogs/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
470
frontend/app/[locale]/prototypes/scan-history-actions/page.tsx
Normal file
470
frontend/app/[locale]/prototypes/scan-history-actions/page.tsx
Normal 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 "View". 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 "Are you sure?" 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>
|
||||
)
|
||||
}
|
||||
791
frontend/app/[locale]/prototypes/sidebar-variants/page.tsx
Normal file
791
frontend/app/[locale]/prototypes/sidebar-variants/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
1060
frontend/app/[locale]/prototypes/vuln-designs/page.tsx
Normal file
1060
frontend/app/[locale]/prototypes/vuln-designs/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
137
frontend/app/[locale]/scan/engine/demo/card-grid/page.tsx
Normal file
137
frontend/app/[locale]/scan/engine/demo/card-grid/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
64
frontend/app/[locale]/scan/engine/demo/data.ts
Normal file
64
frontend/app/[locale]/scan/engine/demo/data.ts
Normal 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" }
|
||||
}
|
||||
]
|
||||
228
frontend/app/[locale]/scan/engine/demo/feature-flow/page.tsx
Normal file
228
frontend/app/[locale]/scan/engine/demo/feature-flow/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
283
frontend/app/[locale]/scan/engine/demo/hybrid/page.tsx
Normal file
283
frontend/app/[locale]/scan/engine/demo/hybrid/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
76
frontend/app/[locale]/scan/engine/demo/page.tsx
Normal file
76
frontend/app/[locale]/scan/engine/demo/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
176
frontend/app/[locale]/scan/engine/demo/table-list/page.tsx
Normal file
176
frontend/app/[locale]/scan/engine/demo/table-list/page.tsx
Normal 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
Reference in New Issue
Block a user