feat: 重构 Dashboard 为卡片式分组布局
表格布局替换为按分组展示的卡片式布局,新增 group 字段配置和 TargetBoard/TargetCard 等组件。模态框详情页支持时间范围筛选和分页,SummaryCards 减为 3 个。API 端点变更:trend/history 改用 from/to 参数,history 支持分页。recentSampleCount 硬编码为 30。
This commit is contained in:
@@ -1,14 +1,41 @@
|
||||
import { useEffect } from "react";
|
||||
import { useSummary } from "./hooks/useSummary";
|
||||
import { useTargets } from "./hooks/useTargets";
|
||||
import { useTargetDetail } from "./hooks/useTargetDetail";
|
||||
import { SummaryCards } from "./components/SummaryCards";
|
||||
import { TargetTable } from "./components/TargetTable";
|
||||
import { TargetBoard } from "./components/TargetBoard";
|
||||
import { TargetDetailModal } from "./components/TargetDetailModal";
|
||||
|
||||
export function App() {
|
||||
const { data: summary, loading: summaryLoading, error: summaryError } = useSummary();
|
||||
const { data: targets, loading: targetsLoading, error: targetsError } = useTargets();
|
||||
const { data: targets, error: targetsError } = useTargets();
|
||||
const {
|
||||
selectedTarget,
|
||||
trendData,
|
||||
trendLoading,
|
||||
historyData,
|
||||
historyLoading,
|
||||
timeFrom,
|
||||
timeTo,
|
||||
openModal,
|
||||
closeModal,
|
||||
handleTimeChange,
|
||||
handlePageChange,
|
||||
} = useTargetDetail();
|
||||
|
||||
const error = summaryError || targetsError;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTarget) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [selectedTarget]);
|
||||
|
||||
return (
|
||||
<main className="dashboard">
|
||||
<header className="dashboard-header">
|
||||
@@ -19,7 +46,22 @@ export function App() {
|
||||
{error && <div className="error-banner">请求失败: {error},将在下一次轮询周期自动重试</div>}
|
||||
|
||||
<SummaryCards summary={summary} loading={summaryLoading} />
|
||||
<TargetTable targets={targets} loading={targetsLoading} />
|
||||
<TargetBoard targets={targets} onTargetClick={openModal} />
|
||||
|
||||
{selectedTarget && (
|
||||
<TargetDetailModal
|
||||
target={selectedTarget}
|
||||
trendData={trendData}
|
||||
trendLoading={trendLoading}
|
||||
historyData={historyData}
|
||||
historyLoading={historyLoading}
|
||||
timeFrom={timeFrom}
|
||||
timeTo={timeTo}
|
||||
onTimeChange={handleTimeChange}
|
||||
onPageChange={handlePageChange}
|
||||
onClose={closeModal}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
17
src/web/components/CardGrid.tsx
Normal file
17
src/web/components/CardGrid.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
import { TargetCard } from "./TargetCard";
|
||||
|
||||
interface CardGridProps {
|
||||
targets: TargetStatus[];
|
||||
onTargetClick: (target: TargetStatus) => void;
|
||||
}
|
||||
|
||||
export function CardGrid({ targets, onTargetClick }: CardGridProps) {
|
||||
return (
|
||||
<div className="card-grid">
|
||||
{targets.map((target) => (
|
||||
<TargetCard key={target.id} target={target} onClick={() => onTargetClick(target)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
src/web/components/GroupHeader.tsx
Normal file
19
src/web/components/GroupHeader.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
interface GroupHeaderProps {
|
||||
name: string;
|
||||
total: number;
|
||||
up: number;
|
||||
down: number;
|
||||
}
|
||||
|
||||
export function GroupHeader({ name, total, up, down }: GroupHeaderProps) {
|
||||
const displayName = name === "default" ? "默认分组" : name;
|
||||
|
||||
return (
|
||||
<div className="group-header">
|
||||
<h2 className="group-title">{displayName}</h2>
|
||||
<span className="group-stats">
|
||||
({total}个, {up} UP / {down} DOWN)
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
src/web/components/MiniSparkline.tsx
Normal file
25
src/web/components/MiniSparkline.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Line, LineChart, ResponsiveContainer } from "recharts";
|
||||
import type { RecentSample } from "../../shared/api";
|
||||
|
||||
interface MiniSparklineProps {
|
||||
data: RecentSample[];
|
||||
}
|
||||
|
||||
export function MiniSparkline({ data }: MiniSparklineProps) {
|
||||
const chartData = data
|
||||
.filter((s) => s.durationMs !== null)
|
||||
.map((s) => ({ duration: s.durationMs! }))
|
||||
.reverse();
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return <span className="sparkline-empty">-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width={80} height={32}>
|
||||
<LineChart data={chartData}>
|
||||
<Line type="monotone" dataKey="duration" stroke="#356dd2" strokeWidth={1.5} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
47
src/web/components/Pagination.tsx
Normal file
47
src/web/components/Pagination.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
interface PaginationProps {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function Pagination({ page, pageSize, total, onPageChange }: PaginationProps) {
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const pages: number[] = [];
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
const visiblePages = pages.filter((p) => {
|
||||
if (totalPages <= 7) return true;
|
||||
if (p === 1 || p === totalPages) return true;
|
||||
if (Math.abs(p - page) <= 1) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="pagination">
|
||||
<button className="pagination-btn" disabled={page <= 1} onClick={() => onPageChange(page - 1)}>
|
||||
<
|
||||
</button>
|
||||
{visiblePages.map((p, idx) => {
|
||||
const prev = visiblePages[idx - 1];
|
||||
const showEllipsis = prev !== undefined && p - prev > 1;
|
||||
return (
|
||||
<span key={p} className="pagination-items">
|
||||
{showEllipsis && <span className="pagination-ellipsis">...</span>}
|
||||
<button className={`pagination-btn ${p === page ? "active" : ""}`} onClick={() => onPageChange(p)}>
|
||||
{p}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<button className="pagination-btn" disabled={page >= totalPages} onClick={() => onPageChange(page + 1)}>
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Line, LineChart, ResponsiveContainer } from "recharts";
|
||||
|
||||
interface SparklineChartProps {
|
||||
data: Array<{ duration: number }>;
|
||||
}
|
||||
|
||||
export function SparklineChart({ data }: SparklineChartProps) {
|
||||
if (data.length === 0) {
|
||||
return <span className="sparkline-empty">-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width={80} height={32}>
|
||||
<LineChart data={data}>
|
||||
<Line type="monotone" dataKey="duration" stroke="#356dd2" strokeWidth={1.5} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
17
src/web/components/StatusBar.tsx
Normal file
17
src/web/components/StatusBar.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
interface StatusBarProps {
|
||||
samples: Array<{ up: boolean }>;
|
||||
}
|
||||
|
||||
export function StatusBar({ samples }: StatusBarProps) {
|
||||
const blocks = [];
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const sample = samples[i];
|
||||
if (sample) {
|
||||
blocks.push(<span key={i} className={`status-bar-block ${sample.up ? "status-bar-up" : "status-bar-down"}`} />);
|
||||
} else {
|
||||
blocks.push(<span key={i} className="status-bar-block status-bar-empty" />);
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="status-bar">{blocks}</div>;
|
||||
}
|
||||
40
src/web/components/StatusDonut.tsx
Normal file
40
src/web/components/StatusDonut.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer } from "recharts";
|
||||
|
||||
interface StatusDonutProps {
|
||||
up: number;
|
||||
down: number;
|
||||
}
|
||||
|
||||
const UP_COLOR = "#1fbf75";
|
||||
const DOWN_COLOR = "#e5484d";
|
||||
const EMPTY_COLOR = "#e2e8f0";
|
||||
|
||||
export function StatusDonut({ up, down }: 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 width="100%" height={180}>
|
||||
<PieChart>
|
||||
<Pie data={data} cx="50%" cy="50%" innerRadius={50} outerRadius={70} dataKey="value" stroke="none">
|
||||
{data.map((_, index) => (
|
||||
<Cell key={index} fill={colors[index % colors.length]!} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="donut-center-label">{availability}%</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,11 +16,6 @@ export function SummaryCards({ summary, loading }: SummaryCardsProps) {
|
||||
{ label: "全部目标", value: summary.total, className: "card-total" },
|
||||
{ label: "正常", value: summary.up, className: "card-up" },
|
||||
{ label: "异常", value: summary.down, className: "card-down" },
|
||||
{
|
||||
label: "平均耗时",
|
||||
value: summary.avgDurationMs !== null ? `${Math.round(summary.avgDurationMs)}ms` : "-",
|
||||
className: "card-latency",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
28
src/web/components/TargetBoard.tsx
Normal file
28
src/web/components/TargetBoard.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
import { TargetGroup } from "./TargetGroup";
|
||||
|
||||
interface TargetBoardProps {
|
||||
targets: TargetStatus[];
|
||||
onTargetClick: (target: TargetStatus) => void;
|
||||
}
|
||||
|
||||
export function TargetBoard({ targets, onTargetClick }: TargetBoardProps) {
|
||||
const groups = new Map<string, TargetStatus[]>();
|
||||
for (const target of targets) {
|
||||
const group = target.group;
|
||||
const list = groups.get(group);
|
||||
if (list) {
|
||||
list.push(target);
|
||||
} else {
|
||||
groups.set(group, [target]);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="target-board">
|
||||
{Array.from(groups.entries()).map(([name, groupTargets]) => (
|
||||
<TargetGroup key={name} name={name} targets={groupTargets} onTargetClick={onTargetClick} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
src/web/components/TargetCard.tsx
Normal file
27
src/web/components/TargetCard.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
import { StatusDot } from "./StatusDot";
|
||||
import { StatusBar } from "./StatusBar";
|
||||
import { MiniSparkline } from "./MiniSparkline";
|
||||
|
||||
interface TargetCardProps {
|
||||
target: TargetStatus;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function TargetCard({ target, onClick }: TargetCardProps) {
|
||||
const isUp = target.latestCheck?.success && target.latestCheck?.matched;
|
||||
|
||||
return (
|
||||
<div className="target-card" onClick={onClick} role="button" tabIndex={0}>
|
||||
<div className="card-row-1">
|
||||
<StatusDot up={!!isUp} />
|
||||
<span className="card-name">{target.name}</span>
|
||||
<span className="card-type-badge">{target.type === "http" ? "HTTP" : "Command"}</span>
|
||||
</div>
|
||||
<div className="card-row-2">
|
||||
<StatusBar samples={target.recentSamples} />
|
||||
<MiniSparkline data={target.recentSamples} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { CheckResult, TargetStatus } from "../../shared/api";
|
||||
import { useTrend } from "../hooks/useTrend";
|
||||
import { TrendChart } from "./TrendChart";
|
||||
|
||||
interface TargetDetailProps {
|
||||
target: TargetStatus;
|
||||
}
|
||||
|
||||
export function TargetDetail({ target }: TargetDetailProps) {
|
||||
const { data: trendData, loading: trendLoading, fetchTrend } = useTrend(target.id);
|
||||
const [history, setHistory] = useState<CheckResult[]>([]);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
|
||||
const fetchHistory = useCallback(async () => {
|
||||
setHistoryLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/targets/${target.id}/history?limit=10`);
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as CheckResult[];
|
||||
setHistory(data);
|
||||
}
|
||||
} finally {
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
}, [target.id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTrend();
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
fetchHistory();
|
||||
}, [fetchTrend, fetchHistory]);
|
||||
|
||||
const { stats } = target;
|
||||
const isUp = target.latestCheck?.success && target.latestCheck?.matched;
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={6} className="detail-cell">
|
||||
<div className="target-detail">
|
||||
<div className="detail-stats">
|
||||
<div className="detail-stat">
|
||||
<span className="detail-stat-label">状态</span>
|
||||
<span className={`detail-stat-value ${isUp ? "text-up" : "text-down"}`}>{isUp ? "UP" : "DOWN"}</span>
|
||||
</div>
|
||||
<div className="detail-stat">
|
||||
<span className="detail-stat-label">可用率</span>
|
||||
<span className="detail-stat-value">
|
||||
{stats.totalChecks > 0 ? `${stats.availability.toFixed(1)}%` : "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-stat">
|
||||
<span className="detail-stat-label">平均耗时</span>
|
||||
<span className="detail-stat-value">
|
||||
{stats.avgDurationMs !== null ? `${Math.round(stats.avgDurationMs)}ms` : "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-stat">
|
||||
<span className="detail-stat-label">P99 耗时</span>
|
||||
<span className="detail-stat-value">
|
||||
{stats.p99DurationMs !== null ? `${Math.round(stats.p99DurationMs)}ms` : "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-trend">
|
||||
<h4>24 小时趋势</h4>
|
||||
<TrendChart data={trendData} loading={trendLoading} />
|
||||
</div>
|
||||
|
||||
<div className="detail-history">
|
||||
<h4>最近检查记录</h4>
|
||||
{historyLoading ? (
|
||||
<p className="history-empty">加载中...</p>
|
||||
) : history.length > 0 ? (
|
||||
<div className="history-list">
|
||||
{history.map((item, idx) => (
|
||||
<div key={idx} className="history-item">
|
||||
<span className={`history-status ${item.success && item.matched ? "text-up" : "text-down"}`}>
|
||||
{item.success && item.matched ? "UP" : "DOWN"}
|
||||
</span>
|
||||
<span className="history-time">{new Date(item.timestamp).toLocaleString("zh-CN")}</span>
|
||||
{item.statusDetail && <span className="history-code">{item.statusDetail}</span>}
|
||||
{item.durationMs !== null && (
|
||||
<span className="history-latency">{Math.round(item.durationMs)}ms</span>
|
||||
)}
|
||||
{item.failure?.message && <span className="history-error">{item.failure.message}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="history-empty">暂无检查记录</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
121
src/web/components/TargetDetailModal.tsx
Normal file
121
src/web/components/TargetDetailModal.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { CheckResult, TargetStatus, TrendPoint } from "../../shared/api";
|
||||
import { TrendChart } from "./TrendChart";
|
||||
import { StatusDonut } from "./StatusDonut";
|
||||
import { StatusDot } from "./StatusDot";
|
||||
import { TimeRangePicker } from "./TimeRangePicker";
|
||||
import { Pagination } from "./Pagination";
|
||||
|
||||
interface TargetDetailModalProps {
|
||||
target: TargetStatus;
|
||||
trendData: TrendPoint[];
|
||||
trendLoading: boolean;
|
||||
historyData: HistoryData;
|
||||
historyLoading: boolean;
|
||||
timeFrom: string;
|
||||
timeTo: string;
|
||||
onTimeChange: (from: string, to: string) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface HistoryData {
|
||||
items: CheckResult[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export function TargetDetailModal({
|
||||
target,
|
||||
trendData,
|
||||
trendLoading,
|
||||
historyData,
|
||||
historyLoading,
|
||||
timeFrom,
|
||||
timeTo,
|
||||
onTimeChange,
|
||||
onPageChange,
|
||||
onClose,
|
||||
}: TargetDetailModalProps) {
|
||||
const isUp = target.latestCheck?.success && target.latestCheck?.matched;
|
||||
const totalChecks = trendData.reduce((sum, p) => sum + p.totalChecks, 0);
|
||||
const upChecks = trendData.reduce((sum, p) => sum + Math.round((p.availability / 100) * p.totalChecks), 0);
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title-row">
|
||||
<StatusDot up={!!isUp} />
|
||||
<h3 className="modal-title">{target.name}</h3>
|
||||
<span className="card-type-badge">{target.type === "http" ? "HTTP" : "Command"}</span>
|
||||
</div>
|
||||
<button className="modal-close-btn" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TimeRangePicker from={timeFrom} to={timeTo} onChange={onTimeChange} />
|
||||
|
||||
<div className="modal-body">
|
||||
<div className="modal-charts">
|
||||
<div className="modal-chart-section">
|
||||
<h4>状态分布</h4>
|
||||
<StatusDonut up={upChecks} down={totalChecks - upChecks} />
|
||||
</div>
|
||||
<div className="modal-chart-section">
|
||||
<h4>趋势图</h4>
|
||||
<TrendChart data={trendData} loading={trendLoading} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-history-section">
|
||||
<h4>检查记录</h4>
|
||||
{historyLoading ? (
|
||||
<div className="history-loading">加载中...</div>
|
||||
) : historyData.items.length > 0 ? (
|
||||
<>
|
||||
<table className="history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="ht-col-status">状态</th>
|
||||
<th className="ht-col-time">时间</th>
|
||||
<th className="ht-col-detail">详情</th>
|
||||
<th className="ht-col-latency">耗时</th>
|
||||
<th className="ht-col-error">错误信息</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{historyData.items.map((item, idx) => (
|
||||
<tr key={idx}>
|
||||
<td>
|
||||
<span className={`ht-status ${item.success && item.matched ? "text-up" : "text-down"}`}>
|
||||
{item.success && item.matched ? "UP" : "DOWN"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="ht-time">{new Date(item.timestamp).toLocaleString("zh-CN")}</td>
|
||||
<td className="ht-detail">{item.statusDetail ?? "-"}</td>
|
||||
<td className="ht-latency">
|
||||
{item.durationMs !== null ? `${Math.round(item.durationMs)}ms` : "-"}
|
||||
</td>
|
||||
<td className="ht-error">{item.failure?.message ?? ""}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pagination
|
||||
page={historyData.page}
|
||||
pageSize={historyData.pageSize}
|
||||
total={historyData.total}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="history-empty">暂无检查记录</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
src/web/components/TargetGroup.tsx
Normal file
21
src/web/components/TargetGroup.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
import { GroupHeader } from "./GroupHeader";
|
||||
import { CardGrid } from "./CardGrid";
|
||||
|
||||
interface TargetGroupProps {
|
||||
name: string;
|
||||
targets: TargetStatus[];
|
||||
onTargetClick: (target: TargetStatus) => void;
|
||||
}
|
||||
|
||||
export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps) {
|
||||
const up = targets.filter((t) => t.latestCheck?.success && t.latestCheck?.matched).length;
|
||||
const down = targets.length - up;
|
||||
|
||||
return (
|
||||
<div className="target-group">
|
||||
<GroupHeader name={name} total={targets.length} up={up} down={down} />
|
||||
<CardGrid targets={targets} onTargetClick={onTargetClick} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
import { StatusDot } from "./StatusDot";
|
||||
import { SparklineChart } from "./SparklineChart";
|
||||
|
||||
interface TargetRowProps {
|
||||
target: TargetStatus;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export function TargetRow({ target, expanded, onToggle }: TargetRowProps) {
|
||||
const isUp = target.latestCheck?.success && target.latestCheck?.matched;
|
||||
|
||||
const sparklineData = target.sparkline.map((duration) => ({ duration }));
|
||||
|
||||
return (
|
||||
<tr className={`target-row ${expanded ? "expanded" : ""}`} onClick={onToggle}>
|
||||
<td className="col-status">
|
||||
<StatusDot up={!!isUp} />
|
||||
</td>
|
||||
<td className="col-name">{target.name}</td>
|
||||
<td className="col-target">{target.target}</td>
|
||||
<td className="col-type">{target.type === "http" ? "HTTP" : "Command"}</td>
|
||||
<td className="col-duration">
|
||||
{target.latestCheck?.durationMs !== null && target.latestCheck?.durationMs !== undefined
|
||||
? `${Math.round(target.latestCheck.durationMs)}ms`
|
||||
: "-"}
|
||||
</td>
|
||||
<td className="col-sparkline">
|
||||
<SparklineChart data={sparklineData} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
import { TargetRow } from "./TargetRow";
|
||||
import { TargetDetail } from "./TargetDetail";
|
||||
|
||||
interface TargetTableProps {
|
||||
targets: TargetStatus[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function TargetTable({ targets, loading }: TargetTableProps) {
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
|
||||
if (loading && targets.length === 0) {
|
||||
return <div className="table-loading">加载目标列表...</div>;
|
||||
}
|
||||
|
||||
if (targets.length === 0) {
|
||||
return <div className="table-empty">暂无拨测目标</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="target-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="col-status">状态</th>
|
||||
<th className="col-name">名称</th>
|
||||
<th className="col-target">目标</th>
|
||||
<th className="col-type">类型</th>
|
||||
<th className="col-duration">耗时</th>
|
||||
<th className="col-sparkline">趋势</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{targets.map((target) => {
|
||||
const isExpanded = expandedId === target.id;
|
||||
return (
|
||||
<TargetRowWrapper
|
||||
key={target.id}
|
||||
target={target}
|
||||
expanded={isExpanded}
|
||||
onToggle={() => setExpandedId(isExpanded ? null : target.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function TargetRowWrapper({
|
||||
target,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
target: TargetStatus;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<TargetRow target={target} expanded={expanded} onToggle={onToggle} />
|
||||
{expanded && <TargetDetail target={target} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
80
src/web/components/TimeRangePicker.tsx
Normal file
80
src/web/components/TimeRangePicker.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useState } from "react";
|
||||
|
||||
interface TimeRangePickerProps {
|
||||
from: string;
|
||||
to: string;
|
||||
onChange: (from: string, to: string) => void;
|
||||
}
|
||||
|
||||
const SHORTCUTS = [
|
||||
{ label: "1h", hours: 1 },
|
||||
{ label: "6h", hours: 6 },
|
||||
{ label: "24h", hours: 24 },
|
||||
{ label: "7d", hours: 168 },
|
||||
];
|
||||
|
||||
function toLocalDatetimeInput(date: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
function subtractHours(date: Date, hours: number): Date {
|
||||
const result = new Date(date);
|
||||
result.setTime(result.getTime() - hours * 60 * 60 * 1000);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function TimeRangePicker({ from, to, onChange }: TimeRangePickerProps) {
|
||||
const [activeShortcut, setActiveShortcut] = useState<string | null>("24h");
|
||||
|
||||
const handleShortcut = (label: string, hours: number) => {
|
||||
const now = new Date();
|
||||
const newFrom = subtractHours(now, hours);
|
||||
onChange(newFrom.toISOString(), now.toISOString());
|
||||
setActiveShortcut(label);
|
||||
};
|
||||
|
||||
const handleFromChange = (value: string) => {
|
||||
onChange(new Date(value).toISOString(), to);
|
||||
setActiveShortcut(null);
|
||||
};
|
||||
|
||||
const handleToChange = (value: string) => {
|
||||
onChange(from, new Date(value).toISOString());
|
||||
setActiveShortcut(null);
|
||||
};
|
||||
|
||||
const fromDate = new Date(from);
|
||||
const toDate = new Date(to);
|
||||
|
||||
return (
|
||||
<div className="time-range-picker">
|
||||
<div className="time-shortcuts">
|
||||
{SHORTCUTS.map((s) => (
|
||||
<button
|
||||
key={s.label}
|
||||
className={`time-shortcut-btn ${activeShortcut === s.label ? "active" : ""}`}
|
||||
onClick={() => handleShortcut(s.label, s.hours)}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="time-inputs">
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="time-input"
|
||||
value={toLocalDatetimeInput(fromDate)}
|
||||
onChange={(e) => handleFromChange(e.target.value)}
|
||||
/>
|
||||
<span className="time-separator">~</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="time-input"
|
||||
value={toLocalDatetimeInput(toDate)}
|
||||
onChange={(e) => handleToChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
src/web/hooks/useHistory.ts
Normal file
35
src/web/hooks/useHistory.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { HistoryResponse } from "../../shared/api";
|
||||
|
||||
export function useHistory(targetId: number | null) {
|
||||
const [data, setData] = useState<HistoryResponse>({ items: [], total: 0, page: 1, pageSize: 15 });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchHistory = useCallback(
|
||||
async (from: string, to: string, page = 1, pageSize = 15) => {
|
||||
if (targetId === null) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/targets/${targetId}/history?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&page=${page}&pageSize=${pageSize}`,
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const result = (await response.json()) as HistoryResponse;
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[targetId],
|
||||
);
|
||||
|
||||
return { data, error, loading, fetchHistory };
|
||||
}
|
||||
77
src/web/hooks/useTargetDetail.ts
Normal file
77
src/web/hooks/useTargetDetail.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
import { useTrend } from "./useTrend";
|
||||
import { useHistory } from "./useHistory";
|
||||
|
||||
export function useTargetDetail() {
|
||||
const [selectedTarget, setSelectedTarget] = useState<TargetStatus | null>(null);
|
||||
const [timeFrom, setTimeFrom] = useState<string>("");
|
||||
const [timeTo, setTimeTo] = useState<string>("");
|
||||
|
||||
const { data: trendData, loading: trendLoading, fetchTrend } = useTrend(selectedTarget?.id ?? null);
|
||||
const { data: historyData, loading: historyLoading, fetchHistory } = useHistory(selectedTarget?.id ?? null);
|
||||
const initialFetchRef = useRef(false);
|
||||
|
||||
const openModal = useCallback((target: TargetStatus) => {
|
||||
setSelectedTarget(target);
|
||||
const now = new Date();
|
||||
const from = subtractHours(now, 24);
|
||||
setTimeFrom(from.toISOString());
|
||||
setTimeTo(now.toISOString());
|
||||
initialFetchRef.current = false;
|
||||
}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setSelectedTarget(null);
|
||||
initialFetchRef.current = false;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTarget && timeFrom && timeTo && !initialFetchRef.current) {
|
||||
initialFetchRef.current = true;
|
||||
fetchTrend(timeFrom, timeTo);
|
||||
fetchHistory(timeFrom, timeTo);
|
||||
}
|
||||
}, [selectedTarget, timeFrom, timeTo, fetchTrend, fetchHistory]);
|
||||
|
||||
const handleTimeChange = useCallback(
|
||||
(from: string, to: string) => {
|
||||
setTimeFrom(from);
|
||||
setTimeTo(to);
|
||||
if (selectedTarget) {
|
||||
fetchTrend(from, to);
|
||||
fetchHistory(from, to);
|
||||
}
|
||||
},
|
||||
[fetchTrend, fetchHistory, selectedTarget],
|
||||
);
|
||||
|
||||
const handlePageChange = useCallback(
|
||||
(page: number) => {
|
||||
if (timeFrom && timeTo) {
|
||||
fetchHistory(timeFrom, timeTo, page);
|
||||
}
|
||||
},
|
||||
[timeFrom, timeTo, fetchHistory],
|
||||
);
|
||||
|
||||
return {
|
||||
selectedTarget,
|
||||
trendData,
|
||||
trendLoading,
|
||||
historyData,
|
||||
historyLoading,
|
||||
timeFrom,
|
||||
timeTo,
|
||||
openModal,
|
||||
closeModal,
|
||||
handleTimeChange,
|
||||
handlePageChange,
|
||||
};
|
||||
}
|
||||
|
||||
function subtractHours(date: Date, hours: number): Date {
|
||||
const result = new Date(date);
|
||||
result.setTime(result.getTime() - hours * 60 * 60 * 1000);
|
||||
return result;
|
||||
}
|
||||
@@ -6,25 +6,30 @@ export function useTrend(targetId: number | null) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchTrend = useCallback(async () => {
|
||||
if (targetId === null) return;
|
||||
const fetchTrend = useCallback(
|
||||
async (from: string, to: string) => {
|
||||
if (targetId === null) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/targets/${targetId}/trend?hours=24`);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/targets/${targetId}/trend?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`,
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const result = (await response.json()) as TrendPoint[];
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [targetId]);
|
||||
const result = (await response.json()) as TrendPoint[];
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[targetId],
|
||||
);
|
||||
|
||||
return { data, error, loading, fetchTrend };
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ body {
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
max-width: 1100px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px;
|
||||
}
|
||||
@@ -55,7 +55,7 @@ body {
|
||||
|
||||
.summary-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
@@ -88,82 +88,89 @@ body {
|
||||
color: #e5484d;
|
||||
}
|
||||
|
||||
.card-latency .card-value {
|
||||
color: #356dd2;
|
||||
.target-board {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.target-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
.target-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.group-stats {
|
||||
color: #61728a;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.target-card {
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(49, 83, 126, 0.12);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 16px rgba(34, 57, 91, 0.08);
|
||||
}
|
||||
|
||||
.target-table thead th {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: #61728a;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid rgba(49, 83, 126, 0.1);
|
||||
background: rgba(236, 243, 252, 0.5);
|
||||
}
|
||||
|
||||
.target-row {
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
box-shadow: 0 2px 8px rgba(34, 57, 91, 0.06);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
transition:
|
||||
box-shadow 0.15s,
|
||||
transform 0.15s;
|
||||
}
|
||||
|
||||
.target-row:hover {
|
||||
background: rgba(236, 243, 252, 0.6);
|
||||
.target-card:hover {
|
||||
box-shadow: 0 6px 24px rgba(34, 57, 91, 0.14);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.target-row.expanded {
|
||||
background: rgba(236, 243, 252, 0.5);
|
||||
.card-row-1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.target-row td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(49, 83, 126, 0.06);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.col-status {
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.col-name {
|
||||
.card-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.col-target {
|
||||
color: #61728a;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
max-width: 260px;
|
||||
font-size: 0.9rem;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.col-type {
|
||||
width: 80px;
|
||||
text-align: center;
|
||||
.card-type-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(53, 109, 210, 0.1);
|
||||
color: #356dd2;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.col-duration {
|
||||
width: 80px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.col-sparkline {
|
||||
width: 100px;
|
||||
.card-row-2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
@@ -171,6 +178,7 @@ body {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-up {
|
||||
@@ -183,75 +191,288 @@ body {
|
||||
box-shadow: 0 0 0 6px rgba(229, 72, 77, 0.14);
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-bar-block {
|
||||
width: 6px;
|
||||
height: 16px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.status-bar-up {
|
||||
background: #1fbf75;
|
||||
}
|
||||
|
||||
.status-bar-down {
|
||||
background: #e5484d;
|
||||
}
|
||||
|
||||
.status-bar-empty {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.sparkline-empty {
|
||||
color: #94a3b8;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.detail-cell {
|
||||
padding: 0 !important;
|
||||
border-bottom: 1px solid rgba(49, 83, 126, 0.1) !important;
|
||||
background: rgba(240, 246, 252, 0.6);
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(16, 32, 51, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.target-detail {
|
||||
padding: 20px 24px;
|
||||
.modal-content {
|
||||
width: 80vw;
|
||||
max-height: 85vh;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 16px 48px rgba(16, 32, 51, 0.2);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detail-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid rgba(49, 83, 126, 0.1);
|
||||
}
|
||||
|
||||
.detail-stat {
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
.modal-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detail-stat-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: #61728a;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.detail-stat-value {
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-up {
|
||||
color: #1fbf75;
|
||||
.modal-close-btn {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 1.5rem;
|
||||
color: #61728a;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.text-down {
|
||||
color: #e5484d;
|
||||
.modal-close-btn:hover {
|
||||
color: #102033;
|
||||
}
|
||||
|
||||
.detail-trend {
|
||||
margin-bottom: 20px;
|
||||
.time-range-picker {
|
||||
padding: 12px 24px;
|
||||
border-bottom: 1px solid rgba(49, 83, 126, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.detail-trend h4,
|
||||
.detail-history h4 {
|
||||
.time-shortcuts {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.time-shortcut-btn {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid rgba(49, 83, 126, 0.2);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #42546c;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.time-shortcut-btn:hover {
|
||||
background: rgba(53, 109, 210, 0.08);
|
||||
}
|
||||
|
||||
.time-shortcut-btn.active {
|
||||
background: #356dd2;
|
||||
color: #fff;
|
||||
border-color: #356dd2;
|
||||
}
|
||||
|
||||
.time-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.time-input {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid rgba(49, 83, 126, 0.2);
|
||||
border-radius: 6px;
|
||||
font-size: 0.82rem;
|
||||
color: #102033;
|
||||
}
|
||||
|
||||
.time-separator {
|
||||
color: #61728a;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px 24px;
|
||||
gap: 24px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-charts {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.modal-chart-section {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-chart-section h4 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.9rem;
|
||||
color: #42546c;
|
||||
}
|
||||
|
||||
.modal-history-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.modal-history-section h4 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.9rem;
|
||||
color: #42546c;
|
||||
}
|
||||
|
||||
.status-donut {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.donut-center-label {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -60%);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #102033;
|
||||
}
|
||||
|
||||
.trend-chart {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.trend-loading,
|
||||
.trend-empty {
|
||||
.trend-empty,
|
||||
.history-loading,
|
||||
.history-empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.detail-history {
|
||||
margin-top: 16px;
|
||||
.history-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid #d1d9e6;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.history-table thead {
|
||||
background: #f0f4fa;
|
||||
}
|
||||
|
||||
.history-table th {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: #61728a;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
border-bottom: 1px solid #d1d9e6;
|
||||
}
|
||||
|
||||
.history-table td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #e8edf4;
|
||||
}
|
||||
|
||||
.history-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.history-table tbody tr:hover {
|
||||
background: rgba(236, 243, 252, 0.6);
|
||||
}
|
||||
|
||||
.ht-col-status {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.ht-col-time {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.ht-col-detail {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.ht-col-latency {
|
||||
width: 80px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ht-col-error {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.ht-status {
|
||||
font-weight: 700;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.ht-time {
|
||||
color: #61728a;
|
||||
}
|
||||
|
||||
.ht-detail {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
color: #42546c;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.ht-latency {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #356dd2;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ht-error {
|
||||
color: #e5484d;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
@@ -269,6 +490,14 @@ body {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.text-up {
|
||||
color: #1fbf75;
|
||||
}
|
||||
|
||||
.text-down {
|
||||
color: #e5484d;
|
||||
}
|
||||
|
||||
.history-time {
|
||||
color: #61728a;
|
||||
}
|
||||
@@ -288,20 +517,48 @@ body {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.history-empty {
|
||||
color: #94a3b8;
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.table-loading,
|
||||
.table-empty {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
.pagination-btn {
|
||||
border: 1px solid rgba(49, 83, 126, 0.2);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #42546c;
|
||||
padding: 4px 10px;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pagination-btn:hover:not(:disabled) {
|
||||
background: rgba(53, 109, 210, 0.08);
|
||||
}
|
||||
|
||||
.pagination-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.pagination-btn.active {
|
||||
background: #356dd2;
|
||||
color: #fff;
|
||||
border-color: #356dd2;
|
||||
}
|
||||
|
||||
.pagination-items {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pagination-ellipsis {
|
||||
color: #94a3b8;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border: 1px solid rgba(49, 83, 126, 0.12);
|
||||
border-radius: 16px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -313,16 +570,25 @@ body {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.detail-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
.card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.col-type,
|
||||
.col-sparkline {
|
||||
display: none;
|
||||
.target-card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.target-row td {
|
||||
padding: 10px 12px;
|
||||
.modal-content {
|
||||
width: 95vw;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.modal-charts {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.time-range-picker {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user