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

@@ -1,14 +1,41 @@
import { useEffect } from "react";
import { useSummary } from "./hooks/useSummary";
import { useTargets } from "./hooks/useTargets";
import { useTargetDetail } from "./hooks/useTargetDetail";
import { SummaryCards } from "./components/SummaryCards";
import { TargetTable } from "./components/TargetTable";
import { TargetBoard } from "./components/TargetBoard";
import { TargetDetailModal } from "./components/TargetDetailModal";
export function App() {
const { data: summary, loading: summaryLoading, error: summaryError } = useSummary();
const { data: targets, loading: targetsLoading, error: targetsError } = useTargets();
const { data: targets, error: targetsError } = useTargets();
const {
selectedTarget,
trendData,
trendLoading,
historyData,
historyLoading,
timeFrom,
timeTo,
openModal,
closeModal,
handleTimeChange,
handlePageChange,
} = useTargetDetail();
const error = summaryError || targetsError;
useEffect(() => {
if (selectedTarget) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [selectedTarget]);
return (
<main className="dashboard">
<header className="dashboard-header">
@@ -19,7 +46,22 @@ export function App() {
{error && <div className="error-banner">: {error}</div>}
<SummaryCards summary={summary} loading={summaryLoading} />
<TargetTable targets={targets} loading={targetsLoading} />
<TargetBoard targets={targets} onTargetClick={openModal} />
{selectedTarget && (
<TargetDetailModal
target={selectedTarget}
trendData={trendData}
trendLoading={trendLoading}
historyData={historyData}
historyLoading={historyLoading}
timeFrom={timeFrom}
timeTo={timeTo}
onTimeChange={handleTimeChange}
onPageChange={handlePageChange}
onClose={closeModal}
/>
)}
</main>
);
}

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>
);
}

View File

@@ -0,0 +1,35 @@
import { useCallback, useState } from "react";
import type { HistoryResponse } from "../../shared/api";
export function useHistory(targetId: number | null) {
const [data, setData] = useState<HistoryResponse>({ items: [], total: 0, page: 1, pageSize: 15 });
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const fetchHistory = useCallback(
async (from: string, to: string, page = 1, pageSize = 15) => {
if (targetId === null) return;
setLoading(true);
setError(null);
try {
const response = await fetch(
`/api/targets/${targetId}/history?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&page=${page}&pageSize=${pageSize}`,
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = (await response.json()) as HistoryResponse;
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : "请求失败");
} finally {
setLoading(false);
}
},
[targetId],
);
return { data, error, loading, fetchHistory };
}

View File

@@ -0,0 +1,77 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { TargetStatus } from "../../shared/api";
import { useTrend } from "./useTrend";
import { useHistory } from "./useHistory";
export function useTargetDetail() {
const [selectedTarget, setSelectedTarget] = useState<TargetStatus | null>(null);
const [timeFrom, setTimeFrom] = useState<string>("");
const [timeTo, setTimeTo] = useState<string>("");
const { data: trendData, loading: trendLoading, fetchTrend } = useTrend(selectedTarget?.id ?? null);
const { data: historyData, loading: historyLoading, fetchHistory } = useHistory(selectedTarget?.id ?? null);
const initialFetchRef = useRef(false);
const openModal = useCallback((target: TargetStatus) => {
setSelectedTarget(target);
const now = new Date();
const from = subtractHours(now, 24);
setTimeFrom(from.toISOString());
setTimeTo(now.toISOString());
initialFetchRef.current = false;
}, []);
const closeModal = useCallback(() => {
setSelectedTarget(null);
initialFetchRef.current = false;
}, []);
useEffect(() => {
if (selectedTarget && timeFrom && timeTo && !initialFetchRef.current) {
initialFetchRef.current = true;
fetchTrend(timeFrom, timeTo);
fetchHistory(timeFrom, timeTo);
}
}, [selectedTarget, timeFrom, timeTo, fetchTrend, fetchHistory]);
const handleTimeChange = useCallback(
(from: string, to: string) => {
setTimeFrom(from);
setTimeTo(to);
if (selectedTarget) {
fetchTrend(from, to);
fetchHistory(from, to);
}
},
[fetchTrend, fetchHistory, selectedTarget],
);
const handlePageChange = useCallback(
(page: number) => {
if (timeFrom && timeTo) {
fetchHistory(timeFrom, timeTo, page);
}
},
[timeFrom, timeTo, fetchHistory],
);
return {
selectedTarget,
trendData,
trendLoading,
historyData,
historyLoading,
timeFrom,
timeTo,
openModal,
closeModal,
handleTimeChange,
handlePageChange,
};
}
function subtractHours(date: Date, hours: number): Date {
const result = new Date(date);
result.setTime(result.getTime() - hours * 60 * 60 * 1000);
return result;
}

View File

@@ -6,25 +6,30 @@ export function useTrend(targetId: number | null) {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const fetchTrend = useCallback(async () => {
if (targetId === null) return;
const fetchTrend = useCallback(
async (from: string, to: string) => {
if (targetId === null) return;
setLoading(true);
setError(null);
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/targets/${targetId}/trend?hours=24`);
try {
const response = await fetch(
`/api/targets/${targetId}/trend?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`,
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = (await response.json()) as TrendPoint[];
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : "请求失败");
} finally {
setLoading(false);
}
}, [targetId]);
const result = (await response.json()) as TrendPoint[];
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : "请求失败");
} finally {
setLoading(false);
}
},
[targetId],
);
return { data, error, loading, fetchTrend };
}

View File

@@ -22,7 +22,7 @@ body {
}
.dashboard {
max-width: 1100px;
max-width: 1200px;
margin: 0 auto;
padding: 32px 24px;
}
@@ -55,7 +55,7 @@ body {
.summary-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 32px;
}
@@ -88,82 +88,89 @@ body {
color: #e5484d;
}
.card-latency .card-value {
color: #356dd2;
.target-board {
display: flex;
flex-direction: column;
gap: 32px;
}
.target-table {
width: 100%;
border-collapse: collapse;
background: rgba(255, 255, 255, 0.85);
.target-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.group-header {
display: flex;
align-items: baseline;
gap: 8px;
}
.group-title {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
.group-stats {
color: #61728a;
font-size: 0.85rem;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.target-card {
padding: 16px;
border: 1px solid rgba(49, 83, 126, 0.12);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 16px rgba(34, 57, 91, 0.08);
}
.target-table thead th {
padding: 12px 16px;
text-align: left;
font-size: 0.78rem;
font-weight: 600;
color: #61728a;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid rgba(49, 83, 126, 0.1);
background: rgba(236, 243, 252, 0.5);
}
.target-row {
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
box-shadow: 0 2px 8px rgba(34, 57, 91, 0.06);
cursor: pointer;
transition: background 0.15s;
transition:
box-shadow 0.15s,
transform 0.15s;
}
.target-row:hover {
background: rgba(236, 243, 252, 0.6);
.target-card:hover {
box-shadow: 0 6px 24px rgba(34, 57, 91, 0.14);
transform: translateY(-2px);
}
.target-row.expanded {
background: rgba(236, 243, 252, 0.5);
.card-row-1 {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.target-row td {
padding: 12px 16px;
border-bottom: 1px solid rgba(49, 83, 126, 0.06);
font-size: 0.9rem;
}
.col-status {
width: 48px;
}
.col-name {
.card-name {
font-weight: 600;
}
.col-target {
color: #61728a;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.82rem;
max-width: 260px;
font-size: 0.9rem;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.col-type {
width: 80px;
text-align: center;
.card-type-badge {
padding: 2px 8px;
border-radius: 6px;
background: rgba(53, 109, 210, 0.1);
color: #356dd2;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.col-duration {
width: 80px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.col-sparkline {
width: 100px;
.card-row-2 {
display: flex;
align-items: center;
justify-content: space-between;
}
.status-dot {
@@ -171,6 +178,7 @@ body {
width: 12px;
height: 12px;
border-radius: 999px;
flex-shrink: 0;
}
.status-up {
@@ -183,75 +191,288 @@ body {
box-shadow: 0 0 0 6px rgba(229, 72, 77, 0.14);
}
.status-bar {
display: flex;
gap: 2px;
align-items: center;
}
.status-bar-block {
width: 6px;
height: 16px;
border-radius: 2px;
}
.status-bar-up {
background: #1fbf75;
}
.status-bar-down {
background: #e5484d;
}
.status-bar-empty {
background: #e2e8f0;
}
.sparkline-empty {
color: #94a3b8;
font-size: 0.85rem;
}
.detail-cell {
padding: 0 !important;
border-bottom: 1px solid rgba(49, 83, 126, 0.1) !important;
background: rgba(240, 246, 252, 0.6);
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(16, 32, 51, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.target-detail {
padding: 20px 24px;
.modal-content {
width: 80vw;
max-height: 85vh;
background: #fff;
border-radius: 16px;
box-shadow: 0 16px 48px rgba(16, 32, 51, 0.2);
overflow-y: auto;
display: flex;
flex-direction: column;
}
.detail-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 1px solid rgba(49, 83, 126, 0.1);
}
.detail-stat {
padding: 12px 16px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.7);
.modal-title-row {
display: flex;
align-items: center;
gap: 8px;
}
.detail-stat-label {
display: block;
font-size: 0.75rem;
color: #61728a;
margin-bottom: 4px;
}
.detail-stat-value {
.modal-title {
margin: 0;
font-size: 1.15rem;
font-weight: 700;
font-weight: 600;
}
.text-up {
color: #1fbf75;
.modal-close-btn {
border: none;
background: none;
font-size: 1.5rem;
color: #61728a;
cursor: pointer;
padding: 4px 8px;
line-height: 1;
}
.text-down {
color: #e5484d;
.modal-close-btn:hover {
color: #102033;
}
.detail-trend {
margin-bottom: 20px;
.time-range-picker {
padding: 12px 24px;
border-bottom: 1px solid rgba(49, 83, 126, 0.1);
display: flex;
align-items: center;
gap: 16px;
}
.detail-trend h4,
.detail-history h4 {
.time-shortcuts {
display: flex;
gap: 6px;
}
.time-shortcut-btn {
padding: 4px 12px;
border: 1px solid rgba(49, 83, 126, 0.2);
border-radius: 6px;
background: transparent;
color: #42546c;
font-size: 0.82rem;
cursor: pointer;
transition: all 0.15s;
}
.time-shortcut-btn:hover {
background: rgba(53, 109, 210, 0.08);
}
.time-shortcut-btn.active {
background: #356dd2;
color: #fff;
border-color: #356dd2;
}
.time-inputs {
display: flex;
align-items: center;
gap: 6px;
}
.time-input {
padding: 4px 8px;
border: 1px solid rgba(49, 83, 126, 0.2);
border-radius: 6px;
font-size: 0.82rem;
color: #102033;
}
.time-separator {
color: #61728a;
}
.modal-body {
display: flex;
flex-direction: column;
padding: 20px 24px;
gap: 24px;
flex: 1;
}
.modal-charts {
display: flex;
gap: 24px;
align-items: flex-start;
}
.modal-chart-section {
flex: 1;
}
.modal-chart-section h4 {
margin: 0 0 12px;
font-size: 0.9rem;
color: #42546c;
}
.modal-history-section {
display: flex;
flex-direction: column;
min-height: 0;
}
.modal-history-section h4 {
margin: 0 0 12px;
font-size: 0.9rem;
color: #42546c;
}
.status-donut {
position: relative;
}
.donut-center-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -60%);
font-size: 1.25rem;
font-weight: 700;
color: #102033;
}
.trend-chart {
width: 100%;
}
.trend-loading,
.trend-empty {
.trend-empty,
.history-loading,
.history-empty {
padding: 24px;
text-align: center;
color: #94a3b8;
font-size: 0.85rem;
}
.detail-history {
margin-top: 16px;
.history-table {
width: 100%;
border-collapse: collapse;
border: 1px solid #d1d9e6;
border-radius: 8px;
overflow: hidden;
font-size: 0.85rem;
}
.history-table thead {
background: #f0f4fa;
}
.history-table th {
padding: 10px 12px;
text-align: left;
font-size: 0.78rem;
font-weight: 600;
color: #61728a;
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 1px solid #d1d9e6;
}
.history-table td {
padding: 10px 12px;
border-bottom: 1px solid #e8edf4;
}
.history-table tbody tr:last-child td {
border-bottom: none;
}
.history-table tbody tr:hover {
background: rgba(236, 243, 252, 0.6);
}
.ht-col-status {
width: 60px;
}
.ht-col-time {
width: 170px;
}
.ht-col-detail {
width: 100px;
}
.ht-col-latency {
width: 80px;
text-align: right;
}
.ht-col-error {
width: auto;
}
.ht-status {
font-weight: 700;
font-size: 0.78rem;
}
.ht-time {
color: #61728a;
}
.ht-detail {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: #42546c;
font-size: 0.82rem;
}
.ht-latency {
font-variant-numeric: tabular-nums;
color: #356dd2;
text-align: right;
}
.ht-error {
color: #e5484d;
font-size: 0.82rem;
}
.history-item {
@@ -269,6 +490,14 @@ body {
font-size: 0.78rem;
}
.text-up {
color: #1fbf75;
}
.text-down {
color: #e5484d;
}
.history-time {
color: #61728a;
}
@@ -288,20 +517,48 @@ body {
font-size: 0.8rem;
}
.history-empty {
color: #94a3b8;
font-size: 0.85rem;
margin: 0;
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 12px 0;
}
.table-loading,
.table-empty {
padding: 40px;
text-align: center;
.pagination-btn {
border: 1px solid rgba(49, 83, 126, 0.2);
border-radius: 6px;
background: transparent;
color: #42546c;
padding: 4px 10px;
font-size: 0.82rem;
cursor: pointer;
}
.pagination-btn:hover:not(:disabled) {
background: rgba(53, 109, 210, 0.08);
}
.pagination-btn:disabled {
opacity: 0.4;
cursor: default;
}
.pagination-btn.active {
background: #356dd2;
color: #fff;
border-color: #356dd2;
}
.pagination-items {
display: flex;
align-items: center;
gap: 4px;
}
.pagination-ellipsis {
color: #94a3b8;
background: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(49, 83, 126, 0.12);
border-radius: 16px;
padding: 0 4px;
}
@media (max-width: 768px) {
@@ -313,16 +570,25 @@ body {
grid-template-columns: repeat(2, 1fr);
}
.detail-stats {
grid-template-columns: repeat(2, 1fr);
.card-grid {
grid-template-columns: 1fr;
}
.col-type,
.col-sparkline {
display: none;
.target-card {
width: 100%;
}
.target-row td {
padding: 10px 12px;
.modal-content {
width: 95vw;
max-height: 90vh;
}
.modal-charts {
flex-direction: column;
}
.time-range-picker {
flex-direction: column;
align-items: flex-start;
}
}