1
0

chore: 强化代码质量与风格检查体系

ESLint 升级到 recommended-type-checked + stylistic-type-checked,
引入 perfectionist 导入排序和 import 插件导入验证。

Prettier 显式声明全部格式化参数,消除跨环境差异。
TypeScript 启用 noUnusedLocals 和 noPropertyAccessFromIndexSignature。
完善 ignore 列表,排除 .agents/、bun.lock、data/ 等。
引入 husky + lint-staged(pre-commit)+ commitlint(commit-msg)。
更新 DEVELOPMENT.md 代码质量章节。
修复所有新增规则检测到的类型和风格违规。
This commit is contained in:
2026-05-12 18:44:59 +08:00
parent ce8baae3d1
commit a5cf6065c2
83 changed files with 2654 additions and 1824 deletions

View File

@@ -1,27 +1,28 @@
import { Alert, Loading, Typography } from "tdesign-react";
import { useSummary, useTargets, useTargetDetail } from "./hooks/useTargetDetail";
import { SummaryCards } from "./components/SummaryCards";
import { TargetBoard } from "./components/TargetBoard";
import { TargetDetailDrawer } from "./components/TargetDetailDrawer";
import { useSummary, useTargetDetail, useTargets } from "./hooks/useTargetDetail";
export function App() {
const { data: summary, isLoading: summaryLoading, error: summaryError } = useSummary();
const { data: targets, isLoading: targetsLoading, error: targetsError } = useTargets();
const { data: summary, error: summaryError, isLoading: summaryLoading } = useSummary();
const { data: targets, error: targetsError, isLoading: targetsLoading } = useTargets();
const {
selectedTarget,
trendData,
trendLoading,
closeDrawer,
handlePageChange,
handleTimeChange,
historyData,
historyLoading,
openDrawer,
selectedTarget,
timeFrom,
timeTo,
openDrawer,
closeDrawer,
handleTimeChange,
handlePageChange,
trendData,
trendLoading,
} = useTargetDetail();
const error = summaryError || targetsError;
const error = summaryError ?? targetsError;
return (
<main className="dashboard">
@@ -30,29 +31,29 @@ export function App() {
<Typography.Text theme="secondary"></Typography.Text>
</header>
{error && <Alert theme="error" message={`请求失败: ${error.message}`} closeBtn />}
{error && <Alert closeBtn message={`请求失败: ${error.message}`} theme="error" />}
{summaryLoading && targetsLoading ? (
<Loading />
) : (
<>
<SummaryCards summary={summary ?? null} />
<TargetBoard targets={targets ?? []} onTargetClick={openDrawer} />
<TargetBoard onTargetClick={openDrawer} targets={targets ?? []} />
</>
)}
<TargetDetailDrawer
key={selectedTarget?.id}
target={selectedTarget}
trendData={trendData}
trendLoading={trendLoading}
historyData={historyData}
historyLoading={historyLoading}
key={selectedTarget?.id}
onClose={closeDrawer}
onPageChange={handlePageChange}
onTimeChange={handleTimeChange}
target={selectedTarget}
timeFrom={timeFrom}
timeTo={timeTo}
onTimeChange={handleTimeChange}
onPageChange={handlePageChange}
onClose={closeDrawer}
trendData={trendData}
trendLoading={trendLoading}
/>
</main>
);

View File

@@ -1,25 +1,25 @@
import { Tag, Typography } from "tdesign-react";
interface GroupHeaderProps {
down: number;
name: string;
total: number;
up: number;
down: number;
}
export function GroupHeader({ name, total, up, down }: GroupHeaderProps) {
export function GroupHeader({ down, name, total, up }: GroupHeaderProps) {
const displayName = name === "default" ? "默认分组" : name;
return (
<div className="group-header">
<Typography.Title level="h4">{displayName}</Typography.Title>
<Tag theme="primary" variant="light" title="总数">
<Tag theme="primary" title="总数" variant="light">
{total}
</Tag>
<Tag theme="success" variant="light" title="正常">
<Tag theme="success" title="正常" variant="light">
{up}
</Tag>
<Tag theme="danger" variant="light" title="异常">
<Tag theme="danger" title="异常" variant="light">
{down}
</Tag>
</div>

View File

@@ -9,12 +9,12 @@ export function StatusBar({ samples }: StatusBarProps) {
if (sample) {
blocks.push(
<span
key={i}
className={`status-bar-block ${sample.up ? "status-bar-block--up" : "status-bar-block--down"}`}
key={i}
/>,
);
} else {
blocks.push(<span key={i} className="status-bar-block status-bar-block--empty" />);
blocks.push(<span className="status-bar-block status-bar-block--empty" key={i} />);
}
}

View File

@@ -1,15 +1,15 @@
import { PieChart, Pie, Cell, ResponsiveContainer } from "recharts";
import { Cell, Pie, PieChart, ResponsiveContainer } from "recharts";
interface StatusDonutProps {
up: number;
down: number;
up: number;
}
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) {
export function StatusDonut({ down, up }: StatusDonutProps) {
const total = up + down;
const availability = total > 0 ? ((up / total) * 100).toFixed(1) : "-";
@@ -25,11 +25,11 @@ export function StatusDonut({ up, down }: StatusDonutProps) {
return (
<div className="status-donut">
<ResponsiveContainer width="100%" height={180}>
<ResponsiveContainer height={180} width="100%">
<PieChart>
<Pie data={data} cx="50%" cy="50%" innerRadius={50} outerRadius={70} dataKey="value" stroke="none">
<Pie cx="50%" cy="50%" data={data} dataKey="value" innerRadius={50} outerRadius={70} stroke="none">
{data.map((_, index) => (
<Cell key={index} fill={colors[index % colors.length]!} />
<Cell fill={colors[index % colors.length]} key={index} />
))}
</Pie>
</PieChart>

View File

@@ -1,25 +1,26 @@
import { Row, Col, Card, Statistic } from "tdesign-react";
import { Card, Col, Row, Statistic } from "tdesign-react";
import type { SummaryResponse } from "../../shared/api";
interface SummaryCardsProps {
summary: SummaryResponse | null;
summary: null | SummaryResponse;
}
export function SummaryCards({ summary }: SummaryCardsProps) {
if (!summary) return null;
const cards = [
{ label: "全部目标", value: summary.total, color: "blue" as const },
{ label: "正常", value: summary.up, color: "green" as const },
{ label: "异常", value: summary.down, color: "red" as const },
{ color: "blue" as const, label: "全部目标", value: summary.total },
{ color: "green" as const, label: "正常", value: summary.up },
{ color: "red" as const, label: "异常", value: summary.down },
];
return (
<Row gutter={16} className="summary-cards-row">
<Row className="summary-cards-row" gutter={16}>
{cards.map((card) => (
<Col key={card.label} span={4}>
<Card bordered>
<Statistic title={card.label} value={card.value} color={card.color} />
<Statistic color={card.color} title={card.label} value={card.value} />
</Card>
</Col>
))}

View File

@@ -1,13 +1,15 @@
import { Space } from "tdesign-react";
import type { TargetStatus } from "../../shared/api";
import { TargetGroup } from "./TargetGroup";
interface TargetBoardProps {
targets: TargetStatus[];
onTargetClick: (target: TargetStatus) => void;
targets: TargetStatus[];
}
export function TargetBoard({ targets, onTargetClick }: TargetBoardProps) {
export function TargetBoard({ onTargetClick, targets }: TargetBoardProps) {
const groups = new Map<string, TargetStatus[]>();
for (const target of targets) {
const group = target.group;
@@ -25,9 +27,9 @@ export function TargetBoard({ targets, onTargetClick }: TargetBoardProps) {
});
return (
<Space direction="vertical" size={32} className="full-width">
<Space className="full-width" direction="vertical" size={32}>
{sortedGroups.map(([name, groupTargets]) => (
<TargetGroup key={name} name={name} targets={groupTargets} onTargetClick={onTargetClick} />
<TargetGroup key={name} name={name} onTargetClick={onTargetClick} targets={groupTargets} />
))}
</Space>
);

View File

@@ -1,96 +1,99 @@
import { useState, useCallback } from "react";
import type { TabValue } from "tdesign-react";
import { useCallback, useState } from "react";
import {
Drawer,
Tabs,
RadioGroup,
DateRangePicker,
Tag,
Row,
Col,
Statistic,
DateRangePicker,
Descriptions,
Skeleton,
PrimaryTable,
Divider,
Drawer,
PrimaryTable,
RadioGroup,
Row,
Skeleton,
Space,
Statistic,
Tabs,
Tag,
Typography,
} 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 type { CheckResult, HistoryResponse, TargetStatus, TrendPoint } from "../../shared/api";
import { getTargetTypeDisplay } from "../constants/target-type-display";
import { subtractHours } from "../utils/time";
import { StatusDonut } from "./StatusDonut";
import { StatusDot } from "./StatusDot";
import { TrendChart } from "./TrendChart";
interface TargetDetailDrawerProps {
target: TargetStatus | null;
trendData: TrendPoint[];
trendLoading: boolean;
historyData: HistoryResponse;
historyLoading: boolean;
onClose: () => void;
onPageChange: (page: number) => void;
onTimeChange: (from: string, to: string) => void;
target: null | TargetStatus;
timeFrom: string;
timeTo: string;
onTimeChange: (from: string, to: string) => void;
onPageChange: (page: number) => void;
onClose: () => void;
trendData: TrendPoint[];
trendLoading: boolean;
}
const TIME_SHORTCUTS = [
{ label: "1小时", hours: 1, value: "1h" },
{ label: "6小时", hours: 6, value: "6h" },
{ label: "24小时", hours: 24, value: "24h" },
{ label: "7天", hours: 168, value: "7d" },
{ hours: 1, label: "1小时", value: "1h" },
{ hours: 6, label: "6小时", value: "6h" },
{ hours: 24, label: "24小时", value: "24h" },
{ hours: 168, label: "7天", value: "7d" },
] as const;
const HISTORY_COLUMNS = [
{
cell: ({ row }: { col: unknown; colIndex: number; row: CheckResult; rowIndex: number }) => (
<StatusDot up={!!row.matched} />
),
colKey: "matched",
title: "#",
width: 40,
cell: ({ row }: { row: CheckResult; rowIndex: number; col: unknown; colIndex: number }) => (
<StatusDot up={!!row.matched} />
),
},
{
colKey: "timestamp",
title: "时间",
width: 180,
cell: ({ row }: { row: CheckResult; rowIndex: number; col: unknown; colIndex: number }) => {
cell: ({ row }: { col: unknown; colIndex: number; row: CheckResult; rowIndex: number }) => {
const d = new Date(row.timestamp);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
},
colKey: "timestamp",
title: "时间",
width: 180,
},
{
align: "center" as const,
cell: ({ row }: { col: unknown; colIndex: number; row: CheckResult; rowIndex: number }) =>
row.durationMs !== null ? Math.round(row.durationMs) : "-",
colKey: "durationMs",
title: "耗时(ms)",
width: 96,
align: "center" as const,
cell: ({ row }: { row: CheckResult; rowIndex: number; col: unknown; colIndex: number }) =>
row.durationMs !== null ? Math.round(row.durationMs) : "-",
},
{
colKey: "statusDetail",
title: "详情",
cell: ({ row }: { row: CheckResult; rowIndex: number; col: unknown; colIndex: number }) => {
cell: ({ row }: { col: unknown; colIndex: number; row: CheckResult; rowIndex: number }) => {
const parts = [row.statusDetail, row.failure?.message].filter(Boolean);
return parts.length > 0 ? parts.join("") : "-";
},
colKey: "statusDetail",
title: "详情",
},
];
export function TargetDetailDrawer({
target,
trendData,
trendLoading,
historyData,
historyLoading,
onClose,
onPageChange,
onTimeChange,
target,
timeFrom,
timeTo,
onTimeChange,
onPageChange,
onClose,
trendData,
trendLoading,
}: TargetDetailDrawerProps) {
const [activeShortcut, setActiveShortcut] = useState<string>("24h");
const [activeTab, setActiveTab] = useState<TabValue>("overview");
@@ -108,8 +111,8 @@ export function TargetDetailDrawer({
);
const handleDateRangeChange = useCallback(
(value: Array<string | number | Date>) => {
if (value && value.length === 2) {
(value: Array<Date | number | string>) => {
if (value?.length === 2) {
onTimeChange(new Date(value[0]!).toISOString(), new Date(value[1]!).toISOString());
setActiveShortcut("");
}
@@ -126,11 +129,7 @@ export function TargetDetailDrawer({
return (
<Drawer
visible={!!target}
placement="right"
size="60%"
footer={false}
onClose={onClose}
header={
<Space align="center" size={8}>
<StatusDot up={!!isUp} />
@@ -140,44 +139,48 @@ export function TargetDetailDrawer({
</Tag>
</Space>
}
onClose={onClose}
placement="right"
size="60%"
visible={!!target}
>
<Space direction="vertical" size={16} className="full-width">
<Space className="full-width" direction="vertical" size={16}>
<RadioGroup
theme="button"
variant="default-filled"
value={activeShortcut}
options={TIME_SHORTCUTS.map((s) => ({ label: s.label, value: s.value }))}
onChange={handleShortcut}
options={TIME_SHORTCUTS.map((s) => ({ label: s.label, value: s.value }))}
theme="button"
value={activeShortcut}
variant="default-filled"
/>
<DateRangePicker
mode="date"
className="full-width"
defaultTime={["00:00:00", "23:59:00"]}
enableTimePicker
format="YYYY-MM-DD HH:mm"
valueType="YYYY-MM-DD HH:mm"
defaultTime={["00:00:00", "23:59:00"]}
timePickerProps={{ format: "HH:mm", steps: [1, 1, 60] }}
className="full-width"
value={timeFrom && timeTo ? [timeFrom, timeTo] : undefined}
mode="date"
onChange={handleDateRangeChange}
timePickerProps={{ format: "HH:mm", steps: [1, 1, 60] }}
value={timeFrom && timeTo ? [timeFrom, timeTo] : undefined}
valueType="YYYY-MM-DD HH:mm"
/>
</Space>
<Tabs value={activeTab} onChange={(val: TabValue) => setActiveTab(val)}>
<Tabs.TabPanel value="overview" label="概览" className="tab-panel-padded">
<Space direction="vertical" size={16} className="full-width">
<Tabs onChange={(val: TabValue) => setActiveTab(val)} value={activeTab}>
<Tabs.TabPanel className="tab-panel-padded" label="概览" value="overview">
<Space className="full-width" direction="vertical" size={16}>
<Divider align="left"></Divider>
<Row gutter={16}>
<Col span={3}>
<Statistic title="总检查" value={totalChecks} color="blue" />
<Statistic color="blue" title="总检查" value={totalChecks} />
</Col>
<Col span={3}>
<Statistic title="正常" value={upChecks} color="green" />
<Statistic color="green" title="正常" value={upChecks} />
</Col>
<Col span={3}>
<Statistic title="异常" value={downChecks} color="red" />
<Statistic color="red" title="异常" value={downChecks} />
</Col>
<Col span={3}>
<Statistic title="可用率" value={target.stats?.availability ?? 0} color="green" suffix="%" />
<Statistic color="green" suffix="%" title="可用率" value={target.stats?.availability ?? 0} />
</Col>
</Row>
@@ -185,38 +188,38 @@ export function TargetDetailDrawer({
{trendLoading ? <Skeleton animation="gradient" /> : <TrendChart data={trendData} loading={false} />}
<Divider align="left"></Divider>
<StatusDonut up={upChecks} down={downChecks} />
<StatusDonut down={downChecks} up={upChecks} />
<Divider align="left"></Divider>
<Descriptions
items={[
{ label: "目标地址", content: target.target },
{ label: "检查间隔", content: target.interval },
{ content: target.target, label: "目标地址" },
{ content: target.interval, label: "检查间隔" },
{
label: "最新检查时间",
content: target.latestCheck ? new Date(target.latestCheck.timestamp).toLocaleString("zh-CN") : "-",
label: "最新检查时间",
},
{ label: "状态详情", content: target.latestCheck?.statusDetail ?? "-" },
{ content: target.latestCheck?.statusDetail ?? "-", label: "状态详情" },
]}
/>
</Space>
</Tabs.TabPanel>
<Tabs.TabPanel value="history" label="记录" className="tab-panel-padded">
<Tabs.TabPanel className="tab-panel-padded" label="记录" value="history">
<PrimaryTable
columns={HISTORY_COLUMNS}
data={historyData.items}
rowKey="timestamp"
loading={historyLoading}
disableDataPage
loading={historyLoading}
onPageChange={({ current }) => {
if (current) onPageChange(current);
}}
pagination={{
current: historyData.page,
pageSize: historyData.pageSize,
total: historyData.total,
}}
onPageChange={({ current }) => {
if (current) onPageChange(current);
}}
rowKey="timestamp"
/>
</Tabs.TabPanel>
</Tabs>

View File

@@ -1,36 +1,38 @@
import type { TargetStatus } from "../../shared/api";
import { GroupHeader } from "./GroupHeader";
import { PrimaryTable } from "tdesign-react";
import type { TargetStatus } from "../../shared/api";
import { TARGET_TABLE_COLUMNS } from "../constants/target-table-columns";
import { GroupHeader } from "./GroupHeader";
interface TargetGroupProps {
name: string;
targets: TargetStatus[];
onTargetClick: (target: TargetStatus) => void;
targets: TargetStatus[];
}
export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps) {
export function TargetGroup({ name, onTargetClick, targets }: TargetGroupProps) {
const up = targets.filter((t) => t.latestCheck?.matched).length;
const down = targets.length - up;
return (
<div>
<GroupHeader name={name} total={targets.length} up={up} down={down} />
<GroupHeader down={down} name={name} total={targets.length} up={up} />
<PrimaryTable
bordered
className="clickable-table"
columns={TARGET_TABLE_COLUMNS}
data={targets}
defaultSort={[{ descending: true, sortBy: "latestCheck.matched" }]}
hover
onRowClick={({ row }) => onTargetClick(row)}
rowClassName={({ row }) => {
const target = row;
return target.latestCheck?.matched === false ? "row-down" : "";
}}
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" : "";
}}
className="clickable-table"
/>
</div>
);

View File

@@ -1,4 +1,5 @@
import { Line, LineChart, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid } from "recharts";
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import type { TrendPoint } from "../../shared/api";
interface TrendChartProps {
@@ -22,23 +23,23 @@ export function TrendChart({ data, loading }: TrendChartProps) {
return (
<div className="trend-chart">
<ResponsiveContainer width="100%" height={240}>
<ResponsiveContainer height={240} width="100%">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--td-border-level-2-color)" />
<XAxis dataKey="hour" tick={{ fontSize: 12 }} stroke="var(--td-text-color-secondary)" />
<CartesianGrid stroke="var(--td-border-level-2-color)" strokeDasharray="3 3" />
<XAxis dataKey="hour" stroke="var(--td-text-color-secondary)" tick={{ fontSize: 12 }} />
<YAxis
yAxisId="duration"
tick={{ fontSize: 12 }}
label={{ fontSize: 11, position: "insideTopRight", value: "ms" }}
stroke="var(--td-text-color-secondary)"
label={{ value: "ms", position: "insideTopRight", fontSize: 11 }}
tick={{ fontSize: 12 }}
yAxisId="duration"
/>
<YAxis
yAxisId="availability"
orientation="right"
domain={[0, 100]}
tick={{ fontSize: 12 }}
label={{ fontSize: 11, position: "insideTopLeft", value: "%" }}
orientation="right"
stroke="var(--td-text-color-secondary)"
label={{ value: "%", position: "insideTopLeft", fontSize: 11 }}
tick={{ fontSize: 12 }}
yAxisId="availability"
/>
<Tooltip
formatter={(value: unknown, name: unknown) => {
@@ -50,22 +51,22 @@ export function TrendChart({ data, loading }: TrendChartProps) {
}}
/>
<Line
yAxisId="duration"
type="monotone"
dataKey="avgDurationMs"
stroke="var(--td-brand-color)"
strokeWidth={2}
dot={false}
name="avgDurationMs"
stroke="var(--td-brand-color)"
strokeWidth={2}
type="monotone"
yAxisId="duration"
/>
<Line
yAxisId="availability"
type="monotone"
dataKey="availability"
stroke="var(--td-success-color)"
strokeWidth={2}
dot={false}
name="availability"
stroke="var(--td-success-color)"
strokeWidth={2}
type="monotone"
yAxisId="availability"
/>
</LineChart>
</ResponsiveContainer>

View File

@@ -1,89 +1,92 @@
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 } from "./color-threshold";
import { availabilitySorter, latencySorter, nameSorter } from "./target-table-sorters";
import { statusFilter, typeFilter } from "./target-table-filters";
import type { PrimaryTableCellParams, PrimaryTableCol } from "tdesign-react";
export const TARGET_TABLE_COLUMNS: PrimaryTableCol<TargetStatus>[] = [
import { Progress, Tag } from "tdesign-react";
import type { TargetStatus } from "../../shared/api";
import { StatusBar } from "../components/StatusBar";
import { StatusDot } from "../components/StatusDot";
import { getAvailabilityProgressColor } from "./color-threshold";
import { statusFilter, typeFilter } from "./target-table-filters";
import { availabilitySorter, latencySorter, nameSorter } from "./target-table-sorters";
import { getTargetTypeDisplay } from "./target-type-display";
export const TARGET_TABLE_COLUMNS: Array<PrimaryTableCol<TargetStatus>> = [
{
align: "center",
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => <StatusDot up={!!row.latestCheck?.matched} />,
colKey: "latestCheck.matched",
filter: statusFilter,
fixed: "left",
title: "#",
width: 60,
fixed: "left",
align: "center",
filter: statusFilter,
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => <StatusDot up={!!row.latestCheck?.matched} />,
},
{
colKey: "name",
title: "名称",
ellipsis: true,
sorter: nameSorter,
sortType: "all",
title: "名称",
},
{
colKey: "type",
title: "类型",
width: 80,
filter: typeFilter,
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => (
<Tag size="small" theme="primary" variant="light-outline">
{getTargetTypeDisplay(row.type)}
</Tag>
),
colKey: "type",
filter: typeFilter,
title: "类型",
width: 80,
},
{
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)}%`}
percentage={availability}
size="small"
theme="line"
/>
);
},
colKey: "stats.availability",
sorter: availabilitySorter,
sortType: "all",
title: "可用率",
width: 160,
},
{
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => <StatusBar samples={row.recentSamples} />,
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 className="text-disabled">-</span>;
const colorClass = ms <= 100 ? "latency-ok" : ms <= 500 ? "latency-warn" : "latency-error";
return <span className={`${colorClass} tabular-nums`}>{Math.round(ms)}ms</span>;
},
colKey: "latestCheck.durationMs",
sorter: latencySorter,
sortType: "all",
title: "延迟",
width: 80,
},
{
align: "center",
colKey: "interval",
title: "间隔",
width: 72,
align: "center",
},
];
export { statusSorter, availabilitySorter, latencySorter, nameSorter } from "./target-table-sorters";
export { statusFilter, typeFilter } from "./target-table-filters";
export { availabilitySorter, latencySorter, nameSorter, statusSorter } from "./target-table-sorters";

View File

@@ -1,19 +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" },
],
type: "single",
};
export const typeFilter: PrimaryTableCol["filter"] = {
type: "single",
list: [
{ label: "全部", value: "" },
{ label: "HTTP", value: "http" },
{ label: "CMD", value: "command" },
],
type: "single",
};

View File

@@ -5,15 +5,6 @@ const STATUS_ORDER: Record<string, number> = {
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);
}
@@ -25,3 +16,12 @@ export function latencySorter(a: TargetStatus, b: TargetStatus): number {
export function nameSorter(a: TargetStatus, b: TargetStatus): number {
return a.name.localeCompare(b.name, "zh-CN");
}
export function statusSorter(a: TargetStatus, b: TargetStatus): number {
return getStatusRank(a) - getStatusRank(b);
}
function getStatusRank(target: TargetStatus): number {
if (!target.latestCheck) return 2;
return target.latestCheck.matched ? STATUS_ORDER["up"]! : STATUS_ORDER["down"]!;
}

View File

@@ -1,6 +1,6 @@
export const TARGET_TYPE_DISPLAY = {
http: "HTTP",
command: "CMD",
http: "HTTP",
} as const;
export type TargetType = keyof typeof TARGET_TYPE_DISPLAY;

View File

@@ -1,34 +1,21 @@
import { useCallback, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useState } from "react";
import type { HistoryResponse, SummaryResponse, TargetStatus, TrendPoint } from "../../shared/api";
import { subtractHours } from "../utils/time";
const queryKeys = {
history: (targetId: number, from: string, to: string, page: number) => ["history", targetId, from, to, page] as const,
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"),
queryKey: queryKeys.summary(),
refetchInterval: 8000,
refetchIntervalInBackground: false,
});
@@ -36,7 +23,7 @@ export function useTargets() {
export function useTargetDetail() {
const queryClient = useQueryClient();
const [selectedTargetId, setSelectedTargetId] = useState<number | null>(null);
const [selectedTargetId, setSelectedTargetId] = useState<null | number>(null);
const [timeFrom, setTimeFrom] = useState("");
const [timeTo, setTimeTo] = useState("");
const [historyPage, setHistoryPage] = useState(1);
@@ -47,27 +34,27 @@ export function useTargetDetail() {
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"],
enabled: selectedTargetId !== null && !!timeFrom && !!timeTo,
queryFn: () =>
fetchJson<TrendPoint[]>(
`/api/targets/${selectedTargetId}/trend?from=${encodeURIComponent(timeFrom)}&to=${encodeURIComponent(timeTo)}`,
),
enabled: selectedTargetId !== null && !!timeFrom && !!timeTo,
queryKey:
selectedTargetId !== null && timeFrom && timeTo
? queryKeys.trend(selectedTargetId, timeFrom, timeTo)
: ["trend", "disabled"],
});
const history = useQuery({
queryKey:
selectedTargetId !== null && timeFrom && timeTo
? queryKeys.history(selectedTargetId, timeFrom, timeTo, historyPage)
: ["history", "disabled"],
enabled: selectedTargetId !== null && !!timeFrom && !!timeTo,
queryFn: () =>
fetchJson<HistoryResponse>(
`/api/targets/${selectedTargetId}/history?from=${encodeURIComponent(timeFrom)}&to=${encodeURIComponent(timeTo)}&page=${historyPage}&pageSize=20`,
),
enabled: selectedTargetId !== null && !!timeFrom && !!timeTo,
queryKey:
selectedTargetId !== null && timeFrom && timeTo
? queryKeys.history(selectedTargetId, timeFrom, timeTo, historyPage)
: ["history", "disabled"],
});
const openDrawer = useCallback((target: TargetStatus) => {
@@ -96,16 +83,31 @@ export function useTargetDetail() {
}, []);
return {
selectedTarget,
trendData: trend.data ?? [],
trendLoading: trend.isLoading,
historyData: history.data ?? { items: [], total: 0, page: 1, pageSize: 20 },
closeDrawer,
handlePageChange,
handleTimeChange,
historyData: history.data ?? { items: [], page: 1, pageSize: 20, total: 0 },
historyLoading: history.isLoading,
openDrawer,
selectedTarget,
timeFrom,
timeTo,
openDrawer,
closeDrawer,
handleTimeChange,
handlePageChange,
trendData: trend.data ?? [],
trendLoading: trend.isLoading,
};
}
export function useTargets() {
return useQuery({
queryFn: () => fetchJson<TargetStatus[]>("/api/targets"),
queryKey: queryKeys.targets(),
refetchInterval: 8000,
refetchIntervalInBackground: false,
});
}
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>;
}

View File

@@ -1,17 +1,19 @@
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 { StrictMode } from "react";
import { createRoot } from "react-dom/client";
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,
retry: 1,
staleTime: 5000,
},
},