Compare commits

...
2 Commits
Author SHA1 Message Date
enzo cbd8b347db feat: visual overhaul — Sidebar/Header/Dashboard aligned to prototype 2026-07-22 20:32:08 +08:00
enzoandClaude a97b885081 fix: comma-separate D-level profiles, map restart to blue in timeline
- D-level profile names now joined with commas instead of concatenated spans
- Gateway trend restart indicator changed from yellow to blue (red/orange/blue scheme)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 20:22:50 +08:00
3 changed files with 518 additions and 329 deletions
+101 -38
View File
@@ -1,7 +1,5 @@
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../../hooks/useAuth'
import Badge from '../ui/Badge'
import SearchBox from '../ui/SearchBox'
export interface HeaderProps {
breadcrumb?: string
@@ -18,7 +16,6 @@ export default function Header({
breadcrumb,
onlineCount = 0,
runningCount = 0,
offlineCount = 0,
onSearch,
onSearchNavigate,
theme,
@@ -28,76 +25,142 @@ export default function Header({
const { user, logout } = useAuth()
const initial = user?.id ? user.id.charAt(0).toUpperCase() : '?'
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
const value = (e.target as HTMLInputElement).value.trim()
if (e.key === 'Enter' && onSearchNavigate) {
onSearchNavigate(value)
}
if (e.key === 'Enter' && onSearch) {
onSearch(value)
}
}
return (
<header
className="flex items-center gap-3 px-4 shrink-0"
style={{
height: 'var(--header-h)',
gridArea: 'header',
background: 'var(--bg-nav)',
borderBottom: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
padding: '0 20px',
gap: 16,
zIndex: 10,
height: 'var(--header-h)',
}}
>
{/* Logo */}
<span style={{ fontWeight: 700, fontSize: 14, color: 'var(--honey)', whiteSpace: 'nowrap' }}>
🐝 知微蜂巢
</span>
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-[13px] min-w-0" style={{ color: 'var(--text-dim)' }}>
{breadcrumb && (
<>
<span
className="cursor-pointer hover:text-[var(--text)] transition-colors"
onClick={() => navigate('/dashboard')}
>
首页
</span>
<span style={{ color: 'var(--text-muted)' }}>/</span>
<span style={{ color: 'var(--text)', fontWeight: 500 }}>{breadcrumb}</span>
</>
)}
</div>
<span style={{ color: 'var(--text-dim)', fontSize: 13 }}>
/ <span style={{ color: 'var(--text)' }}>{breadcrumb || '蜂群总览'}</span>
</span>
{/* Spacer */}
<div className="flex-1" />
<div style={{ flex: 1 }} />
{/* Search */}
{onSearch && (
<SearchBox onSearch={onSearch} onItemClick={onSearchNavigate} placeholder="搜索 Profile / 任务 / Cron / 事件..." />
)}
<input
className="global-search"
placeholder="🔍 搜索 Profile / 任务 / Cron / 事件..."
onKeyDown={handleSearchKeyDown}
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-sm)',
padding: '6px 12px',
color: 'var(--text)',
width: 260,
fontSize: 13,
outline: 'none',
fontFamily: 'inherit',
}}
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--honey)' }}
onBlur={(e) => { e.currentTarget.style.borderColor = 'var(--border)' }}
/>
{/* Status badges */}
<Badge variant="success">在线 {onlineCount}</Badge>
<Badge variant="honey">运行中 {runningCount}</Badge>
<Badge variant="danger">离线 {offlineCount}</Badge>
<span
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
padding: '4px 10px',
fontSize: 12,
color: 'var(--text-dim)',
whiteSpace: 'nowrap',
}}
>
🟢 {onlineCount} online
</span>
<span
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
padding: '4px 10px',
fontSize: 12,
color: 'var(--text-dim)',
whiteSpace: 'nowrap',
}}
>
🟡 {runningCount} running
</span>
{/* Theme toggle */}
<button
onClick={onToggleTheme}
className="cursor-pointer bg-transparent border-none text-[18px] leading-none p-1 rounded hover:bg-[var(--bg-hover)] transition-colors"
title={theme === 'dark' ? '切换浅色模式' : '切换深色模式'}
style={{ background: 'transparent', border: 'none', fontSize: 18, cursor: 'pointer', padding: 4, borderRadius: 'var(--radius-sm)', color: 'var(--text-dim)', lineHeight: 1 }}
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent' }}
>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
{/* User avatar + role */}
{/* User avatar */}
<div
className="flex items-center gap-2 cursor-pointer shrink-0 relative group"
style={{ position: 'relative', cursor: 'pointer', flexShrink: 0 }}
className="group"
>
<div
className="w-[30px] h-[30px] rounded-full flex items-center justify-center text-[13px] font-semibold"
style={{ background: 'var(--honey-glow)', color: 'var(--honey)' }}
style={{
width: 30, height: 30, borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 13, fontWeight: 600,
background: 'var(--honey-glow)', color: 'var(--honey)',
}}
title={user?.role ?? ''}
>
{initial}
</div>
<span className="text-[13px] hidden sm:inline" style={{ color: 'var(--text-dim)' }}>
{user?.role}
</span>
{/* Dropdown */}
<div
className="absolute right-0 top-full mt-1 w-[120px] rounded-[var(--radius)] shadow-[var(--shadow)] py-1 hidden group-hover:block z-50"
style={{ background: 'var(--bg-nav)', border: '1px solid var(--border)' }}
style={{
display: 'none',
position: 'absolute', right: 0, top: '100%', marginTop: 4,
width: 120, borderRadius: 'var(--radius)',
boxShadow: 'var(--shadow)',
padding: '4px 0',
background: 'var(--bg-nav)', border: '1px solid var(--border)',
zIndex: 50,
}}
className="group-hover-block"
onMouseEnter={(e) => { e.currentTarget.style.display = 'block' }}
onMouseLeave={(e) => { e.currentTarget.style.display = 'none' }}
>
<button
onClick={() => { logout(); navigate('/login') }}
className="w-full text-left px-3 py-[6px] text-[13px] hover:bg-[var(--bg-hover)] transition-colors"
style={{ color: 'var(--red)' }}
style={{
width: '100%', textAlign: 'left', padding: '6px 12px',
fontSize: 13, border: 'none', background: 'transparent',
color: 'var(--red)', cursor: 'pointer', fontFamily: 'inherit',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent' }}
>
退出登录
</button>
+130 -47
View File
@@ -1,6 +1,5 @@
import { useEffect, useState } from 'react'
import { useAuth } from '../../hooks/useAuth'
import Badge from '../ui/Badge'
import { getDashboardSummary } from '../../api/dashboard'
interface NavItem {
@@ -8,7 +7,8 @@ interface NavItem {
icon: string
path: string
badge?: number
requiredRole?: string // viewer can see all by default
isNew?: boolean
requiredRole?: string
}
interface NavSection {
@@ -21,8 +21,8 @@ const sections: NavSection[] = [
title: '蜂群态势',
items: [
{ label: '蜂群总览', icon: '📊', path: '/dashboard' },
{ label: 'Gateway', icon: '📡', path: '/gateway', requiredRole: 'operator' },
{ label: '健康分', icon: '🏥', path: '/health' },
{ label: 'Gateway 健康', icon: '📡', path: '/gateway', isNew: true },
{ label: 'Profile 健康分', icon: '🏥', path: '/health', isNew: true },
{ label: '工蜂列表', icon: '🤖', path: '/workers' },
],
},
@@ -30,35 +30,40 @@ const sections: NavSection[] = [
title: '任务编排',
items: [
{ label: '看板追踪', icon: '📋', path: '/kanban' },
{ label: 'Cron 管理', icon: '⏰', path: '/cron', requiredRole: 'operator' },
{ label: '事件管理', icon: '🔔', path: '/events' },
{ label: 'Cron 管理', icon: '⏰', path: '/cron', badge: 1 },
{ label: '事件管理', icon: '🔔', path: '/events', badge: 3 },
],
},
{
title: '通信频道',
items: [{ label: '频道配置', icon: '📶', path: '/channels' }],
items: [
{ label: '频道配置', icon: '📡', path: '/channels' },
{ label: '频道监控', icon: '📶', path: '/channel-monitor', isNew: true },
],
},
{
title: '运维工具',
items: [
{ label: '密钥', icon: '🔑', path: '/keys', requiredRole: 'operator' },
{ label: '文件', icon: '📁', path: '/files' },
{ label: '终端', icon: '💻', path: '/terminal' },
{ label: '日志', icon: '📜', path: '/logs' },
{ label: 'MCP接入', icon: '🔌', path: '/mcp-in' },
{ label: 'MCP对外', icon: '📤', path: '/mcp-out' },
{ label: '密钥管理', icon: '🔑', path: '/keys' },
{ label: '文件浏览', icon: '📁', path: '/files', isNew: true },
{ label: 'Web 终端', icon: '💻', path: '/terminal', isNew: true },
{ label: '蜂群日志', icon: '📜', path: '/logs', isNew: true },
{ label: 'MCP 接入管理', icon: '🔌', path: '/mcp-in', isNew: true },
{ label: 'MCP 对外服务', icon: '📤', path: '/mcp-out', isNew: true },
],
},
{
title: '知识库管理',
items: [{ label: '事件档案', icon: '📚', path: '/archive' }],
items: [
{ label: '事件档案管理', icon: '📚', path: '/archive', isNew: true },
],
},
{
title: '系统',
items: [
{ label: '用户管理', icon: '👥', path: '/users', requiredRole: 'owner' },
{ label: '审计日志', icon: '📋', path: '/audit', requiredRole: 'owner' },
{ label: '设置', icon: '⚙', path: '/settings' },
{ label: '系统设置', icon: '⚙', path: '/settings' },
],
},
]
@@ -68,7 +73,6 @@ export interface SidebarProps {
onNavigate: (path: string) => void
}
/** Check whether the current user's role satisfies the required role level. */
function hasAccess(userRole: string | undefined, requiredRole?: string): boolean {
if (!requiredRole) return true
const hierarchy: Record<string, number> = { viewer: 0, operator: 1, owner: 2 }
@@ -94,61 +98,140 @@ export default function Sidebar({ activePath, onNavigate }: SidebarProps) {
}, [])
return (
<nav className="flex flex-col h-full overflow-y-auto" style={{ background: 'var(--bg-nav)', borderRight: '1px solid var(--border)' }}>
{/* Logo */}
<nav
style={{
background: 'var(--bg-nav)',
borderRight: '1px solid var(--border)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
height: '100%',
}}
>
{/* Nav header / logo */}
<div
className="flex items-center gap-2 shrink-0 cursor-pointer px-5"
style={{ height: 'var(--header-h)' }}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
height: 'var(--header-h)',
padding: '0 14px',
borderBottom: '1px solid var(--border)',
flexShrink: 0,
cursor: 'pointer',
}}
onClick={() => onNavigate('/dashboard')}
>
<span style={{ fontSize: 20 }}>🐝</span>
<span style={{ color: 'var(--honey)', fontWeight: 700, fontSize: 15, letterSpacing: 0.3 }}>
知微蜂巢
<span style={{ fontSize: 16 }}>🐝</span>
<span style={{ fontWeight: 700, fontSize: 14, color: 'var(--honey)' }}>
Hive Manage v3
</span>
</div>
{/* Nav sections */}
<div className="flex-1 overflow-y-auto px-3 pb-4">
{sections.map(section => {
const visible = section.items.filter(item => hasAccess(user?.role, item.requiredRole))
{/* Nav body */}
<div
style={{
flex: 1,
padding: 8,
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
{sections.map((section) => {
const visible = section.items.filter((item) => hasAccess(user?.role, item.requiredRole))
if (visible.length === 0) return null
return (
<div key={section.title} className="mt-4">
<div key={section.title}>
{/* Section title */}
<div
className="px-2 mb-1 text-[11px] font-semibold uppercase tracking-wider"
style={{ color: 'var(--text-muted)' }}
style={{
color: 'var(--text-muted)',
fontSize: 11,
textTransform: 'uppercase',
letterSpacing: 0.5,
padding: '8px 10px 4px',
}}
>
{section.title}
</div>
{visible.map(item => {
{/* Section items */}
{visible.map((item) => {
const isActive = activePath === item.path
const badge = item.path === '/cron' ? cronFailures : item.badge
const itemBadge = item.path === '/cron' ? (badge: number | undefined) => {
// For cron, combine static badge with live failures
const b = cronFailures > 0 ? cronFailures : item.badge
return b
} : () => item.badge
const finalBadge = item.path === '/cron' ? (cronFailures > 0 ? cronFailures : item.badge) : item.badge
return (
<div
<button
key={item.path}
className="flex items-center justify-between rounded-[var(--radius-sm)] cursor-pointer px-2 py-[7px] text-[13px] transition-colors duration-100"
onClick={() => onNavigate(item.path)}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '8px 10px',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
fontSize: 13,
border: 'none',
background: isActive ? 'var(--honey-glow)' : 'transparent',
color: isActive ? 'var(--honey)' : 'var(--text-dim)',
fontWeight: isActive ? 600 : 400,
background: isActive ? 'var(--honey-glow)' : undefined,
width: '100%',
textAlign: 'left',
fontFamily: 'inherit',
transition: 'all .15s',
}}
onMouseEnter={e => {
if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)'
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.background = 'var(--bg-hover)'
e.currentTarget.style.color = 'var(--text)'
}
}}
onMouseLeave={e => {
if (!isActive) e.currentTarget.style.background = 'transparent'
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.background = 'transparent'
e.currentTarget.style.color = 'var(--text-dim)'
}
}}
onClick={() => onNavigate(item.path)}
>
<span>
<span style={{ marginRight: 8 }}>{item.icon}</span>
{item.label}
</span>
{badge != null && badge > 0 && (
<Badge variant="danger">{badge}</Badge>
<span>{item.icon}</span>
<span style={{ flex: 1 }}>{item.label}</span>
{item.isNew && (
<span
style={{
fontSize: 10,
padding: '1px 6px',
borderRadius: 10,
background: 'var(--honey)',
color: '#000',
fontWeight: 600,
}}
>
NEW
</span>
)}
</div>
{finalBadge != null && finalBadge > 0 && (
<span
style={{
fontSize: 10,
padding: '1px 6px',
borderRadius: 10,
background: 'var(--red)',
color: '#fff',
}}
>
{finalBadge}
</span>
)}
</button>
)
})}
</div>
+287 -244
View File
@@ -1,9 +1,6 @@
import { useEffect, useState, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import Card from '../components/ui/Card'
import Skeleton from '../components/ui/Skeleton'
import Timeline, { type TimelineEvent } from '../components/ui/Timeline'
import toast from '../components/ui/Toast'
import {
getDashboardSummary,
@@ -20,26 +17,67 @@ import {
/* ---------- helpers ---------- */
const hourLabel = (iso: string) => {
const m = iso.match(/T(\d{2}):/)
return m ? `${m[1]}:00` : iso
function timeAgo(iso: string): string {
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
if (diff < 5) return '刚刚'
if (diff < 60) return diff + ' 秒前'
const m = Math.floor(diff / 60)
if (m < 60) return m + ' 分钟前'
return Math.floor(m / 60) + ' 小时前'
}
const statusIcon = (s: string) => {
if (s === 'success') return '✅'
if (s === 'failure') return '❌'
if (s === 'paused') return '⏸'
return '❓'
const VAL_COLORS: Record<string, string> = {
green: 'var(--green)',
red: 'var(--red)',
yellow: 'var(--yellow)',
blue: 'var(--blue)',
}
const trendColor = (item: GatewayTrendItem): string => {
if (item.heartbeat_timeouts > 0) return 'var(--red)'
if (item.feishu_disconnects > 0) return 'var(--orange)'
if (item.restarts > 0) return 'var(--yellow)'
return 'var(--green)'
interface RecentEvent {
time: string
profile: string
type: string
typeClass: string
summary: string
status: string
statusClass: string
}
/* ---------- bar-chart sub-component ---------- */
const MOCK_EVENTS: RecentEvent[] = [
{ time: '21:13', profile: 'hindsight', type: 'Cron 失败', typeClass: 'badge-offline', summary: 'Hindsight index 更新超时', status: '待处理', statusClass: 'badge-warn' },
{ time: '18:45', profile: 'monitor', type: '心跳超时', typeClass: 'badge-warn', summary: 'Gateway 心跳 3 次无响应', status: '待处理', statusClass: 'badge-warn' },
{ time: '14:20', profile: 'feishu-gateway', type: '飞书断连', typeClass: 'badge-warn', summary: 'WebSocket 断开 8 分钟后恢复', status: '已恢复', statusClass: 'badge-a' },
]
/* ---------- Stat card ---------- */
function StatCard({
label, value, valueColor, change, changeDirection,
}: {
label: string; value: string | number; valueColor?: string
change?: string; changeDirection?: 'up' | 'down'
}) {
const dirIcon = changeDirection === 'up' ? '↑' : '↓'
const dirColor = changeDirection === 'up' ? 'var(--green)' : 'var(--red)'
return (
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: 16 }}>
<div style={{ fontSize: 12, color: 'var(--text-dim)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 8 }}>
{label}
</div>
<div style={{ fontSize: 28, fontWeight: 700, color: valueColor ? VAL_COLORS[valueColor] : 'var(--text)' }}>
{value}
</div>
{change && (
<div style={{ fontSize: 12, marginTop: 4, color: changeDirection ? dirColor : 'var(--text-dim)' }}>
{changeDirection && <span>{dirIcon} </span>}
{change}
</div>
)}
</div>
)
}
/* ---------- Health vertical bar chart ---------- */
const GRADE_META = [
{ key: 'A', color: 'var(--green)' },
@@ -48,138 +86,154 @@ const GRADE_META = [
{ key: 'D', color: 'var(--red)' },
] as const
function HealthBarChart({
dist,
onBarClick,
}: {
dist: HealthDistribution
onBarClick: (g: string) => void
}) {
function HealthBarChart({ dist, onBarClick }: { dist: HealthDistribution; onBarClick: (g: string) => void }) {
if (!dist || dist.total === 0) {
return <p style={{ color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</p>
}
const maxVal = Math.max(dist.A, dist.B, dist.C, dist.D, 1)
return (
<div>
<div className="space-y-3 mt-2">
{GRADE_META.map(({ key, color }) => {
const val = dist[key as keyof typeof dist] as number
const pct = (val / maxVal) * 100
return (
<div
key={key}
className="flex items-center gap-3 cursor-pointer group"
onClick={() => onBarClick(key)}
>
<span
className="w-5 text-center text-[13px] font-bold shrink-0"
style={{ color }}
>
{key}
</span>
<div className="flex-1 h-6 rounded-[var(--radius-sm)] bg-[var(--bg-hover)] overflow-hidden relative">
<div
className="h-full rounded-[var(--radius-sm)] transition-all duration-500 group-hover:opacity-80"
style={{
width: `${Math.max(pct, 4)}%`,
backgroundColor: color,
}}
/>
</div>
<span className="w-8 text-right text-[13px] shrink-0" style={{ color: 'var(--text-dim)' }}>
{val}
</span>
</div>
)
})}
</div>
{dist.d_profiles && dist.d_profiles.length > 0 && (
<div className="mt-3 pt-3 border-t" style={{ borderColor: 'var(--border)' }}>
<span className="text-[12px]" style={{ color: 'var(--text-dim)' }}>D 级 Profile: </span>
{dist.d_profiles.map((name) => (
<span key={name} className="inline-block text-[12px] px-2 py-0.5 mr-1 mb-1 rounded-[var(--radius-sm)]" style={{ background: 'var(--bg-hover)', color: 'var(--red)' }}>
{name}
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 16, height: 120, padding: '0 8px' }}>
{GRADE_META.map(function(meta) {
var val = dist[meta.key];
var pct = (val / maxVal) * 100;
return (
<div key={meta.key} onClick={function() { onBarClick(meta.key) }}
style={{
flex: 1, borderRadius: '4px 4px 0 0', textAlign: 'center', position: 'relative',
minWidth: 40, cursor: 'pointer', height: Math.max(pct, 4) + '%', backgroundColor: meta.color,
transition: 'opacity .15s',
}}
onMouseEnter={function(e) { e.currentTarget.style.opacity = '0.8' }}
onMouseLeave={function(e) { e.currentTarget.style.opacity = '1' }}
>
<span style={{ position: 'absolute', top: -18, left: '50%', transform: 'translateX(-50%)', fontSize: 12, fontWeight: 700, color: 'var(--text)' }}>
{val}
</span>
))}
</div>
)}
<span style={{ position: 'absolute', bottom: -22, left: '50%', transform: 'translateX(-50%)', fontSize: 11, color: 'var(--text-dim)' }}>
{meta.key}级
</span>
</div>
)
})}
</div>
)
}
/* ---------- Gateway 48-bar timeline ---------- */
function barColor(item: GatewayTrendItem): string {
if (item.heartbeat_timeouts > 0) return 'var(--red)'
if (item.feishu_disconnects > 0) return 'var(--orange)'
return 'var(--green)'
}
function GatewayTimeline({ trend }: { trend: GatewayTrendItem[] }) {
var bars = [];
for (var i = 0; i < trend.length; i++) {
var item = trend[i];
var col = barColor(item);
var details = [];
if (item.heartbeat_timeouts > 0) details.push('心跳超时 ' + item.heartbeat_timeouts);
if (item.feishu_disconnects > 0) details.push('飞书断连 ' + item.feishu_disconnects);
if (item.restarts > 0) details.push('重启 ' + item.restarts);
var tip = details.length > 0 ? details.join(' / ') : '正常运行';
bars.push({ color: col, tooltip: item.hour + ': ' + tip });
bars.push({ color: col, tooltip: item.hour + ': ' + tip });
}
return (
<div style={{ display: 'flex', gap: 2, height: 40, alignItems: 'flex-end', marginBottom: 12 }}>
{bars.map(function(b, i) {
return (
<div key={i} title={b.tooltip}
style={{
flex: 1, borderRadius: '2px 2px 0 0', minWidth: 3, cursor: 'pointer', position: 'relative',
backgroundColor: b.color,
}}
onMouseEnter={function(e) { e.currentTarget.style.opacity = '0.7' }}
onMouseLeave={function(e) { e.currentTarget.style.opacity = '1' }}
/>
)
})}
</div>
)
}
/* ---------- Badge helpers for events table ---------- */
function BadgeSpan({ cls, children }: { cls: string; children: React.ReactNode }) {
var styleMap: Record<string, React.CSSProperties> = {
'badge-offline': { background: 'rgba(255,61,61,.15)', color: 'var(--red)' },
'badge-warn': { background: 'rgba(255,171,0,.15)', color: 'var(--yellow)' },
'badge-a': { background: 'rgba(0,200,83,.15)', color: 'var(--green)' },
}
var baseStyle: React.CSSProperties = styleMap[cls] || { background: 'var(--bg-hover)', color: 'var(--text-dim)' }
return (
<span style={Object.assign({ display: 'inline-block', padding: '2px 8px', borderRadius: 10, fontSize: 11, fontWeight: 600 }, baseStyle)}>
{children}
</span>
)
}
/* ---------- main page ---------- */
export default function DashboardPage() {
const navigate = useNavigate()
var navigate = useNavigate()
const [summary, setSummary] = useState<SummaryData | null>(null)
const [health, setHealth] = useState<HealthDistribution | null>(null)
const [trend, setTrend] = useState<GatewayTrendItem[]>([])
const [kanban, setKanban] = useState<KanbanSummaryItem[]>([])
const [cron, setCron] = useState<CronRecentItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
var _a = useState<SummaryData | null>(null), summary = _a[0], setSummary = _a[1]
var _b = useState<HealthDistribution | null>(null), health = _b[0], setHealth = _b[1]
var _c = useState<GatewayTrendItem[]>([]), trend = _c[0], setTrend = _c[1]
var _d = useState<KanbanSummaryItem[]>([]), kanban = _d[0], setKanban = _d[1]
var _e = useState<CronRecentItem[]>([]), cron = _e[0], setCron = _e[1]
var _f = useState(true), loading = _f[0], setLoading = _f[1]
var _g = useState<string | null>(null), error = _g[0], setError = _g[1]
const fetchAll = useCallback(async () => {
var fetchAll = useCallback(async function() {
setLoading(true)
setError(null)
try {
const [s, h, t, k, c] = await Promise.all([
var res = await Promise.all([
getDashboardSummary(),
getHealthDistribution(),
getGatewayTrend(),
getKanbanSummary(),
getCronRecent(),
])
setSummary(s.data)
setHealth(h.data)
setTrend(t.data)
setKanban(k.data)
setCron(c.data)
} catch {
setSummary(res[0].data)
setHealth(res[1].data)
setTrend(res[2].data)
setKanban(res[3].data)
setCron(res[4].data)
} catch (e) {
setError('加载仪表盘数据失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchAll() }, [fetchAll])
useEffect(function() { fetchAll() }, [fetchAll])
/* ---------- loading ---------- */
/* loading */
if (loading) {
return (
<div>
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>蜂群总览</h2>
<Skeleton variant="card" count={4} className="mt-4" />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4">
<Skeleton variant="card" count={1} />
<Skeleton variant="card" count={1} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4">
<Skeleton variant="card" count={1} />
<Skeleton variant="card" count={1} />
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 4, color: 'var(--text)' }}>蜂群总览</div>
<Skeleton variant='card' count={4} className='mt-4' />
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 20, marginTop: 20 }}>
<Skeleton variant='card' count={1} />
<Skeleton variant='card' count={1} />
</div>
</div>
)
}
/* ---------- error ---------- */
/* error */
if (error) {
return (
<div>
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>蜂群总览</h2>
<div
className="mt-4 p-5 rounded-[var(--radius)] text-center"
style={{ background: 'var(--bg-card)' }}
>
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 4, color: 'var(--text)' }}>蜂群总览</div>
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: 20, textAlign: 'center', marginTop: 20 }}>
<p style={{ color: 'var(--red)', fontSize: 14, marginBottom: 12 }}>{error}</p>
<button
onClick={fetchAll}
className="px-4 py-2 text-[13px] rounded-[var(--radius-sm)] cursor-pointer border-none transition-colors"
style={{ background: 'var(--honey)', color: '#000' }}
>
<button onClick={fetchAll} style={{ background: 'var(--honey)', color: '#000', border: 'none', borderRadius: 'var(--radius-sm)', padding: '6px 14px', fontSize: 13, cursor: 'pointer', fontWeight: 600 }}>
重试
</button>
</div>
@@ -187,174 +241,163 @@ export default function DashboardPage() {
)
}
/* ---------- empty ---------- */
/* empty */
if (!summary) {
return (
<div>
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>蜂群总览</h2>
<p className="mt-4" style={{ color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</p>
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 4, color: 'var(--text)' }}>蜂群总览</div>
<p style={{ color: 'var(--text-dim)', fontSize: 13, marginTop: 20 }}>暂无数据</p>
</div>
)
}
/* ---------- trend -> timeline events ---------- */
const timelineEvents: TimelineEvent[] = trend.map((item) => {
const details: string[] = []
if (item.heartbeat_timeouts > 0) details.push(`心跳超时 ${item.heartbeat_timeouts}`)
if (item.feishu_disconnects > 0) details.push(`飞书断连 ${item.feishu_disconnects}`)
if (item.restarts > 0) details.push(`重启 ${item.restarts}`)
const tooltipText = details.length > 0 ? details.join(' / ') : '正常'
return {
time: hourLabel(item.hour),
type: item.heartbeat_timeouts > 0 ? 'red' : item.feishu_disconnects > 0 ? 'orange' : item.restarts > 0 ? 'orange' : 'green',
tooltip: tooltipText,
color: trendColor(item),
}
})
/* subtitle */
var subtitle = summary.total_profiles + ' 个 Profile' + (summary.updated_at ? ' · 最后更新 ' + timeAgo(summary.updated_at) : '')
return (
<div>
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>蜂群总览</h2>
{/* page header */}
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 4, color: 'var(--text)' }}>蜂群总览</div>
<div style={{ color: 'var(--text-dim)', fontSize: 13, marginBottom: 20 }}>{subtitle}</div>
{/* ---- summary cards ---- */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-[14px] mt-4">
<Card
label="在线 Gateway"
value={summary.online_count}
valueColor="green"
change="2 vs 昨天"
changeDirection="up"
/>
<Card
label="离线 Gateway"
value={summary.offline_count}
valueColor="red"
change="1 vs 昨天"
changeDirection="down"
/>
<Card
label="运行中任务"
value={summary.running_tasks}
valueColor="blue"
/>
<Card
label="今日 Cron 失败"
value={summary.cron_failures_today}
valueColor={summary.cron_failures_today > 3 ? 'red' : 'yellow'}
change={summary.cron_failures_today > 3 ? '需关注' : undefined}
changeDirection={summary.cron_failures_today > 3 ? 'up' : undefined}
/>
{/* stat cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 14, marginBottom: 20 }}>
<StatCard label='在线 Gateway' value={summary.online_count} valueColor='green' change={'↑ vs 昨天'} changeDirection='up' />
<StatCard label='离线 Gateway' value={summary.offline_count} valueColor='red' change={'↓ vs 昨天'} changeDirection='down' />
<StatCard label='运行中任务' value={summary.running_tasks} valueColor='blue' change={'Kanban · ' + kanban.length + ' 个看板活跃'} />
<StatCard label='今日 Cron 失败' value={summary.cron_failures_today} valueColor='red' />
</div>
{/* ---- health + trend ---- */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-[14px] mt-4">
{/* health + trend */}
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 20, marginBottom: 20 }}>
{/* health distribution */}
<div
className="rounded-[var(--radius)] p-4"
style={{ background: 'var(--bg-card)' }}
>
<h3 className="text-[14px] font-semibold mb-1" style={{ color: 'var(--text)' }}>
蜂群健康分布
</h3>
<p className="text-[12px] mb-2" style={{ color: 'var(--text-dim)' }}>
共 {health?.total ?? 0} 个 Profile
</p>
{health ? (
<HealthBarChart dist={health} onBarClick={(g) => navigate(`/health?filter=${g}`)} />
) : (
<p style={{ color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</p>
)}
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
<div style={{ padding: '14px 16px', borderBottom: '1px solid var(--border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>
<span>蜂群健康分布</span>
<span onClick={function() { navigate('/health') }} style={{ color: 'var(--text-dim)', fontSize: 12, cursor: 'pointer' }}>
查看全部 →
</span>
</div>
<div style={{ padding: 16 }}>
{health ? (
<div>
<HealthBarChart dist={health} onBarClick={function(g) { navigate('/health?filter=' + g) }} />
{health.d_profiles && health.d_profiles.length > 0 ? (
<div style={{ marginTop: 16, fontSize: 12, color: 'var(--text-dim)' }}>
<span>⚠ {health.d_profiles.length} 个 D 级 Profile:</span>
<span style={{ color: 'var(--red)' }}>{health.d_profiles.join(' · ')}</span>
</div>
) : null}
</div>
) : (
<p style={{ color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</p>
)}
</div>
</div>
{/* gateway trend */}
<div
className="rounded-[var(--radius)] p-4"
style={{ background: 'var(--bg-card)' }}
>
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text)' }}>
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
<div style={{ padding: '14px 16px', borderBottom: '1px solid var(--border)', fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>
Gateway 异常趋势 (24h)
</h3>
{trend.length > 0 ? (
<div className="overflow-x-auto pb-2">
<Timeline events={timelineEvents} className="min-w-[480px]" />
</div>
<div style={{ padding: 16 }}>
{trend.length > 0 ? <GatewayTimeline trend={trend} /> : <p style={{ color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</p>}
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--text-dim)' }}>
<span><span style={{ color: 'var(--green)' }}>●</span> 正常运行</span>
<span><span style={{ color: 'var(--red)' }}>●</span> 心跳超时</span>
<span><span style={{ color: 'var(--orange)' }}>●</span> 飞书断连</span>
</div>
) : (
<p style={{ color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</p>
)}
<div className="flex gap-4 mt-3 text-[12px]" style={{ color: 'var(--text-dim)' }}>
<span><span style={{ color: 'var(--green)' }}>●</span> 正常</span>
<span><span style={{ color: 'var(--red)' }}>●</span> 心跳超时</span>
<span><span style={{ color: 'var(--orange)' }}>●</span> 飞书断连</span>
<span><span style={{ color: 'var(--yellow)' }}>●</span> 重启</span>
</div>
</div>
</div>
{/* ---- kanban + cron ---- */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-[14px] mt-4">
{/* kanban + cron */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20, marginBottom: 20 }}>
{/* kanban overview */}
<div
className="rounded-[var(--radius)] p-4"
style={{ background: 'var(--bg-card)' }}
>
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text)' }}>
Kanban 概览
</h3>
{kanban.length > 0 ? (
<div className="space-y-2">
{kanban.map((item) => (
<div
key={item.board}
className="flex items-center gap-3 py-2 px-3 rounded-[var(--radius-sm)] transition-colors cursor-pointer hover:bg-[var(--bg-hover)]"
onClick={() => navigate('/kanban')}
>
<span className="text-[13px] font-medium min-w-[120px]" style={{ color: 'var(--text)' }}>
{item.board}
</span>
<span className="flex items-center gap-1 text-[12px] px-2 py-0.5 rounded-[var(--radius-sm)]" style={{ background: 'var(--bg-hover)', color: 'var(--honey)' }}>
RUNNING: {item.running ?? '-'}
</span>
<span className="flex items-center gap-1 text-[12px] px-2 py-0.5 rounded-[var(--radius-sm)]" style={{ background: 'var(--bg-hover)', color: 'var(--green)' }}>
READY: {item.ready ?? '-'}
</span>
</div>
))}
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
<div style={{ padding: '14px 16px', borderBottom: '1px solid var(--border)', fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>
Kanban 任务概览
</div>
<div style={{ padding: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: 'var(--text-dim)', marginBottom: 12 }}>
<span>看板</span>
<span>RUNNING</span>
<span>READY</span>
</div>
) : (
<p style={{ color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</p>
)}
{kanban.length > 0 ? kanban.map(function(item) {
return (
<div key={item.board} onClick={function() { navigate('/kanban') }}
style={{ fontSize: 12, color: 'var(--text-dim)', padding: '4px 0', borderBottom: '1px solid var(--border-light)', cursor: 'pointer', display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--text-dim)' }}>{item.board}</span>
<span style={{ color: 'var(--green)' }}>{item.running || '—'}</span>
<span style={{ color: 'var(--blue)', marginLeft: 40 }}>{item.ready || '—'}</span>
</div>
)
}) : <p style={{ color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</p>}
</div>
</div>
{/* cron recent */}
<div
className="rounded-[var(--radius)] p-4"
style={{ background: 'var(--bg-card)' }}
>
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text)' }}>
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
<div style={{ padding: '14px 16px', borderBottom: '1px solid var(--border)', fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>
Cron 最近执行
</h3>
{cron.length > 0 ? (
<div className="space-y-1">
{cron.map((item) => (
<div
key={item.job}
className="flex items-center gap-3 py-2 px-3 rounded-[var(--radius-sm)] transition-colors cursor-pointer hover:bg-[var(--bg-hover)]"
onClick={() => navigate('/cron')}
>
<span className="text-[13px]" style={{ color: 'var(--text)' }}>
{item.job}
</span>
<span className="text-[14px]">{statusIcon(item.status)}</span>
<span className="text-[12px]" style={{ color: 'var(--text-dim)' }}>
{item.time ?? '-'}
</span>
</div>
))}
</div>
<div style={{ padding: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: 'var(--text-dim)', marginBottom: 8 }}>
<span>Job</span>
<span>状态</span>
<span>时间</span>
</div>
) : (
<p style={{ color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</p>
)}
{cron.length > 0 ? cron.map(function(item) {
return (
<div key={item.job} onClick={function() { navigate('/cron') }}
style={{ fontSize: 12, padding: '4px 0', borderBottom: '1px solid var(--border-light)', cursor: 'pointer' }}>
<span style={{ color: 'var(--text-dim)' }}>{item.job}</span>
<span style={{ float: 'right', marginRight: 80 }}>
{item.status === 'success' ? '✅' : item.status === 'failure' ? '❌' : item.status === 'paused' ? '⏸' : '❓'}
</span>
<span style={{ float: 'right', color: 'var(--text-muted)' }}>{item.time || '—'}</span>
</div>
)
}) : <p style={{ color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</p>}
</div>
</div>
</div>
{/* recent events table */}
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
<div style={{ padding: '14px 16px', borderBottom: '1px solid var(--border)', fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>
最近异常事件
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>时间</th>
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>Profile</th>
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>类型</th>
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>摘要</th>
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>状态</th>
</tr>
</thead>
<tbody>
{MOCK_EVENTS.map(function(evt, i) {
return (
<tr key={i} style={evt.status === '待处理' ? { background: 'rgba(255,61,61,.06)' } : undefined}>
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--text)' }}>{evt.time}</td>
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--blue)' }}>{evt.profile}</td>
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)' }}>
<BadgeSpan cls={evt.typeClass}>{evt.type}</BadgeSpan>
</td>
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--text-dim)' }}>{evt.summary}</td>
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)' }}>
<BadgeSpan cls={evt.statusClass}>{evt.status}</BadgeSpan>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
</div>