1
0

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:
2026-05-13 20:55:42 +08:00
parent a62007083d
commit 31aeee6d60
41 changed files with 713 additions and 902 deletions

View File

@@ -5,6 +5,7 @@ import { createApiError, jsonResponse } from "./helpers";
import { guardGetHead } from "./middleware";
import { handleHealth } from "./routes/health";
import { handleHistory } from "./routes/history";
import { handleMeta } from "./routes/meta";
import { handleSummary } from "./routes/summary";
import { handleTargets } from "./routes/targets";
import { handleTrend } from "./routes/trend";
@@ -29,6 +30,10 @@ export function createFetchHandler(options: AppOptions) {
return handleHealth(request.method, options.mode);
}
if (url.pathname === "/api/meta") {
return handleMetaRoute(request, options.mode);
}
if (url.pathname.startsWith("/api/") && options.store) {
return handleApiRoute(url, request, options.store, options.mode);
}
@@ -78,3 +83,9 @@ function handleApiRoute(url: URL, request: Request, store: ProbeStore, mode: Run
return jsonResponse(createApiError("API route not found", 404), { method, mode, status: 404 });
}
function handleMetaRoute(request: Request, mode: RuntimeMode): Response {
const guardResult = guardGetHead(request.method, mode);
if (guardResult) return guardResult;
return handleMeta(request.method, mode);
}

12
src/server/routes/meta.ts Normal file
View File

@@ -0,0 +1,12 @@
import type { MetaResponse, RuntimeMode } from "../../shared/api";
import { checkerRegistry } from "../checker/runner";
import { jsonResponse } from "../helpers";
export function handleMeta(method: string, mode: RuntimeMode): Response {
const response: MetaResponse = {
checkerTypes: checkerRegistry.supportedTypes,
};
return jsonResponse(response, { method, mode });
}

View File

@@ -33,6 +33,10 @@ export interface HistoryResponse {
total: number;
}
export interface MetaResponse {
checkerTypes: string[];
}
export interface RecentSample {
durationMs: null | number;
timestamp: string;

View File

@@ -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();

View 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"
/>
);
}

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

View File

@@ -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(

View File

@@ -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>

View File

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

View File

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

View File

@@ -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

View File

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

View 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())}`;
}

View File

@@ -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",
};
}

View File

@@ -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",
};

View File

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

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

View File

@@ -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
View 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,
};
}