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,14 +1,12 @@
|
||||
import { useEffect } from "react";
|
||||
import { useSummary } from "./hooks/useSummary";
|
||||
import { useTargets } from "./hooks/useTargets";
|
||||
import { useTargetDetail } from "./hooks/useTargetDetail";
|
||||
import { Alert, Loading } from "tdesign-react";
|
||||
import { useSummary, useTargets, useTargetDetail } from "./hooks/useTargetDetail";
|
||||
import { SummaryCards } from "./components/SummaryCards";
|
||||
import { TargetBoard } from "./components/TargetBoard";
|
||||
import { TargetDetailModal } from "./components/TargetDetailModal";
|
||||
import { TargetDetailDrawer } from "./components/TargetDetailDrawer";
|
||||
|
||||
export function App() {
|
||||
const { data: summary, loading: summaryLoading, error: summaryError } = useSummary();
|
||||
const { data: targets, error: targetsError } = useTargets();
|
||||
const { data: summary, isLoading: summaryLoading, error: summaryError } = useSummary();
|
||||
const { data: targets, isLoading: targetsLoading, error: targetsError } = useTargets();
|
||||
const {
|
||||
selectedTarget,
|
||||
trendData,
|
||||
@@ -17,25 +15,14 @@ export function App() {
|
||||
historyLoading,
|
||||
timeFrom,
|
||||
timeTo,
|
||||
openModal,
|
||||
closeModal,
|
||||
openDrawer,
|
||||
closeDrawer,
|
||||
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">
|
||||
@@ -43,25 +30,29 @@ export function App() {
|
||||
<p className="dashboard-subtitle">统一拨测平台</p>
|
||||
</header>
|
||||
|
||||
{error && <div className="error-banner">请求失败: {error},将在下一次轮询周期自动重试</div>}
|
||||
{error && <Alert theme="error" message={`请求失败: ${error.message}`} closeBtn />}
|
||||
|
||||
<SummaryCards summary={summary} loading={summaryLoading} />
|
||||
<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}
|
||||
/>
|
||||
{summaryLoading && targetsLoading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<>
|
||||
<SummaryCards summary={summary ?? null} />
|
||||
<TargetBoard targets={targets ?? []} onTargetClick={openDrawer} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<TargetDetailDrawer
|
||||
target={selectedTarget}
|
||||
trendData={trendData}
|
||||
trendLoading={trendLoading}
|
||||
historyData={historyData}
|
||||
historyLoading={historyLoading}
|
||||
timeFrom={timeFrom}
|
||||
timeTo={timeTo}
|
||||
onTimeChange={handleTimeChange}
|
||||
onPageChange={handlePageChange}
|
||||
onClose={closeDrawer}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
24
src/web/constants/color-threshold.ts
Normal file
24
src/web/constants/color-threshold.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
const AVAILABILITY_COLORS = [
|
||||
"#d54941", // 0-10%
|
||||
"#d96241", // 10-20%
|
||||
"#e37318", // 20-30%
|
||||
"#e89318", // 30-40%
|
||||
"#d9a818", // 40-50%
|
||||
"#b8b020", // 50-60%
|
||||
"#8dba30", // 60-70%
|
||||
"#6dba3f", // 70-80%
|
||||
"#4dba50", // 80-90%
|
||||
"#3dba60", // 90-100%
|
||||
];
|
||||
|
||||
export function getAvailabilityProgressColor(availability: number): string {
|
||||
const index = Math.min(Math.floor(availability / 10), 9);
|
||||
return AVAILABILITY_COLORS[index]!;
|
||||
}
|
||||
|
||||
|
||||
export function getLatencyColor(ms: number): string {
|
||||
if (ms <= 100) return "var(--td-success-color)";
|
||||
if (ms <= 500) return "var(--td-warning-color)";
|
||||
return "var(--td-error-color)";
|
||||
}
|
||||
89
src/web/constants/target-table-columns.tsx
Normal file
89
src/web/constants/target-table-columns.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { PrimaryTableCol, PrimaryTableCellParams } from "tdesign-react";
|
||||
import { Tag, Progress } from "tdesign-react";
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
import { StatusDot } from "../components/StatusDot";
|
||||
import { StatusBar } from "../components/StatusBar";
|
||||
import { getTargetTypeDisplay } from "./target-type-display";
|
||||
import { getAvailabilityProgressColor, getLatencyColor } from "./color-threshold";
|
||||
import { availabilitySorter, latencySorter, nameSorter } from "./target-table-sorters";
|
||||
import { statusFilter, typeFilter } from "./target-table-filters";
|
||||
|
||||
export const TARGET_TABLE_COLUMNS: PrimaryTableCol<TargetStatus>[] = [
|
||||
{
|
||||
colKey: "latestCheck.matched",
|
||||
title: "状态",
|
||||
width: 80,
|
||||
fixed: "left",
|
||||
align: "center",
|
||||
filter: statusFilter,
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => <StatusDot up={!!row.latestCheck?.matched} />,
|
||||
},
|
||||
{
|
||||
colKey: "name",
|
||||
title: "名称",
|
||||
ellipsis: true,
|
||||
sorter: nameSorter,
|
||||
sortType: "all",
|
||||
},
|
||||
{
|
||||
colKey: "type",
|
||||
title: "类型",
|
||||
width: 80,
|
||||
filter: typeFilter,
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => (
|
||||
<Tag size="small" theme="primary" variant="light-outline">
|
||||
{getTargetTypeDisplay(row.type)}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
colKey: "stats.availability",
|
||||
title: "可用率",
|
||||
width: 160,
|
||||
sorter: availabilitySorter,
|
||||
sortType: "all",
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => {
|
||||
const availability = row.stats?.availability;
|
||||
if (availability === undefined || availability === null) return "-";
|
||||
const color = getAvailabilityProgressColor(availability);
|
||||
return (
|
||||
<Progress
|
||||
theme="line"
|
||||
size="small"
|
||||
percentage={availability}
|
||||
color={color}
|
||||
label={`${availability.toFixed(1)}%`}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
colKey: "recentSamples",
|
||||
title: "最近状态",
|
||||
width: 220,
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => <StatusBar samples={row.recentSamples} />,
|
||||
},
|
||||
{
|
||||
colKey: "latestCheck.durationMs",
|
||||
title: "延迟",
|
||||
width: 80,
|
||||
align: "right",
|
||||
sorter: latencySorter,
|
||||
sortType: "all",
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => {
|
||||
const ms = row.latestCheck?.durationMs;
|
||||
if (ms === null || ms === undefined) return <span style={{ color: "var(--td-text-color-disabled)" }}>-</span>;
|
||||
const color = getLatencyColor(ms);
|
||||
return <span style={{ color, fontVariantNumeric: "tabular-nums" }}>{Math.round(ms)}ms</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
colKey: "interval",
|
||||
title: "间隔",
|
||||
width: 72,
|
||||
align: "center",
|
||||
},
|
||||
];
|
||||
|
||||
export { statusSorter, availabilitySorter, latencySorter, nameSorter } from "./target-table-sorters";
|
||||
export { statusFilter, typeFilter } from "./target-table-filters";
|
||||
19
src/web/constants/target-table-filters.ts
Normal file
19
src/web/constants/target-table-filters.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { PrimaryTableCol } from "tdesign-react";
|
||||
|
||||
export const statusFilter: PrimaryTableCol["filter"] = {
|
||||
type: "single",
|
||||
list: [
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "UP", value: "up" },
|
||||
{ label: "DOWN", value: "down" },
|
||||
],
|
||||
};
|
||||
|
||||
export const typeFilter: PrimaryTableCol["filter"] = {
|
||||
type: "single",
|
||||
list: [
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "HTTP", value: "http" },
|
||||
{ label: "CMD", value: "command" },
|
||||
],
|
||||
};
|
||||
27
src/web/constants/target-table-sorters.ts
Normal file
27
src/web/constants/target-table-sorters.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
|
||||
const STATUS_ORDER: Record<string, number> = {
|
||||
down: 0,
|
||||
up: 1,
|
||||
};
|
||||
|
||||
function getStatusRank(target: TargetStatus): number {
|
||||
if (!target.latestCheck) return 2;
|
||||
return target.latestCheck.matched ? STATUS_ORDER["up"]! : STATUS_ORDER["down"]!;
|
||||
}
|
||||
|
||||
export function statusSorter(a: TargetStatus, b: TargetStatus): number {
|
||||
return getStatusRank(a) - getStatusRank(b);
|
||||
}
|
||||
|
||||
export function availabilitySorter(a: TargetStatus, b: TargetStatus): number {
|
||||
return (a.stats?.availability ?? 0) - (b.stats?.availability ?? 0);
|
||||
}
|
||||
|
||||
export function latencySorter(a: TargetStatus, b: TargetStatus): number {
|
||||
return (a.latestCheck?.durationMs ?? Infinity) - (b.latestCheck?.durationMs ?? Infinity);
|
||||
}
|
||||
|
||||
export function nameSorter(a: TargetStatus, b: TargetStatus): number {
|
||||
return a.name.localeCompare(b.name, "zh-CN");
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useCallback, useRef, 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: 20 });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchHistory = useCallback(
|
||||
async (from: string, to: string, page = 1, pageSize = 20) => {
|
||||
if (targetId === null) return;
|
||||
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/targets/${targetId}/history?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&page=${page}&pageSize=${pageSize}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const result = (await response.json()) as HistoryResponse;
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
setError(err instanceof Error ? err.message : "请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[targetId],
|
||||
);
|
||||
|
||||
return { data, error, loading, fetchHistory };
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { SummaryResponse } from "../../shared/api";
|
||||
|
||||
export function useSummary(intervalMs = 8000) {
|
||||
const [data, setData] = useState<SummaryResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchSummary = useCallback(async () => {
|
||||
try {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const response = await fetch("/api/summary", { signal: controller.signal });
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const result = (await response.json()) as SummaryResponse;
|
||||
setData(result);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
setError(err instanceof Error ? err.message : "请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
fetchSummary();
|
||||
const timer = setInterval(fetchSummary, intervalMs);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [fetchSummary, intervalMs]);
|
||||
|
||||
return { data, error, loading, refresh: fetchSummary };
|
||||
}
|
||||
@@ -1,71 +1,110 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
import { useTrend } from "./useTrend";
|
||||
import { useHistory } from "./useHistory";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { HistoryResponse, SummaryResponse, TargetStatus, TrendPoint } from "../../shared/api";
|
||||
import { subtractHours } from "../utils/time";
|
||||
|
||||
const queryKeys = {
|
||||
summary: () => ["summary"] as const,
|
||||
targets: () => ["targets"] as const,
|
||||
trend: (targetId: number, from: string, to: string) => ["trend", targetId, from, to] as const,
|
||||
history: (targetId: number, from: string, to: string, page: number) => ["history", targetId, from, to, page] as const,
|
||||
};
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function useSummary() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.summary(),
|
||||
queryFn: () => fetchJson<SummaryResponse>("/api/summary"),
|
||||
refetchInterval: 8000,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTargets() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.targets(),
|
||||
queryFn: () => fetchJson<TargetStatus[]>("/api/targets"),
|
||||
refetchInterval: 8000,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTargetDetail() {
|
||||
const [selectedTarget, setSelectedTarget] = useState<TargetStatus | null>(null);
|
||||
const [timeFrom, setTimeFrom] = useState<string>("");
|
||||
const [timeTo, setTimeTo] = useState<string>("");
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedTargetId, setSelectedTargetId] = useState<number | null>(null);
|
||||
const [timeFrom, setTimeFrom] = useState("");
|
||||
const [timeTo, setTimeTo] = useState("");
|
||||
const [historyPage, setHistoryPage] = useState(1);
|
||||
|
||||
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 { data: targetsData } = useTargets();
|
||||
|
||||
const openModal = useCallback((target: TargetStatus) => {
|
||||
setSelectedTarget(target);
|
||||
const selectedTarget =
|
||||
selectedTargetId !== null ? (targetsData?.find((t) => t.id === selectedTargetId) ?? null) : null;
|
||||
|
||||
const trend = useQuery({
|
||||
queryKey:
|
||||
selectedTargetId !== null && timeFrom && timeTo
|
||||
? queryKeys.trend(selectedTargetId, timeFrom, timeTo)
|
||||
: ["trend", "disabled"],
|
||||
queryFn: () =>
|
||||
fetchJson<TrendPoint[]>(
|
||||
`/api/targets/${selectedTargetId}/trend?from=${encodeURIComponent(timeFrom)}&to=${encodeURIComponent(timeTo)}`,
|
||||
),
|
||||
enabled: selectedTargetId !== null && !!timeFrom && !!timeTo,
|
||||
});
|
||||
|
||||
const history = useQuery({
|
||||
queryKey:
|
||||
selectedTargetId !== null && timeFrom && timeTo
|
||||
? queryKeys.history(selectedTargetId, timeFrom, timeTo, historyPage)
|
||||
: ["history", "disabled"],
|
||||
queryFn: () =>
|
||||
fetchJson<HistoryResponse>(
|
||||
`/api/targets/${selectedTargetId}/history?from=${encodeURIComponent(timeFrom)}&to=${encodeURIComponent(timeTo)}&page=${historyPage}&pageSize=20`,
|
||||
),
|
||||
enabled: selectedTargetId !== null && !!timeFrom && !!timeTo,
|
||||
});
|
||||
|
||||
const openDrawer = useCallback((target: TargetStatus) => {
|
||||
setSelectedTargetId(target.id);
|
||||
const now = new Date();
|
||||
const from = subtractHours(now, 24);
|
||||
setTimeFrom(from.toISOString());
|
||||
setTimeTo(now.toISOString());
|
||||
initialFetchRef.current = false;
|
||||
setHistoryPage(1);
|
||||
}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setSelectedTarget(null);
|
||||
initialFetchRef.current = false;
|
||||
const closeDrawer = useCallback(() => {
|
||||
setSelectedTargetId(null);
|
||||
queryClient.removeQueries({ queryKey: ["trend"] });
|
||||
queryClient.removeQueries({ queryKey: ["history"] });
|
||||
}, [queryClient]);
|
||||
|
||||
const handleTimeChange = useCallback((from: string, to: string) => {
|
||||
setTimeFrom(from);
|
||||
setTimeTo(to);
|
||||
setHistoryPage(1);
|
||||
}, []);
|
||||
|
||||
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],
|
||||
);
|
||||
const handlePageChange = useCallback((page: number) => {
|
||||
setHistoryPage(page);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
selectedTarget,
|
||||
trendData,
|
||||
trendLoading,
|
||||
historyData,
|
||||
historyLoading,
|
||||
trendData: trend.data ?? [],
|
||||
trendLoading: trend.isLoading,
|
||||
historyData: history.data ?? { items: [], total: 0, page: 1, pageSize: 20 },
|
||||
historyLoading: history.isLoading,
|
||||
timeFrom,
|
||||
timeTo,
|
||||
openModal,
|
||||
closeModal,
|
||||
openDrawer,
|
||||
closeDrawer,
|
||||
handleTimeChange,
|
||||
handlePageChange,
|
||||
};
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
|
||||
export function useTargets(intervalMs = 8000) {
|
||||
const [data, setData] = useState<TargetStatus[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchTargets = useCallback(async () => {
|
||||
try {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const response = await fetch("/api/targets", { signal: controller.signal });
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const result = (await response.json()) as TargetStatus[];
|
||||
setData(result);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
setError(err instanceof Error ? err.message : "请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
fetchTargets();
|
||||
const timer = setInterval(fetchTargets, intervalMs);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [fetchTargets, intervalMs]);
|
||||
|
||||
return { data, error, loading, refresh: fetchTargets };
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type { TrendPoint } from "../../shared/api";
|
||||
|
||||
export function useTrend(targetId: number | null) {
|
||||
const [data, setData] = useState<TrendPoint[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchTrend = useCallback(
|
||||
async (from: string, to: string) => {
|
||||
if (targetId === null) return;
|
||||
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/targets/${targetId}/trend?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const result = (await response.json()) as TrendPoint[];
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
setError(err instanceof Error ? err.message : "请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[targetId],
|
||||
);
|
||||
|
||||
return { data, error, loading, fetchTrend };
|
||||
}
|
||||
@@ -1,8 +1,22 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { App } from "./app";
|
||||
import "tdesign-react/dist/reset.css";
|
||||
import "tdesign-react/dist/tdesign.min.css";
|
||||
import "./styles.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 5000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
|
||||
if (!rootElement) {
|
||||
@@ -11,6 +25,9 @@ if (!rootElement) {
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -1,27 +1,3 @@
|
||||
:root {
|
||||
color: #102033;
|
||||
background: #edf3f8;
|
||||
font-family:
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
--dashboard-card-width: 280px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
padding: 32px 24px;
|
||||
width: 100%;
|
||||
@@ -39,184 +15,10 @@ body {
|
||||
|
||||
.dashboard-subtitle {
|
||||
margin: 0;
|
||||
color: #61728a;
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid rgba(229, 72, 77, 0.25);
|
||||
border-radius: 12px;
|
||||
color: #9f2228;
|
||||
background: rgba(255, 240, 240, 0.8);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.summary-cards {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
width: var(--dashboard-card-width);
|
||||
flex-shrink: 0;
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(49, 83, 126, 0.12);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
box-shadow: 0 4px 16px rgba(34, 57, 91, 0.08);
|
||||
transition:
|
||||
transform 0.3s ease,
|
||||
box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.summary-card:hover {
|
||||
box-shadow: 0 8px 32px rgba(34, 57, 91, 0.12);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.card-value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.card-label {
|
||||
margin-top: 4px;
|
||||
color: #61728a;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.card-up .card-value {
|
||||
color: #1fbf75;
|
||||
}
|
||||
|
||||
.card-down .card-value {
|
||||
color: #e5484d;
|
||||
}
|
||||
|
||||
.target-board {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.stat-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-badge-total {
|
||||
background: rgba(53, 109, 210, 0.1);
|
||||
color: #356dd2;
|
||||
}
|
||||
|
||||
.stat-badge-up {
|
||||
background: rgba(31, 191, 117, 0.1);
|
||||
color: #1fbf75;
|
||||
}
|
||||
|
||||
.stat-badge-down {
|
||||
background: rgba(229, 72, 77, 0.1);
|
||||
color: #e5484d;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.target-card {
|
||||
width: var(--dashboard-card-width);
|
||||
flex-shrink: 0;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(49, 83, 126, 0.12);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
box-shadow: 0 2px 8px rgba(34, 57, 91, 0.06);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
box-shadow 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.target-card:hover {
|
||||
box-shadow: 0 6px 24px rgba(34, 57, 91, 0.14);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.card-status-bar {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-sparkline {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
@@ -225,16 +27,6 @@ body {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-up {
|
||||
background: #1fbf75;
|
||||
box-shadow: 0 0 0 6px rgba(31, 191, 117, 0.14);
|
||||
}
|
||||
|
||||
.status-down {
|
||||
background: #e5484d;
|
||||
box-shadow: 0 0 0 6px rgba(229, 72, 77, 0.14);
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
@@ -243,173 +35,16 @@ body {
|
||||
}
|
||||
|
||||
.status-bar-block {
|
||||
width: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
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;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(16, 32, 51, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid rgba(49, 83, 126, 0.1);
|
||||
}
|
||||
|
||||
.modal-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-close-btn {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 1.5rem;
|
||||
color: #61728a;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-close-btn:hover {
|
||||
color: #102033;
|
||||
}
|
||||
|
||||
.time-range-picker {
|
||||
padding: 12px 24px;
|
||||
border-bottom: 1px solid rgba(49, 83, 126, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.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;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.donut-center-label {
|
||||
@@ -419,7 +54,6 @@ body {
|
||||
transform: translate(-50%, -60%);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #102033;
|
||||
}
|
||||
|
||||
.trend-chart {
|
||||
@@ -427,181 +61,13 @@ body {
|
||||
}
|
||||
|
||||
.trend-loading,
|
||||
.trend-empty,
|
||||
.history-loading,
|
||||
.history-empty {
|
||||
.trend-empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
color: var(--td-text-color-placeholder);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.history-status {
|
||||
font-weight: 700;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.text-up {
|
||||
color: #1fbf75;
|
||||
}
|
||||
|
||||
.text-down {
|
||||
color: #e5484d;
|
||||
}
|
||||
|
||||
.history-time {
|
||||
color: #61728a;
|
||||
}
|
||||
|
||||
.history-code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
color: #42546c;
|
||||
}
|
||||
|
||||
.history-latency {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #356dd2;
|
||||
}
|
||||
|
||||
.history-error {
|
||||
color: #e5484d;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
padding: 0 4px;
|
||||
.row-down {
|
||||
background: color-mix(in srgb, var(--td-error-color) 6%, transparent) !important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user