1
0

feat: 重构 Dashboard 为卡片式分组布局

表格布局替换为按分组展示的卡片式布局,新增 group 字段配置和 TargetBoard/TargetCard 等组件。模态框详情页支持时间范围筛选和分页,SummaryCards 减为 3 个。API 端点变更:trend/history 改用 from/to 参数,history 支持分页。recentSampleCount 硬编码为 30。
This commit is contained in:
2026-05-11 08:54:21 +08:00
parent b8810f1182
commit 548b44d28e
44 changed files with 1676 additions and 557 deletions

View File

@@ -0,0 +1,17 @@
import type { TargetStatus } from "../../shared/api";
import { TargetCard } from "./TargetCard";
interface CardGridProps {
targets: TargetStatus[];
onTargetClick: (target: TargetStatus) => void;
}
export function CardGrid({ targets, onTargetClick }: CardGridProps) {
return (
<div className="card-grid">
{targets.map((target) => (
<TargetCard key={target.id} target={target} onClick={() => onTargetClick(target)} />
))}
</div>
);
}

View File

@@ -0,0 +1,19 @@
interface GroupHeaderProps {
name: string;
total: number;
up: number;
down: number;
}
export function GroupHeader({ name, total, up, down }: GroupHeaderProps) {
const displayName = name === "default" ? "默认分组" : name;
return (
<div className="group-header">
<h2 className="group-title">{displayName}</h2>
<span className="group-stats">
({total}, {up} UP / {down} DOWN)
</span>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { Line, LineChart, ResponsiveContainer } from "recharts";
import type { RecentSample } from "../../shared/api";
interface MiniSparklineProps {
data: RecentSample[];
}
export function MiniSparkline({ data }: MiniSparklineProps) {
const chartData = data
.filter((s) => s.durationMs !== null)
.map((s) => ({ duration: s.durationMs! }))
.reverse();
if (chartData.length === 0) {
return <span className="sparkline-empty">-</span>;
}
return (
<ResponsiveContainer width={80} height={32}>
<LineChart data={chartData}>
<Line type="monotone" dataKey="duration" stroke="#356dd2" strokeWidth={1.5} dot={false} />
</LineChart>
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,47 @@
interface PaginationProps {
page: number;
pageSize: number;
total: number;
onPageChange: (page: number) => void;
}
export function Pagination({ page, pageSize, total, onPageChange }: PaginationProps) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
if (totalPages <= 1) return null;
const pages: number[] = [];
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
const visiblePages = pages.filter((p) => {
if (totalPages <= 7) return true;
if (p === 1 || p === totalPages) return true;
if (Math.abs(p - page) <= 1) return true;
return false;
});
return (
<div className="pagination">
<button className="pagination-btn" disabled={page <= 1} onClick={() => onPageChange(page - 1)}>
&lt;
</button>
{visiblePages.map((p, idx) => {
const prev = visiblePages[idx - 1];
const showEllipsis = prev !== undefined && p - prev > 1;
return (
<span key={p} className="pagination-items">
{showEllipsis && <span className="pagination-ellipsis">...</span>}
<button className={`pagination-btn ${p === page ? "active" : ""}`} onClick={() => onPageChange(p)}>
{p}
</button>
</span>
);
})}
<button className="pagination-btn" disabled={page >= totalPages} onClick={() => onPageChange(page + 1)}>
&gt;
</button>
</div>
);
}

View File

@@ -1,19 +0,0 @@
import { Line, LineChart, ResponsiveContainer } from "recharts";
interface SparklineChartProps {
data: Array<{ duration: number }>;
}
export function SparklineChart({ data }: SparklineChartProps) {
if (data.length === 0) {
return <span className="sparkline-empty">-</span>;
}
return (
<ResponsiveContainer width={80} height={32}>
<LineChart data={data}>
<Line type="monotone" dataKey="duration" stroke="#356dd2" strokeWidth={1.5} dot={false} />
</LineChart>
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,17 @@
interface StatusBarProps {
samples: Array<{ up: boolean }>;
}
export function StatusBar({ samples }: StatusBarProps) {
const blocks = [];
for (let i = 0; i < 30; i++) {
const sample = samples[i];
if (sample) {
blocks.push(<span key={i} className={`status-bar-block ${sample.up ? "status-bar-up" : "status-bar-down"}`} />);
} else {
blocks.push(<span key={i} className="status-bar-block status-bar-empty" />);
}
}
return <div className="status-bar">{blocks}</div>;
}

View File

@@ -0,0 +1,40 @@
import { PieChart, Pie, Cell, ResponsiveContainer } from "recharts";
interface StatusDonutProps {
up: number;
down: number;
}
const UP_COLOR = "#1fbf75";
const DOWN_COLOR = "#e5484d";
const EMPTY_COLOR = "#e2e8f0";
export function StatusDonut({ up, down }: StatusDonutProps) {
const total = up + down;
const availability = total > 0 ? ((up / total) * 100).toFixed(1) : "-";
const data =
total > 0
? [
{ name: "UP", value: up },
{ name: "DOWN", value: down },
]
: [{ name: "EMPTY", value: 1 }];
const colors = total > 0 ? [UP_COLOR, DOWN_COLOR] : [EMPTY_COLOR];
return (
<div className="status-donut">
<ResponsiveContainer width="100%" height={180}>
<PieChart>
<Pie data={data} cx="50%" cy="50%" innerRadius={50} outerRadius={70} dataKey="value" stroke="none">
{data.map((_, index) => (
<Cell key={index} fill={colors[index % colors.length]!} />
))}
</Pie>
</PieChart>
</ResponsiveContainer>
<div className="donut-center-label">{availability}%</div>
</div>
);
}

View File

@@ -16,11 +16,6 @@ export function SummaryCards({ summary, loading }: SummaryCardsProps) {
{ label: "全部目标", value: summary.total, className: "card-total" },
{ label: "正常", value: summary.up, className: "card-up" },
{ label: "异常", value: summary.down, className: "card-down" },
{
label: "平均耗时",
value: summary.avgDurationMs !== null ? `${Math.round(summary.avgDurationMs)}ms` : "-",
className: "card-latency",
},
];
return (

View File

@@ -0,0 +1,28 @@
import type { TargetStatus } from "../../shared/api";
import { TargetGroup } from "./TargetGroup";
interface TargetBoardProps {
targets: TargetStatus[];
onTargetClick: (target: TargetStatus) => void;
}
export function TargetBoard({ targets, onTargetClick }: TargetBoardProps) {
const groups = new Map<string, TargetStatus[]>();
for (const target of targets) {
const group = target.group;
const list = groups.get(group);
if (list) {
list.push(target);
} else {
groups.set(group, [target]);
}
}
return (
<div className="target-board">
{Array.from(groups.entries()).map(([name, groupTargets]) => (
<TargetGroup key={name} name={name} targets={groupTargets} onTargetClick={onTargetClick} />
))}
</div>
);
}

View File

@@ -0,0 +1,27 @@
import type { TargetStatus } from "../../shared/api";
import { StatusDot } from "./StatusDot";
import { StatusBar } from "./StatusBar";
import { MiniSparkline } from "./MiniSparkline";
interface TargetCardProps {
target: TargetStatus;
onClick: () => void;
}
export function TargetCard({ target, onClick }: TargetCardProps) {
const isUp = target.latestCheck?.success && target.latestCheck?.matched;
return (
<div className="target-card" onClick={onClick} role="button" tabIndex={0}>
<div className="card-row-1">
<StatusDot up={!!isUp} />
<span className="card-name">{target.name}</span>
<span className="card-type-badge">{target.type === "http" ? "HTTP" : "Command"}</span>
</div>
<div className="card-row-2">
<StatusBar samples={target.recentSamples} />
<MiniSparkline data={target.recentSamples} />
</div>
</div>
);
}

View File

@@ -1,99 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import type { CheckResult, TargetStatus } from "../../shared/api";
import { useTrend } from "../hooks/useTrend";
import { TrendChart } from "./TrendChart";
interface TargetDetailProps {
target: TargetStatus;
}
export function TargetDetail({ target }: TargetDetailProps) {
const { data: trendData, loading: trendLoading, fetchTrend } = useTrend(target.id);
const [history, setHistory] = useState<CheckResult[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const fetchHistory = useCallback(async () => {
setHistoryLoading(true);
try {
const response = await fetch(`/api/targets/${target.id}/history?limit=10`);
if (response.ok) {
const data = (await response.json()) as CheckResult[];
setHistory(data);
}
} finally {
setHistoryLoading(false);
}
}, [target.id]);
useEffect(() => {
fetchTrend();
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchHistory();
}, [fetchTrend, fetchHistory]);
const { stats } = target;
const isUp = target.latestCheck?.success && target.latestCheck?.matched;
return (
<tr>
<td colSpan={6} className="detail-cell">
<div className="target-detail">
<div className="detail-stats">
<div className="detail-stat">
<span className="detail-stat-label"></span>
<span className={`detail-stat-value ${isUp ? "text-up" : "text-down"}`}>{isUp ? "UP" : "DOWN"}</span>
</div>
<div className="detail-stat">
<span className="detail-stat-label"></span>
<span className="detail-stat-value">
{stats.totalChecks > 0 ? `${stats.availability.toFixed(1)}%` : "-"}
</span>
</div>
<div className="detail-stat">
<span className="detail-stat-label"></span>
<span className="detail-stat-value">
{stats.avgDurationMs !== null ? `${Math.round(stats.avgDurationMs)}ms` : "-"}
</span>
</div>
<div className="detail-stat">
<span className="detail-stat-label">P99 </span>
<span className="detail-stat-value">
{stats.p99DurationMs !== null ? `${Math.round(stats.p99DurationMs)}ms` : "-"}
</span>
</div>
</div>
<div className="detail-trend">
<h4>24 </h4>
<TrendChart data={trendData} loading={trendLoading} />
</div>
<div className="detail-history">
<h4></h4>
{historyLoading ? (
<p className="history-empty">...</p>
) : history.length > 0 ? (
<div className="history-list">
{history.map((item, idx) => (
<div key={idx} className="history-item">
<span className={`history-status ${item.success && item.matched ? "text-up" : "text-down"}`}>
{item.success && item.matched ? "UP" : "DOWN"}
</span>
<span className="history-time">{new Date(item.timestamp).toLocaleString("zh-CN")}</span>
{item.statusDetail && <span className="history-code">{item.statusDetail}</span>}
{item.durationMs !== null && (
<span className="history-latency">{Math.round(item.durationMs)}ms</span>
)}
{item.failure?.message && <span className="history-error">{item.failure.message}</span>}
</div>
))}
</div>
) : (
<p className="history-empty"></p>
)}
</div>
</div>
</td>
</tr>
);
}

View File

@@ -0,0 +1,121 @@
import type { CheckResult, TargetStatus, TrendPoint } from "../../shared/api";
import { TrendChart } from "./TrendChart";
import { StatusDonut } from "./StatusDonut";
import { StatusDot } from "./StatusDot";
import { TimeRangePicker } from "./TimeRangePicker";
import { Pagination } from "./Pagination";
interface TargetDetailModalProps {
target: TargetStatus;
trendData: TrendPoint[];
trendLoading: boolean;
historyData: HistoryData;
historyLoading: boolean;
timeFrom: string;
timeTo: string;
onTimeChange: (from: string, to: string) => void;
onPageChange: (page: number) => void;
onClose: () => void;
}
interface HistoryData {
items: CheckResult[];
total: number;
page: number;
pageSize: number;
}
export function TargetDetailModal({
target,
trendData,
trendLoading,
historyData,
historyLoading,
timeFrom,
timeTo,
onTimeChange,
onPageChange,
onClose,
}: TargetDetailModalProps) {
const isUp = target.latestCheck?.success && target.latestCheck?.matched;
const totalChecks = trendData.reduce((sum, p) => sum + p.totalChecks, 0);
const upChecks = trendData.reduce((sum, p) => sum + Math.round((p.availability / 100) * p.totalChecks), 0);
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<div className="modal-title-row">
<StatusDot up={!!isUp} />
<h3 className="modal-title">{target.name}</h3>
<span className="card-type-badge">{target.type === "http" ? "HTTP" : "Command"}</span>
</div>
<button className="modal-close-btn" onClick={onClose}>
&times;
</button>
</div>
<TimeRangePicker from={timeFrom} to={timeTo} onChange={onTimeChange} />
<div className="modal-body">
<div className="modal-charts">
<div className="modal-chart-section">
<h4></h4>
<StatusDonut up={upChecks} down={totalChecks - upChecks} />
</div>
<div className="modal-chart-section">
<h4></h4>
<TrendChart data={trendData} loading={trendLoading} />
</div>
</div>
<div className="modal-history-section">
<h4></h4>
{historyLoading ? (
<div className="history-loading">...</div>
) : historyData.items.length > 0 ? (
<>
<table className="history-table">
<thead>
<tr>
<th className="ht-col-status"></th>
<th className="ht-col-time"></th>
<th className="ht-col-detail"></th>
<th className="ht-col-latency"></th>
<th className="ht-col-error"></th>
</tr>
</thead>
<tbody>
{historyData.items.map((item, idx) => (
<tr key={idx}>
<td>
<span className={`ht-status ${item.success && item.matched ? "text-up" : "text-down"}`}>
{item.success && item.matched ? "UP" : "DOWN"}
</span>
</td>
<td className="ht-time">{new Date(item.timestamp).toLocaleString("zh-CN")}</td>
<td className="ht-detail">{item.statusDetail ?? "-"}</td>
<td className="ht-latency">
{item.durationMs !== null ? `${Math.round(item.durationMs)}ms` : "-"}
</td>
<td className="ht-error">{item.failure?.message ?? ""}</td>
</tr>
))}
</tbody>
</table>
<Pagination
page={historyData.page}
pageSize={historyData.pageSize}
total={historyData.total}
onPageChange={onPageChange}
/>
</>
) : (
<div className="history-empty"></div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import type { TargetStatus } from "../../shared/api";
import { GroupHeader } from "./GroupHeader";
import { CardGrid } from "./CardGrid";
interface TargetGroupProps {
name: string;
targets: TargetStatus[];
onTargetClick: (target: TargetStatus) => void;
}
export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps) {
const up = targets.filter((t) => t.latestCheck?.success && t.latestCheck?.matched).length;
const down = targets.length - up;
return (
<div className="target-group">
<GroupHeader name={name} total={targets.length} up={up} down={down} />
<CardGrid targets={targets} onTargetClick={onTargetClick} />
</div>
);
}

View File

@@ -1,34 +0,0 @@
import type { TargetStatus } from "../../shared/api";
import { StatusDot } from "./StatusDot";
import { SparklineChart } from "./SparklineChart";
interface TargetRowProps {
target: TargetStatus;
expanded: boolean;
onToggle: () => void;
}
export function TargetRow({ target, expanded, onToggle }: TargetRowProps) {
const isUp = target.latestCheck?.success && target.latestCheck?.matched;
const sparklineData = target.sparkline.map((duration) => ({ duration }));
return (
<tr className={`target-row ${expanded ? "expanded" : ""}`} onClick={onToggle}>
<td className="col-status">
<StatusDot up={!!isUp} />
</td>
<td className="col-name">{target.name}</td>
<td className="col-target">{target.target}</td>
<td className="col-type">{target.type === "http" ? "HTTP" : "Command"}</td>
<td className="col-duration">
{target.latestCheck?.durationMs !== null && target.latestCheck?.durationMs !== undefined
? `${Math.round(target.latestCheck.durationMs)}ms`
: "-"}
</td>
<td className="col-sparkline">
<SparklineChart data={sparklineData} />
</td>
</tr>
);
}

View File

@@ -1,66 +0,0 @@
import { useState } from "react";
import type { TargetStatus } from "../../shared/api";
import { TargetRow } from "./TargetRow";
import { TargetDetail } from "./TargetDetail";
interface TargetTableProps {
targets: TargetStatus[];
loading: boolean;
}
export function TargetTable({ targets, loading }: TargetTableProps) {
const [expandedId, setExpandedId] = useState<number | null>(null);
if (loading && targets.length === 0) {
return <div className="table-loading">...</div>;
}
if (targets.length === 0) {
return <div className="table-empty"></div>;
}
return (
<table className="target-table">
<thead>
<tr>
<th className="col-status"></th>
<th className="col-name"></th>
<th className="col-target"></th>
<th className="col-type"></th>
<th className="col-duration"></th>
<th className="col-sparkline"></th>
</tr>
</thead>
<tbody>
{targets.map((target) => {
const isExpanded = expandedId === target.id;
return (
<TargetRowWrapper
key={target.id}
target={target}
expanded={isExpanded}
onToggle={() => setExpandedId(isExpanded ? null : target.id)}
/>
);
})}
</tbody>
</table>
);
}
function TargetRowWrapper({
target,
expanded,
onToggle,
}: {
target: TargetStatus;
expanded: boolean;
onToggle: () => void;
}) {
return (
<>
<TargetRow target={target} expanded={expanded} onToggle={onToggle} />
{expanded && <TargetDetail target={target} />}
</>
);
}

View File

@@ -0,0 +1,80 @@
import { useState } from "react";
interface TimeRangePickerProps {
from: string;
to: string;
onChange: (from: string, to: string) => void;
}
const SHORTCUTS = [
{ label: "1h", hours: 1 },
{ label: "6h", hours: 6 },
{ label: "24h", hours: 24 },
{ label: "7d", hours: 168 },
];
function toLocalDatetimeInput(date: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function subtractHours(date: Date, hours: number): Date {
const result = new Date(date);
result.setTime(result.getTime() - hours * 60 * 60 * 1000);
return result;
}
export function TimeRangePicker({ from, to, onChange }: TimeRangePickerProps) {
const [activeShortcut, setActiveShortcut] = useState<string | null>("24h");
const handleShortcut = (label: string, hours: number) => {
const now = new Date();
const newFrom = subtractHours(now, hours);
onChange(newFrom.toISOString(), now.toISOString());
setActiveShortcut(label);
};
const handleFromChange = (value: string) => {
onChange(new Date(value).toISOString(), to);
setActiveShortcut(null);
};
const handleToChange = (value: string) => {
onChange(from, new Date(value).toISOString());
setActiveShortcut(null);
};
const fromDate = new Date(from);
const toDate = new Date(to);
return (
<div className="time-range-picker">
<div className="time-shortcuts">
{SHORTCUTS.map((s) => (
<button
key={s.label}
className={`time-shortcut-btn ${activeShortcut === s.label ? "active" : ""}`}
onClick={() => handleShortcut(s.label, s.hours)}
>
{s.label}
</button>
))}
</div>
<div className="time-inputs">
<input
type="datetime-local"
className="time-input"
value={toLocalDatetimeInput(fromDate)}
onChange={(e) => handleFromChange(e.target.value)}
/>
<span className="time-separator">~</span>
<input
type="datetime-local"
className="time-input"
value={toLocalDatetimeInput(toDate)}
onChange={(e) => handleToChange(e.target.value)}
/>
</div>
</div>
);
}