feat: 将 demo 项目转化为 HTTP 拨测监控工具
新增 YAML 配置解析(Bun 内置 YAML)、SQLite 数据存储(bun:sqlite)、按 interval 分组并发拨测引擎、REST API(/api/summary、/api/targets、/api/targets/:id/history、/api/targets/:id/trend)、React 前端 Dashboard(统计卡片、目标表格、可展开详情面板、recharts 趋势图)。CLI 简化为仅接受配置文件路径。移除 /api/demo 路由和相关 demo 代码。保留 /health、静态资源服务和 SPA fallback。
This commit is contained in:
@@ -1,91 +1,29 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { DemoResponse } from "../shared/api";
|
||||
|
||||
type DemoState =
|
||||
| { status: "loading" }
|
||||
| { status: "success"; data: DemoResponse }
|
||||
| { status: "error"; message: string };
|
||||
import { useSummary } from "./hooks/useSummary";
|
||||
import { useTargets } from "./hooks/useTargets";
|
||||
import { SummaryCards } from "./components/SummaryCards";
|
||||
import { TargetTable } from "./components/TargetTable";
|
||||
|
||||
export function App() {
|
||||
const [demoState, setDemoState] = useState<DemoState>({ status: "loading" });
|
||||
const { data: summary, loading: summaryLoading, error: summaryError } = useSummary();
|
||||
const { data: targets, loading: targetsLoading, error: targetsError } = useTargets();
|
||||
|
||||
useEffect(() => {
|
||||
const abortController = new AbortController();
|
||||
|
||||
async function loadDemo() {
|
||||
try {
|
||||
const response = await fetch("/api/demo", { signal: abortController.signal });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as DemoResponse;
|
||||
setDemoState({ status: "success", data });
|
||||
} catch (error) {
|
||||
if (abortController.signal.aborted) return;
|
||||
|
||||
setDemoState({
|
||||
status: "error",
|
||||
message: error instanceof Error ? error.message : "未知错误",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void loadDemo();
|
||||
|
||||
return () => abortController.abort();
|
||||
}, []);
|
||||
const error = summaryError || targetsError;
|
||||
|
||||
return (
|
||||
<main className="shell">
|
||||
<section className="hero" aria-labelledby="page-title">
|
||||
<p className="eyebrow">Vite + React + Bun</p>
|
||||
<h1 id="page-title">Gateway Checker Demo</h1>
|
||||
<p className="summary">这个页面用于验证前端开发、Bun 后端 API 和单可执行文件打包链路已经跑通。</p>
|
||||
</section>
|
||||
<main className="dashboard">
|
||||
<header className="dashboard-header">
|
||||
<h1>Gateway Checker</h1>
|
||||
<p className="dashboard-subtitle">HTTP 拨测监控面板</p>
|
||||
</header>
|
||||
|
||||
<section className="card" aria-live="polite">
|
||||
<div className="card-header">
|
||||
<span className="status-dot" data-state={demoState.status} />
|
||||
<h2>后端连接状态</h2>
|
||||
{error && (
|
||||
<div className="error-banner">
|
||||
请求失败: {error},将在下一次轮询周期自动重试
|
||||
</div>
|
||||
)}
|
||||
|
||||
{demoState.status === "loading" ? <p>正在请求 /api/demo...</p> : null}
|
||||
|
||||
{demoState.status === "error" ? (
|
||||
<div className="error">
|
||||
<strong>请求失败</strong>
|
||||
<p>{demoState.message}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{demoState.status === "success" ? (
|
||||
<div className="result">
|
||||
<p className="message">{demoState.data.message}</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>运行模式</dt>
|
||||
<dd>{demoState.data.runtime.mode}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Bun 版本</dt>
|
||||
<dd>{demoState.data.runtime.bunVersion}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>平台</dt>
|
||||
<dd>
|
||||
{demoState.data.runtime.platform}/{demoState.data.runtime.arch}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>响应时间</dt>
|
||||
<dd>{demoState.data.runtime.timestamp}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
<SummaryCards summary={summary} loading={summaryLoading} />
|
||||
<TargetTable targets={targets} loading={targetsLoading} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
19
src/web/components/SparklineChart.tsx
Normal file
19
src/web/components/SparklineChart.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Line, LineChart, ResponsiveContainer } from "recharts";
|
||||
|
||||
interface SparklineChartProps {
|
||||
data: Array<{ latency: 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="latency" stroke="#356dd2" strokeWidth={1.5} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
7
src/web/components/StatusDot.tsx
Normal file
7
src/web/components/StatusDot.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
interface StatusDotProps {
|
||||
up: boolean;
|
||||
}
|
||||
|
||||
export function StatusDot({ up }: StatusDotProps) {
|
||||
return <span className={`status-dot ${up ? "status-up" : "status-down"}`} />;
|
||||
}
|
||||
36
src/web/components/SummaryCards.tsx
Normal file
36
src/web/components/SummaryCards.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { SummaryResponse } from "../../shared/api";
|
||||
|
||||
interface SummaryCardsProps {
|
||||
summary: SummaryResponse | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function SummaryCards({ summary, loading }: SummaryCardsProps) {
|
||||
if (loading && !summary) {
|
||||
return <div className="summary-cards">加载中...</div>;
|
||||
}
|
||||
|
||||
if (!summary) return null;
|
||||
|
||||
const cards = [
|
||||
{ label: "全部目标", value: summary.total, className: "card-total" },
|
||||
{ label: "正常", value: summary.up, className: "card-up" },
|
||||
{ label: "异常", value: summary.down, className: "card-down" },
|
||||
{
|
||||
label: "平均延迟",
|
||||
value: summary.avgLatencyMs !== null ? `${Math.round(summary.avgLatencyMs)}ms` : "-",
|
||||
className: "card-latency",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="summary-cards">
|
||||
{cards.map((card) => (
|
||||
<div key={card.className} className={`summary-card ${card.className}`}>
|
||||
<div className="card-value">{card.value}</div>
|
||||
<div className="card-label">{card.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
src/web/components/TargetDetail.tsx
Normal file
106
src/web/components/TargetDetail.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
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(() => {
|
||||
void fetchTrend();
|
||||
void 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.avgLatencyMs !== null ? `${Math.round(stats.avgLatencyMs)}ms` : "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-stat">
|
||||
<span className="detail-stat-label">P99 延迟</span>
|
||||
<span className="detail-stat-value">
|
||||
{stats.p99LatencyMs !== null ? `${Math.round(stats.p99LatencyMs)}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.statusCode && (
|
||||
<span className="history-code">{item.statusCode}</span>
|
||||
)}
|
||||
{item.latencyMs !== null && (
|
||||
<span className="history-latency">{Math.round(item.latencyMs)}ms</span>
|
||||
)}
|
||||
{item.error && (
|
||||
<span className="history-error">{item.error}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="history-empty">暂无检查记录</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
34
src/web/components/TargetRow.tsx
Normal file
34
src/web/components/TargetRow.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
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((latency) => ({ latency }));
|
||||
|
||||
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-url">{target.url}</td>
|
||||
<td className="col-method">{target.method}</td>
|
||||
<td className="col-latency">
|
||||
{target.latestCheck?.latencyMs !== null && target.latestCheck?.latencyMs !== undefined
|
||||
? `${Math.round(target.latestCheck.latencyMs)}ms`
|
||||
: "-"}
|
||||
</td>
|
||||
<td className="col-sparkline">
|
||||
<SparklineChart data={sparklineData} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
66
src/web/components/TargetTable.tsx
Normal file
66
src/web/components/TargetTable.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
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-url">URL</th>
|
||||
<th className="col-method">方法</th>
|
||||
<th className="col-latency">延迟</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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
46
src/web/components/TrendChart.tsx
Normal file
46
src/web/components/TrendChart.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Line, LineChart, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid } from "recharts";
|
||||
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>;
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return <div className="trend-empty">暂无趋势数据</div>;
|
||||
}
|
||||
|
||||
const chartData = data.map((point) => ({
|
||||
...point,
|
||||
hour: point.hour.slice(11, 16),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="trend-chart">
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis dataKey="hour" tick={{ fontSize: 12 }} stroke="#94a3b8" />
|
||||
<YAxis yAxisId="latency" tick={{ fontSize: 12 }} stroke="#94a3b8" label={{ value: "ms", position: "insideTopRight", fontSize: 11 }} />
|
||||
<YAxis yAxisId="availability" orientation="right" domain={[0, 100]} tick={{ fontSize: 12 }} stroke="#94a3b8" label={{ value: "%", position: "insideTopLeft", fontSize: 11 }} />
|
||||
<Tooltip
|
||||
formatter={(value: unknown, name: unknown) => {
|
||||
const num = Number(value);
|
||||
const nameStr = String(name);
|
||||
if (nameStr === "avgLatencyMs") return [`${Math.round(num)}ms`, "平均延迟"];
|
||||
if (nameStr === "availability") return [`${num.toFixed(1)}%`, "可用率"];
|
||||
return [String(value), nameStr];
|
||||
}}
|
||||
/>
|
||||
<Line yAxisId="latency" type="monotone" dataKey="avgLatencyMs" stroke="#356dd2" strokeWidth={2} dot={false} name="avgLatencyMs" />
|
||||
<Line yAxisId="availability" type="monotone" dataKey="availability" stroke="#1fbf75" strokeWidth={2} dot={false} name="availability" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
src/web/hooks/useSummary.ts
Normal file
41
src/web/hooks/useSummary.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { SummaryResponse } from "../../shared/api";
|
||||
|
||||
export function useSummary(intervalMs = 8000) {
|
||||
const [data, setData] = useState<SummaryResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchSummary = useCallback(async () => {
|
||||
try {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const response = await fetch("/api/summary", { signal: controller.signal });
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const result = (await response.json()) as SummaryResponse;
|
||||
setData(result);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
setError(err instanceof Error ? err.message : "请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchSummary();
|
||||
const timer = setInterval(fetchSummary, intervalMs);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [fetchSummary, intervalMs]);
|
||||
|
||||
return { data, error, loading, refresh: fetchSummary };
|
||||
}
|
||||
41
src/web/hooks/useTargets.ts
Normal file
41
src/web/hooks/useTargets.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
|
||||
export function useTargets(intervalMs = 8000) {
|
||||
const [data, setData] = useState<TargetStatus[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchTargets = useCallback(async () => {
|
||||
try {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const response = await fetch("/api/targets", { signal: controller.signal });
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const result = (await response.json()) as TargetStatus[];
|
||||
setData(result);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
setError(err instanceof Error ? err.message : "请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchTargets();
|
||||
const timer = setInterval(fetchTargets, intervalMs);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [fetchTargets, intervalMs]);
|
||||
|
||||
return { data, error, loading, refresh: fetchTargets };
|
||||
}
|
||||
30
src/web/hooks/useTrend.ts
Normal file
30
src/web/hooks/useTrend.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { TrendPoint } from "../../shared/api";
|
||||
|
||||
export function useTrend(targetId: number | null) {
|
||||
const [data, setData] = useState<TrendPoint[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchTrend = useCallback(async () => {
|
||||
if (targetId === null) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/targets/${targetId}/trend?hours=24`);
|
||||
|
||||
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]);
|
||||
|
||||
return { data, error, loading, fetchTrend };
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Gateway Checker Vite React Bun executable demo" />
|
||||
<title>Gateway Checker Demo</title>
|
||||
<meta name="description" content="Gateway Checker - HTTP 拨测监控面板" />
|
||||
<title>Gateway Checker</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -21,155 +21,308 @@ body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(320px, 460px);
|
||||
gap: 32px;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
padding: 56px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(55, 125, 255, 0.18), transparent 34rem),
|
||||
linear-gradient(135deg, #f8fbff 0%, #e3edf7 100%);
|
||||
.dashboard {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
.hero,
|
||||
.card {
|
||||
border: 1px solid rgba(49, 83, 126, 0.14);
|
||||
border-radius: 28px;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
box-shadow: 0 24px 80px rgba(34, 57, 91, 0.16);
|
||||
backdrop-filter: blur(18px);
|
||||
.dashboard-header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 48px;
|
||||
.dashboard-header h1 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 1.75rem;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 18px;
|
||||
color: #356dd2;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
max-width: 760px;
|
||||
margin-bottom: 20px;
|
||||
font-size: clamp(3rem, 8vw, 7rem);
|
||||
line-height: 0.9;
|
||||
letter-spacing: -0.08em;
|
||||
}
|
||||
|
||||
.summary {
|
||||
max-width: 620px;
|
||||
margin-bottom: 0;
|
||||
color: #42546c;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
.dashboard-subtitle {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
color: #61728a;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid rgba(229, 72, 77, 0.25);
|
||||
border-radius: 12px;
|
||||
color: #9f2228;
|
||||
background: rgba(255, 240, 240, 0.8);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.summary-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(49, 83, 126, 0.12);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
box-shadow: 0 4px 16px rgba(34, 57, 91, 0.08);
|
||||
}
|
||||
|
||||
.card-value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.card-label {
|
||||
margin-top: 4px;
|
||||
color: #61728a;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.card-up .card-value {
|
||||
color: #1fbf75;
|
||||
}
|
||||
|
||||
.card-down .card-value {
|
||||
color: #e5484d;
|
||||
}
|
||||
|
||||
.card-latency .card-value {
|
||||
color: #356dd2;
|
||||
}
|
||||
|
||||
.target-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
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 {
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.target-row:hover {
|
||||
background: rgba(236, 243, 252, 0.6);
|
||||
}
|
||||
|
||||
.target-row.expanded {
|
||||
background: rgba(236, 243, 252, 0.5);
|
||||
}
|
||||
|
||||
.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 {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.col-url {
|
||||
color: #61728a;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
max-width: 260px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.col-method {
|
||||
width: 64px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.col-latency {
|
||||
width: 80px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.col-sparkline {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
background: #f5a524;
|
||||
box-shadow: 0 0 0 8px rgba(245, 165, 36, 0.14);
|
||||
}
|
||||
|
||||
.status-dot[data-state="success"] {
|
||||
.status-up {
|
||||
background: #1fbf75;
|
||||
box-shadow: 0 0 0 8px rgba(31, 191, 117, 0.14);
|
||||
box-shadow: 0 0 0 6px rgba(31, 191, 117, 0.14);
|
||||
}
|
||||
|
||||
.status-dot[data-state="error"] {
|
||||
.status-down {
|
||||
background: #e5484d;
|
||||
box-shadow: 0 0 0 8px rgba(229, 72, 77, 0.14);
|
||||
box-shadow: 0 0 0 6px rgba(229, 72, 77, 0.14);
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(229, 72, 77, 0.25);
|
||||
border-radius: 18px;
|
||||
color: #9f2228;
|
||||
background: rgba(255, 240, 240, 0.8);
|
||||
.sparkline-empty {
|
||||
color: #94a3b8;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.error p,
|
||||
.message {
|
||||
margin-bottom: 0;
|
||||
.detail-cell {
|
||||
padding: 0 !important;
|
||||
border-bottom: 1px solid rgba(49, 83, 126, 0.1) !important;
|
||||
background: rgba(240, 246, 252, 0.6);
|
||||
}
|
||||
|
||||
.result {
|
||||
.target-detail {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.detail-stats {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.message {
|
||||
color: #1c3f73;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.6;
|
||||
.detail-stat {
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
dl div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
background: rgba(236, 243, 252, 0.74);
|
||||
}
|
||||
|
||||
dt {
|
||||
.detail-stat-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: #61728a;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.detail-stat-value {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.text-up {
|
||||
color: #1fbf75;
|
||||
}
|
||||
|
||||
.text-down {
|
||||
color: #e5484d;
|
||||
}
|
||||
|
||||
.detail-trend {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.detail-trend h4,
|
||||
.detail-history h4 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.9rem;
|
||||
color: #42546c;
|
||||
}
|
||||
|
||||
.trend-loading,
|
||||
.trend-empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.detail-history {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.history-status {
|
||||
font-weight: 700;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
.history-time {
|
||||
color: #61728a;
|
||||
}
|
||||
|
||||
.history-code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
color: #42546c;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 24px;
|
||||
.history-latency {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #356dd2;
|
||||
}
|
||||
|
||||
.history-error {
|
||||
color: #e5484d;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.history-empty {
|
||||
color: #94a3b8;
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.table-loading,
|
||||
.table-empty {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border: 1px solid rgba(49, 83, 126, 0.12);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.hero,
|
||||
.card {
|
||||
padding: 28px;
|
||||
border-radius: 22px;
|
||||
.summary-cards {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.detail-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.col-method,
|
||||
.col-sparkline {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.target-row td {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user