release: nexus btpanel session fix and app-v2
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#070b16" />
|
||||
<title>Nexus App V2</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3087
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "nexus-app-v2",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0 --port 3002",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview --host 0.0.0.0 --port 3003",
|
||||
"scan:sensitive": "node ./scripts/scan-app-v2-sensitive.mjs",
|
||||
"build:safe": "npm run build && npm run scan:sensitive"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.91.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"vite": "^8.0.0",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"typescript": "~5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const appRoot = path.resolve(__dirname, '..')
|
||||
const defaultTarget = path.resolve(appRoot, '..', 'web', 'app-v2')
|
||||
const targetDir = path.resolve(process.argv[2] || defaultTarget)
|
||||
|
||||
const TEXT_FILE_EXTENSIONS = new Set(['.html', '.js', '.css', '.json', '.txt', '.svg', '.map'])
|
||||
const MAX_CONTEXT = 110
|
||||
|
||||
const checks = [
|
||||
{
|
||||
id: 'forbidden-global-api-key-copy',
|
||||
severity: 'error',
|
||||
pattern: /全局\s*API\s*Key|Global\s+API\s+Key/gi,
|
||||
description: 'UI/build output must not expose the global API Key copy.',
|
||||
},
|
||||
{
|
||||
id: 'private-key-block',
|
||||
severity: 'error',
|
||||
pattern: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]{0,4096}?-----END [A-Z0-9 ]*PRIVATE KEY-----/g,
|
||||
description: 'Private key material must never be present in app-v2 output.',
|
||||
},
|
||||
{
|
||||
id: 'certificate-request-private-material',
|
||||
severity: 'error',
|
||||
pattern: /-----BEGIN CERTIFICATE REQUEST-----[\s\S]{0,4096}?-----END CERTIFICATE REQUEST-----/g,
|
||||
description: 'CSR/PEM material should not be bundled into app-v2 output.',
|
||||
},
|
||||
{
|
||||
id: 'jwt-like-token',
|
||||
severity: 'error',
|
||||
pattern: /eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
|
||||
description: 'JWT-like token detected in app-v2 output.',
|
||||
},
|
||||
{
|
||||
id: 'openai-or-generic-sk-token',
|
||||
severity: 'error',
|
||||
pattern: /\bsk-[A-Za-z0-9_-]{20,}\b/g,
|
||||
description: 'High-confidence secret key token detected.',
|
||||
},
|
||||
{
|
||||
id: 'github-token',
|
||||
severity: 'error',
|
||||
pattern: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}\b|\bgithub_pat_[A-Za-z0-9_]{30,}\b/g,
|
||||
description: 'GitHub token detected.',
|
||||
},
|
||||
{
|
||||
id: 'aws-access-key',
|
||||
severity: 'error',
|
||||
pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g,
|
||||
description: 'AWS access key id detected.',
|
||||
},
|
||||
{
|
||||
id: 'bearer-token',
|
||||
severity: 'error',
|
||||
pattern: /\bBearer\s+(?!\[REDACTED\]|REDACTED|<redacted>)[A-Za-z0-9._~+\/-]{24,}/gi,
|
||||
description: 'Bearer token detected.',
|
||||
},
|
||||
{
|
||||
id: 'tmp-login-token-value',
|
||||
severity: 'error',
|
||||
pattern: /\btmp_(?:login|token)=(?!\[REDACTED\]|REDACTED|<redacted>)[^&\s"'<>]{8,}/gi,
|
||||
description: 'Baota temporary login token value detected.',
|
||||
},
|
||||
{
|
||||
id: 'url-basic-auth-credentials',
|
||||
severity: 'error',
|
||||
pattern: /https?:\/\/[^\s\/:'"<>@]{1,80}:[^\s\/"'<>@]{1,120}@/gi,
|
||||
description: 'URL contains embedded username/password credentials.',
|
||||
},
|
||||
{
|
||||
id: 'inline-secret-assignment',
|
||||
severity: 'error',
|
||||
pattern: /\b(?:password|passwd|pwd|api[_-]?key|access[_-]?key|secret|private[_-]?key|bt[_-]?key)\b["'\s:=]{1,8}(?!\[REDACTED\]|REDACTED|<redacted>|隐藏|脱敏|null|false|true|undefined)[A-Za-z0-9_./+=:@-]{16,}/gi,
|
||||
description: 'High-confidence inline secret assignment detected.',
|
||||
},
|
||||
]
|
||||
|
||||
function walk(dir) {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||
const files = []
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) files.push(...walk(full))
|
||||
else if (entry.isFile() && TEXT_FILE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(full)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
function contextOf(text, index, length) {
|
||||
const start = Math.max(0, index - MAX_CONTEXT)
|
||||
const end = Math.min(text.length, index + length + MAX_CONTEXT)
|
||||
return text
|
||||
.slice(start, end)
|
||||
.replace(/[\r\n\t]+/g, ' ')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
}
|
||||
|
||||
function redactMatch(match) {
|
||||
if (match.length <= 18) return match
|
||||
return match.slice(0, 8) + '...[' + match.length + ' chars]...' + match.slice(-6)
|
||||
}
|
||||
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
console.error('[app-v2-sensitive-scan] target directory does not exist: ' + targetDir)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const files = walk(targetDir)
|
||||
const findings = []
|
||||
for (const file of files) {
|
||||
const rel = path.relative(targetDir, file).replace(/\\/g, '/')
|
||||
const text = fs.readFileSync(file, 'utf8')
|
||||
for (const check of checks) {
|
||||
check.pattern.lastIndex = 0
|
||||
for (const match of text.matchAll(check.pattern)) {
|
||||
findings.push({
|
||||
file: rel,
|
||||
check: check.id,
|
||||
severity: check.severity,
|
||||
description: check.description,
|
||||
offset: match.index ?? 0,
|
||||
match: redactMatch(match[0]),
|
||||
context: contextOf(text, match.index ?? 0, match[0].length),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (findings.length > 0) {
|
||||
console.error('[app-v2-sensitive-scan] FAILED: ' + findings.length + ' finding(s) in ' + targetDir)
|
||||
for (const finding of findings) {
|
||||
console.error('- [' + finding.severity + '] ' + finding.check + ' ' + finding.file + '@' + finding.offset)
|
||||
console.error(' ' + finding.description)
|
||||
console.error(' match: ' + finding.match)
|
||||
console.error(' context: ' + finding.context)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('[app-v2-sensitive-scan] OK: scanned ' + files.length + ' file(s), no high-confidence secrets found in ' + targetDir)
|
||||
@@ -0,0 +1,11 @@
|
||||
import { lazy, Suspense } from 'react'
|
||||
|
||||
const DashboardPage = lazy(() => import('~features/dashboard/DashboardPage'))
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
return (
|
||||
<Suspense fallback={<div className="min-h-screen bg-nexus-bg text-slate-100" />}>
|
||||
<DashboardPage />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface BadgeProps {
|
||||
children: ReactNode
|
||||
tone?: 'green' | 'blue' | 'amber' | 'red' | 'slate' | 'purple'
|
||||
dot?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const toneClass = {
|
||||
green: 'bg-emerald-400/15 text-emerald-200',
|
||||
blue: 'bg-sky-400/15 text-sky-200',
|
||||
amber: 'bg-amber-400/15 text-amber-200',
|
||||
red: 'bg-red-400/15 text-red-200',
|
||||
slate: 'bg-slate-400/10 text-slate-200',
|
||||
purple: 'bg-violet-400/15 text-violet-200',
|
||||
}
|
||||
|
||||
const dotClass = {
|
||||
green: 'bg-emerald-400',
|
||||
blue: 'bg-sky-400',
|
||||
amber: 'bg-amber-400',
|
||||
red: 'bg-red-400',
|
||||
slate: 'bg-slate-400',
|
||||
purple: 'bg-violet-400',
|
||||
}
|
||||
|
||||
export function Badge({ children, tone = 'slate', dot = false, className }: BadgeProps): JSX.Element {
|
||||
return (
|
||||
<span className={cn('inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs font-bold', toneClass[tone], className)}>
|
||||
{dot ? <span className={cn('h-1.5 w-1.5 rounded-full', dotClass[tone])} /> : null}
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { HTMLAttributes, ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface PanelProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function Panel({ className, children, ...props }: PanelProps): JSX.Element {
|
||||
return <section className={cn('glass-panel rounded-[1.65rem]', className)} {...props}>{children}</section>
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import { Component, useCallback, useState } from 'react'
|
||||
import type { ErrorInfo, ReactNode } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { Activity, AlertTriangle, ArrowUpRight, Gauge, Radio, ShieldCheck, Sparkles } from 'lucide-react'
|
||||
import { Badge } from '~components/ui/Badge'
|
||||
import { Panel } from '~components/ui/Panel'
|
||||
import { ApiError } from '@/lib/api'
|
||||
import { useAppStore } from '@/stores/appStore'
|
||||
import { AppShell } from './components/AppShell'
|
||||
import { AssetInventoryPage } from './components/AssetInventoryPage'
|
||||
import { BtPanelPage } from './components/BtPanelPage'
|
||||
import { BtResourceOverviewPage } from './components/BtResourceOverviewPage'
|
||||
import { BtDomainSslPage } from './components/BtDomainSslPage'
|
||||
import { AuditTimeline } from './components/AuditTimeline'
|
||||
import { AuditLogPage } from './components/AuditLogPage'
|
||||
import { CommandPalette } from './components/CommandPalette'
|
||||
import { ExecutionRecordsPage } from './components/ExecutionRecordsPage'
|
||||
import { GlobalSearchPage } from './components/GlobalSearchPage'
|
||||
import { HealthScore } from './components/HealthScore'
|
||||
import { SecurityInspectionPage } from './components/SecurityInspectionPage'
|
||||
import { SettingsOverviewPage } from './components/SettingsOverviewPage'
|
||||
import { ScriptLibraryPage } from './components/ScriptLibraryPage'
|
||||
import { ServerDrawer } from './components/ServerDrawer'
|
||||
import { ServerTable } from './components/ServerTable'
|
||||
import { StatCard } from './components/StatCard'
|
||||
import { TaskCenter } from './components/TaskCenter'
|
||||
import { TaskCenterPage } from './components/TaskCenterPage'
|
||||
import { TerminalFilesPage } from './components/TerminalFilesPage'
|
||||
import { TrendPanel } from './components/TrendPanel'
|
||||
import { WatchMonitoringPage } from './components/WatchMonitoringPage'
|
||||
import { OperationLogsPage } from './components/OperationLogsPage'
|
||||
import { TtlMatrix } from './components/TtlMatrix'
|
||||
import { createBtLoginUrl } from './api/dashboardApi'
|
||||
import { useDashboardData } from './hooks/useDashboardData'
|
||||
import type { ServerAsset } from './data/dashboardData'
|
||||
|
||||
const releaseCards = [
|
||||
{ label: 'V2 当前策略', value: '双后台并行', icon: Sparkles, tone: 'text-cyan-300', desc: '/app 保留,/app-v2 分阶段替换。' },
|
||||
{ label: '登录稳定性', value: 'TTL 兜底修复', icon: ShieldCheck, tone: 'text-emerald-300', desc: '一键登录前检测 hardcoded_3600。' },
|
||||
{ label: '响应优化', value: 'Query 缓存', icon: Gauge, tone: 'text-blue-300', desc: '减少重复请求,首屏分块加载。' },
|
||||
{ label: '实时能力', value: 'Agent 心跳', icon: Radio, tone: 'text-violet-300', desc: '后续接入 WebSocket 事件流。' },
|
||||
]
|
||||
|
||||
interface DashboardErrorBoundaryProps {
|
||||
children: ReactNode
|
||||
fallback: (error: Error) => ReactNode
|
||||
}
|
||||
|
||||
interface DashboardErrorBoundaryState {
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
class DashboardErrorBoundary extends Component<DashboardErrorBoundaryProps, DashboardErrorBoundaryState> {
|
||||
state: DashboardErrorBoundaryState = { error: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error): DashboardErrorBoundaryState {
|
||||
return { error }
|
||||
}
|
||||
|
||||
componentDidCatch(_error: Error, _errorInfo: ErrorInfo): void {
|
||||
// TanStack Query suspense throws into this boundary; UI fallback handles auth/API errors.
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.error) return this.props.fallback(this.state.error)
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
export default function DashboardPage(): JSX.Element {
|
||||
const sessionStatus = useAppStore((state) => state.sessionStatus)
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
{sessionStatus === 'checking' ? <CheckingState /> : null}
|
||||
{sessionStatus === 'guest' ? <LoginRequired reason="当前 /app-v2 需要复用 Nexus 后台登录态。" /> : null}
|
||||
{sessionStatus === 'authenticated' ? (
|
||||
<DashboardErrorBoundary fallback={(error) => <DashboardErrorState error={error} />}>
|
||||
<DashboardContent />
|
||||
</DashboardErrorBoundary>
|
||||
) : null}
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
function DashboardContent(): JSX.Element {
|
||||
const { servers, stats, total, mode, warning } = useDashboardData()
|
||||
const activeModule = useAppStore((state) => state.activeModule)
|
||||
const [loginLoadingId, setLoginLoadingId] = useState<string | null>(null)
|
||||
const [loginMessage, setLoginMessage] = useState<string | null>(null)
|
||||
|
||||
const { mutate: requestBtLogin } = useMutation({
|
||||
mutationFn: async (server: ServerAsset) => {
|
||||
if (!server.sourceId) throw new Error('该服务器缺少后端 ID,无法创建宝塔一键登录链接。')
|
||||
return createBtLoginUrl(server.sourceId)
|
||||
},
|
||||
onSuccess: (data, server) => {
|
||||
window.open(data.url, '_blank', 'noopener,noreferrer')
|
||||
setLoginMessage(server.name + ' 已生成一键登录链接;后端已执行 TTL 兜底检测。')
|
||||
},
|
||||
onError: (error) => {
|
||||
setLoginMessage(error instanceof Error ? error.message : '一键登录失败,请稍后重试。')
|
||||
},
|
||||
onSettled: () => setLoginLoadingId(null),
|
||||
})
|
||||
|
||||
const handleBtLogin = useCallback((server: ServerAsset): void => {
|
||||
setLoginMessage(null)
|
||||
setLoginLoadingId(server.id)
|
||||
requestBtLogin(server)
|
||||
}, [requestBtLogin])
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommandPalette servers={servers} onBtLogin={handleBtLogin} />
|
||||
<div className="space-y-6 p-5 lg:p-10">
|
||||
{warning ? <Notice tone="amber" title="已切换演示数据" message={warning} /> : null}
|
||||
{loginMessage ? <Notice tone={loginMessage.includes('失败') || loginMessage.includes('无法') ? 'red' : 'green'} title="一键登录" message={loginMessage} /> : null}
|
||||
</div>
|
||||
|
||||
{activeModule === 'assets' ? (
|
||||
<AssetInventoryPage servers={servers} total={total} mode={mode} loginLoadingId={loginLoadingId} onBtLogin={handleBtLogin} />
|
||||
) : activeModule === 'btpanel' ? (
|
||||
<BtPanelPage servers={servers} total={total} mode={mode} loginLoadingId={loginLoadingId} onBtLogin={handleBtLogin} />
|
||||
) : activeModule === 'btresources' ? (
|
||||
<BtResourceOverviewPage servers={servers} total={total} mode={mode} />
|
||||
) : activeModule === 'btdomainssl' ? (
|
||||
<BtDomainSslPage servers={servers} total={total} mode={mode} />
|
||||
) : activeModule === 'search' ? (
|
||||
<GlobalSearchPage servers={servers} total={total} mode={mode} />
|
||||
) : activeModule === 'tasks' ? (
|
||||
<TaskCenterPage servers={servers} total={total} mode={mode} />
|
||||
) : activeModule === 'workspace' ? (
|
||||
<TerminalFilesPage servers={servers} total={total} mode={mode} />
|
||||
) : activeModule === 'monitoring' ? (
|
||||
<WatchMonitoringPage servers={servers} total={total} mode={mode} />
|
||||
) : activeModule === 'operations' ? (
|
||||
<OperationLogsPage servers={servers} total={total} mode={mode} />
|
||||
) : activeModule === 'scripts' ? (
|
||||
<ScriptLibraryPage mode={mode} />
|
||||
) : activeModule === 'executions' ? (
|
||||
<ExecutionRecordsPage mode={mode} />
|
||||
) : activeModule === 'audit' ? (
|
||||
<AuditLogPage mode={mode} />
|
||||
) : activeModule === 'security' ? (
|
||||
<SecurityInspectionPage servers={servers} total={total} mode={mode} />
|
||||
) : activeModule === 'settings' ? (
|
||||
<SettingsOverviewPage servers={servers} total={total} mode={mode} />
|
||||
) : (
|
||||
<div id="dashboard" className="space-y-6 px-5 pb-5 lg:px-10 lg:pb-10">
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||
{stats.map((stat) => (
|
||||
<StatCard key={stat.title} {...stat} />
|
||||
))}
|
||||
<HealthScore servers={servers} />
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-[1.1fr_.75fr_.8fr]">
|
||||
<TrendPanel mode={mode} />
|
||||
<TtlMatrix servers={servers} />
|
||||
<TaskCenter servers={servers} total={total} mode={mode} />
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{releaseCards.map((card) => (
|
||||
<Panel key={card.label} className="group overflow-hidden p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-2xl border border-white/10 bg-white/[0.04]">
|
||||
<card.icon className={'h-5 w-5 ' + card.tone} />
|
||||
</div>
|
||||
<ArrowUpRight className="h-4 w-4 text-slate-600 transition group-hover:text-cyan-300" />
|
||||
</div>
|
||||
<div className="mt-5 text-xs font-black uppercase tracking-[0.14em] text-slate-500">{card.label}</div>
|
||||
<div className="mt-2 text-lg font-black text-white">{card.value}</div>
|
||||
<p className="mt-2 text-sm font-medium leading-6 text-slate-500">{card.desc}</p>
|
||||
</Panel>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_28rem]">
|
||||
<ServerTable servers={servers} total={total} mode={mode} loginLoadingId={loginLoadingId} onBtLogin={handleBtLogin} />
|
||||
<ServerDrawer servers={servers} onBtLogin={handleBtLogin} />
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_28rem]">
|
||||
<AuditTimeline />
|
||||
<Panel className="relative overflow-hidden p-6">
|
||||
<div className="absolute -right-10 -top-10 h-36 w-36 rounded-full bg-cyan-400/10 blur-3xl" />
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-black tracking-tight text-white">迁移看板</h2>
|
||||
<p className="mt-1 text-sm font-medium text-slate-500">功能不减少:未迁移入口全部保留旧后台回退。</p>
|
||||
</div>
|
||||
<Badge tone="green" dot>安全</Badge>
|
||||
</div>
|
||||
<div className="mt-6 space-y-4">
|
||||
<MigrationRow label="总览 Dashboard" value="V2 首屏已完成" progress="100%" tone="bg-emerald-400" />
|
||||
<MigrationRow label="服务器资产" value="表格 / 详情已接真实 API" progress="80%" tone="bg-cyan-400" />
|
||||
<MigrationRow label="宝塔面板 / 一键登录" value="V2 页面已接入,配置编辑回旧后台" progress="85%" tone="bg-blue-400" />
|
||||
<MigrationRow label="任务中心" value="只读运行态已迁移,执行/重试回旧后台" progress="60%" tone="bg-violet-400" />
|
||||
<MigrationRow label="审计日志" value="审计 / 告警只读查询已迁移" progress="70%" tone="bg-sky-400" />
|
||||
<MigrationRow label="安全巡检" value="风险总览已迁移,修复动作回旧后台" progress="65%" tone="bg-amber-400" />
|
||||
<MigrationRow label="系统设置" value="只读概览已迁移,敏感写操作回旧后台" progress="55%" tone="bg-slate-400" />
|
||||
<MigrationRow label="终端 / 文件" value="安全入口已迁移,真实执行/写文件回旧后台" progress="45%" tone="bg-purple-400" />
|
||||
</div>
|
||||
<a href="/app/" className="focus-ring mt-7 inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-black text-cyan-200 hover:bg-cyan-400/10">
|
||||
<Activity className="h-4 w-4" /> 打开旧后台完整功能
|
||||
</a>
|
||||
</Panel>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface NoticeProps {
|
||||
tone: 'green' | 'amber' | 'red'
|
||||
title: string
|
||||
message: string
|
||||
}
|
||||
|
||||
function Notice({ tone, title, message }: NoticeProps): JSX.Element {
|
||||
const toneClass = tone === 'green'
|
||||
? 'border-emerald-400/20 bg-emerald-400/10 text-emerald-100'
|
||||
: tone === 'red'
|
||||
? 'border-red-400/20 bg-red-400/10 text-red-100'
|
||||
: 'border-amber-400/20 bg-amber-400/10 text-amber-100'
|
||||
return (
|
||||
<div className={'rounded-3xl border px-5 py-4 text-sm font-semibold ' + toneClass}>
|
||||
<span className="font-black">{title}:</span>{message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CheckingState(): JSX.Element {
|
||||
return (
|
||||
<div className="p-5 lg:p-10">
|
||||
<Panel className="p-8">
|
||||
<div className="h-5 w-40 animate-pulse rounded-full bg-slate-700" />
|
||||
<div className="mt-4 h-4 w-96 max-w-full animate-pulse rounded-full bg-slate-800" />
|
||||
<div className="mt-8 grid gap-4 md:grid-cols-4">
|
||||
{Array.from({ length: 4 }, (_, index) => <div key={index} className="h-28 animate-pulse rounded-3xl bg-slate-800/70" />)}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoginRequired({ reason }: { reason: string }): JSX.Element {
|
||||
return (
|
||||
<div className="p-5 lg:p-10">
|
||||
<Panel className="relative overflow-hidden p-8">
|
||||
<div className="absolute -right-10 -top-10 h-40 w-40 rounded-full bg-cyan-400/10 blur-3xl" />
|
||||
<div className="flex max-w-3xl flex-col gap-4">
|
||||
<Badge tone="amber" dot className="w-fit px-4 py-2">需要登录</Badge>
|
||||
<h2 className="text-3xl font-black tracking-tight text-white">请先登录 Nexus 后台</h2>
|
||||
<p className="text-sm font-semibold leading-7 text-slate-400">{reason} 登录后再进入 /app-v2,可直接读取真实服务器、宝塔状态,并复用一键登录前 TTL 兜底修复。</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<a href="/app/login" className="focus-ring rounded-2xl bg-cyan-400 px-5 py-3 text-sm font-black text-slate-950">去登录</a>
|
||||
<a href="/app/" className="focus-ring rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-black text-slate-200">打开旧后台</a>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DashboardErrorState({ error }: { error: Error }): JSX.Element {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
return <LoginRequired reason="当前登录态已过期或刷新失败,请回旧后台重新登录。" />
|
||||
}
|
||||
return (
|
||||
<div className="p-5 lg:p-10">
|
||||
<Panel className="p-8">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="grid h-12 w-12 place-items-center rounded-2xl bg-red-400/15 text-red-200"><AlertTriangle className="h-5 w-5" /></div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-black text-white">App V2 数据加载异常</h2>
|
||||
<p className="mt-2 text-sm font-semibold leading-7 text-slate-400">{error.message || '未知错误'}。旧后台仍可正常使用,未迁移功能不会丢失。</p>
|
||||
<a href="/app/" className="focus-ring mt-5 inline-flex rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-black text-cyan-200">打开旧后台</a>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MigrationRowProps {
|
||||
label: string
|
||||
value: string
|
||||
progress: string
|
||||
tone: string
|
||||
}
|
||||
|
||||
function MigrationRow({ label, value, progress, tone }: MigrationRowProps): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between text-sm">
|
||||
<span className="font-black text-slate-200">{label}</span>
|
||||
<span className="font-bold text-slate-500">{value}</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-slate-800">
|
||||
<div className={tone + ' h-full rounded-full'} style={{ width: progress }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,138 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { Activity, Bell, BookOpen, Database, FileClock, Globe2, LayoutDashboard, Search, Server, Settings, ShieldCheck, TerminalSquare, Radio, ListChecks, Wrench, Zap } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { Badge } from '~components/ui/Badge'
|
||||
import { useAppStore } from '@/stores/appStore'
|
||||
import { restoreSession } from '@/lib/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AppModule } from '@/stores/appStore'
|
||||
|
||||
interface AppShellProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
module?: AppModule
|
||||
legacyPath?: string
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ label: '总览 Dashboard', icon: LayoutDashboard, module: 'dashboard' },
|
||||
{ label: '全局搜索', icon: Search, module: 'search' },
|
||||
{ label: '服务器资产', icon: Server, module: 'assets' },
|
||||
{ label: '宝塔面板', icon: ShieldCheck, module: 'btpanel' },
|
||||
{ label: '宝塔资源', icon: Database, module: 'btresources' },
|
||||
{ label: '域名 / SSL', icon: Globe2, module: 'btdomainssl' },
|
||||
{ label: '任务中心', icon: Activity, module: 'tasks' },
|
||||
{ label: '终端 / 文件', icon: TerminalSquare, module: 'workspace' },
|
||||
{ label: '实时监控', icon: Radio, module: 'monitoring' },
|
||||
{ label: '命令日志', icon: ListChecks, module: 'operations' },
|
||||
{ label: '脚本库', icon: BookOpen, module: 'scripts' },
|
||||
{ label: '执行记录', icon: FileClock, module: 'executions' },
|
||||
{ label: '安全巡检', icon: Wrench, module: 'security' },
|
||||
{ label: '审计日志', icon: FileClock, module: 'audit' },
|
||||
{ label: '系统设置', icon: Settings, module: 'settings' },
|
||||
]
|
||||
|
||||
export function AppShell({ children }: AppShellProps): JSX.Element {
|
||||
const setCommandOpen = useAppStore((state) => state.setCommandOpen)
|
||||
const activeModule = useAppStore((state) => state.activeModule)
|
||||
const setActiveModule = useAppStore((state) => state.setActiveModule)
|
||||
const admin = useAppStore((state) => state.admin)
|
||||
const sessionStatus = useAppStore((state) => state.sessionStatus)
|
||||
const setAdmin = useAppStore((state) => state.setAdmin)
|
||||
const setSessionStatus = useAppStore((state) => state.setSessionStatus)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
restoreSession()
|
||||
.then((profile) => {
|
||||
if (cancelled) return
|
||||
setAdmin(profile)
|
||||
setSessionStatus(profile ? 'authenticated' : 'guest')
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setAdmin(null)
|
||||
setSessionStatus('guest')
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [setAdmin, setSessionStatus])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault()
|
||||
setCommandOpen(true)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [setCommandOpen])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-nexus-radial text-slate-100">
|
||||
<aside className="fixed inset-y-0 left-0 z-30 hidden w-72 border-r border-white/10 bg-slate-950/70 p-7 backdrop-blur-2xl lg:block">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-2xl bg-gradient-to-br from-cyan-300 via-blue-500 to-violet-500 shadow-cyan">
|
||||
<Zap className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-black tracking-[-0.06em] text-white">Nexus</div>
|
||||
<div className="text-xs font-black uppercase tracking-[0.18em] text-cyan-300">App V2</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="mt-10 space-y-2">
|
||||
{navItems.map((item) => {
|
||||
const isActive = item.module === activeModule
|
||||
const className = cn('focus-ring flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-left text-sm font-black transition', isActive ? 'border border-blue-300/25 bg-blue-400/15 text-white' : 'text-slate-400 hover:bg-white/[0.04] hover:text-white')
|
||||
if (item.module) {
|
||||
const module = item.module
|
||||
return (
|
||||
<button key={item.label} type="button" onClick={() => setActiveModule(module)} className={className} title="App V2 已迁移页面">
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
<span className="ml-auto rounded-full bg-cyan-400/10 px-2 py-0.5 text-[10px] text-cyan-200">V2</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<a key={item.label} href={item.legacyPath} className={className} title="迁移中:先回到旧后台对应功能">
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
<span className="ml-auto rounded-full bg-slate-800 px-2 py-0.5 text-[10px] text-slate-400">/app</span>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
<div className="absolute bottom-7 left-7 right-7 rounded-3xl border border-white/10 bg-slate-900/70 p-5">
|
||||
<div className="text-sm font-black text-white">双后台并行</div>
|
||||
<p className="mt-2 text-xs font-medium leading-5 text-slate-400">旧 /app 不动,新 /app-v2 逐页替换;未迁移功能先跳回旧后台。</p>
|
||||
<div className="mt-4 flex gap-2"><Badge tone="green" dot>旧后台在线</Badge><Badge tone="blue" dot>V2 灰度</Badge></div>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="lg:pl-72">
|
||||
<header className="sticky top-0 z-20 border-b border-white/10 bg-slate-950/30 px-5 py-4 backdrop-blur-2xl lg:px-10">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-black tracking-[-0.05em] text-white lg:text-4xl">Nexus 运维安全控制台</h1>
|
||||
<p className="mt-1 text-sm font-semibold text-slate-400">暗色运维 + 安全中心混合风格 · 功能不减少,组件增强体验</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => setCommandOpen(true)} className="focus-ring hidden min-w-80 items-center gap-3 rounded-2xl border border-white/10 bg-slate-900/70 px-4 py-3 text-left text-sm font-bold text-slate-400 shadow-glow md:flex">
|
||||
<Search className="h-4 w-4" /> Ctrl K 搜索服务器 / IP / 任务 / 风险
|
||||
</button>
|
||||
<button className="focus-ring relative grid h-12 w-12 place-items-center rounded-2xl border border-white/10 bg-slate-900/70 text-amber-300"><Bell className="h-5 w-5" /><span className="absolute -right-1 -top-1 grid h-5 w-5 place-items-center rounded-full bg-amber-400 text-xs font-black text-slate-950">9</span></button>
|
||||
<a href="/app/" className="focus-ring rounded-2xl border border-cyan-300/25 bg-cyan-400/15 px-4 py-3 text-sm font-black text-cyan-200">旧后台</a>
|
||||
<Badge tone={admin ? 'green' : sessionStatus === 'checking' ? 'blue' : 'slate'} dot className="hidden px-4 py-3 md:inline-flex">{admin?.username ?? (sessionStatus === 'checking' ? '检测登录' : '未登录')}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { ExternalLink, Filter, Search, ShieldCheck, SlidersHorizontal } from 'lucide-react'
|
||||
import { Badge } from '~components/ui/Badge'
|
||||
import { Panel } from '~components/ui/Panel'
|
||||
import { ServerDrawer } from './ServerDrawer'
|
||||
import { ServerTable } from './ServerTable'
|
||||
import type { ServerAsset } from '../data/dashboardData'
|
||||
|
||||
interface AssetInventoryPageProps {
|
||||
servers: ServerAsset[]
|
||||
total: number
|
||||
mode: 'live' | 'fallback'
|
||||
loginLoadingId?: string | null
|
||||
onBtLogin: (server: ServerAsset) => void
|
||||
}
|
||||
|
||||
type StatusFilter = 'all' | ServerAsset['panelStatus']
|
||||
type RiskFilter = 'all' | ServerAsset['risk']
|
||||
|
||||
const statusFilters: Array<{ label: string; value: StatusFilter }> = [
|
||||
{ label: '全部状态', value: 'all' },
|
||||
{ label: '在线', value: 'online' },
|
||||
{ label: '已修复', value: 'patched' },
|
||||
{ label: '待配置', value: 'test' },
|
||||
{ label: '离线', value: 'offline' },
|
||||
]
|
||||
|
||||
const riskFilters: Array<{ label: string; value: RiskFilter }> = [
|
||||
{ label: '全部风险', value: 'all' },
|
||||
{ label: '健康', value: 'healthy' },
|
||||
{ label: '已修复', value: 'patched' },
|
||||
{ label: '建议', value: 'warning' },
|
||||
{ label: '关注', value: 'critical' },
|
||||
]
|
||||
|
||||
function countBy<T extends string>(items: ServerAsset[], getter: (server: ServerAsset) => T, value: T): number {
|
||||
return items.filter((server) => getter(server) === value).length
|
||||
}
|
||||
|
||||
export function AssetInventoryPage({ servers, total, mode, loginLoadingId, onBtLogin }: AssetInventoryPageProps): JSX.Element {
|
||||
const [query, setQuery] = useState('')
|
||||
const [status, setStatus] = useState<StatusFilter>('all')
|
||||
const [risk, setRisk] = useState<RiskFilter>('all')
|
||||
|
||||
const filteredServers = useMemo(() => {
|
||||
const keyword = query.trim().toLowerCase()
|
||||
return servers.filter((server) => {
|
||||
const matchesKeyword = !keyword || [server.name, server.ip, server.region, server.ttl, server.btBootstrapState ?? '']
|
||||
.some((value) => value.toLowerCase().includes(keyword))
|
||||
const matchesStatus = status === 'all' || server.panelStatus === status
|
||||
const matchesRisk = risk === 'all' || server.risk === risk
|
||||
return matchesKeyword && matchesStatus && matchesRisk
|
||||
})
|
||||
}, [query, risk, servers, status])
|
||||
|
||||
const onlineCount = useMemo(() => servers.filter((server) => server.online !== false).length, [servers])
|
||||
const patchedCount = useMemo(() => countBy(servers, (server) => server.risk, 'patched'), [servers])
|
||||
const warningCount = useMemo(() => servers.filter((server) => server.risk === 'warning' || server.risk === 'critical').length, [servers])
|
||||
const ttlOkCount = useMemo(() => servers.filter((server) => server.btSessionCleanupOk || server.btSessionCleanupPatched || server.ttl === '86400s').length, [servers])
|
||||
|
||||
return (
|
||||
<div id="assets" className="space-y-6 p-5 lg:p-10">
|
||||
<Panel className="relative overflow-hidden p-6">
|
||||
<div className="absolute -right-16 -top-16 h-48 w-48 rounded-full bg-cyan-400/10 blur-3xl" />
|
||||
<div className="flex flex-wrap items-start justify-between gap-5">
|
||||
<div className="max-w-3xl">
|
||||
<Badge tone="blue" dot className="px-4 py-2">V2 已迁移页面</Badge>
|
||||
<h2 className="mt-4 text-3xl font-black tracking-[-0.05em] text-white lg:text-5xl">服务器资产中心</h2>
|
||||
<p className="mt-3 text-sm font-semibold leading-7 text-slate-400">
|
||||
先把高频资产检索、宝塔状态、一键登录入口迁到 /app-v2;旧后台完整功能仍可从右上角或侧栏回退。
|
||||
</p>
|
||||
</div>
|
||||
<a href="/app/" className="focus-ring inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-black text-cyan-200 hover:bg-cyan-400/10">
|
||||
<ExternalLink className="h-4 w-4" /> 旧后台完整资产管理
|
||||
</a>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<AssetMetric label="在线资产" value={String(onlineCount)} sub={'当前接口返回 ' + String(servers.length) + ' / 总计 ' + String(total)} tone="text-emerald-300" />
|
||||
<AssetMetric label="TTL 兜底通过" value={String(ttlOkCount)} sub="一键登录前仍由后端再次检测" tone="text-cyan-300" />
|
||||
<AssetMetric label="已修复宝塔" value={String(patchedCount)} sub="hardcoded_3600 修复状态" tone="text-blue-300" />
|
||||
<AssetMetric label="需关注" value={String(warningCount)} sub="SSL / TTL / Agent 建议项" tone="text-amber-300" />
|
||||
</section>
|
||||
|
||||
<Panel className="p-5">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-2xl bg-cyan-400/10 text-cyan-200"><Filter className="h-5 w-5" /></div>
|
||||
<div>
|
||||
<div className="text-base font-black text-white">资产筛选</div>
|
||||
<div className="text-xs font-bold text-slate-500">搜索和筛选都在前端缓存内完成,减少重复请求。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(16rem,1fr)_12rem_12rem] xl:min-w-[46rem]">
|
||||
<label className="focus-within:ring-2 focus-within:ring-cyan-300/70 flex items-center gap-2 rounded-2xl border border-white/10 bg-slate-950/50 px-4 py-3 text-sm font-bold text-slate-400">
|
||||
<Search className="h-4 w-4" />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索名称 / IP / 区域 / TTL" className="w-full bg-transparent text-slate-100 outline-none placeholder:text-slate-600" />
|
||||
</label>
|
||||
<select value={status} onChange={(event) => setStatus(event.target.value as StatusFilter)} className="focus-ring rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm font-black text-slate-200">
|
||||
{statusFilters.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
||||
</select>
|
||||
<select value={risk} onChange={(event) => setRisk(event.target.value as RiskFilter)} className="focus-ring rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm font-black text-slate-200">
|
||||
{riskFilters.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_28rem]">
|
||||
<ServerTable servers={filteredServers} total={mode === 'live' ? total : filteredServers.length} mode={mode} loginLoadingId={loginLoadingId} onBtLogin={onBtLogin} />
|
||||
<div className="space-y-6">
|
||||
<Panel className="p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-2xl bg-emerald-400/10 text-emerald-200"><ShieldCheck className="h-5 w-5" /></div>
|
||||
<div>
|
||||
<h3 className="text-lg font-black text-white">登录稳定性说明</h3>
|
||||
<p className="mt-2 text-sm font-semibold leading-7 text-slate-400">
|
||||
V2 的一键登录只调用后端 /api/btpanel/servers/:id/login-url,不在前端生成 token;后端登录前会做 hardcoded_3600 TTL 兜底检测/修复。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel className="p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-2xl bg-blue-400/10 text-blue-200"><SlidersHorizontal className="h-5 w-5" /></div>
|
||||
<div>
|
||||
<h3 className="text-lg font-black text-white">迁移边界</h3>
|
||||
<p className="mt-2 text-sm font-semibold leading-7 text-slate-400">
|
||||
当前页覆盖资产搜索、状态查看、宝塔一键登录;批量编辑、凭据管理、深度审计暂时继续回旧后台,避免功能缺口。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
<ServerDrawer servers={filteredServers.length ? filteredServers : servers} onBtLogin={onBtLogin} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface AssetMetricProps {
|
||||
label: string
|
||||
value: string
|
||||
sub: string
|
||||
tone: string
|
||||
}
|
||||
|
||||
function AssetMetric({ label, value, sub, tone }: AssetMetricProps): JSX.Element {
|
||||
return (
|
||||
<Panel className="p-5">
|
||||
<div className="text-xs font-black uppercase tracking-[0.14em] text-slate-500">{label}</div>
|
||||
<div className={'mt-3 text-4xl font-black tracking-[-0.05em] ' + tone}>{value}</div>
|
||||
<div className="mt-2 text-sm font-semibold leading-6 text-slate-500">{sub}</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { AlertTriangle, BellRing, CalendarClock, ExternalLink, FileClock, Filter, KeyRound, Search, Server, ShieldCheck, UserRound } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { Badge } from '~components/ui/Badge'
|
||||
import { Panel } from '~components/ui/Panel'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { redactSensitiveText } from '@/lib/redaction'
|
||||
import { fetchAuditLogData } from '../api/dashboardApi'
|
||||
import type { AlertHistoryEntry, AuditLogEntry } from '../api/dashboardApi'
|
||||
|
||||
type AuditFilter = 'all' | 'admin' | 'server' | 'btpanel' | 'security' | 'alert'
|
||||
type Severity = 'info' | 'success' | 'warning' | 'danger'
|
||||
|
||||
interface AuditLogPageProps {
|
||||
mode: 'live' | 'fallback'
|
||||
}
|
||||
|
||||
interface NormalizedAuditEvent {
|
||||
id: string
|
||||
source: 'audit' | 'alert'
|
||||
title: string
|
||||
actor: string
|
||||
target: string
|
||||
detail: string
|
||||
time: string
|
||||
ip: string
|
||||
category: AuditFilter
|
||||
severity: Severity
|
||||
icon: LucideIcon
|
||||
}
|
||||
|
||||
const filters: Array<{ label: string; value: AuditFilter }> = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '管理员', value: 'admin' },
|
||||
{ label: '服务器', value: 'server' },
|
||||
{ label: '宝塔', value: 'btpanel' },
|
||||
{ label: '安全', value: 'security' },
|
||||
{ label: '告警', value: 'alert' },
|
||||
]
|
||||
|
||||
function sanitizeText(value?: string | number | null): string {
|
||||
return redactSensitiveText(value, '-', 240)
|
||||
}
|
||||
|
||||
|
||||
function formatDateTime(value?: string | null): string {
|
||||
if (!value) return '未知时间'
|
||||
const timestamp = Date.parse(value)
|
||||
if (!Number.isFinite(timestamp)) return value
|
||||
return new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'medium' }).format(timestamp)
|
||||
}
|
||||
|
||||
function inferCategory(item: AuditLogEntry): AuditFilter {
|
||||
const haystack = [item.action, item.target_type, item.detail, item.target_name].filter(Boolean).join(' ').toLowerCase()
|
||||
if (haystack.includes('bt') || haystack.includes('宝塔') || haystack.includes('ttl') || haystack.includes('login')) return 'btpanel'
|
||||
if (haystack.includes('server') || haystack.includes('agent') || haystack.includes('服务器')) return 'server'
|
||||
if (haystack.includes('auth') || haystack.includes('key') || haystack.includes('allowlist') || haystack.includes('security')) return 'security'
|
||||
return 'admin'
|
||||
}
|
||||
|
||||
function inferSeverity(item: AuditLogEntry): Severity {
|
||||
const action = item.action.toLowerCase()
|
||||
const detail = String(item.detail || '').toLowerCase()
|
||||
if (action.includes('delete') || action.includes('remove') || detail.includes('failed') || detail.includes('失败')) return 'warning'
|
||||
if (action.includes('login') || action.includes('create') || action.includes('update') || detail.includes('成功')) return 'success'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function normalizeAudit(item: AuditLogEntry): NormalizedAuditEvent {
|
||||
const category = inferCategory(item)
|
||||
const targetName = item.target_name || item.target_id || item.target_type || '-'
|
||||
return {
|
||||
id: 'audit-' + String(item.id),
|
||||
source: 'audit',
|
||||
title: sanitizeText(item.action).replaceAll('_', ' '),
|
||||
actor: sanitizeText(item.admin_username),
|
||||
target: sanitizeText(targetName),
|
||||
detail: sanitizeText(item.detail || item.action),
|
||||
time: formatDateTime(item.created_at),
|
||||
ip: sanitizeText(item.ip_address),
|
||||
category,
|
||||
severity: inferSeverity(item),
|
||||
icon: category === 'btpanel' ? KeyRound : category === 'server' ? Server : category === 'security' ? ShieldCheck : UserRound,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAlert(item: AlertHistoryEntry): NormalizedAuditEvent {
|
||||
const severity: Severity = item.is_recovery ? 'success' : 'danger'
|
||||
return {
|
||||
id: 'alert-' + String(item.id),
|
||||
source: 'alert',
|
||||
title: item.is_recovery ? '告警恢复:' + sanitizeText(item.alert_type) : '活跃告警:' + sanitizeText(item.alert_type),
|
||||
actor: 'system',
|
||||
target: sanitizeText(item.server_name || item.server_id || '-'),
|
||||
detail: 'value=' + sanitizeText(item.value),
|
||||
time: formatDateTime(item.created_at),
|
||||
ip: '-',
|
||||
category: 'alert',
|
||||
severity,
|
||||
icon: item.is_recovery ? ShieldCheck : AlertTriangle,
|
||||
}
|
||||
}
|
||||
|
||||
function severityTone(severity: Severity): 'green' | 'amber' | 'red' | 'blue' | 'slate' {
|
||||
if (severity === 'success') return 'green'
|
||||
if (severity === 'warning') return 'amber'
|
||||
if (severity === 'danger') return 'red'
|
||||
if (severity === 'info') return 'blue'
|
||||
return 'slate'
|
||||
}
|
||||
|
||||
function severityLabel(severity: Severity): string {
|
||||
if (severity === 'success') return '成功'
|
||||
if (severity === 'warning') return '关注'
|
||||
if (severity === 'danger') return '告警'
|
||||
return '信息'
|
||||
}
|
||||
|
||||
export function AuditLogPage({ mode }: AuditLogPageProps): JSX.Element {
|
||||
const [query, setQuery] = useState('')
|
||||
const [filter, setFilter] = useState<AuditFilter>('all')
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: ['audit-log-v2'],
|
||||
queryFn: fetchAuditLogData,
|
||||
staleTime: 20_000,
|
||||
})
|
||||
|
||||
const events = useMemo(() => {
|
||||
const normalized = [
|
||||
...data.auditItems.map(normalizeAudit),
|
||||
...data.alertItems.map(normalizeAlert),
|
||||
]
|
||||
return normalized.slice(0, 120)
|
||||
}, [data.alertItems, data.auditItems])
|
||||
|
||||
const keyword = query.trim().toLowerCase()
|
||||
const filteredEvents = useMemo(() => events.filter((event) => {
|
||||
const matchesFilter = filter === 'all' || event.category === filter
|
||||
const matchesKeyword = !keyword || [event.title, event.actor, event.target, event.detail, event.ip, event.time]
|
||||
.some((value) => value.toLowerCase().includes(keyword))
|
||||
return matchesFilter && matchesKeyword
|
||||
}), [events, filter, keyword])
|
||||
|
||||
const adminCount = events.filter((event) => event.source === 'audit').length
|
||||
const alertCount = events.filter((event) => event.source === 'alert').length
|
||||
const riskyCount = events.filter((event) => event.severity === 'danger' || event.severity === 'warning').length
|
||||
const btCount = events.filter((event) => event.category === 'btpanel').length
|
||||
|
||||
return (
|
||||
<div id="audit" className="space-y-6 p-5 lg:p-10">
|
||||
<Panel className="relative overflow-hidden p-6">
|
||||
<div className="absolute -right-16 -top-20 h-56 w-56 rounded-full bg-sky-400/10 blur-3xl" />
|
||||
<div className="flex flex-wrap items-start justify-between gap-5">
|
||||
<div className="max-w-3xl">
|
||||
<Badge tone="blue" dot className="px-4 py-2">V2 审计日志</Badge>
|
||||
<h2 className="mt-4 text-3xl font-black tracking-[-0.05em] text-white lg:text-5xl">审计日志 / 告警时间线</h2>
|
||||
<p className="mt-3 text-sm font-semibold leading-7 text-slate-400">
|
||||
读取现有 /api/audit/ 和 /api/alert-history/,合并展示管理员操作、宝塔登录兜底、服务器风险和告警恢复。V2 只做只读查询,并在前端再次隐藏疑似密码、Cookie、Token、密钥类字段、私钥片段。
|
||||
</p>
|
||||
</div>
|
||||
<a href="/app/" className="focus-ring inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-black text-cyan-200 hover:bg-cyan-400/10">
|
||||
<ExternalLink className="h-4 w-4" /> 旧后台完整查询
|
||||
</a>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{data.warning ? (
|
||||
<div className="rounded-3xl border border-amber-400/20 bg-amber-400/10 px-5 py-4 text-sm font-semibold text-amber-100">
|
||||
<span className="font-black">审计数据提示:</span>{data.warning}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<AuditMetric icon={FileClock} label="审计记录" value={String(data.auditTotal || adminCount)} sub="管理员操作记录" tone="text-cyan-300" />
|
||||
<AuditMetric icon={BellRing} label="告警记录" value={String(data.alertTotal || alertCount)} sub="活跃 / 恢复历史" tone="text-amber-300" />
|
||||
<AuditMetric icon={AlertTriangle} label="需关注" value={String(riskyCount)} sub="删除、失败或活跃告警" tone={riskyCount ? 'text-red-300' : 'text-emerald-300'} />
|
||||
<AuditMetric icon={KeyRound} label="宝塔相关" value={String(btCount)} sub="登录、TTL、面板操作" tone="text-violet-300" />
|
||||
</section>
|
||||
|
||||
<Panel className="p-5">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-2xl bg-cyan-400/10 text-cyan-200"><Filter className="h-5 w-5" /></div>
|
||||
<div>
|
||||
<div className="text-lg font-black text-white">审计过滤器</div>
|
||||
<div className="text-sm font-semibold text-slate-500">当前页面数据:{data.mode === 'live' ? '实时 API' : mode === 'live' ? '审计 fallback' : '演示 fallback'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex min-w-0 flex-1 items-center gap-3 rounded-2xl border border-white/10 bg-slate-950/50 px-4 py-3 xl:max-w-md">
|
||||
<Search className="h-4 w-4 text-slate-500" />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索动作、管理员、目标、IP、详情" className="min-w-0 flex-1 bg-transparent text-sm font-semibold text-white outline-none placeholder:text-slate-600" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
{filters.map((item) => (
|
||||
<button key={item.value} type="button" onClick={() => setFilter(item.value)} className={cn('focus-ring rounded-2xl px-4 py-2 text-sm font-black transition', filter === item.value ? 'bg-cyan-400 text-slate-950' : 'border border-white/10 bg-white/[0.03] text-slate-400 hover:text-white')}>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel className="overflow-hidden p-0">
|
||||
<div className="grid grid-cols-[1fr_auto] gap-4 border-b border-white/10 px-5 py-4">
|
||||
<div>
|
||||
<div className="text-lg font-black text-white">合并时间线</div>
|
||||
<div className="text-sm font-semibold text-slate-500">展示 {String(filteredEvents.length)} 条,敏感字段已隐藏</div>
|
||||
</div>
|
||||
<Badge tone="slate" className="h-fit">只读</Badge>
|
||||
</div>
|
||||
<div className="divide-y divide-white/10">
|
||||
{filteredEvents.map((event) => (
|
||||
<AuditEventRow key={event.id} event={event} />
|
||||
))}
|
||||
{filteredEvents.length === 0 ? (
|
||||
<div className="px-5 py-12 text-center text-sm font-bold text-slate-500">没有匹配的审计记录。</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface AuditMetricProps {
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
value: string
|
||||
sub: string
|
||||
tone: string
|
||||
}
|
||||
|
||||
function AuditMetric({ icon: Icon, label, value, sub, tone }: AuditMetricProps): JSX.Element {
|
||||
return (
|
||||
<Panel className="group overflow-hidden p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-2xl border border-white/10 bg-white/[0.04]">
|
||||
<Icon className={'h-5 w-5 ' + tone} />
|
||||
</div>
|
||||
<CalendarClock className="h-4 w-4 text-slate-600 transition group-hover:text-cyan-300" />
|
||||
</div>
|
||||
<div className="mt-5 text-xs font-black uppercase tracking-[0.14em] text-slate-500">{label}</div>
|
||||
<div className="mt-2 text-3xl font-black text-white">{value}</div>
|
||||
<p className="mt-2 text-sm font-medium leading-6 text-slate-500">{sub}</p>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function AuditEventRow({ event }: { event: NormalizedAuditEvent }): JSX.Element {
|
||||
return (
|
||||
<div className="grid gap-4 px-5 py-5 transition hover:bg-white/[0.03] lg:grid-cols-[auto_minmax(0,1fr)_12rem] lg:items-center">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-2xl border border-white/10 bg-white/[0.04]">
|
||||
<event.icon className="h-5 w-5 text-cyan-200" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="font-black text-white">{event.title}</div>
|
||||
<Badge tone={severityTone(event.severity)} dot>{severityLabel(event.severity)}</Badge>
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold leading-6 text-slate-500">{event.detail}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2 text-sm font-semibold text-slate-400 md:grid-cols-3">
|
||||
<div><span className="text-slate-600">操作者:</span>{event.actor}</div>
|
||||
<div><span className="text-slate-600">目标:</span>{event.target}</div>
|
||||
<div><span className="text-slate-600">IP:</span>{event.ip}</div>
|
||||
</div>
|
||||
<div className="text-sm font-black text-slate-500 lg:text-right">{event.time}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Panel } from '~components/ui/Panel'
|
||||
import { auditEvents } from '../data/dashboardData'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function AuditTimeline(): JSX.Element {
|
||||
return (
|
||||
<Panel className="p-6">
|
||||
<h2 className="text-xl font-black tracking-tight text-white">审计时间线</h2>
|
||||
<div className="mt-6 space-y-4">
|
||||
{auditEvents.map((event, index) => (
|
||||
<div key={event.time + '-' + event.title} className="flex gap-4">
|
||||
<div className="w-12 shrink-0 text-xs font-bold text-slate-500">{event.time}</div>
|
||||
<div className="relative flex shrink-0 justify-center">
|
||||
<span className={cn('mt-1 h-2.5 w-2.5 rounded-full', event.color)} />
|
||||
{index < auditEvents.length - 1 ? <span className="absolute top-5 h-8 w-px bg-white/10" /> : null}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-slate-200">{event.title}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { ArrowUpRight, CalendarClock, Globe2, LockKeyhole, Network, Search, Server, ShieldAlert, ShieldCheck } from 'lucide-react'
|
||||
import { Badge } from '~components/ui/Badge'
|
||||
import { Panel } from '~components/ui/Panel'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { redactSensitiveText, summarizeCertificatePayload } from '@/lib/redaction'
|
||||
import { fetchBtDomainSslData } from '../api/dashboardApi'
|
||||
import type { BtDomainSslData, BtResourceRecord, BtSiteDomainGroup } from '../api/dashboardApi'
|
||||
import type { ServerAsset } from '../data/dashboardData'
|
||||
|
||||
interface BtDomainSslPageProps {
|
||||
servers: ServerAsset[]
|
||||
total: number
|
||||
mode: 'live' | 'fallback'
|
||||
}
|
||||
|
||||
type Tone = 'green' | 'blue' | 'amber' | 'red' | 'slate' | 'purple'
|
||||
|
||||
function field(record: BtResourceRecord | undefined, keys: string[], fallback = '-'): string {
|
||||
if (!record) return fallback
|
||||
for (const key of keys) {
|
||||
const value = record[key]
|
||||
if (value === null || value === undefined || value === '') continue
|
||||
if (typeof value === 'object') continue
|
||||
return redactSensitiveText(value, fallback, 180)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function recordId(record: BtResourceRecord | undefined): string {
|
||||
return field(record, ['id', 'site_id', 'pid'], '')
|
||||
}
|
||||
|
||||
function siteName(record: BtResourceRecord | undefined): string {
|
||||
return field(record, ['name', 'siteName', 'site_name', 'domain'], '未命名站点')
|
||||
}
|
||||
|
||||
function sitePath(record: BtResourceRecord | undefined): string {
|
||||
return field(record, ['path', 'root_path', 'rootPath'], '-')
|
||||
}
|
||||
|
||||
function siteStatus(record: BtResourceRecord | undefined): { label: string; tone: Tone } {
|
||||
const value = field(record, ['status', 'state'], '').toLowerCase()
|
||||
if (['1', 'running', 'enabled', 'true'].includes(value)) return { label: '运行中', tone: 'green' }
|
||||
if (['0', 'stopped', 'disabled', 'false'].includes(value)) return { label: '已停用', tone: 'amber' }
|
||||
return { label: value || '未知', tone: 'slate' }
|
||||
}
|
||||
|
||||
function domainName(record: BtResourceRecord): string {
|
||||
return field(record, ['name', 'domain', 'Domain', 'domain_name', 'host'], '未命名域名')
|
||||
}
|
||||
|
||||
function sslPayload(record: BtResourceRecord | undefined): unknown {
|
||||
return record?.ssl ?? record?.cert ?? record?.certificate ?? record?.ssl_info
|
||||
}
|
||||
|
||||
function sslSummary(record: BtResourceRecord | undefined): { label: string; detail: string; tone: Tone; expiresAt?: string } {
|
||||
const payload = sslPayload(record)
|
||||
if (payload === -1 || payload === 0 || payload === false || payload === null || payload === undefined || payload === '') {
|
||||
return { label: '未配置', detail: '未发现证书信息', tone: 'amber' }
|
||||
}
|
||||
if (typeof payload === 'object' && !Array.isArray(payload)) {
|
||||
const data = payload as Record<string, unknown>
|
||||
const subject = redactSensitiveText(data.subject || data.dns || data.issuer || data.brand || '证书主体', '证书主体', 120)
|
||||
const expiresAt = String(data.notAfter || data.endtime || data.end_time || data.valid_to || data.expire || '')
|
||||
if (expiresAt) {
|
||||
const timestamp = Date.parse(expiresAt)
|
||||
if (Number.isFinite(timestamp)) {
|
||||
const days = Math.ceil((timestamp - Date.now()) / 86_400_000)
|
||||
if (days < 0) return { label: '已过期', detail: subject + ' · ' + expiresAt, tone: 'red', expiresAt }
|
||||
if (days <= 14) return { label: '即将过期', detail: subject + ' · 剩余 ' + String(days) + ' 天', tone: 'amber', expiresAt }
|
||||
return { label: '有效', detail: subject + ' · 剩余 ' + String(days) + ' 天', tone: 'green', expiresAt }
|
||||
}
|
||||
return { label: '已配置', detail: subject + ' · ' + expiresAt, tone: 'blue', expiresAt }
|
||||
}
|
||||
return { label: '已配置', detail: subject, tone: 'blue' }
|
||||
}
|
||||
return { label: '已配置', detail: summarizeCertificatePayload(payload) || '证书内容已脱敏', tone: 'blue' }
|
||||
}
|
||||
|
||||
function matchSslRecord(site: BtResourceRecord, sslSites: BtResourceRecord[]): BtResourceRecord | undefined {
|
||||
const id = recordId(site)
|
||||
const name = siteName(site)
|
||||
return sslSites.find((item) => {
|
||||
const itemId = recordId(item)
|
||||
const itemName = siteName(item)
|
||||
return (id && itemId && id === itemId) || (name && itemName && name === itemName)
|
||||
})
|
||||
}
|
||||
|
||||
function expiringCount(data: BtDomainSslData): number {
|
||||
return data.sites.filter((site) => sslSummary(matchSslRecord(site, data.sslSites)).tone === 'amber').length
|
||||
}
|
||||
|
||||
function invalidCount(data: BtDomainSslData): number {
|
||||
return data.sites.filter((site) => sslSummary(matchSslRecord(site, data.sslSites)).tone === 'red').length
|
||||
}
|
||||
|
||||
function domainTotal(groups: BtSiteDomainGroup[]): number {
|
||||
return groups.reduce((sum, item) => sum + item.domains.length, 0)
|
||||
}
|
||||
|
||||
export function BtDomainSslPage({ servers, total, mode }: BtDomainSslPageProps): JSX.Element {
|
||||
const btServers = useMemo(() => servers.filter((server) => server.sourceId), [servers])
|
||||
const [selectedServerId, setSelectedServerId] = useState<number | undefined>(() => btServers[0]?.sourceId)
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedServerId && btServers[0]?.sourceId) setSelectedServerId(btServers[0].sourceId)
|
||||
}, [btServers, selectedServerId])
|
||||
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: ['bt-domain-ssl', selectedServerId],
|
||||
queryFn: () => fetchBtDomainSslData(selectedServerId),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const selectedServer = useMemo(() => btServers.find((server) => server.sourceId === selectedServerId), [btServers, selectedServerId])
|
||||
const normalizedQuery = query.trim().toLowerCase()
|
||||
const filteredSites = useMemo(() => {
|
||||
if (!normalizedQuery) return data.sites
|
||||
return data.sites.filter((site) => [siteName(site), sitePath(site), field(site, ['ps', 'remark', 'note'], '')]
|
||||
.some((value) => value.toLowerCase().includes(normalizedQuery)))
|
||||
}, [data.sites, normalizedQuery])
|
||||
|
||||
const handleServerChange = useCallback((event: React.ChangeEvent<HTMLSelectElement>): void => {
|
||||
const value = Number(event.target.value)
|
||||
setSelectedServerId(Number.isFinite(value) ? value : undefined)
|
||||
}, [])
|
||||
|
||||
const handleQueryChange = useCallback((event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
setQuery(event.target.value)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div id="bt-domain-ssl" className="space-y-6 px-5 pb-5 lg:px-10 lg:pb-10">
|
||||
<Panel className="relative overflow-hidden p-6 lg:p-8">
|
||||
<div className="absolute -right-24 -top-24 h-72 w-72 rounded-full bg-emerald-400/10 blur-3xl" />
|
||||
<div className="relative grid gap-6 xl:grid-cols-[minmax(0,1fr)_28rem]">
|
||||
<div>
|
||||
<Badge tone="green" dot className="px-4 py-2">宝塔域名 / SSL 只读</Badge>
|
||||
<h1 className="mt-5 text-3xl font-black tracking-tight text-white md:text-4xl">集中查看网站、绑定域名和证书状态</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm font-semibold leading-7 text-slate-400">
|
||||
V2 只调用宝塔 GET 接口读取站点、域名、SSL 站点和 PHP 版本;申请证书、上传证书、添加/删除域名、启停网站全部继续回旧后台,避免早期界面误操作生产宝塔。
|
||||
</p>
|
||||
<div className="mt-6 grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<label className="rounded-3xl border border-white/10 bg-slate-950/70 px-4 py-3">
|
||||
<span className="text-xs font-black uppercase tracking-[0.18em] text-slate-500">宝塔服务器</span>
|
||||
<select value={selectedServerId ?? ''} onChange={handleServerChange} className="mt-2 h-11 w-full bg-transparent text-sm font-black text-white outline-none">
|
||||
{btServers.map((server) => <option key={server.id} value={server.sourceId} className="bg-slate-950 text-white">{server.name} · {server.ip}</option>)}
|
||||
{btServers.length === 0 ? <option value="" className="bg-slate-950 text-white">暂无已配置宝塔服务器</option> : null}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 rounded-3xl border border-white/10 bg-slate-950/70 px-4 py-3">
|
||||
<Search className="h-5 w-5 text-cyan-300" />
|
||||
<input value={query} onChange={handleQueryChange} placeholder="搜索站点 / 路径 / 备注" className="h-12 flex-1 bg-transparent text-sm font-bold text-white outline-none placeholder:text-slate-600" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<MetricCard icon={Server} label="当前服务器" value={selectedServer?.name || '未选择'} tone="blue" />
|
||||
<MetricCard icon={Globe2} label="站点数量" value={String(data.sites.length)} tone="green" />
|
||||
<MetricCard icon={Network} label="已读取域名" value={String(domainTotal(data.domainGroups))} tone="purple" />
|
||||
<MetricCard icon={ShieldAlert} label="需关注证书" value={String(expiringCount(data) + invalidCount(data))} tone={invalidCount(data) > 0 ? 'red' : 'amber'} />
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{data.warning ? <Notice tone="amber" title="已切换演示数据" message={data.warning} /> : null}
|
||||
{data.mode === 'live' && data.sites.length > data.domainGroups.length ? <Notice tone="blue" title="读取限制" message="为避免一次打开数百站点时压垮宝塔面板,V2 只预读前 12 个站点的域名列表;完整域名增删仍回旧后台。" /> : null}
|
||||
{mode === 'fallback' ? <Notice tone="amber" title="资产数据兜底" message={'当前 Dashboard 资产接口处于演示模式,总资产数显示为 ' + String(total) + '。'} /> : null}
|
||||
|
||||
<section className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_24rem]">
|
||||
<Panel className="overflow-hidden p-0">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-white/10 p-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-black text-white">网站与证书状态</h2>
|
||||
<p className="mt-1 text-sm font-semibold text-slate-500">只展示证书摘要和到期信息,不展示证书私钥或 CSR 内容。</p>
|
||||
</div>
|
||||
<Badge tone="blue">{filteredSites.length} / {data.sites.length}</Badge>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[880px] text-left text-sm">
|
||||
<thead className="bg-slate-950/60 text-xs font-black uppercase tracking-[0.12em] text-slate-500">
|
||||
<tr>
|
||||
<th className="px-5 py-4">站点</th>
|
||||
<th className="px-5 py-4">状态</th>
|
||||
<th className="px-5 py-4">SSL</th>
|
||||
<th className="px-5 py-4">路径</th>
|
||||
<th className="px-5 py-4">备注</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10">
|
||||
{filteredSites.map((site) => {
|
||||
const status = siteStatus(site)
|
||||
const ssl = sslSummary(matchSslRecord(site, data.sslSites))
|
||||
return (
|
||||
<tr key={recordId(site) || siteName(site)} className="bg-white/[0.015] hover:bg-white/[0.04]">
|
||||
<td className="px-5 py-4">
|
||||
<div className="font-black text-white">{siteName(site)}</div>
|
||||
<div className="mt-1 font-mono text-xs text-slate-600">ID {recordId(site) || '-'}</div>
|
||||
</td>
|
||||
<td className="px-5 py-4"><Badge tone={status.tone}>{status.label}</Badge></td>
|
||||
<td className="px-5 py-4">
|
||||
<Badge tone={ssl.tone}>{ssl.label}</Badge>
|
||||
<div className="mt-2 max-w-xs truncate text-xs font-semibold text-slate-500">{ssl.detail}</div>
|
||||
</td>
|
||||
<td className="px-5 py-4"><span className="font-mono text-xs text-slate-400">{sitePath(site)}</span></td>
|
||||
<td className="px-5 py-4 text-slate-400">{field(site, ['ps', 'remark', 'note'], '-')}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<div className="space-y-5">
|
||||
<Panel className="p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-black text-white">PHP 版本</h2>
|
||||
<p className="mt-1 text-sm font-semibold text-slate-500">来自 php-versions GET,只读展示。</p>
|
||||
</div>
|
||||
<Badge tone="purple">{data.phpVersions.length} 个</Badge>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
{data.phpVersions.length > 0 ? data.phpVersions.map((item, index) => (
|
||||
<Badge key={String(field(item, ['version', 'name'], String(index)))} tone="slate">{field(item, ['name', 'version', 'title'], 'PHP')}</Badge>
|
||||
)) : <span className="text-sm font-semibold text-slate-500">暂无 PHP 版本数据</span>}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel className="p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-black text-white">域名绑定预览</h2>
|
||||
<p className="mt-1 text-sm font-semibold text-slate-500">最多预读 12 个站点。</p>
|
||||
</div>
|
||||
<Badge tone="green">{domainTotal(data.domainGroups)} 条</Badge>
|
||||
</div>
|
||||
<div className="mt-5 space-y-3">
|
||||
{data.domainGroups.map((group) => (
|
||||
<div key={recordId(group.site) || siteName(group.site)} className="rounded-3xl border border-white/10 bg-white/[0.03] p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-black text-white">{siteName(group.site)}</div>
|
||||
{group.domainWarning ? <div className="mt-1 text-xs font-semibold text-amber-300">{group.domainWarning}</div> : null}
|
||||
</div>
|
||||
<Badge tone="blue">{group.domains.length}</Badge>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{group.domains.length > 0 ? group.domains.slice(0, 6).map((domain) => (
|
||||
<span key={domainName(domain)} className="rounded-full bg-slate-950/70 px-3 py-1 font-mono text-xs text-slate-300">{domainName(domain)}</span>
|
||||
)) : <span className="text-xs font-semibold text-slate-600">暂无域名明细</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="grid h-12 w-12 place-items-center rounded-2xl bg-emerald-400/10 text-emerald-200"><LockKeyhole className="h-5 w-5" /></div>
|
||||
<div>
|
||||
<h2 className="text-xl font-black text-white">安全边界</h2>
|
||||
<p className="mt-2 text-sm font-semibold leading-7 text-slate-500">V2 不申请证书、不上传证书、不新增/删除域名、不启停网站。需要写操作时继续使用旧后台。</p>
|
||||
<a href="/app/btpanel/ssl" className="focus-ring mt-4 inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-black text-cyan-200 hover:bg-cyan-400/10">
|
||||
<ArrowUpRight className="h-4 w-4" /> 打开旧后台 SSL 管理
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MetricCardProps {
|
||||
icon: typeof Server
|
||||
label: string
|
||||
value: string
|
||||
tone: Tone
|
||||
}
|
||||
|
||||
function MetricCard({ icon: Icon, label, value, tone }: MetricCardProps): JSX.Element {
|
||||
const toneClass = tone === 'green'
|
||||
? 'from-emerald-400/20 to-emerald-400/5 text-emerald-200'
|
||||
: tone === 'purple'
|
||||
? 'from-violet-400/20 to-violet-400/5 text-violet-200'
|
||||
: tone === 'red'
|
||||
? 'from-red-400/20 to-red-400/5 text-red-200'
|
||||
: tone === 'amber'
|
||||
? 'from-amber-400/20 to-amber-400/5 text-amber-200'
|
||||
: 'from-cyan-400/20 to-cyan-400/5 text-cyan-200'
|
||||
return (
|
||||
<div className={cn('rounded-3xl border border-white/10 bg-gradient-to-br p-5', toneClass)}>
|
||||
<Icon className="h-5 w-5" />
|
||||
<div className="mt-4 text-xs font-black uppercase tracking-[0.2em] text-slate-500">{label}</div>
|
||||
<div className="mt-1 truncate text-2xl font-black text-white">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface NoticeProps {
|
||||
tone: Tone
|
||||
title: string
|
||||
message: string
|
||||
}
|
||||
|
||||
function Notice({ tone, title, message }: NoticeProps): JSX.Element {
|
||||
const className = tone === 'blue'
|
||||
? 'border-cyan-400/20 bg-cyan-400/10 text-cyan-100'
|
||||
: tone === 'green'
|
||||
? 'border-emerald-400/20 bg-emerald-400/10 text-emerald-100'
|
||||
: 'border-amber-400/20 bg-amber-400/10 text-amber-100'
|
||||
return (
|
||||
<div className={cn('rounded-3xl border px-5 py-4 text-sm font-semibold', className)}>
|
||||
<span className="font-black">{title}:</span>{message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { ExternalLink, Loader2, LockKeyhole, Search, ShieldAlert, ShieldCheck, TimerReset, Wrench } from 'lucide-react'
|
||||
import { Badge } from '~components/ui/Badge'
|
||||
import { Panel } from '~components/ui/Panel'
|
||||
import type { ServerAsset } from '../data/dashboardData'
|
||||
|
||||
interface BtPanelPageProps {
|
||||
servers: ServerAsset[]
|
||||
total: number
|
||||
mode: 'live' | 'fallback'
|
||||
loginLoadingId?: string | null
|
||||
onBtLogin: (server: ServerAsset) => void
|
||||
}
|
||||
|
||||
type PanelFilter = 'all' | 'configured' | 'ttl-ok' | 'needs-fix' | 'ssl-off'
|
||||
|
||||
const filters: Array<{ label: string; value: PanelFilter }> = [
|
||||
{ label: '全部宝塔', value: 'all' },
|
||||
{ label: '已配置', value: 'configured' },
|
||||
{ label: 'TTL 已兜底', value: 'ttl-ok' },
|
||||
{ label: '需要兜底', value: 'needs-fix' },
|
||||
{ label: 'SSL 未开', value: 'ssl-off' },
|
||||
]
|
||||
|
||||
function isTtlProtected(server: ServerAsset): boolean {
|
||||
return Boolean(server.btSessionCleanupOk || server.btSessionCleanupPatched || server.ttl === '86400s')
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string | null): string {
|
||||
if (!value) return '未检查'
|
||||
const timestamp = Date.parse(value)
|
||||
if (!Number.isFinite(timestamp)) return value
|
||||
return new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' }).format(timestamp)
|
||||
}
|
||||
|
||||
function filterPanel(server: ServerAsset, filter: PanelFilter, keyword: string): boolean {
|
||||
const matchesKeyword = !keyword || [server.name, server.ip, server.region, server.ttl, server.btBaseUrl ?? '', server.btBootstrapState ?? '']
|
||||
.some((value) => value.toLowerCase().includes(keyword))
|
||||
if (!matchesKeyword) return false
|
||||
if (filter === 'configured') return server.btConfigured === true
|
||||
if (filter === 'ttl-ok') return isTtlProtected(server)
|
||||
if (filter === 'needs-fix') return server.btConfigured === true && !isTtlProtected(server)
|
||||
if (filter === 'ssl-off') return server.ssl === 'disabled'
|
||||
return server.btConfigured === true || Boolean(server.btBaseUrl) || server.panelStatus !== 'offline'
|
||||
}
|
||||
|
||||
export function BtPanelPage({ servers, total, mode, loginLoadingId, onBtLogin }: BtPanelPageProps): JSX.Element {
|
||||
const [query, setQuery] = useState('')
|
||||
const [filter, setFilter] = useState<PanelFilter>('all')
|
||||
|
||||
const keyword = query.trim().toLowerCase()
|
||||
const panelServers = useMemo(() => servers.filter((server) => filterPanel(server, filter, keyword)), [filter, keyword, servers])
|
||||
const configuredCount = useMemo(() => servers.filter((server) => server.btConfigured).length, [servers])
|
||||
const ttlProtectedCount = useMemo(() => servers.filter(isTtlProtected).length, [servers])
|
||||
const needsFixCount = useMemo(() => servers.filter((server) => server.btConfigured && !isTtlProtected(server)).length, [servers])
|
||||
const sslOffCount = useMemo(() => servers.filter((server) => server.ssl === 'disabled').length, [servers])
|
||||
|
||||
return (
|
||||
<div id="btpanel" className="space-y-6 p-5 lg:p-10">
|
||||
<Panel className="relative overflow-hidden p-6">
|
||||
<div className="absolute -right-16 -top-20 h-56 w-56 rounded-full bg-emerald-400/10 blur-3xl" />
|
||||
<div className="flex flex-wrap items-start justify-between gap-5">
|
||||
<div className="max-w-3xl">
|
||||
<Badge tone="green" dot className="px-4 py-2">V2 宝塔面板</Badge>
|
||||
<h2 className="mt-4 text-3xl font-black tracking-[-0.05em] text-white lg:text-5xl">宝塔登录稳定性中心</h2>
|
||||
<p className="mt-3 text-sm font-semibold leading-7 text-slate-400">
|
||||
重点围绕你遇到的“打开多个宝塔后 1-2 小时陆续掉线”问题:集中展示 TTL 兜底状态、SSL 建议、一键登录入口;配置编辑仍回旧后台。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<a href="/app/" className="focus-ring inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-black text-cyan-200 hover:bg-cyan-400/10">
|
||||
<ExternalLink className="h-4 w-4" /> 旧后台宝塔配置
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<BtMetric icon={ShieldCheck} label="已配置宝塔" value={String(configuredCount)} sub={'当前接口返回 ' + String(servers.length) + ' / 总计 ' + String(total)} tone="text-emerald-300" />
|
||||
<BtMetric icon={TimerReset} label="TTL 兜底通过" value={String(ttlProtectedCount)} sub="已 OK 或已自动 patch" tone="text-cyan-300" />
|
||||
<BtMetric icon={Wrench} label="待兜底修复" value={String(needsFixCount)} sub="一键登录前会再次检测/修复" tone={needsFixCount ? 'text-amber-300' : 'text-emerald-300'} />
|
||||
<BtMetric icon={ShieldAlert} label="面板 SSL 建议" value={String(sslOffCount)} sub="公网面板建议开启 SSL" tone={sslOffCount ? 'text-amber-300' : 'text-emerald-300'} />
|
||||
</section>
|
||||
|
||||
<Panel className="p-5">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-2xl bg-emerald-400/10 text-emerald-200"><LockKeyhole className="h-5 w-5" /></div>
|
||||
<div>
|
||||
<div className="text-base font-black text-white">登录前兜底策略</div>
|
||||
<div className="text-xs font-bold text-slate-500">V2 只触发后端接口;真正的 tmp_login / task.py TTL 修复仍由后端执行。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(16rem,1fr)_12rem] xl:min-w-[34rem]">
|
||||
<label className="focus-within:ring-2 focus-within:ring-emerald-300/70 flex items-center gap-2 rounded-2xl border border-white/10 bg-slate-950/50 px-4 py-3 text-sm font-bold text-slate-400">
|
||||
<Search className="h-4 w-4" />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索宝塔名称 / IP / 地址 / 状态" className="w-full bg-transparent text-slate-100 outline-none placeholder:text-slate-600" />
|
||||
</label>
|
||||
<select value={filter} onChange={(event) => setFilter(event.target.value as PanelFilter)} className="focus-ring rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm font-black text-slate-200">
|
||||
{filters.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-2">
|
||||
{panelServers.map((server) => (
|
||||
<BtPanelCard key={server.id} server={server} loading={loginLoadingId === server.id} onBtLogin={onBtLogin} />
|
||||
))}
|
||||
{panelServers.length === 0 ? (
|
||||
<Panel className="p-10 text-center">
|
||||
<div className="text-lg font-black text-white">没有匹配的宝塔面板</div>
|
||||
<p className="mt-2 text-sm font-semibold text-slate-500">换一个关键词或筛选条件;完整配置仍可回旧后台查看。</p>
|
||||
</Panel>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<Panel className="p-6">
|
||||
<h3 className="text-lg font-black text-white">边界说明</h3>
|
||||
<div className="mt-4 grid gap-3 text-sm font-semibold leading-7 text-slate-400 md:grid-cols-3">
|
||||
<p>1. V2 不保存、不显示宝塔密码、Cookie、tmp_login token。</p>
|
||||
<p>2. 一键登录前由后端兜底检测 hardcoded_3600,新加入的宝塔也会走同一链路。</p>
|
||||
<p>3. 宝塔凭据编辑、批量导入、危险配置修改暂时继续走旧后台,避免迁移期功能缺口。</p>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface BtMetricProps {
|
||||
icon: typeof ShieldCheck
|
||||
label: string
|
||||
value: string
|
||||
sub: string
|
||||
tone: string
|
||||
}
|
||||
|
||||
function BtMetric({ icon: Icon, label, value, sub, tone }: BtMetricProps): JSX.Element {
|
||||
return (
|
||||
<Panel className="p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs font-black uppercase tracking-[0.14em] text-slate-500">{label}</div>
|
||||
<div className={'mt-3 text-4xl font-black tracking-[-0.05em] ' + tone}>{value}</div>
|
||||
</div>
|
||||
<div className="grid h-11 w-11 place-items-center rounded-2xl border border-white/10 bg-white/[0.04]"><Icon className={'h-5 w-5 ' + tone} /></div>
|
||||
</div>
|
||||
<div className="mt-3 text-sm font-semibold leading-6 text-slate-500">{sub}</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
interface BtPanelCardProps {
|
||||
server: ServerAsset
|
||||
loading: boolean
|
||||
onBtLogin: (server: ServerAsset) => void
|
||||
}
|
||||
|
||||
function BtPanelCard({ server, loading, onBtLogin }: BtPanelCardProps): JSX.Element {
|
||||
const ttlProtected = isTtlProtected(server)
|
||||
const canLogin = Boolean(server.sourceId && server.btConfigured)
|
||||
return (
|
||||
<Panel className="overflow-hidden p-0">
|
||||
<div className="border-b border-white/[0.06] p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xl font-black tracking-tight text-white">{server.name}</div>
|
||||
<div className="mt-1 font-mono text-sm font-medium text-slate-500">{server.ip} · {server.region}</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge tone={server.btConfigured ? 'green' : 'amber'} dot>{server.btConfigured ? '已配置' : '待配置'}</Badge>
|
||||
<Badge tone={ttlProtected ? 'green' : 'amber'} dot>{ttlProtected ? 'TTL 通过' : '登录前兜底'}</Badge>
|
||||
<Badge tone={server.ssl === 'disabled' ? 'amber' : 'blue'}>{server.ssl === 'enabled' ? 'SSL 开启' : server.ssl === 'internal' ? '内网' : server.ssl === 'disabled' ? 'SSL 建议' : 'SSL 未知'}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 p-5 md:grid-cols-[1fr_13rem]">
|
||||
<div className="space-y-3">
|
||||
<BtDiagnosis label="面板地址" value={server.btBaseUrl || '未返回 / 未配置'} good={Boolean(server.btBaseUrl || server.btConfigured)} />
|
||||
<BtDiagnosis label="Session TTL" value={ttlProtected ? '86400s / 已兜底' : '待复查 / 登录前修复'} good={ttlProtected} />
|
||||
<BtDiagnosis label="最近检测" value={formatDateTime(server.btLastCheckAt)} good={!server.btLastError} />
|
||||
<BtDiagnosis label="Bootstrap" value={server.btBootstrapState || (server.btConfigured ? '已配置' : '未配置')} good={server.btConfigured !== false} />
|
||||
{server.btLastError ? <BtDiagnosis label="最近错误" value={server.btLastError} good={false} /> : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
className="focus-ring inline-flex items-center justify-center gap-2 rounded-2xl bg-emerald-400 px-4 py-3 text-sm font-black text-slate-950 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!canLogin || loading}
|
||||
onClick={() => onBtLogin(server)}
|
||||
>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <ExternalLink className="h-4 w-4" />}
|
||||
一键登录宝塔
|
||||
</button>
|
||||
<a href="/app/" className="focus-ring inline-flex items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-black text-cyan-200 hover:bg-cyan-400/10">旧后台配置</a>
|
||||
<div className="rounded-2xl bg-slate-950/55 p-4 text-xs font-semibold leading-5 text-slate-500">
|
||||
点击一键登录时,后端会先执行 TTL 兜底检测;如果仍有 hardcoded_3600,会先修复再生成登录地址。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
interface BtDiagnosisProps {
|
||||
label: string
|
||||
value: string
|
||||
good: boolean
|
||||
}
|
||||
|
||||
function BtDiagnosis({ label, value, good }: BtDiagnosisProps): JSX.Element {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 rounded-2xl border border-white/[0.06] bg-slate-950/35 px-4 py-3 text-sm">
|
||||
<span className="font-bold text-slate-500">{label}</span>
|
||||
<span className={good ? 'text-right font-black text-emerald-300' : 'text-right font-black text-amber-300'}>{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { Activity, ArrowUpRight, CalendarClock, Database, Globe2, HardDrive, LockKeyhole, RefreshCcw, Search, Server, ShieldCheck } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { Badge } from '~components/ui/Badge'
|
||||
import { Panel } from '~components/ui/Panel'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isSensitiveKey, redactSensitiveText } from '@/lib/redaction'
|
||||
import { fetchBtResourceOverviewData } from '../api/dashboardApi'
|
||||
import type { BtResourceRecord } from '../api/dashboardApi'
|
||||
import type { ServerAsset } from '../data/dashboardData'
|
||||
|
||||
interface BtResourceOverviewPageProps {
|
||||
servers: ServerAsset[]
|
||||
total: number
|
||||
mode: 'live' | 'fallback'
|
||||
}
|
||||
|
||||
type ResourceKind = 'sites' | 'databases' | 'crontabs'
|
||||
|
||||
function textOf(record: BtResourceRecord, keys: string[], fallback = '-'): string {
|
||||
for (const key of keys) {
|
||||
const value = record[key]
|
||||
if (value !== undefined && value !== null && String(value).trim() !== '') return String(value)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function numberOf(record: BtResourceRecord, keys: string[], fallback = 0): number {
|
||||
for (const key of keys) {
|
||||
const value = record[key]
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value.replace('%', ''))
|
||||
if (Number.isFinite(parsed)) return parsed
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function formatMaybeBytes(value: unknown): string {
|
||||
if (typeof value === 'string' && value.trim()) return value
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return '-'
|
||||
if (value < 1024) return String(value) + ' B'
|
||||
if (value < 1024 * 1024) return (value / 1024).toFixed(1) + ' KB'
|
||||
if (value < 1024 * 1024 * 1024) return (value / 1024 / 1024).toFixed(1) + ' MB'
|
||||
return (value / 1024 / 1024 / 1024).toFixed(1) + ' GB'
|
||||
}
|
||||
|
||||
function redactText(value: unknown): string {
|
||||
return redactSensitiveText(value, '-', 180)
|
||||
}
|
||||
|
||||
|
||||
function enabledStatus(record: BtResourceRecord): boolean {
|
||||
const value = String(record.status ?? record.active ?? record.open ?? '').toLowerCase()
|
||||
return value === '1' || value === 'true' || value === 'on' || value === 'running' || value === 'open'
|
||||
}
|
||||
|
||||
function filterRecord(record: BtResourceRecord, query: string): boolean {
|
||||
if (!query) return true
|
||||
const joined = Object.entries(record)
|
||||
.filter(([key]) => !isSensitiveKey(key))
|
||||
.map(([, value]) => String(value || '').toLowerCase())
|
||||
.join(' ')
|
||||
return joined.includes(query)
|
||||
}
|
||||
|
||||
function dateText(record: BtResourceRecord): string {
|
||||
return textOf(record, ['addtime', 'add_time', 'created_at', 'time', 'backup_time'])
|
||||
}
|
||||
|
||||
export function BtResourceOverviewPage({ servers, total, mode }: BtResourceOverviewPageProps): JSX.Element {
|
||||
const configuredServers = useMemo(() => servers.filter((server) => server.btConfigured && typeof server.sourceId === 'number'), [servers])
|
||||
const [selectedServer, setSelectedServer] = useState(() => String(configuredServers[0]?.sourceId || ''))
|
||||
const [query, setQuery] = useState('')
|
||||
const [activeKind, setActiveKind] = useState<ResourceKind>('sites')
|
||||
const selectedServerId = selectedServer ? Number(selectedServer) : undefined
|
||||
const selectedServerInfo = configuredServers.find((server) => String(server.sourceId) === selectedServer)
|
||||
|
||||
const { data, refetch, isFetching } = useSuspenseQuery({
|
||||
queryKey: ['bt-resource-overview-v2', selectedServerId ?? 'none'],
|
||||
queryFn: () => fetchBtResourceOverviewData(selectedServerId),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const normalizedQuery = query.trim().toLowerCase()
|
||||
const filteredSites = useMemo(() => data.sites.filter((item) => filterRecord(item, normalizedQuery)), [data.sites, normalizedQuery])
|
||||
const filteredDatabases = useMemo(() => data.databases.filter((item) => filterRecord(item, normalizedQuery)), [data.databases, normalizedQuery])
|
||||
const filteredCrontabs = useMemo(() => data.crontabs.filter((item) => filterRecord(item, normalizedQuery)), [data.crontabs, normalizedQuery])
|
||||
|
||||
const resourceRows = activeKind === 'sites' ? filteredSites : activeKind === 'databases' ? filteredDatabases : filteredCrontabs
|
||||
const activeSites = useMemo(() => data.sites.filter(enabledStatus).length, [data.sites])
|
||||
const activeCrons = useMemo(() => data.crontabs.filter(enabledStatus).length, [data.crontabs])
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-5 lg:p-10">
|
||||
<Panel className="overflow-hidden p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-white/10 pb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-2xl bg-sky-400/15 text-sky-200"><Globe2 className="h-5 w-5" /></div>
|
||||
<div>
|
||||
<h2 className="text-xl font-black tracking-[-0.04em] text-white">宝塔资源只读总览</h2>
|
||||
<p className="mt-1 text-sm font-semibold text-slate-500">集中查看站点、数据库、计划任务和系统资源;V2 当前不做站点启停、建站、改库密码、备份、计划任务编辑或服务启停。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge tone={data.mode === 'live' ? 'green' : 'amber'} dot>{data.mode === 'live' ? '实时 API' : '降级数据'}</Badge>
|
||||
<Badge tone={mode === 'live' ? 'blue' : 'slate'}>Dashboard {mode}</Badge>
|
||||
<Badge tone="purple"><LockKeyhole className="mr-1 h-3 w-3" />只读 GET</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.warning ? <div className="mt-4 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm font-semibold text-amber-100">{data.warning}</div> : null}
|
||||
|
||||
<div className="mt-5 grid gap-3 xl:grid-cols-[1fr_auto_auto]">
|
||||
<label className="focus-within:ring-nexus flex items-center gap-3 rounded-2xl border border-white/10 bg-slate-950/50 px-4 py-3 text-sm font-semibold text-slate-500">
|
||||
<Search className="h-4 w-4" />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索站点 / 数据库 / 计划任务 / 备注" className="w-full bg-transparent text-slate-100 outline-none placeholder:text-slate-600" />
|
||||
</label>
|
||||
<label className="flex items-center gap-2 rounded-2xl border border-white/10 bg-slate-950/50 px-4 py-3 text-sm font-bold text-slate-400">
|
||||
<Server className="h-4 w-4" />
|
||||
<select value={selectedServer} onChange={(event) => setSelectedServer(event.target.value)} className="max-w-72 bg-transparent text-slate-100 outline-none">
|
||||
{configuredServers.length === 0 ? <option value="">暂无已配置宝塔服务器</option> : null}
|
||||
{configuredServers.map((server) => <option key={server.sourceId} value={server.sourceId}>{server.name} / {server.ip}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" onClick={() => void refetch()} className="focus-ring inline-flex items-center justify-center gap-2 rounded-2xl border border-cyan-300/20 bg-cyan-400/10 px-4 py-3 text-sm font-black text-cyan-100">
|
||||
<RefreshCcw className={cn('h-4 w-4', isFetching ? 'animate-spin' : '')} />刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-4 md:grid-cols-4">
|
||||
<MetricCard icon={Globe2} label="站点" value={String(data.sites.length)} hint={String(activeSites) + ' 个启用'} tone="text-sky-300" />
|
||||
<MetricCard icon={Database} label="数据库" value={String(data.databases.length)} hint="不展示密码 / 连接串" tone="text-emerald-300" />
|
||||
<MetricCard icon={CalendarClock} label="计划任务" value={String(data.crontabs.length)} hint={String(activeCrons) + ' 个启用'} tone="text-amber-300" />
|
||||
<MetricCard icon={Server} label="Nexus 资产" value={String(total)} hint={selectedServerInfo ? selectedServerInfo.ip : '全部服务器'} tone="text-violet-300" />
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-[24rem_minmax(0,1fr)]">
|
||||
<Panel className="p-6">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-black text-white">系统资源快照</h3>
|
||||
<p className="mt-1 text-xs font-semibold text-slate-500">来自宝塔 system total / disk / network 只读接口。</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<InfoRow label="服务器" value={selectedServerInfo ? selectedServerInfo.name + ' / ' + selectedServerInfo.ip : '-'} />
|
||||
<InfoRow label="系统" value={textOf(data.systemTotal, ['system', 'os', 'platform'])} />
|
||||
<InfoRow label="宝塔版本" value={textOf(data.systemTotal, ['version', 'bt_version', 'panel_version'])} />
|
||||
<InfoRow label="运行时间" value={textOf(data.systemTotal, ['time', 'uptime', 'run_time'])} />
|
||||
<InfoRow label="CPU 核心" value={textOf(data.systemTotal, ['cpuNum', 'cpu_num', 'cpu'], '-')} />
|
||||
<InfoRow label="内存" value={formatMaybeBytes(data.systemTotal.memRealUsed) + ' / ' + formatMaybeBytes(data.systemTotal.memTotal)} />
|
||||
<InfoRow label="上行" value={textOf(data.network, ['up', 'upTotal', 'up_total'])} />
|
||||
<InfoRow label="下行" value={textOf(data.network, ['down', 'downTotal', 'down_total'])} />
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
<div className="text-sm font-black text-slate-300">磁盘</div>
|
||||
{data.disks.map((disk, index) => <DiskCard key={String(textOf(disk, ['path', 'filesystem'], String(index)))} disk={disk} />)}
|
||||
{data.disks.length === 0 ? <div className="rounded-3xl border border-white/10 bg-slate-950/40 p-4 text-sm font-bold text-slate-500">暂无磁盘数据</div> : null}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel className="overflow-hidden p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-black text-white">宝塔资源列表</h3>
|
||||
<p className="mt-1 text-xs font-semibold text-slate-500">所有危险动作回旧后台,V2 当前只做聚合查看。</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<KindButton active={activeKind === 'sites'} icon={Globe2} label="站点" onClick={() => setActiveKind('sites')} />
|
||||
<KindButton active={activeKind === 'databases'} icon={Database} label="数据库" onClick={() => setActiveKind('databases')} />
|
||||
<KindButton active={activeKind === 'crontabs'} icon={CalendarClock} label="计划任务" onClick={() => setActiveKind('crontabs')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
{activeKind === 'sites' ? filteredSites.map((item, index) => <SiteRow key={textOf(item, ['id', 'name'], String(index))} item={item} />) : null}
|
||||
{activeKind === 'databases' ? filteredDatabases.map((item, index) => <DatabaseRow key={textOf(item, ['id', 'name'], String(index))} item={item} />) : null}
|
||||
{activeKind === 'crontabs' ? filteredCrontabs.map((item, index) => <CronRow key={textOf(item, ['id', 'name'], String(index))} item={item} />) : null}
|
||||
{resourceRows.length === 0 ? <div className="rounded-3xl border border-white/10 bg-slate-950/40 px-5 py-12 text-center text-sm font-bold text-slate-500">没有符合条件的数据</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-center justify-between gap-3 rounded-3xl border border-sky-300/10 bg-sky-400/5 p-4">
|
||||
<div className="text-sm font-semibold leading-6 text-slate-400">
|
||||
V2 不调用站点启停、建站、改库密码、备份、计划任务编辑、服务启停等写接口;这些操作继续回旧后台执行,避免误触发。
|
||||
</div>
|
||||
<a href="/app/btpanel/sites" className="focus-ring inline-flex items-center gap-2 rounded-2xl border border-sky-300/25 bg-sky-400/15 px-4 py-3 text-sm font-black text-sky-100">
|
||||
去旧后台管理 <ArrowUpRight className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</Panel>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MetricCard({ icon: Icon, label, value, hint, tone }: { icon: LucideIcon; label: string; value: string; hint: string; tone: string }): JSX.Element {
|
||||
return (
|
||||
<div className="rounded-3xl border border-white/10 bg-slate-950/40 p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-black text-slate-400">{label}</div>
|
||||
<Icon className={cn('h-5 w-5', tone)} />
|
||||
</div>
|
||||
<div className={cn('mt-4 text-3xl font-black tracking-[-0.05em]', tone)}>{value}</div>
|
||||
<div className="mt-2 text-xs font-semibold text-slate-500">{hint}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }): JSX.Element {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-slate-950/40 px-4 py-3 text-sm">
|
||||
<span className="font-bold text-slate-500">{label}</span>
|
||||
<span className="truncate text-right font-black text-slate-200">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DiskCard({ disk }: { disk: BtResourceRecord }): JSX.Element {
|
||||
const percent = Math.max(0, Math.min(100, numberOf(disk, ['percent', 'used_percent', 'usage'], 0)))
|
||||
return (
|
||||
<div className="rounded-3xl border border-white/10 bg-slate-950/40 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-sm font-black text-white"><HardDrive className="h-4 w-4 text-slate-500" />{textOf(disk, ['path', 'filesystem', 'name'])}</div>
|
||||
<span className="text-xs font-black text-slate-500">{percent}%</span>
|
||||
</div>
|
||||
<div className="mt-3 h-2 overflow-hidden rounded-full bg-slate-800"><div className="h-full rounded-full bg-sky-400" style={{ width: String(percent) + '%' }} /></div>
|
||||
<div className="mt-2 text-xs font-semibold text-slate-500">{textOf(disk, ['used', 'used_size'])} / {textOf(disk, ['size', 'total'])},剩余 {textOf(disk, ['free', 'available'])}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KindButton({ active, icon: Icon, label, onClick }: { active: boolean; icon: LucideIcon; label: string; onClick: () => void }): JSX.Element {
|
||||
return (
|
||||
<button type="button" onClick={onClick} className={cn('focus-ring inline-flex items-center gap-2 rounded-2xl px-4 py-3 text-sm font-black transition', active ? 'border border-cyan-300/25 bg-cyan-400/15 text-cyan-100' : 'border border-white/10 bg-slate-950/40 text-slate-400 hover:text-white')}>
|
||||
<Icon className="h-4 w-4" />{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function SiteRow({ item }: { item: BtResourceRecord }): JSX.Element {
|
||||
const running = enabledStatus(item)
|
||||
return (
|
||||
<div className="rounded-3xl border border-white/10 bg-slate-950/40 p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2"><Badge tone={running ? 'green' : 'slate'} dot>{running ? '运行中' : '已停止/未知'}</Badge><span className="text-sm font-black text-white">{textOf(item, ['name', 'siteName', 'domain'])}</span></div>
|
||||
<div className="mt-2 font-mono text-xs font-semibold text-slate-500">{textOf(item, ['path'])}</div>
|
||||
</div>
|
||||
<Badge tone="blue">ID {textOf(item, ['id'])}</Badge>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 text-xs font-semibold text-slate-500 md:grid-cols-3">
|
||||
<span>PHP:{textOf(item, ['php_version', 'phpVersion', 'php'])}</span>
|
||||
<span>备注:{redactText(textOf(item, ['ps', 'remark', 'note']))}</span>
|
||||
<span>创建:{dateText(item)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DatabaseRow({ item }: { item: BtResourceRecord }): JSX.Element {
|
||||
return (
|
||||
<div className="rounded-3xl border border-white/10 bg-slate-950/40 p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2"><Badge tone="green" dot>{textOf(item, ['type', 'db_type'], 'MySQL')}</Badge><span className="text-sm font-black text-white">{textOf(item, ['name', 'db_name'])}</span></div>
|
||||
<div className="mt-2 text-xs font-semibold text-slate-500">用户:{textOf(item, ['username', 'user'])} · 备份:{textOf(item, ['backup_count', 'backupCount'], '0')}</div>
|
||||
</div>
|
||||
<Badge tone="purple">不展示密码</Badge>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 text-xs font-semibold text-slate-500 md:grid-cols-2">
|
||||
<span>备注:{redactText(textOf(item, ['ps', 'remark', 'note']))}</span>
|
||||
<span>创建:{dateText(item)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CronRow({ item }: { item: BtResourceRecord }): JSX.Element {
|
||||
const running = enabledStatus(item)
|
||||
return (
|
||||
<div className="rounded-3xl border border-white/10 bg-slate-950/40 p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2"><Badge tone={running ? 'green' : 'amber'} dot>{running ? '启用' : '停用/未知'}</Badge><span className="text-sm font-black text-white">{textOf(item, ['name', 'title'])}</span></div>
|
||||
<div className="mt-2 text-xs font-semibold text-slate-500">类型:{textOf(item, ['type'])} · 目标:{textOf(item, ['sName', 'target', 'db_name'])}</div>
|
||||
</div>
|
||||
<Badge tone="slate">ID {textOf(item, ['id'])}</Badge>
|
||||
</div>
|
||||
<div className="mt-3 rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-3 font-mono text-xs leading-6 text-slate-300">{redactText(textOf(item, ['sBody', 'script', 'command', 'url']))}</div>
|
||||
<div className="mt-3 grid gap-2 text-xs font-semibold text-slate-500 md:grid-cols-3">
|
||||
<span>小时:{textOf(item, ['hour', 'where_hour'])}</span>
|
||||
<span>分钟:{textOf(item, ['minute', 'where_minute', 'where1'])}</span>
|
||||
<span>创建:{dateText(item)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Command } from 'cmdk'
|
||||
import { ExternalLink, Globe2, Search } from 'lucide-react'
|
||||
import { useAppStore } from '@/stores/appStore'
|
||||
import type { ServerAsset } from '../data/dashboardData'
|
||||
|
||||
interface CommandPaletteProps {
|
||||
servers: ServerAsset[]
|
||||
onBtLogin: (server: ServerAsset) => void
|
||||
}
|
||||
|
||||
export function CommandPalette({ servers, onBtLogin }: CommandPaletteProps): JSX.Element | null {
|
||||
const open = useAppStore((state) => state.commandOpen)
|
||||
const setOpen = useAppStore((state) => state.setCommandOpen)
|
||||
const setSelectedServerId = useAppStore((state) => state.setSelectedServerId)
|
||||
const setActiveModule = useAppStore((state) => state.setActiveModule)
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 grid place-items-start bg-slate-950/70 px-4 pt-[12vh] backdrop-blur" onClick={() => setOpen(false)}>
|
||||
<Command className="mx-auto w-full max-w-2xl overflow-hidden rounded-3xl border border-white/10 bg-slate-950 shadow-glow" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="flex items-center gap-3 border-b border-white/10 px-5 py-4">
|
||||
<Search className="h-5 w-5 text-cyan-300" />
|
||||
<Command.Input autoFocus placeholder="搜索服务器、IP、任务、风险,或输入操作..." className="h-10 flex-1 bg-transparent text-sm font-bold text-slate-100 outline-none placeholder:text-slate-600" />
|
||||
</div>
|
||||
<Command.List className="max-h-96 overflow-auto p-3">
|
||||
<Command.Empty className="px-4 py-8 text-center text-sm font-bold text-slate-500">没有找到结果</Command.Empty>
|
||||
<Command.Group heading="服务器" className="text-xs font-black text-slate-500 [&_[cmdk-group-heading]]:px-3 [&_[cmdk-group-heading]]:py-2">
|
||||
{servers.map((server) => (
|
||||
<Command.Item
|
||||
key={server.id}
|
||||
value={server.name + ' ' + server.ip + ' ' + server.region + ' ' + server.risk}
|
||||
className="flex cursor-pointer items-center justify-between rounded-2xl px-3 py-3 text-sm font-bold text-slate-200 aria-selected:bg-cyan-400/10 aria-selected:text-white"
|
||||
onSelect={() => {
|
||||
setSelectedServerId(server.id)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<span>{server.name}<span className="ml-2 font-mono text-xs text-slate-500">{server.ip}</span></span>
|
||||
<span className="text-xs text-slate-500">{server.ttl}</span>
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.Group>
|
||||
<Command.Group heading="快捷操作" className="mt-2 text-xs font-black text-slate-500 [&_[cmdk-group-heading]]:px-3 [&_[cmdk-group-heading]]:py-2">
|
||||
{servers.filter((server) => server.sourceId).slice(0, 6).map((server) => (
|
||||
<Command.Item
|
||||
key={'login-' + server.id}
|
||||
value={'一键登录 ' + server.name + ' ' + server.ip}
|
||||
className="flex cursor-pointer items-center gap-3 rounded-2xl px-3 py-3 text-sm font-bold text-cyan-200 aria-selected:bg-cyan-400/10 aria-selected:text-white"
|
||||
onSelect={() => {
|
||||
onBtLogin(server)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" /> 一键登录 {server.name}
|
||||
</Command.Item>
|
||||
))}
|
||||
<Command.Item
|
||||
value="打开全局搜索 搜索 服务器 脚本 凭据 计划任务"
|
||||
className="flex cursor-pointer items-center gap-3 rounded-2xl px-3 py-3 text-sm font-bold text-cyan-200 aria-selected:bg-cyan-400/10 aria-selected:text-white"
|
||||
onSelect={() => {
|
||||
setActiveModule('search')
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<Search className="h-4 w-4" /> 打开全局搜索
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
value="打开域名 SSL 宝塔 网站 证书 PHP 只读"
|
||||
className="flex cursor-pointer items-center gap-3 rounded-2xl px-3 py-3 text-sm font-bold text-cyan-200 aria-selected:bg-cyan-400/10 aria-selected:text-white"
|
||||
onSelect={() => {
|
||||
setActiveModule('btdomainssl')
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<Globe2 className="h-4 w-4" /> 打开域名 / SSL
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
value="打开旧后台"
|
||||
className="flex cursor-pointer items-center gap-3 rounded-2xl px-3 py-3 text-sm font-bold text-slate-200 aria-selected:bg-cyan-400/10 aria-selected:text-white"
|
||||
onSelect={() => { window.location.href = '/app/' }}
|
||||
>
|
||||
打开旧后台完整功能
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
</Command.List>
|
||||
</Command>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { flexRender, getCoreRowModel, getFilteredRowModel, getSortedRowModel, useReactTable } from '@tanstack/react-table'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { Activity, ArrowUpRight, Clock3, FileClock, Filter, LockKeyhole, Search, Server, ShieldCheck, TerminalSquare } from 'lucide-react'
|
||||
import { Badge } from '~components/ui/Badge'
|
||||
import { Panel } from '~components/ui/Panel'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { redactSensitiveText } from '@/lib/redaction'
|
||||
import { fetchExecutionRecordsData } from '../api/dashboardApi'
|
||||
import type { ExecutionRecordItem, ExecutionRecordKind, ExecutionRecordStatus } from '../api/dashboardApi'
|
||||
|
||||
interface ExecutionRecordsPageProps {
|
||||
mode: 'live' | 'fallback'
|
||||
}
|
||||
|
||||
type FilterKind = 'all' | ExecutionRecordKind
|
||||
|
||||
type StatusTone = 'green' | 'blue' | 'amber' | 'red' | 'slate' | 'purple'
|
||||
|
||||
function statusTone(status: ExecutionRecordStatus): StatusTone {
|
||||
if (status === 'completed') return 'green'
|
||||
if (status === 'running') return 'blue'
|
||||
if (status === 'partial') return 'amber'
|
||||
if (status === 'failed') return 'red'
|
||||
if (status === 'cancelled') return 'slate'
|
||||
return 'purple'
|
||||
}
|
||||
|
||||
function statusLabel(status: ExecutionRecordStatus): string {
|
||||
const labels: Record<string, string> = {
|
||||
completed: '已完成',
|
||||
running: '运行中',
|
||||
failed: '失败',
|
||||
partial: '部分完成',
|
||||
cancelled: '已取消',
|
||||
}
|
||||
return labels[String(status)] || String(status || '未知')
|
||||
}
|
||||
|
||||
function kindLabel(kind: ExecutionRecordKind): string {
|
||||
return kind === 'script' ? '脚本执行' : '服务器批量任务'
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string | null): string {
|
||||
if (!value) return '-'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
function redactText(value?: string | null): string {
|
||||
return redactSensitiveText(value, '-', 120)
|
||||
}
|
||||
|
||||
function durationText(startedAt?: string | null, completedAt?: string | null): string {
|
||||
if (!startedAt) return '-'
|
||||
const start = Date.parse(startedAt)
|
||||
const end = completedAt ? Date.parse(completedAt) : Date.now()
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return '-'
|
||||
const seconds = Math.round((end - start) / 1000)
|
||||
if (seconds < 60) return String(seconds) + 's'
|
||||
const minutes = Math.round(seconds / 60)
|
||||
if (minutes < 60) return String(minutes) + 'm'
|
||||
return String(Math.round(minutes / 60)) + 'h'
|
||||
}
|
||||
|
||||
function metricValue(items: ExecutionRecordItem[], matcher: (item: ExecutionRecordItem) => boolean): number {
|
||||
return items.filter(matcher).length
|
||||
}
|
||||
|
||||
export function ExecutionRecordsPage({ mode }: ExecutionRecordsPageProps): JSX.Element {
|
||||
const [query, setQuery] = useState('')
|
||||
const [kind, setKind] = useState<FilterKind>('all')
|
||||
const [status, setStatus] = useState<string>('all')
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: ['execution-records-v2'],
|
||||
queryFn: fetchExecutionRecordsData,
|
||||
staleTime: 20_000,
|
||||
})
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const text = query.trim().toLowerCase()
|
||||
return data.items.filter((item) => {
|
||||
const kindMatched = kind === 'all' || item.kind === kind
|
||||
const statusMatched = status === 'all' || item.status === status
|
||||
const textMatched = !text || [item.label, item.operator, item.op, item.command, String(item.id)].some((value) => String(value || '').toLowerCase().includes(text))
|
||||
return kindMatched && statusMatched && textMatched
|
||||
})
|
||||
}, [data.items, kind, query, status])
|
||||
|
||||
const statusOptions = useMemo(() => Array.from(new Set(data.items.map((item) => String(item.status || 'unknown')))), [data.items])
|
||||
|
||||
const columns = useMemo<ColumnDef<ExecutionRecordItem>[]>(() => [
|
||||
{
|
||||
accessorKey: 'label',
|
||||
header: '任务',
|
||||
cell: ({ row }) => {
|
||||
const item = row.original
|
||||
return (
|
||||
<div className="min-w-72">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge tone={item.kind === 'script' ? 'purple' : 'blue'}>{kindLabel(item.kind)}</Badge>
|
||||
<span className="text-sm font-black text-white">#{item.id}</span>
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-black text-white">{redactText(item.label)}</div>
|
||||
<div className="mt-1 text-xs font-semibold text-slate-500">命令/操作:{redactText(item.op || item.command)}</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: '状态',
|
||||
cell: ({ row }) => <Badge tone={statusTone(row.original.status)} dot>{statusLabel(row.original.status)}</Badge>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'progress',
|
||||
header: '进度',
|
||||
cell: ({ row }) => <span className="text-sm font-black text-cyan-200">{row.original.progress || '-'}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'server_count',
|
||||
header: '服务器',
|
||||
cell: ({ row }) => <span className="text-sm font-bold text-slate-300">{row.original.server_count ?? 0} 台</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'operator',
|
||||
header: '操作者',
|
||||
cell: ({ row }) => <span className="text-sm font-semibold text-slate-400">{row.original.operator || '-'}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'started_at',
|
||||
header: '时间',
|
||||
cell: ({ row }) => (
|
||||
<div className="text-xs font-semibold leading-5 text-slate-400">
|
||||
<div>{formatDateTime(row.original.started_at)}</div>
|
||||
<div className="text-slate-600">耗时 {durationText(row.original.started_at, row.original.completed_at)}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
], [])
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredItems,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
})
|
||||
|
||||
const cards = [
|
||||
{ label: '执行记录', value: String(data.total), sub: data.mode === 'live' ? '来自后端实时 API' : '安全降级演示', icon: FileClock, tone: 'text-cyan-300' },
|
||||
{ label: '运行中', value: String(metricValue(data.items, (item) => item.status === 'running')), sub: '只读观察,不在 V2 停止任务', icon: Activity, tone: 'text-blue-300' },
|
||||
{ label: '异常/部分完成', value: String(metricValue(data.items, (item) => item.status === 'failed' || item.status === 'partial')), sub: '详情深查回旧后台', icon: ShieldCheck, tone: 'text-amber-300' },
|
||||
{ label: '涉及服务器', value: String(data.items.reduce((sum, item) => sum + (item.server_count || 0), 0)), sub: '合计 server_count', icon: Server, tone: 'text-emerald-300' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div id="executions" className="space-y-6 px-5 pb-5 lg:px-10 lg:pb-10">
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{cards.map((card) => (
|
||||
<Panel key={card.label} className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-black uppercase tracking-[0.16em] text-slate-500">{card.label}</div>
|
||||
<card.icon className={cn('h-5 w-5', card.tone)} />
|
||||
</div>
|
||||
<div className="mt-3 text-3xl font-black tracking-[-0.05em] text-white">{card.value}</div>
|
||||
<div className="mt-2 text-sm font-semibold text-slate-500">{card.sub}</div>
|
||||
</Panel>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<Panel className="overflow-hidden p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-white/10 pb-5">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-2xl bg-violet-400/15 text-violet-200"><TerminalSquare className="h-5 w-5" /></div>
|
||||
<div>
|
||||
<h2 className="text-xl font-black tracking-[-0.04em] text-white">执行记录只读中心</h2>
|
||||
<p className="mt-1 text-sm font-semibold text-slate-500">接入 /api/execution-records;V2 只展示、筛选和脱敏,不执行/停止/重试命令。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge tone={data.mode === 'live' ? 'green' : 'amber'} dot>{data.mode === 'live' ? '实时 API' : '降级数据'}</Badge>
|
||||
<Badge tone={mode === 'live' ? 'blue' : 'slate'}>Dashboard {mode}</Badge>
|
||||
<Badge tone="purple"><LockKeyhole className="mr-1 h-3 w-3" />敏感字段已隐藏</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.warning ? <div className="mt-4 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm font-semibold text-amber-100">{data.warning}</div> : null}
|
||||
|
||||
<div className="mt-5 grid gap-3 xl:grid-cols-[1fr_auto_auto]">
|
||||
<label className="focus-within:ring-nexus flex items-center gap-3 rounded-2xl border border-white/10 bg-slate-950/50 px-4 py-3 text-sm font-semibold text-slate-500">
|
||||
<Search className="h-4 w-4" />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索任务名 / 操作者 / 操作 / ID" className="w-full bg-transparent text-slate-100 outline-none placeholder:text-slate-600" />
|
||||
</label>
|
||||
<label className="flex items-center gap-2 rounded-2xl border border-white/10 bg-slate-950/50 px-4 py-3 text-sm font-bold text-slate-400">
|
||||
<Filter className="h-4 w-4" />
|
||||
<select value={kind} onChange={(event) => setKind(event.target.value as FilterKind)} className="bg-transparent text-slate-100 outline-none">
|
||||
<option value="all">全部类型</option>
|
||||
<option value="script">脚本执行</option>
|
||||
<option value="server_batch">服务器批量任务</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 rounded-2xl border border-white/10 bg-slate-950/50 px-4 py-3 text-sm font-bold text-slate-400">
|
||||
<Clock3 className="h-4 w-4" />
|
||||
<select value={status} onChange={(event) => setStatus(event.target.value)} className="bg-transparent text-slate-100 outline-none">
|
||||
<option value="all">全部状态</option>
|
||||
{statusOptions.map((option) => <option key={option} value={option}>{statusLabel(option)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 overflow-x-auto rounded-3xl border border-white/10">
|
||||
<table className="min-w-full divide-y divide-white/10">
|
||||
<thead className="bg-white/[0.03]">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th key={header.id} className="px-5 py-4 text-left text-xs font-black uppercase tracking-[0.14em] text-slate-500">
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10 bg-slate-950/20">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr key={row.id} className="transition hover:bg-white/[0.03]">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id} className="px-5 py-4 align-top">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{filteredItems.length === 0 ? <div className="px-5 py-12 text-center text-sm font-bold text-slate-500">没有符合条件的执行记录</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-center justify-between gap-3 rounded-3xl border border-cyan-300/10 bg-cyan-400/5 p-4">
|
||||
<div className="text-sm font-semibold leading-6 text-slate-400">
|
||||
写操作仍保留在旧后台:执行脚本、停止任务、重试任务、查看完整日志都从旧后台进入,避免 V2 早期迁移误触发远程 shell。
|
||||
</div>
|
||||
<a href="/app/scripts" className="focus-ring inline-flex items-center gap-2 rounded-2xl border border-cyan-300/25 bg-cyan-400/15 px-4 py-3 text-sm font-black text-cyan-100">
|
||||
去旧后台处理 <ArrowUpRight className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { ArrowUpRight, BookOpen, Database, Filter, KeyRound, Search, Server, ShieldCheck, Sparkles, TimerReset } from 'lucide-react'
|
||||
import { Badge } from '~components/ui/Badge'
|
||||
import { Panel } from '~components/ui/Panel'
|
||||
import { hasSensitiveMarker } from '@/lib/redaction'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { fetchGlobalSearchData } from '../api/dashboardApi'
|
||||
import type { GlobalSearchData, GlobalSearchEntity, GlobalSearchItem } from '../api/dashboardApi'
|
||||
import type { ServerAsset } from '../data/dashboardData'
|
||||
|
||||
interface GlobalSearchPageProps {
|
||||
servers: ServerAsset[]
|
||||
total: number
|
||||
mode: 'live' | 'fallback'
|
||||
}
|
||||
|
||||
type SearchGroupKey = 'servers' | 'scripts' | 'credentials' | 'schedules'
|
||||
|
||||
type Tone = 'green' | 'blue' | 'amber' | 'red' | 'slate' | 'purple'
|
||||
|
||||
interface SearchGroupMeta {
|
||||
key: SearchGroupKey
|
||||
title: string
|
||||
subtitle: string
|
||||
icon: typeof Server
|
||||
tone: Tone
|
||||
legacyPath: string
|
||||
}
|
||||
|
||||
const groups: SearchGroupMeta[] = [
|
||||
{ key: 'servers', title: '服务器', subtitle: '名称、域名、分组、在线状态', icon: Server, tone: 'blue', legacyPath: '/app/servers' },
|
||||
{ key: 'scripts', title: '脚本库', subtitle: '脚本名称、分类、描述', icon: BookOpen, tone: 'purple', legacyPath: '/app/scripts' },
|
||||
{ key: 'credentials', title: '凭据元数据', subtitle: '只显示名称、类型、主机,不展示密码', icon: KeyRound, tone: 'amber', legacyPath: '/app/settings' },
|
||||
{ key: 'schedules', title: '计划任务', subtitle: '计划名称、Cron 表达式、启停状态', icon: TimerReset, tone: 'green', legacyPath: '/app/tasks' },
|
||||
]
|
||||
|
||||
function normalizeQuery(value: string): string {
|
||||
return value.trim().replace(/\s+/g, ' ').slice(0, 100)
|
||||
}
|
||||
|
||||
function itemTitle(item: GlobalSearchItem, entity: GlobalSearchEntity): string {
|
||||
if (entity === 'servers') return String(item.name || item.domain || '未命名服务器')
|
||||
if (entity === 'scripts') return String(item.name || '未命名脚本')
|
||||
if (entity === 'credentials') return String(item.name || '未命名凭据')
|
||||
return String(item.name || '未命名计划')
|
||||
}
|
||||
|
||||
function itemSubtitle(item: GlobalSearchItem, entity: GlobalSearchEntity): string {
|
||||
if (entity === 'servers') return [item.domain, item.category, item.is_online ? '在线' : '离线/未知'].filter(Boolean).join(' · ')
|
||||
if (entity === 'scripts') return [item.category, '只读跳转'].filter(Boolean).join(' · ')
|
||||
if (entity === 'credentials') return [item.db_type, item.host ? '主机:' + String(item.host) : undefined, '不展示敏感值'].filter(Boolean).join(' · ')
|
||||
return [item.cron_expr, item.enabled === true ? '已启用' : item.enabled === false ? '已停用' : undefined].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
function itemRouteHint(entity: GlobalSearchEntity, item: GlobalSearchItem): string {
|
||||
if (entity === 'servers') return '/app/servers' + (item.id ? '?highlight=' + encodeURIComponent(String(item.id)) : '')
|
||||
if (entity === 'scripts') return '/app/scripts'
|
||||
if (entity === 'credentials') return '/app/settings'
|
||||
return '/app/tasks'
|
||||
}
|
||||
|
||||
function visibleItems(data: GlobalSearchData, key: SearchGroupKey): GlobalSearchItem[] {
|
||||
return data[key].slice(0, 10)
|
||||
}
|
||||
|
||||
function queryFingerprint(value: string): string {
|
||||
let hash = 2166136261
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index)
|
||||
hash = Math.imul(hash, 16777619)
|
||||
}
|
||||
return value.length + ':' + (hash >>> 0).toString(36)
|
||||
}
|
||||
|
||||
export function GlobalSearchPage({ servers, total, mode }: GlobalSearchPageProps): JSX.Element {
|
||||
const [rawQuery, setRawQuery] = useState('')
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('')
|
||||
const normalizedQuery = normalizeQuery(debouncedQuery)
|
||||
const blockedQuery = hasSensitiveMarker(normalizedQuery)
|
||||
const normalizedQueryKey = blockedQuery ? 'blocked-sensitive-query' : queryFingerprint(normalizedQuery)
|
||||
|
||||
useEffect(() => {
|
||||
const handle = window.setTimeout(() => setDebouncedQuery(rawQuery), 350)
|
||||
return () => window.clearTimeout(handle)
|
||||
}, [rawQuery])
|
||||
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: ['global-search', normalizedQueryKey],
|
||||
queryFn: () => fetchGlobalSearchData(blockedQuery ? '' : normalizedQuery),
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
})
|
||||
|
||||
const localMatches = useMemo(() => {
|
||||
const query = normalizeQuery(rawQuery).toLowerCase()
|
||||
if (query.length < 2 || hasSensitiveMarker(query)) return []
|
||||
return servers
|
||||
.filter((server) => [server.name, server.ip, server.region, server.risk, server.ttl].some((value) => String(value || '').toLowerCase().includes(query)))
|
||||
.slice(0, 8)
|
||||
}, [rawQuery, servers])
|
||||
|
||||
const hasQuery = normalizedQuery.length >= 2 && !blockedQuery
|
||||
const hasServerFallback = localMatches.length > 0 && visibleItems(data, 'servers').length === 0
|
||||
const handleQueryChange = useCallback((event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
setRawQuery(event.target.value)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div id="global-search" className="space-y-6 px-5 pb-5 lg:px-10 lg:pb-10">
|
||||
<Panel className="relative overflow-hidden p-6 lg:p-8">
|
||||
<div className="absolute -right-20 -top-20 h-72 w-72 rounded-full bg-cyan-400/10 blur-3xl" />
|
||||
<div className="relative grid gap-6 xl:grid-cols-[minmax(0,1fr)_24rem]">
|
||||
<div>
|
||||
<Badge tone="blue" dot className="px-4 py-2">全局只读搜索</Badge>
|
||||
<h1 className="mt-5 text-3xl font-black tracking-tight text-white md:text-4xl">跨服务器 / 脚本 / 凭据元数据 / 计划任务快速定位</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm font-semibold leading-7 text-slate-400">
|
||||
复用后端 <span className="font-mono text-cyan-200">GET /api/search/</span>,输入防通配符膨胀由后端 escape 兜底。V2 当前只读展示搜索结果,不展示密码、Cookie、Token、密钥类字段、私钥或宝塔 tmp_login。
|
||||
</p>
|
||||
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
|
||||
<label className="focus-within:ring-cyan-300/40 flex min-h-14 flex-1 items-center gap-3 rounded-3xl border border-white/10 bg-slate-950/70 px-5 ring-0 transition focus-within:ring-4">
|
||||
<Search className="h-5 w-5 text-cyan-300" />
|
||||
<input
|
||||
value={rawQuery}
|
||||
onChange={handleQueryChange}
|
||||
placeholder="搜索 IP、服务器名、脚本、凭据名称、计划任务..."
|
||||
className="h-12 flex-1 bg-transparent text-sm font-bold text-white outline-none placeholder:text-slate-600"
|
||||
/>
|
||||
</label>
|
||||
<a href="/app/" className="focus-ring inline-flex items-center justify-center gap-2 rounded-3xl border border-white/10 bg-white/[0.04] px-5 py-4 text-sm font-black text-cyan-100 hover:bg-cyan-400/10">
|
||||
<ArrowUpRight className="h-4 w-4" /> 旧后台完整搜索
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
|
||||
<MetricCard icon={Sparkles} label="搜索模式" value={data.mode === 'live' ? '真实 API' : '演示兜底'} tone="blue" />
|
||||
<MetricCard icon={Database} label="结果总数" value={String(data.total)} tone="green" />
|
||||
<MetricCard icon={ShieldCheck} label="资产来源" value={mode === 'live' ? String(total) + ' 台' : '演示数据'} tone="purple" />
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{data.warning ? <Notice tone="amber" title="已切换演示数据" message={data.warning} /> : null}
|
||||
{!hasQuery ? <Notice tone="blue" title="输入提示" message="请输入至少 2 个字符开始搜索;前端会做 350ms 防抖,避免每次按键都请求后端。" /> : null}
|
||||
{hasServerFallback ? <Notice tone="green" title="本地资产命中" message="后端搜索没有返回服务器结果,但 V2 当前已加载资产表中有匹配项,下面会单独显示本地命中。" /> : null}
|
||||
|
||||
{hasServerFallback ? (
|
||||
<Panel className="p-6">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-black text-white">本地已加载服务器命中</h2>
|
||||
<p className="mt-1 text-sm font-semibold text-slate-500">来自当前 Dashboard 资产缓存,只读展示,不触发写操作。</p>
|
||||
</div>
|
||||
<Badge tone="blue">{localMatches.length} 条</Badge>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{localMatches.map((server) => (
|
||||
<div key={server.id} className="rounded-3xl border border-white/10 bg-white/[0.03] p-4">
|
||||
<div className="font-black text-white">{server.name}</div>
|
||||
<div className="mt-1 font-mono text-xs text-slate-500">{server.ip}</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Badge tone={server.online ? 'green' : 'slate'}>{server.online ? '在线' : '离线/未知'}</Badge>
|
||||
<Badge tone="slate">{server.region}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
) : null}
|
||||
|
||||
<section className="grid gap-5 xl:grid-cols-2">
|
||||
{groups.map((group) => (
|
||||
<SearchGroupPanel key={group.key} group={group} items={visibleItems(data, group.key)} query={normalizedQuery} />
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MetricCardProps {
|
||||
icon: typeof Search
|
||||
label: string
|
||||
value: string
|
||||
tone: Tone
|
||||
}
|
||||
|
||||
function MetricCard({ icon: Icon, label, value, tone }: MetricCardProps): JSX.Element {
|
||||
const toneClass = tone === 'green'
|
||||
? 'from-emerald-400/20 to-emerald-400/5 text-emerald-200'
|
||||
: tone === 'purple'
|
||||
? 'from-violet-400/20 to-violet-400/5 text-violet-200'
|
||||
: 'from-cyan-400/20 to-cyan-400/5 text-cyan-200'
|
||||
return (
|
||||
<div className={cn('rounded-3xl border border-white/10 bg-gradient-to-br p-5', toneClass)}>
|
||||
<Icon className="h-5 w-5" />
|
||||
<div className="mt-4 text-xs font-black uppercase tracking-[0.2em] text-slate-500">{label}</div>
|
||||
<div className="mt-1 text-2xl font-black text-white">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface NoticeProps {
|
||||
tone: Tone
|
||||
title: string
|
||||
message: string
|
||||
}
|
||||
|
||||
function Notice({ tone, title, message }: NoticeProps): JSX.Element {
|
||||
const className = tone === 'green'
|
||||
? 'border-emerald-400/20 bg-emerald-400/10 text-emerald-100'
|
||||
: tone === 'blue'
|
||||
? 'border-cyan-400/20 bg-cyan-400/10 text-cyan-100'
|
||||
: 'border-amber-400/20 bg-amber-400/10 text-amber-100'
|
||||
return (
|
||||
<div className={cn('rounded-3xl border px-5 py-4 text-sm font-semibold', className)}>
|
||||
<span className="font-black">{title}:</span>{message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SearchGroupPanelProps {
|
||||
group: SearchGroupMeta
|
||||
items: GlobalSearchItem[]
|
||||
query: string
|
||||
}
|
||||
|
||||
function SearchGroupPanel({ group, items, query }: SearchGroupPanelProps): JSX.Element {
|
||||
const Icon = group.icon
|
||||
return (
|
||||
<Panel className="overflow-hidden p-0">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-white/10 p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="grid h-12 w-12 place-items-center rounded-2xl border border-white/10 bg-white/[0.04] text-cyan-200">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-black text-white">{group.title}</h2>
|
||||
<p className="mt-1 text-sm font-semibold text-slate-500">{group.subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge tone={group.tone}>{items.length} 条</Badge>
|
||||
</div>
|
||||
<div className="space-y-3 p-4">
|
||||
{items.length > 0 ? items.map((item) => (
|
||||
<a
|
||||
key={group.key + '-' + String(item.id)}
|
||||
href={itemRouteHint(group.key, item)}
|
||||
className="focus-ring group flex items-start justify-between gap-3 rounded-3xl border border-white/10 bg-slate-950/40 p-4 transition hover:border-cyan-300/30 hover:bg-cyan-400/10"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-black text-white">{itemTitle(item, group.key)}</div>
|
||||
<div className="mt-1 line-clamp-2 text-xs font-semibold leading-5 text-slate-500">{itemSubtitle(item, group.key)}</div>
|
||||
</div>
|
||||
<ArrowUpRight className="mt-1 h-4 w-4 shrink-0 text-slate-600 transition group-hover:text-cyan-200" />
|
||||
</a>
|
||||
)) : (
|
||||
<div className="rounded-3xl border border-dashed border-white/10 bg-white/[0.02] px-5 py-8 text-center">
|
||||
<Filter className="mx-auto h-6 w-6 text-slate-600" />
|
||||
<div className="mt-3 text-sm font-black text-slate-400">{query.length >= 2 ? '暂无命中' : '等待输入'}</div>
|
||||
<p className="mt-1 text-xs font-semibold leading-5 text-slate-600">{query.length >= 2 ? '可以换一个关键词,或打开旧后台继续使用完整功能。' : '输入至少 2 个字符后开始查询。'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Panel } from '~components/ui/Panel'
|
||||
import type { ServerAsset } from '../data/dashboardData'
|
||||
|
||||
interface HealthScoreProps {
|
||||
servers: ServerAsset[]
|
||||
}
|
||||
|
||||
function calculateScore(servers: ServerAsset[]): number {
|
||||
if (servers.length === 0) return 0
|
||||
const onlineRate = servers.filter((server) => server.online !== false).length / servers.length
|
||||
const ttlRate = servers.filter((server) => server.btSessionCleanupOk || server.btSessionCleanupPatched || server.ttl === '86400s').length / servers.length
|
||||
const critical = servers.filter((server) => server.risk === 'critical').length
|
||||
const warning = servers.filter((server) => server.risk === 'warning').length
|
||||
const score = Math.round(onlineRate * 45 + ttlRate * 45 + Math.max(0, 10 - critical * 4 - warning * 2))
|
||||
return Math.max(0, Math.min(100, score))
|
||||
}
|
||||
|
||||
export function HealthScore({ servers }: HealthScoreProps): JSX.Element {
|
||||
const score = calculateScore(servers)
|
||||
const highRisk = servers.filter((server) => server.risk === 'critical').length
|
||||
const fixedToday = servers.filter((server) => server.btSessionCleanupPatched || server.btSessionCleanupOk).length
|
||||
|
||||
return (
|
||||
<Panel className="flex items-center justify-between p-5">
|
||||
<div>
|
||||
<div className="text-xs font-bold text-slate-400">健康评分</div>
|
||||
<div className="mt-3 flex items-end gap-3">
|
||||
<div className="text-5xl font-black tracking-[-0.08em] text-white">{score}</div>
|
||||
<div className="pb-2 text-xs font-bold text-emerald-300">HEALTH</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="soft-panel rounded-2xl p-3">
|
||||
<div className="text-xs font-bold text-slate-500">高危</div>
|
||||
<div className="mt-1 text-2xl font-black text-red-300">{highRisk}</div>
|
||||
</div>
|
||||
<div className="soft-panel rounded-2xl p-3">
|
||||
<div className="text-xs font-bold text-slate-500">已修复</div>
|
||||
<div className="mt-1 text-2xl font-black text-emerald-300">{fixedToday}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { flexRender, getCoreRowModel, getSortedRowModel, useReactTable } from '@tanstack/react-table'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { ArrowUpRight, Clock3, FileText, History, LockKeyhole, Search, Server, ShieldCheck, TerminalSquare, UserRound, Wifi } from 'lucide-react'
|
||||
import { Badge } from '~components/ui/Badge'
|
||||
import { Panel } from '~components/ui/Panel'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { hasSensitiveMarker, redactSensitiveText } from '@/lib/redaction'
|
||||
import { fetchOperationLogsData } from '../api/dashboardApi'
|
||||
import type { OperationCommandLogItem, OperationSshSessionItem } from '../api/dashboardApi'
|
||||
import type { ServerAsset } from '../data/dashboardData'
|
||||
|
||||
interface OperationLogsPageProps {
|
||||
servers: ServerAsset[]
|
||||
total: number
|
||||
mode: 'live' | 'fallback'
|
||||
}
|
||||
|
||||
type SessionStatusTone = 'green' | 'blue' | 'amber' | 'red' | 'slate' | 'purple'
|
||||
|
||||
function formatDate(value?: string | null): string {
|
||||
if (!value) return '-'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return String(value)
|
||||
return date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
function durationText(start?: string | null, end?: string | null): string {
|
||||
if (!start) return '-'
|
||||
const startTime = Date.parse(start)
|
||||
const endTime = end ? Date.parse(end) : Date.now()
|
||||
if (!Number.isFinite(startTime) || !Number.isFinite(endTime)) return '-'
|
||||
const minutes = Math.max(0, Math.round((endTime - startTime) / 60000))
|
||||
if (minutes < 1) return '<1 分钟'
|
||||
if (minutes < 60) return String(minutes) + ' 分钟'
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const rest = minutes % 60
|
||||
return rest ? String(hours) + ' 小时 ' + String(rest) + ' 分钟' : String(hours) + ' 小时'
|
||||
}
|
||||
|
||||
function redactCommand(value?: string | null): string {
|
||||
return redactSensitiveText(value, '-', 240)
|
||||
}
|
||||
|
||||
|
||||
function statusTone(status?: string | null): SessionStatusTone {
|
||||
const value = String(status || '').toLowerCase()
|
||||
if (['active', 'open', 'running', 'connected'].some((item) => value.includes(item))) return 'green'
|
||||
if (['closed', 'finished', 'done'].some((item) => value.includes(item))) return 'slate'
|
||||
if (['failed', 'error', 'timeout'].some((item) => value.includes(item))) return 'red'
|
||||
if (['idle', 'waiting'].some((item) => value.includes(item))) return 'amber'
|
||||
return 'blue'
|
||||
}
|
||||
|
||||
function sessionLabel(status?: string | null): string {
|
||||
return String(status || 'unknown')
|
||||
}
|
||||
|
||||
function hasRedaction(command?: string | null): boolean {
|
||||
return hasSensitiveMarker(command)
|
||||
}
|
||||
|
||||
|
||||
function commandSearchText(item: OperationCommandLogItem): string {
|
||||
return [item.id, item.session_id, item.server_id, item.server_name, item.admin_username, item.remote_addr, item.command]
|
||||
.map((value) => String(value || '').toLowerCase())
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
export function OperationLogsPage({ servers, total, mode }: OperationLogsPageProps): JSX.Element {
|
||||
const [selectedServer, setSelectedServer] = useState('all')
|
||||
const [query, setQuery] = useState('')
|
||||
const [redactedOnly, setRedactedOnly] = useState(false)
|
||||
const selectedServerId = selectedServer === 'all' ? undefined : Number(selectedServer)
|
||||
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: ['operation-logs-v2', selectedServerId ?? 'all'],
|
||||
queryFn: () => fetchOperationLogsData(selectedServerId),
|
||||
staleTime: 20_000,
|
||||
})
|
||||
|
||||
const selectableServers = useMemo(() => servers.filter((server) => typeof server.sourceId === 'number'), [servers])
|
||||
|
||||
const filteredCommands = useMemo(() => {
|
||||
const text = query.trim().toLowerCase()
|
||||
return data.commands.filter((item) => {
|
||||
const textMatched = !text || commandSearchText(item).includes(text)
|
||||
const redactedMatched = !redactedOnly || hasRedaction(item.command)
|
||||
return textMatched && redactedMatched
|
||||
})
|
||||
}, [data.commands, query, redactedOnly])
|
||||
|
||||
const activeSessions = useMemo(() => data.sessions.filter((item) => statusTone(item.status) === 'green').length, [data.sessions])
|
||||
const redactedCommands = useMemo(() => data.commands.filter((item) => hasRedaction(item.command)).length, [data.commands])
|
||||
|
||||
const columns = useMemo<ColumnDef<OperationCommandLogItem>[]>(() => [
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: '时间',
|
||||
cell: ({ row }) => (
|
||||
<div className="min-w-36 text-sm font-semibold leading-6 text-slate-400">
|
||||
<div className="flex items-center gap-2 text-slate-200"><Clock3 className="h-4 w-4 text-slate-600" />{formatDate(row.original.created_at)}</div>
|
||||
<div className="text-xs text-slate-600">#{row.original.id}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'server_name',
|
||||
header: '服务器 / 会话',
|
||||
cell: ({ row }) => (
|
||||
<div className="min-w-48 text-sm font-semibold leading-6 text-slate-400">
|
||||
<div className="flex items-center gap-2 text-white"><Server className="h-4 w-4 text-cyan-300" />{row.original.server_name || ('#' + String(row.original.server_id || '-'))}</div>
|
||||
<div className="font-mono text-xs text-slate-600">{row.original.session_id || '-'}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'admin_username',
|
||||
header: '操作者',
|
||||
cell: ({ row }) => (
|
||||
<div className="min-w-32 text-sm font-semibold leading-6 text-slate-400">
|
||||
<div className="flex items-center gap-2"><UserRound className="h-4 w-4 text-slate-600" />{row.original.admin_username || ('admin#' + String(row.original.admin_id || '-'))}</div>
|
||||
<div className="font-mono text-xs text-slate-600">{row.original.remote_addr || '-'}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'command',
|
||||
header: '命令摘要',
|
||||
cell: ({ row }) => {
|
||||
const command = redactCommand(row.original.command)
|
||||
return (
|
||||
<div className="min-w-[32rem]">
|
||||
<code className="block rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 font-mono text-xs leading-6 text-slate-200">{command}</code>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Badge tone={hasRedaction(command) ? 'amber' : 'green'} dot>{hasRedaction(command) ? '已做敏感片段脱敏' : '未命中敏感片段'}</Badge>
|
||||
<Badge tone="slate">V2 只读</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
], [])
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredCommands,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-5 lg:p-10">
|
||||
<Panel className="overflow-hidden p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-white/10 pb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-2xl bg-emerald-400/15 text-emerald-200"><History className="h-5 w-5" /></div>
|
||||
<div>
|
||||
<h2 className="text-xl font-black tracking-[-0.04em] text-white">SSH 会话 / 命令日志只读中心</h2>
|
||||
<p className="mt-1 text-sm font-semibold text-slate-500">V2 仅读取 /api/assets/ssh-sessions 与 /api/assets/command-logs?redact=true;不执行命令、不打开 WebSSH、不暴露敏感参数。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge tone={data.mode === 'live' ? 'green' : 'amber'} dot>{data.mode === 'live' ? '实时 API' : '降级数据'}</Badge>
|
||||
<Badge tone={mode === 'live' ? 'blue' : 'slate'}>Dashboard {mode}</Badge>
|
||||
<Badge tone="purple"><LockKeyhole className="mr-1 h-3 w-3" />服务端脱敏</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.warning ? <div className="mt-4 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm font-semibold text-amber-100">{data.warning}</div> : null}
|
||||
|
||||
<div className="mt-5 grid gap-4 md:grid-cols-4">
|
||||
<MetricCard icon={TerminalSquare} label="SSH 会话" value={String(data.sessionTotal)} hint={String(activeSessions) + ' 个活动中'} tone="text-emerald-300" />
|
||||
<MetricCard icon={FileText} label="命令记录" value={String(data.commandTotal)} hint="当前读取最近 120 条" tone="text-cyan-300" />
|
||||
<MetricCard icon={ShieldCheck} label="脱敏命令" value={String(redactedCommands)} hint="服务端 + 前端双层兜底" tone="text-amber-300" />
|
||||
<MetricCard icon={Server} label="资产规模" value={String(total)} hint="可按服务器筛选" tone="text-violet-300" />
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 xl:grid-cols-[1fr_auto_auto]">
|
||||
<label className="focus-within:ring-nexus flex items-center gap-3 rounded-2xl border border-white/10 bg-slate-950/50 px-4 py-3 text-sm font-semibold text-slate-500">
|
||||
<Search className="h-4 w-4" />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索命令 / 服务器 / 会话 / 操作者 / 来源 IP" className="w-full bg-transparent text-slate-100 outline-none placeholder:text-slate-600" />
|
||||
</label>
|
||||
<label className="flex items-center gap-2 rounded-2xl border border-white/10 bg-slate-950/50 px-4 py-3 text-sm font-bold text-slate-400">
|
||||
<Server className="h-4 w-4" />
|
||||
<select value={selectedServer} onChange={(event) => setSelectedServer(event.target.value)} className="max-w-64 bg-transparent text-slate-100 outline-none">
|
||||
<option value="all">全部服务器</option>
|
||||
{selectableServers.map((server) => <option key={server.sourceId} value={server.sourceId}>{server.name} / {server.ip}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded-2xl border border-white/10 bg-slate-950/50 px-4 py-3 text-sm font-bold text-slate-300">
|
||||
<input type="checkbox" checked={redactedOnly} onChange={(event) => setRedactedOnly(event.target.checked)} className="h-4 w-4 accent-cyan-400" />
|
||||
只看已脱敏命令
|
||||
</label>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-[25rem_minmax(0,1fr)]">
|
||||
<Panel className="p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-black text-white">最近 SSH 会话</h3>
|
||||
<p className="mt-1 text-xs font-semibold text-slate-500">仅展示会话元数据,不建立交互终端。</p>
|
||||
</div>
|
||||
<Badge tone="blue" dot>{data.sessions.length} 条</Badge>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{data.sessions.map((session) => <SessionCard key={session.id} session={session} />)}
|
||||
{data.sessions.length === 0 ? <div className="rounded-3xl border border-white/10 bg-slate-950/40 p-5 text-center text-sm font-bold text-slate-500">暂无 SSH 会话记录</div> : null}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel className="overflow-hidden p-6">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-black text-white">命令日志摘要</h3>
|
||||
<p className="mt-1 text-xs font-semibold text-slate-500">后端 redact=true 先脱敏,前端再次兜底;V2 不提供执行、重放、复制敏感正文能力。</p>
|
||||
</div>
|
||||
<Badge tone="green" dot>{filteredCommands.length} / {data.commandTotal}</Badge>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-3xl border border-white/10">
|
||||
<table className="min-w-full divide-y divide-white/10">
|
||||
<thead className="bg-white/[0.03]">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th key={header.id} className="px-5 py-4 text-left text-xs font-black uppercase tracking-[0.14em] text-slate-500">
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10 bg-slate-950/20">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr key={row.id} className="transition hover:bg-white/[0.03]">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id} className="px-5 py-4 align-top">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{filteredCommands.length === 0 ? <div className="px-5 py-12 text-center text-sm font-bold text-slate-500">没有符合条件的命令日志</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-center justify-between gap-3 rounded-3xl border border-emerald-300/10 bg-emerald-400/5 p-4">
|
||||
<div className="text-sm font-semibold leading-6 text-slate-400">
|
||||
V2 只做审计查看;真实 WebSSH、命令执行、会话回放、日志导出和权限变更仍回旧后台,继续走原鉴权、审计与风险控制。
|
||||
</div>
|
||||
<a href="/app/" className="focus-ring inline-flex items-center gap-2 rounded-2xl border border-emerald-300/25 bg-emerald-400/15 px-4 py-3 text-sm font-black text-emerald-100">
|
||||
回旧后台处理写操作 <ArrowUpRight className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</Panel>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MetricCardProps {
|
||||
icon: typeof TerminalSquare
|
||||
label: string
|
||||
value: string
|
||||
hint: string
|
||||
tone: string
|
||||
}
|
||||
|
||||
function MetricCard({ icon: Icon, label, value, hint, tone }: MetricCardProps): JSX.Element {
|
||||
return (
|
||||
<div className="rounded-3xl border border-white/10 bg-slate-950/40 p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-black text-slate-400">{label}</div>
|
||||
<Icon className={cn('h-5 w-5', tone)} />
|
||||
</div>
|
||||
<div className={cn('mt-4 text-3xl font-black tracking-[-0.05em]', tone)}>{value}</div>
|
||||
<div className="mt-2 text-xs font-semibold text-slate-500">{hint}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionCard({ session }: { session: OperationSshSessionItem }): JSX.Element {
|
||||
const tone = statusTone(session.status)
|
||||
return (
|
||||
<div className="rounded-3xl border border-white/10 bg-slate-950/40 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Badge tone={tone} dot>{sessionLabel(session.status)}</Badge>
|
||||
<span className="font-mono text-xs font-bold text-slate-600">{session.id}</span>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2 text-sm font-black text-white"><Server className="h-4 w-4 text-cyan-300" />{session.server_name || ('#' + String(session.server_id || '-'))}</div>
|
||||
<div className="mt-2 grid gap-2 text-xs font-semibold text-slate-500">
|
||||
<div className="flex items-center gap-2"><Wifi className="h-3.5 w-3.5" />来源:{session.remote_addr || '-'}</div>
|
||||
<div className="flex items-center gap-2"><Clock3 className="h-3.5 w-3.5" />开始:{formatDate(session.started_at)}</div>
|
||||
<div className="flex items-center gap-2"><Clock3 className="h-3.5 w-3.5" />时长:{durationText(session.started_at, session.closed_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { ArrowUp, ExternalLink, File, FolderOpen, HardDrive, LockKeyhole, RefreshCw, Search, Server } from 'lucide-react'
|
||||
import { Badge } from '~components/ui/Badge'
|
||||
import { Panel } from '~components/ui/Panel'
|
||||
import { redactFilePathText } from '@/lib/redaction'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { fetchFileBrowseData } from '../api/dashboardApi'
|
||||
import type { RemoteFileItem } from '../api/dashboardApi'
|
||||
import type { ServerAsset } from '../data/dashboardData'
|
||||
|
||||
interface ReadOnlyFileBrowserProps {
|
||||
servers: ServerAsset[]
|
||||
mode: 'live' | 'fallback'
|
||||
}
|
||||
|
||||
function normalizePathInput(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return '/'
|
||||
return trimmed.startsWith('/') ? trimmed : '/' + trimmed
|
||||
}
|
||||
|
||||
function joinRemotePath(base: string, name: string): string {
|
||||
const normalizedBase = normalizePathInput(base)
|
||||
if (normalizedBase === '/') return '/' + name
|
||||
return normalizedBase.replace(/\/+$/g, '') + '/' + name
|
||||
}
|
||||
|
||||
function parentRemotePath(path: string): string {
|
||||
const normalized = normalizePathInput(path).replace(/\/+$/g, '')
|
||||
if (!normalized || normalized === '/') return '/'
|
||||
const index = normalized.lastIndexOf('/')
|
||||
return index <= 0 ? '/' : normalized.slice(0, index)
|
||||
}
|
||||
|
||||
function pathFingerprint(value: string): string {
|
||||
let hash = 2166136261
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index)
|
||||
hash = Math.imul(hash, 16777619)
|
||||
}
|
||||
return value.length + ':' + (hash >>> 0).toString(36)
|
||||
}
|
||||
|
||||
function formatSize(value?: number | null): string {
|
||||
const size = typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||
if (size < 1024) return String(size) + ' B'
|
||||
if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB'
|
||||
return (size / 1024 / 1024).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
function formatFileTime(value?: string | null): string {
|
||||
if (!value) return '-'
|
||||
if (value === 'demo') return '演示数据'
|
||||
const parsed = Date.parse(value)
|
||||
if (!Number.isFinite(parsed)) return value
|
||||
return new Date(parsed).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
function itemTone(item: RemoteFileItem): 'green' | 'blue' | 'amber' | 'red' | 'slate' | 'purple' {
|
||||
if (item.type === 'directory') return 'blue'
|
||||
if (item.type === 'symlink') return 'purple'
|
||||
const name = item.name.toLowerCase()
|
||||
if (name.endsWith('.sh') || name.endsWith('.py') || name.endsWith('.js')) return 'amber'
|
||||
return 'slate'
|
||||
}
|
||||
|
||||
function sortItems(items: RemoteFileItem[]): RemoteFileItem[] {
|
||||
return [...items].sort((a, b) => {
|
||||
if (a.type === 'directory' && b.type !== 'directory') return -1
|
||||
if (a.type !== 'directory' && b.type === 'directory') return 1
|
||||
return a.name.localeCompare(b.name, 'zh-CN')
|
||||
})
|
||||
}
|
||||
|
||||
export function ReadOnlyFileBrowser({ servers, mode }: ReadOnlyFileBrowserProps): JSX.Element {
|
||||
const browseableServers = useMemo(() => servers.filter((server) => server.sourceId && server.online !== false), [servers])
|
||||
const [serverId, setServerId] = useState<number | undefined>(browseableServers[0]?.sourceId)
|
||||
const [pathInput, setPathInput] = useState('/')
|
||||
const [browsePath, setBrowsePath] = useState('/')
|
||||
const redactedBrowsePath = redactFilePathText(browsePath, '/', 180)
|
||||
const browsePathKey = redactedBrowsePath.includes('[HIDDEN]') ? 'blocked-sensitive-path' : pathFingerprint(browsePath)
|
||||
const [nameQuery, setNameQuery] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (serverId && browseableServers.some((server) => server.sourceId === serverId)) return
|
||||
setServerId(browseableServers[0]?.sourceId)
|
||||
}, [browseableServers, serverId])
|
||||
|
||||
const selectedServer = useMemo(() => browseableServers.find((server) => server.sourceId === serverId), [browseableServers, serverId])
|
||||
const { data, refetch } = useSuspenseQuery({
|
||||
queryKey: ['readonly-file-browser-v2', serverId || 0, browsePathKey],
|
||||
queryFn: () => fetchFileBrowseData(serverId, browsePath),
|
||||
staleTime: 10_000,
|
||||
})
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
const text = nameQuery.trim().toLowerCase()
|
||||
return sortItems(data.items).filter((item) => !text || [item.name, item.type, item.owner, item.group, item.permissions].some((value) => String(value || '').toLowerCase().includes(text)))
|
||||
}, [data.items, nameQuery])
|
||||
|
||||
const directoryCount = useMemo(() => data.items.filter((item) => item.type === 'directory').length, [data.items])
|
||||
const fileCount = useMemo(() => data.items.filter((item) => item.type !== 'directory').length, [data.items])
|
||||
const totalSize = useMemo(() => data.items.reduce((sum, item) => sum + (item.type === 'file' ? (item.size || 0) : 0), 0), [data.items])
|
||||
|
||||
const handleBrowse = useCallback((): void => {
|
||||
const nextPath = normalizePathInput(pathInput)
|
||||
setBrowsePath(nextPath)
|
||||
setPathInput(nextPath)
|
||||
}, [pathInput])
|
||||
|
||||
const handleServerChange = useCallback((value: string): void => {
|
||||
const nextServerId = Number(value) || undefined
|
||||
setServerId(nextServerId)
|
||||
}, [])
|
||||
|
||||
const handleDirectoryOpen = useCallback((item: RemoteFileItem): void => {
|
||||
if (item.type !== 'directory' || item.name === '[HIDDEN]') return
|
||||
const nextPath = joinRemotePath(browsePath, item.name)
|
||||
setBrowsePath(nextPath)
|
||||
setPathInput(nextPath)
|
||||
}, [browsePath])
|
||||
|
||||
const handleParent = useCallback((): void => {
|
||||
const nextPath = parentRemotePath(browsePath)
|
||||
setBrowsePath(nextPath)
|
||||
setPathInput(nextPath)
|
||||
}, [browsePath])
|
||||
|
||||
const handleRefresh = useCallback((): void => {
|
||||
void refetch()
|
||||
}, [refetch])
|
||||
|
||||
return (
|
||||
<Panel className="overflow-hidden p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-white/10 pb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-2xl bg-emerald-400/15 text-emerald-200">
|
||||
<FolderOpen className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-black tracking-[-0.04em] text-white">真实只读文件浏览</h3>
|
||||
<p className="mt-1 text-sm font-semibold text-slate-500">
|
||||
调用 /api/files/browse 只列目录;不读取文件正文,不上传、不删除、不写入。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge tone={data.mode === 'live' ? 'green' : 'amber'} dot>{data.mode === 'live' ? '远程目录' : '降级演示'}</Badge>
|
||||
<Badge tone={mode === 'live' ? 'blue' : 'slate'}>Dashboard {mode}</Badge>
|
||||
<Badge tone="purple"><LockKeyhole className="mr-1 h-3 w-3" />只读</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.warning ? <div className="mt-4 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm font-semibold text-amber-100">{data.warning}</div> : null}
|
||||
{data.error ? <div className="mt-4 rounded-2xl border border-red-300/20 bg-red-400/10 px-4 py-3 text-sm font-semibold text-red-100">远程返回错误:{data.error}</div> : null}
|
||||
|
||||
<div className="mt-5 grid gap-3 xl:grid-cols-[280px_1fr_auto_auto]">
|
||||
<label className="flex items-center gap-2 rounded-2xl border border-white/10 bg-slate-950/50 px-4 py-3 text-sm font-bold text-slate-400">
|
||||
<Server className="h-4 w-4" />
|
||||
<select value={serverId ?? 0} onChange={(event) => handleServerChange(event.target.value)} className="min-w-0 flex-1 bg-transparent text-slate-100 outline-none">
|
||||
{browseableServers.map((server) => <option key={server.id} value={server.sourceId ?? 0}>{server.name} / {server.ip}</option>)}
|
||||
{browseableServers.length === 0 ? <option value={0}>无在线服务器</option> : null}
|
||||
</select>
|
||||
</label>
|
||||
<label className="focus-within:ring-nexus flex items-center gap-3 rounded-2xl border border-white/10 bg-slate-950/50 px-4 py-3 text-sm font-semibold text-slate-500">
|
||||
<HardDrive className="h-4 w-4" />
|
||||
<input value={pathInput} onChange={(event) => setPathInput(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') handleBrowse() }} placeholder="/www/wwwroot" className="w-full bg-transparent text-slate-100 outline-none placeholder:text-slate-600" />
|
||||
</label>
|
||||
<button type="button" onClick={handleBrowse} className="focus-ring rounded-2xl bg-cyan-400 px-4 py-3 text-sm font-black text-slate-950">浏览</button>
|
||||
<button type="button" onClick={handleRefresh} className="focus-ring inline-flex items-center justify-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-black text-cyan-100"><RefreshCw className="h-4 w-4" />刷新</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-4">
|
||||
<div className="rounded-2xl border border-white/10 bg-slate-950/40 p-4"><div className="text-xs font-black text-slate-500">当前服务器</div><div className="mt-1 truncate text-sm font-black text-white">{selectedServer ? selectedServer.name + ' / ' + selectedServer.ip : '未选择'}</div></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-slate-950/40 p-4"><div className="text-xs font-black text-slate-500">当前路径</div><div className="mt-1 truncate text-sm font-black text-cyan-100">{data.path}</div></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-slate-950/40 p-4"><div className="text-xs font-black text-slate-500">目录 / 文件</div><div className="mt-1 text-sm font-black text-white">{directoryCount} / {fileCount}</div></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-slate-950/40 p-4"><div className="text-xs font-black text-slate-500">文件体积合计</div><div className="mt-1 text-sm font-black text-white">{formatSize(totalSize)}</div></div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
|
||||
<button type="button" onClick={handleParent} className="focus-ring inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-black text-slate-200"><ArrowUp className="h-4 w-4" />上级目录</button>
|
||||
<label className="focus-within:ring-nexus flex min-w-72 items-center gap-3 rounded-2xl border border-white/10 bg-slate-950/50 px-4 py-2 text-sm font-semibold text-slate-500">
|
||||
<Search className="h-4 w-4" />
|
||||
<input value={nameQuery} onChange={(event) => setNameQuery(event.target.value)} placeholder="过滤名称 / owner / 权限" className="w-full bg-transparent text-slate-100 outline-none placeholder:text-slate-600" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 overflow-x-auto rounded-3xl border border-white/10">
|
||||
<table className="min-w-full divide-y divide-white/10">
|
||||
<thead className="bg-white/[0.03]">
|
||||
<tr>
|
||||
<th className="px-5 py-4 text-left text-xs font-black uppercase tracking-[0.14em] text-slate-500">名称</th>
|
||||
<th className="px-5 py-4 text-left text-xs font-black uppercase tracking-[0.14em] text-slate-500">权限</th>
|
||||
<th className="px-5 py-4 text-left text-xs font-black uppercase tracking-[0.14em] text-slate-500">Owner</th>
|
||||
<th className="px-5 py-4 text-left text-xs font-black uppercase tracking-[0.14em] text-slate-500">大小</th>
|
||||
<th className="px-5 py-4 text-left text-xs font-black uppercase tracking-[0.14em] text-slate-500">修改时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10 bg-slate-950/20">
|
||||
{visibleItems.map((item) => (
|
||||
<tr key={item.name + ':' + item.type + ':' + (item.link_target || '')} className="transition hover:bg-white/[0.03]">
|
||||
<td className="px-5 py-4">
|
||||
<button type="button" onClick={() => handleDirectoryOpen(item)} disabled={item.type !== 'directory' || item.name === '[HIDDEN]'} className={cn('flex max-w-md items-center gap-3 text-left text-sm font-black', item.type === 'directory' ? 'text-cyan-100 hover:text-cyan-300' : 'cursor-default text-slate-300')}>
|
||||
{item.type === 'directory' ? <FolderOpen className="h-4 w-4 shrink-0 text-cyan-300" /> : <File className="h-4 w-4 shrink-0 text-slate-500" />}
|
||||
<span className="truncate">{item.name}</span>
|
||||
</button>
|
||||
{item.link_target ? <div className="mt-1 text-xs font-semibold text-slate-600">→ {item.link_target}</div> : null}
|
||||
</td>
|
||||
<td className="px-5 py-4"><Badge tone={itemTone(item)}>{item.permissions || item.mode_octal || '-'}</Badge></td>
|
||||
<td className="px-5 py-4 text-sm font-semibold text-slate-400">{item.owner || '-'}:{item.group || '-'}</td>
|
||||
<td className="px-5 py-4 text-sm font-semibold text-slate-400">{item.type === 'file' ? formatSize(item.size) : '-'}</td>
|
||||
<td className="px-5 py-4 text-sm font-semibold text-slate-500">{formatFileTime(item.modified)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{visibleItems.length === 0 ? <div className="px-5 py-12 text-center text-sm font-bold text-slate-500">没有符合条件的目录项</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-center justify-between gap-3 rounded-3xl border border-emerald-300/10 bg-emerald-400/5 p-4">
|
||||
<div className="text-sm font-semibold leading-6 text-slate-400">
|
||||
该浏览器只执行远程 ls 目录列表;文件读取、上传、删除、chmod、压缩/解压、跨服务器推送仍回旧后台处理。
|
||||
</div>
|
||||
<a href="/app/files" className="focus-ring inline-flex items-center gap-2 rounded-2xl border border-emerald-300/25 bg-emerald-400/15 px-4 py-3 text-sm font-black text-emerald-100">
|
||||
去旧文件管理 <ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { flexRender, getCoreRowModel, getFilteredRowModel, getSortedRowModel, useReactTable } from '@tanstack/react-table'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { ArrowUpRight, BookOpen, Code2, FileText, Filter, LockKeyhole, Search, ShieldCheck, Tags, UserRound } from 'lucide-react'
|
||||
import { Badge } from '~components/ui/Badge'
|
||||
import { Panel } from '~components/ui/Panel'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { redactSensitiveText } from '@/lib/redaction'
|
||||
import { fetchScriptLibraryData } from '../api/dashboardApi'
|
||||
import type { ScriptSummaryItem } from '../api/dashboardApi'
|
||||
|
||||
interface ScriptLibraryPageProps {
|
||||
mode: 'live' | 'fallback'
|
||||
}
|
||||
|
||||
type CategoryFilter = 'all' | string
|
||||
|
||||
function formatDate(value?: string | null): string {
|
||||
if (!value) return '-'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
function formatSize(value?: number | null): string {
|
||||
const size = typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||
if (size < 1024) return String(size) + ' B'
|
||||
if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB'
|
||||
return (size / 1024 / 1024).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
function redactText(value?: string | null): string {
|
||||
return redactSensitiveText(value, '-', 160)
|
||||
}
|
||||
|
||||
function categoryTone(category?: string | null): 'green' | 'blue' | 'amber' | 'red' | 'slate' | 'purple' {
|
||||
const value = String(category || '').toLowerCase()
|
||||
if (value.includes('security') || value.includes('safe')) return 'green'
|
||||
if (value.includes('bt') || value.includes('panel')) return 'blue'
|
||||
if (value.includes('agent')) return 'purple'
|
||||
if (value.includes('db') || value.includes('mysql')) return 'amber'
|
||||
return 'slate'
|
||||
}
|
||||
|
||||
function scriptAgeDays(value?: string | null): number | null {
|
||||
if (!value) return null
|
||||
const time = Date.parse(value)
|
||||
if (!Number.isFinite(time)) return null
|
||||
return Math.max(0, Math.round((Date.now() - time) / 86400000))
|
||||
}
|
||||
|
||||
export function ScriptLibraryPage({ mode }: ScriptLibraryPageProps): JSX.Element {
|
||||
const [query, setQuery] = useState('')
|
||||
const [category, setCategory] = useState<CategoryFilter>('all')
|
||||
const [contentOnly, setContentOnly] = useState(false)
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: ['script-library-v2'],
|
||||
queryFn: fetchScriptLibraryData,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const text = query.trim().toLowerCase()
|
||||
return data.items.filter((item) => {
|
||||
const categoryMatched = category === 'all' || (item.category || '未分类') === category
|
||||
const contentMatched = !contentOnly || item.has_content === true
|
||||
const textMatched = !text || [item.name, item.category, item.description, item.created_by, String(item.id)].some((value) => String(value || '').toLowerCase().includes(text))
|
||||
return categoryMatched && contentMatched && textMatched
|
||||
})
|
||||
}, [category, contentOnly, data.items, query])
|
||||
|
||||
const columns = useMemo<ColumnDef<ScriptSummaryItem>[]>(() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: '脚本',
|
||||
cell: ({ row }) => {
|
||||
const item = row.original
|
||||
return (
|
||||
<div className="min-w-80">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge tone={categoryTone(item.category)}>{item.category || '未分类'}</Badge>
|
||||
<span className="text-sm font-black text-white">#{item.id}</span>
|
||||
{item.has_content ? <Badge tone="green" dot>有正文</Badge> : <Badge tone="slate">空正文</Badge>}
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-black text-white">{redactText(item.name)}</div>
|
||||
<div className="mt-1 max-w-2xl text-xs font-semibold leading-5 text-slate-500">{redactText(item.description)}</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_by',
|
||||
header: '创建者',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-slate-400">
|
||||
<UserRound className="h-4 w-4 text-slate-600" /> {row.original.created_by || '-'}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'line_count',
|
||||
header: '规模',
|
||||
cell: ({ row }) => (
|
||||
<div className="text-sm font-semibold leading-6 text-slate-300">
|
||||
<div>{row.original.line_count || 0} 行</div>
|
||||
<div className="text-xs text-slate-600">{formatSize(row.original.content_size)}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: '更新时间',
|
||||
cell: ({ row }) => {
|
||||
const days = scriptAgeDays(row.original.updated_at)
|
||||
return (
|
||||
<div className="text-xs font-semibold leading-5 text-slate-400">
|
||||
<div>{formatDate(row.original.updated_at)}</div>
|
||||
<div className="text-slate-600">{days === null ? '-' : String(days) + ' 天前'}</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
], [])
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredItems,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
})
|
||||
|
||||
const totalLines = data.items.reduce((sum, item) => sum + (item.line_count || 0), 0)
|
||||
const totalSize = data.items.reduce((sum, item) => sum + (item.content_size || 0), 0)
|
||||
const cards = [
|
||||
{ label: '脚本数量', value: String(data.total), sub: data.mode === 'live' ? '来自 summary-only API' : '安全降级演示', icon: BookOpen, tone: 'text-cyan-300' },
|
||||
{ label: '分类', value: String(data.categories.length), sub: '按 category 聚合', icon: Tags, tone: 'text-blue-300' },
|
||||
{ label: '正文规模', value: formatSize(totalSize), sub: String(totalLines) + ' 行;不返回正文', icon: Code2, tone: 'text-violet-300' },
|
||||
{ label: '安全边界', value: '只读', sub: '不编辑、不执行、不复制正文', icon: ShieldCheck, tone: 'text-emerald-300' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div id="scripts" className="space-y-6 px-5 pb-5 lg:px-10 lg:pb-10">
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{cards.map((card) => (
|
||||
<Panel key={card.label} className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-black uppercase tracking-[0.16em] text-slate-500">{card.label}</div>
|
||||
<card.icon className={cn('h-5 w-5', card.tone)} />
|
||||
</div>
|
||||
<div className="mt-3 text-3xl font-black tracking-[-0.05em] text-white">{card.value}</div>
|
||||
<div className="mt-2 text-sm font-semibold text-slate-500">{card.sub}</div>
|
||||
</Panel>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<Panel className="overflow-hidden p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-white/10 pb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-2xl bg-cyan-400/15 text-cyan-200"><FileText className="h-5 w-5" /></div>
|
||||
<div>
|
||||
<h2 className="text-xl font-black tracking-[-0.04em] text-white">脚本库只读浏览</h2>
|
||||
<p className="mt-1 text-sm font-semibold text-slate-500">V2 使用 /api/scripts/summaries,只拿元数据,不让脚本正文进入浏览器响应。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge tone={data.mode === 'live' ? 'green' : 'amber'} dot>{data.mode === 'live' ? '实时 API' : '降级数据'}</Badge>
|
||||
<Badge tone={mode === 'live' ? 'blue' : 'slate'}>Dashboard {mode}</Badge>
|
||||
<Badge tone="purple"><LockKeyhole className="mr-1 h-3 w-3" />不返回 content</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.warning ? <div className="mt-4 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm font-semibold text-amber-100">{data.warning}</div> : null}
|
||||
|
||||
<div className="mt-5 grid gap-3 xl:grid-cols-[1fr_auto_auto]">
|
||||
<label className="focus-within:ring-nexus flex items-center gap-3 rounded-2xl border border-white/10 bg-slate-950/50 px-4 py-3 text-sm font-semibold text-slate-500">
|
||||
<Search className="h-4 w-4" />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索脚本名 / 分类 / 描述 / 创建者 / ID" className="w-full bg-transparent text-slate-100 outline-none placeholder:text-slate-600" />
|
||||
</label>
|
||||
<label className="flex items-center gap-2 rounded-2xl border border-white/10 bg-slate-950/50 px-4 py-3 text-sm font-bold text-slate-400">
|
||||
<Filter className="h-4 w-4" />
|
||||
<select value={category} onChange={(event) => setCategory(event.target.value)} className="bg-transparent text-slate-100 outline-none">
|
||||
<option value="all">全部分类</option>
|
||||
{data.categories.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded-2xl border border-white/10 bg-slate-950/50 px-4 py-3 text-sm font-bold text-slate-300">
|
||||
<input type="checkbox" checked={contentOnly} onChange={(event) => setContentOnly(event.target.checked)} className="h-4 w-4 accent-cyan-400" />
|
||||
只看有正文
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 overflow-x-auto rounded-3xl border border-white/10">
|
||||
<table className="min-w-full divide-y divide-white/10">
|
||||
<thead className="bg-white/[0.03]">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th key={header.id} className="px-5 py-4 text-left text-xs font-black uppercase tracking-[0.14em] text-slate-500">
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10 bg-slate-950/20">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr key={row.id} className="transition hover:bg-white/[0.03]">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id} className="px-5 py-4 align-top">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{filteredItems.length === 0 ? <div className="px-5 py-12 text-center text-sm font-bold text-slate-500">没有符合条件的脚本摘要</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-center justify-between gap-3 rounded-3xl border border-cyan-300/10 bg-cyan-400/5 p-4">
|
||||
<div className="text-sm font-semibold leading-6 text-slate-400">
|
||||
V2 只读列表不返回脚本正文;编辑、复制正文、执行、删除、凭据选择仍回旧后台,继续走原有鉴权、危险命令检查和审计链路。
|
||||
</div>
|
||||
<a href="/app/scripts" className="focus-ring inline-flex items-center gap-2 rounded-2xl border border-cyan-300/25 bg-cyan-400/15 px-4 py-3 text-sm font-black text-cyan-100">
|
||||
去旧后台脚本库 <ArrowUpRight className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { AlertTriangle, CheckCircle2, ExternalLink, Eye, FileSearch, Filter, KeyRound, LockKeyhole, Search, Server, ShieldAlert, ShieldCheck, TerminalSquare, TimerReset, Wrench } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { Badge } from '~components/ui/Badge'
|
||||
import { Panel } from '~components/ui/Panel'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { fetchAuditLogData } from '../api/dashboardApi'
|
||||
import type { ServerAsset } from '../data/dashboardData'
|
||||
|
||||
interface SecurityInspectionPageProps {
|
||||
servers: ServerAsset[]
|
||||
total: number
|
||||
mode: 'live' | 'fallback'
|
||||
}
|
||||
|
||||
type FindingSeverity = 'critical' | 'warning' | 'passed' | 'info'
|
||||
type FindingCategory = 'btpanel' | 'ssl' | 'agent' | 'audit' | 'script' | 'secret'
|
||||
type FindingFilter = 'all' | FindingSeverity | FindingCategory
|
||||
|
||||
interface SecurityFinding {
|
||||
id: string
|
||||
title: string
|
||||
desc: string
|
||||
category: FindingCategory
|
||||
severity: FindingSeverity
|
||||
affected: number
|
||||
total: number
|
||||
evidence: string
|
||||
action: string
|
||||
icon: LucideIcon
|
||||
}
|
||||
|
||||
const filters: Array<{ label: string; value: FindingFilter }> = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '严重', value: 'critical' },
|
||||
{ label: '关注', value: 'warning' },
|
||||
{ label: '通过', value: 'passed' },
|
||||
{ label: '宝塔', value: 'btpanel' },
|
||||
{ label: 'SSL', value: 'ssl' },
|
||||
{ label: 'Agent', value: 'agent' },
|
||||
{ label: '审计', value: 'audit' },
|
||||
]
|
||||
|
||||
function isTtlProtected(server: ServerAsset): boolean {
|
||||
return Boolean(server.btSessionCleanupOk || server.btSessionCleanupPatched || server.ttl === '86400s')
|
||||
}
|
||||
|
||||
function percent(done: number, total: number): number {
|
||||
if (total <= 0) return 0
|
||||
return Math.max(0, Math.min(100, Math.round((done / total) * 100)))
|
||||
}
|
||||
|
||||
function severityTone(severity: FindingSeverity): 'green' | 'amber' | 'red' | 'blue' | 'slate' {
|
||||
if (severity === 'critical') return 'red'
|
||||
if (severity === 'warning') return 'amber'
|
||||
if (severity === 'passed') return 'green'
|
||||
return 'blue'
|
||||
}
|
||||
|
||||
function severityLabel(severity: FindingSeverity): string {
|
||||
if (severity === 'critical') return '严重'
|
||||
if (severity === 'warning') return '需关注'
|
||||
if (severity === 'passed') return '通过'
|
||||
return '信息'
|
||||
}
|
||||
|
||||
function severityBar(severity: FindingSeverity): string {
|
||||
if (severity === 'critical') return 'bg-red-400'
|
||||
if (severity === 'warning') return 'bg-amber-400'
|
||||
if (severity === 'passed') return 'bg-emerald-400'
|
||||
return 'bg-cyan-400'
|
||||
}
|
||||
|
||||
function categoryLabel(category: FindingCategory): string {
|
||||
if (category === 'btpanel') return '宝塔'
|
||||
if (category === 'ssl') return 'SSL'
|
||||
if (category === 'agent') return 'Agent'
|
||||
if (category === 'audit') return '审计'
|
||||
if (category === 'script') return '脚本'
|
||||
return '敏感信息'
|
||||
}
|
||||
|
||||
function buildFindings(servers: ServerAsset[], total: number, auditRiskCount: number): SecurityFinding[] {
|
||||
const effectiveTotal = Math.max(total, servers.length)
|
||||
const configuredBt = servers.filter((server) => server.btConfigured).length
|
||||
const ttlUnprotected = servers.filter((server) => server.btConfigured && !isTtlProtected(server)).length
|
||||
const sslOff = servers.filter((server) => server.ssl === 'disabled').length
|
||||
const offline = servers.filter((server) => server.online === false || server.panelStatus === 'offline').length
|
||||
const criticalAssets = servers.filter((server) => server.risk === 'critical').length
|
||||
const publicBt = servers.filter((server) => server.btConfigured && server.ssl !== 'internal').length
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'bt-ttl-hardcoded-3600',
|
||||
title: '宝塔 Session TTL 兜底',
|
||||
desc: '检查一键登录前 hardcoded_3600 清理风险;新增宝塔仍依赖后端登录前自动检测/修复。',
|
||||
category: 'btpanel',
|
||||
severity: ttlUnprotected > 0 ? 'warning' : 'passed',
|
||||
affected: ttlUnprotected > 0 ? ttlUnprotected : configuredBt,
|
||||
total: Math.max(configuredBt, 1),
|
||||
evidence: ttlUnprotected > 0 ? String(ttlUnprotected) + ' 台已配置宝塔仍需复查 TTL' : String(configuredBt) + ' 台已配置宝塔当前 TTL 兜底通过',
|
||||
action: '一键登录前继续走后端 TTL guard,不在前端生成 tmp_login token。',
|
||||
icon: TimerReset,
|
||||
},
|
||||
{
|
||||
id: 'bt-panel-ssl',
|
||||
title: '公网宝塔面板 SSL',
|
||||
desc: '识别 verify_ssl 未开启的公网宝塔面板;V2 只展示建议,开启/证书改动回旧后台。',
|
||||
category: 'ssl',
|
||||
severity: sslOff > 0 ? 'warning' : 'passed',
|
||||
affected: sslOff > 0 ? sslOff : publicBt,
|
||||
total: Math.max(publicBt, servers.length, 1),
|
||||
evidence: sslOff > 0 ? String(sslOff) + ' 台公网面板未开启 SSL 校验' : '公网面板 SSL 状态未发现异常',
|
||||
action: '优先处理公网面板;内网测试机可保留 internal 标记。',
|
||||
icon: LockKeyhole,
|
||||
},
|
||||
{
|
||||
id: 'agent-heartbeat-risk',
|
||||
title: 'Agent 在线与资产同步',
|
||||
desc: '检查离线服务器、心跳异常和关键资产风险;离线资产会影响巡检覆盖率。',
|
||||
category: 'agent',
|
||||
severity: offline + criticalAssets > 0 ? 'critical' : 'passed',
|
||||
affected: offline + criticalAssets,
|
||||
total: effectiveTotal,
|
||||
evidence: String(offline) + ' 台离线,' + String(criticalAssets) + ' 台 critical 风险',
|
||||
action: '离线机器先回旧后台或 SSH 检查 Agent/网络,再重新同步。',
|
||||
icon: Server,
|
||||
},
|
||||
{
|
||||
id: 'audit-risk-events',
|
||||
title: '审计/告警异常事件',
|
||||
desc: '基于审计日志和告警历史统计删除、失败、活跃告警等需关注事件。',
|
||||
category: 'audit',
|
||||
severity: auditRiskCount > 0 ? 'warning' : 'passed',
|
||||
affected: auditRiskCount,
|
||||
total: Math.max(auditRiskCount, 1),
|
||||
evidence: auditRiskCount > 0 ? String(auditRiskCount) + ' 条近期审计/告警事件需要复核' : '近期审计/告警没有明显风险项',
|
||||
action: '详细时间线已迁移到 V2 审计日志页,深度追溯仍可回旧后台。',
|
||||
icon: FileSearch,
|
||||
},
|
||||
{
|
||||
id: 'script-service-admin-shell',
|
||||
title: '管理员远程执行命令边界',
|
||||
desc: 'script_service 是管理员远程执行命令能力,允许 shell 属于预期设计;关注点是权限、审计和二次确认。',
|
||||
category: 'script',
|
||||
severity: 'info',
|
||||
affected: 0,
|
||||
total: 1,
|
||||
evidence: 'V2 当前不直接执行/停止/重试脚本,只提供旧后台入口。',
|
||||
action: '后续迁移写操作时再加显式确认、审计说明和最小暴露字段。',
|
||||
icon: TerminalSquare,
|
||||
},
|
||||
{
|
||||
id: 'secret-visibility',
|
||||
title: '敏感信息前端可见性',
|
||||
desc: '检查 V2 页面是否展示密码、Cookie、tmp_login token、密钥类字段、私钥等敏感字段。',
|
||||
category: 'secret',
|
||||
severity: 'passed',
|
||||
affected: 0,
|
||||
total: 1,
|
||||
evidence: 'V2 仅保存内存 access token;页面不展示密钥类敏感值和宝塔凭据。',
|
||||
action: '继续保持设置、凭据、批量导入等敏感编辑回旧后台或做强确认。',
|
||||
icon: KeyRound,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function SecurityInspectionPage({ servers, total, mode }: SecurityInspectionPageProps): JSX.Element {
|
||||
const [query, setQuery] = useState('')
|
||||
const [filter, setFilter] = useState<FindingFilter>('all')
|
||||
const { data: auditData } = useSuspenseQuery({
|
||||
queryKey: ['security-audit-signal-v2'],
|
||||
queryFn: fetchAuditLogData,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const auditRiskCount = useMemo(() => {
|
||||
const riskyAudits = auditData.auditItems.filter((item) => {
|
||||
const text = [item.action, item.detail, item.target_type].filter(Boolean).join(' ').toLowerCase()
|
||||
return text.includes('delete') || text.includes('remove') || text.includes('failed') || text.includes('失败')
|
||||
}).length
|
||||
const activeAlerts = auditData.alertItems.filter((item) => item.is_recovery !== true).length
|
||||
return riskyAudits + activeAlerts
|
||||
}, [auditData.alertItems, auditData.auditItems])
|
||||
|
||||
const findings = useMemo(() => buildFindings(servers, total, auditRiskCount), [auditRiskCount, servers, total])
|
||||
const keyword = query.trim().toLowerCase()
|
||||
const filteredFindings = useMemo(() => findings.filter((finding) => {
|
||||
const matchesFilter = filter === 'all' || finding.severity === filter || finding.category === filter
|
||||
const matchesKeyword = !keyword || [finding.title, finding.desc, finding.evidence, finding.action, categoryLabel(finding.category), severityLabel(finding.severity)]
|
||||
.some((value) => value.toLowerCase().includes(keyword))
|
||||
return matchesFilter && matchesKeyword
|
||||
}), [filter, findings, keyword])
|
||||
|
||||
const criticalCount = findings.filter((finding) => finding.severity === 'critical').length
|
||||
const warningCount = findings.filter((finding) => finding.severity === 'warning').length
|
||||
const passedCount = findings.filter((finding) => finding.severity === 'passed').length
|
||||
const score = percent(passedCount, findings.length)
|
||||
|
||||
return (
|
||||
<div id="security" className="space-y-6 p-5 lg:p-10">
|
||||
<Panel className="relative overflow-hidden p-6">
|
||||
<div className="absolute -right-16 -top-20 h-56 w-56 rounded-full bg-amber-400/10 blur-3xl" />
|
||||
<div className="flex flex-wrap items-start justify-between gap-5">
|
||||
<div className="max-w-3xl">
|
||||
<Badge tone={criticalCount ? 'red' : warningCount ? 'amber' : 'green'} dot className="px-4 py-2">V2 安全巡检</Badge>
|
||||
<h2 className="mt-4 text-3xl font-black tracking-[-0.05em] text-white lg:text-5xl">安全巡检 / 风险总览</h2>
|
||||
<p className="mt-3 text-sm font-semibold leading-7 text-slate-400">
|
||||
这一版先迁移只读风险总览:围绕宝塔 TTL、面板 SSL、Agent 在线、审计告警、脚本执行边界和敏感信息可见性做快速判断。深度扫描、修复、删除、执行类动作仍回旧后台,避免绕过现有权限与审计链路。
|
||||
</p>
|
||||
</div>
|
||||
<a href="/app/" className="focus-ring inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-black text-cyan-200 hover:bg-cyan-400/10">
|
||||
<ExternalLink className="h-4 w-4" /> 旧后台深度巡检
|
||||
</a>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{auditData.warning ? (
|
||||
<div className="rounded-3xl border border-amber-400/20 bg-amber-400/10 px-5 py-4 text-sm font-semibold text-amber-100">
|
||||
<span className="font-black">巡检数据提示:</span>{auditData.warning}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<SecurityMetric icon={ShieldCheck} label="巡检得分" value={String(score) + '%'} sub={mode === 'live' ? '实时资产 + 审计信号' : '演示资产 + 审计信号'} tone={score >= 80 ? 'text-emerald-300' : score >= 60 ? 'text-amber-300' : 'text-red-300'} />
|
||||
<SecurityMetric icon={ShieldAlert} label="严重项" value={String(criticalCount)} sub="离线 / critical 风险" tone={criticalCount ? 'text-red-300' : 'text-emerald-300'} />
|
||||
<SecurityMetric icon={AlertTriangle} label="需关注" value={String(warningCount)} sub="TTL / SSL / 审计告警" tone={warningCount ? 'text-amber-300' : 'text-emerald-300'} />
|
||||
<SecurityMetric icon={CheckCircle2} label="通过项" value={String(passedCount)} sub="当前符合预期" tone="text-emerald-300" />
|
||||
</section>
|
||||
|
||||
<Panel className="p-5">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-2xl bg-amber-400/10 text-amber-200"><Filter className="h-5 w-5" /></div>
|
||||
<div>
|
||||
<div className="text-lg font-black text-white">巡检过滤器</div>
|
||||
<div className="text-sm font-semibold text-slate-500">审计信号:{auditData.mode === 'live' ? '实时 API' : 'fallback'},资产信号:{mode === 'live' ? '实时 API' : 'fallback'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex min-w-0 flex-1 items-center gap-3 rounded-2xl border border-white/10 bg-slate-950/50 px-4 py-3 xl:max-w-md">
|
||||
<Search className="h-4 w-4 text-slate-500" />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索巡检项、证据、建议" className="min-w-0 flex-1 bg-transparent text-sm font-semibold text-white outline-none placeholder:text-slate-600" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
{filters.map((item) => (
|
||||
<button key={item.value} type="button" onClick={() => setFilter(item.value)} className={cn('focus-ring rounded-2xl px-4 py-2 text-sm font-black transition', filter === item.value ? 'bg-cyan-400 text-slate-950' : 'border border-white/10 bg-white/[0.03] text-slate-400 hover:text-white')}>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-2">
|
||||
{filteredFindings.map((finding) => (
|
||||
<FindingCard key={finding.id} finding={finding} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
<Panel className="grid gap-5 p-6 lg:grid-cols-[minmax(0,1fr)_22rem] lg:items-center">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-2xl bg-cyan-400/10 text-cyan-200"><Eye className="h-5 w-5" /></div>
|
||||
<div>
|
||||
<div className="text-xl font-black text-white">V2 巡检边界</div>
|
||||
<div className="text-sm font-semibold text-slate-500">当前只读,不触发修复、不执行脚本、不泄露凭据。</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 text-sm font-semibold leading-7 text-slate-500">
|
||||
这个页面用于快速判断优先级;真正会改服务器状态的操作,例如修复 SSL、修改宝塔凭据、执行 shell、清理审计日志,仍然从旧后台入口处理或后续增加明确二次确认后再迁移。
|
||||
</p>
|
||||
</div>
|
||||
<a href="/app/" className="focus-ring inline-flex items-center justify-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-black text-slate-200 hover:bg-white/[0.08]">
|
||||
<ExternalLink className="h-4 w-4" /> 打开旧后台安全巡检
|
||||
</a>
|
||||
</Panel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SecurityMetricProps {
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
value: string
|
||||
sub: string
|
||||
tone: string
|
||||
}
|
||||
|
||||
function SecurityMetric({ icon: Icon, label, value, sub, tone }: SecurityMetricProps): JSX.Element {
|
||||
return (
|
||||
<Panel className="group overflow-hidden p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-2xl border border-white/10 bg-white/[0.04]">
|
||||
<Icon className={'h-5 w-5 ' + tone} />
|
||||
</div>
|
||||
<Wrench className="h-4 w-4 text-slate-600 transition group-hover:text-cyan-300" />
|
||||
</div>
|
||||
<div className="mt-5 text-xs font-black uppercase tracking-[0.14em] text-slate-500">{label}</div>
|
||||
<div className="mt-2 text-3xl font-black text-white">{value}</div>
|
||||
<p className="mt-2 text-sm font-medium leading-6 text-slate-500">{sub}</p>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function FindingCard({ finding }: { finding: SecurityFinding }): JSX.Element {
|
||||
const Icon = finding.icon
|
||||
return (
|
||||
<Panel className="relative overflow-hidden p-5">
|
||||
<div className="absolute -right-12 -top-12 h-32 w-32 rounded-full bg-amber-400/5 blur-3xl" />
|
||||
<div className="relative flex items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 gap-4">
|
||||
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-2xl border border-white/10 bg-white/[0.04]">
|
||||
<Icon className="h-5 w-5 text-cyan-200" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-lg font-black text-white">{finding.title}</h3>
|
||||
<Badge tone={severityTone(finding.severity)} dot>{severityLabel(finding.severity)}</Badge>
|
||||
<Badge tone="slate">{categoryLabel(finding.category)}</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-sm font-semibold leading-7 text-slate-500">{finding.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mt-5">
|
||||
<div className="mb-2 flex items-center justify-between text-sm">
|
||||
<span className="font-black text-slate-300">证据:{finding.evidence}</span>
|
||||
<span className="font-black text-slate-500">{finding.affected} / {finding.total}</span>
|
||||
</div>
|
||||
<div className="h-2.5 overflow-hidden rounded-full bg-slate-800/90">
|
||||
<div className={severityBar(finding.severity) + ' h-full rounded-full'} style={{ width: String(finding.severity === 'passed' ? 100 : percent(finding.total - finding.affected, finding.total)) + '%' }} />
|
||||
</div>
|
||||
<div className="mt-4 rounded-2xl border border-white/10 bg-slate-950/40 px-4 py-3 text-sm font-semibold leading-6 text-slate-400">
|
||||
<span className="font-black text-slate-200">建议:</span>{finding.action}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { Badge } from '~components/ui/Badge'
|
||||
import { Panel } from '~components/ui/Panel'
|
||||
import { useAppStore } from '@/stores/appStore'
|
||||
import type { ServerAsset } from '../data/dashboardData'
|
||||
|
||||
interface ServerDrawerProps {
|
||||
servers: ServerAsset[]
|
||||
onBtLogin: (server: ServerAsset) => void
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string | null): string {
|
||||
if (!value) return '未检查'
|
||||
const timestamp = Date.parse(value)
|
||||
if (!Number.isFinite(timestamp)) return value
|
||||
return new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' }).format(timestamp)
|
||||
}
|
||||
|
||||
export function ServerDrawer({ servers, onBtLogin }: ServerDrawerProps): JSX.Element {
|
||||
const selectedServerId = useAppStore((state) => state.selectedServerId)
|
||||
const server = servers.find((item) => item.id === selectedServerId) ?? servers[0]
|
||||
|
||||
return (
|
||||
<Panel className="p-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h2 className="text-xl font-black tracking-tight text-white">服务器详情</h2>
|
||||
<Badge tone="blue" dot>当前选中</Badge>
|
||||
</div>
|
||||
{server ? (
|
||||
<>
|
||||
<div className="mt-7">
|
||||
<div className="text-2xl font-black tracking-tight text-white">{server.name}</div>
|
||||
<div className="mt-1 font-mono text-sm font-medium text-slate-500">{server.ip} · {server.region}</div>
|
||||
</div>
|
||||
<div className="mt-7 grid grid-cols-2 gap-3">
|
||||
<Metric label="CPU" value={String(server.cpu) + '%'} tone="text-emerald-300" />
|
||||
<Metric label="内存" value={String(server.memory) + '%'} tone="text-cyan-300" />
|
||||
<Metric label="磁盘" value={String(server.disk) + '%'} tone="text-amber-300" />
|
||||
<Metric label="心跳" value={server.latency} tone="text-emerald-300" />
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<div className="mb-3 text-sm font-black text-slate-200">快捷操作</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
className="focus-ring inline-flex items-center gap-2 rounded-full bg-sky-400/15 px-4 py-2 text-xs font-bold text-sky-200 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!server.sourceId}
|
||||
onClick={() => onBtLogin(server)}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" /> 一键登录宝塔
|
||||
</button>
|
||||
<a href="/app/" className="focus-ring rounded-full bg-violet-400/15 px-4 py-2 text-xs font-bold text-violet-200">打开终端</a>
|
||||
<a href="/app/" className="focus-ring rounded-full bg-slate-400/10 px-4 py-2 text-xs font-bold text-slate-200">执行脚本</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<div className="mb-4 text-sm font-black text-slate-200">最近诊断</div>
|
||||
<Diagnosis label="Session TTL" value={server.ttl === '86400s' ? '86400s · 健康' : server.ttl + ' · 待修复'} good={server.ttl === '86400s'} />
|
||||
<Diagnosis label="BT Task 清理" value={server.btSessionCleanupPatched ? '已补丁 / 兜底' : server.btSessionCleanupOk ? '已验证' : '登录前自动检测'} good={Boolean(server.btSessionCleanupOk || server.btSessionCleanupPatched)} />
|
||||
<Diagnosis label="面板 SSL" value={server.ssl === 'enabled' ? '已开启' : server.ssl === 'internal' ? '内网' : server.ssl === 'disabled' ? '建议开启' : '未知'} good={server.ssl !== 'disabled'} />
|
||||
<Diagnosis label="凭据展示" value="敏感值已隐藏" good />
|
||||
<Diagnosis label="上次检查" value={formatDateTime(server.btLastCheckAt)} good={!server.btLastError} />
|
||||
{server.btLastError ? <Diagnosis label="最近错误" value={server.btLastError} good={false} /> : null}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="mt-8 rounded-3xl border border-dashed border-white/10 p-8 text-center text-sm font-bold text-slate-500">暂无服务器数据</div>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
interface MetricProps { label: string; value: string; tone: string }
|
||||
function Metric({ label, value, tone }: MetricProps): JSX.Element {
|
||||
return <div className="soft-panel rounded-2xl p-4"><div className="text-xs font-bold text-slate-500">{label}</div><div className={'mt-1 text-2xl font-black ' + tone}>{value}</div></div>
|
||||
}
|
||||
|
||||
interface DiagnosisProps { label: string; value: string; good: boolean }
|
||||
function Diagnosis({ label, value, good }: DiagnosisProps): JSX.Element {
|
||||
return <div className="flex items-center justify-between gap-4 border-b border-white/[0.06] py-3 text-sm"><span className="font-bold text-slate-500">{label}</span><span className={good ? 'text-right font-black text-emerald-300' : 'text-right font-black text-amber-300'}>{value}</span></div>
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useMemo } from 'react'
|
||||
import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'
|
||||
import { ExternalLink, Loader2 } from 'lucide-react'
|
||||
import { Badge } from '~components/ui/Badge'
|
||||
import { Panel } from '~components/ui/Panel'
|
||||
import { useAppStore } from '@/stores/appStore'
|
||||
import type { ServerAsset } from '../data/dashboardData'
|
||||
|
||||
type PanelStatus = ServerAsset['panelStatus']
|
||||
type Risk = ServerAsset['risk']
|
||||
|
||||
interface ServerTableProps {
|
||||
servers: ServerAsset[]
|
||||
total: number
|
||||
mode: 'live' | 'fallback'
|
||||
loginLoadingId?: string | null
|
||||
onBtLogin: (server: ServerAsset) => void
|
||||
}
|
||||
|
||||
function statusBadge(status: PanelStatus): JSX.Element {
|
||||
if (status === 'online') return <Badge tone="green" dot>在线</Badge>
|
||||
if (status === 'patched') return <Badge tone="green" dot>已修复</Badge>
|
||||
if (status === 'test') return <Badge tone="blue" dot>待配置</Badge>
|
||||
return <Badge tone="red" dot>离线</Badge>
|
||||
}
|
||||
|
||||
function riskBadge(risk: Risk): JSX.Element {
|
||||
if (risk === 'healthy') return <Badge tone="green">健康</Badge>
|
||||
if (risk === 'patched') return <Badge tone="green">已修复</Badge>
|
||||
if (risk === 'warning') return <Badge tone="amber">建议</Badge>
|
||||
return <Badge tone="red">关注</Badge>
|
||||
}
|
||||
|
||||
function sslLabel(value: ServerAsset['ssl']): string {
|
||||
if (value === 'enabled') return '开启'
|
||||
if (value === 'disabled') return '未开启'
|
||||
if (value === 'internal') return '内网'
|
||||
return '未知'
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<ServerAsset>()
|
||||
|
||||
export function ServerTable({ servers, total, mode, loginLoadingId, onBtLogin }: ServerTableProps): JSX.Element {
|
||||
const selectedServerId = useAppStore((state) => state.selectedServerId)
|
||||
const setSelectedServerId = useAppStore((state) => state.setSelectedServerId)
|
||||
|
||||
const columns = useMemo(() => [
|
||||
columnHelper.accessor('name', {
|
||||
header: '服务器',
|
||||
cell: (info) => <span className="font-black text-slate-100">{info.getValue()}</span>,
|
||||
}),
|
||||
columnHelper.accessor((row) => row.ip + ' · ' + row.region, {
|
||||
id: 'ipRegion',
|
||||
header: 'IP / 区域',
|
||||
cell: (info) => <span className="font-mono text-slate-300">{info.getValue()}</span>,
|
||||
}),
|
||||
columnHelper.accessor('panelStatus', {
|
||||
header: '宝塔',
|
||||
cell: (info) => statusBadge(info.getValue()),
|
||||
}),
|
||||
columnHelper.accessor('ttl', {
|
||||
header: 'TTL',
|
||||
cell: (info) => <span className={info.getValue() === '86400s' ? 'font-bold text-emerald-300' : 'font-bold text-amber-300'}>{info.getValue()}</span>,
|
||||
}),
|
||||
columnHelper.accessor('ssl', {
|
||||
header: 'SSL',
|
||||
cell: (info) => {
|
||||
const value = info.getValue()
|
||||
return <span className={value === 'disabled' ? 'font-bold text-amber-300' : 'font-bold text-slate-200'}>{sslLabel(value)}</span>
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor('risk', {
|
||||
header: '风险',
|
||||
cell: (info) => riskBadge(info.getValue()),
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: 'action',
|
||||
header: '操作',
|
||||
cell: (info) => {
|
||||
const server = info.row.original
|
||||
const loading = loginLoadingId === server.id
|
||||
return (
|
||||
<button
|
||||
className="inline-flex items-center gap-1 font-black text-cyan-300 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={loading || !server.sourceId}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onBtLogin(server)
|
||||
}}
|
||||
>
|
||||
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ExternalLink className="h-3.5 w-3.5" />}
|
||||
{server.action}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
}),
|
||||
], [loginLoadingId, onBtLogin])
|
||||
|
||||
const table = useReactTable({ data: servers, columns, getCoreRowModel: getCoreRowModel() })
|
||||
|
||||
return (
|
||||
<Panel className="overflow-hidden p-0">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 p-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-black tracking-tight text-white">服务器资产 / 宝塔状态</h2>
|
||||
<p className="mt-1 text-sm font-medium text-slate-500">
|
||||
{mode === 'live' ? '实时 API 数据 · 共 ' + String(total) + ' 台资产' : '演示数据 · 实时 API 暂不可用'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge tone={mode === 'live' ? 'green' : 'amber'} dot className="px-4 py-2">{mode === 'live' ? '实时' : 'Fallback'}</Badge>
|
||||
<Badge tone="green" dot className="px-4 py-2">一键登录前 TTL 兜底</Badge>
|
||||
<Badge tone="slate" className="px-4 py-2">导出</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[920px] border-collapse text-sm">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id} className="border-y border-white/10 text-left text-xs uppercase tracking-[0.08em] text-slate-500">
|
||||
{headerGroup.headers.map((header) => <th key={header.id} className="px-6 py-4 font-black">{flexRender(header.column.columnDef.header, header.getContext())}</th>)}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={'cursor-pointer border-b border-white/[0.06] transition hover:bg-white/[0.04] ' + (selectedServerId === row.original.id ? 'bg-cyan-400/[0.06]' : '')}
|
||||
onClick={() => setSelectedServerId(row.original.id)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => <td key={cell.id} className="px-6 py-4">{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { AlertTriangle, CheckCircle2, DatabaseZap, ExternalLink, EyeOff, LockKeyhole, ServerCog, Settings, ShieldCheck, SlidersHorizontal, UserRound } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { Badge } from '~components/ui/Badge'
|
||||
import { Panel } from '~components/ui/Panel'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isSensitiveKey, redactSensitiveText } from '@/lib/redaction'
|
||||
import { useAppStore } from '@/stores/appStore'
|
||||
import { fetchSettingsOverviewData } from '../api/dashboardApi'
|
||||
import type { SettingsOverviewData } from '../api/dashboardApi'
|
||||
import type { ServerAsset } from '../data/dashboardData'
|
||||
|
||||
interface SettingsOverviewPageProps {
|
||||
servers: ServerAsset[]
|
||||
total: number
|
||||
mode: 'live' | 'fallback'
|
||||
}
|
||||
|
||||
type StatusTone = 'green' | 'blue' | 'amber' | 'red' | 'slate' | 'purple'
|
||||
|
||||
interface SettingCard {
|
||||
title: string
|
||||
value: string
|
||||
desc: string
|
||||
tone: StatusTone
|
||||
icon: LucideIcon
|
||||
}
|
||||
|
||||
interface BoundaryItem {
|
||||
title: string
|
||||
desc: string
|
||||
tone: StatusTone
|
||||
icon: LucideIcon
|
||||
}
|
||||
|
||||
const safeDisplayKeys = [
|
||||
'system_name',
|
||||
'system_title',
|
||||
'api_base_url',
|
||||
'heartbeat_timeout',
|
||||
'cpu_alert_threshold',
|
||||
'mem_alert_threshold',
|
||||
'disk_alert_threshold',
|
||||
'alert_streak_required',
|
||||
'theme',
|
||||
'editor_theme',
|
||||
]
|
||||
|
||||
function displaySettingValue(key: string, value: string | null | undefined, valueSet?: boolean | null): string {
|
||||
if (isSensitiveKey(key)) return valueSet || Boolean(value) ? '已配置(隐藏)' : '未配置'
|
||||
if (!value) return '未设置'
|
||||
const safeValue = redactSensitiveText(value, '')
|
||||
if (safeValue.length > 64) return safeValue.slice(0, 61) + '...'
|
||||
return safeValue
|
||||
}
|
||||
|
||||
function settingValue(data: SettingsOverviewData, key: string): string | null {
|
||||
const item = data.settings.find((setting) => setting.key === key)
|
||||
return item?.value || null
|
||||
}
|
||||
|
||||
function hasSetting(data: SettingsOverviewData, key: string): boolean {
|
||||
const item = data.settings.find((setting) => setting.key === key)
|
||||
return Boolean(item?.value_set || item?.value)
|
||||
}
|
||||
|
||||
function ttlProtected(server: ServerAsset): boolean {
|
||||
return Boolean(server.btSessionCleanupOk || server.btSessionCleanupPatched || server.ttl === '86400s')
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '暂无记录'
|
||||
const timestamp = Date.parse(value)
|
||||
if (!Number.isFinite(timestamp)) return value
|
||||
return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).format(new Date(timestamp))
|
||||
}
|
||||
|
||||
function buildCards(data: SettingsOverviewData, servers: ServerAsset[], total: number): SettingCard[] {
|
||||
const configuredBt = servers.filter((server) => server.btConfigured).length
|
||||
const protectedBt = servers.filter((server) => server.btConfigured && ttlProtected(server)).length
|
||||
const allowlist = data.allowlist
|
||||
const apiBaseUrl = settingValue(data, 'api_base_url') || '跟随后端环境配置'
|
||||
const heartbeat = settingValue(data, 'heartbeat_timeout') || '默认阈值'
|
||||
return [
|
||||
{
|
||||
title: '管理员会话',
|
||||
value: '内存 Access Token',
|
||||
desc: 'V2 启动时通过 refresh cookie 恢复登录态;页面不把访问令牌写入 localStorage。',
|
||||
tone: 'green',
|
||||
icon: LockKeyhole,
|
||||
},
|
||||
{
|
||||
title: '登录白名单',
|
||||
value: allowlist?.enabled ? '已启用' : '未启用 / 未读取',
|
||||
desc: allowlist ? '订阅 ' + String(allowlist.subscription_count) + ' 条,手动 ' + String(allowlist.manual_count) + ' 条,共 ' + String(allowlist.total_count) + ' 条。' : '未读取到白名单 API,写操作仍回旧后台。',
|
||||
tone: allowlist?.enabled ? 'green' : 'slate',
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
title: '宝塔 TTL 兜底',
|
||||
value: String(protectedBt) + ' / ' + String(Math.max(configuredBt, 1)),
|
||||
desc: '一键登录继续走后端 login-url,登录前自动检测/修复 hardcoded_3600。',
|
||||
tone: protectedBt >= configuredBt && configuredBt > 0 ? 'green' : 'amber',
|
||||
icon: ServerCog,
|
||||
},
|
||||
{
|
||||
title: '中心 API 地址',
|
||||
value: apiBaseUrl,
|
||||
desc: '用于 Agent / 宝塔回连判断;这里仅显示非敏感公开地址。',
|
||||
tone: 'blue',
|
||||
icon: DatabaseZap,
|
||||
},
|
||||
{
|
||||
title: '心跳阈值',
|
||||
value: heartbeat,
|
||||
desc: '当前资产 ' + String(total) + ' 台;离线判断和风险看板依赖该配置。',
|
||||
tone: 'purple',
|
||||
icon: SlidersHorizontal,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function buildBoundaries(data: SettingsOverviewData): BoundaryItem[] {
|
||||
const hiddenCount = data.settings.filter((setting) => isSensitiveKey(setting.key) && (setting.value_set || setting.value)).length
|
||||
return [
|
||||
{
|
||||
title: '敏感值默认隐藏',
|
||||
desc: '检测到 ' + String(hiddenCount) + ' 个敏感配置仅显示状态;V2 不展示密码、Cookie、Token、私钥和密钥类敏感值。',
|
||||
tone: 'green',
|
||||
icon: EyeOff,
|
||||
},
|
||||
{
|
||||
title: '高风险写操作回旧后台',
|
||||
desc: '凭据编辑、密钥修改、SSH 私钥、白名单增删、系统级写入暂不在 V2 直接执行。',
|
||||
tone: 'amber',
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
{
|
||||
title: '只读概览先迁移',
|
||||
desc: '本页优先迁移查看、核对和跳转能力,后续按风险逐项拆分设置写入表单。',
|
||||
tone: 'blue',
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function SettingsOverviewPage({ servers, total, mode }: SettingsOverviewPageProps): JSX.Element {
|
||||
const admin = useAppStore((state) => state.admin)
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: ['settings-overview-v2'],
|
||||
queryFn: fetchSettingsOverviewData,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const cards = useMemo(() => buildCards(data, servers, total), [data, servers, total])
|
||||
const boundaries = useMemo(() => buildBoundaries(data), [data])
|
||||
const visibleSettings = useMemo(() => data.settings.filter((setting) => safeDisplayKeys.includes(setting.key)), [data])
|
||||
const btConfigured = useMemo(() => servers.filter((server) => server.btConfigured).length, [servers])
|
||||
const sslDisabled = useMemo(() => servers.filter((server) => server.ssl === 'disabled').length, [servers])
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-5 lg:p-10">
|
||||
<section className="grid gap-6 xl:grid-cols-[1.1fr_.9fr]">
|
||||
<Panel className="relative overflow-hidden p-7">
|
||||
<div className="absolute -right-16 -top-16 h-56 w-56 rounded-full bg-cyan-400/10 blur-3xl" />
|
||||
<div className="relative flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<Badge tone="blue" dot className="px-4 py-2">系统设置 V2 · 只读概览</Badge>
|
||||
<h2 className="mt-4 text-3xl font-black tracking-[-0.05em] text-white lg:text-5xl">设置安全边界先迁移</h2>
|
||||
<p className="mt-4 max-w-3xl text-sm font-semibold leading-7 text-slate-400">
|
||||
V2 先提供管理员、登录安全、密钥类配置状态、白名单、宝塔 TTL 兜底的只读总览;任何可能泄漏或改坏生产配置的写操作,继续跳回旧后台处理。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid min-w-64 gap-3 rounded-3xl border border-white/10 bg-slate-950/50 p-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-xs font-black uppercase tracking-[0.16em] text-slate-500">当前管理员</span>
|
||||
<Badge tone={admin?.totp_enabled ? 'green' : 'amber'} dot>{admin?.totp_enabled ? 'TOTP 已启用' : 'TOTP 未启用'}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-2xl bg-cyan-400/15 text-cyan-200"><UserRound className="h-5 w-5" /></div>
|
||||
<div>
|
||||
<div className="text-lg font-black text-white">{admin?.username || '未知管理员'}</div>
|
||||
<div className="text-xs font-bold text-slate-500">创建:{formatDate(admin?.created_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel className="p-7">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-black text-white">迁移状态</h3>
|
||||
<p className="mt-2 text-sm font-semibold text-slate-500">当前设置页不直接写入生产配置。</p>
|
||||
</div>
|
||||
<Badge tone={data.mode === 'live' ? 'green' : 'amber'} dot>{data.mode === 'live' ? '真实 API' : '降级数据'}</Badge>
|
||||
</div>
|
||||
{data.warning ? <div className="mt-5 rounded-3xl border border-amber-400/20 bg-amber-400/10 p-4 text-sm font-semibold leading-6 text-amber-100">{data.warning}</div> : null}
|
||||
<div className="mt-5 grid gap-3">
|
||||
<StatusLine label="设置读取" value={data.mode === 'live' ? '已连接 /api/settings' : '使用安全降级'} tone={data.mode === 'live' ? 'green' : 'amber'} />
|
||||
<StatusLine label="宝塔配置" value={String(btConfigured) + ' 台已配置'} tone="blue" />
|
||||
<StatusLine label="面板 SSL" value={String(sslDisabled) + ' 台未开启 SSL'} tone={sslDisabled > 0 ? 'amber' : 'green'} />
|
||||
<StatusLine label="数据源" value={mode === 'live' ? '资产 API 实时' : '资产演示数据'} tone={mode === 'live' ? 'green' : 'slate'} />
|
||||
</div>
|
||||
</Panel>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-2 2xl:grid-cols-3">
|
||||
{cards.map((card) => (
|
||||
<Panel key={card.title} className="group p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className={cn('grid h-12 w-12 place-items-center rounded-2xl border border-white/10 bg-white/[0.04]', card.tone === 'green' ? 'text-emerald-200' : card.tone === 'amber' ? 'text-amber-200' : card.tone === 'purple' ? 'text-violet-200' : 'text-cyan-200')}>
|
||||
<card.icon className="h-5 w-5" />
|
||||
</div>
|
||||
<Badge tone={card.tone} dot>{card.title}</Badge>
|
||||
</div>
|
||||
<div className="mt-5 break-words text-2xl font-black text-white">{card.value}</div>
|
||||
<p className="mt-3 text-sm font-semibold leading-6 text-slate-500">{card.desc}</p>
|
||||
</Panel>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-[.9fr_1.1fr]">
|
||||
<Panel className="p-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-black text-white">安全边界</h3>
|
||||
<p className="mt-2 text-sm font-semibold text-slate-500">先把“能安全看”的迁移到 V2。</p>
|
||||
</div>
|
||||
<Settings className="h-5 w-5 text-slate-500" />
|
||||
</div>
|
||||
<div className="mt-6 space-y-4">
|
||||
{boundaries.map((item) => (
|
||||
<div key={item.title} className="rounded-3xl border border-white/10 bg-slate-950/40 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-white/[0.04] text-cyan-200"><item.icon className="h-5 w-5" /></div>
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-black text-white">{item.title}</span>
|
||||
<Badge tone={item.tone}>{item.tone === 'green' ? '已约束' : item.tone === 'amber' ? '回旧后台' : 'V2 规划'}</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-sm font-semibold leading-6 text-slate-500">{item.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<a href="/app/" className="focus-ring mt-6 inline-flex items-center gap-2 rounded-2xl border border-cyan-300/25 bg-cyan-400/10 px-4 py-3 text-sm font-black text-cyan-200 hover:bg-cyan-400/15">
|
||||
<ExternalLink className="h-4 w-4" /> 打开旧后台设置写操作
|
||||
</a>
|
||||
</Panel>
|
||||
|
||||
<Panel className="overflow-hidden p-0">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-white/10 p-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-black text-white">可安全展示的设置</h3>
|
||||
<p className="mt-2 text-sm font-semibold text-slate-500">只列出非敏感键;敏感键仅统计状态不显示值。</p>
|
||||
</div>
|
||||
<Badge tone="slate">{String(visibleSettings.length)} 项</Badge>
|
||||
</div>
|
||||
<div className="divide-y divide-white/10">
|
||||
{visibleSettings.map((setting) => (
|
||||
<div key={setting.key} className="grid gap-3 p-5 md:grid-cols-[220px_1fr_140px] md:items-center">
|
||||
<div className="font-mono text-xs font-black text-cyan-200">{setting.key}</div>
|
||||
<div className="break-words text-sm font-semibold text-slate-200">{displaySettingValue(setting.key, setting.value, setting.value_set)}</div>
|
||||
<div className="text-xs font-bold text-slate-500 md:text-right">{formatDate(setting.updated_at)}</div>
|
||||
</div>
|
||||
))}
|
||||
{visibleSettings.length === 0 ? (
|
||||
<div className="p-8 text-sm font-semibold text-slate-500">暂无可安全展示的设置项,或当前环境未写入 settings 表。</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Panel>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusLine({ label, value, tone }: { label: string; value: string; tone: StatusTone }): JSX.Element {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 rounded-2xl border border-white/10 bg-slate-950/40 px-4 py-3">
|
||||
<span className="text-sm font-bold text-slate-500">{label}</span>
|
||||
<Badge tone={tone} dot>{value}</Badge>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ArrowUpRight } from 'lucide-react'
|
||||
import { Panel } from '~components/ui/Panel'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface StatCardProps {
|
||||
title: string
|
||||
value: string
|
||||
sub: string
|
||||
accent: string
|
||||
}
|
||||
|
||||
export function StatCard({ title, value, sub, accent }: StatCardProps): JSX.Element {
|
||||
return (
|
||||
<Panel className="p-5">
|
||||
<div className="text-xs font-bold text-slate-400">{title}</div>
|
||||
<div className="mt-3 flex items-end justify-between">
|
||||
<div className="text-4xl font-black tracking-[-0.07em] text-slate-50">{value}</div>
|
||||
<ArrowUpRight className={cn('h-5 w-5', accent)} />
|
||||
</div>
|
||||
<div className={cn('mt-3 text-xs font-bold', accent)}>{sub}</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Panel } from '~components/ui/Panel'
|
||||
import type { ServerAsset } from '../data/dashboardData'
|
||||
|
||||
interface TaskCenterProps {
|
||||
servers: ServerAsset[]
|
||||
total: number
|
||||
mode: 'live' | 'fallback'
|
||||
}
|
||||
|
||||
function percent(done: number, total: number): number {
|
||||
if (total <= 0) return 0
|
||||
return Math.max(0, Math.min(100, Math.round((done / total) * 100)))
|
||||
}
|
||||
|
||||
export function TaskCenter({ servers, total, mode }: TaskCenterProps): JSX.Element {
|
||||
const effectiveTotal = Math.max(total, servers.length)
|
||||
const ttlFixed = servers.filter((server) => server.btSessionCleanupOk || server.btSessionCleanupPatched || server.ttl === '86400s').length
|
||||
const sslReady = servers.filter((server) => server.ssl === 'enabled' || server.ssl === 'internal').length
|
||||
const online = servers.filter((server) => server.online !== false).length
|
||||
const tasks = [
|
||||
{ name: 'BT TTL 自动巡检', progress: percent(ttlFixed, servers.length), done: String(ttlFixed) + ' / ' + String(servers.length), color: 'bg-emerald-400' },
|
||||
{ name: 'SSL 批量扫描', progress: percent(sslReady, servers.length), done: String(sslReady) + ' / ' + String(servers.length), color: 'bg-amber-400' },
|
||||
{ name: 'Agent 心跳同步', progress: percent(online, effectiveTotal), done: String(online) + ' / ' + String(effectiveTotal), color: 'bg-cyan-400' },
|
||||
]
|
||||
|
||||
return (
|
||||
<Panel className="p-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h2 className="text-xl font-black tracking-tight text-white">任务中心</h2>
|
||||
<span className="text-xs font-black text-slate-500">{mode === 'live' ? 'LIVE' : 'DEMO'}</span>
|
||||
</div>
|
||||
<div className="mt-6 space-y-5">
|
||||
{tasks.map((task) => (
|
||||
<div key={task.name}>
|
||||
<div className="mb-2 flex items-center justify-between text-sm">
|
||||
<span className="font-bold text-slate-200">{task.name}</span>
|
||||
<span className="font-bold text-slate-500">{task.done}</span>
|
||||
</div>
|
||||
<div className="h-2.5 overflow-hidden rounded-full bg-slate-800/80">
|
||||
<div className={task.color + ' h-full rounded-full'} style={{ width: String(task.progress) + '%' }} />
|
||||
</div>
|
||||
<div className="mt-1 text-xs font-black text-slate-500">{task.progress}%</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Activity, AlertTriangle, CheckCircle2, Clock3, ExternalLink, Filter, Gauge, PlayCircle, RotateCw, Search, Server, ShieldCheck, TimerReset, Wrench } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { Badge } from '~components/ui/Badge'
|
||||
import { Panel } from '~components/ui/Panel'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ServerAsset } from '../data/dashboardData'
|
||||
|
||||
interface TaskCenterPageProps {
|
||||
servers: ServerAsset[]
|
||||
total: number
|
||||
mode: 'live' | 'fallback'
|
||||
}
|
||||
|
||||
type TaskStatus = 'running' | 'completed' | 'attention' | 'queued'
|
||||
type TaskFilter = 'all' | TaskStatus
|
||||
|
||||
interface TaskItem {
|
||||
id: string
|
||||
name: string
|
||||
desc: string
|
||||
type: string
|
||||
status: TaskStatus
|
||||
progress: number
|
||||
affected: number
|
||||
total: number
|
||||
updatedAt: string
|
||||
icon: LucideIcon
|
||||
tone: string
|
||||
bar: string
|
||||
}
|
||||
|
||||
const filters: Array<{ label: string; value: TaskFilter }> = [
|
||||
{ label: '全部任务', value: 'all' },
|
||||
{ label: '运行中', value: 'running' },
|
||||
{ label: '已完成', value: 'completed' },
|
||||
{ label: '需要关注', value: 'attention' },
|
||||
{ label: '排队中', value: 'queued' },
|
||||
]
|
||||
|
||||
function percent(done: number, total: number): number {
|
||||
if (total <= 0) return 0
|
||||
return Math.max(0, Math.min(100, Math.round((done / total) * 100)))
|
||||
}
|
||||
|
||||
function isTtlProtected(server: ServerAsset): boolean {
|
||||
return Boolean(server.btSessionCleanupOk || server.btSessionCleanupPatched || server.ttl === '86400s')
|
||||
}
|
||||
|
||||
function statusLabel(status: TaskStatus): string {
|
||||
if (status === 'running') return '运行中'
|
||||
if (status === 'completed') return '已完成'
|
||||
if (status === 'attention') return '需要关注'
|
||||
return '排队中'
|
||||
}
|
||||
|
||||
function statusTone(status: TaskStatus): 'green' | 'amber' | 'red' | 'blue' {
|
||||
if (status === 'completed') return 'green'
|
||||
if (status === 'attention') return 'red'
|
||||
if (status === 'running') return 'blue'
|
||||
return 'amber'
|
||||
}
|
||||
|
||||
function buildTasks(servers: ServerAsset[], total: number): TaskItem[] {
|
||||
const effectiveTotal = Math.max(total, servers.length)
|
||||
const configuredBt = servers.filter((server) => server.btConfigured).length
|
||||
const ttlProtected = servers.filter(isTtlProtected).length
|
||||
const needsTtlFix = servers.filter((server) => server.btConfigured && !isTtlProtected(server)).length
|
||||
const sslReady = servers.filter((server) => server.ssl === 'enabled' || server.ssl === 'internal').length
|
||||
const sslOff = servers.filter((server) => server.ssl === 'disabled').length
|
||||
const online = servers.filter((server) => server.online !== false).length
|
||||
const risks = servers.filter((server) => server.risk === 'warning' || server.risk === 'critical').length
|
||||
const now = new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit' }).format(new Date())
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'bt-ttl-sweep',
|
||||
name: 'BT TTL 自动巡检',
|
||||
desc: '持续检查宝塔 task.py hardcoded_3600,登录前仍由后端兜底检测/修复。',
|
||||
type: '宝塔稳定性',
|
||||
status: needsTtlFix > 0 ? 'attention' : 'completed',
|
||||
progress: percent(ttlProtected, Math.max(servers.length, 1)),
|
||||
affected: ttlProtected,
|
||||
total: servers.length,
|
||||
updatedAt: now,
|
||||
icon: TimerReset,
|
||||
tone: needsTtlFix > 0 ? 'text-amber-300' : 'text-emerald-300',
|
||||
bar: needsTtlFix > 0 ? 'bg-amber-400' : 'bg-emerald-400',
|
||||
},
|
||||
{
|
||||
id: 'bt-login-guard',
|
||||
name: '宝塔一键登录兜底队列',
|
||||
desc: '新增宝塔或未复查宝塔,在一键登录前先触发 TTL 兜底,不在前端生成 tmp_login token。',
|
||||
type: '登录安全',
|
||||
status: configuredBt > ttlProtected ? 'running' : 'completed',
|
||||
progress: percent(ttlProtected, Math.max(configuredBt, 1)),
|
||||
affected: configuredBt,
|
||||
total: configuredBt,
|
||||
updatedAt: now,
|
||||
icon: ShieldCheck,
|
||||
tone: 'text-cyan-300',
|
||||
bar: 'bg-cyan-400',
|
||||
},
|
||||
{
|
||||
id: 'ssl-advice',
|
||||
name: '面板 SSL 建议扫描',
|
||||
desc: '识别公网宝塔 verify_ssl 未开启的服务器,先展示建议,高风险改动回旧后台确认。',
|
||||
type: '安全建议',
|
||||
status: sslOff > 0 ? 'attention' : 'completed',
|
||||
progress: percent(sslReady, Math.max(servers.length, 1)),
|
||||
affected: sslOff,
|
||||
total: servers.length,
|
||||
updatedAt: now,
|
||||
icon: Wrench,
|
||||
tone: sslOff > 0 ? 'text-amber-300' : 'text-emerald-300',
|
||||
bar: sslOff > 0 ? 'bg-amber-400' : 'bg-emerald-400',
|
||||
},
|
||||
{
|
||||
id: 'agent-heartbeat',
|
||||
name: 'Agent 心跳同步',
|
||||
desc: '同步服务器在线状态、CPU、内存、磁盘和最近心跳,用于资产中心快速响应。',
|
||||
type: '资产同步',
|
||||
status: online < effectiveTotal ? 'running' : 'completed',
|
||||
progress: percent(online, effectiveTotal),
|
||||
affected: online,
|
||||
total: effectiveTotal,
|
||||
updatedAt: now,
|
||||
icon: Activity,
|
||||
tone: 'text-blue-300',
|
||||
bar: 'bg-blue-400',
|
||||
},
|
||||
{
|
||||
id: 'asset-risk-sync',
|
||||
name: '服务器资产风险同步',
|
||||
desc: '汇总离线、TTL、SSL、宝塔配置状态;写操作和批量修复仍回旧后台执行。',
|
||||
type: '风险汇总',
|
||||
status: risks > 0 ? 'attention' : 'completed',
|
||||
progress: percent(Math.max(servers.length - risks, 0), Math.max(servers.length, 1)),
|
||||
affected: risks,
|
||||
total: servers.length,
|
||||
updatedAt: now,
|
||||
icon: Server,
|
||||
tone: risks > 0 ? 'text-red-300' : 'text-emerald-300',
|
||||
bar: risks > 0 ? 'bg-red-400' : 'bg-emerald-400',
|
||||
},
|
||||
{
|
||||
id: 'script-audit',
|
||||
name: '脚本执行审计同步',
|
||||
desc: '管理员远程执行命令属于预期能力;V2 第一版只展示入口,执行/停止/重试先回旧后台。',
|
||||
type: '管理员脚本',
|
||||
status: 'queued',
|
||||
progress: 30,
|
||||
affected: 0,
|
||||
total: effectiveTotal,
|
||||
updatedAt: now,
|
||||
icon: PlayCircle,
|
||||
tone: 'text-violet-300',
|
||||
bar: 'bg-violet-400',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function TaskCenterPage({ servers, total, mode }: TaskCenterPageProps): JSX.Element {
|
||||
const [query, setQuery] = useState('')
|
||||
const [filter, setFilter] = useState<TaskFilter>('all')
|
||||
|
||||
const tasks = useMemo(() => buildTasks(servers, total), [servers, total])
|
||||
const keyword = query.trim().toLowerCase()
|
||||
const filteredTasks = useMemo(() => tasks.filter((task) => {
|
||||
const matchesKeyword = !keyword || [task.name, task.desc, task.type, statusLabel(task.status)]
|
||||
.some((value) => value.toLowerCase().includes(keyword))
|
||||
const matchesStatus = filter === 'all' || task.status === filter
|
||||
return matchesKeyword && matchesStatus
|
||||
}), [filter, keyword, tasks])
|
||||
|
||||
const runningCount = tasks.filter((task) => task.status === 'running').length
|
||||
const completedCount = tasks.filter((task) => task.status === 'completed').length
|
||||
const attentionCount = tasks.filter((task) => task.status === 'attention').length
|
||||
const queuedCount = tasks.filter((task) => task.status === 'queued').length
|
||||
|
||||
return (
|
||||
<div id="tasks" className="space-y-6 p-5 lg:p-10">
|
||||
<Panel className="relative overflow-hidden p-6">
|
||||
<div className="absolute -right-16 -top-20 h-56 w-56 rounded-full bg-violet-400/10 blur-3xl" />
|
||||
<div className="flex flex-wrap items-start justify-between gap-5">
|
||||
<div className="max-w-3xl">
|
||||
<Badge tone="blue" dot className="px-4 py-2">V2 任务中心</Badge>
|
||||
<h2 className="mt-4 text-3xl font-black tracking-[-0.05em] text-white lg:text-5xl">任务中心 / 自动化运行态</h2>
|
||||
<p className="mt-3 text-sm font-semibold leading-7 text-slate-400">
|
||||
这一版先迁移只读运行态和任务入口:集中展示宝塔 TTL 兜底、SSL 建议、Agent 同步、资产风险汇总。脚本执行、停止、重试等高风险写操作暂时回旧后台,避免绕过既有权限和审计链路。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<a href="/app/" className="focus-ring inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-black text-cyan-200 hover:bg-cyan-400/10">
|
||||
<ExternalLink className="h-4 w-4" /> 旧后台执行/重试
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<TaskMetric icon={Gauge} label="运行中" value={String(runningCount)} sub="同步 / 兜底队列" tone="text-blue-300" />
|
||||
<TaskMetric icon={CheckCircle2} label="已完成" value={String(completedCount)} sub="当前检查通过" tone="text-emerald-300" />
|
||||
<TaskMetric icon={AlertTriangle} label="需要关注" value={String(attentionCount)} sub="TTL / SSL / 离线风险" tone={attentionCount ? 'text-amber-300' : 'text-emerald-300'} />
|
||||
<TaskMetric icon={Clock3} label="排队中" value={String(queuedCount)} sub="深度功能回旧后台" tone="text-violet-300" />
|
||||
</section>
|
||||
|
||||
<Panel className="p-5">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-2xl bg-violet-400/10 text-violet-200"><Filter className="h-5 w-5" /></div>
|
||||
<div>
|
||||
<div className="text-lg font-black text-white">任务过滤器</div>
|
||||
<div className="text-sm font-semibold text-slate-500">当前数据源:{mode === 'live' ? '实时 API' : '演示 fallback'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex min-w-0 flex-1 items-center gap-3 rounded-2xl border border-white/10 bg-slate-950/50 px-4 py-3 xl:max-w-md">
|
||||
<Search className="h-4 w-4 text-slate-500" />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索任务、类型、状态" className="min-w-0 flex-1 bg-transparent text-sm font-semibold text-white outline-none placeholder:text-slate-600" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
{filters.map((item) => (
|
||||
<button key={item.value} type="button" onClick={() => setFilter(item.value)} className={cn('focus-ring rounded-2xl px-4 py-2 text-sm font-black transition', filter === item.value ? 'bg-cyan-400 text-slate-950' : 'border border-white/10 bg-white/[0.03] text-slate-400 hover:text-white')}>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-2">
|
||||
{filteredTasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
<Panel className="grid gap-4 p-6 lg:grid-cols-[1fr_auto] lg:items-center">
|
||||
<div>
|
||||
<div className="text-xl font-black text-white">安全边界</div>
|
||||
<p className="mt-2 text-sm font-semibold leading-7 text-slate-500">
|
||||
V2 任务中心当前不直接调用脚本执行 API,不展示凭据、不展示 Cookie/Token/密钥类字段。宝塔一键登录仍在宝塔页面和资产页面走后端接口,后端继续负责 TTL 兜底修复和审计。
|
||||
</p>
|
||||
</div>
|
||||
<a href="/app/" className="focus-ring inline-flex items-center justify-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-black text-slate-200 hover:bg-white/[0.08]">
|
||||
<ExternalLink className="h-4 w-4" /> 打开旧后台完整任务
|
||||
</a>
|
||||
</Panel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TaskMetricProps {
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
value: string
|
||||
sub: string
|
||||
tone: string
|
||||
}
|
||||
|
||||
function TaskMetric({ icon: Icon, label, value, sub, tone }: TaskMetricProps): JSX.Element {
|
||||
return (
|
||||
<Panel className="group overflow-hidden p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-2xl border border-white/10 bg-white/[0.04]">
|
||||
<Icon className={'h-5 w-5 ' + tone} />
|
||||
</div>
|
||||
<RotateCw className="h-4 w-4 text-slate-600 transition group-hover:text-cyan-300" />
|
||||
</div>
|
||||
<div className="mt-5 text-xs font-black uppercase tracking-[0.14em] text-slate-500">{label}</div>
|
||||
<div className="mt-2 text-3xl font-black text-white">{value}</div>
|
||||
<p className="mt-2 text-sm font-medium leading-6 text-slate-500">{sub}</p>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function TaskCard({ task }: { task: TaskItem }): JSX.Element {
|
||||
return (
|
||||
<Panel className="relative overflow-hidden p-5">
|
||||
<div className="absolute -right-12 -top-12 h-32 w-32 rounded-full bg-cyan-400/5 blur-3xl" />
|
||||
<div className="relative flex items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 gap-4">
|
||||
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-2xl border border-white/10 bg-white/[0.04]">
|
||||
<task.icon className={'h-5 w-5 ' + task.tone} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-lg font-black text-white">{task.name}</h3>
|
||||
<Badge tone={statusTone(task.status)} dot>{statusLabel(task.status)}</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-sm font-semibold leading-7 text-slate-500">{task.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden text-right lg:block">
|
||||
<div className="text-xs font-black uppercase tracking-[0.16em] text-slate-600">{task.type}</div>
|
||||
<div className="mt-2 text-sm font-black text-slate-400">{task.updatedAt}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mt-5 grid gap-4 md:grid-cols-[1fr_auto] md:items-end">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between text-sm">
|
||||
<span className="font-black text-slate-300">进度</span>
|
||||
<span className="font-black text-slate-500">{task.affected} / {task.total}</span>
|
||||
</div>
|
||||
<div className="h-2.5 overflow-hidden rounded-full bg-slate-800/90">
|
||||
<div className={task.bar + ' h-full rounded-full'} style={{ width: String(task.progress) + '%' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-2xl font-black text-white">{task.progress}%</div>
|
||||
</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { Activity, AlertTriangle, Archive, Code2, ExternalLink, FileClock, FileSearch, FolderOpen, LockKeyhole, Search, Server, ShieldCheck, TerminalSquare, UploadCloud } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { Badge } from '~components/ui/Badge'
|
||||
import { Panel } from '~components/ui/Panel'
|
||||
import { ReadOnlyFileBrowser } from './ReadOnlyFileBrowser'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { redactSensitiveText } from '@/lib/redaction'
|
||||
import { fetchAuditLogData } from '../api/dashboardApi'
|
||||
import type { AuditLogEntry } from '../api/dashboardApi'
|
||||
import type { ServerAsset } from '../data/dashboardData'
|
||||
|
||||
interface TerminalFilesPageProps {
|
||||
servers: ServerAsset[]
|
||||
total: number
|
||||
mode: 'live' | 'fallback'
|
||||
}
|
||||
|
||||
type CapabilityTone = 'green' | 'blue' | 'amber' | 'red' | 'slate' | 'purple'
|
||||
type CapabilityStatus = 'v2-readonly' | 'legacy-action' | 'guarded'
|
||||
|
||||
interface CapabilityCard {
|
||||
title: string
|
||||
desc: string
|
||||
status: CapabilityStatus
|
||||
tone: CapabilityTone
|
||||
icon: LucideIcon
|
||||
legacyPath: string
|
||||
metrics: string
|
||||
}
|
||||
|
||||
interface SafeActivity {
|
||||
id: string
|
||||
title: string
|
||||
detail: string
|
||||
actor: string
|
||||
time: string
|
||||
tone: CapabilityTone
|
||||
}
|
||||
|
||||
function maskSensitive(value: string | null | undefined): string {
|
||||
return redactSensitiveText(value, '已隐藏', 180)
|
||||
}
|
||||
|
||||
|
||||
function includesAny(value: string, words: string[]): boolean {
|
||||
const lower = value.toLowerCase()
|
||||
return words.some((word) => lower.includes(word))
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '未知时间'
|
||||
const timestamp = Date.parse(value)
|
||||
if (!Number.isFinite(timestamp)) return value
|
||||
return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).format(new Date(timestamp))
|
||||
}
|
||||
|
||||
function statusLabel(status: CapabilityStatus): string {
|
||||
if (status === 'v2-readonly') return 'V2 只读'
|
||||
if (status === 'guarded') return '后端兜底'
|
||||
return '旧后台执行'
|
||||
}
|
||||
|
||||
function buildCapabilities(servers: ServerAsset[], total: number): CapabilityCard[] {
|
||||
const online = servers.filter((server) => server.online).length
|
||||
const btConfigured = servers.filter((server) => server.btConfigured).length
|
||||
const risky = servers.filter((server) => server.risk === 'critical' || server.risk === 'warning').length
|
||||
return [
|
||||
{
|
||||
title: 'WebSSH 终端',
|
||||
desc: 'V2 先展示入口和安全边界;真实交互终端继续回旧后台,避免在迁移阶段重写 WebSocket 输入输出链路。',
|
||||
status: 'legacy-action',
|
||||
tone: 'amber',
|
||||
icon: TerminalSquare,
|
||||
legacyPath: '/app/terminal',
|
||||
metrics: String(online) + ' / ' + String(total) + ' 台在线',
|
||||
},
|
||||
{
|
||||
title: '远程文件浏览',
|
||||
desc: '后端已有 /api/files/browse + ETag 只读浏览能力;V2 本页先做入口,文件写入/删除/上传仍留在旧后台。',
|
||||
status: 'v2-readonly',
|
||||
tone: 'blue',
|
||||
icon: FolderOpen,
|
||||
legacyPath: '/app/files',
|
||||
metrics: 'GET browse 可接入',
|
||||
},
|
||||
{
|
||||
title: '文件推送 / 上传',
|
||||
desc: '上传、解压、权限修改、跨服务器传输属于会改变远端状态的操作,当前继续由旧后台完整处理。',
|
||||
status: 'legacy-action',
|
||||
tone: 'amber',
|
||||
icon: UploadCloud,
|
||||
legacyPath: '/app/push',
|
||||
metrics: '写操作回旧后台',
|
||||
},
|
||||
{
|
||||
title: '脚本库 / 命令执行',
|
||||
desc: 'script_service 是管理员远程执行 shell 的预期能力;V2 不绕过后端、不直接执行、不展示脚本内容中的敏感片段。',
|
||||
status: 'guarded',
|
||||
tone: 'purple',
|
||||
icon: Code2,
|
||||
legacyPath: '/app/scripts',
|
||||
metrics: String(risky) + ' 台风险资产',
|
||||
},
|
||||
{
|
||||
title: '命令日志 / 执行记录',
|
||||
desc: 'V2 只显示审计摘要,并二次脱敏 detail;执行详情、停止、重试、日志拉取仍回旧后台。',
|
||||
status: 'v2-readonly',
|
||||
tone: 'green',
|
||||
icon: FileClock,
|
||||
legacyPath: '/app/commands',
|
||||
metrics: '审计摘要已接入',
|
||||
},
|
||||
{
|
||||
title: '宝塔联动保护',
|
||||
desc: '宝塔一键登录仍在资产/宝塔页调用后端 login-url;登录前 TTL hardcoded_3600 兜底修复不受本页影响。',
|
||||
status: 'guarded',
|
||||
tone: 'green',
|
||||
icon: ShieldCheck,
|
||||
legacyPath: '/app/',
|
||||
metrics: String(btConfigured) + ' 台宝塔配置',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function normalizeActivities(items: AuditLogEntry[]): SafeActivity[] {
|
||||
const keywords = ['terminal', 'webssh', 'ssh', 'command', 'script', 'sync', 'file', 'browse', 'upload', 'download', 'chmod', 'compress', 'decompress']
|
||||
return items
|
||||
.filter((item) => includesAny([item.action, item.target_type, item.target_name, item.detail].filter(Boolean).join(' '), keywords))
|
||||
.slice(0, 8)
|
||||
.map((item) => {
|
||||
const rawTitle = item.action || item.target_type || 'operation'
|
||||
const danger = includesAny(rawTitle + ' ' + (item.detail || ''), ['delete', 'chmod', 'write', 'upload', 'exec', 'script'])
|
||||
return {
|
||||
id: String(item.id),
|
||||
title: rawTitle.replaceAll('_', ' '),
|
||||
detail: maskSensitive(item.detail || item.target_name || item.target_type),
|
||||
actor: item.admin_username || 'system',
|
||||
time: formatDate(item.created_at),
|
||||
tone: danger ? 'amber' : 'blue',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function TerminalFilesPage({ servers, total, mode }: TerminalFilesPageProps): JSX.Element {
|
||||
const [query, setQuery] = useState('')
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: ['terminal-files-audit-v2'],
|
||||
queryFn: fetchAuditLogData,
|
||||
staleTime: 45_000,
|
||||
})
|
||||
|
||||
const capabilities = useMemo(() => buildCapabilities(servers, total), [servers, total])
|
||||
const filteredCapabilities = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase()
|
||||
if (!normalized) return capabilities
|
||||
return capabilities.filter((item) => [item.title, item.desc, item.metrics].join(' ').toLowerCase().includes(normalized))
|
||||
}, [capabilities, query])
|
||||
const activities = useMemo(() => normalizeActivities(data.auditItems), [data.auditItems])
|
||||
const online = useMemo(() => servers.filter((server) => server.online).length, [servers])
|
||||
const writableActions = useMemo(() => capabilities.filter((item) => item.status === 'legacy-action').length, [capabilities])
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-5 lg:p-10">
|
||||
<Panel className="relative overflow-hidden p-7">
|
||||
<div className="absolute -right-20 -top-20 h-64 w-64 rounded-full bg-violet-400/10 blur-3xl" />
|
||||
<div className="relative grid gap-6 xl:grid-cols-[1fr_360px] xl:items-end">
|
||||
<div>
|
||||
<Badge tone="purple" dot className="px-4 py-2">终端 / 文件 V2 · 安全入口</Badge>
|
||||
<h2 className="mt-4 text-3xl font-black tracking-[-0.05em] text-white lg:text-5xl">高风险操作先分层迁移</h2>
|
||||
<p className="mt-4 max-w-4xl text-sm font-semibold leading-7 text-slate-400">
|
||||
终端、文件写入、脚本执行都可能直接改变服务器状态。V2 当前先迁移导航、只读摘要、审计脱敏和安全边界;真正执行、上传、删除、停止、重试仍回旧后台,保证功能不减少且不绕过后端审计。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3 rounded-3xl border border-white/10 bg-slate-950/50 p-5">
|
||||
<MetricRow label="在线资产" value={String(online) + ' / ' + String(total)} tone="green" />
|
||||
<MetricRow label="V2 数据源" value={mode === 'live' ? '实时 API' : '演示数据'} tone={mode === 'live' ? 'green' : 'slate'} />
|
||||
<MetricRow label="写操作策略" value={String(writableActions) + ' 类回旧后台'} tone="amber" />
|
||||
<MetricRow label="审计来源" value={data.mode === 'live' ? '真实审计' : '降级审计'} tone={data.mode === 'live' ? 'blue' : 'amber'} />
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<ReadOnlyFileBrowser servers={servers} mode={mode} />
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-[1fr_360px]">
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-xl font-black text-white">能力分层</h3>
|
||||
<p className="mt-1 text-sm font-semibold text-slate-500">先把可安全看的放到 V2,会改变服务器的动作继续跳旧后台。</p>
|
||||
</div>
|
||||
<label className="focus-within:ring-2 focus-within:ring-cyan-300/40 flex w-full max-w-md items-center gap-3 rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-3 text-sm font-semibold text-slate-400">
|
||||
<Search className="h-4 w-4" />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索终端、文件、脚本、审计" className="w-full bg-transparent text-slate-100 outline-none placeholder:text-slate-600" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{filteredCapabilities.map((item) => (
|
||||
<Panel key={item.title} className="group p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className={cn('grid h-12 w-12 place-items-center rounded-2xl border border-white/10 bg-white/[0.04]', item.tone === 'green' ? 'text-emerald-200' : item.tone === 'amber' ? 'text-amber-200' : item.tone === 'purple' ? 'text-violet-200' : 'text-cyan-200')}>
|
||||
<item.icon className="h-5 w-5" />
|
||||
</div>
|
||||
<Badge tone={item.tone} dot>{statusLabel(item.status)}</Badge>
|
||||
</div>
|
||||
<div className="mt-5 text-xl font-black text-white">{item.title}</div>
|
||||
<p className="mt-3 text-sm font-semibold leading-6 text-slate-500">{item.desc}</p>
|
||||
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
|
||||
<span className="rounded-full bg-slate-900 px-3 py-1 text-xs font-black text-slate-300">{item.metrics}</span>
|
||||
<a href={item.legacyPath} className="focus-ring inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-black text-cyan-200 hover:bg-cyan-400/10">
|
||||
旧后台打开 <ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
</Panel>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<Panel className="p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-xl font-black text-white">安全边界</h3>
|
||||
<p className="mt-2 text-sm font-semibold text-slate-500">本页不会直接执行 shell。</p>
|
||||
</div>
|
||||
<LockKeyhole className="h-5 w-5 text-emerald-300" />
|
||||
</div>
|
||||
<div className="mt-5 space-y-3 text-sm font-semibold leading-6 text-slate-400">
|
||||
<BoundaryLine icon={TerminalSquare} text="不创建 WebSSH 会话,不发送键盘输入。" />
|
||||
<BoundaryLine icon={FolderOpen} text="不上传、不删除、不写文件、不 chmod。" />
|
||||
<BoundaryLine icon={Code2} text="不调用 /api/scripts/exec,不绕过危险命令检查。" />
|
||||
<BoundaryLine icon={FileSearch} text="审计 detail 在前端二次脱敏后展示。" />
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel className="p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-xl font-black text-white">最近相关审计</h3>
|
||||
<p className="mt-2 text-sm font-semibold text-slate-500">终端 / 文件 / 脚本相关摘要。</p>
|
||||
</div>
|
||||
<Activity className="h-5 w-5 text-cyan-300" />
|
||||
</div>
|
||||
{data.warning ? <div className="mt-4 rounded-2xl border border-amber-400/20 bg-amber-400/10 p-3 text-xs font-bold leading-5 text-amber-100">{data.warning}</div> : null}
|
||||
<div className="mt-5 space-y-3">
|
||||
{activities.map((item) => (
|
||||
<div key={item.id} className="rounded-2xl border border-white/10 bg-slate-950/40 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-black text-white">{item.title}</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs font-semibold leading-5 text-slate-500">{item.detail}</p>
|
||||
</div>
|
||||
<Badge tone={item.tone}>{item.time}</Badge>
|
||||
</div>
|
||||
<div className="mt-2 text-xs font-bold text-slate-600">operator: {item.actor}</div>
|
||||
</div>
|
||||
))}
|
||||
{activities.length === 0 ? (
|
||||
<div className="rounded-2xl border border-white/10 bg-slate-950/40 p-5 text-sm font-semibold text-slate-500">暂无终端 / 文件 / 脚本相关审计摘要。</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Panel className="p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-2xl bg-amber-400/15 text-amber-200"><AlertTriangle className="h-5 w-5" /></div>
|
||||
<div>
|
||||
<h3 className="text-lg font-black text-white">迁移提醒</h3>
|
||||
<p className="mt-1 text-sm font-semibold leading-6 text-slate-500">后续如果要把真实终端和文件写操作搬到 V2,需要先拆 WebSocket token、路径校验、二次确认、审计日志和权限提示,不能只重做界面。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<a href="/app/terminal" className="focus-ring inline-flex items-center gap-2 rounded-2xl bg-cyan-400 px-4 py-3 text-sm font-black text-slate-950"><TerminalSquare className="h-4 w-4" /> 旧终端</a>
|
||||
<a href="/app/files" className="focus-ring inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-black text-cyan-200"><Archive className="h-4 w-4" /> 旧文件管理</a>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MetricRow({ label, value, tone }: { label: string; value: string; tone: CapabilityTone }): JSX.Element {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 rounded-2xl border border-white/10 bg-slate-950/40 px-4 py-3">
|
||||
<span className="text-sm font-bold text-slate-500">{label}</span>
|
||||
<Badge tone={tone} dot>{value}</Badge>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BoundaryLine({ icon: Icon, text }: { icon: LucideIcon; text: string }): JSX.Element {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-2xl bg-slate-950/40 px-4 py-3">
|
||||
<Icon className="h-4 w-4 shrink-0 text-cyan-300" />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Panel } from '~components/ui/Panel'
|
||||
import { Badge } from '~components/ui/Badge'
|
||||
|
||||
interface TrendPanelProps {
|
||||
mode: 'live' | 'fallback'
|
||||
}
|
||||
|
||||
export function TrendPanel({ mode }: TrendPanelProps): JSX.Element {
|
||||
return (
|
||||
<Panel className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-black tracking-tight text-white">资产健康趋势</h2>
|
||||
<p className="mt-1 text-sm font-medium text-slate-500">API 响应、在线率、一键登录成功率</p>
|
||||
</div>
|
||||
<Badge tone={mode === 'live' ? 'blue' : 'amber'}>{mode === 'live' ? '实时' : '演示'}</Badge>
|
||||
</div>
|
||||
<div className="mt-7 h-40 rounded-3xl border border-white/10 bg-slate-950/35 p-4">
|
||||
<svg viewBox="0 0 580 150" className="h-full w-full overflow-visible">
|
||||
<path d="M8 120 C70 86 108 104 160 58 S262 2 336 48 S450 132 570 34" fill="none" stroke="#38bdf8" strokeWidth="8" strokeLinecap="round" />
|
||||
<path d="M8 134 C96 122 138 90 224 98 S362 102 428 62 S510 32 570 50" fill="none" stroke="#22c55e" strokeWidth="8" strokeLinecap="round" opacity="0.72" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Badge tone="blue" dot>响应 126ms</Badge>
|
||||
<Badge tone="green" dot>成功率 98.7%</Badge>
|
||||
<Badge tone="purple" dot>缓存命中 82%</Badge>
|
||||
</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Badge } from '~components/ui/Badge'
|
||||
import { Panel } from '~components/ui/Panel'
|
||||
import { ttlCells } from '../data/dashboardData'
|
||||
import type { ServerAsset } from '../data/dashboardData'
|
||||
|
||||
interface TtlMatrixProps {
|
||||
servers: ServerAsset[]
|
||||
}
|
||||
|
||||
function cellClass(server: ServerAsset | undefined, fallback: string): string {
|
||||
if (!server) return fallback
|
||||
if (server.online === false) return 'bg-red-400'
|
||||
if (server.btSessionCleanupOk || server.btSessionCleanupPatched || server.ttl === '86400s') return 'bg-emerald-400'
|
||||
if (server.btConfigured) return 'bg-amber-400'
|
||||
return 'bg-slate-600'
|
||||
}
|
||||
|
||||
export function TtlMatrix({ servers }: TtlMatrixProps): JSX.Element {
|
||||
const fixed = servers.filter((server) => server.btSessionCleanupOk || server.btSessionCleanupPatched || server.ttl === '86400s').length
|
||||
const needsCheck = servers.filter((server) => server.btConfigured && !(server.btSessionCleanupOk || server.btSessionCleanupPatched || server.ttl === '86400s')).length
|
||||
const offline = servers.filter((server) => server.online === false).length
|
||||
|
||||
return (
|
||||
<Panel className="p-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-black tracking-tight text-white">宝塔 TTL 健康矩阵</h2>
|
||||
<p className="mt-1 text-sm font-medium text-slate-500">一键登录前会自动检测 / 修复 hardcoded_3600。</p>
|
||||
</div>
|
||||
<Badge tone="green" dot>自动兜底</Badge>
|
||||
</div>
|
||||
<div className="mt-6 grid grid-cols-8 gap-2">
|
||||
{ttlCells.map((fallback, index) => {
|
||||
const server = servers[index % Math.max(servers.length, 1)]
|
||||
return <span key={fallback + '-' + String(index)} title={server?.name ?? '演示节点'} className={'h-7 rounded-xl shadow-glow ' + cellClass(server, fallback)} />
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-3 gap-3 text-center text-xs font-black">
|
||||
<div className="rounded-2xl bg-emerald-400/10 p-3 text-emerald-200">健康 {fixed}</div>
|
||||
<div className="rounded-2xl bg-amber-400/10 p-3 text-amber-200">待查 {needsCheck}</div>
|
||||
<div className="rounded-2xl bg-red-400/10 p-3 text-red-200">离线 {offline}</div>
|
||||
</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { Activity, Clock, Cpu, ExternalLink, HardDrive, MemoryStick, Radio, RefreshCw, Search, Server } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { Badge } from '~components/ui/Badge'
|
||||
import { Panel } from '~components/ui/Panel'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { fetchWatchMonitoringData } from '../api/dashboardApi'
|
||||
import type { WatchMetricPoint, WatchProbeRecordItem, WatchSessionItem, WatchSlotItem } from '../api/dashboardApi'
|
||||
import type { ServerAsset } from '../data/dashboardData'
|
||||
|
||||
interface WatchMonitoringPageProps {
|
||||
servers: ServerAsset[]
|
||||
total: number
|
||||
mode: 'live' | 'fallback'
|
||||
}
|
||||
|
||||
function toNumber(value: number | null | undefined): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
function formatPercent(value: number | null | undefined): string {
|
||||
return toNumber(value).toFixed(1) + '%'
|
||||
}
|
||||
|
||||
function formatBps(value: number | null | undefined): string {
|
||||
const bps = toNumber(value)
|
||||
if (bps < 1024) return bps.toFixed(0) + ' B/s'
|
||||
if (bps < 1024 * 1024) return (bps / 1024).toFixed(1) + ' KB/s'
|
||||
return (bps / 1024 / 1024).toFixed(1) + ' MB/s'
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string | null): string {
|
||||
if (!value) return '-'
|
||||
const parsed = Date.parse(value)
|
||||
if (!Number.isFinite(parsed)) return value
|
||||
return new Date(parsed).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
function formatRemaining(value?: number | null): string {
|
||||
if (value === null || value === undefined) return '暂停'
|
||||
const seconds = Math.max(0, value)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const rest = seconds % 60
|
||||
if (minutes <= 0) return rest + ' 秒'
|
||||
return minutes + ' 分 ' + rest + ' 秒'
|
||||
}
|
||||
|
||||
function probeTone(status?: string | null): 'green' | 'amber' | 'red' | 'slate' {
|
||||
if (!status) return 'slate'
|
||||
const normalized = status.toLowerCase()
|
||||
if (normalized === 'ok' || normalized === 'success') return 'green'
|
||||
if (normalized.includes('timeout') || normalized.includes('warn')) return 'amber'
|
||||
return 'red'
|
||||
}
|
||||
|
||||
function agentTone(action?: string | null): 'green' | 'amber' | 'red' | 'slate' | 'purple' {
|
||||
if (!action || action === 'ok') return 'green'
|
||||
if (action === 'upgrade') return 'amber'
|
||||
if (action === 'install') return 'purple'
|
||||
return 'slate'
|
||||
}
|
||||
|
||||
function averageMetric(slots: WatchSlotItem[], selector: (point: WatchMetricPoint) => number | null | undefined): number {
|
||||
const values = slots
|
||||
.map((slot) => slot.metrics ? selector(slot.metrics) : null)
|
||||
.filter((value): value is number => typeof value === 'number' && Number.isFinite(value))
|
||||
if (values.length === 0) return 0
|
||||
return values.reduce((sum, value) => sum + value, 0) / values.length
|
||||
}
|
||||
|
||||
function latestProbeTime(slots: WatchSlotItem[]): string {
|
||||
const timestamps = slots
|
||||
.map((slot) => slot.metrics?.recorded_at || slot.metrics?.timestamp || null)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map((value) => Date.parse(value))
|
||||
.filter((value) => Number.isFinite(value))
|
||||
if (timestamps.length === 0) return '-'
|
||||
return formatDateTime(new Date(Math.max(...timestamps)).toISOString())
|
||||
}
|
||||
|
||||
function sparklineBars(points?: WatchMetricPoint[]): JSX.Element {
|
||||
const safePoints = points && points.length > 0 ? points.slice(-18) : []
|
||||
if (safePoints.length === 0) return <div className="h-10 rounded-2xl bg-slate-900/60" />
|
||||
return (
|
||||
<div className="flex h-10 items-end gap-1 rounded-2xl bg-slate-950/50 p-2">
|
||||
{safePoints.map((point, index) => {
|
||||
const height = Math.max(8, Math.min(100, toNumber(point.cpu_pct || point.mem_pct)))
|
||||
return <span key={String(point.recorded_at || point.timestamp || index)} className="flex-1 rounded-full bg-cyan-300/70" style={{ height: height + '%' }} />
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function findServer(servers: ServerAsset[], slot: WatchSlotItem): ServerAsset | undefined {
|
||||
if (!slot.server_id) return undefined
|
||||
return servers.find((server) => server.sourceId === slot.server_id || String(server.id) === String(slot.server_id))
|
||||
}
|
||||
|
||||
export function WatchMonitoringPage({ servers, total, mode }: WatchMonitoringPageProps): JSX.Element {
|
||||
const [hours, setHours] = useState(24)
|
||||
const [query, setQuery] = useState('')
|
||||
const { data, refetch } = useSuspenseQuery({
|
||||
queryKey: ['watch-monitoring-v2', hours],
|
||||
queryFn: () => fetchWatchMonitoringData(hours),
|
||||
staleTime: 8_000,
|
||||
})
|
||||
|
||||
const activeSlots = useMemo(() => data.slots.filter((slot) => !slot.empty), [data.slots])
|
||||
const emptySlots = useMemo(() => data.slots.filter((slot) => slot.empty).length, [data.slots])
|
||||
const avgCpu = useMemo(() => averageMetric(activeSlots, (point) => point.cpu_pct), [activeSlots])
|
||||
const avgMem = useMemo(() => averageMetric(activeSlots, (point) => point.mem_pct), [activeSlots])
|
||||
const avgDisk = useMemo(() => averageMetric(activeSlots, (point) => point.disk_pct), [activeSlots])
|
||||
const failedProbeCount = useMemo(() => data.probes.filter((probe) => probe.probe_status && probe.probe_status !== 'ok').length, [data.probes])
|
||||
|
||||
const filteredProbes = useMemo(() => {
|
||||
const text = query.trim().toLowerCase()
|
||||
return data.probes.filter((probe) => !text || [probe.server_name, probe.server_id, probe.session_id, probe.probe_status, probe.error].some((value) => String(value || '').toLowerCase().includes(text)))
|
||||
}, [data.probes, query])
|
||||
|
||||
const recentSessions = useMemo(() => data.sessions.slice(0, 8), [data.sessions])
|
||||
|
||||
const handleRefresh = useCallback((): void => {
|
||||
void refetch()
|
||||
}, [refetch])
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-5 pb-5 lg:px-10 lg:pb-10">
|
||||
<Panel className="relative overflow-hidden p-6">
|
||||
<div className="absolute -right-16 -top-16 h-56 w-56 rounded-full bg-cyan-400/10 blur-3xl" />
|
||||
<div className="relative flex flex-wrap items-start justify-between gap-5">
|
||||
<div>
|
||||
<Badge tone="blue" dot className="px-4 py-2">Watch Metrics V2</Badge>
|
||||
<h2 className="mt-4 text-3xl font-black tracking-[-0.05em] text-white md:text-4xl">实时监控只读中心</h2>
|
||||
<p className="mt-3 max-w-3xl text-sm font-semibold leading-7 text-slate-400">
|
||||
复用 /api/watch 的 pins、live、sessions、probe-records 只读接口,先把监控态势、Agent 状态、探针历史搬到新后台;新增监控槽、停止监控、安装 psutil 等写操作仍回旧后台。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge tone={data.mode === 'live' ? 'green' : 'amber'}>{data.mode === 'live' ? '实时 API' : '演示降级'}</Badge>
|
||||
<Badge tone={mode === 'live' ? 'blue' : 'slate'}>资产 {mode}</Badge>
|
||||
<Badge tone="purple">资产总数 {total}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{data.warning ? <div className="relative mt-5 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm font-semibold text-amber-100">{data.warning}</div> : null}
|
||||
</Panel>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||
<MetricCard icon={Radio} label="监控槽" value={String(activeSlots.length) + ' / 4'} sub={'空槽 ' + emptySlots + ' 个'} tone="text-cyan-300" />
|
||||
<MetricCard icon={Cpu} label="平均 CPU" value={formatPercent(avgCpu)} sub="当前活跃槽均值" tone="text-emerald-300" />
|
||||
<MetricCard icon={MemoryStick} label="平均内存" value={formatPercent(avgMem)} sub="当前活跃槽均值" tone="text-blue-300" />
|
||||
<MetricCard icon={HardDrive} label="平均磁盘" value={formatPercent(avgDisk)} sub="当前活跃槽均值" tone="text-violet-300" />
|
||||
<MetricCard icon={Clock} label="最近探针" value={latestProbeTime(activeSlots)} sub={'异常 ' + failedProbeCount + ' 条'} tone="text-amber-300" />
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-[1fr_380px]">
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-xl font-black tracking-[-0.03em] text-white">监控槽实时快照</h3>
|
||||
<p className="mt-1 text-sm font-semibold text-slate-500">只读展示当前 pinned servers,不在 V2 增删槽位。</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[6, 24, 72].map((value) => (
|
||||
<button key={value} type="button" onClick={() => setHours(value)} className={cn('focus-ring rounded-2xl px-4 py-2 text-xs font-black', hours === value ? 'bg-cyan-400 text-slate-950' : 'border border-white/10 bg-white/[0.04] text-slate-300')}>{value}h</button>
|
||||
))}
|
||||
<button type="button" onClick={handleRefresh} className="focus-ring inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-black text-cyan-100"><RefreshCw className="h-4 w-4" />刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{data.slots.map((slot) => {
|
||||
const server = findServer(servers, slot)
|
||||
return (
|
||||
<Panel key={slot.slot_index} className="p-5">
|
||||
{slot.empty ? (
|
||||
<div className="flex min-h-56 flex-col items-center justify-center rounded-3xl border border-dashed border-white/10 bg-slate-950/30 text-center">
|
||||
<div className="grid h-12 w-12 place-items-center rounded-2xl bg-slate-800 text-slate-400"><Server className="h-5 w-5" /></div>
|
||||
<div className="mt-4 text-sm font-black text-slate-300">槽位 {slot.slot_index + 1} 空闲</div>
|
||||
<p className="mt-2 max-w-xs text-xs font-semibold leading-6 text-slate-600">V2 暂不创建监控槽,避免误触发后台状态变更。</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge tone="blue">槽位 {slot.slot_index + 1}</Badge>
|
||||
<Badge tone={slot.monitoring_enabled === false ? 'slate' : 'green'}>{slot.monitoring_enabled === false ? '暂停' : '监控中'}</Badge>
|
||||
</div>
|
||||
<h4 className="mt-3 truncate text-lg font-black text-white">{slot.server_name || server?.name || 'Server #' + slot.server_id}</h4>
|
||||
<div className="mt-1 text-xs font-semibold text-slate-500">{server?.ip || 'server_id=' + slot.server_id}</div>
|
||||
</div>
|
||||
<Badge tone={agentTone(slot.agent_action)}>{slot.agent_action || 'ok'}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-3 gap-3">
|
||||
<MiniMetric label="CPU" value={formatPercent(slot.metrics?.cpu_pct)} />
|
||||
<MiniMetric label="内存" value={formatPercent(slot.metrics?.mem_pct)} />
|
||||
<MiniMetric label="磁盘" value={formatPercent(slot.metrics?.disk_pct)} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4">{sparklineBars(slot.sparkline)}</div>
|
||||
|
||||
<div className="mt-4 grid gap-2 text-xs font-semibold text-slate-500">
|
||||
<div className="flex items-center justify-between"><span>剩余时间</span><span className="font-black text-slate-200">{formatRemaining(slot.remaining_sec)}</span></div>
|
||||
<div className="flex items-center justify-between"><span>TTL</span><span className="font-black text-slate-200">{slot.ttl_minutes || '-'} min</span></div>
|
||||
<div className="flex items-center justify-between"><span>上行 / 下行</span><span className="font-black text-slate-200">{formatBps(slot.metrics?.net_up_bps)} / {formatBps(slot.metrics?.net_down_bps)}</span></div>
|
||||
<div className="flex items-center justify-between"><span>Agent</span><span className="font-black text-slate-200">{slot.agent_version || '-'}</span></div>
|
||||
</div>
|
||||
{slot.agent_action_message ? <div className="mt-4 rounded-2xl border border-amber-300/20 bg-amber-400/10 p-3 text-xs font-semibold leading-6 text-amber-100">{slot.agent_action_message}</div> : null}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="space-y-5">
|
||||
<Panel className="p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid h-10 w-10 place-items-center rounded-2xl bg-blue-400/15 text-blue-200"><Activity className="h-5 w-5" /></div>
|
||||
<div>
|
||||
<h3 className="font-black text-white">监控会话</h3>
|
||||
<p className="text-xs font-semibold text-slate-500">最近 168 小时</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{recentSessions.map((session) => <SessionRow key={session.session_id} session={session} />)}
|
||||
{recentSessions.length === 0 ? <div className="rounded-2xl bg-slate-950/40 p-4 text-sm font-semibold text-slate-500">暂无监控会话。</div> : null}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel className="p-5">
|
||||
<h3 className="font-black text-white">写操作仍回旧后台</h3>
|
||||
<p className="mt-2 text-sm font-semibold leading-7 text-slate-500">添加/替换监控槽、启停监控、调整 TTL、安装 psutil 都会改变远程状态,V2 当前只放只读视图。</p>
|
||||
<a href="/app/watch" className="focus-ring mt-4 inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-black text-cyan-100">打开旧监控页 <ExternalLink className="h-4 w-4" /></a>
|
||||
</Panel>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<Panel className="overflow-hidden p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-xl font-black tracking-[-0.03em] text-white">探针记录</h3>
|
||||
<p className="mt-1 text-sm font-semibold text-slate-500">读取 /api/watch/probe-records,最多展示最近 40 条。</p>
|
||||
</div>
|
||||
<label className="focus-within:ring-nexus flex min-w-72 items-center gap-3 rounded-2xl border border-white/10 bg-slate-950/50 px-4 py-2 text-sm font-semibold text-slate-500">
|
||||
<Search className="h-4 w-4" />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="过滤服务器 / 状态 / 错误" className="w-full bg-transparent text-slate-100 outline-none placeholder:text-slate-600" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 overflow-x-auto rounded-3xl border border-white/10">
|
||||
<table className="min-w-full divide-y divide-white/10">
|
||||
<thead className="bg-white/[0.03]">
|
||||
<tr>
|
||||
<th className="px-5 py-4 text-left text-xs font-black uppercase tracking-[0.14em] text-slate-500">时间</th>
|
||||
<th className="px-5 py-4 text-left text-xs font-black uppercase tracking-[0.14em] text-slate-500">服务器</th>
|
||||
<th className="px-5 py-4 text-left text-xs font-black uppercase tracking-[0.14em] text-slate-500">状态</th>
|
||||
<th className="px-5 py-4 text-left text-xs font-black uppercase tracking-[0.14em] text-slate-500">CPU / 内存 / 磁盘</th>
|
||||
<th className="px-5 py-4 text-left text-xs font-black uppercase tracking-[0.14em] text-slate-500">耗时</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10 bg-slate-950/20">
|
||||
{filteredProbes.map((probe) => <ProbeRow key={probe.id} probe={probe} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
{filteredProbes.length === 0 ? <div className="px-5 py-12 text-center text-sm font-bold text-slate-500">没有符合条件的探针记录</div> : null}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MetricCardProps {
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
value: string
|
||||
sub: string
|
||||
tone: string
|
||||
}
|
||||
|
||||
function MetricCard({ icon: Icon, label, value, sub, tone }: MetricCardProps): JSX.Element {
|
||||
return (
|
||||
<Panel className="p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-black uppercase tracking-[0.16em] text-slate-500">{label}</div>
|
||||
<div className="mt-2 text-2xl font-black tracking-[-0.04em] text-white">{value}</div>
|
||||
<div className="mt-1 text-xs font-semibold text-slate-500">{sub}</div>
|
||||
</div>
|
||||
<Icon className={cn('h-5 w-5', tone)} />
|
||||
</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function MiniMetric({ label, value }: { label: string; value: string }): JSX.Element {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-slate-950/40 p-3">
|
||||
<div className="text-[10px] font-black uppercase tracking-[0.14em] text-slate-600">{label}</div>
|
||||
<div className="mt-1 text-sm font-black text-white">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionRow({ session }: { session: WatchSessionItem }): JSX.Element {
|
||||
const failCount = toNumber(session.probe_fail_count)
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-slate-950/40 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-black text-white">{session.server_name || 'Server #' + session.server_id}</div>
|
||||
<div className="mt-1 text-xs font-semibold text-slate-500">Session #{session.session_id}</div>
|
||||
</div>
|
||||
<Badge tone={failCount > 0 ? 'amber' : 'green'}>{failCount > 0 ? '有异常' : '正常'}</Badge>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-xs font-semibold text-slate-500">
|
||||
<div>OK <span className="font-black text-slate-200">{session.probe_ok_count || 0}</span></div>
|
||||
<div>Fail <span className="font-black text-slate-200">{failCount}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProbeRow({ probe }: { probe: WatchProbeRecordItem }): JSX.Element {
|
||||
return (
|
||||
<tr className="transition hover:bg-white/[0.03]">
|
||||
<td className="px-5 py-4 text-sm font-semibold text-slate-500">{formatDateTime(probe.recorded_at || probe.timestamp)}</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="text-sm font-black text-white">{probe.server_name || 'Server #' + probe.server_id}</div>
|
||||
<div className="mt-1 text-xs font-semibold text-slate-600">Session #{probe.session_id || '-'}</div>
|
||||
</td>
|
||||
<td className="px-5 py-4"><Badge tone={probeTone(probe.probe_status)}>{probe.probe_status || '-'}</Badge></td>
|
||||
<td className="px-5 py-4 text-sm font-semibold text-slate-400">{formatPercent(probe.cpu_pct)} / {formatPercent(probe.mem_pct)} / {formatPercent(probe.disk_pct)}</td>
|
||||
<td className="px-5 py-4 text-sm font-semibold text-slate-500">{probe.duration_ms ? probe.duration_ms + ' ms' : '-'}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
export type ServerRisk = 'healthy' | 'warning' | 'critical' | 'patched'
|
||||
|
||||
export interface ServerAsset {
|
||||
id: string
|
||||
sourceId?: number
|
||||
name: string
|
||||
ip: string
|
||||
region: string
|
||||
panelStatus: 'online' | 'offline' | 'patched' | 'test'
|
||||
ttl: string
|
||||
ssl: 'enabled' | 'disabled' | 'internal' | 'unknown'
|
||||
risk: ServerRisk
|
||||
action: string
|
||||
cpu: number
|
||||
memory: number
|
||||
disk: number
|
||||
latency: string
|
||||
btConfigured?: boolean
|
||||
btBaseUrl?: string | null
|
||||
btVerifySsl?: boolean | null
|
||||
btSessionCleanupOk?: boolean
|
||||
btSessionCleanupPatched?: boolean
|
||||
btBootstrapState?: string | null
|
||||
btLastCheckAt?: string | null
|
||||
btLastError?: string | null
|
||||
online?: boolean
|
||||
}
|
||||
|
||||
export interface DashboardStatSeed {
|
||||
title: string
|
||||
value: string
|
||||
sub: string
|
||||
accent: string
|
||||
}
|
||||
|
||||
export interface AuditEvent {
|
||||
time: string
|
||||
title: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export const stats: DashboardStatSeed[] = [
|
||||
{ title: '在线服务器', value: '420', sub: '+18 今日接入', accent: 'text-emerald-300' },
|
||||
{ title: '宝塔已修复', value: '148', sub: 'TTL 86400s', accent: 'text-emerald-300' },
|
||||
{ title: '活跃任务', value: '26', sub: '脚本 / 巡检 / 同步', accent: 'text-cyan-300' },
|
||||
{ title: '风险告警', value: '12', sub: 'SSL / TTL / Agent', accent: 'text-amber-300' },
|
||||
]
|
||||
|
||||
export const servers: ServerAsset[] = [
|
||||
{ id: 'prod-bt-042', name: 'prod-bt-042', ip: '42.193.182.190', region: 'Tencent', panelStatus: 'patched', ttl: '86400s', ssl: 'enabled', risk: 'patched', action: '一键登录', cpu: 18, memory: 42, disk: 61, latency: '42ms', btConfigured: true, btSessionCleanupOk: true, btSessionCleanupPatched: true, online: true },
|
||||
{ id: 'aliyun-web-12', name: 'aliyun-web-12', ip: '120.79.213.154', region: 'Aliyun', panelStatus: 'patched', ttl: '86400s', ssl: 'disabled', risk: 'warning', action: '一键登录', cpu: 24, memory: 48, disk: 66, latency: '58ms', btConfigured: true, btSessionCleanupOk: true, btSessionCleanupPatched: true, online: true },
|
||||
{ id: 'lan-test-node', name: 'lan-test-node', ip: '192.168.124.219', region: 'LAN', panelStatus: 'test', ttl: '86400s', ssl: 'internal', risk: 'healthy', action: '测试部署', cpu: 12, memory: 34, disk: 43, latency: '3ms', btConfigured: true, btSessionCleanupOk: true, online: true },
|
||||
{ id: 'legacy-bt-148', name: 'legacy-bt-148', ip: '159.75.154.203', region: 'Tencent', panelStatus: 'patched', ttl: '86400s', ssl: 'enabled', risk: 'patched', action: '一键登录', cpu: 31, memory: 52, disk: 72, latency: '49ms', btConfigured: true, btSessionCleanupOk: true, btSessionCleanupPatched: true, online: true },
|
||||
{ id: 'edge-hk-018', name: 'edge-hk-018', ip: '8.210.xx.xx', region: 'HK', panelStatus: 'online', ttl: '待复查', ssl: 'enabled', risk: 'warning', action: '检测修复', cpu: 20, memory: 38, disk: 54, latency: '88ms', btConfigured: true, btSessionCleanupOk: false, online: true },
|
||||
{ id: 'cold-backup-03', name: 'cold-backup-03', ip: '172.18.xx.xx', region: 'VPC', panelStatus: 'offline', ttl: '未知', ssl: 'unknown', risk: 'critical', action: '重试', cpu: 0, memory: 0, disk: 81, latency: '-', btConfigured: false, online: false },
|
||||
]
|
||||
|
||||
export const auditEvents: AuditEvent[] = [
|
||||
{ time: '12:05', title: '一键登录 159.75.154.203 成功,登录前 TTL 兜底通过', color: 'bg-emerald-400' },
|
||||
{ time: '12:03', title: '自动修复 hardcoded_3600:37 台宝塔面板完成', color: 'bg-emerald-400' },
|
||||
{ time: '11:58', title: '批量检测宝塔 SSL:112 台未开启,已进入建议队列', color: 'bg-amber-400' },
|
||||
{ time: '11:42', title: '脚本任务 #1024 完成:成功 140 / 148', color: 'bg-cyan-400' },
|
||||
]
|
||||
|
||||
export const ttlCells = Array.from({ length: 32 }, (_, index) => {
|
||||
if ([7, 21].includes(index)) return 'bg-amber-400'
|
||||
if (index === 14) return 'bg-red-400'
|
||||
return 'bg-emerald-400'
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { fetchDashboardData } from '../api/dashboardApi'
|
||||
import type { DashboardData } from '../api/dashboardApi'
|
||||
|
||||
export function useDashboardData(): DashboardData {
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: ['dashboard-v2'],
|
||||
queryFn: fetchDashboardData,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
export interface AdminProfile {
|
||||
id: number
|
||||
username: string
|
||||
totp_enabled: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public readonly status: number, message: string, public readonly detail?: unknown) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
}
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE || '/api'
|
||||
let accessToken: string | null = null
|
||||
let refreshInFlight: Promise<boolean> | null = null
|
||||
|
||||
function buildUrl(path: string, params?: Record<string, string | number | boolean | undefined>): string {
|
||||
const url = new URL(`${BASE_URL}${path}`, window.location.origin)
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) url.searchParams.set(key, String(value))
|
||||
})
|
||||
}
|
||||
return url.pathname + url.search
|
||||
}
|
||||
|
||||
function authHeaders(init?: RequestInit): HeadersInit {
|
||||
const headers = new Headers(init?.headers)
|
||||
if (!(init?.body instanceof FormData) && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
if (accessToken) headers.set('Authorization', `Bearer ${accessToken}`)
|
||||
return headers
|
||||
}
|
||||
|
||||
export function setAccessToken(token: string | null): void {
|
||||
accessToken = token
|
||||
}
|
||||
|
||||
async function parseResponseBody(res: Response): Promise<unknown> {
|
||||
const text = await res.text()
|
||||
if (!text) return undefined
|
||||
try { return JSON.parse(text) } catch { return text }
|
||||
}
|
||||
|
||||
async function refreshSession(): Promise<boolean> {
|
||||
if (refreshInFlight) return refreshInFlight
|
||||
refreshInFlight = (async () => {
|
||||
try {
|
||||
const res = await fetch(buildUrl('/auth/refresh'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
})
|
||||
if (!res.ok) return false
|
||||
const data = await res.json() as { access_token?: string }
|
||||
if (!data.access_token) return false
|
||||
setAccessToken(data.access_token)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
refreshInFlight = null
|
||||
}
|
||||
})()
|
||||
return refreshInFlight
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(
|
||||
path: string,
|
||||
init: RequestInit & { params?: Record<string, string | number | boolean | undefined>; retry?: boolean } = {},
|
||||
): Promise<T> {
|
||||
const { params, retry, ...requestInit } = init
|
||||
const res = await fetch(buildUrl(path, params), {
|
||||
...requestInit,
|
||||
headers: authHeaders(requestInit),
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (res.status === 401 && !retry && !path.startsWith('/auth/')) {
|
||||
const refreshed = await refreshSession()
|
||||
if (refreshed) return apiFetch<T>(path, { ...init, retry: true })
|
||||
}
|
||||
|
||||
const data = await parseResponseBody(res)
|
||||
if (!res.ok) {
|
||||
const detail = typeof data === 'object' && data && 'detail' in data ? (data as { detail?: unknown }).detail : data
|
||||
throw new ApiError(res.status, typeof detail === 'string' ? detail : res.statusText, detail)
|
||||
}
|
||||
return data as T
|
||||
}
|
||||
|
||||
export async function restoreSession(): Promise<AdminProfile | null> {
|
||||
const refreshed = await refreshSession()
|
||||
if (!refreshed) return null
|
||||
try {
|
||||
return await apiFetch<AdminProfile>('/auth/me')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
const REDACTED = '[REDACTED]'
|
||||
const HIDDEN = '[HIDDEN]'
|
||||
const MAX_SANITIZE_DEPTH = 5
|
||||
|
||||
const SENSITIVE_KEY_PATTERNS = [
|
||||
/password/i,
|
||||
/passwd/i,
|
||||
/\bpwd\b/i,
|
||||
/token/i,
|
||||
/cookie/i,
|
||||
/secret/i,
|
||||
/api[_\s-]?key/i,
|
||||
/access[_\s-]?key/i,
|
||||
/private[_\s-]?key/i,
|
||||
/bt[_\s-]?key/i,
|
||||
/bt[_\s-]?panel/i,
|
||||
/tmp[_\s-]?(login|token)/i,
|
||||
/\bpem\b/i,
|
||||
/\bcsr\b/i,
|
||||
/cert(ificate)?[_\s-]?pem/i,
|
||||
]
|
||||
|
||||
const SENSITIVE_TEXT_KEYS = '(?:password|passwd|pwd|token|cookie|secret|api[_\\s-]?key|access[_\\s-]?key|private[_\\s-]?key|bt[_\\s-]?key|bt[_\\s-]?panel|tmp[_\\s-]?(?:login|token)|pem|csr|cert(?:ificate)?[_\\s-]?pem|BT_PANEL|BT_KEY|Authorization)'
|
||||
|
||||
const TEXT_PATTERNS: Array<[RegExp, string]> = [
|
||||
[/-----BEGIN [A-Z0-9 ]+-----[\s\S]*?-----END [A-Z0-9 ]+-----/gi, '-----BEGIN PEM BLOCK-----' + HIDDEN + '-----END PEM BLOCK-----'],
|
||||
[new RegExp(`([\"']${SENSITIVE_TEXT_KEYS}[\"']\\s*:\\s*)([\"'])([\\s\\S]*?)(\\2)`, 'gi'), '$1$2' + REDACTED + '$4'],
|
||||
[new RegExp(`(${SENSITIVE_TEXT_KEYS}\\s*[:=]\\s*)(\".*?\"|'.*?'|[^\\s,;]+)`, 'gi'), '$1' + REDACTED],
|
||||
[/(Authorization\s*:\s*Bearer\s+)([^\s'";,]+)/gi, '$1' + REDACTED],
|
||||
[/(Bearer\s+)([^\s'";,]+)/gi, '$1' + REDACTED],
|
||||
[/((?:--password|--passwd|--token|--secret|--api-key|--cookie)\s+)([^\s]+)/gi, '$1' + REDACTED],
|
||||
[/((?:sshpass\s+-p\s+|\s-p)\s*)([^\s]+)/gi, '$1' + REDACTED],
|
||||
[/((?:tmp_login|tmp_token)=)([^&\s]+)/gi, '$1' + REDACTED],
|
||||
]
|
||||
|
||||
const SENSITIVE_FILE_PATTERNS = [
|
||||
/^\.env(?:\.|$)/i,
|
||||
/^id_(?:rsa|dsa|ecdsa|ed25519)(?:\.|$)/i,
|
||||
/^\.(?:ssh|gnupg|aws|azure|kube|docker)$/i,
|
||||
/^(?:credentials|authorized_keys|known_hosts)$/i,
|
||||
/(?:^|[._-])(?:password|passwd|pwd|token|cookie|secret|api[_-]?key|access[_-]?key|private[_-]?key|bt[_-]?key|tmp[_-]?(?:login|token))(?:[._-]|$)/i,
|
||||
/\.(?:pem|key|csr|p12|pfx|kdbx|pgpass)$/i,
|
||||
]
|
||||
|
||||
export function isSensitiveKey(key: string): boolean {
|
||||
const normalized = String(key || '').toLowerCase()
|
||||
return SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(normalized))
|
||||
}
|
||||
|
||||
export function redactSensitiveText(value: unknown, fallback = '-', maxLength = 240): string {
|
||||
if (value === null || value === undefined || value === '') return fallback
|
||||
let text = String(value)
|
||||
for (const [pattern, replacement] of TEXT_PATTERNS) text = text.replace(pattern, replacement)
|
||||
if (text.length > maxLength) return text.slice(0, Math.max(0, maxLength - 3)) + '...'
|
||||
return text
|
||||
}
|
||||
|
||||
export function hasSensitiveMarker(value: unknown): boolean {
|
||||
const text = String(value || '')
|
||||
if (!text) return false
|
||||
if (text.includes(REDACTED) || text.includes('[REDACTED]') || /-----BEGIN [A-Z0-9 ]+-----/i.test(text)) return true
|
||||
if (new RegExp(SENSITIVE_TEXT_KEYS, 'i').test(text)) return true
|
||||
return TEXT_PATTERNS.some(([pattern]) => {
|
||||
pattern.lastIndex = 0
|
||||
const matched = pattern.test(text)
|
||||
pattern.lastIndex = 0
|
||||
return matched
|
||||
})
|
||||
}
|
||||
|
||||
export function isSensitiveFileName(value: unknown): boolean {
|
||||
const name = String(value || '').split(/[\\/]/).filter(Boolean).pop() || ''
|
||||
if (!name) return false
|
||||
return SENSITIVE_FILE_PATTERNS.some((pattern) => pattern.test(name)) || hasSensitiveMarker(name)
|
||||
}
|
||||
|
||||
export function redactFilePathText(value: unknown, fallback = '-', maxLength = 160): string {
|
||||
if (value === null || value === undefined || value === '') return fallback
|
||||
const text = String(value)
|
||||
const parts = text.split(/([\\/]+)/)
|
||||
const redacted = parts.map((part) => {
|
||||
if (!part || /^[\\/]+$/.test(part)) return part
|
||||
return isSensitiveFileName(part) ? HIDDEN : redactSensitiveText(part, part, maxLength)
|
||||
}).join('')
|
||||
if (redacted.length > maxLength) return redacted.slice(0, Math.max(0, maxLength - 3)) + '...'
|
||||
return redacted
|
||||
}
|
||||
|
||||
function sanitizeValue(value: unknown, key = '', depth = 0): unknown {
|
||||
if (isSensitiveKey(key)) return value === null || value === undefined || value === '' ? value : HIDDEN
|
||||
if (typeof value === 'string') return redactSensitiveText(value, value, 240)
|
||||
if (value === null || value === undefined) return value
|
||||
if (typeof value !== 'object') return value
|
||||
if (depth >= MAX_SANITIZE_DEPTH) return HIDDEN
|
||||
if (Array.isArray(value)) return value.map((item) => sanitizeValue(item, key, depth + 1))
|
||||
const output: Record<string, unknown> = {}
|
||||
for (const [childKey, childValue] of Object.entries(value as Record<string, unknown>)) {
|
||||
output[childKey] = sanitizeValue(childValue, childKey, depth + 1)
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
export function sanitizePlainRecord<T extends Record<string, unknown>>(record: T, allowedKeys?: string[]): Record<string, unknown> {
|
||||
const output: Record<string, unknown> = {}
|
||||
const entries = allowedKeys ? allowedKeys.map((key) => [key, record[key]] as const) : Object.entries(record)
|
||||
for (const [key, value] of entries) {
|
||||
if (value === undefined) continue
|
||||
output[key] = sanitizeValue(value, key, 0)
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
export function sanitizeSettingEntry<T extends { key: string; value?: string | null; value_set?: boolean | null }>(setting: T): T {
|
||||
if (!isSensitiveKey(setting.key)) {
|
||||
return { ...setting, value: redactSensitiveText(setting.value, '') || null }
|
||||
}
|
||||
return {
|
||||
...setting,
|
||||
value: null,
|
||||
value_set: Boolean(setting.value_set || setting.value),
|
||||
}
|
||||
}
|
||||
|
||||
export function summarizeCertificatePayload(value: unknown): string {
|
||||
if (value === -1 || value === 0 || value === false || value === null || value === undefined || value === '') return ''
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
const data = value as Record<string, unknown>
|
||||
const safe = [data.subject, data.dns, data.issuer, data.brand, data.notAfter, data.endtime, data.end_time, data.valid_to, data.expire]
|
||||
.filter((item) => item !== null && item !== undefined && item !== '')
|
||||
.map((item) => redactSensitiveText(item, '', 80))
|
||||
return safe.length ? safe.join(' / ') : 'certificate metadata hidden'
|
||||
}
|
||||
const text = redactSensitiveText(value, '', 160)
|
||||
if (!text) return ''
|
||||
if (/PRIVATE KEY|CERTIFICATE REQUEST|BEGIN CERTIFICATE|tmp_login|tmp_token|Bearer|BT_KEY|BT_PANEL|password|passwd|secret|api[_\s-]?key/i.test(String(value))) {
|
||||
return 'certificate metadata hidden'
|
||||
}
|
||||
return text
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { StrictMode, Suspense } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import App from './App'
|
||||
import './styles.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60_000,
|
||||
gcTime: 10 * 60_000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Suspense fallback={<div className="min-h-screen bg-nexus-bg text-slate-100" />}>
|
||||
<App />
|
||||
</Suspense>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
import { create } from 'zustand'
|
||||
import type { AdminProfile } from '@/lib/api'
|
||||
|
||||
export type AppModule = 'dashboard' | 'assets' | 'btpanel' | 'btresources' | 'btdomainssl' | 'search' | 'tasks' | 'workspace' | 'monitoring' | 'operations' | 'scripts' | 'executions' | 'audit' | 'security' | 'settings'
|
||||
|
||||
type SessionStatus = 'checking' | 'authenticated' | 'guest'
|
||||
|
||||
interface AppState {
|
||||
selectedServerId: string
|
||||
commandOpen: boolean
|
||||
activeModule: AppModule
|
||||
admin: AdminProfile | null
|
||||
sessionStatus: SessionStatus
|
||||
setSelectedServerId: (id: string) => void
|
||||
setCommandOpen: (open: boolean) => void
|
||||
setActiveModule: (module: AppModule) => void
|
||||
setAdmin: (admin: AdminProfile | null) => void
|
||||
setSessionStatus: (status: SessionStatus) => void
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set) => ({
|
||||
selectedServerId: 'prod-bt-042',
|
||||
commandOpen: false,
|
||||
activeModule: 'dashboard',
|
||||
admin: null,
|
||||
sessionStatus: 'checking',
|
||||
setSelectedServerId: (id) => set({ selectedServerId: id }),
|
||||
setCommandOpen: (open) => set({ commandOpen: open }),
|
||||
setActiveModule: (activeModule) => set({ activeModule }),
|
||||
setAdmin: (admin) => set({ admin }),
|
||||
setSessionStatus: (sessionStatus) => set({ sessionStatus }),
|
||||
}))
|
||||
@@ -0,0 +1,27 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { min-height: 100%; }
|
||||
body { margin: 0; background: #070b16; }
|
||||
|
||||
@layer components {
|
||||
.glass-panel {
|
||||
@apply border border-white/10 bg-slate-900/75 shadow-glow backdrop-blur-xl;
|
||||
}
|
||||
.soft-panel {
|
||||
@apply border border-white/10 bg-slate-950/35 backdrop-blur;
|
||||
}
|
||||
.focus-ring {
|
||||
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950;
|
||||
}
|
||||
}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import type { ReactElement } from 'react'
|
||||
|
||||
declare global {
|
||||
namespace JSX {
|
||||
type Element = ReactElement
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const config: Config = {
|
||||
darkMode: ['class'],
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'PingFang SC', 'Microsoft YaHei', 'system-ui', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'Consolas', 'ui-monospace', 'monospace'],
|
||||
},
|
||||
colors: {
|
||||
nexus: {
|
||||
bg: '#070b16',
|
||||
panel: '#0f172a',
|
||||
panel2: '#111c33',
|
||||
line: 'rgba(148, 163, 184, 0.16)',
|
||||
cyan: '#67e8f9',
|
||||
blue: '#60a5fa',
|
||||
green: '#86efac',
|
||||
amber: '#fbbf24',
|
||||
red: '#fca5a5',
|
||||
},
|
||||
},
|
||||
boxShadow: {
|
||||
glow: '0 24px 80px rgba(0,0,0,.28)',
|
||||
cyan: '0 18px 50px rgba(14,165,233,.22)',
|
||||
},
|
||||
backgroundImage: {
|
||||
'nexus-radial': 'radial-gradient(circle at 18% 0%, rgba(51, 69, 255, .32), transparent 34%), radial-gradient(circle at 90% 10%, rgba(239, 68, 68, .16), transparent 30%), linear-gradient(135deg, #070b16 0%, #101827 58%, #0b1020 100%)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
export default config
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/*"
|
||||
],
|
||||
"~components/*": [
|
||||
"src/components/*"
|
||||
],
|
||||
"~features/*": [
|
||||
"src/features/*"
|
||||
],
|
||||
"~types/*": [
|
||||
"src/types/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
|
||||
function chunkName(moduleId: string): string | undefined {
|
||||
if (moduleId.includes('/node_modules/react/') || moduleId.includes('/node_modules/react-dom/')) return 'react'
|
||||
if (moduleId.includes('/node_modules/@tanstack/react-query/')) return 'query'
|
||||
if (moduleId.includes('/node_modules/@tanstack/react-table/') || moduleId.includes('/node_modules/@tanstack/react-virtual/')) return 'table'
|
||||
if (moduleId.includes('/node_modules/lucide-react/') || moduleId.includes('/node_modules/cmdk/')) return 'ui'
|
||||
return undefined
|
||||
}
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
const apiTarget = env.VITE_DEV_API_PROXY || 'http://127.0.0.1:8600'
|
||||
const wsTarget = apiTarget.replace(/^https:/i, 'wss:').replace(/^http:/i, 'ws:')
|
||||
|
||||
return {
|
||||
plugins: react(),
|
||||
base: '/app-v2/',
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('src', import.meta.url)),
|
||||
'~components': fileURLToPath(new URL('src/components', import.meta.url)),
|
||||
'~features': fileURLToPath(new URL('src/features', import.meta.url)),
|
||||
'~types': fileURLToPath(new URL('src/types', import.meta.url)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: fileURLToPath(new URL('../web/app-v2', import.meta.url)),
|
||||
emptyOutDir: true,
|
||||
sourcemap: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: chunkName,
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3002,
|
||||
proxy: {
|
||||
'/api': { target: apiTarget, changeOrigin: true, secure: false },
|
||||
'/ws': { target: wsTarget, ws: true, changeOrigin: true, secure: false },
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user