refactor: 全面重构前端 Dashboard 为 TDesign + TanStack Query 分组表格布局
- 卡片式布局改为分组 PrimaryTable,Modal 改为 Drawer - 手写 hooks 替换为 TanStack Query(轮询/缓存/条件查询) - CSS 607行精简至73行,颜色迁移至 TDesign tokens - 可用率进度条颜色按 10% 一档红→绿渐变 - 新增纯函数测试 34 项全通过(排序/筛选/色阶阈值) - 同步更新主 specs 并归档变更文档
This commit is contained in:
@@ -1,17 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Space, Tag } from "tdesign-react";
|
||||
|
||||
interface GroupHeaderProps {
|
||||
name: string;
|
||||
total: number;
|
||||
@@ -9,19 +11,17 @@ export function GroupHeader({ name, total, up, down }: GroupHeaderProps) {
|
||||
const displayName = name === "default" ? "默认分组" : name;
|
||||
|
||||
return (
|
||||
<div className="group-header">
|
||||
<h2 className="group-title">{displayName}</h2>
|
||||
<div className="group-stats">
|
||||
<span className="stat-badge stat-badge-total" title="总数">
|
||||
{total}
|
||||
</span>
|
||||
<span className="stat-badge stat-badge-up" title="正常">
|
||||
{up}
|
||||
</span>
|
||||
<span className="stat-badge stat-badge-down" title="异常">
|
||||
{down}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Space align="center" size={8} style={{ marginBottom: 12 }}>
|
||||
<h2 style={{ margin: 0, fontSize: "1.1rem", fontWeight: 600 }}>{displayName}</h2>
|
||||
<Tag theme="primary" variant="light" title="总数">
|
||||
{total}
|
||||
</Tag>
|
||||
<Tag theme="success" variant="light" title="正常">
|
||||
{up}
|
||||
</Tag>
|
||||
<Tag theme="danger" variant="light" title="异常">
|
||||
{down}
|
||||
</Tag>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
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="100%" height={40}>
|
||||
<LineChart data={chartData}>
|
||||
<Line type="monotone" dataKey="duration" stroke="#356dd2" strokeWidth={1.5} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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)}>
|
||||
<
|
||||
</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)}>
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,9 +7,17 @@ export function StatusBar({ samples }: StatusBarProps) {
|
||||
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"}`} />);
|
||||
blocks.push(
|
||||
<span
|
||||
key={i}
|
||||
className="status-bar-block"
|
||||
style={{ background: sample.up ? "var(--td-success-color)" : "var(--td-error-color)" }}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
blocks.push(<span key={i} className="status-bar-block status-bar-empty" />);
|
||||
blocks.push(
|
||||
<span key={i} className="status-bar-block" style={{ background: "var(--td-bg-color-component-disabled)" }} />,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ interface StatusDonutProps {
|
||||
down: number;
|
||||
}
|
||||
|
||||
const UP_COLOR = "#1fbf75";
|
||||
const DOWN_COLOR = "#e5484d";
|
||||
const EMPTY_COLOR = "#e2e8f0";
|
||||
const UP_COLOR = "var(--td-success-color)";
|
||||
const DOWN_COLOR = "var(--td-error-color)";
|
||||
const EMPTY_COLOR = "var(--td-bg-color-component-disabled)";
|
||||
|
||||
export function StatusDonut({ up, down }: StatusDonutProps) {
|
||||
const total = up + down;
|
||||
|
||||
@@ -3,5 +3,12 @@ interface StatusDotProps {
|
||||
}
|
||||
|
||||
export function StatusDot({ up }: StatusDotProps) {
|
||||
return <span className={`status-dot ${up ? "status-up" : "status-down"}`} />;
|
||||
const color = up ? "var(--td-success-color)" : "var(--td-error-color)";
|
||||
const shadow = up ? "var(--td-success-color)" : "var(--td-error-color)";
|
||||
return (
|
||||
<span
|
||||
className="status-dot"
|
||||
style={{ background: color, boxShadow: `0 0 0 6px color-mix(in srgb, ${shadow} 14%, transparent)` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,28 @@
|
||||
import { Row, Col, Card, Statistic } from "tdesign-react";
|
||||
import type { SummaryResponse } from "../../shared/api";
|
||||
|
||||
interface SummaryCardsProps {
|
||||
summary: SummaryResponse | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function SummaryCards({ summary, loading }: SummaryCardsProps) {
|
||||
if (loading && !summary) {
|
||||
return <div className="summary-cards">加载中...</div>;
|
||||
}
|
||||
|
||||
export function SummaryCards({ summary }: SummaryCardsProps) {
|
||||
if (!summary) return null;
|
||||
|
||||
const cards = [
|
||||
{ label: "全部目标", value: summary.total, className: "card-total" },
|
||||
{ label: "正常", value: summary.up, className: "card-up" },
|
||||
{ label: "异常", value: summary.down, className: "card-down" },
|
||||
{ label: "全部目标", value: summary.total, color: "blue" as const },
|
||||
{ label: "正常", value: summary.up, color: "green" as const },
|
||||
{ label: "异常", value: summary.down, color: "red" as const },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="summary-cards">
|
||||
<Row gutter={16} style={{ marginBottom: 32 }}>
|
||||
{cards.map((card) => (
|
||||
<div key={card.className} className={`summary-card ${card.className}`}>
|
||||
<div className="card-value">{card.value}</div>
|
||||
<div className="card-label">{card.label}</div>
|
||||
</div>
|
||||
<Col key={card.label} span={4}>
|
||||
<Card bordered>
|
||||
<Statistic title={card.label} value={card.value} color={card.color} />
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</div>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Space } from "tdesign-react";
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
import { TargetGroup } from "./TargetGroup";
|
||||
|
||||
@@ -18,11 +19,16 @@ export function TargetBoard({ targets, onTargetClick }: TargetBoardProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const sortedGroups = Array.from(groups.entries()).sort(([a]) => {
|
||||
if (a === "default") return -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="target-board">
|
||||
{Array.from(groups.entries()).map(([name, groupTargets]) => (
|
||||
<Space direction="vertical" size={32} style={{ width: "100%" }}>
|
||||
{sortedGroups.map(([name, groupTargets]) => (
|
||||
<TargetGroup key={name} name={name} targets={groupTargets} onTargetClick={onTargetClick} />
|
||||
))}
|
||||
</div>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
import { StatusDot } from "./StatusDot";
|
||||
import { StatusBar } from "./StatusBar";
|
||||
import { MiniSparkline } from "./MiniSparkline";
|
||||
import { getTargetTypeDisplay } from "../constants/target-type-display";
|
||||
|
||||
interface TargetCardProps {
|
||||
target: TargetStatus;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function TargetCard({ target, onClick }: TargetCardProps) {
|
||||
const isUp = target.latestCheck?.matched;
|
||||
|
||||
return (
|
||||
<div className="target-card" onClick={onClick} role="button" tabIndex={0}>
|
||||
<div className="card-header">
|
||||
<StatusDot up={!!isUp} />
|
||||
<span className="card-name" title={target.name}>
|
||||
{target.name}
|
||||
</span>
|
||||
<span className="card-type-badge">{getTargetTypeDisplay(target.type)}</span>
|
||||
</div>
|
||||
<div className="card-status-bar">
|
||||
<StatusBar samples={target.recentSamples} />
|
||||
</div>
|
||||
<div className="card-sparkline">
|
||||
<MiniSparkline data={target.recentSamples} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
215
src/web/components/TargetDetailDrawer.tsx
Normal file
215
src/web/components/TargetDetailDrawer.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import {
|
||||
Drawer,
|
||||
Tabs,
|
||||
RadioGroup,
|
||||
DateRangePicker,
|
||||
Tag,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
Descriptions,
|
||||
Skeleton,
|
||||
PrimaryTable,
|
||||
} from "tdesign-react";
|
||||
import type { TabValue } from "tdesign-react";
|
||||
import type { CheckResult, TargetStatus, TrendPoint, HistoryResponse } from "../../shared/api";
|
||||
import { StatusDot } from "./StatusDot";
|
||||
import { StatusDonut } from "./StatusDonut";
|
||||
import { TrendChart } from "./TrendChart";
|
||||
import { getTargetTypeDisplay } from "../constants/target-type-display";
|
||||
import { subtractHours } from "../utils/time";
|
||||
|
||||
interface TargetDetailDrawerProps {
|
||||
target: TargetStatus | null;
|
||||
trendData: TrendPoint[];
|
||||
trendLoading: boolean;
|
||||
historyData: HistoryResponse;
|
||||
historyLoading: boolean;
|
||||
timeFrom: string;
|
||||
timeTo: string;
|
||||
onTimeChange: (from: string, to: string) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const TIME_SHORTCUTS = [
|
||||
{ label: "1h", hours: 1, value: "1h" },
|
||||
{ label: "6h", hours: 6, value: "6h" },
|
||||
{ label: "24h", hours: 24, value: "24h" },
|
||||
{ label: "7d", hours: 168, value: "7d" },
|
||||
] as const;
|
||||
|
||||
const HISTORY_COLUMNS = [
|
||||
{
|
||||
colKey: "matched",
|
||||
title: "状态",
|
||||
width: 72,
|
||||
cell: ({ row }: { row: CheckResult; rowIndex: number; col: unknown; colIndex: number }) => (
|
||||
<Tag theme={row.matched ? "success" : "danger"} size="small">
|
||||
{row.matched ? "UP" : "DOWN"}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
colKey: "timestamp",
|
||||
title: "时间",
|
||||
width: 170,
|
||||
cell: ({ row }: { row: CheckResult; rowIndex: number; col: unknown; colIndex: number }) =>
|
||||
new Date(row.timestamp).toLocaleString("zh-CN"),
|
||||
},
|
||||
{
|
||||
colKey: "statusDetail",
|
||||
title: "详情",
|
||||
width: 100,
|
||||
cell: ({ row }: { row: CheckResult; rowIndex: number; col: unknown; colIndex: number }) => row.statusDetail ?? "-",
|
||||
},
|
||||
{
|
||||
colKey: "durationMs",
|
||||
title: "耗时",
|
||||
width: 80,
|
||||
align: "right" as const,
|
||||
cell: ({ row }: { row: CheckResult; rowIndex: number; col: unknown; colIndex: number }) =>
|
||||
row.durationMs !== null ? `${Math.round(row.durationMs)}ms` : "-",
|
||||
},
|
||||
{
|
||||
colKey: "failure",
|
||||
title: "错误信息",
|
||||
cell: ({ row }: { row: CheckResult; rowIndex: number; col: unknown; colIndex: number }) =>
|
||||
row.failure?.message ?? "",
|
||||
},
|
||||
];
|
||||
|
||||
export function TargetDetailDrawer({
|
||||
target,
|
||||
trendData,
|
||||
trendLoading,
|
||||
historyData,
|
||||
historyLoading,
|
||||
timeFrom,
|
||||
timeTo,
|
||||
onTimeChange,
|
||||
onPageChange,
|
||||
onClose,
|
||||
}: TargetDetailDrawerProps) {
|
||||
const [activeShortcut, setActiveShortcut] = useState<string>("24h");
|
||||
const [activeTab, setActiveTab] = useState<TabValue>("overview");
|
||||
|
||||
const handleShortcut = useCallback(
|
||||
(value: string) => {
|
||||
const shortcut = TIME_SHORTCUTS.find((s) => s.value === value);
|
||||
if (!shortcut) return;
|
||||
const now = new Date();
|
||||
const from = subtractHours(now, shortcut.hours);
|
||||
onTimeChange(from.toISOString(), now.toISOString());
|
||||
setActiveShortcut(value);
|
||||
},
|
||||
[onTimeChange],
|
||||
);
|
||||
|
||||
const handleDateRangeChange = useCallback(
|
||||
(value: Array<string | number | Date>) => {
|
||||
if (value && value.length === 2) {
|
||||
onTimeChange(new Date(value[0]!).toISOString(), new Date(value[1]!).toISOString());
|
||||
setActiveShortcut("");
|
||||
}
|
||||
},
|
||||
[onTimeChange],
|
||||
);
|
||||
|
||||
if (!target) return null;
|
||||
|
||||
const isUp = 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);
|
||||
const downChecks = totalChecks - upChecks;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
visible={!!target}
|
||||
placement="right"
|
||||
size="60%"
|
||||
onClose={onClose}
|
||||
header={
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<StatusDot up={!!isUp} />
|
||||
<span style={{ fontWeight: 600 }}>{target.name}</span>
|
||||
<Tag size="small" theme="primary" variant="light-outline">
|
||||
{getTargetTypeDisplay(target.type)}
|
||||
</Tag>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16, marginBottom: 16 }}>
|
||||
<RadioGroup
|
||||
variant="default-filled"
|
||||
value={activeShortcut}
|
||||
options={TIME_SHORTCUTS.map((s) => ({ label: s.label, value: s.value }))}
|
||||
onChange={handleShortcut}
|
||||
/>
|
||||
<DateRangePicker
|
||||
mode="date"
|
||||
enableTimePicker
|
||||
value={timeFrom && timeTo ? [timeFrom, timeTo] : undefined}
|
||||
onChange={handleDateRangeChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onChange={(val: TabValue) => setActiveTab(val)}>
|
||||
<Tabs.TabPanel value="overview" label="概览">
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={3}>
|
||||
<Statistic title="总检查" value={totalChecks} color="blue" />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic title="正常" value={upChecks} color="green" />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic title="异常" value={downChecks} color="red" />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic title="可用率" value={target.stats?.availability ?? 0} color="green" suffix="%" />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Descriptions
|
||||
items={[
|
||||
{ label: "目标地址", content: target.target },
|
||||
{ label: "检查间隔", content: target.interval },
|
||||
{
|
||||
label: "最新检查时间",
|
||||
content: target.latestCheck ? new Date(target.latestCheck.timestamp).toLocaleString("zh-CN") : "-",
|
||||
},
|
||||
{ label: "状态详情", content: target.latestCheck?.statusDetail ?? "-" },
|
||||
]}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<StatusDonut up={upChecks} down={downChecks} />
|
||||
</Tabs.TabPanel>
|
||||
|
||||
<Tabs.TabPanel value="trend" label="趋势">
|
||||
{trendLoading ? <Skeleton animation="gradient" /> : <TrendChart data={trendData} loading={false} />}
|
||||
</Tabs.TabPanel>
|
||||
|
||||
<Tabs.TabPanel value="history" label="记录">
|
||||
<PrimaryTable
|
||||
columns={HISTORY_COLUMNS}
|
||||
data={historyData.items}
|
||||
rowKey="timestamp"
|
||||
loading={historyLoading}
|
||||
disableDataPage
|
||||
pagination={{
|
||||
current: historyData.page,
|
||||
pageSize: historyData.pageSize,
|
||||
total: historyData.total,
|
||||
}}
|
||||
onPageChange={({ current }) => {
|
||||
if (current) onPageChange(current);
|
||||
}}
|
||||
/>
|
||||
</Tabs.TabPanel>
|
||||
</Tabs>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
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";
|
||||
import { getTargetTypeDisplay } from "../constants/target-type-display";
|
||||
|
||||
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?.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">{getTargetTypeDisplay(target.type)}</span>
|
||||
</div>
|
||||
<button className="modal-close-btn" onClick={onClose}>
|
||||
×
|
||||
</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.matched ? "text-up" : "text-down"}`}>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
import { GroupHeader } from "./GroupHeader";
|
||||
import { CardGrid } from "./CardGrid";
|
||||
import { PrimaryTable } from "tdesign-react";
|
||||
import { TARGET_TABLE_COLUMNS } from "../constants/target-table-columns";
|
||||
|
||||
interface TargetGroupProps {
|
||||
name: string;
|
||||
@@ -13,9 +14,24 @@ export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps)
|
||||
const down = targets.length - up;
|
||||
|
||||
return (
|
||||
<div className="target-group">
|
||||
<div>
|
||||
<GroupHeader name={name} total={targets.length} up={up} down={down} />
|
||||
<CardGrid targets={targets} onTargetClick={onTargetClick} />
|
||||
<PrimaryTable
|
||||
columns={TARGET_TABLE_COLUMNS}
|
||||
data={targets}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
stripe
|
||||
hover
|
||||
bordered
|
||||
defaultSort={[{ sortBy: "latestCheck.matched", descending: true }]}
|
||||
onRowClick={({ row }) => onTargetClick(row as TargetStatus)}
|
||||
rowClassName={({ row }) => {
|
||||
const target = row as TargetStatus;
|
||||
return target.latestCheck?.matched === false ? "row-down" : "";
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { subtractHours } from "../utils/time";
|
||||
|
||||
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())}`;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -24,12 +24,12 @@ export function TrendChart({ data, loading }: TrendChartProps) {
|
||||
<div className="trend-chart">
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis dataKey="hour" tick={{ fontSize: 12 }} stroke="#94a3b8" />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--td-border-level-2-color)" />
|
||||
<XAxis dataKey="hour" tick={{ fontSize: 12 }} stroke="var(--td-text-color-secondary)" />
|
||||
<YAxis
|
||||
yAxisId="duration"
|
||||
tick={{ fontSize: 12 }}
|
||||
stroke="#94a3b8"
|
||||
stroke="var(--td-text-color-secondary)"
|
||||
label={{ value: "ms", position: "insideTopRight", fontSize: 11 }}
|
||||
/>
|
||||
<YAxis
|
||||
@@ -37,7 +37,7 @@ export function TrendChart({ data, loading }: TrendChartProps) {
|
||||
orientation="right"
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 12 }}
|
||||
stroke="#94a3b8"
|
||||
stroke="var(--td-text-color-secondary)"
|
||||
label={{ value: "%", position: "insideTopLeft", fontSize: 11 }}
|
||||
/>
|
||||
<Tooltip
|
||||
@@ -53,7 +53,7 @@ export function TrendChart({ data, loading }: TrendChartProps) {
|
||||
yAxisId="duration"
|
||||
type="monotone"
|
||||
dataKey="avgDurationMs"
|
||||
stroke="#356dd2"
|
||||
stroke="var(--td-brand-color)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
name="avgDurationMs"
|
||||
@@ -62,7 +62,7 @@ export function TrendChart({ data, loading }: TrendChartProps) {
|
||||
yAxisId="availability"
|
||||
type="monotone"
|
||||
dataKey="availability"
|
||||
stroke="#1fbf75"
|
||||
stroke="var(--td-success-color)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
name="availability"
|
||||
|
||||
Reference in New Issue
Block a user