refactor: 前端架构重构 — hook拆分、组件拆分、类型筛选器动态化、Meta API
- 后端新增 GET /api/meta 端点(checkerRegistry.supportedTypes)及 MetaResponse 类型 - 前端 hook 拆分为 use-queries.ts(全局查询+useMeta)和 use-target-detail.ts(Drawer状态) - TargetDetailDrawer 拆分为 OverviewTab + HistoryTab + history-table-columns + stats.ts - 类型筛选器由 meta API 动态驱动,删除 target-type-display 静态映射 - 列定义改为工厂函数 createTargetTableColumns(checkerTypes),TargetGroup 新增 columns prop - 修复 StatusDonut key、StatusBar maxSlots prop、TrendChart 移除 loading prop - 补充 utils/time、utils/stats、动态列工厂测试,删除旧 mapping 测试 - 同步 delta specs 到主 specs,归档 frontend-architecture-refactor change
This commit is contained in:
@@ -3,7 +3,8 @@ import { Alert, Loading, Typography } from "tdesign-react";
|
||||
import { SummaryCards } from "./components/SummaryCards";
|
||||
import { TargetBoard } from "./components/TargetBoard";
|
||||
import { TargetDetailDrawer } from "./components/TargetDetailDrawer";
|
||||
import { useSummary, useTargetDetail, useTargets } from "./hooks/useTargetDetail";
|
||||
import { useSummary, useTargets } from "./hooks/use-queries";
|
||||
import { useTargetDetail } from "./hooks/use-target-detail";
|
||||
|
||||
export function App() {
|
||||
const { data: summary, error: summaryError, isLoading: summaryLoading } = useSummary();
|
||||
|
||||
31
src/web/components/HistoryTab.tsx
Normal file
31
src/web/components/HistoryTab.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { PrimaryTable } from "tdesign-react";
|
||||
|
||||
import type { HistoryResponse } from "../../shared/api";
|
||||
|
||||
import { HISTORY_COLUMNS } from "../constants/history-table-columns";
|
||||
|
||||
interface HistoryTabProps {
|
||||
historyData: HistoryResponse;
|
||||
historyLoading: boolean;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function HistoryTab({ historyData, historyLoading, onPageChange }: HistoryTabProps) {
|
||||
return (
|
||||
<PrimaryTable
|
||||
columns={HISTORY_COLUMNS}
|
||||
data={historyData.items}
|
||||
disableDataPage
|
||||
loading={historyLoading}
|
||||
onPageChange={({ current }) => {
|
||||
if (current) onPageChange(current);
|
||||
}}
|
||||
pagination={{
|
||||
current: historyData.page,
|
||||
pageSize: historyData.pageSize,
|
||||
total: historyData.total,
|
||||
}}
|
||||
rowKey="timestamp"
|
||||
/>
|
||||
);
|
||||
}
|
||||
57
src/web/components/OverviewTab.tsx
Normal file
57
src/web/components/OverviewTab.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useMemo } from "react";
|
||||
import { Col, Descriptions, Divider, Row, Skeleton, Space, Statistic } from "tdesign-react";
|
||||
|
||||
import type { TargetStatus, TrendPoint } from "../../shared/api";
|
||||
|
||||
import { computeTrendStats } from "../utils/stats";
|
||||
import { StatusDonut } from "./StatusDonut";
|
||||
import { TrendChart } from "./TrendChart";
|
||||
|
||||
interface OverviewTabProps {
|
||||
target: TargetStatus;
|
||||
trendData: TrendPoint[];
|
||||
trendLoading: boolean;
|
||||
}
|
||||
|
||||
export function OverviewTab({ target, trendData, trendLoading }: OverviewTabProps) {
|
||||
const { downChecks, totalChecks, upChecks } = useMemo(() => computeTrendStats(trendData), [trendData]);
|
||||
|
||||
return (
|
||||
<Space className="full-width" direction="vertical" size={16}>
|
||||
<Divider align="left">统计</Divider>
|
||||
<Row gutter={16}>
|
||||
<Col span={3}>
|
||||
<Statistic color="blue" title="总检查" value={totalChecks} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic color="green" title="正常" value={upChecks} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic color="red" title="异常" value={downChecks} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic color="green" suffix="%" title="可用率" value={target.stats?.availability ?? 0} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Divider align="left">趋势</Divider>
|
||||
{trendLoading ? <Skeleton animation="gradient" /> : <TrendChart data={trendData} />}
|
||||
|
||||
<Divider align="left">状态分布</Divider>
|
||||
<StatusDonut down={downChecks} up={upChecks} />
|
||||
|
||||
<Divider align="left">基本信息</Divider>
|
||||
<Descriptions
|
||||
items={[
|
||||
{ content: target.target, label: "目标地址" },
|
||||
{ content: target.interval, label: "检查间隔" },
|
||||
{
|
||||
content: target.latestCheck ? new Date(target.latestCheck.timestamp).toLocaleString("zh-CN") : "-",
|
||||
label: "最新检查时间",
|
||||
},
|
||||
{ content: target.latestCheck?.statusDetail ?? "-", label: "状态详情" },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
interface StatusBarProps {
|
||||
maxSlots?: number;
|
||||
samples: Array<{ up: boolean }>;
|
||||
}
|
||||
|
||||
export function StatusBar({ samples }: StatusBarProps) {
|
||||
export function StatusBar({ maxSlots = 30, samples }: StatusBarProps) {
|
||||
const blocks = [];
|
||||
for (let i = 0; i < 30; i++) {
|
||||
for (let i = 0; i < maxSlots; i++) {
|
||||
const sample = samples[i];
|
||||
if (sample) {
|
||||
blocks.push(
|
||||
|
||||
@@ -28,8 +28,8 @@ export function StatusDonut({ down, up }: StatusDonutProps) {
|
||||
<ResponsiveContainer height={180} width="100%">
|
||||
<PieChart>
|
||||
<Pie cx="50%" cy="50%" data={data} dataKey="value" innerRadius={50} outerRadius={70} stroke="none">
|
||||
{data.map((_, index) => (
|
||||
<Cell fill={colors[index % colors.length]} key={index} />
|
||||
{data.map((item, index) => (
|
||||
<Cell fill={colors[index % colors.length]} key={item.name} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { useMemo } from "react";
|
||||
import { Space } from "tdesign-react";
|
||||
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
|
||||
import { createTargetTableColumns } from "../constants/target-table-columns";
|
||||
import { useMeta } from "../hooks/use-queries";
|
||||
import { TargetGroup } from "./TargetGroup";
|
||||
|
||||
const EMPTY_CHECKER_TYPES: string[] = [];
|
||||
|
||||
interface TargetBoardProps {
|
||||
onTargetClick: (target: TargetStatus) => void;
|
||||
targets: TargetStatus[];
|
||||
}
|
||||
|
||||
export function TargetBoard({ onTargetClick, targets }: TargetBoardProps) {
|
||||
const { data: meta } = useMeta();
|
||||
const checkerTypes = meta?.checkerTypes ?? EMPTY_CHECKER_TYPES;
|
||||
const columns = useMemo(() => createTargetTableColumns(checkerTypes), [checkerTypes]);
|
||||
|
||||
const groups = new Map<string, TargetStatus[]>();
|
||||
for (const target of targets) {
|
||||
const group = target.group;
|
||||
@@ -29,7 +38,7 @@ export function TargetBoard({ onTargetClick, targets }: TargetBoardProps) {
|
||||
return (
|
||||
<Space className="full-width" direction="vertical" size={32}>
|
||||
{sortedGroups.map(([name, groupTargets]) => (
|
||||
<TargetGroup key={name} name={name} onTargetClick={onTargetClick} targets={groupTargets} />
|
||||
<TargetGroup columns={columns} key={name} name={name} onTargetClick={onTargetClick} targets={groupTargets} />
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
|
||||
@@ -1,30 +1,14 @@
|
||||
import type { TabValue } from "tdesign-react";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
Col,
|
||||
DateRangePicker,
|
||||
Descriptions,
|
||||
Divider,
|
||||
Drawer,
|
||||
PrimaryTable,
|
||||
RadioGroup,
|
||||
Row,
|
||||
Skeleton,
|
||||
Space,
|
||||
Statistic,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
} from "tdesign-react";
|
||||
import { DateRangePicker, Drawer, RadioGroup, Space, Tabs, Tag, Typography } from "tdesign-react";
|
||||
|
||||
import type { CheckResult, HistoryResponse, TargetStatus, TrendPoint } from "../../shared/api";
|
||||
import type { HistoryResponse, TargetStatus, TrendPoint } from "../../shared/api";
|
||||
|
||||
import { getTargetTypeDisplay } from "../constants/target-type-display";
|
||||
import { subtractHours } from "../utils/time";
|
||||
import { StatusDonut } from "./StatusDonut";
|
||||
import { HistoryTab } from "./HistoryTab";
|
||||
import { OverviewTab } from "./OverviewTab";
|
||||
import { StatusDot } from "./StatusDot";
|
||||
import { TrendChart } from "./TrendChart";
|
||||
|
||||
interface TargetDetailDrawerProps {
|
||||
historyData: HistoryResponse;
|
||||
@@ -46,43 +30,6 @@ const TIME_SHORTCUTS = [
|
||||
{ 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 }: { 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,
|
||||
},
|
||||
{
|
||||
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({
|
||||
historyData,
|
||||
historyLoading,
|
||||
@@ -123,9 +70,6 @@ export function TargetDetailDrawer({
|
||||
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
|
||||
@@ -135,7 +79,7 @@ export function TargetDetailDrawer({
|
||||
<StatusDot up={!!isUp} />
|
||||
<Typography.Text strong>{target.name}</Typography.Text>
|
||||
<Tag size="small" theme="primary" variant="light-outline">
|
||||
{getTargetTypeDisplay(target.type)}
|
||||
{target.type}
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
@@ -163,66 +107,16 @@ export function TargetDetailDrawer({
|
||||
value={timeFrom && timeTo ? [timeFrom, timeTo] : undefined}
|
||||
valueType="YYYY-MM-DD HH:mm"
|
||||
/>
|
||||
<Tabs onChange={(val: TabValue) => setActiveTab(val)} value={activeTab}>
|
||||
<Tabs.TabPanel className="tab-panel-padded" label="概览" value="overview">
|
||||
<OverviewTab target={target} trendData={trendData} trendLoading={trendLoading} />
|
||||
</Tabs.TabPanel>
|
||||
|
||||
<Tabs.TabPanel className="tab-panel-padded" label="记录" value="history">
|
||||
<HistoryTab historyData={historyData} historyLoading={historyLoading} onPageChange={onPageChange} />
|
||||
</Tabs.TabPanel>
|
||||
</Tabs>
|
||||
</Space>
|
||||
|
||||
<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 color="blue" title="总检查" value={totalChecks} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic color="green" title="正常" value={upChecks} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic color="red" title="异常" value={downChecks} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic color="green" suffix="%" title="可用率" value={target.stats?.availability ?? 0} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Divider align="left">趋势</Divider>
|
||||
{trendLoading ? <Skeleton animation="gradient" /> : <TrendChart data={trendData} loading={false} />}
|
||||
|
||||
<Divider align="left">状态分布</Divider>
|
||||
<StatusDonut down={downChecks} up={upChecks} />
|
||||
|
||||
<Divider align="left">基本信息</Divider>
|
||||
<Descriptions
|
||||
items={[
|
||||
{ content: target.target, label: "目标地址" },
|
||||
{ content: target.interval, label: "检查间隔" },
|
||||
{
|
||||
content: target.latestCheck ? new Date(target.latestCheck.timestamp).toLocaleString("zh-CN") : "-",
|
||||
label: "最新检查时间",
|
||||
},
|
||||
{ content: target.latestCheck?.statusDetail ?? "-", label: "状态详情" },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Tabs.TabPanel>
|
||||
|
||||
<Tabs.TabPanel className="tab-panel-padded" label="记录" value="history">
|
||||
<PrimaryTable
|
||||
columns={HISTORY_COLUMNS}
|
||||
data={historyData.items}
|
||||
disableDataPage
|
||||
loading={historyLoading}
|
||||
onPageChange={({ current }) => {
|
||||
if (current) onPageChange(current);
|
||||
}}
|
||||
pagination={{
|
||||
current: historyData.page,
|
||||
pageSize: historyData.pageSize,
|
||||
total: historyData.total,
|
||||
}}
|
||||
rowKey="timestamp"
|
||||
/>
|
||||
</Tabs.TabPanel>
|
||||
</Tabs>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import type { PrimaryTableCol } from "tdesign-react";
|
||||
|
||||
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 {
|
||||
columns: Array<PrimaryTableCol<TargetStatus>>;
|
||||
name: string;
|
||||
onTargetClick: (target: TargetStatus) => void;
|
||||
targets: TargetStatus[];
|
||||
}
|
||||
|
||||
export function TargetGroup({ name, onTargetClick, targets }: TargetGroupProps) {
|
||||
export function TargetGroup({ columns, name, onTargetClick, targets }: TargetGroupProps) {
|
||||
const up = targets.filter((t) => t.latestCheck?.matched).length;
|
||||
const down = targets.length - up;
|
||||
|
||||
@@ -21,7 +23,7 @@ export function TargetGroup({ name, onTargetClick, targets }: TargetGroupProps)
|
||||
<PrimaryTable
|
||||
bordered
|
||||
className="clickable-table"
|
||||
columns={TARGET_TABLE_COLUMNS}
|
||||
columns={columns}
|
||||
data={targets}
|
||||
defaultSort={[{ descending: true, sortBy: "latestCheck.matched" }]}
|
||||
hover
|
||||
|
||||
@@ -4,14 +4,9 @@ import type { TrendPoint } from "../../shared/api";
|
||||
|
||||
interface TrendChartProps {
|
||||
data: TrendPoint[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function TrendChart({ data, loading }: TrendChartProps) {
|
||||
if (loading) {
|
||||
return <div className="trend-loading">加载趋势数据...</div>;
|
||||
}
|
||||
|
||||
export function TrendChart({ data }: TrendChartProps) {
|
||||
if (data.length === 0) {
|
||||
return <div className="trend-empty">暂无趋势数据</div>;
|
||||
}
|
||||
|
||||
42
src/web/constants/history-table-columns.tsx
Normal file
42
src/web/constants/history-table-columns.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { PrimaryTableCellParams, PrimaryTableCol } from "tdesign-react";
|
||||
|
||||
import type { CheckResult } from "../../shared/api";
|
||||
|
||||
import { StatusDot } from "../components/StatusDot";
|
||||
|
||||
export const HISTORY_COLUMNS: Array<PrimaryTableCol<CheckResult>> = [
|
||||
{
|
||||
cell: ({ row }: PrimaryTableCellParams<CheckResult>) => <StatusDot up={!!row.matched} />,
|
||||
colKey: "matched",
|
||||
title: "#",
|
||||
width: 40,
|
||||
},
|
||||
{
|
||||
cell: ({ row }: PrimaryTableCellParams<CheckResult>) => formatTimestamp(row.timestamp),
|
||||
colKey: "timestamp",
|
||||
title: "时间",
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
align: "center",
|
||||
cell: ({ row }: PrimaryTableCellParams<CheckResult>) =>
|
||||
row.durationMs !== null ? Math.round(row.durationMs) : "-",
|
||||
colKey: "durationMs",
|
||||
title: "耗时(ms)",
|
||||
width: 96,
|
||||
},
|
||||
{
|
||||
cell: ({ row }: PrimaryTableCellParams<CheckResult>) => {
|
||||
const parts = [row.statusDetail, row.failure?.message].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join(":") : "-";
|
||||
},
|
||||
colKey: "statusDetail",
|
||||
title: "详情",
|
||||
},
|
||||
];
|
||||
|
||||
function formatTimestamp(timestamp: string): string {
|
||||
const date = new Date(timestamp);
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
@@ -7,86 +7,91 @@ 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 { statusFilter } 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,
|
||||
},
|
||||
{
|
||||
colKey: "name",
|
||||
ellipsis: true,
|
||||
sorter: nameSorter,
|
||||
sortType: "all",
|
||||
title: "名称",
|
||||
},
|
||||
{
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => (
|
||||
<Tag size="small" theme="primary" variant="light-outline">
|
||||
{getTargetTypeDisplay(row.type)}
|
||||
</Tag>
|
||||
),
|
||||
colKey: "type",
|
||||
filter: typeFilter,
|
||||
title: "类型",
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => {
|
||||
const availability = row.stats?.availability;
|
||||
if (availability === undefined || availability === null) return "-";
|
||||
const color = getAvailabilityProgressColor(availability);
|
||||
return (
|
||||
<Progress
|
||||
color={color}
|
||||
label={`${availability.toFixed(1)}%`}
|
||||
percentage={availability}
|
||||
size="small"
|
||||
theme="line"
|
||||
/>
|
||||
);
|
||||
export function createTargetTableColumns(checkerTypes: string[]): Array<PrimaryTableCol<TargetStatus>> {
|
||||
return [
|
||||
{
|
||||
align: "center",
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => <StatusDot up={!!row.latestCheck?.matched} />,
|
||||
colKey: "latestCheck.matched",
|
||||
filter: statusFilter,
|
||||
fixed: "left",
|
||||
title: "#",
|
||||
width: 60,
|
||||
},
|
||||
colKey: "stats.availability",
|
||||
sorter: availabilitySorter,
|
||||
sortType: "all",
|
||||
title: "可用率",
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => <StatusBar samples={row.recentSamples} />,
|
||||
colKey: "recentSamples",
|
||||
title: "最近状态",
|
||||
width: 220,
|
||||
},
|
||||
{
|
||||
align: "right",
|
||||
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: "name",
|
||||
ellipsis: true,
|
||||
sorter: nameSorter,
|
||||
sortType: "all",
|
||||
title: "名称",
|
||||
},
|
||||
colKey: "latestCheck.durationMs",
|
||||
sorter: latencySorter,
|
||||
sortType: "all",
|
||||
title: "延迟",
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
align: "center",
|
||||
colKey: "interval",
|
||||
title: "间隔",
|
||||
width: 72,
|
||||
},
|
||||
];
|
||||
{
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => (
|
||||
<Tag size="small" theme="primary" variant="light-outline">
|
||||
{row.type}
|
||||
</Tag>
|
||||
),
|
||||
colKey: "type",
|
||||
filter: createTypeFilter(checkerTypes),
|
||||
title: "类型",
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => {
|
||||
const availability = row.stats?.availability;
|
||||
if (availability === undefined || availability === null) return "-";
|
||||
const color = getAvailabilityProgressColor(availability);
|
||||
return (
|
||||
<Progress
|
||||
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,
|
||||
},
|
||||
{
|
||||
align: "right",
|
||||
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,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export { statusFilter, typeFilter } from "./target-table-filters";
|
||||
export { availabilitySorter, latencySorter, nameSorter, statusSorter } from "./target-table-sorters";
|
||||
function createTypeFilter(checkerTypes: string[]): PrimaryTableCol["filter"] {
|
||||
return {
|
||||
list: [{ label: "全部", value: "" }, ...checkerTypes.map((type) => ({ label: type, value: type }))],
|
||||
type: "single",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,12 +8,3 @@ export const statusFilter: PrimaryTableCol["filter"] = {
|
||||
],
|
||||
type: "single",
|
||||
};
|
||||
|
||||
export const typeFilter: PrimaryTableCol["filter"] = {
|
||||
list: [
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "HTTP", value: "http" },
|
||||
{ label: "CMD", value: "command" },
|
||||
],
|
||||
type: "single",
|
||||
};
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export const TARGET_TYPE_DISPLAY = {
|
||||
command: "CMD",
|
||||
http: "HTTP",
|
||||
} as const;
|
||||
|
||||
export type TargetType = keyof typeof TARGET_TYPE_DISPLAY;
|
||||
|
||||
export function getTargetTypeDisplay(type: string): string {
|
||||
return TARGET_TYPE_DISPLAY[type as TargetType] || type.toUpperCase();
|
||||
}
|
||||
41
src/web/hooks/use-queries.ts
Normal file
41
src/web/hooks/use-queries.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import type { MetaResponse, SummaryResponse, TargetStatus } from "../../shared/api";
|
||||
|
||||
const queryKeys = {
|
||||
meta: () => ["meta"] as const,
|
||||
summary: () => ["summary"] as const,
|
||||
targets: () => ["targets"] as const,
|
||||
};
|
||||
|
||||
export 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 useMeta() {
|
||||
return useQuery({
|
||||
queryFn: () => fetchJson<MetaResponse>("/api/meta"),
|
||||
queryKey: queryKeys.meta(),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSummary() {
|
||||
return useQuery({
|
||||
queryFn: () => fetchJson<SummaryResponse>("/api/summary"),
|
||||
queryKey: queryKeys.summary(),
|
||||
refetchInterval: 8000,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTargets() {
|
||||
return useQuery({
|
||||
queryFn: () => fetchJson<TargetStatus[]>("/api/targets"),
|
||||
queryKey: queryKeys.targets(),
|
||||
refetchInterval: 8000,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
}
|
||||
@@ -1,26 +1,16 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import type { HistoryResponse, SummaryResponse, TargetStatus, TrendPoint } from "../../shared/api";
|
||||
import type { HistoryResponse, TargetStatus, TrendPoint } from "../../shared/api";
|
||||
|
||||
import { subtractHours } from "../utils/time";
|
||||
import { fetchJson, useTargets } from "./use-queries";
|
||||
|
||||
const queryKeys = {
|
||||
const detailQueryKeys = {
|
||||
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,
|
||||
};
|
||||
|
||||
export function useSummary() {
|
||||
return useQuery({
|
||||
queryFn: () => fetchJson<SummaryResponse>("/api/summary"),
|
||||
queryKey: queryKeys.summary(),
|
||||
refetchInterval: 8000,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTargetDetail() {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedTargetId, setSelectedTargetId] = useState<null | number>(null);
|
||||
@@ -29,9 +19,8 @@ export function useTargetDetail() {
|
||||
const [historyPage, setHistoryPage] = useState(1);
|
||||
|
||||
const { data: targetsData } = useTargets();
|
||||
|
||||
const selectedTarget =
|
||||
selectedTargetId !== null ? (targetsData?.find((t) => t.id === selectedTargetId) ?? null) : null;
|
||||
selectedTargetId !== null ? (targetsData?.find((target) => target.id === selectedTargetId) ?? null) : null;
|
||||
|
||||
const trend = useQuery({
|
||||
enabled: selectedTargetId !== null && !!timeFrom && !!timeTo,
|
||||
@@ -41,7 +30,7 @@ export function useTargetDetail() {
|
||||
),
|
||||
queryKey:
|
||||
selectedTargetId !== null && timeFrom && timeTo
|
||||
? queryKeys.trend(selectedTargetId, timeFrom, timeTo)
|
||||
? detailQueryKeys.trend(selectedTargetId, timeFrom, timeTo)
|
||||
: ["trend", "disabled"],
|
||||
});
|
||||
|
||||
@@ -53,7 +42,7 @@ export function useTargetDetail() {
|
||||
),
|
||||
queryKey:
|
||||
selectedTargetId !== null && timeFrom && timeTo
|
||||
? queryKeys.history(selectedTargetId, timeFrom, timeTo, historyPage)
|
||||
? detailQueryKeys.history(selectedTargetId, timeFrom, timeTo, historyPage)
|
||||
: ["history", "disabled"],
|
||||
});
|
||||
|
||||
@@ -96,18 +85,3 @@ export function useTargetDetail() {
|
||||
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>;
|
||||
}
|
||||
23
src/web/utils/stats.ts
Normal file
23
src/web/utils/stats.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { TrendPoint } from "../../shared/api";
|
||||
|
||||
export interface TrendStats {
|
||||
downChecks: number;
|
||||
totalChecks: number;
|
||||
upChecks: number;
|
||||
}
|
||||
|
||||
export function computeTrendStats(points: TrendPoint[]): TrendStats {
|
||||
let totalChecks = 0;
|
||||
let upChecks = 0;
|
||||
|
||||
for (const point of points) {
|
||||
totalChecks += point.totalChecks;
|
||||
upChecks += Math.round((point.availability / 100) * point.totalChecks);
|
||||
}
|
||||
|
||||
return {
|
||||
downChecks: totalChecks - upChecks,
|
||||
totalChecks,
|
||||
upChecks,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user