feat: visual overhaul — Sidebar/Header/Dashboard aligned to prototype
This commit is contained in:
@@ -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(--blue)'
|
||||
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,136 +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>
|
||||
<span className="inline-block text-[12px] px-2 py-0.5 rounded-[var(--radius-sm)]" style={{ background: 'var(--bg-hover)', color: 'var(--red)' }}>
|
||||
{dist.d_profiles.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
<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>
|
||||
@@ -185,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 ? 'blue' : '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(--blue)' }}>●</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>
|
||||
|
||||
Reference in New Issue
Block a user