refactor: split default theme into standalone repository
This commit is contained in:
1 parent
d7cdbd7a91
commit
acf3b6b57f
43 files changed
+115
-11036
No files matched your search
+3
-1
@@ -14,8 +14,10 @@
|
|||||||
*~
|
*~
|
||||||
|
|
||||||
# 构建产物
|
# 构建产物
|
||||||
# bin/agents/ 和 web/dist/ 不能忽略,运行时从本地文件加载
|
# bin/agents/、web/dist/ 和 themes/ 不能忽略,运行时从本地文件加载
|
||||||
web/node_modules/
|
web/node_modules/
|
||||||
|
# CI 仅使用该 checkout 构建 themes/default,不应进入镜像上下文
|
||||||
|
pika-default-theme
|
||||||
|
|
||||||
# 数据目录
|
# 数据目录
|
||||||
data/
|
data/
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
04ff9915a411debd851bfadba9e28f7294ec910c
|
||||||
@@ -23,6 +23,17 @@ jobs:
|
|||||||
- name: 检出代码
|
- name: 检出代码
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: 读取默认主题锁定版本
|
||||||
|
run: echo "DEFAULT_THEME_REF=$(tr -d '\r\n' < .github/default-theme.ref)" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
# 默认主题作为独立源码仓库参与 Pika 发布构建
|
||||||
|
- name: 检出官方默认主题
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: pika-monitor/pika-default-theme
|
||||||
|
ref: ${{ env.DEFAULT_THEME_REF }}
|
||||||
|
path: pika-default-theme
|
||||||
|
|
||||||
# 安装 make 和 upx
|
# 安装 make 和 upx
|
||||||
- name: 安装 make 和 upx
|
- name: 安装 make 和 upx
|
||||||
run: |
|
run: |
|
||||||
@@ -44,7 +55,7 @@ jobs:
|
|||||||
# 构建所有组件(前端、agents、服务端)
|
# 构建所有组件(前端、agents、服务端)
|
||||||
- name: 构建所有组件
|
- name: 构建所有组件
|
||||||
run: |
|
run: |
|
||||||
make build-web
|
make DEFAULT_THEME_DIR=pika-default-theme build-web
|
||||||
make build-agents
|
make build-agents
|
||||||
make build-servers
|
make build-servers
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,17 @@ jobs:
|
|||||||
- name: 检出代码
|
- name: 检出代码
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: 读取默认主题锁定版本
|
||||||
|
run: echo "DEFAULT_THEME_REF=$(tr -d '\r\n' < .github/default-theme.ref)" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
# 默认主题作为独立源码仓库参与 Pika 发布构建
|
||||||
|
- name: 检出官方默认主题
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: pika-monitor/pika-default-theme
|
||||||
|
ref: ${{ env.DEFAULT_THEME_REF }}
|
||||||
|
path: pika-default-theme
|
||||||
|
|
||||||
# 安装 make 和 upx
|
# 安装 make 和 upx
|
||||||
- name: 安装 make 和 upx
|
- name: 安装 make 和 upx
|
||||||
run: |
|
run: |
|
||||||
@@ -52,7 +63,7 @@ jobs:
|
|||||||
# 构建所有组件(前端、agents、服务端)
|
# 构建所有组件(前端、agents、服务端)
|
||||||
- name: 构建所有组件
|
- name: 构建所有组件
|
||||||
run: |
|
run: |
|
||||||
make build-release
|
make DEFAULT_THEME_DIR=pika-default-theme build-release
|
||||||
|
|
||||||
# 列出编译产物
|
# 列出编译产物
|
||||||
- name: 列出编译产物
|
- name: 列出编译产物
|
||||||
|
|||||||
@@ -42,4 +42,5 @@ agent.yaml
|
|||||||
playground
|
playground
|
||||||
web/node_modules
|
web/node_modules
|
||||||
web/dist
|
web/dist
|
||||||
|
themes/default
|
||||||
deploy.sh
|
deploy.sh
|
||||||
@@ -17,6 +17,7 @@ ARG TARGETARCH
|
|||||||
COPY ./bin/pika-linux-${TARGETARCH} ./pika
|
COPY ./bin/pika-linux-${TARGETARCH} ./pika
|
||||||
COPY ./bin/agents ./bin/agents
|
COPY ./bin/agents ./bin/agents
|
||||||
COPY ./web/dist ./web/dist
|
COPY ./web/dist ./web/dist
|
||||||
|
COPY ./themes ./themes
|
||||||
|
|
||||||
# 暴露端口
|
# 暴露端口
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|||||||
@@ -17,12 +17,27 @@ LDFLAGS=-s -w -X 'github.com/pika-monitor/pika/pkg/version.Version=$(VERSION)' -
|
|||||||
AGENT_LDFLAGS=-s -w -X 'github.com/pika-monitor/pika/pkg/version.Version=$(VERSION)' -X 'github.com/pika-monitor/pika/pkg/version.AgentVersion=$(AGENT_VERSION)'
|
AGENT_LDFLAGS=-s -w -X 'github.com/pika-monitor/pika/pkg/version.Version=$(VERSION)' -X 'github.com/pika-monitor/pika/pkg/version.AgentVersion=$(AGENT_VERSION)'
|
||||||
GOFLAGS=CGO_ENABLED=0
|
GOFLAGS=CGO_ENABLED=0
|
||||||
|
|
||||||
# 构建前端
|
# 默认主题已拆分到独立仓库。本地默认使用同级目录,CI 会显式传入 checkout 目录。
|
||||||
|
DEFAULT_THEME_DIR ?= ../pika-default-theme
|
||||||
|
DEFAULT_THEME_OUTPUT_DIR ?= themes/default
|
||||||
|
|
||||||
|
.PHONY: build-web build-default-theme
|
||||||
|
|
||||||
|
# 构建官方管理前端和独立的默认主题,并组装到统一发布目录。
|
||||||
build-web:
|
build-web:
|
||||||
npm ci --prefix web
|
npm ci --prefix web
|
||||||
npm run build --prefix web
|
npm run build --prefix web
|
||||||
npm ci --prefix portal
|
$(MAKE) build-default-theme
|
||||||
npm run build --prefix portal
|
|
||||||
|
build-default-theme:
|
||||||
|
test -f "$(DEFAULT_THEME_DIR)/package-lock.json"
|
||||||
|
test -f "$(DEFAULT_THEME_DIR)/pika-theme.json"
|
||||||
|
npm ci --prefix "$(DEFAULT_THEME_DIR)"
|
||||||
|
npm run build --prefix "$(DEFAULT_THEME_DIR)"
|
||||||
|
rm -rf "$(DEFAULT_THEME_OUTPUT_DIR)"
|
||||||
|
mkdir -p "$(DEFAULT_THEME_OUTPUT_DIR)"
|
||||||
|
cp "$(DEFAULT_THEME_DIR)/pika-theme.json" "$(DEFAULT_THEME_OUTPUT_DIR)/pika-theme.json"
|
||||||
|
cp -R "$(DEFAULT_THEME_DIR)/dist" "$(DEFAULT_THEME_OUTPUT_DIR)/dist"
|
||||||
|
|
||||||
# 构建服务端(开发)
|
# 构建服务端(开发)
|
||||||
build-server:
|
build-server:
|
||||||
@@ -67,14 +82,15 @@ build-agents:
|
|||||||
|
|
||||||
# 构建所有(release版本)
|
# 构建所有(release版本)
|
||||||
build-release:
|
build-release:
|
||||||
make build-web
|
$(MAKE) build-web
|
||||||
make build-agents
|
$(MAKE) build-agents
|
||||||
make build-servers
|
$(MAKE) build-servers
|
||||||
|
|
||||||
# 清理编译产物
|
# 清理编译产物
|
||||||
clean:
|
clean:
|
||||||
rm -rf bin/*
|
rm -rf bin/*
|
||||||
rm -rf web/dist portal/dist
|
rm -rf web/dist
|
||||||
|
rm -rf themes/default
|
||||||
|
|
||||||
# 运行测试
|
# 运行测试
|
||||||
test:
|
test:
|
||||||
|
|||||||
+5
-3
@@ -4,9 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/pika-monitor/pika/internal/assets"
|
||||||
"github.com/pika-monitor/pika/internal/config"
|
"github.com/pika-monitor/pika/internal/config"
|
||||||
"github.com/pika-monitor/pika/internal/handler"
|
"github.com/pika-monitor/pika/internal/handler"
|
||||||
"github.com/pika-monitor/pika/internal/migrate"
|
"github.com/pika-monitor/pika/internal/migrate"
|
||||||
@@ -275,9 +277,9 @@ func setupApi(app *orz.App, components *AppComponents) error {
|
|||||||
// GitHub 认证路由(如果启用)
|
// GitHub 认证路由(如果启用)
|
||||||
publicApi.POST("/auth/github/callback", components.AccountHandler.GitHubLogin)
|
publicApi.POST("/auth/github/callback", components.AccountHandler.GitHubLogin)
|
||||||
|
|
||||||
// 官方管理 SPA 与活动公开主题使用互不重叠的资源前缀。
|
// 管理前端从 web/dist 由 Echo 直接加载;活动公开主题由主题服务选择独立目录。
|
||||||
e.GET("/admin/assets/*", components.WebHandler.ServeAdminAsset)
|
e.Static("/admin/assets/", filepath.Join(assets.WebDir(), "assets"), handler.ImmutableStaticHeaders)
|
||||||
e.GET("/theme-assets/*", components.WebHandler.ServeThemeAsset)
|
e.GET("/t/*", components.WebHandler.ServeThemeAsset)
|
||||||
e.GET("/*", components.WebHandler.ServeSPA)
|
e.GET("/*", components.WebHandler.ServeSPA)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
defaultWebDir = "web/dist"
|
defaultWebDir = "web/dist"
|
||||||
|
defaultThemeDir = "themes/default"
|
||||||
defaultAgentDir = "bin/agents"
|
defaultAgentDir = "bin/agents"
|
||||||
defaultLogoPath = "portal/public/logo.png"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func WebDir() string {
|
func WebDir() string {
|
||||||
@@ -19,6 +19,13 @@ func WebDir() string {
|
|||||||
return defaultWebDir
|
return defaultWebDir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func DefaultThemeDir() string {
|
||||||
|
if dir := os.Getenv("PIKA_DEFAULT_THEME_DIR"); dir != "" {
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
return defaultThemeDir
|
||||||
|
}
|
||||||
|
|
||||||
func AgentDir() string {
|
func AgentDir() string {
|
||||||
if dir := os.Getenv("PIKA_AGENT_DIR"); dir != "" {
|
if dir := os.Getenv("PIKA_AGENT_DIR"); dir != "" {
|
||||||
return dir
|
return dir
|
||||||
@@ -33,7 +40,7 @@ func AgentPath(filename string) string {
|
|||||||
func DefaultLogoBase64() string {
|
func DefaultLogoBase64() string {
|
||||||
for _, path := range []string{
|
for _, path := range []string{
|
||||||
os.Getenv("PIKA_DEFAULT_LOGO_PATH"),
|
os.Getenv("PIKA_DEFAULT_LOGO_PATH"),
|
||||||
defaultLogoPath,
|
filepath.Join(DefaultThemeDir(), "dist", "logo.png"),
|
||||||
filepath.Join(WebDir(), "logo.png"),
|
filepath.Join(WebDir(), "logo.png"),
|
||||||
} {
|
} {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
|
|||||||
@@ -31,16 +31,12 @@ func (h *WebHandler) PublicConfig(c *echo.Context) error {
|
|||||||
return orz.Ok(c, runtime)
|
return orz.Ok(c, runtime)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *WebHandler) ServeAdminAsset(c *echo.Context) error {
|
|
||||||
return serveStaticFile(c, filepath.Join(assets.WebDir(), "admin", "assets"), c.Param("*"), true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *WebHandler) ServeThemeAsset(c *echo.Context) error {
|
func (h *WebHandler) ServeThemeAsset(c *echo.Context) error {
|
||||||
_, dist, err := h.themeService.ActiveDistDir(c.Request().Context())
|
_, dist, err := h.themeService.ActiveDistDir(c.Request().Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return serveStaticFile(c, dist, c.Param("*"), true)
|
return serveStaticFile(c, dist, c.Param("*"), false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *WebHandler) ServeSPA(c *echo.Context) error {
|
func (h *WebHandler) ServeSPA(c *echo.Context) error {
|
||||||
@@ -74,13 +70,23 @@ func isAdminPage(path string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *WebHandler) serveAdminIndex(c *echo.Context) error {
|
func (h *WebHandler) serveAdminIndex(c *echo.Context) error {
|
||||||
path := filepath.Join(assets.WebDir(), "admin", "index.html")
|
root := assets.WebDir()
|
||||||
data, err := os.ReadFile(path)
|
path := filepath.Join(root, "index.html")
|
||||||
if err != nil {
|
if stat, err := os.Stat(path); err != nil || !stat.Mode().IsRegular() {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, "官方管理前端缺失")
|
return echo.NewHTTPError(http.StatusInternalServerError, "官方管理前端缺失")
|
||||||
}
|
}
|
||||||
setHTMLHeaders(c)
|
setHTMLHeaders(c)
|
||||||
return c.Blob(http.StatusOK, "text/html; charset=utf-8", data)
|
return c.FileFS("index.html", os.DirFS(root))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImmutableStaticHeaders 为带内容摘要文件名的前端构建产物设置长期缓存和安全响应头。
|
||||||
|
func ImmutableStaticHeaders(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c *echo.Context) error {
|
||||||
|
c.Response().Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
c.Response().Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
c.Response().Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *WebHandler) serveThemeIndex(c *echo.Context) error {
|
func (h *WebHandler) serveThemeIndex(c *echo.Context) error {
|
||||||
|
|||||||
@@ -91,20 +91,20 @@ func TestResolveStaticPathAndHeaders(t *testing.T) {
|
|||||||
|
|
||||||
func TestEchoWildcardRoutesKeepAdminAndAPISeparated(t *testing.T) {
|
func TestEchoWildcardRoutesKeepAdminAndAPISeparated(t *testing.T) {
|
||||||
webRoot := t.TempDir()
|
webRoot := t.TempDir()
|
||||||
if err := os.MkdirAll(filepath.Join(webRoot, "admin", "assets"), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(webRoot, "assets"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(webRoot, "admin", "assets", "app.js"), []byte("admin"), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(webRoot, "assets", "app.js"), []byte("admin"), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(webRoot, "admin", "index.html"), []byte("<html>admin</html>"), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("<html>admin</html>"), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
t.Setenv("PIKA_WEB_DIR", webRoot)
|
t.Setenv("PIKA_WEB_DIR", webRoot)
|
||||||
|
|
||||||
e := echo.New()
|
e := echo.New()
|
||||||
handler := &WebHandler{}
|
handler := &WebHandler{}
|
||||||
e.GET("/admin/assets/*", handler.ServeAdminAsset)
|
e.Static("/admin/assets/", filepath.Join(webRoot, "assets"), ImmutableStaticHeaders)
|
||||||
e.GET("/*", handler.ServeSPA)
|
e.GET("/*", handler.ServeSPA)
|
||||||
|
|
||||||
assetRecorder := httptest.NewRecorder()
|
assetRecorder := httptest.NewRecorder()
|
||||||
@@ -112,6 +112,9 @@ func TestEchoWildcardRoutesKeepAdminAndAPISeparated(t *testing.T) {
|
|||||||
if assetRecorder.Code != http.StatusOK || assetRecorder.Body.String() != "admin" {
|
if assetRecorder.Code != http.StatusOK || assetRecorder.Body.String() != "admin" {
|
||||||
t.Fatalf("wildcard asset route failed: status=%d body=%q", assetRecorder.Code, assetRecorder.Body.String())
|
t.Fatalf("wildcard asset route failed: status=%d body=%q", assetRecorder.Code, assetRecorder.Body.String())
|
||||||
}
|
}
|
||||||
|
if assetRecorder.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" {
|
||||||
|
t.Fatalf("admin asset missing immutable cache header: %#v", assetRecorder.Header())
|
||||||
|
}
|
||||||
|
|
||||||
adminRecorder := httptest.NewRecorder()
|
adminRecorder := httptest.NewRecorder()
|
||||||
e.ServeHTTP(adminRecorder, httptest.NewRequest(http.MethodGet, "/admin/agents", nil))
|
e.ServeHTTP(adminRecorder, httptest.NewRequest(http.MethodGet, "/admin/agents", nil))
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ var (
|
|||||||
themeIDPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$`)
|
themeIDPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$`)
|
||||||
reservedThemeIDs = map[string]struct{}{
|
reservedThemeIDs = map[string]struct{}{
|
||||||
"default": {}, "admin": {}, "official": {}, "system": {},
|
"default": {}, "admin": {}, "official": {}, "system": {},
|
||||||
"api": {}, "assets": {}, "theme-assets": {},
|
"api": {}, "assets": {}, "t": {}, "theme-assets": {},
|
||||||
}
|
}
|
||||||
requiredThemeCaps = []string{"server-list", "server-detail", "monitor-list", "monitor-detail"}
|
requiredThemeCaps = []string{"server-list", "server-detail", "monitor-list", "monitor-detail"}
|
||||||
titleElement = regexp.MustCompile(`(?is)<title(?:\s[^>]*)?>.*?</title>`)
|
titleElement = regexp.MustCompile(`(?is)<title(?:\s[^>]*)?>.*?</title>`)
|
||||||
@@ -123,7 +123,7 @@ func NewThemeService(logger *zap.Logger, propertyService *PropertyService, cfg *
|
|||||||
logger: logger,
|
logger: logger,
|
||||||
propertyService: propertyService,
|
propertyService: propertyService,
|
||||||
themeDir: themeDir,
|
themeDir: themeDir,
|
||||||
defaultThemeDir: "portal",
|
defaultThemeDir: assets.DefaultThemeDir(),
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Join(themeDir, ".staging"), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Join(themeDir, ".staging"), 0755); err != nil {
|
||||||
return nil, fmt.Errorf("创建主题目录失败: %w", err)
|
return nil, fmt.Errorf("创建主题目录失败: %w", err)
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ func TestThemeInstallActivateDeleteLifecycle(t *testing.T) {
|
|||||||
webRoot := t.TempDir()
|
webRoot := t.TempDir()
|
||||||
writeOfficialWebBuild(t, webRoot)
|
writeOfficialWebBuild(t, webRoot)
|
||||||
t.Setenv("PIKA_WEB_DIR", webRoot)
|
t.Setenv("PIKA_WEB_DIR", webRoot)
|
||||||
|
t.Setenv("PIKA_DEFAULT_THEME_DIR", filepath.Join(webRoot, "default-theme"))
|
||||||
themeDir := filepath.Join(t.TempDir(), "themes")
|
themeDir := filepath.Join(t.TempDir(), "themes")
|
||||||
|
|
||||||
database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
@@ -267,13 +268,10 @@ func makeInstallableThemeZIP(t *testing.T, manifest ThemeManifest) []byte {
|
|||||||
|
|
||||||
func writeOfficialWebBuild(t *testing.T, webRoot string) {
|
func writeOfficialWebBuild(t *testing.T, webRoot string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if err := os.MkdirAll(filepath.Join(webRoot, "admin", "assets"), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(webRoot, "assets"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Join(webRoot, "admin"), 0o755); err != nil {
|
if err := os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("<html>admin</html>"), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(webRoot, "admin", "index.html"), []byte("<html>admin</html>"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
writeThemeRoot(t, filepath.Join(webRoot, "default-theme"), validTestManifest(DefaultThemeID))
|
writeThemeRoot(t, filepath.Join(webRoot, "default-theme"), validTestManifest(DefaultThemeID))
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ import (
|
|||||||
"golang.org/x/text/unicode/norm"
|
"golang.org/x/text/unicode/norm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// validateOfficialWebBuild 启动时验证官方 web/dist 完整性。
|
// validateOfficialWebBuild 启动时验证官方管理前端和默认主题的发布产物完整性。
|
||||||
func validateOfficialWebBuild(webDir, defaultThemeDir string) error {
|
func validateOfficialWebBuild(webDir, defaultThemeDir string) error {
|
||||||
if version.GetVersion() == "dev" && strings.TrimSpace(os.Getenv("PIKA_WEB_DIR")) == "" {
|
if version.GetVersion() == "dev" && strings.TrimSpace(os.Getenv("PIKA_WEB_DIR")) == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
requiredFiles := []string{
|
requiredFiles := []string{
|
||||||
filepath.Join(webDir, "admin", "index.html"),
|
filepath.Join(webDir, "index.html"),
|
||||||
filepath.Join(defaultThemeDir, "pika-theme.json"),
|
filepath.Join(defaultThemeDir, "pika-theme.json"),
|
||||||
filepath.Join(defaultThemeDir, "dist", "index.html"),
|
filepath.Join(defaultThemeDir, "dist", "index.html"),
|
||||||
}
|
}
|
||||||
@@ -34,9 +34,9 @@ func validateOfficialWebBuild(webDir, defaultThemeDir string) error {
|
|||||||
return fmt.Errorf("官方 Web 构建缺失: %s", path)
|
return fmt.Errorf("官方 Web 构建缺失: %s", path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assetStat, err := os.Stat(filepath.Join(webDir, "admin", "assets"))
|
assetStat, err := os.Stat(filepath.Join(webDir, "assets"))
|
||||||
if err != nil || !assetStat.IsDir() {
|
if err != nil || !assetStat.IsDir() {
|
||||||
return fmt.Errorf("官方管理静态资源目录缺失: %s", filepath.Join(webDir, "admin", "assets"))
|
return fmt.Errorf("官方管理静态资源目录缺失: %s", filepath.Join(webDir, "assets"))
|
||||||
}
|
}
|
||||||
manifest, err := loadThemeManifest(filepath.Join(defaultThemeDir, "pika-theme.json"))
|
manifest, err := loadThemeManifest(filepath.Join(defaultThemeDir, "pika-theme.json"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
.DS_Store
|
|
||||||
yarn.lock
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import globals from 'globals';
|
|
||||||
import tseslint from 'typescript-eslint';
|
|
||||||
|
|
||||||
export default [
|
|
||||||
{ignores: ['dist', 'node_modules']},
|
|
||||||
{
|
|
||||||
files: ['src/**/*.{ts,tsx}'],
|
|
||||||
languageOptions: {
|
|
||||||
parser: tseslint.parser,
|
|
||||||
parserOptions: {ecmaVersion: 'latest', sourceType: 'module', ecmaFeatures: {jsx: true}},
|
|
||||||
globals: {...globals.browser, ...globals.es2022},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8"/>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
||||||
<link rel="icon" href="/api/logo"/>
|
|
||||||
<title>Pika Monitor</title>
|
|
||||||
<!-- pika:head -->
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<!-- pika:body -->
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Generated
-3618
File diff suppressed because it is too large.
Load diff
@@ -1,36 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@pika/default-theme",
|
|
||||||
"private": true,
|
|
||||||
"version": "1.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "tsc -b && vite build",
|
|
||||||
"lint": "eslint src"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-tooltip": "^1.2.12",
|
|
||||||
"@tanstack/react-query": "^5.101.2",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"dayjs": "^1.11.21",
|
|
||||||
"lucide-react": "^1.24.0",
|
|
||||||
"react": "^19.2.7",
|
|
||||||
"react-dom": "^19.2.7",
|
|
||||||
"react-router-dom": "^7.18.1",
|
|
||||||
"recharts": "^3.9.2",
|
|
||||||
"tailwind-merge": "^3.6.0",
|
|
||||||
"tw-animate-css": "^1.4.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@tailwindcss/vite": "^4.3.2",
|
|
||||||
"@types/node": "^26.1.1",
|
|
||||||
"@types/react": "^19.2.17",
|
|
||||||
"@types/react-dom": "^19.2.3",
|
|
||||||
"@vitejs/plugin-react": "^6.0.3",
|
|
||||||
"eslint": "^10.7.0",
|
|
||||||
"globals": "^17.7.0",
|
|
||||||
"typescript": "~6.0.3",
|
|
||||||
"typescript-eslint": "^8.63.0",
|
|
||||||
"vite": "8.1.4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"schemaVersion": 1,
|
|
||||||
"id": "default",
|
|
||||||
"name": "Pika Official",
|
|
||||||
"description": "Pika 官方默认公开主题",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"author": "dushixiang",
|
|
||||||
"homepage": "https://github.com/dushixiang/pika",
|
|
||||||
"license": "MIT",
|
|
||||||
"preview": "dist/logo.png",
|
|
||||||
"entry": "dist/index.html",
|
|
||||||
"apiVersion": "v1",
|
|
||||||
"capabilities": ["server-list", "server-detail", "monitor-list", "monitor-detail"]
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 226 KiB |
@@ -1,28 +0,0 @@
|
|||||||
import {lazy, Suspense} from 'react';
|
|
||||||
import {BrowserRouter, Route, Routes} from 'react-router-dom';
|
|
||||||
import {ColorModeProvider} from './contexts/ColorMode';
|
|
||||||
|
|
||||||
const PublicLayout = lazy(() => import('./pages/PublicLayout'));
|
|
||||||
const ServerList = lazy(() => import('./pages/ServerList'));
|
|
||||||
const ServerDetail = lazy(() => import('./pages/ServerDetail'));
|
|
||||||
const MonitorList = lazy(() => import('./pages/MonitorList'));
|
|
||||||
const MonitorDetail = lazy(() => import('./pages/MonitorDetail'));
|
|
||||||
|
|
||||||
export default function PortalApp() {
|
|
||||||
return (
|
|
||||||
<ColorModeProvider>
|
|
||||||
<BrowserRouter>
|
|
||||||
<Suspense fallback={<div className="flex h-[75vh] items-center justify-center text-gray-500 dark:text-cyan-300">页面加载中...</div>}>
|
|
||||||
<Routes>
|
|
||||||
<Route element={<PublicLayout/>}>
|
|
||||||
<Route path="/" element={<ServerList/>}/>
|
|
||||||
<Route path="/servers/:id" element={<ServerDetail/>}/>
|
|
||||||
<Route path="/monitors" element={<MonitorList/>}/>
|
|
||||||
<Route path="/monitors/:id" element={<MonitorDetail/>}/>
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
|
||||||
</Suspense>
|
|
||||||
</BrowserRouter>
|
|
||||||
</ColorModeProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
/* Pika 公开 API 客户端。自动拼接 /api 前缀并从 localStorage 读取登录 token。 */
|
|
||||||
|
|
||||||
import type {
|
|
||||||
PikaRuntimeConfig, ColorMode,
|
|
||||||
Agent, LatestMetrics, PublicMonitor, AgentMonitorStat,
|
|
||||||
MetricsResponse, MetricsParams, HistoryParams,
|
|
||||||
TagsResponse, NetworkInterfacesResponse, ListAgentParams, CurrentUser,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
export class PikaAPIError extends Error {
|
|
||||||
constructor(public status: number, message: string, public data?: unknown) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'PikaAPIError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Query = Record<string, string | number | boolean | undefined>;
|
|
||||||
|
|
||||||
const request = async <T>(path: string, query?: Query): Promise<T> => {
|
|
||||||
const search = new URLSearchParams();
|
|
||||||
for (const [key, value] of Object.entries(query || {})) {
|
|
||||||
if (value !== undefined) search.set(key, String(value));
|
|
||||||
}
|
|
||||||
const token = typeof localStorage === 'undefined' ? null : localStorage.getItem('token');
|
|
||||||
const response = await fetch('/api' + path + (search.size ? '?' + search : ''), {
|
|
||||||
headers: token ? {Authorization: 'Bearer ' + token} : {},
|
|
||||||
});
|
|
||||||
const contentType = response.headers.get('content-type') || '';
|
|
||||||
const data: unknown = contentType.includes('application/json') ? await response.json() : await response.text();
|
|
||||||
if (!response.ok) {
|
|
||||||
const message = typeof data === 'object' && data !== null && 'message' in data && typeof data.message === 'string'
|
|
||||||
? data.message
|
|
||||||
: 'Pika API 请求失败';
|
|
||||||
throw new PikaAPIError(response.status, message, data);
|
|
||||||
}
|
|
||||||
return data as T;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const pika = {
|
|
||||||
getConfig: () => request<PikaRuntimeConfig>('/config'),
|
|
||||||
listAgents: <T = Agent>(params: ListAgentParams = {}) => request<T[]>('/agents', params),
|
|
||||||
getAgent: <T = Agent>(id: string) => request<T>('/agents/' + encodeURIComponent(id)),
|
|
||||||
getLatestMetrics: <T = LatestMetrics>(id: string) =>
|
|
||||||
request<T>('/agents/' + encodeURIComponent(id) + '/metrics/latest'),
|
|
||||||
getMetrics: <T = MetricsResponse>(id: string, params: MetricsParams = {}) =>
|
|
||||||
request<T>('/agents/' + encodeURIComponent(id) + '/metrics', params),
|
|
||||||
getTags: <T = TagsResponse>() => request<T>('/agents/tags'),
|
|
||||||
getNetworkInterfaces: <T = NetworkInterfacesResponse>(id: string) =>
|
|
||||||
request<T>('/agents/' + encodeURIComponent(id) + '/network-interfaces'),
|
|
||||||
listMonitors: <T = PublicMonitor>() => request<T[]>('/monitors'),
|
|
||||||
getMonitorStats: <T = Record<string, unknown>>(id: string) =>
|
|
||||||
request<T>('/monitors/' + encodeURIComponent(id) + '/stats'),
|
|
||||||
getMonitorAgents: <T = AgentMonitorStat[]>(id: string) =>
|
|
||||||
request<T[]>('/monitors/' + encodeURIComponent(id) + '/agents'),
|
|
||||||
getMonitorHistory: <T = MetricsResponse>(id: string, params: HistoryParams = {}) =>
|
|
||||||
request<T>('/monitors/' + encodeURIComponent(id) + '/history', params),
|
|
||||||
getCurrentUser: <T = CurrentUser>() => request<T>('/admin/account/info'),
|
|
||||||
};
|
|
||||||
|
|
||||||
/* 运行时配置(后端在入口 HTML 注入 window.PikaRuntime) */
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {PikaRuntime?: PikaRuntimeConfig}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getRuntimeConfig = (): PikaRuntimeConfig => {
|
|
||||||
if (typeof window === 'undefined' || !window.PikaRuntime) {
|
|
||||||
throw new Error('PikaRuntime 尚未注入');
|
|
||||||
}
|
|
||||||
return window.PikaRuntime;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const resolveColorMode = (mode: ColorMode): Exclude<ColorMode, 'system'> => {
|
|
||||||
if (mode !== 'system') return mode;
|
|
||||||
return typeof matchMedia !== 'undefined' && matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
||||||
};
|
|
||||||
@@ -1,768 +0,0 @@
|
|||||||
import {type ReactNode, useEffect, useState} from 'react';
|
|
||||||
import {Activity, AlertCircle, Heart, Loader2, LogIn, Menu, Moon, ServerIcon, Settings, Sun, TrendingUp, X} from 'lucide-react';
|
|
||||||
import {Link, useLocation} from 'react-router-dom';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import type {TimeRangeOption} from './types';
|
|
||||||
import {getRuntimeConfig, pika} from './api';
|
|
||||||
import {cn} from './lib/utils';
|
|
||||||
import {useColorMode} from './contexts/ColorMode';
|
|
||||||
|
|
||||||
/* ========================================== Card ========================================== */
|
|
||||||
|
|
||||||
interface CardProps {
|
|
||||||
title?: ReactNode;
|
|
||||||
description?: ReactNode;
|
|
||||||
action?: ReactNode;
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
interactive?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Card = ({title, description, action, children, className, interactive}: CardProps) => {
|
|
||||||
const hasHeader = title || description || action;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn(
|
|
||||||
'group relative overflow-hidden rounded-xl border border-slate-200 bg-white/90 shadow-sm backdrop-blur-md transition-all duration-300 dark:rounded-none dark:border-cyan-500/20 dark:bg-[#0f1016]/80 dark:shadow-[0_0_15px_rgba(6,182,212,0.05)]',
|
|
||||||
interactive && 'cursor-pointer hover:border-slate-300 hover:bg-white dark:hover:border-cyan-500/50 dark:hover:bg-[#0f1016]/90',
|
|
||||||
)}>
|
|
||||||
<span className="hidden dark:block absolute top-0 left-0 w-2 h-2 border-t-2 border-l-2 border-cyan-500/30 group-hover:border-cyan-400 transition-colors duration-300"/>
|
|
||||||
<span className="hidden dark:block absolute top-0 right-0 w-2 h-2 border-t-2 border-r-2 border-cyan-500/30 group-hover:border-cyan-400 transition-colors duration-300"/>
|
|
||||||
<span className="hidden dark:block absolute bottom-0 left-0 w-2 h-2 border-b-2 border-l-2 border-cyan-500/30 group-hover:border-cyan-400 transition-colors duration-300"/>
|
|
||||||
<span className="hidden dark:block absolute bottom-0 right-0 w-2 h-2 border-b-2 border-r-2 border-cyan-500/30 group-hover:border-cyan-400 transition-colors duration-300"/>
|
|
||||||
{interactive && (
|
|
||||||
<span className="hidden dark:block absolute inset-0 bg-gradient-to-b from-transparent via-cyan-500/5 to-transparent opacity-0 group-hover:opacity-100 -translate-y-full group-hover:translate-y-full transition-[transform,opacity] duration-1000 ease-in-out pointer-events-none will-change-transform"/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={cn('relative z-10', hasHeader ? 'p-6' : 'p-4', className)}>
|
|
||||||
{hasHeader && (
|
|
||||||
<div className="flex flex-col gap-3 border-b border-slate-200 dark:border-slate-700 pb-4 sm:flex-row sm:items-start sm:justify-between">
|
|
||||||
<div>
|
|
||||||
{title && <h2 className="text-lg font-semibold text-slate-900 dark:text-white">{title}</h2>}
|
|
||||||
{description && <p className="mt-1 text-xs font-mono text-gray-600 dark:text-cyan-500">{description}</p>}
|
|
||||||
</div>
|
|
||||||
{action && <div className="shrink-0">{action}</div>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className={cn(hasHeader && 'pt-4')}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ========================================== ChartPlaceholder ========================================== */
|
|
||||||
|
|
||||||
interface ChartPlaceholderProps {
|
|
||||||
icon?: typeof TrendingUp;
|
|
||||||
title?: string;
|
|
||||||
subtitle?: string;
|
|
||||||
heightClass?: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ChartPlaceholder = ({
|
|
||||||
icon: Icon = TrendingUp,
|
|
||||||
title = '暂无数据',
|
|
||||||
subtitle = '等待采集新数据后展示图表',
|
|
||||||
heightClass = 'h-52',
|
|
||||||
className,
|
|
||||||
}: ChartPlaceholderProps) => {
|
|
||||||
return (
|
|
||||||
<div className={cn(
|
|
||||||
"flex items-center justify-center rounded-lg border border-dashed border-slate-200 dark:border-slate-700 text-sm text-slate-500 dark:text-slate-400",
|
|
||||||
heightClass,
|
|
||||||
className
|
|
||||||
)}>
|
|
||||||
<div className="text-center">
|
|
||||||
<Icon className="mx-auto mb-3 h-10 w-10 text-slate-300 dark:text-slate-600"/>
|
|
||||||
<p className="font-medium">{title}</p>
|
|
||||||
{subtitle && (
|
|
||||||
<p className="mt-1 text-xs text-slate-400 dark:text-slate-500">
|
|
||||||
{subtitle}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ========================================== CustomTooltip ========================================== */
|
|
||||||
|
|
||||||
type CustomTooltipProps = {
|
|
||||||
active?: boolean;
|
|
||||||
payload?: Array<{
|
|
||||||
name?: string;
|
|
||||||
value?: number;
|
|
||||||
color?: string;
|
|
||||||
dataKey?: string;
|
|
||||||
payload?: {
|
|
||||||
timestamp?: number | string;
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
label?: string | number;
|
|
||||||
unit?: string;
|
|
||||||
className?: string;
|
|
||||||
timeFormat?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CustomTooltip = ({active, payload, label, unit = '%', className, timeFormat = 'MM-DD HH:mm'}: CustomTooltipProps) => {
|
|
||||||
if (!active || !payload || payload.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从 payload 中获取完整的时间戳信息(如果有的话)
|
|
||||||
const fullTimestamp = payload[0]?.payload?.timestamp;
|
|
||||||
const displayLabel = fullTimestamp
|
|
||||||
? dayjs(fullTimestamp).format(timeFormat)
|
|
||||||
: label;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn(
|
|
||||||
"rounded-md border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shadow-lg px-3 py-2 text-xs",
|
|
||||||
className
|
|
||||||
)}>
|
|
||||||
<p className="font-semibold text-slate-700 dark:text-white mb-2">
|
|
||||||
{displayLabel}
|
|
||||||
</p>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{payload.map((entry, index) => {
|
|
||||||
if (!entry) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dotColor = entry.color ?? '#6366f1';
|
|
||||||
const title = entry.name ?? entry.dataKey ?? `系列 ${index + 1}`;
|
|
||||||
const value = typeof entry.value === 'number'
|
|
||||||
? Number.isFinite(entry.value)
|
|
||||||
? entry.value.toFixed(2)
|
|
||||||
: '-'
|
|
||||||
: entry.value;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<p key={`${entry.dataKey ?? index}`} className="flex items-center gap-2 text-xs">
|
|
||||||
<span
|
|
||||||
className="inline-block h-2 w-2 rounded-full"
|
|
||||||
style={{backgroundColor: dotColor}}
|
|
||||||
/>
|
|
||||||
<span className="text-slate-600 dark:text-slate-400">
|
|
||||||
{title}: <span className="font-semibold text-slate-900 dark:text-white">{value}{unit}</span>
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ========================================== EmptyState ========================================== */
|
|
||||||
|
|
||||||
interface EmptyStateProps {
|
|
||||||
message?: string;
|
|
||||||
showBackButton?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EmptyState = ({message = '监控数据不存在', showBackButton = false, className}: EmptyStateProps) => {
|
|
||||||
return (
|
|
||||||
<div className={cn(
|
|
||||||
"flex min-h-screen items-center justify-center bg-slate-50 dark:bg-slate-900",
|
|
||||||
className
|
|
||||||
)}>
|
|
||||||
<div className="flex flex-col items-center gap-3 text-center">
|
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-slate-100 dark:bg-slate-800 text-slate-400 dark:text-slate-500">
|
|
||||||
<AlertCircle className="h-8 w-8"/>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm font-mono text-slate-600 dark:text-slate-400">
|
|
||||||
{message}
|
|
||||||
</p>
|
|
||||||
{showBackButton && (
|
|
||||||
<button
|
|
||||||
onClick={() => window.history.back()}
|
|
||||||
className="mt-4 text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
|
||||||
>
|
|
||||||
返回监控列表
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ========================================== LoadingSpinner ========================================== */
|
|
||||||
|
|
||||||
interface LoadingSpinnerProps {
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LoadingSpinner = ({message}: LoadingSpinnerProps) => {
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn(
|
|
||||||
"flex h-[75vh] items-center justify-center bg-slate-50 dark:bg-[#05050a]",
|
|
||||||
)}>
|
|
||||||
<div className="flex flex-col items-center gap-3">
|
|
||||||
<Loader2 className={"h-8 w-8 animate-spin text-blue-500 dark:text-cyan-500"}/>
|
|
||||||
<p className={cn(
|
|
||||||
"text-sm font-mono text-slate-500 dark:text-cyan-400",
|
|
||||||
)}>
|
|
||||||
{message || '数据加载中,请稍候...'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ========================================== MetricItem ========================================== */
|
|
||||||
|
|
||||||
// 统计卡片组件
|
|
||||||
export const MetricItem = ({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string | number;
|
|
||||||
}) => (
|
|
||||||
<div
|
|
||||||
key={label}
|
|
||||||
className="rounded-xl bg-slate-50 dark:bg-black/40 border border-slate-200 dark:border-cyan-900/50 p-4 text-left hover:border-slate-300 dark:hover:border-cyan-700/50 transition"
|
|
||||||
>
|
|
||||||
<p className="text-sm uppercase tracking-[0.3em] text-gray-700 dark:text-cyan-500 font-mono font-bold">{label}</p>
|
|
||||||
<p className="mt-2 text-base font-semibold text-slate-800 dark:text-cyan-100">{value}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
/* ========================================== StatBlock ========================================== */
|
|
||||||
|
|
||||||
// 统计卡片组件
|
|
||||||
interface StatBlockProps {
|
|
||||||
title: string;
|
|
||||||
value: any;
|
|
||||||
unit?: string
|
|
||||||
icon: any;
|
|
||||||
color: string;
|
|
||||||
alert?: boolean,
|
|
||||||
glow?: boolean,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const StatBlock = ({title, value, unit, icon: Icon, color, alert, glow}: StatBlockProps) => {
|
|
||||||
|
|
||||||
const colorMap = {
|
|
||||||
cyan: 'dark:text-cyan-400 dark:border-cyan-500/30 dark:bg-cyan-500/5',
|
|
||||||
emerald: 'dark:text-emerald-400 dark:border-emerald-500/30 dark:bg-emerald-500/5',
|
|
||||||
rose: 'dark:text-rose-400 dark:border-rose-500/30 dark:bg-rose-500/5',
|
|
||||||
purple: 'dark:text-purple-400 dark:border-purple-500/30 dark:bg-purple-500/5'
|
|
||||||
};
|
|
||||||
const style = colorMap[color] || colorMap.cyan;
|
|
||||||
|
|
||||||
const iconColor = {
|
|
||||||
cyan: 'text-cyan-400',
|
|
||||||
emerald: 'text-emerald-400',
|
|
||||||
rose: 'text-rose-400',
|
|
||||||
purple: 'text-purple-400'
|
|
||||||
}
|
|
||||||
let iconStyle = iconColor[color] || colorMap.cyan;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
`relative overflow-hidden rounded-xl border p-5`,
|
|
||||||
'bg-white/80 backdrop-blur-md border border-slate-200 shadow-sm',
|
|
||||||
style,
|
|
||||||
alert && 'animate-pulse bg-rose-500/10',
|
|
||||||
glow && 'shadow-[0_0_20px_rgba(16,185,129,0.1)]',
|
|
||||||
)}>
|
|
||||||
<div className="absolute -right-4 -bottom-4 opacity-10 rotate-[-15deg]"><Icon className="w-24 h-24"/></div>
|
|
||||||
<div className="relative z-10 flex justify-between items-start">
|
|
||||||
<div>
|
|
||||||
<div className="text-xs font-bold font-mono uppercase tracking-widest opacity-70 mb-2">{title}</div>
|
|
||||||
<div className="text-4xl font-black tracking-tight flex items-baseline gap-1">{value}{unit &&
|
|
||||||
<span className="text-sm font-normal opacity-60 ml-1">{unit}</span>}</div>
|
|
||||||
</div>
|
|
||||||
<div className={`p-3`}>
|
|
||||||
<Icon className={cn("w-6 h-6", iconStyle)}/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ========================================== StatusBadge ========================================== */
|
|
||||||
|
|
||||||
interface StatusBadgeProps {
|
|
||||||
status: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const StatusBadge = ({status}: StatusBadgeProps) => {
|
|
||||||
const styles = {
|
|
||||||
up: "bg-emerald-500/10 dark:bg-emerald-500/10 text-emerald-400 dark:text-emerald-400 border-emerald-500/80",
|
|
||||||
down: "bg-rose-500/10 dark:bg-rose-500/10 text-rose-400 dark:text-rose-400 border-rose-500/80",
|
|
||||||
unknown: "bg-slate-100 dark:bg-slate-800 text-slate-400 dark:text-slate-300 border-slate-200 dark:border-slate-700",
|
|
||||||
};
|
|
||||||
|
|
||||||
const labels = {
|
|
||||||
up: "正常",
|
|
||||||
down: "异常",
|
|
||||||
unknown: "未知",
|
|
||||||
};
|
|
||||||
|
|
||||||
const style = styles[status as keyof typeof styles] || styles.unknown;
|
|
||||||
const label = labels[status as keyof typeof labels] || labels.unknown;
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn("px-2.5 py-0.5 rounded-xl text-xs font-medium border flex items-center gap-1.5 w-fit", style)}>
|
|
||||||
<span className={cn(
|
|
||||||
"w-1.5 h-1.5 rounded-full animate-pulse",
|
|
||||||
status === 'up' ? 'bg-emerald-500 dark:bg-emerald-400' :
|
|
||||||
status === 'down' ? 'bg-rose-500 dark:bg-rose-400' :
|
|
||||||
'bg-slate-400 dark:bg-slate-400'
|
|
||||||
)}/>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ========================================== PublicHeader ========================================== */
|
|
||||||
|
|
||||||
export const PublicHeader = () => {
|
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
|
||||||
const {resolvedColorMode: appliedTheme, setColorMode: setTheme} = useColorMode();
|
|
||||||
let location = useLocation();
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// 检查本地是否有 token
|
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
const userInfo = localStorage.getItem('userInfo');
|
|
||||||
|
|
||||||
if (!token || !userInfo) {
|
|
||||||
setIsLoggedIn(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用后端接口验证 token 是否有效
|
|
||||||
pika.getCurrentUser()
|
|
||||||
.then(() => {
|
|
||||||
setIsLoggedIn(true);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// token 无效,清除本地存储
|
|
||||||
localStorage.removeItem('token');
|
|
||||||
localStorage.removeItem('userInfo');
|
|
||||||
setIsLoggedIn(false);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 时钟特效
|
|
||||||
useEffect(() => {
|
|
||||||
const t = setInterval(() => setCurrentTime(new Date()), 1000);
|
|
||||||
return () => clearInterval(t);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 判断导航是否激活
|
|
||||||
const currentPath = location.pathname;
|
|
||||||
|
|
||||||
let activeTab = 'servers';
|
|
||||||
|
|
||||||
if (currentPath.startsWith('/monitors')) {
|
|
||||||
activeTab = 'monitors';
|
|
||||||
}
|
|
||||||
|
|
||||||
const runtime = getRuntimeConfig();
|
|
||||||
let systemName = runtime.system.nameEn;
|
|
||||||
|
|
||||||
let leftName = '';
|
|
||||||
let rightName = '';
|
|
||||||
|
|
||||||
if (systemName) {
|
|
||||||
// 优先在空格处分割
|
|
||||||
const spaceIndex = systemName.indexOf(' ');
|
|
||||||
if (spaceIndex > 0) {
|
|
||||||
leftName = systemName.substring(0, spaceIndex);
|
|
||||||
rightName = systemName.substring(spaceIndex); // 保留空格
|
|
||||||
} else {
|
|
||||||
// 如果没有空格,从中间分割
|
|
||||||
const mid = Math.floor(systemName.length / 2);
|
|
||||||
leftName = systemName.substring(0, mid);
|
|
||||||
rightName = systemName.substring(mid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<header
|
|
||||||
className="border-b border-slate-200 dark:border-cyan-900/50 bg-white/80 dark:bg-[#05050a]/80 backdrop-blur-xl fixed top-0 left-0 right-0 z-40 transition-colors duration-300">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 h-20 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-8">
|
|
||||||
<Link to={'/'}>
|
|
||||||
<div className="flex items-center gap-3 group cursor-pointer">
|
|
||||||
<div className="relative">
|
|
||||||
<img
|
|
||||||
src={"/api/logo"}
|
|
||||||
className="h-8 w-8 sm:h-9 sm:w-9 object-contain rounded-md"
|
|
||||||
alt={'logo'}
|
|
||||||
onError={(e) => {
|
|
||||||
e.currentTarget.src = '/logo.png';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-black tracking-widest text-transparent bg-clip-text bg-gradient-to-r from-cyan-500 via-blue-500 to-purple-500 dark:from-cyan-400 dark:via-blue-400 dark:to-purple-400 uppercase italic">
|
|
||||||
{leftName}<span className="text-slate-800 dark:text-white">{rightName}</span>
|
|
||||||
</h1>
|
|
||||||
<p className="text-xs text-slate-500 dark:text-cyan-500 font-mono tracking-[0.3em] uppercase">
|
|
||||||
{runtime.system.nameZh}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* HUD Navigation - Desktop Only */}
|
|
||||||
<div className="hidden md:flex items-center gap-8">
|
|
||||||
{[
|
|
||||||
{id: 'servers', icon: ServerIcon, label: '设备监控', to: '/'},
|
|
||||||
{id: 'monitors', icon: Activity, label: '服务监控', to: '/monitors'}
|
|
||||||
].map(tab => (
|
|
||||||
<Link to={tab.to} key={tab.id}>
|
|
||||||
<button
|
|
||||||
className={`
|
|
||||||
relative group flex items-center gap-2 py-2 text-xs font-bold tracking-widest transition-colors cursor-pointer font-mono uppercase
|
|
||||||
${activeTab === tab.id ? 'text-blue-600 dark:text-cyan-500' : 'text-slate-500 dark:text-slate-400 hover:text-blue-500 dark:hover:text-cyan-200'}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<tab.icon
|
|
||||||
className={`w-4 h-4 ${activeTab === tab.id ? 'text-blue-600 dark:text-cyan-500' : 'text-slate-400 dark:text-slate-600 group-hover:text-blue-500 dark:group-hover:text-cyan-200'}`}/>
|
|
||||||
{tab.label}
|
|
||||||
|
|
||||||
{/* Active Indicator (Underline Glow) */}
|
|
||||||
<span
|
|
||||||
className={`absolute -bottom-1 left-0 w-full h-[2px] bg-blue-600 dark:bg-cyan-500 shadow-[0_0_10px_rgba(37,99,235,0.8)] dark:shadow-[0_0_10px_rgba(34,211,238,0.8)] transition-transform duration-300 origin-left ${activeTab === tab.id ? 'scale-x-100' : 'scale-x-0'}`}></span>
|
|
||||||
</button>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop Right Section */}
|
|
||||||
<div className="hidden md:flex items-center gap-2">
|
|
||||||
<div className="hidden lg:flex flex-col items-end">
|
|
||||||
<span
|
|
||||||
className="text-xs font-mono text-slate-800 dark:text-cyan-500 font-bold">{currentTime.toLocaleTimeString()}</span>
|
|
||||||
<span
|
|
||||||
className="text-xs text-slate-500 dark:text-cyan-500 font-mono tracking-widest">{currentTime.toLocaleDateString()}</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-6 w-[1px] bg-slate-300 dark:bg-cyan-900/50 hidden lg:block"></div>
|
|
||||||
|
|
||||||
{/* 主题切换按钮 - Desktop */}
|
|
||||||
<button
|
|
||||||
onClick={() => setTheme(appliedTheme === 'dark' ? 'light' : 'dark')}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 cursor-pointer rounded transition-all text-slate-600 dark:text-cyan-400 hover:bg-slate-100 dark:hover:bg-cyan-500/10"
|
|
||||||
title={appliedTheme === 'dark' ? '切换到浅色模式' : '切换到暗黑模式'}
|
|
||||||
>
|
|
||||||
{appliedTheme === 'dark' ? (
|
|
||||||
<Sun className="w-4 h-4"/>
|
|
||||||
) : (
|
|
||||||
<Moon className="w-4 h-4"/>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* 登录/管理后台按钮 - Desktop */}
|
|
||||||
{isLoggedIn ? (
|
|
||||||
<a
|
|
||||||
href="/admin"
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-cyan-50 dark:bg-cyan-500/10 hover:bg-cyan-100 dark:hover:bg-cyan-500/20 text-cyan-500 rounded transition-all text-xs font-bold tracking-wider uppercase group"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<Settings className="w-3 h-3 group-hover:rotate-90 transition-transform"/>
|
|
||||||
<span>Admin</span>
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<a
|
|
||||||
href="/admin/login"
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-cyan-50 dark:bg-cyan-500/10 hover:bg-cyan-100 dark:hover:bg-cyan-500/20 text-cyan-500 rounded transition-all text-xs font-bold tracking-wider uppercase group"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<LogIn className="w-3 h-3"/>
|
|
||||||
<span>Login</span>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Menu Button */}
|
|
||||||
<button
|
|
||||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
|
||||||
className="md:hidden p-2 text-cyan-500 hover:bg-cyan-500/10 rounded transition-colors"
|
|
||||||
aria-label="Toggle menu"
|
|
||||||
>
|
|
||||||
{mobileMenuOpen ? (
|
|
||||||
<X className="w-6 h-6"/>
|
|
||||||
) : (
|
|
||||||
<Menu className="w-6 h-6"/>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="h-[1px] w-full bg-gradient-to-r from-transparent via-cyan-500/30 to-transparent"></div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Mobile Menu */}
|
|
||||||
{mobileMenuOpen && (
|
|
||||||
<div
|
|
||||||
className="md:hidden fixed inset-0 top-20 bg-white/95 dark:bg-[#05050a]/95 backdrop-blur-xl z-30 animate-in slide-in-from-top">
|
|
||||||
<div className="flex flex-col p-4 gap-4">
|
|
||||||
{/* Mobile Navigation */}
|
|
||||||
{[
|
|
||||||
{id: 'servers', icon: ServerIcon, label: '设备监控', to: '/'},
|
|
||||||
{id: 'monitors', icon: Activity, label: '服务监控', to: '/monitors'}
|
|
||||||
].map(tab => (
|
|
||||||
<Link
|
|
||||||
to={tab.to}
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
|
||||||
className={`
|
|
||||||
flex items-center gap-3 p-4 rounded-lg border transition-all
|
|
||||||
${activeTab === tab.id
|
|
||||||
? 'bg-blue-50 dark:bg-cyan-500/20 border-blue-500 dark:border-cyan-500/80 text-blue-600 dark:text-cyan-500'
|
|
||||||
: 'bg-slate-50/50 dark:bg-cyan-500/5 border-slate-200 dark:border-cyan-500/30 text-slate-600 dark:text-slate-400 hover:bg-blue-50 dark:hover:bg-cyan-500/10 hover:border-blue-300 dark:hover:border-cyan-500/50'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<tab.icon className="w-5 h-5"/>
|
|
||||||
<span className="font-bold tracking-wider">{tab.label}</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="h-[1px] bg-slate-200 dark:bg-cyan-900/50 my-2"></div>
|
|
||||||
|
|
||||||
{/* Mobile Theme Toggle Button */}
|
|
||||||
<button
|
|
||||||
onClick={() => setTheme(appliedTheme === 'dark' ? 'light' : 'dark')}
|
|
||||||
className="w-full flex items-center justify-center gap-3 p-4 bg-cyan-50 dark:bg-cyan-500/10 hover:bg-cyan-100 dark:hover:bg-cyan-500/20 text-cyan-500 rounded-lg transition-all font-bold tracking-wider uppercase"
|
|
||||||
>
|
|
||||||
{appliedTheme === 'dark' ? (
|
|
||||||
<>
|
|
||||||
<Sun className="w-5 h-5"/>
|
|
||||||
<span>切换到浅色模式</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Moon className="w-5 h-5"/>
|
|
||||||
<span>切换到暗黑模式</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Mobile Login/Admin Button */}
|
|
||||||
{isLoggedIn ? (
|
|
||||||
<a
|
|
||||||
href="/admin"
|
|
||||||
target="_blank"
|
|
||||||
className="flex items-center justify-center gap-3 p-4 bg-cyan-50 dark:bg-cyan-500/10 hover:bg-cyan-100 dark:hover:bg-cyan-500/20 text-cyan-500 rounded-lg transition-all font-bold tracking-wider uppercase"
|
|
||||||
>
|
|
||||||
<Settings className="w-5 h-5"/>
|
|
||||||
<span>管理后台</span>
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<a
|
|
||||||
href="/admin/login"
|
|
||||||
target="_blank"
|
|
||||||
className="flex items-center justify-center gap-3 p-4 bg-cyan-50 dark:bg-cyan-500/10 hover:bg-cyan-100 dark:hover:bg-cyan-500/20 text-cyan-500 rounded-lg transition-all font-bold tracking-wider uppercase"
|
|
||||||
>
|
|
||||||
<LogIn className="w-5 h-5"/>
|
|
||||||
<span>登录</span>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ========================================== PublicFooter ========================================== */
|
|
||||||
|
|
||||||
export const PublicFooter = () => {
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
const runtime = getRuntimeConfig();
|
|
||||||
const icpCode = runtime.system.icpCode;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<footer className="border-t border-slate-200 dark:border-cyan-900/50 bg-[#f0f2f5] dark:bg-[#05050a] transition-colors duration-300">
|
|
||||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
|
||||||
<div className="py-6">
|
|
||||||
<div className="flex flex-col items-center justify-between gap-4 text-xs text-slate-500 dark:text-cyan-500 sm:flex-row font-mono">
|
|
||||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
|
||||||
<span className="text-slate-600 dark:text-cyan-500">© {currentYear}</span>
|
|
||||||
<span className="text-slate-300 dark:text-cyan-900">|</span>
|
|
||||||
{/* GitHub 链接 */}
|
|
||||||
<a
|
|
||||||
href="https://github.com/dushixiang/pika"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-1.5 text-slate-600 dark:text-cyan-500 hover:text-cyan-500 dark:hover:text-cyan-300 transition-colors group"
|
|
||||||
title="查看 GitHub 仓库"
|
|
||||||
>
|
|
||||||
<span className="underline decoration-slate-400 dark:decoration-cyan-700 underline-offset-2">Pika Monitor</span>
|
|
||||||
</a>
|
|
||||||
<span className="text-slate-500 dark:text-cyan-500/80 tracking-wider">{runtime.system.version}</span>
|
|
||||||
{/* ICP 备案号 */}
|
|
||||||
{icpCode && (
|
|
||||||
<>
|
|
||||||
<span className="text-slate-300 dark:text-cyan-900">|</span>
|
|
||||||
<a
|
|
||||||
href="https://beian.miit.gov.cn"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-slate-500 dark:text-cyan-500/80 hover:text-slate-700 dark:hover:text-cyan-500 transition-colors"
|
|
||||||
>
|
|
||||||
{icpCode}
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5 text-slate-600 dark:text-cyan-500">
|
|
||||||
<span>用</span>
|
|
||||||
<Heart className="h-3 w-3 fill-rose-500 text-rose-500 animate-pulse"/>
|
|
||||||
<span>构建</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="h-[1px] w-full bg-gradient-to-r from-transparent via-cyan-500/30 to-transparent"></div>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ========================================== TimeRangeSelector ========================================== */
|
|
||||||
|
|
||||||
interface CustomRange {
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TimeRangeSelectorProps {
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
options: readonly TimeRangeOption[];
|
|
||||||
enableCustom?: boolean;
|
|
||||||
customRange?: CustomRange | null;
|
|
||||||
onCustomRangeApply?: (range: CustomRange) => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parseDateTimeLocal = (value: string): number | null => {
|
|
||||||
if (!value) return null;
|
|
||||||
const timestamp = new Date(value).getTime();
|
|
||||||
if (Number.isNaN(timestamp)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return timestamp;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toDateTimeLocal = (timestamp: number): string => {
|
|
||||||
const date = new Date(timestamp);
|
|
||||||
const offsetMs = date.getTimezoneOffset() * 60000;
|
|
||||||
return new Date(timestamp - offsetMs).toISOString().slice(0, 16);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TimeRangeSelector = ({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
options,
|
|
||||||
enableCustom = false,
|
|
||||||
customRange,
|
|
||||||
onCustomRangeApply,
|
|
||||||
className,
|
|
||||||
}: TimeRangeSelectorProps) => {
|
|
||||||
const [customStart, setCustomStart] = useState('');
|
|
||||||
const [customEnd, setCustomEnd] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (customRange?.start) {
|
|
||||||
setCustomStart(toDateTimeLocal(customRange.start));
|
|
||||||
}
|
|
||||||
if (customRange?.end) {
|
|
||||||
setCustomEnd(toDateTimeLocal(customRange.end));
|
|
||||||
}
|
|
||||||
}, [customRange]);
|
|
||||||
|
|
||||||
const startMs = parseDateTimeLocal(customStart);
|
|
||||||
const endMs = parseDateTimeLocal(customEnd);
|
|
||||||
const canApply = startMs !== null && endMs !== null && startMs < endMs;
|
|
||||||
const showCustomOption = enableCustom && value === 'custom';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("flex flex-wrap items-center gap-2", className)}>
|
|
||||||
<select
|
|
||||||
value={showCustomOption ? 'custom' : value}
|
|
||||||
onChange={(event) => {
|
|
||||||
const nextValue = event.target.value;
|
|
||||||
if (nextValue === 'custom') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onChange(nextValue);
|
|
||||||
}}
|
|
||||||
className="rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 hover:border-blue-300 dark:hover:border-blue-600 focus:border-blue-400 dark:focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-500/30 px-3 py-1.5 text-xs font-medium transition-all whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{showCustomOption && (
|
|
||||||
<option value="custom" disabled>
|
|
||||||
自定义
|
|
||||||
</option>
|
|
||||||
)}
|
|
||||||
{options.map((option) => (
|
|
||||||
<option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{enableCustom && (
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="text-xs font-medium text-slate-600 dark:text-slate-300">
|
|
||||||
自定义
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
value={customStart}
|
|
||||||
onChange={(event) => setCustomStart(event.target.value)}
|
|
||||||
className="rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 focus:border-blue-400 dark:focus:border-blue-500 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-500/30 px-2 py-1 text-xs font-medium"
|
|
||||||
/>
|
|
||||||
<span className="text-xs font-medium text-slate-600 dark:text-slate-300">
|
|
||||||
至
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
value={customEnd}
|
|
||||||
onChange={(event) => setCustomEnd(event.target.value)}
|
|
||||||
className="rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 focus:border-blue-400 dark:focus:border-blue-500 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-500/30 px-2 py-1 text-xs font-medium"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (!canApply || startMs === null || endMs === null) return;
|
|
||||||
onCustomRangeApply?.({start: startMs, end: endMs});
|
|
||||||
onChange("custom");
|
|
||||||
}}
|
|
||||||
disabled={!canApply}
|
|
||||||
className={cn(
|
|
||||||
"rounded-lg border px-3 py-1.5 text-xs font-medium transition-all whitespace-nowrap",
|
|
||||||
canApply
|
|
||||||
? "border-blue-500 dark:border-blue-500 bg-blue-500 dark:bg-blue-600 text-white shadow-sm hover:bg-blue-600 dark:hover:bg-blue-700"
|
|
||||||
: "border-slate-200 dark:border-slate-700 bg-slate-100 dark:bg-slate-800 text-slate-400 dark:text-slate-500 cursor-not-allowed"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
应用
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import type {TimeRangeOption} from './types';
|
|
||||||
|
|
||||||
export const LIVE_RANGE = 'live';
|
|
||||||
export const LIVE_INITIAL_RANGE = '2m';
|
|
||||||
export const LIVE_WINDOW_MS = 2 * 60 * 1000;
|
|
||||||
|
|
||||||
export const SERVER_TIME_RANGE_OPTIONS: TimeRangeOption[] = [
|
|
||||||
{label: '实时', value: LIVE_RANGE},
|
|
||||||
{label: '15分钟', value: '15m'},
|
|
||||||
{label: '30分钟', value: '30m'},
|
|
||||||
{label: '1小时', value: '1h'},
|
|
||||||
{label: '3小时', value: '3h'},
|
|
||||||
{label: '6小时', value: '6h'},
|
|
||||||
{label: '12小时', value: '12h'},
|
|
||||||
{label: '1天', value: '1d'},
|
|
||||||
{label: '3天', value: '3d'},
|
|
||||||
{label: '7天', value: '7d'},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const MONITOR_TIME_RANGE_OPTIONS: TimeRangeOption[] = [
|
|
||||||
{label: '12小时', value: '12h'},
|
|
||||||
{label: '1天', value: '1d'},
|
|
||||||
{label: '3天', value: '3d'},
|
|
||||||
{label: '7天', value: '7d'},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const AGENT_COLORS = [
|
|
||||||
'#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899', '#06b6d4', '#f97316', '#14b8a6',
|
|
||||||
];
|
|
||||||
|
|
||||||
export const INTERFACE_COLORS = [
|
|
||||||
{upload: '#6FD598', download: '#2C70F6'},
|
|
||||||
{upload: '#f59e0b', download: '#8b5cf6'},
|
|
||||||
{upload: '#ec4899', download: '#06b6d4'},
|
|
||||||
{upload: '#10b981', download: '#f97316'},
|
|
||||||
{upload: '#14b8a6', download: '#2563eb'},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const TEMPERATURE_COLORS: Record<string, string> = {
|
|
||||||
'CPU': '#f97316', 'GPU': '#8b5cf6', 'DISK': '#06b6d4', 'BATTERY': '#10b981',
|
|
||||||
'CHIPSET': '#f59e0b', 'SYSTEM': '#6366f1', 'PSU': '#ec4899',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ACCENT_THEMES: Record<'blue' | 'emerald' | 'purple' | 'amber', { icon: string; badge: string; highlight: string }> = {
|
|
||||||
blue: {icon: 'text-blue-400', badge: 'text-blue-400', highlight: 'text-blue-400'},
|
|
||||||
emerald: {icon: 'text-emerald-400', badge: 'text-emerald-400', highlight: 'text-emerald-400'},
|
|
||||||
purple: {icon: 'text-purple-400', badge: 'text-purple-400', highlight: 'text-purple-400'},
|
|
||||||
amber: {icon: 'text-amber-400', badge: 'text-amber-400', highlight: 'text-amber-400'},
|
|
||||||
};
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import {createContext, useContext, useEffect, useState, type ReactNode} from 'react';
|
|
||||||
import {getRuntimeConfig, resolveColorMode} from '../api';
|
|
||||||
import type {ColorMode} from '../types';
|
|
||||||
|
|
||||||
export type ResolvedColorMode = 'light' | 'dark';
|
|
||||||
|
|
||||||
interface ColorModeContextValue {
|
|
||||||
colorMode: ColorMode;
|
|
||||||
resolvedColorMode: ResolvedColorMode;
|
|
||||||
setColorMode: (mode: ColorMode) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ColorModeContext = createContext<ColorModeContextValue | undefined>(undefined);
|
|
||||||
|
|
||||||
export const ColorModeProvider = ({children}: {children: ReactNode}) => {
|
|
||||||
const [colorMode, setColorModeState] = useState<ColorMode>(() => {
|
|
||||||
const saved = localStorage.getItem('colorMode') || localStorage.getItem('theme');
|
|
||||||
if (saved === 'auto') return 'system';
|
|
||||||
if (saved === 'light' || saved === 'dark' || saved === 'system') return saved;
|
|
||||||
return getRuntimeConfig().system.defaultColorMode;
|
|
||||||
});
|
|
||||||
const [resolvedColorMode, setResolvedColorMode] = useState<ResolvedColorMode>('dark');
|
|
||||||
|
|
||||||
const setColorMode = (mode: ColorMode) => {
|
|
||||||
setColorModeState(mode);
|
|
||||||
localStorage.setItem('colorMode', mode);
|
|
||||||
localStorage.removeItem('theme');
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const apply = () => {
|
|
||||||
const resolved = resolveColorMode(colorMode);
|
|
||||||
setResolvedColorMode(resolved);
|
|
||||||
document.documentElement.classList.toggle('dark', resolved === 'dark');
|
|
||||||
document.documentElement.dataset.colorMode = resolved;
|
|
||||||
};
|
|
||||||
apply();
|
|
||||||
if (colorMode !== 'system') return;
|
|
||||||
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
|
||||||
media.addEventListener('change', apply);
|
|
||||||
return () => media.removeEventListener('change', apply);
|
|
||||||
}, [colorMode]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ColorModeContext.Provider value={{colorMode, resolvedColorMode, setColorMode}}>
|
|
||||||
{children}
|
|
||||||
</ColorModeContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useColorMode = () => {
|
|
||||||
const value = useContext(ColorModeContext);
|
|
||||||
if (!value) throw new Error('useColorMode must be used within ColorModeProvider');
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import {useEffect, useRef, useState} from 'react';
|
|
||||||
import {useQuery} from '@tanstack/react-query';
|
|
||||||
import {pika} from '../api';
|
|
||||||
import type {Agent, LatestMetrics, MetricsAggregation, MetricsParams, MetricsResponse} from '../types';
|
|
||||||
|
|
||||||
interface UseMetricsQueryOptions {
|
|
||||||
agentId: string;
|
|
||||||
type: MetricsParams['type'];
|
|
||||||
range?: string;
|
|
||||||
start?: number;
|
|
||||||
end?: number;
|
|
||||||
interfaceName?: string;
|
|
||||||
aggregation?: MetricsAggregation;
|
|
||||||
refetchIntervalMs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useAgentQuery = (agentId?: string) => {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['agent', agentId],
|
|
||||||
queryFn: () => pika.getAgent<Agent>(agentId!),
|
|
||||||
enabled: !!agentId,
|
|
||||||
staleTime: 60000,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useLatestMetricsQuery = (agentId?: string, intervalMs: number = 5000) => {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['agent', agentId, 'metrics', 'latest'],
|
|
||||||
queryFn: () => pika.getLatestMetrics<LatestMetrics>(agentId!),
|
|
||||||
enabled: !!agentId,
|
|
||||||
refetchInterval: intervalMs > 0 ? intervalMs : false,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useMetricsQuery = ({agentId, type, range, start, end, interfaceName, aggregation, refetchIntervalMs}: UseMetricsQueryOptions) => {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['agent', agentId, 'metrics', type, range, start, end, interfaceName, aggregation],
|
|
||||||
queryFn: () =>
|
|
||||||
pika.getMetrics<MetricsResponse>(agentId, {
|
|
||||||
type,
|
|
||||||
range: start !== undefined && end !== undefined ? undefined : range,
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
interface: interfaceName,
|
|
||||||
aggregation,
|
|
||||||
}),
|
|
||||||
enabled: !!agentId,
|
|
||||||
refetchInterval: refetchIntervalMs && refetchIntervalMs > 0 ? refetchIntervalMs : false,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useNetworkInterfacesQuery = (agentId?: string) => {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['agent', agentId, 'network-interfaces'],
|
|
||||||
queryFn: () => pika.getNetworkInterfaces(agentId!),
|
|
||||||
enabled: !!agentId,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useLiveBuffer<T extends { timestamp: number }>(
|
|
||||||
initial: T[],
|
|
||||||
isLive: boolean,
|
|
||||||
livePoint: T | null,
|
|
||||||
windowMs: number,
|
|
||||||
resetKey?: unknown,
|
|
||||||
): T[] {
|
|
||||||
const [buffer, setBuffer] = useState<T[]>([]);
|
|
||||||
const lastTsRef = useRef<number>(0);
|
|
||||||
const seededRef = useRef<boolean>(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
seededRef.current = false;
|
|
||||||
lastTsRef.current = 0;
|
|
||||||
setBuffer([]);
|
|
||||||
}, [isLive, resetKey]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isLive) return;
|
|
||||||
if (seededRef.current) return;
|
|
||||||
if (!initial || initial.length === 0) return;
|
|
||||||
seededRef.current = true;
|
|
||||||
|
|
||||||
const initLastTs = initial[initial.length - 1].timestamp;
|
|
||||||
setBuffer(prev => {
|
|
||||||
if (prev.length === 0) {
|
|
||||||
return initial.slice();
|
|
||||||
}
|
|
||||||
const earliestExisting = prev[0].timestamp;
|
|
||||||
const olderHistory = initial.filter(p => p.timestamp < earliestExisting);
|
|
||||||
return olderHistory.length > 0 ? [...olderHistory, ...prev] : prev;
|
|
||||||
});
|
|
||||||
if (initLastTs > lastTsRef.current) {
|
|
||||||
lastTsRef.current = initLastTs;
|
|
||||||
}
|
|
||||||
}, [isLive, initial, resetKey]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isLive || !livePoint) return;
|
|
||||||
const ts = livePoint.timestamp;
|
|
||||||
if (!Number.isFinite(ts) || ts <= 0) return;
|
|
||||||
if (ts <= lastTsRef.current) return;
|
|
||||||
lastTsRef.current = ts;
|
|
||||||
|
|
||||||
setBuffer(prev => {
|
|
||||||
const next = prev.length === 0 ? [livePoint] : [...prev, livePoint];
|
|
||||||
const cutoff = ts - windowMs;
|
|
||||||
let drop = 0;
|
|
||||||
while (drop < next.length && next[drop].timestamp < cutoff) drop++;
|
|
||||||
return drop > 0 ? next.slice(drop) : next;
|
|
||||||
});
|
|
||||||
}, [livePoint, isLive, windowMs]);
|
|
||||||
|
|
||||||
return isLive ? buffer : initial;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MOBILE_BREAKPOINT = 768;
|
|
||||||
|
|
||||||
export function useIsMobile() {
|
|
||||||
const [isMobile, setIsMobile] = useState<boolean | undefined>(undefined);
|
|
||||||
useEffect(() => {
|
|
||||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
|
||||||
const onChange = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
|
||||||
mql.addEventListener('change', onChange);
|
|
||||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
|
||||||
return () => mql.removeEventListener('change', onChange);
|
|
||||||
}, []);
|
|
||||||
return !!isMobile;
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
@import "tw-animate-css";
|
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
|
||||||
|
|
||||||
/* Tailwind v4 暗色模式配置 */
|
|
||||||
@variant dark (&:where(.dark, .dark *));
|
|
||||||
|
|
||||||
/* 标签过滤器滚动条样式 */
|
|
||||||
.tag-filter-scrollbar {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: rgb(203, 213, 225) transparent;
|
|
||||||
scrollbar-gutter: stable;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-filter-scrollbar::-webkit-scrollbar {
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-filter-scrollbar::-webkit-scrollbar-track {
|
|
||||||
background: rgb(241, 245, 249);
|
|
||||||
border-radius: 9999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-filter-scrollbar::-webkit-scrollbar-thumb {
|
|
||||||
background: rgb(203, 213, 225);
|
|
||||||
border-radius: 9999px;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-filter-scrollbar::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgb(148, 163, 184);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 暗色模式标签过滤器滚动条 */
|
|
||||||
.dark .tag-filter-scrollbar {
|
|
||||||
scrollbar-color: rgb(71, 85, 105) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .tag-filter-scrollbar::-webkit-scrollbar-track {
|
|
||||||
background: rgb(30, 41, 59);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .tag-filter-scrollbar::-webkit-scrollbar-thumb {
|
|
||||||
background: rgb(71, 85, 105);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .tag-filter-scrollbar::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgb(100, 116, 139);
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
|
||||||
--radius-lg: var(--radius);
|
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
|
||||||
--radius-2xl: calc(var(--radius) + 8px);
|
|
||||||
--radius-3xl: calc(var(--radius) + 12px);
|
|
||||||
--radius-4xl: calc(var(--radius) + 16px);
|
|
||||||
--color-background: var(--background);
|
|
||||||
--color-foreground: var(--foreground);
|
|
||||||
--color-card: var(--card);
|
|
||||||
--color-card-foreground: var(--card-foreground);
|
|
||||||
--color-popover: var(--popover);
|
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
|
||||||
--color-primary: var(--primary);
|
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
|
||||||
--color-secondary: var(--secondary);
|
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
|
||||||
--color-muted: var(--muted);
|
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
|
||||||
--color-accent: var(--accent);
|
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
|
||||||
--color-destructive: var(--destructive);
|
|
||||||
--color-border: var(--border);
|
|
||||||
--color-input: var(--input);
|
|
||||||
--color-ring: var(--ring);
|
|
||||||
--color-chart-1: var(--chart-1);
|
|
||||||
--color-chart-2: var(--chart-2);
|
|
||||||
--color-chart-3: var(--chart-3);
|
|
||||||
--color-chart-4: var(--chart-4);
|
|
||||||
--color-chart-5: var(--chart-5);
|
|
||||||
--color-sidebar: var(--sidebar);
|
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
|
||||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
||||||
--color-sidebar-accent: var(--sidebar-accent);
|
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--radius: 0.625rem;
|
|
||||||
--background: oklch(1 0 0);
|
|
||||||
--foreground: oklch(0.145 0 0);
|
|
||||||
--card: oklch(1 0 0);
|
|
||||||
--card-foreground: oklch(0.145 0 0);
|
|
||||||
--popover: oklch(1 0 0);
|
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
|
||||||
--primary: oklch(0.205 0 0);
|
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
|
||||||
--secondary: oklch(0.97 0 0);
|
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
|
||||||
--muted: oklch(0.97 0 0);
|
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
|
||||||
--accent: oklch(0.97 0 0);
|
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
|
||||||
--border: oklch(0.922 0 0);
|
|
||||||
--input: oklch(0.922 0 0);
|
|
||||||
--ring: oklch(0.708 0 0);
|
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
|
||||||
--sidebar: oklch(0.985 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--background: oklch(0.145 0 0);
|
|
||||||
--foreground: oklch(0.985 0 0);
|
|
||||||
--card: oklch(0.205 0 0);
|
|
||||||
--card-foreground: oklch(0.985 0 0);
|
|
||||||
--popover: oklch(0.205 0 0);
|
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
|
||||||
--primary: oklch(0.922 0 0);
|
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
|
||||||
--secondary: oklch(0.269 0 0);
|
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
|
||||||
--muted: oklch(0.269 0 0);
|
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
|
||||||
--accent: oklch(0.269 0 0);
|
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
|
||||||
--border: oklch(1 0 0 / 10%);
|
|
||||||
--input: oklch(1 0 0 / 15%);
|
|
||||||
--ring: oklch(0.556 0 0);
|
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
|
||||||
--sidebar: oklch(0.205 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
* {
|
|
||||||
@apply border-border outline-ring/50;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
@apply bg-background text-foreground;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import {type ClassValue, clsx} from 'clsx';
|
|
||||||
import {twMerge} from 'tailwind-merge';
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs));
|
|
||||||
}
|
|
||||||
|
|
||||||
export const formatBytes = (value: number | undefined | null, precision = 2): string => {
|
|
||||||
if (value === undefined || value === null || !Number.isFinite(value) || value <= 0) return '0 B';
|
|
||||||
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
|
||||||
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
|
|
||||||
return `${(value / 1024 ** index).toFixed(precision)} ${units[index]}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const formatSpeed = (bytesPerSecond: number): string => {
|
|
||||||
if (!Number.isFinite(bytesPerSecond) || bytesPerSecond <= 0) return '0 B/s';
|
|
||||||
const units = ['B/s', 'K/s', 'M/s', 'G/s', 'T/s'];
|
|
||||||
const index = Math.min(Math.floor(Math.log(bytesPerSecond) / Math.log(1024)), units.length - 1);
|
|
||||||
const value = bytesPerSecond / 1024 ** index;
|
|
||||||
const decimals = value >= 100 ? 0 : value >= 10 ? 1 : 2;
|
|
||||||
return `${value.toFixed(decimals)} ${units[index]}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const formatTime = (milliseconds: number): string => {
|
|
||||||
if (!Number.isFinite(milliseconds) || milliseconds <= 0) return '0 ms';
|
|
||||||
if (milliseconds < 1000) return `${milliseconds.toFixed(0)} ms`;
|
|
||||||
return `${(milliseconds / 1000).toFixed(2)} s`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const formatDateTime = (value: string | number | undefined | null): string => {
|
|
||||||
if (value === undefined || value === null || value === '') return '-';
|
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) return '-';
|
|
||||||
const pad = (part: number) => String(part).padStart(2, '0');
|
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const formatPercentValue = (value: number | undefined | null): string => {
|
|
||||||
if (value === undefined || value === null || Number.isNaN(value)) return '0.0';
|
|
||||||
return value.toFixed(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const formatUptime = (seconds: number | undefined | null): string => {
|
|
||||||
if (seconds === undefined || seconds === null) return '-';
|
|
||||||
if (seconds <= 0) return '0 秒';
|
|
||||||
const days = Math.floor(seconds / 86400);
|
|
||||||
const hours = Math.floor((seconds % 86400) / 3600);
|
|
||||||
const minutes = Math.floor((seconds % 3600) / 60);
|
|
||||||
if (days > 0) return `${days} 天${hours > 0 ? ` ${hours} 小时` : ''}`;
|
|
||||||
if (hours > 0) return `${hours} 小时${minutes > 0 ? ` ${minutes} 分钟` : ''}`;
|
|
||||||
return minutes > 0 ? `${minutes} 分钟` : '不到 1 分钟';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const formatChartTime = (timestamp: number, timeRange: string, rangeMs?: number): string => {
|
|
||||||
const date = new Date(timestamp);
|
|
||||||
if (timeRange === 'live') {
|
|
||||||
return date.toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false});
|
|
||||||
}
|
|
||||||
const isLongRange = rangeMs !== undefined
|
|
||||||
? rangeMs >= 24 * 60 * 60 * 1000
|
|
||||||
: timeRange === '1d' || timeRange === '24h' || (timeRange.endsWith('d') && Number.parseInt(timeRange) > 1);
|
|
||||||
if (isLongRange) {
|
|
||||||
const pad = (part: number) => String(part).padStart(2, '0');
|
|
||||||
return `${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
||||||
}
|
|
||||||
return date.toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isExpired = (expireTime?: number) => {
|
|
||||||
return Boolean(expireTime && expireTime > 0 && expireTime - Date.now() < 30 * 24 * 60 * 60 * 1000);
|
|
||||||
};
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import {StrictMode} from 'react';
|
|
||||||
import {createRoot} from 'react-dom/client';
|
|
||||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
|
|
||||||
import {pika} from './api';
|
|
||||||
import type {PikaRuntimeConfig} from './types';
|
|
||||||
import PortalApp from './App';
|
|
||||||
import './index.css';
|
|
||||||
|
|
||||||
const fallbackRuntime: PikaRuntimeConfig = {
|
|
||||||
apiVersion: 'v1',
|
|
||||||
system: {
|
|
||||||
nameZh: '皮卡监控',
|
|
||||||
nameEn: 'Pika Monitor',
|
|
||||||
logo: '/api/logo',
|
|
||||||
icpCode: '',
|
|
||||||
version: '',
|
|
||||||
defaultView: 'grid',
|
|
||||||
defaultColorMode: 'system',
|
|
||||||
},
|
|
||||||
theme: {id: 'default', version: '1.0.0'},
|
|
||||||
features: {
|
|
||||||
serverList: true,
|
|
||||||
serverDetail: true,
|
|
||||||
monitorList: true,
|
|
||||||
monitorDetail: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const start = async () => {
|
|
||||||
if (!window.PikaRuntime) {
|
|
||||||
try {
|
|
||||||
window.PikaRuntime = await pika.getConfig();
|
|
||||||
} catch (error) {
|
|
||||||
window.PikaRuntime = fallbackRuntime;
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: {queries: {refetchOnWindowFocus: false, retry: 1}},
|
|
||||||
});
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
|
||||||
<StrictMode>
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<PortalApp/>
|
|
||||||
</QueryClientProvider>
|
|
||||||
</StrictMode>,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
start().catch(console.error);
|
|
||||||
@@ -1,795 +0,0 @@
|
|||||||
import {useEffect, useMemo, useState} from 'react';
|
|
||||||
import {useNavigate, useParams} from 'react-router-dom';
|
|
||||||
import {useQuery} from '@tanstack/react-query';
|
|
||||||
import {Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
|
|
||||||
import {AlertCircle, ArrowLeft, ChevronDown, ChevronUp, Clock, Globe, MapPin, RotateCcw, Server, ShieldCheck, Wifi} from 'lucide-react';
|
|
||||||
import {pika} from '../api';
|
|
||||||
import type {AgentMonitorStat, MetricsResponse, PublicMonitor} from '../types';
|
|
||||||
import {AGENT_COLORS, MONITOR_TIME_RANGE_OPTIONS} from '../constants';
|
|
||||||
import {cn, formatChartTime, formatDateTime, formatTime} from '../lib/utils';
|
|
||||||
import {useIsMobile} from '../hooks';
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
ChartPlaceholder,
|
|
||||||
CustomTooltip,
|
|
||||||
EmptyState,
|
|
||||||
LoadingSpinner,
|
|
||||||
MetricItem,
|
|
||||||
StatusBadge,
|
|
||||||
TimeRangeSelector
|
|
||||||
} from '../components';
|
|
||||||
|
|
||||||
/* ========================================== TypeIcon ========================================== */
|
|
||||||
|
|
||||||
const TypeIcon = ({type}: {type: string}) => {
|
|
||||||
switch (type.toLowerCase()) {
|
|
||||||
case 'https':
|
|
||||||
return <ShieldCheck className="w-4 h-4 text-purple-500 dark:text-purple-400"/>;
|
|
||||||
case 'http':
|
|
||||||
return <Globe className="w-4 h-4 text-blue-500 dark:text-blue-400"/>;
|
|
||||||
case 'tcp':
|
|
||||||
return <Server className="w-4 h-4 text-orange-500 dark:text-orange-400"/>;
|
|
||||||
case 'icmp':
|
|
||||||
case 'ping':
|
|
||||||
return <Wifi className="w-4 h-4 text-cyan-500 dark:text-cyan-500"/>;
|
|
||||||
default:
|
|
||||||
return <Server className="w-4 h-4 text-slate-500 dark:text-slate-400"/>;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ========================================== CertBadge ========================================== */
|
|
||||||
|
|
||||||
const CertBadge = ({expiryTime, daysLeft}: {expiryTime: number; daysLeft: number}) => {
|
|
||||||
if (!expiryTime || daysLeft === undefined) return null;
|
|
||||||
|
|
||||||
const isExpired = daysLeft < 0;
|
|
||||||
let colorClass = "text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20";
|
|
||||||
|
|
||||||
if (isExpired) {
|
|
||||||
colorClass = "text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-500/10 border-rose-200 dark:border-rose-500/20";
|
|
||||||
} else if (daysLeft < 30) {
|
|
||||||
colorClass = "text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10 border-amber-200 dark:border-amber-500/20";
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("flex items-center gap-1.5 px-2 py-1 rounded text-xs border", colorClass)}>
|
|
||||||
<ShieldCheck className="w-3 h-3"/>
|
|
||||||
<span>{isExpired ? "已过期" : `${daysLeft} 天后过期`}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ========================================== MonitorHero ========================================== */
|
|
||||||
|
|
||||||
interface MonitorHeroProps {
|
|
||||||
monitor: PublicMonitor;
|
|
||||||
onBack: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MonitorHero = ({monitor, onBack}: MonitorHeroProps) => {
|
|
||||||
return (
|
|
||||||
<Card className={'p-6 space-y-6'}>
|
|
||||||
{/* 返回按钮 */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onBack}
|
|
||||||
className="group inline-flex items-center gap-2 text-xs font-medium uppercase tracking-wider text-gray-600 dark:text-cyan-500 hover:text-gray-800 dark:hover:text-cyan-400 transition font-mono"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4 transition-transform group-hover:-translate-x-1"/>
|
|
||||||
返回监控列表
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* 监控信息 */}
|
|
||||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
|
||||||
<div className="flex items-start gap-4 flex-1 min-w-0">
|
|
||||||
<div className="p-3 bg-gray-100 dark:bg-cyan-950/30 border border-slate-200 dark:border-cyan-500/20 rounded-lg flex-shrink-0">
|
|
||||||
<TypeIcon type={monitor.type}/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex flex-wrap items-center gap-3 mb-2">
|
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold truncate text-slate-800 dark:text-cyan-100 tracking-wide">{monitor.name}</h1>
|
|
||||||
<StatusBadge status={monitor.status}/>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-600 dark:text-cyan-500/80 font-mono truncate">
|
|
||||||
{monitor.showTargetPublic ? monitor.target : '******'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 统计卡片 */}
|
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 w-full lg:w-auto lg:min-w-[480px]">
|
|
||||||
<MetricItem
|
|
||||||
label="监控类型"
|
|
||||||
value={monitor.type.toUpperCase()}
|
|
||||||
/>
|
|
||||||
<MetricItem
|
|
||||||
label="探针数量"
|
|
||||||
value={monitor.agentCount}
|
|
||||||
/>
|
|
||||||
<MetricItem
|
|
||||||
label="平均响应"
|
|
||||||
value={`${monitor.responseTime}ms`}
|
|
||||||
/>
|
|
||||||
<MetricItem
|
|
||||||
label="最慢响应"
|
|
||||||
value={`${monitor.responseTimeMax}ms`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 证书信息(如果存在证书数据)*/}
|
|
||||||
{monitor.certExpiryTime > 0 && (
|
|
||||||
<div className="flex flex-col gap-3 pt-4 border-t border-slate-200 dark:border-cyan-900/50">
|
|
||||||
<span className="text-xs text-gray-600 dark:text-cyan-500 font-mono">SSL 证书:</span>
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3">
|
|
||||||
<CertBadge
|
|
||||||
expiryTime={monitor.certExpiryTime}
|
|
||||||
daysLeft={monitor.certDaysLeft}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-gray-500 dark:text-cyan-600 font-mono break-all sm:break-normal">
|
|
||||||
到期时间: {formatDateTime(monitor.certExpiryTime)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ========================================== AgentStatsTable ========================================== */
|
|
||||||
|
|
||||||
interface AgentStatsTableProps {
|
|
||||||
monitorStats: AgentMonitorStat[];
|
|
||||||
monitorType: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AgentStatsTable = ({monitorStats, monitorType}: AgentStatsTableProps) => {
|
|
||||||
if (monitorStats.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="text-center py-12 text-gray-600 dark:text-cyan-500">
|
|
||||||
<p className="text-sm font-mono">暂无探针数据</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="p-4 sm:p-6">
|
|
||||||
<div className="mb-4 sm:mb-6">
|
|
||||||
<h3 className="text-base sm:text-lg font-bold tracking-wide text-slate-800 dark:text-cyan-100 uppercase">探针监控详情</h3>
|
|
||||||
<p className="text-xs text-gray-600 dark:text-cyan-500 mt-1 font-mono">各探针的当前状态和统计数据</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 移动端卡片布局 */}
|
|
||||||
<div className="block lg:hidden space-y-3">
|
|
||||||
{monitorStats.map((stat, index) => {
|
|
||||||
const color = AGENT_COLORS[index % AGENT_COLORS.length];
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={stat.agentId}
|
|
||||||
className="p-4 bg-slate-50 dark:bg-cyan-950/20 border border-slate-200 dark:border-cyan-900/50 rounded-lg space-y-3"
|
|
||||||
>
|
|
||||||
{/* 探针名称和状态 */}
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|
||||||
<span
|
|
||||||
className="inline-block h-2 w-2 rounded-full flex-shrink-0"
|
|
||||||
style={{backgroundColor: color}}
|
|
||||||
/>
|
|
||||||
<MapPin className="h-3.5 w-3.5 text-gray-600 dark:text-cyan-500 flex-shrink-0"/>
|
|
||||||
<span className="font-mono text-sm text-slate-800 dark:text-cyan-200 truncate">
|
|
||||||
{stat.agentName || stat.agentId.substring(0, 8)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<StatusBadge status={stat.status}/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 响应时间和最后检测 */}
|
|
||||||
<div className="flex items-center justify-between gap-4 text-sm">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Clock className="h-4 w-4 text-gray-600 dark:text-cyan-500"/>
|
|
||||||
<span className="font-semibold text-slate-800 dark:text-cyan-100 font-mono">
|
|
||||||
{formatTime(stat.responseTime)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-gray-600 dark:text-cyan-500 font-mono">
|
|
||||||
{formatDateTime(stat.checkedAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 证书信息 */}
|
|
||||||
{monitorType === 'https' && stat.certExpiryTime && (
|
|
||||||
<div className="pt-2 border-t border-slate-200 dark:border-cyan-900/30">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className="text-xs text-gray-600 dark:text-cyan-500 font-mono">证书:</span>
|
|
||||||
<CertBadge
|
|
||||||
expiryTime={stat.certExpiryTime}
|
|
||||||
daysLeft={stat.certDaysLeft}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 错误信息 */}
|
|
||||||
{stat.status === 'down' && stat.message && (
|
|
||||||
<div className="pt-2 border-t border-slate-200 dark:border-cyan-900/30">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<AlertCircle className="h-4 w-4 text-rose-400 flex-shrink-0 mt-0.5"/>
|
|
||||||
<span className="text-xs text-rose-300 break-words font-mono">
|
|
||||||
{stat.message}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 桌面端表格布局 */}
|
|
||||||
<div className="hidden lg:block overflow-x-auto -mx-6 px-6">
|
|
||||||
<table className="min-w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-slate-200 dark:border-cyan-900/50">
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-widest text-gray-600 dark:text-cyan-500 font-mono">
|
|
||||||
探针名称
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-widest text-gray-600 dark:text-cyan-500 font-mono">
|
|
||||||
状态
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-widest text-gray-600 dark:text-cyan-500 font-mono">
|
|
||||||
响应时间
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-widest text-gray-600 dark:text-cyan-500 font-mono">
|
|
||||||
最后检测
|
|
||||||
</th>
|
|
||||||
{monitorType === 'https' && (
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-widest text-gray-600 dark:text-cyan-500 font-mono hidden xl:table-cell">
|
|
||||||
证书信息
|
|
||||||
</th>
|
|
||||||
)}
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-widest text-gray-600 dark:text-cyan-500 font-mono hidden xl:table-cell">
|
|
||||||
错误信息
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-200 dark:divide-cyan-900/30">
|
|
||||||
{monitorStats.map((stat, index) => {
|
|
||||||
const color = AGENT_COLORS[index % AGENT_COLORS.length];
|
|
||||||
return (
|
|
||||||
<tr key={stat.agentId}
|
|
||||||
className="hover:bg-slate-100 dark:hover:bg-cyan-950/20 transition-colors">
|
|
||||||
<td className="px-4 py-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span
|
|
||||||
className="inline-block h-2 w-2 rounded-full flex-shrink-0"
|
|
||||||
style={{backgroundColor: color}}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<MapPin className="h-3.5 w-3.5 text-gray-600 dark:text-cyan-500"/>
|
|
||||||
<span className="font-mono text-sm text-slate-800 dark:text-cyan-200">
|
|
||||||
{stat.agentName || stat.agentId.substring(0, 8)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-4">
|
|
||||||
<StatusBadge status={stat.status}/>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Clock className="h-4 w-4 text-gray-600 dark:text-cyan-500"/>
|
|
||||||
<span
|
|
||||||
className="text-sm font-semibold text-slate-800 dark:text-cyan-100 font-mono">
|
|
||||||
{formatTime(stat.responseTime)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-4 text-sm text-gray-600 dark:text-cyan-500 font-mono">
|
|
||||||
{formatDateTime(stat.checkedAt)}
|
|
||||||
</td>
|
|
||||||
{monitorType === 'https' && (
|
|
||||||
<td className="px-4 py-4 hidden xl:table-cell">
|
|
||||||
{stat.certExpiryTime ? (
|
|
||||||
<CertBadge
|
|
||||||
expiryTime={stat.certExpiryTime}
|
|
||||||
daysLeft={stat.certDaysLeft}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-gray-600 dark:text-cyan-500">-</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
<td className="px-4 py-4 hidden xl:table-cell">
|
|
||||||
{stat.status === 'down' && stat.message ? (
|
|
||||||
<div className="flex items-start gap-2 max-w-xs">
|
|
||||||
<AlertCircle
|
|
||||||
className="h-4 w-4 text-rose-400 flex-shrink-0 mt-0.5"/>
|
|
||||||
<span
|
|
||||||
className="text-xs text-rose-300 break-words line-clamp-2 font-mono">
|
|
||||||
{stat.message}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-gray-600 dark:text-cyan-500">-</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ========================================== ResponseTimeChart ========================================== */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据时间范围确定最大数据点数
|
|
||||||
*/
|
|
||||||
const getMaxDataPoints = (timeRange: string): number => {
|
|
||||||
switch (timeRange) {
|
|
||||||
case '15m':
|
|
||||||
case '1h':
|
|
||||||
return 200; // 短时间:详细数据
|
|
||||||
case '12h':
|
|
||||||
return 300;
|
|
||||||
case '24h':
|
|
||||||
return 400;
|
|
||||||
case '7d':
|
|
||||||
return 500;
|
|
||||||
case '30d':
|
|
||||||
return 600;
|
|
||||||
default:
|
|
||||||
return 400;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成不重复的颜色
|
|
||||||
* 使用 HSL 色轮均匀分布,支持无限数量的探针
|
|
||||||
*/
|
|
||||||
const generateColors = (count: number): string[] => {
|
|
||||||
const colors: string[] = [];
|
|
||||||
const hueStep = 360 / count;
|
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const hue = (i * hueStep) % 360;
|
|
||||||
const saturation = 65 + (i % 3) * 10;
|
|
||||||
const lightness = 45 + (i % 2) * 10;
|
|
||||||
colors.push(`hsl(${hue}, ${saturation}%, ${lightness}%)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return colors;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 自定义图例组件
|
|
||||||
*/
|
|
||||||
const CustomLegend = ({onClick, selectedAgents, allAgents, colors, collapsed}: any) => {
|
|
||||||
if (!allAgents || allAgents.length === 0) return null;
|
|
||||||
|
|
||||||
if (collapsed) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap justify-center gap-4 pt-4">
|
|
||||||
{allAgents.map((agent: {id: string; name: string}, index: number) => {
|
|
||||||
const isSelected = selectedAgents.has(agent.id);
|
|
||||||
const color = colors[index];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={agent.id}
|
|
||||||
onClick={() => onClick(agent.id)}
|
|
||||||
className="flex items-center gap-2 cursor-pointer transition-opacity"
|
|
||||||
style={{
|
|
||||||
opacity: isSelected ? 1 : 0.4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="32" height="12" className="overflow-visible">
|
|
||||||
<line
|
|
||||||
x1="0"
|
|
||||||
y1="6"
|
|
||||||
x2="32"
|
|
||||||
y2="6"
|
|
||||||
stroke={isSelected ? color : '#9ca3af'}
|
|
||||||
strokeWidth="2"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span
|
|
||||||
className="text-xs font-medium"
|
|
||||||
style={{
|
|
||||||
color: isSelected ? color : '#9ca3af',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{agent.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ResponseTimeChartProps {
|
|
||||||
monitorId: string;
|
|
||||||
monitorStats: AgentMonitorStat[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const ResponseTimeChart = ({monitorId, monitorStats}: ResponseTimeChartProps) => {
|
|
||||||
const [selectedAgents, setSelectedAgents] = useState<Set<string>>(new Set());
|
|
||||||
const [timeRange, setTimeRange] = useState<string>('12h');
|
|
||||||
const [customRange, setCustomRange] = useState<{start: number; end: number} | null>(null);
|
|
||||||
const [legendCollapsed, setLegendCollapsed] = useState(true); // 移动端默认收起
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
const customStart = timeRange === 'custom' ? customRange?.start : undefined;
|
|
||||||
const customEnd = timeRange === 'custom' ? customRange?.end : undefined;
|
|
||||||
const rangeMs = customStart !== undefined && customEnd !== undefined ? customEnd - customStart : undefined;
|
|
||||||
|
|
||||||
// 获取历史数据
|
|
||||||
const {data: historyData} = useQuery<MetricsResponse>({
|
|
||||||
queryKey: ['monitorHistory', monitorId, timeRange, customStart, customEnd],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!monitorId) throw new Error('Monitor ID is required');
|
|
||||||
return pika.getMonitorHistory(monitorId, {
|
|
||||||
range: timeRange,
|
|
||||||
start: customStart,
|
|
||||||
end: customEnd,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
refetchInterval: 30000,
|
|
||||||
enabled: !!monitorId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 获取所有可用的探针列表
|
|
||||||
const availableAgents = useMemo(() => {
|
|
||||||
if (monitorStats.length === 0) return [];
|
|
||||||
return monitorStats.map(stat => ({
|
|
||||||
id: stat.agentId,
|
|
||||||
name: stat.agentName || stat.agentId.substring(0, 8),
|
|
||||||
}));
|
|
||||||
}, [monitorStats]);
|
|
||||||
|
|
||||||
// 初始化选中所有探针
|
|
||||||
useEffect(() => {
|
|
||||||
if (availableAgents.length > 0 && selectedAgents.size === 0) {
|
|
||||||
setSelectedAgents(new Set(availableAgents.map(a => a.id)));
|
|
||||||
}
|
|
||||||
}, [availableAgents, selectedAgents.size]);
|
|
||||||
|
|
||||||
// 动态生成颜色
|
|
||||||
const colors = useMemo(() => {
|
|
||||||
return generateColors(availableAgents.length);
|
|
||||||
}, [availableAgents.length]);
|
|
||||||
|
|
||||||
const toggleAgent = (agentId: string) => {
|
|
||||||
setSelectedAgents((current) => {
|
|
||||||
if (current.size === availableAgents.length) return new Set([agentId]);
|
|
||||||
const next = new Set(current);
|
|
||||||
if (next.has(agentId)) next.delete(agentId);
|
|
||||||
else next.add(agentId);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAreaClick = (data: unknown) => {
|
|
||||||
const key = (data as {dataKey?: string})?.dataKey;
|
|
||||||
if (key) toggleAgent(key.replace('agent_', ''));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 恢复全选
|
|
||||||
const handleSelectAll = () => {
|
|
||||||
setSelectedAgents(new Set(availableAgents.map(a => a.id)));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 是否有探针未选中
|
|
||||||
const hasUnselected = selectedAgents.size < availableAgents.length;
|
|
||||||
|
|
||||||
// 切换图例显示/隐藏(仅移动端)
|
|
||||||
const toggleLegend = () => setLegendCollapsed((collapsed) => !collapsed);
|
|
||||||
|
|
||||||
// 生成图表数据 - 使用统一时间网格对齐
|
|
||||||
const chartData = useMemo(() => {
|
|
||||||
if (!historyData?.series) return [];
|
|
||||||
|
|
||||||
const seriesList = historyData.series.filter(s => s.name === 'response_time');
|
|
||||||
if (seriesList.length === 0) return [];
|
|
||||||
|
|
||||||
// 收集所有选中探针的数据
|
|
||||||
const selectedSeriesData: Array<{key: string; data: Array<{timestamp: number; value: number}>}> = [];
|
|
||||||
|
|
||||||
seriesList.forEach((s) => {
|
|
||||||
const agentId = s.labels?.agent_id || 'unknown';
|
|
||||||
if (!selectedAgents.has(agentId)) return;
|
|
||||||
if (!s.data || s.data.length === 0) return;
|
|
||||||
|
|
||||||
selectedSeriesData.push({
|
|
||||||
key: `agent_${agentId}`,
|
|
||||||
data: [...s.data].sort((a, b) => a.timestamp - b.timestamp)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (selectedSeriesData.length === 0) return [];
|
|
||||||
|
|
||||||
// 确定全局时间范围
|
|
||||||
let minTime = Infinity, maxTime = -Infinity;
|
|
||||||
selectedSeriesData.forEach(s => {
|
|
||||||
minTime = Math.min(minTime, s.data[0].timestamp);
|
|
||||||
maxTime = Math.max(maxTime, s.data[s.data.length - 1].timestamp);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (minTime >= maxTime) return [];
|
|
||||||
|
|
||||||
// 生成均匀的目标时间点
|
|
||||||
const maxPoints = getMaxDataPoints(timeRange);
|
|
||||||
const timeStep = (maxTime - minTime) / (maxPoints - 1);
|
|
||||||
const targetTimestamps: number[] = [];
|
|
||||||
for (let i = 0; i < maxPoints; i++) {
|
|
||||||
targetTimestamps.push(minTime + i * timeStep);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 线性插值函数
|
|
||||||
const interpolate = (data: Array<{timestamp: number; value: number}>, targetTime: number): number | null => {
|
|
||||||
if (data.length === 0) return null;
|
|
||||||
if (data.length === 1) return data[0].timestamp === targetTime ? data[0].value : null;
|
|
||||||
|
|
||||||
// 超出范围不插值(防止产生虚假连线)
|
|
||||||
if (targetTime < data[0].timestamp || targetTime > data[data.length - 1].timestamp) {
|
|
||||||
// 如果距离最近的点足够近(比如小于两个采样间隔),可以考虑保留,否则返回null
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 二分查找
|
|
||||||
let left = 0, right = data.length - 1;
|
|
||||||
while (right - left > 1) {
|
|
||||||
const mid = Math.floor((left + right) / 2);
|
|
||||||
if (data[mid].timestamp <= targetTime) {
|
|
||||||
left = mid;
|
|
||||||
} else {
|
|
||||||
right = mid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const leftPoint = data[left];
|
|
||||||
const rightPoint = data[right];
|
|
||||||
const ratio = (targetTime - leftPoint.timestamp) / (rightPoint.timestamp - leftPoint.timestamp);
|
|
||||||
return leftPoint.value + ratio * (rightPoint.value - leftPoint.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 对齐所有探针数据
|
|
||||||
return targetTimestamps.map(timestamp => {
|
|
||||||
const dataPoint: any = {timestamp};
|
|
||||||
selectedSeriesData.forEach(s => {
|
|
||||||
const value = interpolate(s.data, timestamp);
|
|
||||||
if (value !== null) {
|
|
||||||
dataPoint[s.key] = Number(value.toFixed(2));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return dataPoint;
|
|
||||||
});
|
|
||||||
}, [historyData, selectedAgents, timeRange, customStart, customEnd]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className={'p-6'}>
|
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-bold tracking-wide text-slate-800 dark:text-cyan-100 uppercase">响应时间趋势</h3>
|
|
||||||
<p className="text-xs text-gray-600 dark:text-cyan-500 mt-1 font-mono">监控各探针的响应时间变化</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col sm:flex-row flex-wrap items-start sm:items-center gap-3">
|
|
||||||
<TimeRangeSelector
|
|
||||||
value={timeRange}
|
|
||||||
onChange={setTimeRange}
|
|
||||||
options={MONITOR_TIME_RANGE_OPTIONS}
|
|
||||||
enableCustom
|
|
||||||
customRange={customRange}
|
|
||||||
onCustomRangeApply={(range) => {
|
|
||||||
setCustomRange(range);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 使用提示和恢复按钮 */}
|
|
||||||
{availableAgents.length > 0 && (
|
|
||||||
<div className="mb-3 flex items-center justify-between">
|
|
||||||
<div className="text-xs text-gray-500 dark:text-cyan-600">
|
|
||||||
💡 点击图表线条或图例切换显示
|
|
||||||
</div>
|
|
||||||
{hasUnselected && (
|
|
||||||
<button
|
|
||||||
onClick={handleSelectAll}
|
|
||||||
className="p-1.5 rounded
|
|
||||||
text-gray-500 dark:text-cyan-500
|
|
||||||
hover:text-gray-700 dark:hover:text-cyan-400
|
|
||||||
hover:bg-gray-100 dark:hover:bg-cyan-900/30
|
|
||||||
transition-colors"
|
|
||||||
title="恢复全选"
|
|
||||||
>
|
|
||||||
<RotateCcw size={16}/>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{chartData.length > 0 ? (
|
|
||||||
<div>
|
|
||||||
<ResponsiveContainer width="100%" height={360}>
|
|
||||||
<AreaChart data={chartData}>
|
|
||||||
<defs>
|
|
||||||
{Array.from(selectedAgents).map((agentId, index) => {
|
|
||||||
const originalIndex = availableAgents.findIndex(a => a.id === agentId);
|
|
||||||
const agentKey = `agent_${agentId}`;
|
|
||||||
return (
|
|
||||||
<linearGradient key={agentKey} id={`gradient_${agentKey}`} x1="0" y1="0"
|
|
||||||
x2="0" y2="1">
|
|
||||||
<stop offset="5%" stopColor={colors[originalIndex]} stopOpacity={0.3}/>
|
|
||||||
<stop offset="95%" stopColor={colors[originalIndex]} stopOpacity={0}/>
|
|
||||||
</linearGradient>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</defs>
|
|
||||||
<CartesianGrid
|
|
||||||
strokeDasharray="3 3"
|
|
||||||
className="stroke-slate-200 dark:stroke-cyan-900/30"
|
|
||||||
vertical={false}
|
|
||||||
/>
|
|
||||||
<XAxis
|
|
||||||
dataKey="timestamp"
|
|
||||||
type="number"
|
|
||||||
scale="time"
|
|
||||||
domain={['dataMin', 'dataMax']}
|
|
||||||
tickFormatter={(value) => formatChartTime(Number(value), timeRange, rangeMs)}
|
|
||||||
className="text-xs text-gray-600 dark:text-cyan-500 font-mono"
|
|
||||||
stroke="currentColor"
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
angle={-15}
|
|
||||||
textAnchor="end"
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
className="text-xs text-gray-600 dark:text-cyan-500 font-mono"
|
|
||||||
stroke="currentColor"
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
tickFormatter={(value) => `${value}ms`}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
content={<CustomTooltip unit={'ms'}/>}
|
|
||||||
wrapperStyle={{zIndex: 9999}}
|
|
||||||
/>
|
|
||||||
{Array.from(selectedAgents).map((agentId) => {
|
|
||||||
const originalIndex = availableAgents.findIndex(a => a.id === agentId);
|
|
||||||
const agentKey = `agent_${agentId}`;
|
|
||||||
const agent = availableAgents.find(a => a.id === agentId);
|
|
||||||
return (
|
|
||||||
<Area
|
|
||||||
key={agentKey}
|
|
||||||
type="monotone"
|
|
||||||
dataKey={agentKey}
|
|
||||||
name={agent?.name || agentId.substring(0, 8)}
|
|
||||||
stroke={colors[originalIndex]}
|
|
||||||
strokeWidth={2}
|
|
||||||
fill={`url(#gradient_${agentKey})`}
|
|
||||||
activeDot={{r: 5, strokeWidth: 0}}
|
|
||||||
dot={false}
|
|
||||||
connectNulls
|
|
||||||
onClick={handleAreaClick}
|
|
||||||
style={{cursor: 'pointer'}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
|
|
||||||
{/* 桌面端:直接显示图例 */}
|
|
||||||
{!isMobile && availableAgents.length > 0 && (
|
|
||||||
<CustomLegend
|
|
||||||
onClick={toggleAgent}
|
|
||||||
selectedAgents={selectedAgents}
|
|
||||||
allAgents={availableAgents}
|
|
||||||
colors={colors}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 移动端:可折叠图例 */}
|
|
||||||
{isMobile && availableAgents.length > 0 && (
|
|
||||||
<div className="pt-4">
|
|
||||||
<button
|
|
||||||
onClick={toggleLegend}
|
|
||||||
className="w-full flex items-center justify-center gap-2 py-2 text-xs text-gray-600 dark:text-cyan-400 hover:text-gray-900 dark:hover:text-cyan-300"
|
|
||||||
>
|
|
||||||
<span>{legendCollapsed ? '显示图例' : '收起图例'}</span>
|
|
||||||
{legendCollapsed ? <ChevronDown size={16}/> : <ChevronUp size={16}/>}
|
|
||||||
</button>
|
|
||||||
<CustomLegend
|
|
||||||
onClick={toggleAgent}
|
|
||||||
selectedAgents={selectedAgents}
|
|
||||||
allAgents={availableAgents}
|
|
||||||
colors={colors}
|
|
||||||
collapsed={legendCollapsed}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ChartPlaceholder
|
|
||||||
subtitle="正在收集数据,请稍后查看历史趋势"
|
|
||||||
heightClass="h-80"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ========================================== MonitorDetail ========================================== */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 监控详情页面
|
|
||||||
* 显示监控的详细信息、响应时间趋势和各探针统计
|
|
||||||
*/
|
|
||||||
const MonitorDetail = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const {id} = useParams<{id: string}>();
|
|
||||||
|
|
||||||
// 获取监控详情(聚合数据)
|
|
||||||
const {data: monitorDetail, isLoading} = useQuery<PublicMonitor>({
|
|
||||||
queryKey: ['monitorDetail', id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!id) throw new Error('Monitor ID is required');
|
|
||||||
return pika.getMonitorStats<PublicMonitor>(id);
|
|
||||||
},
|
|
||||||
refetchInterval: 30000,
|
|
||||||
enabled: !!id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 获取各探针的统计数据
|
|
||||||
const {data: monitorStats = []} = useQuery<AgentMonitorStat[]>({
|
|
||||||
queryKey: ['monitorAgentStats', id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!id) return [];
|
|
||||||
return pika.getMonitorAgents<AgentMonitorStat>(id);
|
|
||||||
},
|
|
||||||
refetchInterval: 30000,
|
|
||||||
enabled: !!id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <LoadingSpinner/>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!monitorDetail) {
|
|
||||||
return <EmptyState/>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-[#f0f2f5] dark:bg-[#05050a] min-h-screen">
|
|
||||||
<div className="mx-auto flex max-w-7xl flex-col px-4 pb-10 pt-4 sm:pt-6 sm:px-6 lg:px-8">
|
|
||||||
{/* 头部区域 */}
|
|
||||||
<MonitorHero
|
|
||||||
monitor={monitorDetail}
|
|
||||||
onBack={() => navigate('/monitors')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 主内容区 */}
|
|
||||||
<main className="flex-1 py-6 sm:py-8 lg:py-10 space-y-6 sm:space-y-8 lg:space-y-10">
|
|
||||||
{/* 响应时间趋势图表 */}
|
|
||||||
<ResponseTimeChart
|
|
||||||
monitorId={id!}
|
|
||||||
monitorStats={monitorStats}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 各探针详细数据 */}
|
|
||||||
<AgentStatsTable
|
|
||||||
monitorStats={monitorStats}
|
|
||||||
monitorType={monitorDetail.type}
|
|
||||||
/>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MonitorDetail;
|
|
||||||
@@ -1,431 +0,0 @@
|
|||||||
import {useEffect, useMemo, useState} from 'react';
|
|
||||||
import {Link} from 'react-router-dom';
|
|
||||||
import {useQuery} from '@tanstack/react-query';
|
|
||||||
import {AlertTriangle, BarChart3, CheckCircle2, Globe, Loader2, Maximize2, Search, Server, Shield, ShieldCheck, Wifi, Zap} from 'lucide-react';
|
|
||||||
import {Area, AreaChart, ResponsiveContainer} from 'recharts';
|
|
||||||
import {pika} from '../api';
|
|
||||||
import type {MetricsResponse, PublicMonitor} from '../types';
|
|
||||||
import {cn, formatDateTime} from '../lib/utils';
|
|
||||||
import {Card, StatBlock, StatusBadge} from '../components';
|
|
||||||
|
|
||||||
/* ========================================== TypeIcon ========================================== */
|
|
||||||
|
|
||||||
const TypeIcon = ({type}: {type: string}) => {
|
|
||||||
switch (type.toLowerCase()) {
|
|
||||||
case 'https':
|
|
||||||
return <ShieldCheck className="w-4 h-4 text-purple-500 dark:text-purple-400"/>;
|
|
||||||
case 'http':
|
|
||||||
return <Globe className="w-4 h-4 text-blue-500 dark:text-blue-400"/>;
|
|
||||||
case 'tcp':
|
|
||||||
return <Server className="w-4 h-4 text-orange-500 dark:text-orange-400"/>;
|
|
||||||
case 'icmp':
|
|
||||||
case 'ping':
|
|
||||||
return <Wifi className="w-4 h-4 text-cyan-500 dark:text-cyan-500"/>;
|
|
||||||
default:
|
|
||||||
return <Server className="w-4 h-4 text-slate-500 dark:text-slate-400"/>;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ========================================== CertBadge ========================================== */
|
|
||||||
|
|
||||||
const CertBadge = ({expiryTime, daysLeft}: {expiryTime: number; daysLeft: number}) => {
|
|
||||||
if (!expiryTime || daysLeft === undefined) return null;
|
|
||||||
|
|
||||||
const isExpired = daysLeft < 0;
|
|
||||||
let colorClass = "text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20";
|
|
||||||
|
|
||||||
if (isExpired) {
|
|
||||||
colorClass = "text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-500/10 border-rose-200 dark:border-rose-500/20";
|
|
||||||
} else if (daysLeft < 30) {
|
|
||||||
colorClass = "text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10 border-amber-200 dark:border-amber-500/20";
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("flex items-center gap-1.5 px-2 py-1 rounded text-xs border", colorClass)}>
|
|
||||||
<ShieldCheck className="w-3 h-3"/>
|
|
||||||
<span>{isExpired ? "已过期" : `${daysLeft} 天后过期`}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ========================================== MonitorCard ========================================== */
|
|
||||||
|
|
||||||
export type DisplayMode = 'avg' | 'max';
|
|
||||||
|
|
||||||
const MiniChart = ({data, lastValue, id}: {
|
|
||||||
data: Array<{timestamp: number; value: number}>;
|
|
||||||
lastValue?: number;
|
|
||||||
id: string;
|
|
||||||
}) => {
|
|
||||||
const chartData = useMemo(() => [...data].sort((a, b) => a.timestamp - b.timestamp), [data]);
|
|
||||||
if (chartData.length === 0) {
|
|
||||||
return <div className="flex h-16 w-full items-center justify-center text-xs text-slate-400 dark:text-slate-500">暂无数据</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const color = lastValue && lastValue <= 200 ? '#22d3ee' : '#fbbf24';
|
|
||||||
return (
|
|
||||||
<div className="-mb-2 h-16 w-full">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<AreaChart data={chartData}>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id={`colorLatency-${id}`} x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor={color} stopOpacity={0.3}/>
|
|
||||||
<stop offset="100%" stopColor={color} stopOpacity={0}/>
|
|
||||||
</linearGradient>
|
|
||||||
<filter id={`glow-${id}`} height="300%" width="300%" x="-75%" y="-75%">
|
|
||||||
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
|
|
||||||
<feMerge>
|
|
||||||
<feMergeNode in="coloredBlur"/>
|
|
||||||
<feMergeNode in="SourceGraphic"/>
|
|
||||||
</feMerge>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="value"
|
|
||||||
stroke={color}
|
|
||||||
fill={`url(#colorLatency-${id})`}
|
|
||||||
strokeWidth={2}
|
|
||||||
filter={`url(#glow-${id})`}
|
|
||||||
isAnimationActive={false}
|
|
||||||
connectNulls
|
|
||||||
dot={false}
|
|
||||||
/>
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const MonitorCard = ({monitor, displayMode}: {
|
|
||||||
monitor: PublicMonitor;
|
|
||||||
displayMode: DisplayMode;
|
|
||||||
}) => {
|
|
||||||
// 为每个监控卡片查询历史数据
|
|
||||||
const {data: historyData} = useQuery<MetricsResponse>({
|
|
||||||
queryKey: ['monitorHistory', monitor.id, '12h'], // 对应后端 60 秒步长
|
|
||||||
queryFn: async () => {
|
|
||||||
return pika.getMonitorHistory(monitor.id, {range: '1h'});
|
|
||||||
},
|
|
||||||
refetchInterval: 60000,
|
|
||||||
staleTime: 30000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 转换时序数据为图表数据 - 使用统一格点对该对齐多探针数据
|
|
||||||
const chartData = useMemo(() => {
|
|
||||||
if (!historyData?.series || historyData.series.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const validSeries = historyData.series.filter(s => s.data && s.data.length > 0);
|
|
||||||
if (validSeries.length === 0) return [];
|
|
||||||
|
|
||||||
// 确定全局时间范围
|
|
||||||
let minTime = Infinity, maxTime = -Infinity;
|
|
||||||
validSeries.forEach(s => {
|
|
||||||
minTime = Math.min(minTime, s.data![0].timestamp);
|
|
||||||
maxTime = Math.max(maxTime, s.data![s.data!.length - 1].timestamp);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (minTime >= maxTime) return [];
|
|
||||||
|
|
||||||
// 定义目标采集点 (1小时数据,建议 60 个采集点)
|
|
||||||
const maxPoints = 60;
|
|
||||||
const timeStep = (maxTime - minTime) / (maxPoints - 1);
|
|
||||||
const targetTimestamps: number[] = [];
|
|
||||||
for (let i = 0; i < maxPoints; i++) {
|
|
||||||
targetTimestamps.push(minTime + i * timeStep);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 线性插值函数
|
|
||||||
const interpolate = (data: Array<{ timestamp: number; value: number }>, targetTime: number): number | null => {
|
|
||||||
if (data.length === 0) return null;
|
|
||||||
if (data.length === 1) return data[0].timestamp === targetTime ? data[0].value : null;
|
|
||||||
if (targetTime < data[0].timestamp || targetTime > data[data.length - 1].timestamp) return null;
|
|
||||||
|
|
||||||
let left = 0, right = data.length - 1;
|
|
||||||
while (right - left > 1) {
|
|
||||||
const mid = Math.floor((left + right) / 2);
|
|
||||||
if (data[mid].timestamp <= targetTime) left = mid;
|
|
||||||
else right = mid;
|
|
||||||
}
|
|
||||||
const leftPoint = data[left];
|
|
||||||
const rightPoint = data[right];
|
|
||||||
const ratio = (targetTime - leftPoint.timestamp) / (rightPoint.timestamp - leftPoint.timestamp);
|
|
||||||
return leftPoint.value + ratio * (rightPoint.value - leftPoint.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 对每个目标时间点,计算所有探针的聚合值
|
|
||||||
return targetTimestamps.map(timestamp => {
|
|
||||||
const values: number[] = [];
|
|
||||||
validSeries.forEach(s => {
|
|
||||||
const val = interpolate(s.data!, timestamp);
|
|
||||||
if (val !== null) values.push(val);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (values.length === 0) return { timestamp, value: 0 };
|
|
||||||
|
|
||||||
return {
|
|
||||||
timestamp,
|
|
||||||
value: displayMode === 'avg'
|
|
||||||
? Math.round(values.reduce((a, b) => a + b, 0) / values.length)
|
|
||||||
: Math.max(...values),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [historyData, displayMode]);
|
|
||||||
|
|
||||||
const displayValue = displayMode === 'avg' ? monitor.responseTime : monitor.responseTimeMax;
|
|
||||||
const displayLabel = displayMode === 'avg' ? '平均延迟' : '最差节点延迟';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className={'p-5'} interactive>
|
|
||||||
{/* 头部 */}
|
|
||||||
<div className="flex justify-between items-start mb-4">
|
|
||||||
<div className="flex gap-3 flex-1 min-w-0">
|
|
||||||
<div
|
|
||||||
className="p-2.5 bg-gray-100 dark:bg-cyan-950/30 border border-slate-200 dark:border-cyan-500/20 rounded-lg flex-shrink-0">
|
|
||||||
<TypeIcon type={monitor.type}/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="font-bold text-sm text-slate-800 dark:text-cyan-100 tracking-wide truncate group-hover:text-cyan-500 transition-colors">
|
|
||||||
{monitor.name}
|
|
||||||
</h3>
|
|
||||||
<div className="text-xs font-mono text-gray-600 dark:text-cyan-500/80 mb-0.5 tracking-wider truncate">
|
|
||||||
{monitor.target}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-shrink-0 ml-2">
|
|
||||||
<StatusBadge status={monitor.status}/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 指标信息 */}
|
|
||||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-gray-600 dark:text-cyan-500 mb-1 flex items-center gap-1">
|
|
||||||
{displayLabel}
|
|
||||||
{monitor.agentCount > 0 && (
|
|
||||||
<span
|
|
||||||
className="bg-slate-200 dark:bg-slate-700 text-xs px-1.5 rounded-full text-slate-700 dark:text-cyan-300">
|
|
||||||
{monitor.agentCount} 节点
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
className={`text-xl font-bold flex items-baseline gap-1 ${displayValue > 200 ? 'text-amber-600 dark:text-amber-400 drop-shadow-[0_0_8px_rgba(251,191,36,0.3)] dark:drop-shadow-[0_0_8px_rgba(251,191,36,0.5)]' : 'text-slate-800 dark:text-white drop-shadow-none dark:drop-shadow-[0_0_8px_rgba(34,211,238,0.5)]'}`}>
|
|
||||||
{displayValue}<span className="text-xs text-gray-600 dark:text-cyan-500 font-normal">ms</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{monitor.type === 'https' && monitor.certExpiryTime ? (
|
|
||||||
<>
|
|
||||||
<p className="text-xs text-gray-600 dark:text-cyan-500 mb-1">SSL 证书</p>
|
|
||||||
<CertBadge
|
|
||||||
expiryTime={monitor.certExpiryTime}
|
|
||||||
daysLeft={monitor.certDaysLeft}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p className="text-xs text-gray-600 dark:text-cyan-500 mb-1">上次检测</p>
|
|
||||||
<p className="md:text-sm text-xs text-gray-700 dark:text-cyan-300 font-mono">
|
|
||||||
{formatDateTime(monitor.lastCheckTime)}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 迷你走势图 */}
|
|
||||||
<MiniChart
|
|
||||||
data={chartData}
|
|
||||||
lastValue={displayValue}
|
|
||||||
id={monitor.id}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ========================================== MonitorList 本地组件 ========================================== */
|
|
||||||
|
|
||||||
const MonitorListSpinner = () => (
|
|
||||||
<div className="flex min-h-[400px] w-full items-center justify-center">
|
|
||||||
<div className="flex flex-col items-center gap-3 text-gray-600 dark:text-cyan-500">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-gray-600 dark:text-cyan-500"/>
|
|
||||||
<span className="text-sm font-mono">加载监控数据中...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const MonitorListEmpty = () => (
|
|
||||||
<div className="flex min-h-[400px] flex-col items-center justify-center text-gray-600 dark:text-cyan-500">
|
|
||||||
<Shield className="mb-4 h-16 w-16 opacity-20"/>
|
|
||||||
<p className="text-lg font-medium font-mono">暂无监控数据</p>
|
|
||||||
<p className="mt-2 text-sm text-gray-600 dark:text-cyan-500">请先在管理后台添加监控任务</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
interface Stats {
|
|
||||||
total: number;
|
|
||||||
online: number;
|
|
||||||
issues: number;
|
|
||||||
avgLatency: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================================== MonitorList ========================================== */
|
|
||||||
|
|
||||||
const MonitorList = () => {
|
|
||||||
const [searchKeyword, setSearchKeyword] = useState('');
|
|
||||||
const [displayMode, setDisplayMode] = useState<DisplayMode>('max');
|
|
||||||
|
|
||||||
const {data: monitors = [], isLoading} = useQuery<PublicMonitor[]>({
|
|
||||||
queryKey: ['publicMonitors'],
|
|
||||||
queryFn: () => pika.listMonitors<PublicMonitor>(),
|
|
||||||
refetchInterval: 30000,
|
|
||||||
});
|
|
||||||
|
|
||||||
let [stats, setStats] = useState<Stats>();
|
|
||||||
|
|
||||||
// 过滤和搜索
|
|
||||||
const filteredMonitors = useMemo(() => {
|
|
||||||
let result = [...monitors];
|
|
||||||
|
|
||||||
// 搜索过滤
|
|
||||||
if (searchKeyword.trim()) {
|
|
||||||
const keyword = searchKeyword.toLowerCase();
|
|
||||||
result = result.filter(m =>
|
|
||||||
m.name.toLowerCase().includes(keyword) ||
|
|
||||||
m.target.toLowerCase().includes(keyword)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.sort((a, b) => Number(a.status !== 'up') - Number(b.status !== 'up'));
|
|
||||||
}, [monitors, searchKeyword]);
|
|
||||||
|
|
||||||
// 统计信息
|
|
||||||
const calculateStats = (monitors: PublicMonitor[]) => {
|
|
||||||
const total = monitors.length;
|
|
||||||
const online = monitors.filter(m => m.status === 'up').length;
|
|
||||||
const issues = total - online;
|
|
||||||
const avgLatency = total > 0
|
|
||||||
? Math.round(monitors.reduce((acc, curr) => acc + curr.responseTime, 0) / total)
|
|
||||||
: 0;
|
|
||||||
return {total, online, issues, avgLatency};
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let stats = calculateStats(monitors);
|
|
||||||
setStats(stats);
|
|
||||||
}, [monitors]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
|
|
||||||
<MonitorListSpinner/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-7xl px-3 sm:px-6 lg:px-8 py-4 sm:py-8 space-y-4 sm:space-y-6">
|
|
||||||
{/* 统计卡片 */}
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 sm:gap-4">
|
|
||||||
<StatBlock
|
|
||||||
title="监控服务总数"
|
|
||||||
value={stats?.total}
|
|
||||||
icon={Globe}
|
|
||||||
color="cyan"
|
|
||||||
/>
|
|
||||||
<StatBlock
|
|
||||||
title="系统正常"
|
|
||||||
value={stats?.online}
|
|
||||||
icon={CheckCircle2}
|
|
||||||
color="emerald"
|
|
||||||
glow
|
|
||||||
/>
|
|
||||||
<StatBlock
|
|
||||||
title="异常服务"
|
|
||||||
value={stats?.issues}
|
|
||||||
icon={AlertTriangle}
|
|
||||||
color="rose"
|
|
||||||
alert={stats?.issues > 0}
|
|
||||||
/>
|
|
||||||
<StatBlock
|
|
||||||
title="全局平均延迟"
|
|
||||||
value={stats?.avgLatency}
|
|
||||||
unit={'ms'}
|
|
||||||
icon={Zap}
|
|
||||||
color="blue"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 过滤和搜索 */}
|
|
||||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-6">
|
|
||||||
<div className="flex flex-wrap gap-4 items-center w-full md:w-auto">
|
|
||||||
{/* 显示模式切换 */}
|
|
||||||
<div className="flex gap-1 bg-slate-100 dark:bg-black/40 p-1 rounded-lg border border-slate-200 dark:border-cyan-900/50 items-center">
|
|
||||||
<span className="text-xs text-gray-600 dark:text-cyan-500 px-2 font-mono">卡片指标:</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setDisplayMode('avg')}
|
|
||||||
className={cn(
|
|
||||||
"px-3 py-1.5 text-xs font-medium rounded transition-all flex items-center gap-1 font-mono cursor-pointer",
|
|
||||||
displayMode === 'avg'
|
|
||||||
? 'bg-gray-200 dark:bg-cyan-500/20 text-gray-800 dark:text-cyan-300 border border-gray-300 dark:border-cyan-500/30'
|
|
||||||
: 'text-gray-600 dark:text-cyan-500 hover:text-gray-800 dark:hover:text-cyan-400'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<BarChart3 className="w-3 h-3"/> 平均
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setDisplayMode('max')}
|
|
||||||
className={cn(
|
|
||||||
"px-3 py-1.5 text-xs font-medium rounded transition-all flex items-center gap-1 font-mono cursor-pointer",
|
|
||||||
displayMode === 'max'
|
|
||||||
? 'bg-gray-200 dark:bg-cyan-500/20 text-gray-800 dark:text-cyan-300 border border-gray-300 dark:border-cyan-500/30'
|
|
||||||
: 'text-gray-600 dark:text-cyan-500 hover:text-gray-800 dark:hover:text-cyan-400'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Maximize2 className="w-3 h-3"/> 最差(Max)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 搜索框 */}
|
|
||||||
<div className="relative w-full md:w-64 group">
|
|
||||||
<div
|
|
||||||
className="hidden dark:block absolute -inset-0.5 bg-gradient-to-r from-cyan-500 to-blue-600 rounded-lg blur opacity-20 group-hover:opacity-40 transition duration-500"></div>
|
|
||||||
<div className="relative flex items-center bg-white dark:bg-[#0a0b10] rounded-lg border border-slate-200 dark:border-cyan-900">
|
|
||||||
<Search className="w-4 h-4 ml-3 text-gray-500 dark:text-cyan-500"/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="搜索服务名称或地址..."
|
|
||||||
value={searchKeyword}
|
|
||||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
|
||||||
className="w-full bg-transparent border-none text-xs text-gray-800 dark:text-cyan-100 p-2.5 focus:ring-0 placeholder-gray-400 dark:placeholder-cyan-600 font-mono focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 监控卡片列表 */}
|
|
||||||
{filteredMonitors.length === 0 ? (
|
|
||||||
<MonitorListEmpty/>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 md:gap-4 gap-2">
|
|
||||||
{filteredMonitors.map(monitor => (
|
|
||||||
<Link key={monitor.id} to={`/monitors/${monitor.id}`}>
|
|
||||||
<MonitorCard
|
|
||||||
monitor={monitor}
|
|
||||||
displayMode={displayMode}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MonitorList;
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import {Outlet} from 'react-router-dom';
|
|
||||||
import {PublicHeader, PublicFooter} from '../components';
|
|
||||||
|
|
||||||
const PublicLayout = () => {
|
|
||||||
return (
|
|
||||||
<div className="bg-[#f0f2f5] dark:bg-[#05050a] text-slate-800 dark:text-slate-200 flex flex-col relative overflow-x-hidden transition-colors duration-500">
|
|
||||||
{/* 背景网格效果 */}
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 pointer-events-none z-0 transition-opacity duration-500"
|
|
||||||
style={{
|
|
||||||
backgroundImage: 'linear-gradient(to_right,#cbd5e180_1px,transparent_1px),linear-gradient(to_bottom,#cbd5e180_1px,transparent_1px)',
|
|
||||||
backgroundSize: '30px 30px',
|
|
||||||
}}
|
|
||||||
></div>
|
|
||||||
{/* 暗色模式网格 */}
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 pointer-events-none z-0 opacity-0 dark:opacity-100 transition-opacity duration-500"
|
|
||||||
style={{
|
|
||||||
backgroundImage: 'linear-gradient(to_right,#4f4f4f1a_1px,transparent_1px),linear-gradient(to_bottom,#4f4f4f1a_1px,transparent_1px)',
|
|
||||||
backgroundSize: '30px 30px',
|
|
||||||
}}
|
|
||||||
></div>
|
|
||||||
{/* 顶部发光效果 */}
|
|
||||||
<div
|
|
||||||
className="fixed top-0 left-1/2 -translate-x-1/2 w-[1000px] h-[300px] bg-cyan-600/15 blur-[120px] rounded-full pointer-events-none z-0 opacity-40 dark:opacity-100 transition-opacity duration-500"
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<PublicHeader/>
|
|
||||||
<div className="relative z-10 flex flex-col min-h-screen pt-[81px]">
|
|
||||||
<main className="flex-1">
|
|
||||||
<Outlet/>
|
|
||||||
</main>
|
|
||||||
<PublicFooter/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PublicLayout;
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,848 +0,0 @@
|
|||||||
import {type FC, type ReactNode, useMemo, useState} from 'react';
|
|
||||||
import {Link, useNavigate} from 'react-router-dom';
|
|
||||||
import {useQuery} from '@tanstack/react-query';
|
|
||||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
|
||||||
import {
|
|
||||||
Activity,
|
|
||||||
AlertTriangle,
|
|
||||||
ArrowDown,
|
|
||||||
ArrowUp,
|
|
||||||
Calendar,
|
|
||||||
Clock,
|
|
||||||
Cpu,
|
|
||||||
Filter,
|
|
||||||
Globe,
|
|
||||||
HardDrive,
|
|
||||||
LinkIcon,
|
|
||||||
MemoryStick,
|
|
||||||
Network,
|
|
||||||
Thermometer,
|
|
||||||
UnlinkIcon
|
|
||||||
} from 'lucide-react';
|
|
||||||
import {pika} from '../api';
|
|
||||||
import type {Agent, LatestMetrics, TagsResponse} from '../types';
|
|
||||||
import {cn, formatBytes, formatSpeed, formatTime, formatUptime, isExpired} from '../lib/utils';
|
|
||||||
import {Card, LoadingSpinner, StatBlock} from '../components';
|
|
||||||
|
|
||||||
interface AgentWithMetrics extends Agent {
|
|
||||||
metrics?: LatestMetrics;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================================== CompactResourceBar ========================================== */
|
|
||||||
|
|
||||||
// 紧凑型资源条组件
|
|
||||||
const CompactResourceBar = ({value, label, subtext, icon: Icon, color = "bg-cyan-500"}: {
|
|
||||||
value: number;
|
|
||||||
label: string;
|
|
||||||
subtext?: string;
|
|
||||||
icon: any;
|
|
||||||
color?: string;
|
|
||||||
}) => {
|
|
||||||
const isCritical = value > 90;
|
|
||||||
const isWarning = value > 75;
|
|
||||||
|
|
||||||
// 颜色定义 (Hex codes for precise control)
|
|
||||||
let barColor = "";
|
|
||||||
let iconClass = "";
|
|
||||||
let textClass = "dark:text-cyan-50"; // 默认高亮白/青
|
|
||||||
|
|
||||||
if (isCritical) {
|
|
||||||
barColor = "bg-rose-500";
|
|
||||||
iconClass = "text-rose-600 dark:text-rose-500";
|
|
||||||
textClass = "text-rose-400";
|
|
||||||
} else if (isWarning) {
|
|
||||||
barColor = "bg-amber-500";
|
|
||||||
iconClass = "text-amber-600 dark:text-amber-400";
|
|
||||||
textClass = "text-amber-400";
|
|
||||||
} else if (color.includes("purple")) {
|
|
||||||
barColor = "bg-purple-500";
|
|
||||||
iconClass = "text-purple-600 dark:text-purple-400";
|
|
||||||
} else if (color.includes("blue")) {
|
|
||||||
barColor = "bg-blue-500";
|
|
||||||
iconClass = "text-blue-600 dark:text-blue-400";
|
|
||||||
} else {
|
|
||||||
barColor = "bg-cyan-500";
|
|
||||||
iconClass = "text-cyan-600 dark:text-cyan-400";
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Tooltip.Provider delayDuration={200}>
|
|
||||||
<Tooltip.Root>
|
|
||||||
<Tooltip.Trigger asChild>
|
|
||||||
<div className="flex items-center w-full h-5 gap-2 text-xs font-mono">
|
|
||||||
{/* Icon & Label */}
|
|
||||||
<div className={`flex items-center gap-2 w-10 flex-shrink-0 ${iconClass}`}>
|
|
||||||
<Icon className="w-3.5 h-3.5" strokeWidth={2}/>
|
|
||||||
<span className="text-xs font-bold tracking-wider opacity-80">{label}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Track Container */}
|
|
||||||
<div className="w-[100px] h-2 dark:bg-[#121217] bg-[#e2e8f0] relative border border-white/5 overflow-hidden">
|
|
||||||
|
|
||||||
{/* Scale Marks Background (The "Ruler" effect) */}
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 w-full h-full opacity-20 pointer-events-none z-0"
|
|
||||||
style={{
|
|
||||||
backgroundImage: 'linear-gradient(90deg, #94a3b8 1px, transparent 1px)',
|
|
||||||
backgroundSize: '10% 100%'
|
|
||||||
}}
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={`h-full relative transition-all duration-500 ease-out z-10 ${barColor}`}
|
|
||||||
style={{
|
|
||||||
width: `${Math.min(value, 100)}%`,
|
|
||||||
backgroundImage: 'linear-gradient(45deg,rgba(0,0,0,.2) 25%,transparent 25%,transparent 50%,rgba(0,0,0,.2) 50%,rgba(0,0,0,.2) 75%,transparent 75%,transparent)',
|
|
||||||
backgroundSize: '4px 4px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="absolute right-0 top-0 bottom-0 w-[1.5px] bg-red-500 shadow-[0_0_8px_red-500] dark:bg-white dark:shadow-[0_0_8px_white]"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={cn(`w-10 font-medium text-xs cursor-pointer`, textClass)}>
|
|
||||||
{value.toFixed(1)}%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Tooltip.Trigger>
|
|
||||||
<Tooltip.Portal>
|
|
||||||
<Tooltip.Content
|
|
||||||
className="px-2 py-1 bg-slate-800/95 text-slate-200 text-xs rounded border border-white/10 whitespace-nowrap shadow-lg z-50 animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95"
|
|
||||||
sideOffset={8}
|
|
||||||
side="top"
|
|
||||||
>
|
|
||||||
{subtext}
|
|
||||||
<Tooltip.Arrow className="fill-slate-800/95"/>
|
|
||||||
</Tooltip.Content>
|
|
||||||
</Tooltip.Portal>
|
|
||||||
</Tooltip.Root>
|
|
||||||
</Tooltip.Provider>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ========================================== 共享辅助函数 ========================================== */
|
|
||||||
|
|
||||||
const calculateNetworkSpeed = (metrics?: LatestMetrics) => {
|
|
||||||
if (!metrics?.network) {
|
|
||||||
return {upload: 0, download: 0};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
upload: metrics.network.totalBytesSentRate,
|
|
||||||
download: metrics.network.totalBytesRecvRate
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateDiskUsage = (metrics?: LatestMetrics) => {
|
|
||||||
if (!metrics?.disk) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return metrics.disk.usagePercent;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTemperatures = (metrics?: LatestMetrics) => {
|
|
||||||
if (!metrics?.temperature || metrics.temperature.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
// 返回所有温度数据
|
|
||||||
return metrics.temperature.sort((a, b) => a.type.localeCompare(b.type));
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTrafficProgressColor = (percent: number) => {
|
|
||||||
if (percent >= 100) return 'bg-red-500';
|
|
||||||
if (percent >= 90) return 'bg-orange-500';
|
|
||||||
if (percent >= 80) return 'bg-yellow-500';
|
|
||||||
return 'bg-emerald-500';
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ========================================== ServerCard ========================================== */
|
|
||||||
|
|
||||||
interface ServerCardProps {
|
|
||||||
server: AgentWithMetrics;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ServerCard: FC<ServerCardProps> = ({server}) => {
|
|
||||||
const isOnline = server.status === 1;
|
|
||||||
const cpuUsage = server.metrics?.cpu?.usagePercent ?? 0;
|
|
||||||
const memoryUsage = server.metrics?.memory?.usagePercent ?? 0;
|
|
||||||
const memoryTotal = server.metrics?.memory?.total ?? 0;
|
|
||||||
const memoryUsed = server.metrics?.memory?.used ?? 0;
|
|
||||||
const diskUsage = calculateDiskUsage(server.metrics);
|
|
||||||
const diskTotal = server.metrics?.disk?.total ?? 0;
|
|
||||||
const diskUsed = server.metrics?.disk?.used ?? 0;
|
|
||||||
const {upload, download} = calculateNetworkSpeed(server.metrics);
|
|
||||||
const temperatures = getTemperatures(server.metrics);
|
|
||||||
const netConn = server.metrics?.networkConnection;
|
|
||||||
const traffic = server.trafficStats;
|
|
||||||
const trafficUsagePercent = traffic?.enabled && traffic.limit > 0
|
|
||||||
? Math.min(100, (traffic.used / traffic.limit) * 100)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link to={`/servers/${server.id.substring(0, 8)}`}>
|
|
||||||
<Card>
|
|
||||||
<div className="relative z-10 p-5 space-y-2">
|
|
||||||
{/* 顶部:名称和状态 */}
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className={cn(
|
|
||||||
'font-bold text-slate-800 dark:text-cyan-100 font-mono text-base truncate',
|
|
||||||
isExpired(server.expireTime) ? 'text-red-600 dark:text-red-400' : ''
|
|
||||||
)}>
|
|
||||||
{server.name || server.hostname}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 text-xs text-gray-600 dark:text-cyan-500 mt-1 font-mono uppercase">
|
|
||||||
<span>{server.os}</span>
|
|
||||||
<span className="w-px h-2 bg-gray-400 dark:bg-cyan-800"></span>
|
|
||||||
<span>{server.arch}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{server.tags && server.tags.length > 0 && (
|
|
||||||
<div className="flex gap-1 flex-wrap justify-end">
|
|
||||||
{server.tags.slice(0, 2).map(tag => (
|
|
||||||
<span
|
|
||||||
key={tag}
|
|
||||||
className="px-1.5 py-0.5 bg-gray-100 dark:bg-cyan-900/40 text-gray-700 dark:text-cyan-500 border border-gray-300 dark:border-cyan-700/50 text-xs font-mono rounded-sm whitespace-nowrap"
|
|
||||||
>
|
|
||||||
#{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{server.tags.length > 2 && (
|
|
||||||
<span
|
|
||||||
className="px-1.5 py-0.5 bg-gray-100 dark:bg-cyan-900/40 text-gray-700 dark:text-cyan-500 border border-gray-300 dark:border-cyan-700/50 text-xs font-mono rounded-sm">
|
|
||||||
+{server.tags.length - 2}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isOnline && server.metrics?.host && (
|
|
||||||
<div className="flex items-center gap-2 text-xs font-mono mt-1.5">
|
|
||||||
<div className="flex items-center gap-1 text-gray-500 dark:text-cyan-500">
|
|
||||||
<Clock className="w-3 h-3"/>
|
|
||||||
<span>{formatUptime(server.metrics.host.uptime)}</span>
|
|
||||||
</div>
|
|
||||||
<span className="w-px h-2 bg-gray-400 dark:bg-cyan-800"></span>
|
|
||||||
<div className="flex items-center gap-1 text-gray-500 dark:text-cyan-500">
|
|
||||||
<Activity className="w-3 h-3"/>
|
|
||||||
<span>{server.metrics.host.procs} 进程</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 资源使用情况 */}
|
|
||||||
{isOnline ? (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<CompactResourceBar
|
|
||||||
value={cpuUsage}
|
|
||||||
label="CPU"
|
|
||||||
icon={Cpu}
|
|
||||||
subtext={server.metrics?.cpu ? `${server.metrics.cpu.physicalCores}核` : null}
|
|
||||||
color="bg-blue-500"
|
|
||||||
/>
|
|
||||||
<CompactResourceBar
|
|
||||||
value={memoryUsage}
|
|
||||||
label="RAM"
|
|
||||||
icon={MemoryStick}
|
|
||||||
subtext={`${formatBytes(memoryUsed, 0)}/${formatBytes(memoryTotal, 0)}`}
|
|
||||||
color="bg-purple-500"
|
|
||||||
/>
|
|
||||||
<CompactResourceBar
|
|
||||||
value={diskUsage}
|
|
||||||
label="DSK"
|
|
||||||
icon={HardDrive}
|
|
||||||
subtext={`${formatBytes(diskUsed, 0)}/${formatBytes(diskTotal, 0)}`}
|
|
||||||
color="bg-emerald-500"
|
|
||||||
/>
|
|
||||||
{temperatures.length > 0 && (
|
|
||||||
<div className="flex items-center gap-2 mt-1 text-xs font-mono pt-1 flex-wrap">
|
|
||||||
<Thermometer className="w-3 h-3 text-orange-400"/>
|
|
||||||
{temperatures.map((temp, index) => (
|
|
||||||
<span key={index} className="flex items-center gap-1">
|
|
||||||
<span className="text-orange-400">{temp.temperature?.toFixed(1)}°C</span>
|
|
||||||
<span className="text-gray-500 dark:text-cyan-500">{temp.type}</span>
|
|
||||||
{index < temperatures.length - 1 &&
|
|
||||||
<span className="text-gray-400 dark:text-cyan-900">|</span>}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-rose-500 font-mono flex items-center gap-2 py-2">
|
|
||||||
<AlertTriangle className="w-4 h-4"/>
|
|
||||||
<span>离线</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 网络和流量 */}
|
|
||||||
<div className="pt-2 border-t border-slate-200 dark:border-cyan-900/30 space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex gap-3 text-xs font-mono">
|
|
||||||
<span className="flex items-center gap-1 text-emerald-600 dark:text-emerald-400/80">
|
|
||||||
<ArrowDown className="w-3 h-3"/>
|
|
||||||
{formatSpeed(download)}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1 text-blue-600 dark:text-blue-400/80">
|
|
||||||
<ArrowUp className="w-3 h-3"/>
|
|
||||||
{formatSpeed(upload)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{server.expireTime > 0 && (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
`text-xs font-mono flex items-center gap-1 text-gray-600 dark:text-cyan-500`,
|
|
||||||
// 剩余时间小于 30 天时显示为红色
|
|
||||||
isExpired(server.expireTime) ? 'text-red-600 dark:text-red-400' : ''
|
|
||||||
)}>
|
|
||||||
<Calendar className="w-3 h-3"/>
|
|
||||||
{new Date(server.expireTime).toLocaleDateString('zh-CN')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{isOnline && netConn && (
|
|
||||||
<div className="flex gap-3 text-xs font-mono">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Network className="w-3 h-3 text-emerald-600 dark:text-emerald-400"/>
|
|
||||||
<span
|
|
||||||
className="text-emerald-600 dark:text-emerald-400">{netConn.established || 0}</span>
|
|
||||||
<span className="text-gray-600 dark:text-cyan-500">ESTABLISHED</span>
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Network className="w-3 h-3 text-blue-600 dark:text-blue-400"/>
|
|
||||||
<span className="text-blue-600 dark:text-blue-400">{netConn.listen || 0}</span>
|
|
||||||
<span className="text-gray-600 dark:text-cyan-500">LISTEN</span>
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Network className="w-3 h-3 text-rose-600 dark:text-rose-400"/>
|
|
||||||
<span className="text-rose-600 dark:text-rose-400">{netConn.closeWait || 0}</span>
|
|
||||||
<span className="text-gray-600 dark:text-cyan-500">CLOSE_WAIT</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{traffic?.enabled && (
|
|
||||||
<div className="pt-2 border-t border-slate-200 dark:border-cyan-900/30 space-y-1.5">
|
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-cyan-600 font-mono">
|
|
||||||
<Activity className="w-3 h-3"/>
|
|
||||||
<span>{traffic.type === 'recv' ? '进站' : traffic.type === 'send' ? '出站' : '全部'}流量</span>
|
|
||||||
</div>
|
|
||||||
{traffic.limit > 0 ? (
|
|
||||||
<>
|
|
||||||
<div className="flex items-baseline justify-between">
|
|
||||||
<span className="text-xs text-gray-600 dark:text-cyan-500 font-mono">
|
|
||||||
{formatBytes(traffic.used, 1)} / {formatBytes(traffic.limit, 1)}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs font-bold text-gray-700 dark:text-cyan-400 font-mono">
|
|
||||||
{trafficUsagePercent.toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-1.5 bg-slate-200 dark:bg-cyan-900/50 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`h-full transition-all ${getTrafficProgressColor(trafficUsagePercent)}`}
|
|
||||||
style={{width: `${trafficUsagePercent}%`}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-cyan-600 font-mono">
|
|
||||||
重置日期: 每月 {traffic.resetDay} 号
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-gray-500 dark:text-cyan-600 font-mono">
|
|
||||||
已使用: {formatBytes(traffic.used, 1)}
|
|
||||||
<div className="text-xs text-gray-400 dark:text-cyan-700 mt-1">仅统计模式</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ========================================== ServerList 本地组件 ========================================== */
|
|
||||||
|
|
||||||
const NetworkStatCard = ({uploadRate, downloadRate, uploadTotal, downloadTotal}: {
|
|
||||||
uploadRate: number;
|
|
||||||
downloadRate: number;
|
|
||||||
uploadTotal: number;
|
|
||||||
downloadTotal: number;
|
|
||||||
}) => (
|
|
||||||
<div className="relative overflow-hidden rounded-xl border border-slate-200 bg-white/80 p-5 shadow-sm backdrop-blur-md dark:border-blue-500/30 dark:bg-blue-500/5 dark:text-blue-400">
|
|
||||||
<Network className="absolute -right-4 -bottom-4 h-16 w-16 rotate-[-15deg] opacity-10 sm:h-24 sm:w-24"/>
|
|
||||||
<div className="relative z-10 flex items-start justify-between">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="mb-3 text-xs font-bold font-mono uppercase tracking-widest opacity-70">网络统计</div>
|
|
||||||
<div className="space-y-0.5 text-xs font-mono">
|
|
||||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
|
||||||
<ArrowUp className="h-3 w-3 flex-shrink-0 text-blue-600 dark:text-blue-400"/>
|
|
||||||
<span className="truncate dark:text-cyan-300">{formatSpeed(uploadRate)}</span>
|
|
||||||
<span className="hidden text-gray-700 dark:text-cyan-500 sm:inline">({formatBytes(uploadTotal)})</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
|
||||||
<ArrowDown className="h-3 w-3 flex-shrink-0 text-green-600 dark:text-emerald-400"/>
|
|
||||||
<span className="truncate dark:text-cyan-300">{formatSpeed(downloadRate)}</span>
|
|
||||||
<span className="hidden text-gray-700 dark:text-cyan-500 sm:inline">({formatBytes(downloadTotal)})</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Network className="m-3 h-6 w-6"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
interface ServerListEmptyProps {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
extra?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ServerListEmpty = ({title, description, extra}: ServerListEmptyProps) => (
|
|
||||||
<div
|
|
||||||
className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-slate-200 dark:border-cyan-500/30 bg-white/90 dark:bg-[#0a0b10]/90 p-12 text-center backdrop-blur">
|
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-cyan-500/10 text-cyan-500">
|
|
||||||
<HardDrive className="h-7 w-7"/>
|
|
||||||
</div>
|
|
||||||
<h3 className="mt-4 text-base font-semibold text-slate-800 dark:text-cyan-100 font-mono">{title}</h3>
|
|
||||||
<p className="mt-2 max-w-sm text-sm text-slate-600 dark:text-cyan-500">{description}</p>
|
|
||||||
{extra ? <div className="mt-4">{extra}</div> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
/* ========================================== ServerList ========================================== */
|
|
||||||
|
|
||||||
const ServerList = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [selectedTag, setSelectedTag] = useState<string>('');
|
|
||||||
|
|
||||||
const {data: agents = [], isLoading} = useQuery<AgentWithMetrics[]>({
|
|
||||||
queryKey: ['agents', 'online'],
|
|
||||||
queryFn: () => pika.listAgents<AgentWithMetrics>(),
|
|
||||||
refetchInterval: 3000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 获取标签列表
|
|
||||||
const {data: tagsData} = useQuery({
|
|
||||||
queryKey: ['tags', 'public'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await pika.getTags<TagsResponse>();
|
|
||||||
return response.tags || [];
|
|
||||||
},
|
|
||||||
refetchInterval: 30000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 计算所有标签(包括ALL和ONLINE/OFFLINE)
|
|
||||||
const allTags = useMemo(() => {
|
|
||||||
const tags = ['ALL', 'ONLINE', 'OFFLINE'];
|
|
||||||
if (tagsData && tagsData.length > 0) {
|
|
||||||
tagsData.forEach((tag: string) => {
|
|
||||||
if (!tags.includes(tag.toUpperCase())) {
|
|
||||||
tags.push(tag.toUpperCase());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return tags;
|
|
||||||
}, [tagsData]);
|
|
||||||
|
|
||||||
// 过滤逻辑
|
|
||||||
const displayAgents = useMemo(() => {
|
|
||||||
if (selectedTag === 'ONLINE') {
|
|
||||||
return agents.filter(a => a.status === 1);
|
|
||||||
} else if (selectedTag === 'OFFLINE') {
|
|
||||||
return agents.filter(a => a.status !== 1);
|
|
||||||
} else if (selectedTag && selectedTag !== 'ALL') {
|
|
||||||
return agents.filter(a => a.tags?.map(t => t.toUpperCase()).includes(selectedTag));
|
|
||||||
}
|
|
||||||
return agents;
|
|
||||||
}, [agents, selectedTag]);
|
|
||||||
|
|
||||||
// 计算统计数据(基于过滤后的 displayAgents)
|
|
||||||
const stats = useMemo(() => {
|
|
||||||
const total = displayAgents.length;
|
|
||||||
const online = displayAgents.filter(a => a.status === 1).length;
|
|
||||||
const offline = total - online;
|
|
||||||
|
|
||||||
// 计算网络统计
|
|
||||||
let totalUploadRate = 0;
|
|
||||||
let totalDownloadRate = 0;
|
|
||||||
let totalUploadTotal = 0;
|
|
||||||
let totalDownloadTotal = 0;
|
|
||||||
|
|
||||||
displayAgents.forEach(agent => {
|
|
||||||
if (agent.status === 1 && agent.metrics?.network) {
|
|
||||||
totalUploadRate += agent.metrics.network.totalBytesSentRate || 0;
|
|
||||||
totalDownloadRate += agent.metrics.network.totalBytesRecvRate || 0;
|
|
||||||
totalUploadTotal += agent.metrics.network.totalBytesSentTotal || 0;
|
|
||||||
totalDownloadTotal += agent.metrics.network.totalBytesRecvTotal || 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
total,
|
|
||||||
online,
|
|
||||||
offline,
|
|
||||||
uploadRate: totalUploadRate,
|
|
||||||
downloadRate: totalDownloadRate,
|
|
||||||
uploadTotal: totalUploadTotal,
|
|
||||||
downloadTotal: totalDownloadTotal
|
|
||||||
};
|
|
||||||
}, [displayAgents]);
|
|
||||||
|
|
||||||
const handleNavigate = (agentId: string) => {
|
|
||||||
navigate(`/servers/${agentId.substring(0, 8)}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <LoadingSpinner/>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// debug
|
|
||||||
// displayAgents = Array.from({length:10}, ()=>displayAgents).flat();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-7xl px-3 sm:px-6 lg:px-8 py-4 sm:py-8 space-y-4 sm:space-y-6">
|
|
||||||
{/* 统计卡片 */}
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 sm:gap-4">
|
|
||||||
<StatBlock
|
|
||||||
title="设备总数"
|
|
||||||
value={stats?.total}
|
|
||||||
icon={Globe}
|
|
||||||
color="cyan"
|
|
||||||
/>
|
|
||||||
<StatBlock
|
|
||||||
title="在线设备"
|
|
||||||
value={stats?.online}
|
|
||||||
icon={LinkIcon}
|
|
||||||
color="emerald"
|
|
||||||
glow
|
|
||||||
/>
|
|
||||||
<StatBlock
|
|
||||||
title="离线设备"
|
|
||||||
value={stats.offline}
|
|
||||||
icon={UnlinkIcon}
|
|
||||||
color="rose"
|
|
||||||
alert={stats?.offline > 0}
|
|
||||||
/>
|
|
||||||
<NetworkStatCard
|
|
||||||
uploadRate={stats?.uploadRate}
|
|
||||||
downloadRate={stats?.downloadRate}
|
|
||||||
uploadTotal={stats?.uploadTotal}
|
|
||||||
downloadTotal={stats?.downloadTotal}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 标签过滤器 */}
|
|
||||||
{allTags.length > 1 && (
|
|
||||||
<div className="flex flex-wrap gap-1.5 sm:gap-2 items-center">
|
|
||||||
<div
|
|
||||||
className="text-sm sm:text-xs font-mono text-gray-700 dark:text-cyan-500 flex items-center gap-1.5 sm:gap-2 mr-1 sm:mr-2 font-bold">
|
|
||||||
<Filter className="w-4 h-4"/>
|
|
||||||
<span className="hidden sm:inline">FILTERS:</span>
|
|
||||||
</div>
|
|
||||||
{allTags.map(tag => {
|
|
||||||
const tagKey = tag === 'ALL' ? '' : tag;
|
|
||||||
let count = 0;
|
|
||||||
if (tag === 'ALL') count = agents.length;
|
|
||||||
else if (tag === 'ONLINE') count = agents?.filter(a => a.status === 1).length;
|
|
||||||
else if (tag === 'OFFLINE') count = agents?.filter(a => a.status !== 1).length;
|
|
||||||
else count = agents?.filter(a => a.tags?.map(t => t.toUpperCase()).includes(tag)).length;
|
|
||||||
|
|
||||||
if (count === 0 && tag !== 'ALL') return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={tag}
|
|
||||||
onClick={() => setSelectedTag(tagKey)}
|
|
||||||
className={cn(
|
|
||||||
"px-4 py-1.5 rounded-full text-xs font-bold font-mono tracking-wider transition-all border cursor-pointer uppercase",
|
|
||||||
selectedTag === tagKey
|
|
||||||
? 'bg-gray-100 dark:bg-cyan-500 dark:text-white border-gray-600 dark:border-cyan-600 shadow-md'
|
|
||||||
: 'bg-transparent text-slate-600 dark:text-cyan-500 border-slate-200 dark:border-cyan-900/30 hover:bg-gray-100 hover:border-cyan-900/30 dark:hover:text-cyan-500 dark:hover:border-cyan-500'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tag} ({count})
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 服务器列表 */}
|
|
||||||
{displayAgents.length === 0 ? (
|
|
||||||
<ServerListEmpty
|
|
||||||
title={selectedTag ? '没有匹配的服务器' : '暂无在线服务器'}
|
|
||||||
description={selectedTag ? `标签 "${selectedTag}" 下暂无服务器` : '当前没有任何探针在线,请稍后再试。'}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* 桌面端表格布局 */}
|
|
||||||
<div
|
|
||||||
className="hidden md:block bg-white/80 dark:bg-[#0a0b10]/90 border border-slate-200 dark:border-cyan-900/50 rounded-xl overflow-hidden shadow-sm dark:shadow-2xl backdrop-blur-md">
|
|
||||||
<table className="w-full text-left border-collapse">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-slate-50 dark:bg-black/40 text-xs font-mono uppercase tracking-widest text-slate-400 dark:text-cyan-500 border-b border-slate-200 dark:border-cyan-900/50 font-bold">
|
|
||||||
<th className="p-5 font-bold w-[250px]">Identity</th>
|
|
||||||
<th className="p-5 font-bold">Telemetry</th>
|
|
||||||
<th className="p-5 font-bold w-[220px]">I/O Rate</th>
|
|
||||||
<th className="p-5 font-bold w-[240px]">Traffic</th>
|
|
||||||
<th className="p-5 font-bold w-[150px]">Network</th>
|
|
||||||
<th className="p-5 font-bold w-[200px]">Meta / Tags</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-200 dark:divide-cyan-900/50">
|
|
||||||
{displayAgents.map(server => {
|
|
||||||
const isOnline = server.status === 1;
|
|
||||||
const cpuUsage = server.metrics?.cpu?.usagePercent ?? 0;
|
|
||||||
const memoryUsage = server.metrics?.memory?.usagePercent ?? 0;
|
|
||||||
const memoryTotal = server.metrics?.memory?.total ?? 0;
|
|
||||||
const memoryUsed = server.metrics?.memory?.used ?? 0;
|
|
||||||
const diskUsage = calculateDiskUsage(server.metrics);
|
|
||||||
const diskTotal = server.metrics?.disk?.total ?? 0;
|
|
||||||
const diskUsed = server.metrics?.disk?.used ?? 0;
|
|
||||||
const {upload, download} = calculateNetworkSpeed(server.metrics);
|
|
||||||
const temperatures = getTemperatures(server.metrics);
|
|
||||||
const netConn = server.metrics?.networkConnection;
|
|
||||||
const traffic = server.trafficStats;
|
|
||||||
const trafficUsagePercent = traffic?.enabled && traffic.limit > 0
|
|
||||||
? Math.min(100, (traffic.used / traffic.limit) * 100)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
key={server.id}
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={() => handleNavigate(server.id)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
|
||||||
event.preventDefault();
|
|
||||||
handleNavigate(server.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="group hover:bg-gray-500/5 dark:hover:bg-cyan-500/5 transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
{/* Identity */}
|
|
||||||
<td className="p-4 align-top">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'font-bold text-slate-800 dark:text-cyan-100 font-mono text-sm transition-colors',
|
|
||||||
isExpired(server.expireTime) ? 'text-red-600 dark:text-red-400' : ''
|
|
||||||
)}>
|
|
||||||
{server.name}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 text-xs text-gray-600 dark:text-cyan-400 mt-1 font-mono uppercase">
|
|
||||||
<span>{server.os}</span>
|
|
||||||
<span className="w-px h-2 bg-gray-400 dark:bg-cyan-800"></span>
|
|
||||||
<span>{server.arch}</span>
|
|
||||||
</div>
|
|
||||||
{isOnline && server.metrics?.host && (
|
|
||||||
<div className="flex items-center gap-3 text-xs font-mono mt-1">
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-1 text-gray-500 dark:text-cyan-600">
|
|
||||||
<Clock className="w-3 h-3"/>
|
|
||||||
<span>{formatUptime(server.metrics.host.uptime)}</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-1 text-gray-500 dark:text-cyan-600">
|
|
||||||
<Activity className="w-3 h-3"/>
|
|
||||||
<span>{server.metrics.host.procs} 进程</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Resources */}
|
|
||||||
<td className="p-4 align-top">
|
|
||||||
{isOnline ? (
|
|
||||||
<div className="flex flex-col justify-center h-full gap-0.5">
|
|
||||||
<CompactResourceBar
|
|
||||||
value={cpuUsage}
|
|
||||||
label="CPU"
|
|
||||||
icon={Cpu}
|
|
||||||
subtext={server.metrics?.cpu ? `${server.metrics.cpu.modelName} (${server.metrics.cpu.physicalCores}核)` : undefined}
|
|
||||||
color="bg-blue-500"
|
|
||||||
/>
|
|
||||||
<CompactResourceBar
|
|
||||||
value={memoryUsage}
|
|
||||||
label="RAM"
|
|
||||||
icon={MemoryStick}
|
|
||||||
subtext={`${formatBytes(memoryUsed, 1)}/${formatBytes(memoryTotal, 1)}`}
|
|
||||||
color="bg-purple-500"
|
|
||||||
/>
|
|
||||||
<CompactResourceBar
|
|
||||||
value={diskUsage}
|
|
||||||
label="DSK"
|
|
||||||
icon={HardDrive}
|
|
||||||
subtext={`${formatBytes(diskUsed, 1)}/${formatBytes(diskTotal, 1)}`}
|
|
||||||
color="bg-emerald-500"
|
|
||||||
/>
|
|
||||||
{temperatures.length > 0 && (
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 mt-1 text-xs font-mono flex-wrap">
|
|
||||||
<Thermometer className="w-3 h-3 text-orange-400"/>
|
|
||||||
{temperatures.map((temp, index) => (
|
|
||||||
<span key={index} className="flex items-center gap-1">
|
|
||||||
<span
|
|
||||||
className="text-orange-400">{temp.temperature?.toFixed(1)}°C</span>
|
|
||||||
<span
|
|
||||||
className="text-gray-500 dark:text-cyan-500">{temp.type}</span>
|
|
||||||
{index < temperatures.length - 1 &&
|
|
||||||
<span className="text-cyan-900">|</span>}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="text-xs text-rose-500 font-mono flex items-center gap-2 py-4">
|
|
||||||
<AlertTriangle className="w-4 h-4"/>
|
|
||||||
<span>CONNECTION_LOST // RECONNECTING...</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Network */}
|
|
||||||
<td className="p-4 font-mono text-xs align-top">
|
|
||||||
<div className="flex flex-col gap-1.5 mb-1.5">
|
|
||||||
<span
|
|
||||||
className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400/80">
|
|
||||||
<ArrowDown className="w-3 h-3"/>
|
|
||||||
<span>{formatSpeed(download)}</span>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="flex items-center gap-2 text-blue-600 dark:text-blue-400/80">
|
|
||||||
<ArrowUp className="w-3 h-3"/>
|
|
||||||
<span>{formatSpeed(upload)}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Traffic */}
|
|
||||||
<td className="p-4 font-mono text-xs align-top">
|
|
||||||
{traffic?.enabled ? (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<div className="text-xs text-gray-500 dark:text-cyan-600 font-mono">
|
|
||||||
{traffic.type === 'recv' ? '进站' : traffic.type === 'send' ? '出站' : '全部'}流量
|
|
||||||
</div>
|
|
||||||
{traffic.limit > 0 ? (
|
|
||||||
<>
|
|
||||||
<div className="flex items-baseline justify-between">
|
|
||||||
<span className="text-xs text-gray-600 dark:text-cyan-500 font-mono">
|
|
||||||
{formatBytes(traffic.used, 1)} / {formatBytes(traffic.limit, 1)}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs font-bold text-gray-700 dark:text-cyan-400 font-mono">
|
|
||||||
{trafficUsagePercent.toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-1.5 bg-slate-200 dark:bg-cyan-900/50 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`h-full transition-all ${getTrafficProgressColor(trafficUsagePercent)}`}
|
|
||||||
style={{width: `${trafficUsagePercent}%`}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-cyan-600 font-mono">
|
|
||||||
重置日期: 每月 {traffic.resetDay} 号
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-gray-500 dark:text-cyan-600 font-mono">
|
|
||||||
已使用: {formatBytes(traffic.used, 1)}
|
|
||||||
<div className="text-xs text-gray-400 dark:text-cyan-700 mt-1">仅统计模式</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-gray-600 dark:text-cyan-500">-</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Connections */}
|
|
||||||
<td className="p-4 font-mono text-xs align-top">
|
|
||||||
{isOnline && netConn ? (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Network
|
|
||||||
className="w-3 h-3 text-emerald-600 dark:text-emerald-400"/>
|
|
||||||
<span
|
|
||||||
className="text-emerald-600 dark:text-emerald-400">{netConn.established || 0}</span>
|
|
||||||
<span
|
|
||||||
className="text-gray-600 dark:text-cyan-500">ESTABLISHED</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Network className="w-3 h-3 text-blue-600 dark:text-blue-400"/>
|
|
||||||
<span
|
|
||||||
className="text-blue-600 dark:text-blue-400">{netConn.listen || 0}</span>
|
|
||||||
<span className="text-gray-600 dark:text-cyan-500">LISTEN</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Network className="w-3 h-3 text-rose-600 dark:text-rose-400"/>
|
|
||||||
<span
|
|
||||||
className="text-rose-600 dark:text-rose-400">{netConn.closeWait || 0}</span>
|
|
||||||
<span
|
|
||||||
className="text-gray-600 dark:text-cyan-500">CLOSE_WAIT</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-gray-600 dark:text-cyan-500">-</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Meta */}
|
|
||||||
<td className="p-4 align-top">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex gap-1 flex-wrap">
|
|
||||||
{server.tags && server.tags.length > 0 && server.tags.map(tag => (
|
|
||||||
<span key={tag}
|
|
||||||
className="px-1.5 py-0.5 bg-gray-100 dark:bg-cyan-900/40 text-gray-700 dark:text-cyan-500 border border-gray-300 dark:border-cyan-700/50 text-xs font-mono rounded-sm">
|
|
||||||
#{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
`text-xs font-mono flex items-center gap-1 text-gray-600 dark:text-cyan-500`,
|
|
||||||
// 剩余时间小于 30 天时显示为红色
|
|
||||||
isExpired(server.expireTime) ? 'text-red-600 dark:text-red-400' : ''
|
|
||||||
)}>
|
|
||||||
|
|
||||||
{server.expireTime > 0 &&
|
|
||||||
<div className={'flex items-center gap-1'}>
|
|
||||||
<div>Expired: {new Date(server.expireTime).toLocaleDateString('zh-CN')}</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 移动端卡片布局 */}
|
|
||||||
<div className="md:hidden flex flex-col gap-2">
|
|
||||||
{displayAgents.map(server => (
|
|
||||||
<ServerCard
|
|
||||||
key={server.id}
|
|
||||||
server={server}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ServerList;
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
/* 主题前端使用的全部类型定义。 */
|
|
||||||
|
|
||||||
export type ColorMode = 'light' | 'dark' | 'system';
|
|
||||||
export type MetricsAggregation = 'avg' | 'max' | 'raw';
|
|
||||||
|
|
||||||
export interface PikaRuntimeConfig {
|
|
||||||
apiVersion: 'v1';
|
|
||||||
system: {
|
|
||||||
nameZh: string;
|
|
||||||
nameEn: string;
|
|
||||||
logo: string;
|
|
||||||
icpCode: string;
|
|
||||||
version: string;
|
|
||||||
defaultView: string;
|
|
||||||
defaultColorMode: ColorMode;
|
|
||||||
};
|
|
||||||
theme: {id: string; version: string};
|
|
||||||
features: Record<string, boolean>;
|
|
||||||
legacySystemConfig?: LegacySystemConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LegacySystemConfig {
|
|
||||||
SystemNameZh: string;
|
|
||||||
SystemNameEn: string;
|
|
||||||
ICPCode: string;
|
|
||||||
DefaultView: string;
|
|
||||||
Version: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ListAgentParams {tag?: string; page?: number; pageSize?: number; [key: string]: string | number | boolean | undefined}
|
|
||||||
export interface MetricsParams {
|
|
||||||
type?: 'cpu' | 'memory' | 'disk' | 'network' | 'network_connection' | 'disk_io' | 'gpu' | 'temperature' | 'monitor';
|
|
||||||
range?: string;
|
|
||||||
start?: number;
|
|
||||||
end?: number;
|
|
||||||
interface?: string;
|
|
||||||
aggregation?: MetricsAggregation;
|
|
||||||
[key: string]: string | number | boolean | undefined;
|
|
||||||
}
|
|
||||||
export interface HistoryParams {
|
|
||||||
range?: string;
|
|
||||||
start?: number;
|
|
||||||
end?: number;
|
|
||||||
aggregation?: MetricsAggregation;
|
|
||||||
[key: string]: string | number | boolean | undefined;
|
|
||||||
}
|
|
||||||
export interface TimeRangeOption {label: string; value: string}
|
|
||||||
|
|
||||||
export interface Agent {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
hostname: string;
|
|
||||||
ip: string;
|
|
||||||
ipv4?: string;
|
|
||||||
ipv6?: string;
|
|
||||||
os: string;
|
|
||||||
arch: string;
|
|
||||||
version: string;
|
|
||||||
tags?: string[];
|
|
||||||
expireTime?: number;
|
|
||||||
status: number;
|
|
||||||
visibility?: string;
|
|
||||||
weight?: number;
|
|
||||||
remark?: string;
|
|
||||||
lastSeenAt: string | number;
|
|
||||||
trafficStats?: {
|
|
||||||
enabled: boolean;
|
|
||||||
type: string;
|
|
||||||
limit: number;
|
|
||||||
used: number;
|
|
||||||
resetDay: number;
|
|
||||||
periodStart: number;
|
|
||||||
periodEnd: number;
|
|
||||||
daysUntilReset: number;
|
|
||||||
baselineRecv: number;
|
|
||||||
baselineSend: number;
|
|
||||||
alertSent80: boolean;
|
|
||||||
alertSent90: boolean;
|
|
||||||
alertSent100: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CPUMetric {
|
|
||||||
timestamp: number;
|
|
||||||
logicalCores: number;
|
|
||||||
physicalCores: number;
|
|
||||||
modelName: string;
|
|
||||||
usagePercent: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MemoryMetric {
|
|
||||||
timestamp: number;
|
|
||||||
total: number;
|
|
||||||
used: number;
|
|
||||||
free: number;
|
|
||||||
available: number;
|
|
||||||
usagePercent: number;
|
|
||||||
swapTotal: number;
|
|
||||||
swapUsed: number;
|
|
||||||
swapFree: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DiskSummary {
|
|
||||||
usagePercent: number;
|
|
||||||
totalDisks: number;
|
|
||||||
total: number;
|
|
||||||
used: number;
|
|
||||||
free: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DiskIOSummary {
|
|
||||||
totalReadBytesRate: number;
|
|
||||||
totalWriteBytesRate: number;
|
|
||||||
totalDevices: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NetworkSummary {
|
|
||||||
totalBytesSentRate: number;
|
|
||||||
totalBytesRecvRate: number;
|
|
||||||
totalBytesSentTotal: number;
|
|
||||||
totalBytesRecvTotal: number;
|
|
||||||
totalInterfaces: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NetworkInterfaceMetric {
|
|
||||||
interface: string;
|
|
||||||
macAddress?: string;
|
|
||||||
addrs?: string[];
|
|
||||||
bytesSentRate: number;
|
|
||||||
bytesRecvRate: number;
|
|
||||||
bytesSentTotal: number;
|
|
||||||
bytesRecvTotal: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NetworkConnectionMetric {
|
|
||||||
established: number;
|
|
||||||
synSent: number;
|
|
||||||
synRecv: number;
|
|
||||||
finWait1: number;
|
|
||||||
finWait2: number;
|
|
||||||
timeWait: number;
|
|
||||||
close: number;
|
|
||||||
closeWait: number;
|
|
||||||
lastAck: number;
|
|
||||||
listen: number;
|
|
||||||
closing: number;
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HostInfo {
|
|
||||||
uptime: number;
|
|
||||||
bootTime: number;
|
|
||||||
procs: number;
|
|
||||||
load1: number;
|
|
||||||
load5: number;
|
|
||||||
load15: number;
|
|
||||||
platform: string;
|
|
||||||
platformVersion: string;
|
|
||||||
kernelVersion: string;
|
|
||||||
kernelArch: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GPUMetric {
|
|
||||||
index: number;
|
|
||||||
name: string;
|
|
||||||
utilization: number;
|
|
||||||
memoryTotal: number;
|
|
||||||
memoryUsed: number;
|
|
||||||
memoryFree: number;
|
|
||||||
temperature: number;
|
|
||||||
powerUsage: number;
|
|
||||||
fanSpeed: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TemperatureMetric {
|
|
||||||
sensorKey: string;
|
|
||||||
type: string;
|
|
||||||
temperature: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MonitorData {
|
|
||||||
agentId: string;
|
|
||||||
agentName: string;
|
|
||||||
monitorId: string;
|
|
||||||
monitorName?: string;
|
|
||||||
type: string;
|
|
||||||
target?: string;
|
|
||||||
status: string;
|
|
||||||
statusCode?: number;
|
|
||||||
responseTime: number;
|
|
||||||
error?: string;
|
|
||||||
checkedAt: number;
|
|
||||||
message?: string;
|
|
||||||
contentMatch?: boolean;
|
|
||||||
certExpiryTime?: number;
|
|
||||||
certDaysLeft?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LatestMetrics {
|
|
||||||
timestamp?: number;
|
|
||||||
cpu?: CPUMetric;
|
|
||||||
memory?: MemoryMetric;
|
|
||||||
disk?: DiskSummary;
|
|
||||||
diskIO?: DiskIOSummary;
|
|
||||||
network?: NetworkSummary;
|
|
||||||
networkInterfaces?: NetworkInterfaceMetric[];
|
|
||||||
networkConnection?: NetworkConnectionMetric;
|
|
||||||
host?: HostInfo;
|
|
||||||
gpu?: GPUMetric[];
|
|
||||||
temperature?: TemperatureMetric[];
|
|
||||||
monitors?: MonitorData[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PublicMonitor {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: 'http' | 'https' | 'tcp' | 'icmp' | 'ping';
|
|
||||||
target: string;
|
|
||||||
showTargetPublic: boolean;
|
|
||||||
description?: string;
|
|
||||||
enabled: boolean;
|
|
||||||
interval: number;
|
|
||||||
agentIds: string[];
|
|
||||||
agentCount: number;
|
|
||||||
status: string;
|
|
||||||
responseTime: number;
|
|
||||||
responseTimeMin: number;
|
|
||||||
responseTimeMax: number;
|
|
||||||
certExpiryTime: number;
|
|
||||||
certDaysLeft: number;
|
|
||||||
agentStats: {up: number; down: number; unknown: number};
|
|
||||||
lastCheckTime: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AgentMonitorStat {
|
|
||||||
agentId: string;
|
|
||||||
agentName: string;
|
|
||||||
monitorId: string;
|
|
||||||
type: string;
|
|
||||||
target: string;
|
|
||||||
status: string;
|
|
||||||
statusCode: number;
|
|
||||||
responseTime: number;
|
|
||||||
checkedAt: number;
|
|
||||||
message: string;
|
|
||||||
certExpiryTime: number;
|
|
||||||
certDaysLeft: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MetricPoint {timestamp: number; value: number}
|
|
||||||
export interface MetricSeries {name: string; labels?: Record<string, string>; data: MetricPoint[]}
|
|
||||||
export interface MetricsResponse {agentId: string; type: string; range: string; series: MetricSeries[]}
|
|
||||||
export interface TagsResponse {tags: string[]}
|
|
||||||
export interface NetworkInterfacesResponse {interfaces: string[]}
|
|
||||||
export interface CurrentUser {userId: string; username: string}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
|
||||||
"target": "ES2022",
|
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"types": ["vite/client"],
|
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"strict": false,
|
|
||||||
|
|
||||||
"paths": {
|
|
||||||
"@/*": [
|
|
||||||
"./src/*"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"allowSyntheticDefaultImports": true
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"files": [],
|
|
||||||
"references": [
|
|
||||||
{
|
|
||||||
"path": "./tsconfig.app.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "./tsconfig.node.json"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"compilerOptions": {
|
|
||||||
"paths": {
|
|
||||||
"@/*": [
|
|
||||||
"./src/*"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
|
||||||
"target": "ES2023",
|
|
||||||
"lib": ["ES2023"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"types": ["node"],
|
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
|
||||||
},
|
|
||||||
"include": ["vite.config.ts"]
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import {defineConfig} from 'vite';
|
|
||||||
import react from '@vitejs/plugin-react';
|
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
export default defineConfig(({command}) => ({
|
|
||||||
base: command === 'serve' ? '/' : '/theme-assets/',
|
|
||||||
plugins: [react(), tailwindcss()],
|
|
||||||
publicDir: path.resolve(__dirname, 'public'),
|
|
||||||
resolve: {alias: {'@': path.resolve(__dirname, './src')}},
|
|
||||||
build: {
|
|
||||||
outDir: path.resolve(__dirname, 'dist'),
|
|
||||||
emptyOutDir: true,
|
|
||||||
assetsDir: 'assets',
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
port: 5173,
|
|
||||||
proxy: {'/api/': {target: 'http://localhost:8080/', changeOrigin: true}},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
-1384
File diff suppressed because it is too large.
Load diff
+16
-5
@@ -2,24 +2,35 @@
|
|||||||
|
|
||||||
`web/` 是官方管理后台前端(React/Vite),发布到 `/admin/assets/*`。
|
`web/` 是官方管理后台前端(React/Vite),发布到 `/admin/assets/*`。
|
||||||
|
|
||||||
官方默认公开主题在仓库根目录的 `portal/`,是一个独立的项目,可以搬迁到单独的仓库。
|
官方默认公开主题是独立项目 [`pika-monitor/pika-default-theme`](https://github.com/pika-monitor/pika-default-theme)。本地开发时,`Makefile` 默认从同级目录 `../pika-default-theme` 构建它。
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd web && npm ci && npm run dev # 管理后台,http://localhost:5174/admin/
|
cd web && npm ci && npm run dev # 管理后台,http://localhost:5174/admin/
|
||||||
cd portal && npm ci && npm run dev # 默认主题,http://localhost:5173/
|
cd ../pika-default-theme && npm ci && npm run dev # 默认主题,http://localhost:5173/
|
||||||
```
|
```
|
||||||
|
|
||||||
两者都把 `/api/*` 代理到 `http://localhost:8080`,可以同时启动。
|
两者都把 `/api/*` 代理到 `http://localhost:8080`,可以同时启动。
|
||||||
|
|
||||||
## 构建
|
## 构建
|
||||||
|
|
||||||
`make build-web` 分别构建 `web/`(管理后台 → `web/dist/admin/`)和 `portal/`(默认主题 → `portal/dist/`)。后端直接从这两个目录读取,无需组装脚本。
|
`make build-web` 构建 `web/` 和独立的默认主题,将两者放到独立的发布目录:
|
||||||
|
|
||||||
|
- 管理后台:`web/dist/`;
|
||||||
|
- 默认主题:`themes/default/`。
|
||||||
|
|
||||||
|
如果主题源码不在默认位置,可以显式指定:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make DEFAULT_THEME_DIR=/path/to/pika-default-theme build-web
|
||||||
|
```
|
||||||
|
|
||||||
|
GitHub Actions 使用 `.github/default-theme.ref` 中的完整 Git Commit SHA 锁定默认主题,保证同一 Pika 版本的发布构建可复现。
|
||||||
|
|
||||||
## 运行路径
|
## 运行路径
|
||||||
|
|
||||||
- 公开主题:`/`、`/servers/*`、`/monitors/*`;
|
- 公开主题:`/`、`/servers/*`、`/monitors/*`;
|
||||||
- 管理后台:`/admin/*`,包括 `/admin/login` 和 OAuth/OIDC 回调;
|
- 管理后台:`/admin/*`,包括 `/admin/login` 和 OAuth/OIDC 回调;
|
||||||
- 管理后台资源:`/admin/assets/*`;
|
- 管理后台资源:`/admin/assets/*`;
|
||||||
- 活动主题资源:`/theme-assets/*`。
|
- 活动主题资源:`/t/*`。
|
||||||
+1
-1
@@ -11,7 +11,7 @@ export default defineConfig({
|
|||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
}},
|
}},
|
||||||
build: {
|
build: {
|
||||||
outDir: path.resolve(__dirname, 'dist/admin'),
|
outDir: path.resolve(__dirname, 'dist'),
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
assetsDir: 'assets',
|
assetsDir: 'assets',
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user