refactor: 前端视觉重构 — Layout/HeadMenu 骨架、SummaryCards 合并、Card 分组、Drawer 概览重设计
This commit is contained in:
@@ -1,13 +1,30 @@
|
||||
import { Alert, Loading, Typography } from "tdesign-react";
|
||||
import type { SkeletonProps } from "tdesign-react";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, Layout, Menu, Skeleton, Typography } from "tdesign-react";
|
||||
|
||||
import { SummaryCards } from "./components/SummaryCards";
|
||||
import { TargetBoard } from "./components/TargetBoard";
|
||||
import { TargetDetailDrawer } from "./components/TargetDetailDrawer";
|
||||
import { useDashboard } from "./hooks/use-queries";
|
||||
import { DASHBOARD_REFRESH_INTERVAL_MS, useDashboard } from "./hooks/use-queries";
|
||||
import { useTargetDetail } from "./hooks/use-target-detail";
|
||||
|
||||
const { Content, Header } = Layout;
|
||||
const DASHBOARD_SKELETON_ROW_COL: SkeletonProps["rowCol"] = [
|
||||
[{ height: "112px", type: "rect", width: "100%" }],
|
||||
[{ height: "56px", type: "rect", width: "100%" }],
|
||||
[{ height: "320px", type: "rect", width: "100%" }],
|
||||
];
|
||||
|
||||
export function App() {
|
||||
const { data: dashboard, error: dashboardError, isLoading: dashboardLoading } = useDashboard();
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
const {
|
||||
data: dashboard,
|
||||
dataUpdatedAt: dashboardUpdatedAt,
|
||||
error: dashboardError,
|
||||
isFetching: dashboardFetching,
|
||||
isLoading: dashboardLoading,
|
||||
} = useDashboard();
|
||||
const {
|
||||
closeDrawer,
|
||||
handlePageChange,
|
||||
@@ -21,25 +38,55 @@ export function App() {
|
||||
timeFrom,
|
||||
timeTo,
|
||||
} = useTargetDetail();
|
||||
const nextRefreshSeconds =
|
||||
dashboardUpdatedAt > 0
|
||||
? Math.max(0, Math.ceil((dashboardUpdatedAt + DASHBOARD_REFRESH_INTERVAL_MS - now.getTime()) / 1000))
|
||||
: null;
|
||||
const refreshText =
|
||||
dashboardUpdatedAt > 0
|
||||
? dashboardFetching && !dashboardLoading
|
||||
? "刷新中..."
|
||||
: `下一次刷新:${nextRefreshSeconds}秒`
|
||||
: "等待首次刷新";
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(new Date()), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="dashboard">
|
||||
<header className="dashboard-header">
|
||||
<Typography.Title level="h1">DiAL</Typography.Title>
|
||||
<Typography.Text theme="secondary">统一拨测平台</Typography.Text>
|
||||
</header>
|
||||
|
||||
{dashboardError && <Alert closeBtn message={`请求失败: ${dashboardError.message}`} theme="error" />}
|
||||
|
||||
{dashboardLoading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<>
|
||||
<SummaryCards summary={dashboard?.summary ?? null} />
|
||||
<TargetBoard onTargetClick={openDrawer} targets={dashboard?.targets ?? []} />
|
||||
</>
|
||||
)}
|
||||
<Layout className="dashboard">
|
||||
<Header>
|
||||
<Menu.HeadMenu
|
||||
logo={
|
||||
<span className="dashboard-brand">
|
||||
<span className="dashboard-logo">DiAL</span>
|
||||
<span className="dashboard-subtitle">统一拨测平台</span>
|
||||
</span>
|
||||
}
|
||||
operations={
|
||||
<span className="dashboard-refresh-status">
|
||||
<Typography.Text className="dashboard-refresh-text" theme="secondary">
|
||||
{refreshText}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</Header>
|
||||
<Content>
|
||||
<div className="dashboard-content">
|
||||
{dashboardError && <Alert closeBtn message={`请求失败: ${dashboardError.message}`} theme="error" />}
|
||||
|
||||
{dashboardLoading ? (
|
||||
<Skeleton animation="gradient" rowCol={DASHBOARD_SKELETON_ROW_COL} />
|
||||
) : (
|
||||
<>
|
||||
<SummaryCards summary={dashboard?.summary ?? null} />
|
||||
<TargetBoard onTargetClick={openDrawer} targets={dashboard?.targets ?? []} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Content>
|
||||
<TargetDetailDrawer
|
||||
historyData={historyData}
|
||||
historyLoading={historyLoading}
|
||||
@@ -53,6 +100,6 @@ export function App() {
|
||||
timeFrom={timeFrom}
|
||||
timeTo={timeTo}
|
||||
/>
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Tag, Typography } from "tdesign-react";
|
||||
|
||||
interface GroupHeaderProps {
|
||||
down: number;
|
||||
name: string;
|
||||
total: number;
|
||||
up: number;
|
||||
}
|
||||
|
||||
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" title="总数" variant="light">
|
||||
{total}
|
||||
</Tag>
|
||||
<Tag theme="success" title="正常" variant="light">
|
||||
{up}
|
||||
</Tag>
|
||||
<Tag theme="danger" title="异常" variant="light">
|
||||
{down}
|
||||
</Tag>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
import { Col, Descriptions, Divider, Row, Skeleton, Space, Statistic } from "tdesign-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Card, Col, Descriptions, Divider, Row, Skeleton, Space, Statistic, Typography } from "tdesign-react";
|
||||
|
||||
import type { TargetMetricsResponse, TargetStatus } from "../../shared/api";
|
||||
|
||||
import { formatDurationUnit } from "../utils/time";
|
||||
import { StatusDonut } from "./StatusDonut";
|
||||
import { TrendChart } from "./TrendChart";
|
||||
|
||||
interface OverviewStatItemProps {
|
||||
color?: string;
|
||||
suffix?: ReactNode;
|
||||
title: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface OverviewTabProps {
|
||||
metricsData: null | TargetMetricsResponse;
|
||||
metricsLoading: boolean;
|
||||
@@ -20,70 +28,6 @@ export function OverviewTab({ metricsData, metricsLoading, target }: OverviewTab
|
||||
|
||||
return (
|
||||
<Space className="full-width" direction="vertical" size={16}>
|
||||
<Divider align="left">统计</Divider>
|
||||
{metricsLoading ? (
|
||||
<Skeleton animation="gradient" />
|
||||
) : stats ? (
|
||||
<Space className="full-width" direction="vertical" size={16}>
|
||||
<Row gutter={16}>
|
||||
<Col span={3}>
|
||||
<Statistic color="green" suffix="%" title="可用率" value={stats.availability} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic
|
||||
suffix={stats.avgDurationMs === null ? "" : "ms"}
|
||||
title="平均延迟"
|
||||
value={stats.avgDurationMs ?? 0}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic
|
||||
suffix={stats.p95DurationMs === null ? "" : "ms"}
|
||||
title="P95 延迟"
|
||||
value={stats.p95DurationMs ?? 0}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic color="blue" title="检查总数" value={stats.totalChecks} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={3}>
|
||||
<Statistic suffix={mttr.suffix} title="MTTR" value={mttr.value} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic suffix={longestOutage.suffix} title="最长故障" value={longestOutage.value} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic color="red" suffix="次" title="故障次数" value={stats.incidentCount} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic color="green" suffix="次" title="连续正常" value={currentUpStreak} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Space>
|
||||
) : (
|
||||
<div className="trend-empty">暂无指标数据</div>
|
||||
)}
|
||||
|
||||
<Divider align="left">趋势</Divider>
|
||||
{metricsLoading ? (
|
||||
<Skeleton animation="gradient" />
|
||||
) : metricsData ? (
|
||||
<TrendChart data={metricsData.trend} />
|
||||
) : (
|
||||
<div className="trend-empty">暂无趋势数据</div>
|
||||
)}
|
||||
|
||||
<Divider align="left">状态分布</Divider>
|
||||
{metricsLoading ? (
|
||||
<Skeleton animation="gradient" />
|
||||
) : stats ? (
|
||||
<StatusDonut down={stats.downChecks} up={stats.upChecks} />
|
||||
) : (
|
||||
<div className="trend-empty">暂无状态数据</div>
|
||||
)}
|
||||
|
||||
<Divider align="left">基本信息</Divider>
|
||||
<Descriptions
|
||||
items={[
|
||||
@@ -96,6 +40,68 @@ export function OverviewTab({ metricsData, metricsLoading, target }: OverviewTab
|
||||
{ content: target.latestCheck?.statusDetail ?? "-", label: "状态详情" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Divider align="left">统计</Divider>
|
||||
{metricsLoading ? (
|
||||
<Skeleton animation="gradient" />
|
||||
) : stats ? (
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={3}>
|
||||
<OverviewStatItem color="green" suffix="%" title="可用率" value={stats.availability} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<OverviewStatItem
|
||||
suffix={stats.avgDurationMs === null ? "" : "ms"}
|
||||
title="平均延迟"
|
||||
value={stats.avgDurationMs ?? 0}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<OverviewStatItem
|
||||
suffix={stats.p95DurationMs === null ? "" : "ms"}
|
||||
title="P95 延迟"
|
||||
value={stats.p95DurationMs ?? 0}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<OverviewStatItem color="blue" title="检查总数" value={stats.totalChecks} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<OverviewStatItem suffix={mttr.suffix} title="MTTR" value={mttr.value} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<OverviewStatItem suffix={longestOutage.suffix} title="最长故障" value={longestOutage.value} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<OverviewStatItem color="red" suffix="次" title="故障次数" value={stats.incidentCount} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<OverviewStatItem color="green" suffix="次" title="连续正常" value={currentUpStreak} />
|
||||
</Col>
|
||||
</Row>
|
||||
) : (
|
||||
<div className="trend-empty">暂无指标数据</div>
|
||||
)}
|
||||
|
||||
<Divider align="left">趋势</Divider>
|
||||
{metricsLoading ? (
|
||||
<Skeleton animation="gradient" />
|
||||
) : metricsData ? (
|
||||
<TrendChart data={metricsData.trend} />
|
||||
) : (
|
||||
<div className="trend-empty">暂无趋势数据</div>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewStatItem({ color, suffix, title, value }: OverviewStatItemProps) {
|
||||
return (
|
||||
<Card bordered={false} className="overview-stat-card" size="small">
|
||||
<div className="overview-stat-item">
|
||||
<Typography.Text theme="secondary">{title}</Typography.Text>
|
||||
<Statistic className="overview-stat-value" color={color} suffix={suffix} value={value} />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Tooltip } from "tdesign-react";
|
||||
|
||||
import type { RecentSample } from "../../shared/api";
|
||||
|
||||
import { formatRelativeTime } from "../utils/time";
|
||||
|
||||
interface StatusBarProps {
|
||||
maxSlots?: number;
|
||||
samples: Array<{ up: boolean }>;
|
||||
samples: RecentSample[];
|
||||
}
|
||||
|
||||
export function StatusBar({ maxSlots = 30, samples }: StatusBarProps) {
|
||||
@@ -9,10 +15,13 @@ export function StatusBar({ maxSlots = 30, samples }: StatusBarProps) {
|
||||
const sample = samples[i];
|
||||
if (sample) {
|
||||
blocks.push(
|
||||
<span
|
||||
className={`status-bar-block ${sample.up ? "status-bar-block--up" : "status-bar-block--down"}`}
|
||||
<Tooltip
|
||||
content={`${formatRelativeTime(sample.timestamp)},${sample.up ? "正常" : "异常"}`}
|
||||
key={i}
|
||||
/>,
|
||||
placement="top"
|
||||
>
|
||||
<span className={`status-bar-block ${sample.up ? "status-bar-block--up" : "status-bar-block--down"}`} />
|
||||
</Tooltip>,
|
||||
);
|
||||
} else {
|
||||
blocks.push(<span className="status-bar-block status-bar-block--empty" key={i} />);
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Cell, Pie, PieChart, ResponsiveContainer } from "recharts";
|
||||
|
||||
interface StatusDonutProps {
|
||||
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({ down, up }: StatusDonutProps) {
|
||||
const total = up + down;
|
||||
const availability = total > 0 ? ((up / total) * 100).toFixed(1) : "-";
|
||||
|
||||
const data =
|
||||
total > 0
|
||||
? [
|
||||
{ name: "UP", value: up },
|
||||
{ name: "DOWN", value: down },
|
||||
]
|
||||
: [{ name: "EMPTY", value: 1 }];
|
||||
|
||||
const colors = total > 0 ? [UP_COLOR, DOWN_COLOR] : [EMPTY_COLOR];
|
||||
|
||||
return (
|
||||
<div className="status-donut">
|
||||
<ResponsiveContainer height={180} width="100%">
|
||||
<PieChart>
|
||||
<Pie cx="50%" cy="50%" data={data} dataKey="value" innerRadius={50} outerRadius={70} stroke="none">
|
||||
{data.map((item, index) => (
|
||||
<Cell fill={colors[index % colors.length]} key={item.name} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="donut-center-label">{total > 0 ? `${availability}%` : "-"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, Col, Row, Statistic, Typography } from "tdesign-react";
|
||||
import { Card, Col, Row, Statistic } from "tdesign-react";
|
||||
|
||||
import type { DashboardResponse } from "../../shared/api";
|
||||
|
||||
import { formatRelativeTime, isOlderThan } from "../utils/time";
|
||||
|
||||
interface SummaryCardsProps {
|
||||
summary: DashboardResponse["summary"] | null;
|
||||
}
|
||||
|
||||
export function SummaryCards({ summary }: SummaryCardsProps) {
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(new Date()), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
if (!summary) return null;
|
||||
|
||||
const cards = [
|
||||
@@ -25,25 +15,18 @@ export function SummaryCards({ summary }: SummaryCardsProps) {
|
||||
{ color: "red" as const, label: "异常", value: summary.down },
|
||||
{ color: "orange" as const, label: `${summary.window.label} 异常事件数`, value: summary.incidents },
|
||||
];
|
||||
const freshnessWarning = isOlderThan(summary.lastCheckTime, 60000, now);
|
||||
|
||||
return (
|
||||
<section className="summary-cards-row">
|
||||
<Row gutter={16}>
|
||||
{cards.map((card) => (
|
||||
<Col key={card.label} span={3}>
|
||||
<Card bordered>
|
||||
<Card bordered={false}>
|
||||
<Row gutter={16}>
|
||||
{cards.map((card) => (
|
||||
<Col className="summary-stat-col" key={card.label} span={3}>
|
||||
<Statistic color={card.color} title={card.label} value={card.value} />
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
<Typography.Text
|
||||
className={freshnessWarning ? "summary-freshness summary-freshness--warning" : "summary-freshness"}
|
||||
theme="secondary"
|
||||
>
|
||||
{summary.lastCheckTime ? `最后更新: ${formatRelativeTime(summary.lastCheckTime, now)}` : "尚无检查数据"}
|
||||
</Typography.Text>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export function TargetBoard({ onTargetClick, targets }: TargetBoardProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<Space className="full-width" direction="vertical" size={32}>
|
||||
<Space className="full-width" direction="vertical" size={24}>
|
||||
{sortedGroups.map(([name, groupTargets]) => (
|
||||
<TargetGroup columns={columns} key={name} name={name} onTargetClick={onTargetClick} targets={groupTargets} />
|
||||
))}
|
||||
|
||||
@@ -85,28 +85,30 @@ export function TargetDetailDrawer({
|
||||
}
|
||||
onClose={onClose}
|
||||
placement="right"
|
||||
size="60%"
|
||||
size="52%"
|
||||
visible={!!target}
|
||||
>
|
||||
<Space className="full-width" direction="vertical" size={16}>
|
||||
<RadioGroup
|
||||
onChange={handleShortcut}
|
||||
options={TIME_SHORTCUTS.map((s) => ({ label: s.label, value: s.value }))}
|
||||
theme="button"
|
||||
value={activeShortcut}
|
||||
variant="default-filled"
|
||||
/>
|
||||
<DateRangePicker
|
||||
className="full-width"
|
||||
defaultTime={["00:00:00", "23:59:00"]}
|
||||
enableTimePicker
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
mode="date"
|
||||
onChange={handleDateRangeChange}
|
||||
timePickerProps={{ format: "HH:mm", steps: [1, 1, 60] }}
|
||||
value={timeFrom && timeTo ? [timeFrom, timeTo] : undefined}
|
||||
valueType="YYYY-MM-DD HH:mm"
|
||||
/>
|
||||
<div className="drawer-time-controls">
|
||||
<RadioGroup
|
||||
onChange={handleShortcut}
|
||||
options={TIME_SHORTCUTS.map((s) => ({ label: s.label, value: s.value }))}
|
||||
theme="button"
|
||||
value={activeShortcut}
|
||||
variant="default-filled"
|
||||
/>
|
||||
<DateRangePicker
|
||||
className="drawer-date-range"
|
||||
defaultTime={["00:00:00", "23:59:00"]}
|
||||
enableTimePicker
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
mode="date"
|
||||
onChange={handleDateRangeChange}
|
||||
timePickerProps={{ format: "HH:mm", steps: [1, 1, 60] }}
|
||||
value={timeFrom && timeTo ? [timeFrom, timeTo] : undefined}
|
||||
valueType="YYYY-MM-DD HH:mm"
|
||||
/>
|
||||
</div>
|
||||
<Tabs onChange={(val: TabValue) => setActiveTab(val)} value={activeTab}>
|
||||
<Tabs.TabPanel className="tab-panel-padded" label="概览" value="overview">
|
||||
<OverviewTab metricsData={metricsData} metricsLoading={metricsLoading} target={target} />
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { PrimaryTableCol } from "tdesign-react";
|
||||
|
||||
import { PrimaryTable } from "tdesign-react";
|
||||
import { Card, PrimaryTable, Space, Tag } from "tdesign-react";
|
||||
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
|
||||
import { GroupHeader } from "./GroupHeader";
|
||||
|
||||
interface TargetGroupProps {
|
||||
columns: Array<PrimaryTableCol<TargetStatus>>;
|
||||
name: string;
|
||||
@@ -16,12 +14,24 @@ interface TargetGroupProps {
|
||||
export function TargetGroup({ columns, name, onTargetClick, targets }: TargetGroupProps) {
|
||||
const up = targets.filter((t) => t.latestCheck?.matched).length;
|
||||
const down = targets.length - up;
|
||||
const displayName = name === "default" ? "默认分组" : name;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<GroupHeader down={down} name={name} total={targets.length} up={up} />
|
||||
<Card
|
||||
actions={
|
||||
<Space size={8}>
|
||||
<Tag theme="success" title="正常" variant="light">
|
||||
{up}
|
||||
</Tag>
|
||||
<Tag theme="danger" title="异常" variant="light">
|
||||
{down}
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
headerBordered
|
||||
title={displayName}
|
||||
>
|
||||
<PrimaryTable
|
||||
bordered
|
||||
className="clickable-table"
|
||||
columns={columns}
|
||||
data={targets}
|
||||
@@ -36,6 +46,6 @@ export function TargetGroup({ columns, name, onTargetClick, targets }: TargetGro
|
||||
size="small"
|
||||
stripe
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,13 +74,13 @@ export function createTargetTableColumns(checkerTypes: string[]): Array<PrimaryT
|
||||
return (
|
||||
<Tag size="small" theme={streak.up ? "success" : "danger"} variant="light">
|
||||
{streak.up ? "▲" : "▼"} {streak.count}
|
||||
{streak.capped ? "+" : "次"}
|
||||
{streak.capped ? "+" : ""}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
colKey: "currentStreak",
|
||||
title: "连续",
|
||||
width: 100,
|
||||
title: "连续(次)",
|
||||
width: 88,
|
||||
},
|
||||
{
|
||||
align: "right",
|
||||
@@ -88,19 +88,14 @@ export function createTargetTableColumns(checkerTypes: string[]): Array<PrimaryT
|
||||
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>;
|
||||
const latencyText = ms > 9999 ? "9999+ms" : `${Math.round(ms)}ms`;
|
||||
return <span className={`${colorClass} latency-value tabular-nums`}>{latencyText}</span>;
|
||||
},
|
||||
colKey: "latestCheck.durationMs",
|
||||
sorter: latencySorter,
|
||||
sortType: "all",
|
||||
title: "延迟",
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
align: "center",
|
||||
colKey: "interval",
|
||||
title: "间隔",
|
||||
width: 72,
|
||||
width: 75,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ const queryKeys = {
|
||||
["metrics", targetId, from, to, bucket] as const,
|
||||
};
|
||||
|
||||
export const DASHBOARD_REFRESH_INTERVAL_MS = 8000;
|
||||
|
||||
export async function fetchJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
@@ -19,7 +21,7 @@ export function useDashboard() {
|
||||
return useQuery({
|
||||
queryFn: () => fetchJson<DashboardResponse>("/api/dashboard?window=24h&recentLimit=30"),
|
||||
queryKey: queryKeys.dashboard(),
|
||||
refetchInterval: 8000,
|
||||
refetchInterval: DASHBOARD_REFRESH_INTERVAL_MS,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,17 +12,44 @@
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
min-height: 100vh;
|
||||
background: var(--td-bg-color-page);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dashboard-content {
|
||||
box-sizing: border-box;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: var(--td-comp-paddingTB-xl) var(--td-comp-paddingLR-xl);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
margin-bottom: var(--td-comp-margin-l);
|
||||
.dashboard-brand {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: var(--td-comp-margin-s);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.dashboard-header .t-typography {
|
||||
.dashboard-logo {
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
color: var(--td-text-color-primary);
|
||||
font-size: calc(var(--td-font-size-title-large) + 6px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dashboard-subtitle {
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: var(--td-font-size-body-medium);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.dashboard-refresh-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-right: var(--td-comp-margin-xxl);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
@@ -69,24 +96,6 @@
|
||||
background: var(--td-bg-color-component-disabled);
|
||||
}
|
||||
|
||||
.status-donut {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.donut-center-label {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -60%);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.trend-chart {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -104,6 +113,7 @@
|
||||
}
|
||||
|
||||
.t-table tr.row-down {
|
||||
border-left: 3px solid var(--td-error-color);
|
||||
background: color-mix(in srgb, var(--td-error-color) 6%, transparent);
|
||||
}
|
||||
|
||||
@@ -131,6 +141,58 @@
|
||||
color: var(--td-error-color);
|
||||
}
|
||||
|
||||
.latency-value {
|
||||
display: inline-block;
|
||||
min-width: 7ch;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.drawer-time-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--td-comp-margin-m);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.drawer-date-range {
|
||||
flex: 1;
|
||||
min-width: 360px;
|
||||
}
|
||||
|
||||
.overview-stat-card {
|
||||
background: var(--td-bg-color-container-hover);
|
||||
}
|
||||
|
||||
.overview-stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--td-comp-margin-m);
|
||||
}
|
||||
|
||||
.overview-stat-value {
|
||||
font-size: var(--td-font-size-body-medium);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.overview-stat-value .t-statistic-content {
|
||||
font-size: var(--td-font-size-body-medium);
|
||||
}
|
||||
|
||||
.summary-stat-col {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.drawer-time-controls {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.drawer-date-range {
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -139,33 +201,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
margin-bottom: var(--td-comp-margin-m);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.group-header .t-typography {
|
||||
margin: 0;
|
||||
font-size: var(--td-font-size-title-medium);
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.summary-cards-row {
|
||||
margin-bottom: var(--td-comp-margin-xl);
|
||||
}
|
||||
|
||||
.summary-freshness {
|
||||
display: block;
|
||||
margin-top: var(--td-comp-margin-s);
|
||||
}
|
||||
|
||||
.summary-freshness--warning {
|
||||
color: var(--td-warning-color);
|
||||
}
|
||||
|
||||
.error-boundary-fallback {
|
||||
padding-top: 20vh;
|
||||
width: 100%;
|
||||
|
||||
Reference in New Issue
Block a user