refactor: split default theme into standalone repository

This commit is contained in:
dushixiang committed 2026-08-12 20:00:48 +08:00
1 parent d7cdbd7a91
commit acf3b6b57f
43 files changed
+116 -11037

No files matched your search

+3 -1
View File
@@ -14,8 +14,10 @@
*~
# 构建产物
# bin/agents/ 和 web/dist/ 不能忽略,运行时从本地文件加载
# bin/agents/、web/dist/ 和 themes/ 不能忽略,运行时从本地文件加载
web/node_modules/
# CI 仅使用该 checkout 构建 themes/default,不应进入镜像上下文
pika-default-theme
# 数据目录
data/
+1
View File
@@ -0,0 +1 @@
04ff9915a411debd851bfadba9e28f7294ec910c
+12 -1
View File
@@ -23,6 +23,17 @@ jobs:
- name: 检出代码
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
- name: 安装 make 和 upx
run: |
@@ -44,7 +55,7 @@ jobs:
# 构建所有组件(前端、agents、服务端)
- name: 构建所有组件
run: |
make build-web
make DEFAULT_THEME_DIR=pika-default-theme build-web
make build-agents
make build-servers
+12 -1
View File
@@ -31,6 +31,17 @@ jobs:
- name: 检出代码
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
- name: 安装 make 和 upx
run: |
@@ -52,7 +63,7 @@ jobs:
# 构建所有组件(前端、agents、服务端)
- name: 构建所有组件
run: |
make build-release
make DEFAULT_THEME_DIR=pika-default-theme build-release
# 列出编译产物
- name: 列出编译产物
+2 -1
View File
@@ -42,4 +42,5 @@ agent.yaml
playground
web/node_modules
web/dist
deploy.sh
themes/default
deploy.sh
+1
View File
@@ -17,6 +17,7 @@ ARG TARGETARCH
COPY ./bin/pika-linux-${TARGETARCH} ./pika
COPY ./bin/agents ./bin/agents
COPY ./web/dist ./web/dist
COPY ./themes ./themes
# 暴露端口
EXPOSE 8080
+23 -7
View File
@@ -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)'
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:
npm ci --prefix web
npm run build --prefix web
npm ci --prefix portal
npm run build --prefix portal
$(MAKE) build-default-theme
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:
@@ -67,14 +82,15 @@ build-agents:
# 构建所有(release版本)
build-release:
make build-web
make build-agents
make build-servers
$(MAKE) build-web
$(MAKE) build-agents
$(MAKE) build-servers
# 清理编译产物
clean:
rm -rf bin/*
rm -rf web/dist portal/dist
rm -rf web/dist
rm -rf themes/default
# 运行测试
test:
+5 -3
View File
@@ -4,9 +4,11 @@ import (
"context"
"log"
"net/http"
"path/filepath"
"time"
"github.com/google/uuid"
"github.com/pika-monitor/pika/internal/assets"
"github.com/pika-monitor/pika/internal/config"
"github.com/pika-monitor/pika/internal/handler"
"github.com/pika-monitor/pika/internal/migrate"
@@ -275,9 +277,9 @@ func setupApi(app *orz.App, components *AppComponents) error {
// GitHub 认证路由(如果启用)
publicApi.POST("/auth/github/callback", components.AccountHandler.GitHubLogin)
// 官方管理 SPA 与活动公开主题使用互不重叠的资源前缀。
e.GET("/admin/assets/*", components.WebHandler.ServeAdminAsset)
e.GET("/theme-assets/*", components.WebHandler.ServeThemeAsset)
// 管理前端从 web/dist 由 Echo 直接加载;活动公开主题由主题服务选择独立目录。
e.Static("/admin/assets/", filepath.Join(assets.WebDir(), "assets"), handler.ImmutableStaticHeaders)
e.GET("/t/*", components.WebHandler.ServeThemeAsset)
e.GET("/*", components.WebHandler.ServeSPA)
return nil
+9 -2
View File
@@ -8,8 +8,8 @@ import (
const (
defaultWebDir = "web/dist"
defaultThemeDir = "themes/default"
defaultAgentDir = "bin/agents"
defaultLogoPath = "portal/public/logo.png"
)
func WebDir() string {
@@ -19,6 +19,13 @@ func WebDir() string {
return defaultWebDir
}
func DefaultThemeDir() string {
if dir := os.Getenv("PIKA_DEFAULT_THEME_DIR"); dir != "" {
return dir
}
return defaultThemeDir
}
func AgentDir() string {
if dir := os.Getenv("PIKA_AGENT_DIR"); dir != "" {
return dir
@@ -33,7 +40,7 @@ func AgentPath(filename string) string {
func DefaultLogoBase64() string {
for _, path := range []string{
os.Getenv("PIKA_DEFAULT_LOGO_PATH"),
defaultLogoPath,
filepath.Join(DefaultThemeDir(), "dist", "logo.png"),
filepath.Join(WebDir(), "logo.png"),
} {
if path == "" {
+15 -9
View File
@@ -31,16 +31,12 @@ func (h *WebHandler) PublicConfig(c *echo.Context) error {
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 {
_, dist, err := h.themeService.ActiveDistDir(c.Request().Context())
if err != nil {
return err
}
return serveStaticFile(c, dist, c.Param("*"), true)
return serveStaticFile(c, dist, c.Param("*"), false)
}
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 {
path := filepath.Join(assets.WebDir(), "admin", "index.html")
data, err := os.ReadFile(path)
if err != nil {
root := assets.WebDir()
path := filepath.Join(root, "index.html")
if stat, err := os.Stat(path); err != nil || !stat.Mode().IsRegular() {
return echo.NewHTTPError(http.StatusInternalServerError, "官方管理前端缺失")
}
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 {
+7 -4
View File
@@ -91,20 +91,20 @@ func TestResolveStaticPathAndHeaders(t *testing.T) {
func TestEchoWildcardRoutesKeepAdminAndAPISeparated(t *testing.T) {
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)
}
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)
}
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.Setenv("PIKA_WEB_DIR", webRoot)
e := echo.New()
handler := &WebHandler{}
e.GET("/admin/assets/*", handler.ServeAdminAsset)
e.Static("/admin/assets/", filepath.Join(webRoot, "assets"), ImmutableStaticHeaders)
e.GET("/*", handler.ServeSPA)
assetRecorder := httptest.NewRecorder()
@@ -112,6 +112,9 @@ func TestEchoWildcardRoutesKeepAdminAndAPISeparated(t *testing.T) {
if assetRecorder.Code != http.StatusOK || assetRecorder.Body.String() != "admin" {
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()
e.ServeHTTP(adminRecorder, httptest.NewRequest(http.MethodGet, "/admin/agents", nil))
+2 -2
View File
@@ -83,7 +83,7 @@ var (
themeIDPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$`)
reservedThemeIDs = map[string]struct{}{
"default": {}, "admin": {}, "official": {}, "system": {},
"api": {}, "assets": {}, "theme-assets": {},
"api": {}, "assets": {}, "t": {}, "theme-assets": {},
}
requiredThemeCaps = []string{"server-list", "server-detail", "monitor-list", "monitor-detail"}
titleElement = regexp.MustCompile(`(?is)<title(?:\s[^>]*)?>.*?</title>`)
@@ -123,7 +123,7 @@ func NewThemeService(logger *zap.Logger, propertyService *PropertyService, cfg *
logger: logger,
propertyService: propertyService,
themeDir: themeDir,
defaultThemeDir: "portal",
defaultThemeDir: assets.DefaultThemeDir(),
}
if err := os.MkdirAll(filepath.Join(themeDir, ".staging"), 0755); err != nil {
return nil, fmt.Errorf("创建主题目录失败: %w", err)
+3 -5
View File
@@ -168,6 +168,7 @@ func TestThemeInstallActivateDeleteLifecycle(t *testing.T) {
webRoot := t.TempDir()
writeOfficialWebBuild(t, webRoot)
t.Setenv("PIKA_WEB_DIR", webRoot)
t.Setenv("PIKA_DEFAULT_THEME_DIR", filepath.Join(webRoot, "default-theme"))
themeDir := filepath.Join(t.TempDir(), "themes")
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) {
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)
}
if err := os.MkdirAll(filepath.Join(webRoot, "admin"), 0o755); err != nil {
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)
}
writeThemeRoot(t, filepath.Join(webRoot, "default-theme"), validTestManifest(DefaultThemeID))
+4 -4
View File
@@ -18,13 +18,13 @@ import (
"golang.org/x/text/unicode/norm"
)
// validateOfficialWebBuild 启动时验证官方 web/dist 完整性。
// validateOfficialWebBuild 启动时验证官方管理前端和默认主题的发布产物完整性。
func validateOfficialWebBuild(webDir, defaultThemeDir string) error {
if version.GetVersion() == "dev" && strings.TrimSpace(os.Getenv("PIKA_WEB_DIR")) == "" {
return nil
}
requiredFiles := []string{
filepath.Join(webDir, "admin", "index.html"),
filepath.Join(webDir, "index.html"),
filepath.Join(defaultThemeDir, "pika-theme.json"),
filepath.Join(defaultThemeDir, "dist", "index.html"),
}
@@ -34,9 +34,9 @@ func validateOfficialWebBuild(webDir, defaultThemeDir string) error {
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() {
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"))
if err != nil {
-6
View File
@@ -1,6 +0,0 @@
node_modules
dist
dist-ssr
*.local
.DS_Store
yarn.lock
-14
View File
@@ -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},
},
},
];
-15
View File
@@ -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>
-3618
View File
File diff suppressed because it is too large. Load diff
-36
View File
@@ -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"
}
}
-14
View File
@@ -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

-28
View File
@@ -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>
);
}
-76
View File
@@ -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';
};
-768
View File
@@ -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>
);
};
-49
View File
@@ -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'},
};
-55
View File
@@ -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;
};
-128
View File
@@ -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;
}
-169
View File
@@ -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;
}
}
-71
View File
@@ -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);
};
-51
View File
@@ -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);
-795
View File
@@ -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;
-431
View File
@@ -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;
-39
View File
@@ -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
-848
View File
@@ -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;
-255
View File
@@ -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}
-28
View File
@@ -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"]
}
-18
View File
@@ -1,18 +0,0 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
],
"compilerOptions": {
"paths": {
"@/*": [
"./src/*"
]
}
}
}
-18
View File
@@ -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"]
}
-20
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+16 -5
View File
@@ -2,24 +2,35 @@
`web/` 是官方管理后台前端(React/Vite),发布到 `/admin/assets/*`。
官方默认公开主题在仓库根目录的 `portal/`,是一个独立的项目,可以搬迁到单独的仓库。
官方默认公开主题是独立项目 [`pika-monitor/pika-default-theme`](https://github.com/pika-monitor/pika-default-theme)。本地开发时,`Makefile` 默认从同级目录 `../pika-default-theme` 构建它。
## 开发
```bash
cd web && npm ci && npm run dev # 管理后台,http://localhost:5174/admin/
cd portal && npm ci && npm run dev # 默认主题,http://localhost:5173/
cd web && npm ci && npm run dev # 管理后台,http://localhost:5174/admin/
cd ../pika-default-theme && npm ci && npm run dev # 默认主题,http://localhost:5173/
```
两者都把 `/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/*`;
- 管理后台:`/admin/*`,包括 `/admin/login` 和 OAuth/OIDC 回调;
- 管理后台资源:`/admin/assets/*`;
- 活动主题资源:`/theme-assets/*`。
- 活动主题资源:`/t/*`。
+1 -1
View File
@@ -11,7 +11,7 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'),
}},
build: {
outDir: path.resolve(__dirname, 'dist/admin'),
outDir: path.resolve(__dirname, 'dist'),
emptyOutDir: true,
assetsDir: 'assets',
},