mirror of
https://github.com/Buriburizaem0n/nezha-dash-v1.git
synced 2026-11-04 01:00:20 +00:00
Compare commits
14
Commits
3dc0830aa5
...
e74143b827
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e74143b827 | ||
|
|
576e230794 | ||
|
|
512ef01b34 | ||
|
|
cc33841ede | ||
|
|
247cbcf5ac | ||
|
|
c59c18de17 | ||
|
|
2a250378fe | ||
|
|
2750d9858a | ||
|
|
d4fe4e5806 | ||
|
|
74a52e8cb0 | ||
|
|
cd070d57da | ||
|
|
58fd74df14 | ||
|
|
3e082beb79 | ||
|
|
203438242a |
No files matched your search
@@ -5,3 +5,28 @@
|
||||
> [!NOTE]
|
||||
> 此项目为 nezha-dash 的官方实现,作为哪吒监控的默认前端,功能上可能与 nezha-dash 有所不同。
|
||||
> https://github.com/hamster1963/nezha-dash
|
||||
|
||||
## 本地调整 UI
|
||||
|
||||
使用 Node.js >= 22、pnpm >= 11,在项目目录运行:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
MOCK_NEZHA_COUNT=6 pnpm dev:mock
|
||||
```
|
||||
|
||||
该命令同时启动本地 mock API(8008 端口)和 Vite,使用仓库 `.cert` 下的 HTTPS 证书。打开终端显示的地址(通常为 `https://localhost:5173`);若浏览器提示本地证书不受信任,允许访问本地开发地址。
|
||||
|
||||
进入 `/server/1`,点击「在线 / Online」标签。在线历史支持 1/7/30 天切换、时间轴提示、在线/离线时段和「展开更多」。数据按当前时间生成,使用 `Asia/Shanghai` 时区;查询窗口是最近 N × 24 小时,因此每日列表通常会包含首尾两个不完整日期。
|
||||
|
||||
| 详情页 | 预览场景 |
|
||||
| --- | --- |
|
||||
| `/server/1`、`/server/6` | 正常在线,包含短暂掉线和跨日会话 |
|
||||
| `/server/2` | 持续在线,在线率 100% |
|
||||
| `/server/3` | 当前离线,包含进行中的离线时段 |
|
||||
| `/server/4` | 无在线历史,在线率 0%,最后活跃显示 N/A |
|
||||
| `/server/5` | 频繁掉线,用于查看密集时段标签和换行 |
|
||||
|
||||
主要样式位于 `src/components/ServerDetailOnlineHistory.tsx` 的 `className`,保存后自动热更新;桌面与手机布局的切换点是 768px。修改 mock 脚本后需重启 `pnpm dev:mock`,按 `Ctrl+C` 可停止前端和 mock 服务。
|
||||
|
||||
在线历史 mock 允许查询最多 30 天数据,无需真实后端或登录。不要同时运行占用 8008 端口的后端;省略 `MOCK_NEZHA_COUNT` 时仍使用原有的 3000 节点规模。
|
||||
@@ -74,8 +74,6 @@
|
||||
<meta name="apple-mobile-web-app-title" content="Nezha Monitoring" />
|
||||
<link rel="apple-touch-icon" href="/android-chrome-192x192.png" />
|
||||
|
||||
<link rel="stylesheet" href="https://fastly.jsdelivr.net/gh/lipis/flag-icons@7.0.0/css/flag-icons.min.css" />
|
||||
<link rel="stylesheet" href="https://fastly.jsdelivr.net/npm/font-logos@1/assets/font-logos.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+7
-6
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "nazha-dash-v2",
|
||||
"private": true,
|
||||
"version": "2.0.2",
|
||||
"version": "2.5.1",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22",
|
||||
@@ -52,6 +52,7 @@
|
||||
"country-flag-icons": "1.6.17",
|
||||
"d3-geo": "3.1.1",
|
||||
"dayjs": "1.11.21",
|
||||
"flag-icons": "7.0.0",
|
||||
"i18n-iso-countries": "7.14.0",
|
||||
"i18next": "26.3.1",
|
||||
"lucide-react": "1.17.0",
|
||||
@@ -59,7 +60,7 @@
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"react-i18next": "17.0.8",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"react-router-dom": "^7.18.4",
|
||||
"recharts": "3.8.1",
|
||||
"sonner": "2.0.7",
|
||||
"tailwind-merge": "3.6.0",
|
||||
@@ -75,14 +76,14 @@
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"@vitest/coverage-v8": "^4.1.12",
|
||||
"globals": "17.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "8.5.15",
|
||||
"postcss": "8.5.31",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.1.3",
|
||||
"vitest": "^4.1.9",
|
||||
"vite": "^8.1.6",
|
||||
"vitest": "^4.1.12",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"packageManager": "pnpm@11.5.1+sha512.93f7b57422ea7068257235b4c16eb60762eb68e1dc23723199cc739043ea9be2c4143274a399d8c6defa2b1176226d9ca1c4b63482d6200c1a8fbaa78c1d1485"
|
||||
|
||||
Generated
+390
-258
File diff suppressed because it is too large.
Load diff
@@ -7,6 +7,13 @@ const serverCount = Number(process.env.MOCK_NEZHA_COUNT || 3000);
|
||||
const updateIntervalMs = Number(
|
||||
process.env.MOCK_NEZHA_INTERVAL_MS || (serverCount > 10_000 ? 0 : 2000),
|
||||
);
|
||||
const dayMs = 86_400_000;
|
||||
const minuteMs = 60_000;
|
||||
const timezoneOffsetMs = 8 * 60 * minuteMs;
|
||||
const startedAt = Math.floor(Date.now() / 1000) * 1000;
|
||||
|
||||
// 前几个节点提供固定 UI 场景;其余节点保留每 17 个离线一个的规则。
|
||||
const isServerOffline = (id) => id === 3 || id === 4 || id % 17 === 0;
|
||||
|
||||
const countries = [
|
||||
"US",
|
||||
@@ -40,14 +47,16 @@ function createServer(index) {
|
||||
const memUsed = memTotal * ((index % 90) / 100);
|
||||
const diskUsed = diskTotal * ((index % 84) / 100);
|
||||
const swapUsed = swapTotal ? swapTotal * ((index % 60) / 100) : 0;
|
||||
const isOffline = id % 17 === 0;
|
||||
const isOffline = isServerOffline(id);
|
||||
|
||||
return {
|
||||
id,
|
||||
name: `mock-server-${String(id).padStart(4, "0")}`,
|
||||
public_note: id % 19 === 0 ? "[[Plan]] bandwidth=1Gbps" : "",
|
||||
last_active: isOffline
|
||||
? new Date(Date.now() - 10 * 60 * 1000).toISOString()
|
||||
? new Date(
|
||||
startedAt - (id === 4 ? 31 * dayMs : 10 * minuteMs),
|
||||
).toISOString()
|
||||
: nowIso(),
|
||||
country_code: countries[index % countries.length],
|
||||
host: {
|
||||
@@ -87,14 +96,16 @@ function createServer(index) {
|
||||
const servers = Array.from({ length: serverCount }, (_, index) =>
|
||||
createServer(index),
|
||||
);
|
||||
let onlineCount = servers.filter((server) => server.id % 17 !== 0).length;
|
||||
let onlineCount = servers.filter(
|
||||
(server) => !isServerOffline(server.id),
|
||||
).length;
|
||||
|
||||
function updateServerMetrics() {
|
||||
const now = Date.now();
|
||||
let nextOnlineCount = 0;
|
||||
|
||||
for (const server of servers) {
|
||||
const isOffline = server.id % 17 === 0;
|
||||
const isOffline = isServerOffline(server.id);
|
||||
if (!isOffline) {
|
||||
server.last_active = new Date(now).toISOString();
|
||||
nextOnlineCount += 1;
|
||||
@@ -126,8 +137,8 @@ function websocketPayload() {
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
function jsonResponse(response, data) {
|
||||
response.writeHead(200, {
|
||||
function jsonResponse(response, data, statusCode = 200) {
|
||||
response.writeHead(statusCode, {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
@@ -135,6 +146,92 @@ function jsonResponse(response, data) {
|
||||
response.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
function createOnlineHistory(server, days) {
|
||||
const windowEnd = Math.floor(Date.now() / 1000) * 1000;
|
||||
const windowStart = windowEnd - days * dayMs;
|
||||
const firstDay =
|
||||
Math.floor((windowStart + timezoneOffsetMs) / dayMs) * dayMs -
|
||||
timezoneOffsetMs;
|
||||
const currentlyOnline = !isServerOffline(server.id);
|
||||
const onlineEnd = currentlyOnline
|
||||
? windowEnd
|
||||
: Date.parse(server.last_active);
|
||||
const periods = [];
|
||||
const daily = [];
|
||||
|
||||
const addPeriod = (start, end) => {
|
||||
if (end <= start) return;
|
||||
periods.push({
|
||||
start: new Date(start).toISOString(),
|
||||
end: new Date(end).toISOString(),
|
||||
ongoing: currentlyOnline && end === windowEnd,
|
||||
});
|
||||
};
|
||||
|
||||
// 1/6: 日常短暂掉线;2: 持续在线;3: 当前离线;4: 无历史;5: 密集短时段。
|
||||
// 先生成离线间隙,再取补集,跨午夜的在线会话保持连续。
|
||||
if (server.id !== 4) {
|
||||
let cursor = windowStart;
|
||||
for (let day = firstDay; day < onlineEnd; day += dayMs) {
|
||||
if (server.id === 2) continue;
|
||||
const dayIndex = Math.floor((day + timezoneOffsetMs) / dayMs);
|
||||
const hours =
|
||||
server.id === 5
|
||||
? [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22]
|
||||
: [2, 8, 15];
|
||||
for (const hour of hours) {
|
||||
const gapStart = Math.max(windowStart, day + hour * 60 * minuteMs);
|
||||
const gapMinutes =
|
||||
server.id === 5 ? 35 : 10 + ((dayIndex + server.id + hour) % 6) * 13;
|
||||
// 最后活跃前半小时保持在线,让时段终点与 WebSocket 状态一致。
|
||||
const gapEnd = Math.min(
|
||||
day + (hour * 60 + gapMinutes) * minuteMs,
|
||||
onlineEnd - 30 * minuteMs,
|
||||
);
|
||||
if (gapEnd <= gapStart) continue;
|
||||
addPeriod(cursor, gapStart);
|
||||
cursor = gapEnd;
|
||||
}
|
||||
}
|
||||
addPeriod(cursor, onlineEnd);
|
||||
}
|
||||
|
||||
// 每日数字和概要都从同一组时段计算,避免时间轴与统计不一致。
|
||||
for (let day = firstDay; day < windowEnd; day += dayMs) {
|
||||
const onlineSeconds = periods.reduce((total, period) => {
|
||||
const start = Math.max(day, Date.parse(period.start));
|
||||
const end = Math.min(day + dayMs, Date.parse(period.end));
|
||||
return total + Math.max(0, (end - start) / 1000);
|
||||
}, 0);
|
||||
daily.push({
|
||||
date: new Date(day + timezoneOffsetMs).toISOString().slice(0, 10),
|
||||
online_seconds: onlineSeconds,
|
||||
});
|
||||
}
|
||||
const totalOnlineSeconds = daily.reduce(
|
||||
(total, day) => total + day.online_seconds,
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
server_id: server.id,
|
||||
server_name: server.name,
|
||||
days,
|
||||
max_days: 30,
|
||||
timezone: "Asia/Shanghai",
|
||||
window_start: new Date(windowStart).toISOString(),
|
||||
window_end: new Date(windowEnd).toISOString(),
|
||||
total_online_seconds: totalOnlineSeconds,
|
||||
online_rate:
|
||||
(totalOnlineSeconds / ((windowEnd - windowStart) / 1000)) * 100,
|
||||
currently_online: currentlyOnline,
|
||||
last_active:
|
||||
server.id === 4 ? undefined : new Date(onlineEnd).toISOString(),
|
||||
periods,
|
||||
daily,
|
||||
};
|
||||
}
|
||||
|
||||
const populatedServerGroups = [
|
||||
{
|
||||
group: {
|
||||
@@ -211,6 +308,35 @@ const httpServer = http.createServer((request, response) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const onlineHistoryMatch = requestUrl.pathname.match(
|
||||
/^\/api\/v1\/server\/(\d+)\/online-history$/,
|
||||
);
|
||||
if (onlineHistoryMatch) {
|
||||
const server = servers[Number(onlineHistoryMatch[1]) - 1];
|
||||
const days = Number(requestUrl.searchParams.get("days") ?? 7);
|
||||
if (!server) {
|
||||
jsonResponse(
|
||||
response,
|
||||
{ success: false, error: "server not found" },
|
||||
404,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(days) || days < 1 || days > 30) {
|
||||
jsonResponse(
|
||||
response,
|
||||
{ success: false, error: "days must be an integer between 1 and 30" },
|
||||
400,
|
||||
);
|
||||
return;
|
||||
}
|
||||
jsonResponse(response, {
|
||||
success: true,
|
||||
data: createOnlineHistory(server, days),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
switch (requestUrl.pathname) {
|
||||
case "/api/v1/setting":
|
||||
jsonResponse(response, {
|
||||
|
||||
@@ -12,7 +12,6 @@ import { useWebSocketContext } from "@/hooks/use-websocket-context";
|
||||
import { fetchLoginUser, fetchSetting } from "@/lib/nezha-api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import AnimateCountClient from "./AnimatedCount";
|
||||
import { LanguageSwitcher } from "./LanguageSwitcher";
|
||||
import { Loader, LoadingSpinner } from "./loading/Loader";
|
||||
import NumericText from "./NumericText";
|
||||
@@ -355,12 +354,18 @@ function Overview() {
|
||||
<p className="text-sm font-medium opacity-50">{t("whereTheTimeIs")}</p>
|
||||
{mounted ? (
|
||||
<div className="flex items-center font-medium text-sm">
|
||||
<AnimateCountClient count={time.hh} minDigits={2} />
|
||||
<span className="tabular-nums">
|
||||
{String(time.hh).padStart(2, "0")}
|
||||
</span>
|
||||
<span className="mb-px font-medium text-sm opacity-50">:</span>
|
||||
<AnimateCountClient count={time.mm} minDigits={2} />
|
||||
<span className="tabular-nums">
|
||||
{String(time.mm).padStart(2, "0")}
|
||||
</span>
|
||||
<span className="mb-px font-medium text-sm opacity-50">:</span>
|
||||
<span className="font-medium text-sm">
|
||||
<AnimateCountClient count={time.ss} minDigits={2} />
|
||||
<span className="tabular-nums">
|
||||
{String(time.ss).padStart(2, "0")}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -34,6 +34,13 @@ export function LanguageSwitcher() {
|
||||
{ name: t("language.es-ES"), code: "es-ES" },
|
||||
{ name: t("language.de-DE"), code: "de-DE" },
|
||||
{ name: t("language.ta-IN"), code: "ta-IN" },
|
||||
{ name: t("language.fr"), code: "fr" },
|
||||
{ name: t("language.gl"), code: "gl" },
|
||||
{ name: t("language.id"), code: "id" },
|
||||
{ name: t("language.ja"), code: "ja" },
|
||||
{ name: t("language.pt_BR"), code: "pt_BR" },
|
||||
{ name: t("language.ro"), code: "ro" },
|
||||
{ name: t("language.uk"), code: "uk" },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -274,6 +274,8 @@ export default function ServerDetailChart({
|
||||
|
||||
const gpuStats = server.state.gpu || [];
|
||||
const gpuList = server.host.gpu || [];
|
||||
// Newer agents also report memory per card, index-aligned with the above.
|
||||
const gpuDetails = server.state.gpus || [];
|
||||
|
||||
return (
|
||||
<section className="flex flex-col">
|
||||
@@ -298,6 +300,7 @@ export default function ServerDetailChart({
|
||||
now={nezhaWsData.now}
|
||||
gpuStat={gpuStats[index]}
|
||||
gpuName={gpu}
|
||||
gpuMemory={gpuDetails[index]}
|
||||
messageHistory={messageHistory}
|
||||
period={selectedPeriod}
|
||||
key={index}
|
||||
@@ -311,6 +314,7 @@ export default function ServerDetailChart({
|
||||
now={nezhaWsData.now}
|
||||
gpuStat={gpu}
|
||||
gpuName={`#${index + 1}`}
|
||||
gpuMemory={gpuDetails[index]}
|
||||
messageHistory={messageHistory}
|
||||
period={selectedPeriod}
|
||||
key={index}
|
||||
@@ -426,6 +430,7 @@ function GpuChart({
|
||||
index,
|
||||
gpuStat,
|
||||
gpuName,
|
||||
gpuMemory,
|
||||
messageHistory,
|
||||
period,
|
||||
}: {
|
||||
@@ -434,6 +439,7 @@ function GpuChart({
|
||||
index: number;
|
||||
gpuStat: number;
|
||||
gpuName?: string;
|
||||
gpuMemory?: { memory_used?: number; memory_total?: number };
|
||||
messageHistory: NezhaWebsocketResponse[];
|
||||
period: ChartPeriod;
|
||||
}) {
|
||||
@@ -532,6 +538,14 @@ function GpuChart({
|
||||
<section className="flex flex-col items-center gap-2">
|
||||
{!gpuName && <p className="text-md font-medium">GPU</p>}
|
||||
{gpuName && <p className="text-xs mt-1 mb-1.5">GPU: {gpuName}</p>}
|
||||
{gpuMemory?.memory_total ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{gpuMemory.memory_used != null
|
||||
? formatBytes(gpuMemory.memory_used * 1024 * 1024)
|
||||
: "—"}{" "}
|
||||
/ {formatBytes(gpuMemory.memory_total * 1024 * 1024)}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
<section className="flex items-center gap-2">
|
||||
<p className="text-xs text-end w-10 font-medium">
|
||||
|
||||
@@ -0,0 +1,784 @@
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import OnlineHistorySkeleton from "@/components/loading/OnlineHistorySkeleton";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useActiveIndicator } from "@/hooks/use-active-indicator";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { useWebSocketContext } from "@/hooks/use-websocket-context";
|
||||
import { fetchServerOnlineHistory } from "@/lib/nezha-api";
|
||||
import {
|
||||
buildDailyOnlineRows,
|
||||
buildOfflineGroups,
|
||||
formatDuration,
|
||||
formatPeriodMoment,
|
||||
groupOnlineRows,
|
||||
panelDay,
|
||||
panelNow,
|
||||
} from "@/lib/online-history";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DAY_OPTIONS = [1, 7, 30];
|
||||
// Online periods 默认展开最近 3 天,每次「展开更多」追加 10 天
|
||||
const PERIODS_INITIAL_DAYS = 3;
|
||||
const PERIODS_EXPAND_STEP = 10;
|
||||
|
||||
type PeriodMode = "online" | "offline";
|
||||
|
||||
// 与详情页一致的轻量指标标签。
|
||||
function SummaryLabel({ children }: { children: ReactNode }) {
|
||||
return <span className="text-xs text-muted-foreground">{children}</span>;
|
||||
}
|
||||
|
||||
// 四项指标共用行高,辅助信息在底部对齐。
|
||||
function SummaryMetric({
|
||||
label,
|
||||
children,
|
||||
footer,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
children: ReactNode;
|
||||
footer: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col items-center gap-2 px-3 text-center lg:px-5">
|
||||
<span className="text-xs leading-4 text-muted-foreground">{label}</span>
|
||||
<div className="text-lg font-medium leading-7 tracking-tight tabular-nums text-foreground lg:text-[22px]">
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex min-h-4 items-center justify-center text-[11px] leading-4 text-muted-foreground tabular-nums">
|
||||
{footer}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 概要卡的信息盒:标签在上、数值在下
|
||||
function SummaryBox({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col items-center justify-center gap-1.5 text-center">
|
||||
<SummaryLabel>{label}</SummaryLabel>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WindowShade({
|
||||
left,
|
||||
width,
|
||||
label,
|
||||
striped = false,
|
||||
}: {
|
||||
left: number;
|
||||
width: number;
|
||||
label: string;
|
||||
striped?: boolean;
|
||||
}) {
|
||||
if (width <= 0) return null;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
data-testid={striped ? "outside-window" : "unrecorded-window"}
|
||||
className={cn(
|
||||
"absolute inset-y-0 text-stone-300 focus-visible:outline-2 focus-visible:outline-ring dark:text-stone-600",
|
||||
!striped && "bg-background/80",
|
||||
)}
|
||||
style={{
|
||||
left: `${left}%`,
|
||||
width: `${width}%`,
|
||||
backgroundImage: striped
|
||||
? "repeating-linear-gradient(135deg, transparent, transparent 3px, currentColor 3px, currentColor 5px)"
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function DaysSelector({
|
||||
selectedDays,
|
||||
maxDays,
|
||||
onDaysChange,
|
||||
}: {
|
||||
selectedDays: number;
|
||||
maxDays: number;
|
||||
onDaysChange: (days: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { containerRef, enableIndicatorAnimation, indicator, setItemRef } =
|
||||
useActiveIndicator<number>(DAY_OPTIONS, selectedDays);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-testid="days-selector"
|
||||
className="relative flex gap-0.5 flex-wrap p-0.5 bg-muted dark:bg-muted/40 rounded-full w-fit border border-border/60 dark:border-border"
|
||||
>
|
||||
{indicator && (
|
||||
<div
|
||||
className="active-indicator-fade-in absolute left-0 top-0 z-10 bg-white dark:bg-background rounded-full ring-1 ring-border/60 dark:ring-border/40"
|
||||
style={{
|
||||
height: indicator.height,
|
||||
transform: `translate(${indicator.x}px, ${indicator.y}px)`,
|
||||
transition: indicator.shouldAnimate
|
||||
? "transform 0.5s var(--timing), width 0.5s var(--timing), height 0.5s var(--timing)"
|
||||
: "none",
|
||||
width: indicator.width,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{DAY_OPTIONS.map((days, index) => {
|
||||
const isLocked = days > maxDays;
|
||||
return (
|
||||
<div
|
||||
key={days}
|
||||
ref={setItemRef(index)}
|
||||
onClick={() => {
|
||||
if (!isLocked) {
|
||||
if (selectedDays !== days) {
|
||||
enableIndicatorAnimation();
|
||||
}
|
||||
onDaysChange(days);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"relative z-20 cursor-pointer rounded-full px-3 py-1.5 text-xs font-medium transition-colors duration-300",
|
||||
selectedDays === days
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
isLocked && "cursor-not-allowed opacity-40 grayscale",
|
||||
)}
|
||||
>
|
||||
<p className="whitespace-nowrap">
|
||||
{t(`serverOnlineHistory.days${days}`)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PeriodsModeSwitch({
|
||||
mode,
|
||||
onModeChange,
|
||||
}: {
|
||||
mode: PeriodMode;
|
||||
onModeChange: (mode: PeriodMode) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const options: PeriodMode[] = ["online", "offline"];
|
||||
const { containerRef, enableIndicatorAnimation, indicator, setItemRef } =
|
||||
useActiveIndicator(options, mode);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-testid="period-mode-switch"
|
||||
className="relative ml-auto -mt-1 -mr-2 flex self-start gap-0.5 p-0.5 bg-muted dark:bg-muted/40 rounded-[8px] w-fit border border-border/60 dark:border-border sm:-mr-3"
|
||||
>
|
||||
{indicator && (
|
||||
<div
|
||||
className="active-indicator-fade-in absolute left-0 top-0 z-10 bg-white dark:bg-background rounded-[5px] ring-1 ring-border/60 dark:ring-border/40"
|
||||
style={{
|
||||
height: indicator.height,
|
||||
transform: `translate(${indicator.x}px, ${indicator.y}px)`,
|
||||
transition: indicator.shouldAnimate
|
||||
? "transform 0.5s var(--timing), width 0.5s var(--timing), height 0.5s var(--timing)"
|
||||
: "none",
|
||||
width: indicator.width,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{options.map((option, index) => (
|
||||
<div
|
||||
key={option}
|
||||
ref={setItemRef(index)}
|
||||
onClick={() => {
|
||||
if (mode !== option) {
|
||||
enableIndicatorAnimation();
|
||||
onModeChange(option);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"relative z-20 cursor-pointer rounded-[5px] px-2.5 py-1 text-xs font-medium transition-colors duration-300",
|
||||
mode === option
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<p className="whitespace-nowrap">
|
||||
{t(
|
||||
option === "online"
|
||||
? "serverOnlineHistory.periodsOnline"
|
||||
: "serverOnlineHistory.periodsOffline",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ServerDetailOnlineHistory({
|
||||
server_id,
|
||||
}: {
|
||||
server_id: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { lastData } = useWebSocketContext();
|
||||
const [days, setDays] = useState(DAY_OPTIONS[0]);
|
||||
const [guestMaxDays, setGuestMaxDays] = useState<number | null>(null);
|
||||
const [periodMode, setPeriodMode] = useState<PeriodMode>("online");
|
||||
const [visibleDays, setVisibleDays] = useState(PERIODS_INITIAL_DAYS);
|
||||
// 桌面为四列,手机为两行指标,两端统一使用在线率进度条。
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
const {
|
||||
data: historyData,
|
||||
isError,
|
||||
isPending,
|
||||
error: queryError,
|
||||
} = useQuery({
|
||||
queryKey: ["online-history", server_id, days],
|
||||
queryFn: () => fetchServerOnlineHistory(Number(server_id), days),
|
||||
placeholderData: keepPreviousData,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchInterval: 60000,
|
||||
});
|
||||
|
||||
// 游客仅能查 1 天:服务端拒绝 days>1 时自动回退到 1d 并锁定更长窗口,
|
||||
// 避免游客首屏只看到登录提示
|
||||
useEffect(() => {
|
||||
if (
|
||||
guestMaxDays === null &&
|
||||
queryError instanceof Error &&
|
||||
queryError.message.includes("only 1d data available for guests")
|
||||
) {
|
||||
setGuestMaxDays(1);
|
||||
setDays(1);
|
||||
}
|
||||
}, [guestMaxDays, queryError]);
|
||||
|
||||
const customBackgroundImage =
|
||||
(window.CustomBackgroundImage as string) !== ""
|
||||
? window.CustomBackgroundImage
|
||||
: undefined;
|
||||
|
||||
const online = historyData?.data;
|
||||
const tz = online?.timezone;
|
||||
const dailyRows = useMemo(
|
||||
() =>
|
||||
online
|
||||
? buildDailyOnlineRows(
|
||||
online.periods,
|
||||
online.daily,
|
||||
online.window_start,
|
||||
online.window_end,
|
||||
tz,
|
||||
)
|
||||
: [],
|
||||
[online, tz],
|
||||
);
|
||||
const periodGroups = useMemo(() => {
|
||||
if (!online) return [];
|
||||
return periodMode === "online"
|
||||
? groupOnlineRows(dailyRows, tz)
|
||||
: buildOfflineGroups(
|
||||
online.periods,
|
||||
online.daily,
|
||||
online.window_start,
|
||||
online.window_end,
|
||||
online.currently_online,
|
||||
tz,
|
||||
);
|
||||
}, [online, dailyRows, periodMode, tz]);
|
||||
// 最后活跃属于节点实时状态,不随 1/7/30 天查询缓存切换。
|
||||
// 历史尚无活跃记录时继续展示 N/A,实时数据不可用时回退到接口值。
|
||||
const lastActive = online?.last_active
|
||||
? lastData?.servers.find((server) => server.id === Number(server_id))
|
||||
?.last_active || online.last_active
|
||||
: undefined;
|
||||
// 使用接口返回的实际窗口,兼容不足整天的记录和时区切换。
|
||||
const windowSeconds = online
|
||||
? panelDay(online.window_end, tz).diff(
|
||||
panelDay(online.window_start, tz),
|
||||
"second",
|
||||
)
|
||||
: 0;
|
||||
const offlineSeconds = Math.max(
|
||||
0,
|
||||
windowSeconds - (online?.total_online_seconds ?? 0),
|
||||
);
|
||||
const windowRange = online
|
||||
? `${panelDay(online.window_start, tz).format("MM-DD")} – ${panelDay(online.window_end, tz).format("MM-DD")}`
|
||||
: "";
|
||||
|
||||
if (isPending) {
|
||||
return <OnlineHistorySkeleton days={days} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
{online && (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 text-xs font-medium md:px-1",
|
||||
online.currently_online
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"size-2 rounded-full",
|
||||
online.currently_online
|
||||
? "bg-green-500"
|
||||
: "bg-stone-400 dark:bg-stone-500",
|
||||
)}
|
||||
/>
|
||||
{online.currently_online ? t("online") : t("offline")}
|
||||
</span>
|
||||
)}
|
||||
<DaysSelector
|
||||
selectedDays={days}
|
||||
maxDays={guestMaxDays ?? online?.max_days ?? DAY_OPTIONS[2]}
|
||||
onDaysChange={(nextDays) => {
|
||||
setDays(nextDays);
|
||||
setVisibleDays(PERIODS_INITIAL_DAYS);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Card className="border-0 bg-transparent shadow-none ring-0 dark:shadow-none dark:ring-0">
|
||||
<CardContent className="px-0 py-2 md:px-6 md:py-4">
|
||||
{isError && !online ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("serverOnlineHistory.loginRequired")}
|
||||
</p>
|
||||
) : (
|
||||
online &&
|
||||
(isDesktop ? (
|
||||
<div
|
||||
className="grid grid-cols-4 divide-x divide-stone-100 dark:divide-stone-800"
|
||||
data-testid="summary-desktop"
|
||||
>
|
||||
<SummaryMetric
|
||||
label={t("serverOnlineHistory.onlineRate")}
|
||||
footer={
|
||||
<div className="h-1 w-20 overflow-hidden rounded-full bg-stone-100 dark:bg-stone-800">
|
||||
<div
|
||||
className="h-full rounded-full bg-green-500"
|
||||
style={{
|
||||
width: `${Math.min(100, Math.max(0, online.online_rate))}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{online.online_rate.toFixed(1)}%
|
||||
</SummaryMetric>
|
||||
<SummaryMetric
|
||||
label={t("serverOnlineHistory.totalOnline")}
|
||||
footer={windowRange}
|
||||
>
|
||||
{formatDuration(online.total_online_seconds)}
|
||||
</SummaryMetric>
|
||||
<SummaryMetric
|
||||
label={t("serverOnlineHistory.totalOffline")}
|
||||
footer={windowRange}
|
||||
>
|
||||
{formatDuration(offlineSeconds)}
|
||||
</SummaryMetric>
|
||||
<SummaryMetric
|
||||
label={t("serverOnlineHistory.lastActive")}
|
||||
footer={
|
||||
lastActive
|
||||
? panelDay(lastActive, tz).format("YYYY-MM-DD")
|
||||
: null
|
||||
}
|
||||
>
|
||||
{lastActive
|
||||
? panelDay(lastActive, tz).format("HH:mm:ss")
|
||||
: "N/A"}
|
||||
</SummaryMetric>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3" data-testid="summary-mobile">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex min-w-0 flex-col items-center justify-center gap-1.5 text-center">
|
||||
<SummaryLabel>
|
||||
{t("serverOnlineHistory.onlineRate")}
|
||||
</SummaryLabel>
|
||||
<div className="flex min-w-0 flex-col items-center justify-center gap-1.5">
|
||||
<span className="shrink-0 text-2xl font-medium leading-7 tracking-tight tabular-nums">
|
||||
{online.online_rate.toFixed(1)}%
|
||||
</span>
|
||||
<div className="h-1 w-20 overflow-hidden rounded-full bg-stone-100 dark:bg-stone-800">
|
||||
<div
|
||||
className="h-full rounded-full bg-green-500"
|
||||
style={{
|
||||
width: `${Math.min(100, Math.max(0, online.online_rate))}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[11px] leading-4 text-muted-foreground tabular-nums">
|
||||
{windowRange}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col items-center justify-center gap-1.5 text-center">
|
||||
<SummaryLabel>
|
||||
{t("serverOnlineHistory.lastActive")}
|
||||
</SummaryLabel>
|
||||
<span className="text-xl font-medium leading-7 tracking-tight tabular-nums">
|
||||
{lastActive
|
||||
? panelDay(lastActive, tz).format("HH:mm:ss")
|
||||
: "N/A"}
|
||||
</span>
|
||||
{lastActive && (
|
||||
<span className="text-[11px] leading-4 text-muted-foreground tabular-nums">
|
||||
{panelDay(lastActive, tz).format("YYYY-MM-DD")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 border-t border-stone-100 pt-3 dark:border-stone-800">
|
||||
<SummaryBox label={t("serverOnlineHistory.totalOnline")}>
|
||||
<span className="text-lg font-medium leading-6 tracking-tight tabular-nums text-foreground">
|
||||
{formatDuration(online.total_online_seconds)}
|
||||
</span>
|
||||
</SummaryBox>
|
||||
<SummaryBox label={t("serverOnlineHistory.totalOffline")}>
|
||||
<span className="text-lg font-medium leading-6 tracking-tight tabular-nums text-foreground">
|
||||
{formatDuration(offlineSeconds)}
|
||||
</span>
|
||||
</SummaryBox>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className={cn({ "bg-card/70": customBackgroundImage })}>
|
||||
<CardContent className="px-4 py-3 sm:px-5">
|
||||
<div className="flex min-h-8 flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-base font-medium">
|
||||
{t("serverOnlineHistory.dailyOnline")}
|
||||
</p>
|
||||
{/* 面板时区下的 0:00–24:00 挂钟轴 */}
|
||||
<span className="text-[11px] leading-4 text-muted-foreground tabular-nums">
|
||||
0:00 – 24:00{tz ? ` · ${tz}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<TooltipProvider delayDuration={100}>
|
||||
{/* 宽屏两列一行两日;末行为奇数时占满整行 */}
|
||||
<div className="mt-3 grid grid-cols-1 gap-2 md:grid-cols-2 md:[&>*:last-child:nth-child(odd)]:col-span-2">
|
||||
{dailyRows.map((day) => {
|
||||
const segments = day.segments;
|
||||
return (
|
||||
<div
|
||||
key={day.date}
|
||||
data-testid="daily-online-day"
|
||||
className="flex min-w-0 flex-col gap-2.5 rounded-lg bg-muted/40 px-2.5 py-2 dark:bg-muted/20"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2 text-xs">
|
||||
<span className="text-foreground tabular-nums">
|
||||
{day.date}
|
||||
</span>
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{formatDuration(day.online_seconds)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="relative h-2 overflow-hidden rounded-full bg-stone-200/70 dark:bg-stone-700">
|
||||
<WindowShade
|
||||
left={0}
|
||||
width={day.window.leftPct}
|
||||
striped
|
||||
label={t("serverOnlineHistory.outsideWindow")}
|
||||
/>
|
||||
<WindowShade
|
||||
left={day.window.rightPct}
|
||||
width={100 - day.window.rightPct}
|
||||
label={t("serverOnlineHistory.notYetRecorded")}
|
||||
/>
|
||||
{segments.map((segment) => (
|
||||
<Tooltip
|
||||
key={`${segment.leftPct}-${segment.period.start}`}
|
||||
>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
data-testid="online-segment"
|
||||
className={cn(
|
||||
"absolute inset-y-0 cursor-pointer rounded-full bg-green-500/90 transition-colors hover:bg-green-600",
|
||||
segment.live && "bg-green-500",
|
||||
)}
|
||||
style={{
|
||||
left: `${segment.leftPct}%`,
|
||||
width: `${segment.widthPct}%`,
|
||||
minWidth: 2,
|
||||
}}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="px-2 py-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span>
|
||||
{segment.segStart.format("HH:mm:ss")}
|
||||
{" → "}
|
||||
{segment.live
|
||||
? t("serverOnlineHistory.ongoing")
|
||||
: formatPeriodMoment(
|
||||
segment.segEnd,
|
||||
segment.segStart,
|
||||
tz,
|
||||
true,
|
||||
)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatDuration(
|
||||
segment.segEnd.diff(
|
||||
segment.segStart,
|
||||
"second",
|
||||
),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
{[25, 50, 75].map((position) => (
|
||||
<span
|
||||
key={position}
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-y-0 w-px bg-foreground/15"
|
||||
style={{ left: `${position}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{day.window.seconds > 0 &&
|
||||
day.window.rightPct > 0 &&
|
||||
day.window.rightPct < 100 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="data-cutoff"
|
||||
aria-label={`${t("serverOnlineHistory.dataCutoff")} ${day.window.end.format("HH:mm:ss")}`}
|
||||
className="absolute -top-0.5 -bottom-0.5 w-px bg-stone-500 dark:bg-stone-400"
|
||||
style={{ left: `${day.window.rightPct}%` }}
|
||||
>
|
||||
<span className="absolute -top-0.5 left-1/2 size-1.5 -translate-x-1/2 rounded-full bg-stone-500 dark:bg-stone-400" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("serverOnlineHistory.dataCutoff")}{" "}
|
||||
{day.window.end.format("HH:mm:ss")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
aria-hidden
|
||||
className="flex justify-between text-[9px] leading-none text-muted-foreground tabular-nums"
|
||||
>
|
||||
{[0, 6, 12, 18, 24].map((hour) => (
|
||||
<span key={hour}>{hour}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-[10px] text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2 w-3 rounded-sm"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"repeating-linear-gradient(135deg, transparent, transparent 3px, currentColor 3px, currentColor 4px)",
|
||||
}}
|
||||
/>
|
||||
{t("serverOnlineHistory.outsideWindow")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2 w-3 rounded-sm border border-border bg-background/80"
|
||||
/>
|
||||
{t("serverOnlineHistory.notYetRecorded")}
|
||||
</span>
|
||||
<span>{t("serverOnlineHistory.dataCutoffHint")}</span>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className={cn({ "bg-card/70": customBackgroundImage })}>
|
||||
<CardContent className="px-4 py-3 sm:px-5">
|
||||
<div className="flex min-h-8 flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-base font-medium">
|
||||
{t(
|
||||
periodMode === "online"
|
||||
? "serverOnlineHistory.periods"
|
||||
: "serverOnlineHistory.offlinePeriods",
|
||||
)}
|
||||
</p>
|
||||
<PeriodsModeSwitch
|
||||
mode={periodMode}
|
||||
onModeChange={(nextMode) => {
|
||||
setPeriodMode(nextMode);
|
||||
setVisibleDays(PERIODS_INITIAL_DAYS);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{(() => {
|
||||
if (!online) {
|
||||
return null;
|
||||
}
|
||||
const groups = periodGroups;
|
||||
const visibleGroups = groups.slice(0, visibleDays);
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t("serverOnlineHistory.noData")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
{visibleGroups.map((group) => (
|
||||
<div key={group.date} data-testid="period-day">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1 pb-2">
|
||||
<span className="text-xs font-medium">
|
||||
{group.day.format("MM-DD")}
|
||||
{group.day.isSame(panelNow(tz), "day") && (
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{" · "}
|
||||
{t("serverOnlineHistory.today")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t(periodMode === "online" ? "online" : "offline")}{" "}
|
||||
<span>{formatDuration(group.total)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{group.chips.map((chip, index) => {
|
||||
const seconds = chip.end.diff(chip.start, "second");
|
||||
return (
|
||||
<Tooltip key={`${chip.start.valueOf()}-${index}`}>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
data-testid="period-chip"
|
||||
className={cn(
|
||||
"inline-flex max-w-full items-center gap-1.5 rounded-[6px] px-2 py-1.5 text-xs tabular-nums transition-colors cursor-default",
|
||||
chip.ongoing
|
||||
? periodMode === "online"
|
||||
? "bg-green-50 text-green-700 dark:bg-green-500/10 dark:text-green-400"
|
||||
: "bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400"
|
||||
: "bg-stone-100 text-stone-600 hover:bg-stone-200/70 dark:bg-stone-800 dark:text-stone-300 dark:hover:bg-stone-700",
|
||||
)}
|
||||
>
|
||||
{chip.ongoing && (
|
||||
<span className="size-1.5 shrink-0 rounded-full bg-current" />
|
||||
)}
|
||||
{chip.start.format("HH:mm")}
|
||||
{" → "}
|
||||
{chip.ongoing
|
||||
? t("serverOnlineHistory.ongoing")
|
||||
: formatPeriodMoment(
|
||||
chip.end,
|
||||
chip.start,
|
||||
tz,
|
||||
)}
|
||||
<span className="text-muted-foreground">
|
||||
{formatDuration(seconds)}
|
||||
</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="px-2 py-1">
|
||||
{chip.start.format("HH:mm:ss")}
|
||||
{" → "}
|
||||
{chip.ongoing
|
||||
? t("serverOnlineHistory.ongoing")
|
||||
: formatPeriodMoment(
|
||||
chip.end,
|
||||
chip.start,
|
||||
tz,
|
||||
true,
|
||||
)}
|
||||
{" · "}
|
||||
{formatDuration(seconds)}
|
||||
{chip.original &&
|
||||
(!chip.start.isSame(
|
||||
panelDay(chip.original.start, tz),
|
||||
) ||
|
||||
!chip.end.isSame(
|
||||
panelDay(chip.original.end, tz),
|
||||
)) && (
|
||||
<div className="mt-1 text-muted-foreground">
|
||||
{t("serverOnlineHistory.originalSession")}{" "}
|
||||
{panelDay(chip.original.start, tz).format(
|
||||
"MM-DD HH:mm:ss",
|
||||
)}
|
||||
{" → "}
|
||||
{chip.original.ongoing
|
||||
? t("serverOnlineHistory.ongoing")
|
||||
: panelDay(
|
||||
chip.original.end,
|
||||
tz,
|
||||
).format("MM-DD HH:mm:ss")}
|
||||
</div>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{groups.length > visibleDays && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="periods-expand"
|
||||
onClick={() =>
|
||||
setVisibleDays((d) => d + PERIODS_EXPAND_STEP)
|
||||
}
|
||||
className="w-full rounded-md border border-dashed border-border py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring cursor-pointer"
|
||||
>
|
||||
{t("serverOnlineHistory.expandMore")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
})()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -106,7 +106,6 @@ export default function ServerOverview({
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-500 opacity-75"></span>
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-green-500"></span>
|
||||
</span>
|
||||
<NumericText value={online} className="text-lg font-semibold" />
|
||||
@@ -136,7 +135,6 @@ export default function ServerOverview({
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-500 opacity-75"></span>
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-red-500"></span>
|
||||
</span>
|
||||
<NumericText value={offline} className="text-lg font-semibold" />
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Bar({ className }: { className: string }) {
|
||||
return (
|
||||
<Skeleton
|
||||
className={cn(
|
||||
"rounded-[5px] bg-stone-200/70 motion-reduce:animate-none dark:bg-stone-700/60",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ footer = true }: { footer?: boolean }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col items-center justify-center gap-2">
|
||||
<div className="flex h-4 items-center">
|
||||
<Bar className="h-3 w-14" />
|
||||
</div>
|
||||
<Bar className="h-7 w-24 max-w-full" />
|
||||
{footer && (
|
||||
<div className="flex h-4 max-w-full items-center">
|
||||
<Bar className="h-3 w-20 max-w-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OnlineHistorySkeleton({ days = 1 }: { days?: number }) {
|
||||
const { t } = useTranslation();
|
||||
const dayCount = Math.min(31, Math.max(1, days) + 1);
|
||||
const cardClassName = cn({
|
||||
"bg-card/70":
|
||||
typeof window !== "undefined" && Boolean(window.CustomBackgroundImage),
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={t("serverOnlineHistory.loading")}
|
||||
aria-busy="true"
|
||||
data-testid="online-history-loading"
|
||||
>
|
||||
<div aria-hidden="true" className="flex flex-col gap-4">
|
||||
<div className="flex min-h-9 items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 md:px-1">
|
||||
<Bar className="size-2 rounded-full" />
|
||||
<Bar className="h-3 w-8" />
|
||||
</div>
|
||||
<Bar className="h-9 w-36 rounded-full" />
|
||||
</div>
|
||||
|
||||
<div className="px-0 py-2 md:px-6 md:py-4">
|
||||
<div className="hidden grid-cols-4 divide-x divide-stone-100 dark:divide-stone-800 md:grid">
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
<Metric key={index} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Metric />
|
||||
<Metric />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 border-t border-stone-100 pt-3 dark:border-stone-800">
|
||||
<Metric footer={false} />
|
||||
<Metric footer={false} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className={cardClassName}>
|
||||
<CardContent className="px-4 py-3 sm:px-5">
|
||||
<div className="flex min-h-8 flex-wrap items-center justify-between gap-2">
|
||||
<Bar className="h-4 w-28" />
|
||||
<Bar className="h-3 w-32" />
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-1 gap-2 md:grid-cols-2 md:[&>*:last-child:nth-child(odd)]:col-span-2">
|
||||
{Array.from({ length: dayCount }, (_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex min-w-0 flex-col gap-2.5 rounded-lg bg-muted/40 px-2.5 py-2 dark:bg-muted/20"
|
||||
>
|
||||
<div className="flex h-4 items-center justify-between gap-2">
|
||||
<Bar className="h-3 w-20" />
|
||||
<Bar className="h-3 w-14" />
|
||||
</div>
|
||||
<Bar className="h-2 w-full rounded-full" />
|
||||
<div className="flex h-[9px] items-center justify-between">
|
||||
{Array.from({ length: 5 }, (_, tick) => (
|
||||
<Bar key={tick} className="h-1 w-2" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 flex h-4 items-center gap-4">
|
||||
<Bar className="h-2 w-16" />
|
||||
<Bar className="h-2 w-16" />
|
||||
<Bar className="h-2 w-20" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className={cardClassName}>
|
||||
<CardContent className="px-4 py-3 sm:px-5">
|
||||
<div className="flex min-h-8 items-center justify-between gap-2">
|
||||
<Bar className="h-4 w-24" />
|
||||
<Bar className="-mt-1 -mr-2 h-[30px] w-24 self-start rounded-[8px] sm:-mr-3" />
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
{Array.from({ length: Math.min(3, dayCount) }, (_, index) => (
|
||||
<div key={index}>
|
||||
<div className="flex h-6 items-start justify-between gap-3">
|
||||
<Bar className="h-3 w-16" />
|
||||
<Bar className="h-3 w-20" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Bar className="h-7 w-36" />
|
||||
<Bar className="h-7 w-40" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{dayCount > 3 && (
|
||||
<div className="flex h-8 items-center justify-center rounded-md border border-dashed border-border">
|
||||
<Bar className="h-3 w-16" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// matchMedia 不可用(如 jsdom)时默认命中桌面布局,保证测试环境渲染完整概要
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(() => {
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
typeof window.matchMedia !== "function"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return window.matchMedia(query).matches;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window.matchMedia !== "function") {
|
||||
return;
|
||||
}
|
||||
const mediaQueryList = window.matchMedia(query);
|
||||
const onChange = (event: MediaQueryListEvent) => {
|
||||
setMatches(event.matches);
|
||||
};
|
||||
setMatches(mediaQueryList.matches);
|
||||
mediaQueryList.addEventListener("change", onChange);
|
||||
return () => {
|
||||
mediaQueryList.removeEventListener("change", onChange);
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
+28
@@ -4,8 +4,15 @@ import { initReactI18next } from "react-i18next";
|
||||
import deTranslation from "./locales/de/translation.json";
|
||||
import enTranslation from "./locales/en/translation.json";
|
||||
import esTranslation from "./locales/es/translation.json";
|
||||
import frTranslation from "./locales/fr/translation.json";
|
||||
import glTranslation from "./locales/gl/translation.json";
|
||||
import idTranslation from "./locales/id/translation.json";
|
||||
import jaTranslation from "./locales/ja/translation.json";
|
||||
import ptBRTranslation from "./locales/pt_BR/translation.json";
|
||||
import roTranslation from "./locales/ro/translation.json";
|
||||
import ruTranslation from "./locales/ru/translation.json";
|
||||
import taTranslation from "./locales/ta/translation.json";
|
||||
import ukTranslation from "./locales/uk/translation.json";
|
||||
import zhCNTranslation from "./locales/zh-CN/translation.json";
|
||||
import zhTWTranslation from "./locales/zh-TW/translation.json";
|
||||
|
||||
@@ -25,12 +32,33 @@ const resources = {
|
||||
"es-ES": {
|
||||
translation: esTranslation,
|
||||
},
|
||||
fr: {
|
||||
translation: frTranslation,
|
||||
},
|
||||
gl: {
|
||||
translation: glTranslation,
|
||||
},
|
||||
id: {
|
||||
translation: idTranslation,
|
||||
},
|
||||
ja: {
|
||||
translation: jaTranslation,
|
||||
},
|
||||
pt_BR: {
|
||||
translation: ptBRTranslation,
|
||||
},
|
||||
ro: {
|
||||
translation: roTranslation,
|
||||
},
|
||||
"ru-RU": {
|
||||
translation: ruTranslation,
|
||||
},
|
||||
"ta-IN": {
|
||||
translation: taTranslation,
|
||||
},
|
||||
uk: {
|
||||
translation: ukTranslation,
|
||||
},
|
||||
};
|
||||
|
||||
const getStoredLanguage = () => {
|
||||
|
||||
@@ -3,12 +3,21 @@ import type {
|
||||
MetricPeriod,
|
||||
MetricType,
|
||||
MonitorResponse,
|
||||
OnlineHistoryResponse,
|
||||
ServerGroupResponse,
|
||||
ServerMetricsResponse,
|
||||
ServiceResponse,
|
||||
SettingResponse,
|
||||
} from "@/types/nezha-api";
|
||||
|
||||
export const API_ERROR_UNAUTHORIZED = "ApiErrorUnauthorized";
|
||||
|
||||
export function isUnauthorizedError(error: unknown): boolean {
|
||||
const message =
|
||||
error instanceof Error ? error.message : error == null ? "" : String(error);
|
||||
return message === API_ERROR_UNAUTHORIZED;
|
||||
}
|
||||
|
||||
let lastestRefreshTokenAt = 0;
|
||||
|
||||
const getCookieValue = (name: string): string | undefined => {
|
||||
@@ -110,3 +119,18 @@ export const fetchServerMetrics = async (
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
export const fetchServerOnlineHistory = async (
|
||||
server_id: number,
|
||||
days?: number,
|
||||
): Promise<OnlineHistoryResponse> => {
|
||||
const query = days ? `?days=${days}` : "";
|
||||
const response = await fetch(
|
||||
`/api/v1/server/${server_id}/online-history${query}`,
|
||||
);
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
@@ -0,0 +1,398 @@
|
||||
import dayjs from "dayjs";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import type { DailyOnlineStat, OnlinePeriod } from "@/types/nezha-api";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
export type PeriodChip = {
|
||||
start: dayjs.Dayjs;
|
||||
end: dayjs.Dayjs;
|
||||
ongoing: boolean;
|
||||
original?: OnlinePeriod;
|
||||
};
|
||||
|
||||
export type PeriodDayGroup = {
|
||||
date: string;
|
||||
day: dayjs.Dayjs;
|
||||
total: number;
|
||||
chips: PeriodChip[];
|
||||
};
|
||||
|
||||
export type DaySegment = {
|
||||
period: OnlinePeriod;
|
||||
segStart: dayjs.Dayjs;
|
||||
segEnd: dayjs.Dayjs;
|
||||
leftPct: number;
|
||||
widthPct: number;
|
||||
/** 该段是否仍然进行中(只有结束于真实会话末尾的段才为 true) */
|
||||
live: boolean;
|
||||
};
|
||||
|
||||
// 后端按面板时区拆日;响应带 timezone 时前端用同一时区解释时间,
|
||||
// 保证跨时区访客的时间轴、分组与每日数字一致
|
||||
export function panelDay(value: string, tz?: string): dayjs.Dayjs {
|
||||
// dayjs 的 .tz(input, zone) 解析模式会忽略输入里的显式偏移(Z / ±HH:MM):
|
||||
// 带偏移的时间戳须先按原义解析再换算时区,无偏移的日期串才用 zone 解析
|
||||
if (tz) {
|
||||
return /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value)
|
||||
? dayjs(value).tz(tz)
|
||||
: dayjs.tz(value, tz);
|
||||
}
|
||||
return dayjs(value);
|
||||
}
|
||||
|
||||
export function panelNow(tz?: string): dayjs.Dayjs {
|
||||
return tz ? dayjs().tz(tz) : dayjs();
|
||||
}
|
||||
|
||||
// 下一天的日历日期用 UTC 做纯日期运算,避免 dayjs .add(1, "day")
|
||||
// 在 DST 切换日加的是 24 小时而不是下一个挂钟零点
|
||||
function nextDate(date: string): string {
|
||||
const [year, month, day] = date.split("-").map(Number);
|
||||
return new Date(Date.UTC(year, month - 1, day + 1))
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
export function panelNextDayStart(date: string, tz?: string): dayjs.Dayjs {
|
||||
return panelDay(nextDate(date), tz);
|
||||
}
|
||||
|
||||
// 结束于次日零点的段在 chip 上显示为 24:00,避免出现“00:00 → 00:00”这种读不通的全天段
|
||||
export function formatPeriodMoment(
|
||||
value: dayjs.Dayjs,
|
||||
anchor: dayjs.Dayjs,
|
||||
tz?: string,
|
||||
withSeconds = false,
|
||||
): string {
|
||||
const dayEnd = panelNextDayStart(anchor.format("YYYY-MM-DD"), tz);
|
||||
if (value.valueOf() === dayEnd.valueOf()) {
|
||||
return withSeconds ? "24:00:00" : "24:00";
|
||||
}
|
||||
return value.format(withSeconds ? "HH:mm:ss" : "HH:mm");
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (d > 0) {
|
||||
return `${d}d ${h}h ${m}m`;
|
||||
}
|
||||
if (h > 0) {
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
// 时间轴刻度是固定的挂钟 0:00–24:00,定位必须用面板时区的挂钟时间;
|
||||
// 用 elapsed seconds / 86400 会在 DST 切换日(23h/25h)偏移
|
||||
function wallClockPercent(value: dayjs.Dayjs): number {
|
||||
const seconds =
|
||||
value.hour() * 3600 +
|
||||
value.minute() * 60 +
|
||||
value.second() +
|
||||
value.millisecond() / 1000;
|
||||
return (seconds / 86400) * 100;
|
||||
}
|
||||
|
||||
// 每天实际纳入统计的区间;挂钟坐标用于绘图,秒数用于时长/在线率。
|
||||
export function buildDayWindow(
|
||||
date: string,
|
||||
windowStart: string,
|
||||
windowEnd: string,
|
||||
tz?: string,
|
||||
) {
|
||||
const dayStart = panelDay(date, tz);
|
||||
const dayEnd = panelNextDayStart(date, tz);
|
||||
const start = panelDay(windowStart, tz);
|
||||
const end = panelDay(windowEnd, tz);
|
||||
const effectiveStart =
|
||||
start.valueOf() > dayStart.valueOf() ? start : dayStart;
|
||||
const effectiveEnd = end.valueOf() < dayEnd.valueOf() ? end : dayEnd;
|
||||
const percent = (value: dayjs.Dayjs) =>
|
||||
value.valueOf() < dayStart.valueOf() ||
|
||||
value.valueOf() === dayStart.valueOf()
|
||||
? 0
|
||||
: value.valueOf() > dayEnd.valueOf() ||
|
||||
value.valueOf() === dayEnd.valueOf()
|
||||
? 100
|
||||
: wallClockPercent(value);
|
||||
return {
|
||||
dayStart,
|
||||
dayEnd,
|
||||
start: effectiveStart,
|
||||
end: effectiveEnd,
|
||||
seconds: Math.max(0, effectiveEnd.diff(effectiveStart, "second")),
|
||||
leftPct: percent(effectiveStart),
|
||||
rightPct: percent(effectiveEnd),
|
||||
};
|
||||
}
|
||||
|
||||
export function dailyOnlineRate(onlineSeconds: number, windowSeconds: number) {
|
||||
return windowSeconds > 0
|
||||
? Math.max(0, Math.min(100, (onlineSeconds / windowSeconds) * 100))
|
||||
: 0;
|
||||
}
|
||||
|
||||
// 每份响应只解析一次时段和窗口边界;后续按天裁剪仅比较时间戳。
|
||||
function preparePeriods(
|
||||
periods: OnlinePeriod[],
|
||||
tz?: string,
|
||||
windowStart?: string,
|
||||
windowEnd?: string,
|
||||
) {
|
||||
const lower = windowStart ? panelDay(windowStart, tz) : null;
|
||||
const upper = windowEnd ? panelDay(windowEnd, tz) : null;
|
||||
return periods.flatMap((period) => {
|
||||
const originalStart = panelDay(period.start, tz);
|
||||
const originalEnd = panelDay(period.end, tz);
|
||||
const start =
|
||||
lower && lower.valueOf() > originalStart.valueOf()
|
||||
? lower
|
||||
: originalStart;
|
||||
const end =
|
||||
upper && upper.valueOf() < originalEnd.valueOf() ? upper : originalEnd;
|
||||
return start.isValid() && end.isValid() && end.valueOf() > start.valueOf()
|
||||
? [
|
||||
{
|
||||
period,
|
||||
start,
|
||||
end,
|
||||
live: period.ongoing && end.valueOf() === originalEnd.valueOf(),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
function buildParsedDaySegments(
|
||||
periods: ReturnType<typeof preparePeriods>,
|
||||
dayStart: dayjs.Dayjs,
|
||||
dayEnd: dayjs.Dayjs,
|
||||
): DaySegment[] {
|
||||
const lower = dayStart.valueOf(),
|
||||
upper = dayEnd.valueOf();
|
||||
return periods.flatMap(({ period, start, end, live }) => {
|
||||
if (end.valueOf() <= lower || start.valueOf() >= upper) return [];
|
||||
const segStart = start.valueOf() > lower ? start : dayStart;
|
||||
const segEnd = end.valueOf() < upper ? end : dayEnd;
|
||||
const leftPct =
|
||||
segStart.valueOf() === lower ? 0 : wallClockPercent(segStart);
|
||||
const rightPct =
|
||||
segEnd.valueOf() === upper ? 100 : wallClockPercent(segEnd);
|
||||
const widthPct = Math.max(0, Math.min(rightPct - leftPct, 100 - leftPct));
|
||||
return widthPct > 0
|
||||
? [
|
||||
{
|
||||
period,
|
||||
segStart,
|
||||
segEnd,
|
||||
leftPct,
|
||||
widthPct,
|
||||
live: live && segEnd.valueOf() === end.valueOf(),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
export function buildDaySegments(
|
||||
periods: OnlinePeriod[],
|
||||
date: string,
|
||||
tz?: string,
|
||||
windowStart?: string,
|
||||
windowEnd?: string,
|
||||
): DaySegment[] {
|
||||
return buildParsedDaySegments(
|
||||
preparePeriods(periods, tz, windowStart, windowEnd),
|
||||
panelDay(date, tz),
|
||||
panelNextDayStart(date, tz),
|
||||
);
|
||||
}
|
||||
|
||||
// 时间轴、每日总计及在线列表共享这份结果,不再各自遍历并转换整份历史。
|
||||
export function buildDailyOnlineRows(
|
||||
periods: OnlinePeriod[],
|
||||
daily: DailyOnlineStat[],
|
||||
windowStart: string,
|
||||
windowEnd: string,
|
||||
tz?: string,
|
||||
) {
|
||||
const prepared = preparePeriods(periods, tz, windowStart, windowEnd);
|
||||
return daily.map((day) => {
|
||||
const window = buildDayWindow(day.date, windowStart, windowEnd, tz);
|
||||
const segments = buildParsedDaySegments(
|
||||
prepared,
|
||||
window.dayStart,
|
||||
window.dayEnd,
|
||||
);
|
||||
return {
|
||||
...day,
|
||||
segments,
|
||||
window,
|
||||
online_seconds: segments.reduce(
|
||||
(sum, segment) => sum + segment.segEnd.diff(segment.segStart, "second"),
|
||||
0,
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function groupOnlineRows(
|
||||
rows: { date: string; segments: DaySegment[] }[],
|
||||
tz?: string,
|
||||
): PeriodDayGroup[] {
|
||||
return rows
|
||||
.filter((row) => row.segments.length > 0)
|
||||
.map((row) => ({
|
||||
date: row.date,
|
||||
day: panelDay(row.date, tz),
|
||||
chips: row.segments.map((segment) => ({
|
||||
start: segment.segStart,
|
||||
end: segment.segEnd,
|
||||
ongoing: segment.live,
|
||||
original: segment.period,
|
||||
})),
|
||||
total: row.segments.reduce(
|
||||
(sum, segment) => sum + segment.segEnd.diff(segment.segStart, "second"),
|
||||
0,
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => b.date.localeCompare(a.date));
|
||||
}
|
||||
|
||||
// 合并重叠/相邻的时间段,按开始时间排序
|
||||
export function mergePeriods(
|
||||
periods: OnlinePeriod[],
|
||||
tz?: string,
|
||||
): PeriodChip[] {
|
||||
const sorted = periods
|
||||
.map((period) => ({
|
||||
start: panelDay(period.start, tz),
|
||||
end: panelDay(period.end, tz),
|
||||
ongoing: period.ongoing,
|
||||
}))
|
||||
.sort((a, b) => a.start.valueOf() - b.start.valueOf());
|
||||
const merged: PeriodChip[] = [];
|
||||
for (const period of sorted) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && period.start.valueOf() < last.end.valueOf()) {
|
||||
if (period.end.valueOf() > last.end.valueOf()) {
|
||||
last.end = period.end;
|
||||
}
|
||||
last.ongoing = last.ongoing || period.ongoing;
|
||||
} else {
|
||||
merged.push({ ...period });
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
// 独立调用也复用同一预解析/裁剪流程。
|
||||
export function groupOnlinePeriods(
|
||||
periods: OnlinePeriod[],
|
||||
tz?: string,
|
||||
windowStart?: string,
|
||||
windowEnd?: string,
|
||||
): PeriodDayGroup[] {
|
||||
const prepared = preparePeriods(periods, tz, windowStart, windowEnd);
|
||||
const dates = new Set<string>();
|
||||
for (const { start, end } of prepared) {
|
||||
const endDate = end.format("YYYY-MM-DD");
|
||||
for (
|
||||
let date = start.format("YYYY-MM-DD");
|
||||
date <= endDate;
|
||||
date = nextDate(date)
|
||||
)
|
||||
dates.add(date);
|
||||
}
|
||||
return groupOnlineRows(
|
||||
[...dates].map((date) => ({
|
||||
date,
|
||||
segments: buildParsedDaySegments(
|
||||
prepared,
|
||||
panelDay(date, tz),
|
||||
panelNextDayStart(date, tz),
|
||||
),
|
||||
})),
|
||||
tz,
|
||||
);
|
||||
}
|
||||
|
||||
// 离线模式:对窗口内每一天求在线时段的补集。计算区间先与查询窗口取交集,
|
||||
// 避免在窗口开始前/结束后凭空生成离线段;服务器当前离线时,结束于窗口末端的
|
||||
// 最新一段标记为进行中。
|
||||
export function buildOfflineGroups(
|
||||
periods: OnlinePeriod[],
|
||||
daily: DailyOnlineStat[],
|
||||
windowStart: string,
|
||||
windowEnd: string,
|
||||
currentlyOnline: boolean,
|
||||
tz?: string,
|
||||
): PeriodDayGroup[] {
|
||||
const merged = mergePeriods(periods, tz);
|
||||
const windowStartTime = panelDay(windowStart, tz);
|
||||
const windowEndTime = panelDay(windowEnd, tz);
|
||||
const groups = daily
|
||||
.map((day) => day.date)
|
||||
.sort((a, b) => (a < b ? 1 : -1))
|
||||
.flatMap((date): PeriodDayGroup[] => {
|
||||
const dayStart = panelDay(date, tz);
|
||||
const dayEnd = panelNextDayStart(date, tz);
|
||||
// 当天与查询窗口的交集;首日/末日通常不是完整 24 小时
|
||||
const effectiveStart =
|
||||
dayStart.valueOf() > windowStartTime.valueOf()
|
||||
? dayStart
|
||||
: windowStartTime;
|
||||
const effectiveEnd =
|
||||
dayEnd.valueOf() < windowEndTime.valueOf() ? dayEnd : windowEndTime;
|
||||
if (!(effectiveEnd.valueOf() > effectiveStart.valueOf())) {
|
||||
return [];
|
||||
}
|
||||
const chips: PeriodChip[] = [];
|
||||
let cursor = effectiveStart;
|
||||
for (const segment of merged) {
|
||||
if (!(segment.end.valueOf() > effectiveStart.valueOf())) {
|
||||
continue;
|
||||
}
|
||||
// merged 按开始时间升序;到达当天区间之后的时段即可停止,
|
||||
// 否则会把未来日期的开始时刻当成当天的 gap 终点
|
||||
if (!(segment.start.valueOf() < effectiveEnd.valueOf())) {
|
||||
break;
|
||||
}
|
||||
const segStart =
|
||||
segment.start.valueOf() > cursor.valueOf() ? segment.start : cursor;
|
||||
if (segStart.valueOf() > cursor.valueOf()) {
|
||||
chips.push({ start: cursor, end: segStart, ongoing: false });
|
||||
}
|
||||
const segEnd =
|
||||
segment.end.valueOf() < effectiveEnd.valueOf()
|
||||
? segment.end
|
||||
: effectiveEnd;
|
||||
if (segEnd.valueOf() > cursor.valueOf()) {
|
||||
cursor = segEnd;
|
||||
}
|
||||
}
|
||||
if (cursor.valueOf() < effectiveEnd.valueOf()) {
|
||||
chips.push({ start: cursor, end: effectiveEnd, ongoing: false });
|
||||
}
|
||||
const total = chips.reduce(
|
||||
(sum, chip) => sum + chip.end.diff(chip.start, "second"),
|
||||
0,
|
||||
);
|
||||
return [{ date, day: dayStart, total, chips }];
|
||||
})
|
||||
.filter((group) => group.chips.length > 0);
|
||||
if (!currentlyOnline && groups.length > 0) {
|
||||
const newest = groups[0].chips;
|
||||
const last = newest[newest.length - 1];
|
||||
// 只有真正持续到窗口末端的离线段才是进行中;旧日期的段不能误标
|
||||
if (last?.end.isSame(windowEndTime)) {
|
||||
last.ongoing = true;
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
@@ -89,7 +89,8 @@
|
||||
},
|
||||
"tabSwitch": {
|
||||
"Network": "Netzwerk",
|
||||
"Detail": "Detail"
|
||||
"Detail": "Detail",
|
||||
"Online": "Online"
|
||||
},
|
||||
"nezha": "Nezha Monitoring",
|
||||
"dashboard": "Dashboard",
|
||||
@@ -140,5 +141,23 @@
|
||||
"up_total": "Upload ges.",
|
||||
"down_total": "Download ges."
|
||||
}
|
||||
},
|
||||
"serverOnlineHistory": {
|
||||
"days1": "1d",
|
||||
"days7": "7d",
|
||||
"days30": "30d",
|
||||
"onlineRate": "Online rate",
|
||||
"totalOnline": "Online in window",
|
||||
"lastActive": "Last active",
|
||||
"dailyOnline": "Daily online time",
|
||||
"periods": "Online periods",
|
||||
"ongoing": "Ongoing",
|
||||
"noData": "No online records",
|
||||
"loginRequired": "This window requires login; guests can switch to 1d",
|
||||
"offlinePeriods": "Offline periods",
|
||||
"periodsOnline": "Online",
|
||||
"periodsOffline": "Offline",
|
||||
"expandMore": "Expand more",
|
||||
"today": "Today"
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,14 @@
|
||||
"de-DE": "Deutsch",
|
||||
"es-ES": "Español",
|
||||
"ru-RU": "Русский",
|
||||
"ta-IN": "தமிழ்"
|
||||
"ta-IN": "தமிழ்",
|
||||
"fr": "Français",
|
||||
"gl": "Galego",
|
||||
"id": "Bahasa Indonesia",
|
||||
"ja": "日本語",
|
||||
"pt_BR": "Português (Brasil)",
|
||||
"ro": "Română",
|
||||
"uk": "Українська"
|
||||
},
|
||||
"theme": {
|
||||
"light": "Light",
|
||||
@@ -110,11 +117,14 @@
|
||||
"pageNotFound": "Page not found",
|
||||
"backToHome": "Back to home",
|
||||
"backendUnavailableTitle": "Backend API unavailable",
|
||||
"backendUnavailableDescription": "The page is still available, but data cannot be loaded right now."
|
||||
"backendUnavailableDescription": "The page is still available, but data cannot be loaded right now.",
|
||||
"loginRequiredTitle": "Login required",
|
||||
"loginRequiredDescription": "This site requires a login to load monitoring data."
|
||||
},
|
||||
"tabSwitch": {
|
||||
"Detail": "Detail",
|
||||
"Network": "Network"
|
||||
"Network": "Network",
|
||||
"Online": "Online"
|
||||
},
|
||||
"monitor": {
|
||||
"noData": "No server monitor data, please add a service monitor first",
|
||||
@@ -176,5 +186,30 @@
|
||||
},
|
||||
"group": {
|
||||
"all": "All"
|
||||
},
|
||||
"serverOnlineHistory": {
|
||||
"loading": "Loading online history",
|
||||
"originalSession": "Original session",
|
||||
"dataCutoffHint": "Marker: data cutoff",
|
||||
"dataCutoff": "Data through",
|
||||
"notYetRecorded": "Not yet recorded",
|
||||
"outsideWindow": "Outside selected window",
|
||||
"days1": "1d",
|
||||
"days7": "7d",
|
||||
"days30": "30d",
|
||||
"onlineRate": "Online rate",
|
||||
"totalOnline": "Total online",
|
||||
"totalOffline": "Total offline",
|
||||
"lastActive": "Last active",
|
||||
"dailyOnline": "Daily online time",
|
||||
"periods": "Online periods",
|
||||
"ongoing": "Ongoing",
|
||||
"noData": "No online records",
|
||||
"loginRequired": "This window requires login; guests can switch to 1d",
|
||||
"offlinePeriods": "Offline periods",
|
||||
"periodsOnline": "Online",
|
||||
"periodsOffline": "Offline",
|
||||
"expandMore": "Expand more",
|
||||
"today": "Today"
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,8 @@
|
||||
},
|
||||
"tabSwitch": {
|
||||
"Detail": "Detalle",
|
||||
"Network": "Red"
|
||||
"Network": "Red",
|
||||
"Online": "Online"
|
||||
},
|
||||
"monitor": {
|
||||
"avgDelay": "Latencia",
|
||||
@@ -155,5 +156,23 @@
|
||||
"up_total": "Subida total",
|
||||
"down_total": "Bajada total"
|
||||
}
|
||||
},
|
||||
"serverOnlineHistory": {
|
||||
"days1": "1d",
|
||||
"days7": "7d",
|
||||
"days30": "30d",
|
||||
"onlineRate": "Online rate",
|
||||
"totalOnline": "Online in window",
|
||||
"lastActive": "Last active",
|
||||
"dailyOnline": "Daily online time",
|
||||
"periods": "Online periods",
|
||||
"ongoing": "Ongoing",
|
||||
"noData": "No online records",
|
||||
"loginRequired": "This window requires login; guests can switch to 1d",
|
||||
"offlinePeriods": "Offline periods",
|
||||
"periodsOnline": "Online",
|
||||
"periodsOffline": "Offline",
|
||||
"expandMore": "Expand more",
|
||||
"today": "Today"
|
||||
}
|
||||
}
|
||||
@@ -12,5 +12,26 @@
|
||||
},
|
||||
"serverDetail": {
|
||||
"download": "Téléchargement"
|
||||
},
|
||||
"tabSwitch": {
|
||||
"Online": "Online"
|
||||
},
|
||||
"serverOnlineHistory": {
|
||||
"days1": "1d",
|
||||
"days7": "7d",
|
||||
"days30": "30d",
|
||||
"onlineRate": "Online rate",
|
||||
"totalOnline": "Online in window",
|
||||
"lastActive": "Last active",
|
||||
"dailyOnline": "Daily online time",
|
||||
"periods": "Online periods",
|
||||
"ongoing": "Ongoing",
|
||||
"noData": "No online records",
|
||||
"loginRequired": "This window requires login; guests can switch to 1d",
|
||||
"offlinePeriods": "Offline periods",
|
||||
"periodsOnline": "Online",
|
||||
"periodsOffline": "Offline",
|
||||
"expandMore": "Expand more",
|
||||
"today": "Today"
|
||||
}
|
||||
}
|
||||
@@ -102,7 +102,8 @@
|
||||
},
|
||||
"tabSwitch": {
|
||||
"Detail": "Detalle",
|
||||
"Network": "Rede"
|
||||
"Network": "Rede",
|
||||
"Online": "Online"
|
||||
},
|
||||
"monitor": {
|
||||
"noData": "Non hai datos de monitor de servidor. Engada primeiro un monitor de servizo",
|
||||
@@ -133,5 +134,23 @@
|
||||
"ToggleLightMode": "Cambiar ao modo claro",
|
||||
"ToggleDarkMode": "Cambiar ao modo escuro",
|
||||
"ToggleSystemMode": "Cambiar ao determinado polo sistema",
|
||||
"Home": "Páxina principal"
|
||||
"Home": "Páxina principal",
|
||||
"serverOnlineHistory": {
|
||||
"days1": "1d",
|
||||
"days7": "7d",
|
||||
"days30": "30d",
|
||||
"onlineRate": "Online rate",
|
||||
"totalOnline": "Online in window",
|
||||
"lastActive": "Last active",
|
||||
"dailyOnline": "Daily online time",
|
||||
"periods": "Online periods",
|
||||
"ongoing": "Ongoing",
|
||||
"noData": "No online records",
|
||||
"loginRequired": "This window requires login; guests can switch to 1d",
|
||||
"offlinePeriods": "Offline periods",
|
||||
"periodsOnline": "Online",
|
||||
"periodsOffline": "Offline",
|
||||
"expandMore": "Expand more",
|
||||
"today": "Today"
|
||||
}
|
||||
}
|
||||
@@ -108,11 +108,14 @@
|
||||
"pageNotFound": "Halaman tidak ditemukan",
|
||||
"backToHome": "Kembali ke beranda",
|
||||
"backendUnavailableTitle": "API backend tidak tersedia",
|
||||
"backendUnavailableDescription": "Halaman masih tersedia, tetapi data tidak dapat dimuat saat ini."
|
||||
"backendUnavailableDescription": "Halaman masih tersedia, tetapi data tidak dapat dimuat saat ini.",
|
||||
"loginRequiredTitle": "Perlu masuk",
|
||||
"loginRequiredDescription": "Situs ini memerlukan login untuk memuat data pemantauan."
|
||||
},
|
||||
"tabSwitch": {
|
||||
"Detail": "Detail",
|
||||
"Network": "Jaringan"
|
||||
"Network": "Jaringan",
|
||||
"Online": "Online"
|
||||
},
|
||||
"monitor": {
|
||||
"noData": "Tidak ada data pemantauan server, harap tambahkan pemantauan layanan terlebih dahulu",
|
||||
@@ -167,5 +170,23 @@
|
||||
},
|
||||
"group": {
|
||||
"all": "Semua"
|
||||
},
|
||||
"serverOnlineHistory": {
|
||||
"days1": "1d",
|
||||
"days7": "7d",
|
||||
"days30": "30d",
|
||||
"onlineRate": "Online rate",
|
||||
"totalOnline": "Online in window",
|
||||
"lastActive": "Last active",
|
||||
"dailyOnline": "Daily online time",
|
||||
"periods": "Online periods",
|
||||
"ongoing": "Ongoing",
|
||||
"noData": "No online records",
|
||||
"loginRequired": "This window requires login; guests can switch to 1d",
|
||||
"offlinePeriods": "Offline periods",
|
||||
"periodsOnline": "Online",
|
||||
"periodsOffline": "Offline",
|
||||
"expandMore": "Expand more",
|
||||
"today": "Today"
|
||||
}
|
||||
}
|
||||
@@ -23,5 +23,26 @@
|
||||
"map": {
|
||||
"Distributions": "サーバーの場所",
|
||||
"Regions": "地域"
|
||||
},
|
||||
"tabSwitch": {
|
||||
"Online": "Online"
|
||||
},
|
||||
"serverOnlineHistory": {
|
||||
"days1": "1d",
|
||||
"days7": "7d",
|
||||
"days30": "30d",
|
||||
"onlineRate": "Online rate",
|
||||
"totalOnline": "Online in window",
|
||||
"lastActive": "Last active",
|
||||
"dailyOnline": "Daily online time",
|
||||
"periods": "Online periods",
|
||||
"ongoing": "Ongoing",
|
||||
"noData": "No online records",
|
||||
"loginRequired": "This window requires login; guests can switch to 1d",
|
||||
"offlinePeriods": "Offline periods",
|
||||
"periodsOnline": "Online",
|
||||
"periodsOffline": "Offline",
|
||||
"expandMore": "Expand more",
|
||||
"today": "Today"
|
||||
}
|
||||
}
|
||||
@@ -108,11 +108,14 @@
|
||||
"pageNotFound": "Página não encontrada",
|
||||
"backToHome": "Voltar ao inicio",
|
||||
"backendUnavailableTitle": "API Backend indisponível",
|
||||
"backendUnavailableDescription": "A página ainda está disponível, mas os dados não podem ser carregados agora."
|
||||
"backendUnavailableDescription": "A página ainda está disponível, mas os dados não podem ser carregados agora.",
|
||||
"loginRequiredTitle": "Login necessário",
|
||||
"loginRequiredDescription": "Este site exige login para carregar os dados de monitoramento."
|
||||
},
|
||||
"tabSwitch": {
|
||||
"Detail": "Detalhes",
|
||||
"Network": "Rede"
|
||||
"Network": "Rede",
|
||||
"Online": "Online"
|
||||
},
|
||||
"monitor": {
|
||||
"noData": "Sem servidores para monitorar. por favor adicione um primeiro monitor de serviço",
|
||||
@@ -163,5 +166,23 @@
|
||||
},
|
||||
"group": {
|
||||
"all": "Todos"
|
||||
},
|
||||
"serverOnlineHistory": {
|
||||
"days1": "1d",
|
||||
"days7": "7d",
|
||||
"days30": "30d",
|
||||
"onlineRate": "Online rate",
|
||||
"totalOnline": "Online in window",
|
||||
"lastActive": "Last active",
|
||||
"dailyOnline": "Daily online time",
|
||||
"periods": "Online periods",
|
||||
"ongoing": "Ongoing",
|
||||
"noData": "No online records",
|
||||
"loginRequired": "This window requires login; guests can switch to 1d",
|
||||
"offlinePeriods": "Offline periods",
|
||||
"periodsOnline": "Online",
|
||||
"periodsOffline": "Offline",
|
||||
"expandMore": "Expand more",
|
||||
"today": "Today"
|
||||
}
|
||||
}
|
||||
@@ -4,5 +4,26 @@
|
||||
"light": "Mod luminos",
|
||||
"dark": "Mod întunecat"
|
||||
},
|
||||
"Servers": "Servere"
|
||||
"Servers": "Servere",
|
||||
"tabSwitch": {
|
||||
"Online": "Online"
|
||||
},
|
||||
"serverOnlineHistory": {
|
||||
"days1": "1d",
|
||||
"days7": "7d",
|
||||
"days30": "30d",
|
||||
"onlineRate": "Online rate",
|
||||
"totalOnline": "Online in window",
|
||||
"lastActive": "Last active",
|
||||
"dailyOnline": "Daily online time",
|
||||
"periods": "Online periods",
|
||||
"ongoing": "Ongoing",
|
||||
"noData": "No online records",
|
||||
"loginRequired": "This window requires login; guests can switch to 1d",
|
||||
"offlinePeriods": "Offline periods",
|
||||
"periodsOnline": "Online",
|
||||
"periodsOffline": "Offline",
|
||||
"expandMore": "Expand more",
|
||||
"today": "Today"
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,8 @@
|
||||
},
|
||||
"tabSwitch": {
|
||||
"Detail": "Детали",
|
||||
"Network": "Сеть"
|
||||
"Network": "Сеть",
|
||||
"Online": "Online"
|
||||
},
|
||||
"whereTheTimeIs": "Где время",
|
||||
"theme": {
|
||||
@@ -159,5 +160,23 @@
|
||||
"up_total": "Загружено",
|
||||
"down_total": "Скачано"
|
||||
}
|
||||
},
|
||||
"serverOnlineHistory": {
|
||||
"days1": "1d",
|
||||
"days7": "7d",
|
||||
"days30": "30d",
|
||||
"onlineRate": "Online rate",
|
||||
"totalOnline": "Online in window",
|
||||
"lastActive": "Last active",
|
||||
"dailyOnline": "Daily online time",
|
||||
"periods": "Online periods",
|
||||
"ongoing": "Ongoing",
|
||||
"noData": "No online records",
|
||||
"loginRequired": "This window requires login; guests can switch to 1d",
|
||||
"offlinePeriods": "Offline periods",
|
||||
"periodsOnline": "Online",
|
||||
"periodsOffline": "Offline",
|
||||
"expandMore": "Expand more",
|
||||
"today": "Today"
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,8 @@
|
||||
},
|
||||
"tabSwitch": {
|
||||
"Detail": "விவரம்",
|
||||
"Network": "பிணையம்"
|
||||
"Network": "பிணையம்",
|
||||
"Online": "Online"
|
||||
},
|
||||
"monitor": {
|
||||
"noData": "சேவையக மானிட்டர் தரவு இல்லை, முதலில் ஒரு பணி மானிட்டரைச் சேர்க்கவும்",
|
||||
@@ -140,5 +141,23 @@
|
||||
"up_total": "மொத்த பதிவேற்றம்",
|
||||
"down_total": "மொத்த பதிவிறக்கம்"
|
||||
}
|
||||
},
|
||||
"serverOnlineHistory": {
|
||||
"days1": "1d",
|
||||
"days7": "7d",
|
||||
"days30": "30d",
|
||||
"onlineRate": "Online rate",
|
||||
"totalOnline": "Online in window",
|
||||
"lastActive": "Last active",
|
||||
"dailyOnline": "Daily online time",
|
||||
"periods": "Online periods",
|
||||
"ongoing": "Ongoing",
|
||||
"noData": "No online records",
|
||||
"loginRequired": "This window requires login; guests can switch to 1d",
|
||||
"offlinePeriods": "Offline periods",
|
||||
"periodsOnline": "Online",
|
||||
"periodsOffline": "Offline",
|
||||
"expandMore": "Expand more",
|
||||
"today": "Today"
|
||||
}
|
||||
}
|
||||
@@ -69,5 +69,26 @@
|
||||
"today": "Сьогодні",
|
||||
"loading": "Завантаження..."
|
||||
},
|
||||
"login": "Логін"
|
||||
"login": "Логін",
|
||||
"tabSwitch": {
|
||||
"Online": "Online"
|
||||
},
|
||||
"serverOnlineHistory": {
|
||||
"days1": "1d",
|
||||
"days7": "7d",
|
||||
"days30": "30d",
|
||||
"onlineRate": "Online rate",
|
||||
"totalOnline": "Online in window",
|
||||
"lastActive": "Last active",
|
||||
"dailyOnline": "Daily online time",
|
||||
"periods": "Online periods",
|
||||
"ongoing": "Ongoing",
|
||||
"noData": "No online records",
|
||||
"loginRequired": "This window requires login; guests can switch to 1d",
|
||||
"offlinePeriods": "Offline periods",
|
||||
"periodsOnline": "Online",
|
||||
"periodsOffline": "Offline",
|
||||
"expandMore": "Expand more",
|
||||
"today": "Today"
|
||||
}
|
||||
}
|
||||
@@ -111,11 +111,14 @@
|
||||
"pageNotFound": "页面不存在",
|
||||
"backToHome": "回到主页",
|
||||
"backendUnavailableTitle": "后端 API 无法访问",
|
||||
"backendUnavailableDescription": "页面仍会正常显示,但暂时无法加载监控数据。"
|
||||
"backendUnavailableDescription": "页面仍会正常显示,但暂时无法加载监控数据。",
|
||||
"loginRequiredTitle": "需要登录",
|
||||
"loginRequiredDescription": "当前站点已开启强制登录,登录后即可加载监控数据。"
|
||||
},
|
||||
"tabSwitch": {
|
||||
"Detail": "详情",
|
||||
"Network": "网络"
|
||||
"Network": "网络",
|
||||
"Online": "在线"
|
||||
},
|
||||
"monitor": {
|
||||
"noData": "没有服务监控数据,请在管理后台服务页添加监控任务",
|
||||
@@ -177,5 +180,30 @@
|
||||
},
|
||||
"group": {
|
||||
"all": "全部"
|
||||
},
|
||||
"serverOnlineHistory": {
|
||||
"loading": "正在加载在线记录",
|
||||
"originalSession": "原始会话",
|
||||
"dataCutoffHint": "竖线:数据截止",
|
||||
"dataCutoff": "数据截止",
|
||||
"notYetRecorded": "尚未统计",
|
||||
"outsideWindow": "未纳入统计",
|
||||
"days1": "1 天",
|
||||
"days7": "7 天",
|
||||
"days30": "30 天",
|
||||
"onlineRate": "在线率",
|
||||
"totalOnline": "累计在线",
|
||||
"totalOffline": "累计离线",
|
||||
"lastActive": "最后活跃",
|
||||
"dailyOnline": "每日在线时长",
|
||||
"periods": "在线时间段",
|
||||
"ongoing": "进行中",
|
||||
"noData": "暂无在线记录",
|
||||
"loginRequired": "当前窗口需要登录后查看,游客可切换到 1 天",
|
||||
"offlinePeriods": "不在线时间段",
|
||||
"periodsOnline": "在线",
|
||||
"periodsOffline": "离线",
|
||||
"expandMore": "展开更多",
|
||||
"today": "今天"
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,11 @@
|
||||
"nezha": "哪吒監控",
|
||||
"overview": "概覽",
|
||||
"dashboard": "管理後台",
|
||||
"login": "登錄",
|
||||
"online": "在線",
|
||||
"login": "登入",
|
||||
"online": "線上",
|
||||
"offline": "離線",
|
||||
"whereTheTimeIs": "目前時間",
|
||||
"refreshing": "刷新中",
|
||||
"refreshing": "重新整理中",
|
||||
"info": {
|
||||
"websocketConnecting": "WebSocket 連線中",
|
||||
"websocketConnected": "WebSocket 連線成功",
|
||||
@@ -19,7 +19,7 @@
|
||||
"totalServers": "總伺服器",
|
||||
"onlineServers": "線上伺服器",
|
||||
"offlineServers": "離線伺服器",
|
||||
"totalBandwidth": "總帶寬",
|
||||
"totalBandwidth": "總頻寬",
|
||||
"speed": "速率",
|
||||
"network": "網路",
|
||||
"totalDomains": "總域名數"
|
||||
@@ -30,14 +30,14 @@
|
||||
"Servers": "個伺服器"
|
||||
},
|
||||
"serverCard": {
|
||||
"mem": "內存",
|
||||
"stg": "存儲",
|
||||
"mem": "記憶體",
|
||||
"stg": "儲存空間",
|
||||
"days": "天",
|
||||
"hours": "小時",
|
||||
"upload": "上傳",
|
||||
"download": "下載",
|
||||
"system": "系統",
|
||||
"uptime": "運行時間",
|
||||
"uptime": "運作時間",
|
||||
"totalUpload": "總上傳",
|
||||
"totalDownload": "總下載"
|
||||
},
|
||||
@@ -47,8 +47,8 @@
|
||||
"nextUpdate": "下次更新"
|
||||
},
|
||||
"serviceTracker": {
|
||||
"noService": "無服務數據",
|
||||
"uptime": "在線率",
|
||||
"noService": "無服務資料",
|
||||
"uptime": "上線率",
|
||||
"delay": "延遲",
|
||||
"daysAgo": "天前",
|
||||
"today": "今天",
|
||||
@@ -61,26 +61,32 @@
|
||||
"hours": "小時",
|
||||
"offline": "離線",
|
||||
"unknown": "未知",
|
||||
"uptime": "運行時間",
|
||||
"uptime": "運作時間",
|
||||
"version": "版本",
|
||||
"arch": "架構",
|
||||
"mem": "內存",
|
||||
"disk": "磁盤",
|
||||
"mem": "記憶體",
|
||||
"disk": "磁碟",
|
||||
"region": "地區",
|
||||
"system": "系統",
|
||||
"upload": "上傳",
|
||||
"download": "下載",
|
||||
"lastActive": "最後上報時間",
|
||||
"lastActive": "最後回報時間",
|
||||
"temperature": "溫度",
|
||||
"bootTime": "啟動時間"
|
||||
},
|
||||
"serverDetailChart": {
|
||||
"process": "進程數",
|
||||
"disk": "磁盤",
|
||||
"mem": "內存",
|
||||
"process": "程序數",
|
||||
"disk": "磁碟",
|
||||
"mem": "記憶體",
|
||||
"swap": "虛擬記憶體",
|
||||
"upload": "上傳",
|
||||
"download": "下載"
|
||||
"download": "下載",
|
||||
"realtime": "即時",
|
||||
"period1d": "1 天",
|
||||
"period7d": "7 天",
|
||||
"period30d": "30 天",
|
||||
"tsdbRequired": "請啟用 TSDB 以使用歷史資料",
|
||||
"loginRequired": "請先登入才能查看"
|
||||
},
|
||||
"footer": {
|
||||
"themeBy": "主題-"
|
||||
@@ -102,7 +108,7 @@
|
||||
},
|
||||
"error": {
|
||||
"pageNotFound": "頁面不存在",
|
||||
"backToHome": "回到主頁",
|
||||
"backToHome": "回到首頁",
|
||||
"backendUnavailableTitle": "後端 API 無法存取",
|
||||
"backendUnavailableDescription": "頁面仍會正常顯示,但暫時無法載入監控資料。"
|
||||
},
|
||||
@@ -110,23 +116,25 @@
|
||||
"detail": "詳細資訊",
|
||||
"network": "網路",
|
||||
"Detail": "詳細資訊",
|
||||
"Network": "網路"
|
||||
"Network": "網路",
|
||||
"Online": "線上"
|
||||
},
|
||||
"monitor": {
|
||||
"noData": "沒有服務監控數據,請在管理後台服務新增監控任務",
|
||||
"noData": "沒有伺服器監控資料,請先新增服務監控",
|
||||
"status": "狀態",
|
||||
"avgDelay": "延遲",
|
||||
"monitorCount": "個監控",
|
||||
"packetLoss": "丟包率",
|
||||
"packetLoss": "封包遺失率",
|
||||
"clearSelections": "清除",
|
||||
"peakCut": "削峰",
|
||||
"period1d": "1 天",
|
||||
"period7d": "7 天",
|
||||
"period30d": "30 天"
|
||||
"period30d": "30 天",
|
||||
"loginRequired": "請先登入才能查看"
|
||||
},
|
||||
"billingInfo": {
|
||||
"remaining": "剩餘天數",
|
||||
"error": "獲取失敗",
|
||||
"error": "取得失敗",
|
||||
"indefinite": "無限期",
|
||||
"expired": "已過期",
|
||||
"days": "天",
|
||||
@@ -151,7 +159,7 @@
|
||||
},
|
||||
"pwa": {
|
||||
"offlineReady": "可離線使用之應用程式",
|
||||
"newContent": "有新内容可用",
|
||||
"newContent": "有新內容可用",
|
||||
"reload": "更新"
|
||||
},
|
||||
"sort": {
|
||||
@@ -159,7 +167,7 @@
|
||||
"types": {
|
||||
"default": "預設",
|
||||
"name": "名稱",
|
||||
"uptime": "運行時間",
|
||||
"uptime": "運作時間",
|
||||
"system": "系統",
|
||||
"cpu": "CPU",
|
||||
"mem": "記憶體",
|
||||
@@ -172,5 +180,30 @@
|
||||
},
|
||||
"group": {
|
||||
"all": "全部"
|
||||
},
|
||||
"serverOnlineHistory": {
|
||||
"loading": "正在載入在線記錄",
|
||||
"originalSession": "原始連線",
|
||||
"dataCutoffHint": "直線:資料截止",
|
||||
"dataCutoff": "資料截止",
|
||||
"notYetRecorded": "尚未統計",
|
||||
"outsideWindow": "未納入統計",
|
||||
"days1": "1 天",
|
||||
"days7": "7 天",
|
||||
"days30": "30 天",
|
||||
"onlineRate": "線上率",
|
||||
"totalOnline": "累計在線",
|
||||
"totalOffline": "累計離線",
|
||||
"lastActive": "最後活躍",
|
||||
"dailyOnline": "每日線上時長",
|
||||
"periods": "線上時間段",
|
||||
"ongoing": "進行中",
|
||||
"noData": "暫無線上紀錄",
|
||||
"loginRequired": "此視窗需要登入後查看,訪客可切換到 1 天",
|
||||
"offlinePeriods": "Offline periods",
|
||||
"periodsOnline": "在線",
|
||||
"periodsOffline": "離線",
|
||||
"expandMore": "Expand more",
|
||||
"today": "Today"
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,10 @@ import { WebSocketProvider } from "./context/websocket-provider";
|
||||
import { initCustomConfig } from "./lib/custom-config";
|
||||
import "./i18n";
|
||||
import "./index.css";
|
||||
// Bundled instead of loaded from jsdelivr: the CDN <link> in index.html was
|
||||
// render-blocking and unreliable on mainland networks. Vite hashes the flag
|
||||
// sprites into assets/, so they inherit the immutable cache headers.
|
||||
import "flag-icons/css/flag-icons.min.css";
|
||||
|
||||
initCustomConfig();
|
||||
|
||||
|
||||
+27
-7
@@ -19,11 +19,16 @@ import ServerCard from "@/components/ServerCard";
|
||||
import ServerCardInline from "@/components/ServerCardInline";
|
||||
import ServerOverview from "@/components/ServerOverview";
|
||||
import { ServiceTracker } from "@/components/ServiceTracker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SORT_TYPES } from "@/context/sort-context";
|
||||
import { useSort } from "@/hooks/use-sort";
|
||||
import { useStatus } from "@/hooks/use-status";
|
||||
import { useWebSocketContext } from "@/hooks/use-websocket-context";
|
||||
import { fetchServerGroup, fetchService } from "@/lib/nezha-api";
|
||||
import {
|
||||
fetchServerGroup,
|
||||
fetchService,
|
||||
isUnauthorizedError,
|
||||
} from "@/lib/nezha-api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { NezhaServer, ServerGroup } from "@/types/nezha-api";
|
||||
|
||||
@@ -55,21 +60,36 @@ const getErrorMessage = (error: unknown) => {
|
||||
|
||||
function BackendErrorState({ error }: { error: unknown }) {
|
||||
const { t } = useTranslation();
|
||||
const unauthorized = isUnauthorizedError(error);
|
||||
const message = getErrorMessage(error);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-96 flex-col items-center justify-center px-4 text-center">
|
||||
<div className="flex max-w-md flex-col items-center gap-2">
|
||||
<p className="text-sm font-semibold text-stone-900 dark:text-stone-100">
|
||||
{t("error.backendUnavailableTitle")}
|
||||
{t(
|
||||
unauthorized
|
||||
? "error.loginRequiredTitle"
|
||||
: "error.backendUnavailableTitle",
|
||||
)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("error.backendUnavailableDescription")}
|
||||
{t(
|
||||
unauthorized
|
||||
? "error.loginRequiredDescription"
|
||||
: "error.backendUnavailableDescription",
|
||||
)}
|
||||
</p>
|
||||
{message && (
|
||||
<p className="mt-1 max-w-full break-words rounded-md bg-stone-100 px-2 py-1 text-xs text-stone-500 dark:bg-stone-800 dark:text-stone-400">
|
||||
{message}
|
||||
</p>
|
||||
{unauthorized ? (
|
||||
<Button asChild className="mt-2">
|
||||
<a href="/dashboard">{t("login")}</a>
|
||||
</Button>
|
||||
) : (
|
||||
message && (
|
||||
<p className="mt-1 max-w-full break-words rounded-md bg-stone-100 px-2 py-1 text-xs text-stone-500 dark:bg-stone-800 dark:text-stone-400">
|
||||
{message}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { Navigate, useParams } from "react-router-dom";
|
||||
import OnlineHistorySkeleton from "@/components/loading/OnlineHistorySkeleton";
|
||||
import NetworkChartLoading from "@/components/NetworkChartLoading";
|
||||
import ServerDetailChart from "@/components/ServerDetailChart";
|
||||
import ServerDetailOverview from "@/components/ServerDetailOverview";
|
||||
@@ -12,12 +13,18 @@ const NetworkChart = lazy(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
const ServerDetailOnlineHistory = lazy(() =>
|
||||
import("@/components/ServerDetailOnlineHistory").then((module) => ({
|
||||
default: module.default,
|
||||
})),
|
||||
);
|
||||
|
||||
export default function ServerDetail() {
|
||||
useEffect(() => {
|
||||
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
|
||||
}, []);
|
||||
|
||||
const tabs = ["Detail", "Network"];
|
||||
const tabs = ["Detail", "Network", "Online"];
|
||||
const [currentTab, setCurrentTab] = useState(tabs[0]);
|
||||
|
||||
const { id: server_id } = useParams();
|
||||
@@ -51,6 +58,11 @@ export default function ServerDetail() {
|
||||
<NetworkChart server_id={Number(server_id)} show={true} />
|
||||
</Suspense>
|
||||
)}
|
||||
{currentTab === tabs[2] && (
|
||||
<Suspense fallback={<OnlineHistorySkeleton />}>
|
||||
<ServerDetailOnlineHistory server_id={server_id} />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import dayjs from "dayjs";
|
||||
import type { ReactElement } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ServerDetailOnlineHistory from "@/components/ServerDetailOnlineHistory";
|
||||
import { createTestQueryClient } from "@/test/utils";
|
||||
import type { OnlineHistoryResponse } from "@/types/nezha-api";
|
||||
|
||||
const websocketMocks = vi.hoisted(() => ({
|
||||
lastData: null as { servers: { id: number; last_active: string }[] } | null,
|
||||
}));
|
||||
vi.mock("@/hooks/use-websocket-context", () => ({
|
||||
useWebSocketContext: () => websocketMocks,
|
||||
}));
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
fetchServerOnlineHistory: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/nezha-api", () => ({
|
||||
fetchServerOnlineHistory: apiMocks.fetchServerOnlineHistory,
|
||||
}));
|
||||
|
||||
function onlineHistoryResponse(maxDays = 30): OnlineHistoryResponse {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
server_id: 7,
|
||||
server_name: "edge-online",
|
||||
days: 7,
|
||||
max_days: maxDays,
|
||||
timezone: "UTC",
|
||||
window_start: "2026-10-07T04:00:00.000Z",
|
||||
window_end: "2026-10-14T12:00:00.000Z",
|
||||
total_online_seconds: 5400,
|
||||
online_rate: 0.89,
|
||||
currently_online: true,
|
||||
last_active: "2026-10-14T03:30:00.000Z",
|
||||
periods: [
|
||||
{
|
||||
start: "2026-10-13T08:00:00.000Z",
|
||||
end: "2026-10-13T09:30:00.000Z",
|
||||
ongoing: false,
|
||||
},
|
||||
{
|
||||
start: "2026-10-14T10:00:00.000Z",
|
||||
end: "2026-10-14T11:00:00.000Z",
|
||||
ongoing: true,
|
||||
},
|
||||
],
|
||||
daily: [
|
||||
{ date: "2026-10-13", online_seconds: 5400 },
|
||||
{ date: "2026-10-14", online_seconds: 3600 },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function renderWithQuery(ui: ReactElement) {
|
||||
return render(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
{ui}
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ServerDetailOnlineHistory", () => {
|
||||
beforeEach(() => {
|
||||
websocketMocks.lastData = null;
|
||||
apiMocks.fetchServerOnlineHistory.mockReset();
|
||||
});
|
||||
|
||||
it("renders the loading state while history data is unavailable", () => {
|
||||
apiMocks.fetchServerOnlineHistory.mockReturnValue(
|
||||
new Promise(() => undefined),
|
||||
);
|
||||
|
||||
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
|
||||
expect(
|
||||
screen.getByRole("status", { name: "serverOnlineHistory.loading" }),
|
||||
).toHaveAttribute("aria-busy", "true");
|
||||
expect(screen.getByTestId("online-history-loading")).toBeInTheDocument();
|
||||
expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledWith(7, 1);
|
||||
});
|
||||
|
||||
it("keeps existing content instead of flashing the skeleton while changing days", async () => {
|
||||
apiMocks.fetchServerOnlineHistory
|
||||
.mockResolvedValueOnce(onlineHistoryResponse())
|
||||
.mockReturnValue(new Promise(() => undefined));
|
||||
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
|
||||
await screen.findByText("0.9%");
|
||||
expect(
|
||||
screen.queryByTestId("online-history-loading"),
|
||||
).not.toBeInTheDocument();
|
||||
await userEvent
|
||||
.setup()
|
||||
.click(screen.getByText("serverOnlineHistory.days30"));
|
||||
await waitFor(() =>
|
||||
expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledWith(7, 30),
|
||||
);
|
||||
expect(screen.getByText("0.9%")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("online-history-loading"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders daily totals and all groups when fewer than three days are available", async () => {
|
||||
apiMocks.fetchServerOnlineHistory.mockResolvedValue(
|
||||
onlineHistoryResponse(),
|
||||
);
|
||||
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
|
||||
await screen.findByText("serverOnlineHistory.dailyOnline");
|
||||
expect(screen.getAllByText("online")).toHaveLength(3);
|
||||
expect(screen.getByText("0.9%")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("1h 30m")).toHaveLength(4);
|
||||
expect(screen.getAllByText("1h 0m")).toHaveLength(3);
|
||||
expect(screen.getByText("10-13")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("serverOnlineHistory.expandMore"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getAllByTestId("period-chip")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[1800, "1h 30m"],
|
||||
[7200, "0m"],
|
||||
[7500, "0m"],
|
||||
])("calculates offline time from the actual window with %i online seconds", async (seconds, expected) => {
|
||||
const response = onlineHistoryResponse();
|
||||
response.data.window_start = "2026-10-14T10:00:00.000Z";
|
||||
response.data.window_end = "2026-10-14T12:00:00.000Z";
|
||||
response.data.total_online_seconds = seconds;
|
||||
apiMocks.fetchServerOnlineHistory.mockResolvedValue(response);
|
||||
|
||||
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
|
||||
|
||||
const label = await screen.findByText("serverOnlineHistory.totalOffline");
|
||||
expect(label.parentElement).toHaveTextContent(expected);
|
||||
});
|
||||
|
||||
it("maps each day's periods onto the shared 0:00-24:00 timeline", async () => {
|
||||
apiMocks.fetchServerOnlineHistory.mockResolvedValue(
|
||||
onlineHistoryResponse(),
|
||||
);
|
||||
|
||||
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
|
||||
|
||||
expect(await screen.findByText(/0:00 – 24:00/)).toBeInTheDocument();
|
||||
|
||||
// 时间轴按面板时区(UTC)的挂钟定位:08:00 在 33.33%,1.5h 宽 6.25%
|
||||
const segments = await screen.findAllByTestId("online-segment");
|
||||
expect(segments).toHaveLength(2);
|
||||
expect(Number.parseFloat(segments[0].style.left)).toBeCloseTo(
|
||||
(8 / 24) * 100,
|
||||
2,
|
||||
);
|
||||
expect(Number.parseFloat(segments[0].style.width)).toBeCloseTo(
|
||||
(1.5 / 24) * 100,
|
||||
2,
|
||||
);
|
||||
expect(segments[0].style.minWidth).toBe("2px");
|
||||
});
|
||||
|
||||
it("keeps daily totals, carry-over chips and window markers consistent", async () => {
|
||||
const response = onlineHistoryResponse();
|
||||
Object.assign(response.data, {
|
||||
window_start: "2026-10-13T06:00:00.000Z",
|
||||
window_end: "2026-10-14T12:00:00.000Z",
|
||||
total_online_seconds: 9 * 3600,
|
||||
daily: [
|
||||
{ date: "2026-10-13", online_seconds: 7200 },
|
||||
{ date: "2026-10-14", online_seconds: 7 * 3600 },
|
||||
],
|
||||
periods: [
|
||||
{
|
||||
start: "2026-10-13T22:00:00.000Z",
|
||||
end: "2026-10-14T02:00:00.000Z",
|
||||
ongoing: false,
|
||||
},
|
||||
{
|
||||
start: "2026-10-14T03:00:00.000Z",
|
||||
end: "2026-10-14T04:00:00.000Z",
|
||||
ongoing: false,
|
||||
},
|
||||
{
|
||||
start: "2026-10-14T08:00:00.000Z",
|
||||
end: "2026-10-14T12:00:00.000Z",
|
||||
ongoing: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
apiMocks.fetchServerOnlineHistory.mockResolvedValue(response);
|
||||
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
|
||||
const rows = await screen.findAllByTestId("daily-online-day");
|
||||
expect(within(rows[1]).getByText("7h 0m")).toBeInTheDocument();
|
||||
expect(within(rows[1]).getAllByTestId("online-segment")).toHaveLength(3);
|
||||
const latestGroup = screen.getAllByTestId("period-day")[0];
|
||||
expect(within(latestGroup).getByText("7h 0m")).toBeInTheDocument();
|
||||
const chips = within(latestGroup).getAllByTestId("period-chip");
|
||||
expect(chips).toHaveLength(3);
|
||||
expect(chips[0]).toHaveTextContent("00:00 → 02:00");
|
||||
expect(screen.getByTestId("outside-window")).toHaveStyle({ width: "25%" });
|
||||
expect(screen.getByTestId("unrecorded-window")).toHaveStyle({
|
||||
left: "50%",
|
||||
width: "50%",
|
||||
});
|
||||
expect(screen.getByTestId("data-cutoff")).toHaveAccessibleName(
|
||||
"serverOnlineHistory.dataCutoff 12:00:00",
|
||||
);
|
||||
await userEvent.setup().hover(chips[0]);
|
||||
expect(await screen.findByRole("tooltip")).toHaveTextContent(
|
||||
"serverOnlineHistory.originalSession 10-13 22:00:00 → 10-14 02:00:00",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the compact mobile summary below the md breakpoint", async () => {
|
||||
apiMocks.fetchServerOnlineHistory.mockResolvedValue(
|
||||
onlineHistoryResponse(),
|
||||
);
|
||||
vi.stubGlobal(
|
||||
"matchMedia",
|
||||
vi.fn().mockImplementation(() => ({
|
||||
matches: false,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
})),
|
||||
);
|
||||
try {
|
||||
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
|
||||
|
||||
expect(await screen.findByTestId("summary-mobile")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("summary-desktop")).not.toBeInTheDocument();
|
||||
// 移动端:在线率 + 在线/离线双指标 + 最近活跃。
|
||||
expect(screen.getByText("0.9%")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("serverOnlineHistory.totalOffline"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("serverOnlineHistory.totalOnline"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("serverOnlineHistory.lastActive"),
|
||||
).toBeInTheDocument();
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the live last-active time when switching history windows", async () => {
|
||||
const user = userEvent.setup();
|
||||
websocketMocks.lastData = {
|
||||
servers: [{ id: 7, last_active: "2026-10-14T12:34:56.000Z" }],
|
||||
};
|
||||
apiMocks.fetchServerOnlineHistory.mockImplementation((_id, days) => {
|
||||
const response = onlineHistoryResponse();
|
||||
response.data.online_rate = days === 30 ? 50 : 90;
|
||||
response.data.last_active =
|
||||
days === 30 ? "2026-10-14T10:00:00.000Z" : "2026-10-14T11:00:00.000Z";
|
||||
return Promise.resolve(response);
|
||||
});
|
||||
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
|
||||
expect(await screen.findByText("12:34:56")).toBeInTheDocument();
|
||||
await user.click(screen.getByText("serverOnlineHistory.days30"));
|
||||
expect(await screen.findByText("50.0%")).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledWith(7, 30),
|
||||
);
|
||||
expect(screen.getByText("12:34:56")).toBeInTheDocument();
|
||||
await user.click(screen.getByText("serverOnlineHistory.days7"));
|
||||
expect(screen.getByText("12:34:56")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches the query window via the day selector", async () => {
|
||||
const user = userEvent.setup();
|
||||
apiMocks.fetchServerOnlineHistory.mockResolvedValue(
|
||||
onlineHistoryResponse(),
|
||||
);
|
||||
|
||||
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
|
||||
await screen.findByText("serverOnlineHistory.dailyOnline");
|
||||
|
||||
await user.click(screen.getByText("serverOnlineHistory.days30"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledWith(7, 30);
|
||||
});
|
||||
});
|
||||
|
||||
it("locks day options beyond the configured max window", async () => {
|
||||
const user = userEvent.setup();
|
||||
apiMocks.fetchServerOnlineHistory.mockResolvedValue(
|
||||
onlineHistoryResponse(7),
|
||||
);
|
||||
|
||||
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
|
||||
await screen.findByText("serverOnlineHistory.dailyOnline");
|
||||
|
||||
await user.click(screen.getByText("serverOnlineHistory.days30"));
|
||||
expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledTimes(1);
|
||||
expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledWith(7, 1);
|
||||
});
|
||||
|
||||
it("shows the login hint when the API rejects anonymous guests", async () => {
|
||||
apiMocks.fetchServerOnlineHistory.mockRejectedValue(
|
||||
new Error("unauthorized: only 1d data available for guests"),
|
||||
);
|
||||
|
||||
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
|
||||
|
||||
expect(
|
||||
await screen.findByText("serverOnlineHistory.loginRequired"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to one day when guests request a longer window", async () => {
|
||||
const user = userEvent.setup();
|
||||
apiMocks.fetchServerOnlineHistory
|
||||
.mockResolvedValueOnce(onlineHistoryResponse())
|
||||
.mockRejectedValueOnce(
|
||||
new Error("unauthorized: only 1d data available for guests"),
|
||||
)
|
||||
.mockResolvedValue(onlineHistoryResponse(1));
|
||||
|
||||
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
|
||||
|
||||
// 首屏直接请求 1d;手动选择 7d 被拒后自动回退到 1d。
|
||||
await screen.findByText("serverOnlineHistory.dailyOnline");
|
||||
expect(apiMocks.fetchServerOnlineHistory).toHaveBeenNthCalledWith(1, 7, 1);
|
||||
await user.click(screen.getByText("serverOnlineHistory.days7"));
|
||||
await waitFor(() => {
|
||||
expect(apiMocks.fetchServerOnlineHistory).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
7,
|
||||
1,
|
||||
);
|
||||
});
|
||||
expect(apiMocks.fetchServerOnlineHistory).toHaveBeenNthCalledWith(2, 7, 7);
|
||||
|
||||
// 更长窗口被锁定(游客上限 1 天),点击不再发起新请求
|
||||
const calls = apiMocks.fetchServerOnlineHistory.mock.calls.length;
|
||||
await user.click(screen.getByText("serverOnlineHistory.days30"));
|
||||
expect(apiMocks.fetchServerOnlineHistory).toHaveBeenNthCalledWith(3, 7, 1);
|
||||
expect(apiMocks.fetchServerOnlineHistory.mock.calls.length).toBe(calls);
|
||||
});
|
||||
|
||||
it("groups periods by the panel timezone from the response", async () => {
|
||||
const data = onlineHistoryResponse().data;
|
||||
// 面板时区非 UTC 才能暴露 dayjs .tz(input, zone) 忽略显式偏移的问题
|
||||
data.timezone = "Asia/Shanghai";
|
||||
data.window_start = "2026-01-01T16:00:00.000Z";
|
||||
data.window_end = "2026-01-02T16:00:00.000Z";
|
||||
data.daily = [{ date: "2026-01-02", online_seconds: 7200 }];
|
||||
data.periods = [
|
||||
{
|
||||
start: "2026-01-01T20:00:00.000Z",
|
||||
end: "2026-01-01T22:00:00.000Z",
|
||||
ongoing: false,
|
||||
},
|
||||
];
|
||||
apiMocks.fetchServerOnlineHistory.mockResolvedValue({
|
||||
success: true,
|
||||
data,
|
||||
});
|
||||
|
||||
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
|
||||
await screen.findByText("serverOnlineHistory.dailyOnline");
|
||||
|
||||
// 20:00Z 在面板时区(UTC+8)为 01-02 04:00:分组落到 01-02,段内时刻同步换算
|
||||
expect(screen.getByText("01-02")).toBeInTheDocument();
|
||||
expect(screen.queryByText("01-01")).not.toBeInTheDocument();
|
||||
const chip = screen.getByTestId("period-chip");
|
||||
expect(chip.textContent).toContain("04:00");
|
||||
expect(chip.textContent).toContain("06:00");
|
||||
});
|
||||
|
||||
it("shows three days initially and expands ten days per click", async () => {
|
||||
const user = userEvent.setup();
|
||||
const data = onlineHistoryResponse().data;
|
||||
const dates = Array.from({ length: 25 }, (_, i) =>
|
||||
dayjs("2026-10-14").subtract(i, "day").format("YYYY-MM-DD"),
|
||||
);
|
||||
data.window_start = `${dates[dates.length - 1]}T00:00:00.000Z`;
|
||||
data.daily = dates.map((date) => ({ date, online_seconds: 3600 }));
|
||||
data.periods = dates.map((date) => ({
|
||||
start: `${date}T06:00:00.000Z`,
|
||||
end: `${date}T07:00:00.000Z`,
|
||||
ongoing: false,
|
||||
}));
|
||||
apiMocks.fetchServerOnlineHistory.mockResolvedValue({
|
||||
success: true,
|
||||
data,
|
||||
});
|
||||
|
||||
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
|
||||
await screen.findByText("serverOnlineHistory.dailyOnline");
|
||||
|
||||
expect(screen.getAllByTestId("period-day")).toHaveLength(3);
|
||||
|
||||
await user.click(screen.getByText("serverOnlineHistory.expandMore"));
|
||||
expect(screen.getAllByTestId("period-day")).toHaveLength(13);
|
||||
|
||||
await user.click(screen.getByText("serverOnlineHistory.expandMore"));
|
||||
expect(screen.getAllByTestId("period-day")).toHaveLength(23);
|
||||
|
||||
await user.click(screen.getByText("serverOnlineHistory.expandMore"));
|
||||
expect(screen.getAllByTestId("period-day")).toHaveLength(25);
|
||||
expect(
|
||||
screen.queryByText("serverOnlineHistory.expandMore"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("derives offline periods as the complement of online sessions", async () => {
|
||||
const user = userEvent.setup();
|
||||
const data = onlineHistoryResponse().data;
|
||||
data.window_start = "2026-10-14T00:00:00.000Z";
|
||||
data.window_end = "2026-10-14T12:00:00.000Z";
|
||||
data.currently_online = false;
|
||||
data.daily = [{ date: "2026-10-14", online_seconds: 7200 }];
|
||||
data.periods = [
|
||||
{
|
||||
start: "2026-10-14T02:00:00.000Z",
|
||||
end: "2026-10-14T04:00:00.000Z",
|
||||
ongoing: false,
|
||||
},
|
||||
];
|
||||
apiMocks.fetchServerOnlineHistory.mockResolvedValue({
|
||||
success: true,
|
||||
data,
|
||||
});
|
||||
|
||||
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
|
||||
await screen.findByText("serverOnlineHistory.dailyOnline");
|
||||
|
||||
await user.click(screen.getByText("serverOnlineHistory.periodsOffline"));
|
||||
|
||||
// 在线 02:00-04:00 两侧的空档各成一段,且服务器当前离线 → 末段进行中
|
||||
const chips = screen.getAllByTestId("period-chip");
|
||||
expect(chips).toHaveLength(2);
|
||||
expect(chips[0].textContent).toContain("00:00");
|
||||
expect(chips[0].textContent).toContain("2h 0m");
|
||||
expect(chips[1].textContent).toContain("04:00");
|
||||
expect(chips[1].textContent).toContain("8h 0m");
|
||||
expect(chips[1].textContent).toContain("serverOnlineHistory.ongoing");
|
||||
|
||||
// 组头合计 = 窗口时长 12h − 当日在线 2h = 10h
|
||||
expect(screen.getByText("10h 0m")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
fetchServerMetrics,
|
||||
fetchService,
|
||||
fetchSetting,
|
||||
isUnauthorizedError,
|
||||
} from "@/lib/nezha-api";
|
||||
|
||||
const jsonResponse = (body: unknown, init?: ResponseInit) =>
|
||||
@@ -52,6 +53,12 @@ describe("nezha api fetchers", () => {
|
||||
await expect(fetchService()).rejects.toThrow("service unavailable");
|
||||
});
|
||||
|
||||
it("detects guest-auth errors from the dashboard API", () => {
|
||||
expect(isUnauthorizedError(new Error("ApiErrorUnauthorized"))).toBe(true);
|
||||
expect(isUnauthorizedError(new Error("settings failed"))).toBe(false);
|
||||
expect(isUnauthorizedError(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("adds monitor and metrics query parameters when periods are provided", async () => {
|
||||
vi.mocked(fetch)
|
||||
.mockResolvedValueOnce(jsonResponse({ success: true, data: [] }))
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildDaySegments,
|
||||
buildDayWindow,
|
||||
buildOfflineGroups,
|
||||
dailyOnlineRate,
|
||||
formatPeriodMoment,
|
||||
groupOnlinePeriods,
|
||||
panelDay,
|
||||
panelNextDayStart,
|
||||
} from "@/lib/online-history";
|
||||
import type { DailyOnlineStat, OnlinePeriod } from "@/types/nezha-api";
|
||||
|
||||
function daily(date: string, onlineSeconds: number): DailyOnlineStat {
|
||||
return { date, online_seconds: onlineSeconds };
|
||||
}
|
||||
|
||||
function period(start: string, end: string, ongoing = false): OnlinePeriod {
|
||||
return { start, end, ongoing };
|
||||
}
|
||||
|
||||
function expectChipsInsideTheirDay(
|
||||
groups: ReturnType<typeof buildOfflineGroups>,
|
||||
) {
|
||||
for (const group of groups) {
|
||||
const dayStart = panelDay(group.date, "UTC");
|
||||
const dayEnd = panelNextDayStart(group.date, "UTC");
|
||||
for (const chip of group.chips) {
|
||||
expect(chip.start.valueOf()).toBeGreaterThanOrEqual(dayStart.valueOf());
|
||||
expect(chip.end.valueOf()).toBeLessThanOrEqual(dayEnd.valueOf());
|
||||
expect(chip.end.valueOf()).toBeGreaterThan(chip.start.valueOf());
|
||||
}
|
||||
for (let index = 1; index < group.chips.length; index += 1) {
|
||||
expect(group.chips[index].start.valueOf()).toBeGreaterThanOrEqual(
|
||||
group.chips[index - 1].end.valueOf(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("buildOfflineGroups", () => {
|
||||
it("never lets a gap run into a later day's online segment", () => {
|
||||
const groups = buildOfflineGroups(
|
||||
[
|
||||
period("2026-10-02T10:00:00.000Z", "2026-10-02T12:00:00.000Z"),
|
||||
period("2026-10-03T10:00:00.000Z", "2026-10-03T12:00:00.000Z"),
|
||||
],
|
||||
[
|
||||
daily("2026-10-01", 0),
|
||||
daily("2026-10-02", 7200),
|
||||
daily("2026-10-03", 7200),
|
||||
],
|
||||
"2026-10-01T00:00:00.000Z",
|
||||
"2026-10-04T00:00:00.000Z",
|
||||
true,
|
||||
"UTC",
|
||||
);
|
||||
|
||||
expect(groups).toHaveLength(3);
|
||||
expectChipsInsideTheirDay(groups);
|
||||
|
||||
// 10-01 没有在线段:应是一整段 00:00–24:00,而不是跨到 10-02 10:00
|
||||
const oldest = groups[2];
|
||||
expect(oldest.date).toBe("2026-10-01");
|
||||
expect(oldest.chips).toHaveLength(1);
|
||||
expect(oldest.chips[0].start.format("HH:mm")).toBe("00:00");
|
||||
expect(oldest.chips[0].end.format("HH:mm")).toBe("00:00");
|
||||
expect(oldest.chips[0].end.diff(oldest.chips[0].start, "hour")).toBe(24);
|
||||
expect(oldest.total).toBe(86400);
|
||||
|
||||
// 10-02 / 10-03 各自只有 00:00–10:00 与 12:00–24:00 两段
|
||||
for (const group of [groups[1], groups[0]]) {
|
||||
expect(group.chips).toHaveLength(2);
|
||||
expect(group.total).toBe(86400 - 7200);
|
||||
}
|
||||
});
|
||||
|
||||
it("starts the first day at window_start and ends the last day at window_end", () => {
|
||||
const groups = buildOfflineGroups(
|
||||
[period("2026-10-02T07:00:00.000Z", "2026-10-02T08:00:00.000Z")],
|
||||
[daily("2026-10-02", 3600), daily("2026-10-03", 0)],
|
||||
"2026-10-02T06:00:00.000Z",
|
||||
"2026-10-03T12:00:00.000Z",
|
||||
false,
|
||||
"UTC",
|
||||
);
|
||||
|
||||
expectChipsInsideTheirDay(groups);
|
||||
|
||||
const first = groups[1];
|
||||
expect(first.date).toBe("2026-10-02");
|
||||
// 窗口从 06:00 开始,不能凭空生成 00:00–06:00 的离线
|
||||
expect(first.chips[0].start.format("HH:mm")).toBe("06:00");
|
||||
expect(first.chips[0].end.format("HH:mm")).toBe("07:00");
|
||||
// 18h 窗口 − 1h 在线 = 17h
|
||||
expect(first.total).toBe(17 * 3600);
|
||||
|
||||
const last = groups[0];
|
||||
expect(last.date).toBe("2026-10-03");
|
||||
expect(last.chips).toHaveLength(1);
|
||||
expect(last.chips[0].start.format("HH:mm")).toBe("00:00");
|
||||
expect(last.chips[0].end.format("HH:mm")).toBe("12:00");
|
||||
expect(last.total).toBe(12 * 3600);
|
||||
// 服务器当前离线,且末段确实结束于窗口末端 → 标记进行中
|
||||
expect(last.chips[0].ongoing).toBe(true);
|
||||
});
|
||||
|
||||
it("does not mark an old day's last gap as ongoing", () => {
|
||||
const groups = buildOfflineGroups(
|
||||
[],
|
||||
[daily("2026-10-02", 0)],
|
||||
"2026-10-02T00:00:00.000Z",
|
||||
"2026-10-02T12:00:00.000Z",
|
||||
false,
|
||||
"UTC",
|
||||
);
|
||||
|
||||
// 窗口在当天中午结束,末段确实持续到窗口末端
|
||||
expect(groups[0].chips[groups[0].chips.length - 1]?.ongoing).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupOnlinePeriods", () => {
|
||||
it("splits cross-midnight sessions into the same segments as the daily timeline", () => {
|
||||
const periods = [
|
||||
period("2026-10-19T22:00:00.000Z", "2026-10-20T03:00:00.000Z", true),
|
||||
];
|
||||
const groups = groupOnlinePeriods(periods, "UTC");
|
||||
expect(groups.map((g) => [g.date, g.total])).toEqual([
|
||||
["2026-10-20", 10800],
|
||||
["2026-10-19", 7200],
|
||||
]);
|
||||
for (const group of groups) {
|
||||
const segments = buildDaySegments(periods, group.date, "UTC");
|
||||
expect(group.total).toBe(
|
||||
segments.reduce(
|
||||
(sum, s) => sum + s.segEnd.diff(s.segStart, "second"),
|
||||
0,
|
||||
),
|
||||
);
|
||||
expect(group.chips[0].original).toEqual(periods[0]);
|
||||
}
|
||||
expect(groups[0].chips[0].ongoing).toBe(true);
|
||||
expect(groups[1].chips[0].ongoing).toBe(false);
|
||||
});
|
||||
|
||||
it("clips both timeline and chips to the actual query window", () => {
|
||||
const periods = [
|
||||
period("2026-10-19T22:00:00.000Z", "2026-10-21T03:00:00.000Z", true),
|
||||
];
|
||||
const start = "2026-10-20T06:00:00.000Z",
|
||||
end = "2026-10-20T12:00:00.000Z";
|
||||
const [group] = groupOnlinePeriods(periods, "UTC", start, end);
|
||||
expect(group.total).toBe(21600);
|
||||
expect(group.chips[0].start.toISOString()).toBe(start);
|
||||
expect(group.chips[0].end.toISOString()).toBe(end);
|
||||
expect(group.chips[0].ongoing).toBe(false);
|
||||
const [segment] = buildDaySegments(periods, group.date, "UTC", start, end);
|
||||
expect(segment.leftPct).toBe(25);
|
||||
expect(segment.widthPct).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDayWindow", () => {
|
||||
it("distinguishes excluded hours from a partially observed day", () => {
|
||||
const window = buildDayWindow(
|
||||
"2026-10-20",
|
||||
"2026-10-20T06:00:00Z",
|
||||
"2026-10-20T12:00:00Z",
|
||||
"UTC",
|
||||
);
|
||||
expect(window.leftPct).toBe(25);
|
||||
expect(window.rightPct).toBe(50);
|
||||
expect(window.seconds).toBe(21600);
|
||||
expect(dailyOnlineRate(21600, window.seconds)).toBe(100);
|
||||
expect(dailyOnlineRate(0, 0)).toBe(0);
|
||||
});
|
||||
it.each([
|
||||
["2026-03-08", "2026-03-08T05:00:00Z", "2026-03-09T04:00:00Z", 23],
|
||||
["2026-11-01", "2026-11-01T04:00:00Z", "2026-11-02T05:00:00Z", 25],
|
||||
])("uses actual elapsed time across DST on %s", (date, start, end, hours) => {
|
||||
const window = buildDayWindow(date, start, end, "America/New_York");
|
||||
expect(window.seconds).toBe(Number(hours) * 3600);
|
||||
expect(window.leftPct).toBe(0);
|
||||
expect(window.rightPct).toBe(100);
|
||||
expect(dailyOnlineRate(window.seconds, window.seconds)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatPeriodMoment", () => {
|
||||
it("renders a segment ending at the next midnight as 24:00", () => {
|
||||
const day = panelDay("2026-10-01", "UTC");
|
||||
expect(
|
||||
formatPeriodMoment(panelNextDayStart("2026-10-01", "UTC"), day, "UTC"),
|
||||
).toBe("24:00");
|
||||
expect(
|
||||
formatPeriodMoment(
|
||||
panelNextDayStart("2026-10-01", "UTC"),
|
||||
day,
|
||||
"UTC",
|
||||
true,
|
||||
),
|
||||
).toBe("24:00:00");
|
||||
expect(
|
||||
formatPeriodMoment(
|
||||
panelDay("2026-10-01T23:00:00.000Z", "UTC"),
|
||||
day,
|
||||
"UTC",
|
||||
),
|
||||
).toBe("23:00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDaySegments", () => {
|
||||
it("maps positions by panel wall clock on a spring-forward day", () => {
|
||||
const tz = "America/New_York";
|
||||
const [fullDay] = buildDaySegments(
|
||||
[period("2026-03-08T05:00:00.000Z", "2026-03-09T04:00:00.000Z")],
|
||||
"2026-03-08",
|
||||
tz,
|
||||
);
|
||||
// 00:00 EST → 次日 00:00 EDT,全天应铺满 0–100%
|
||||
expect(fullDay.leftPct).toBe(0);
|
||||
expect(fullDay.widthPct).toBe(100);
|
||||
|
||||
const [noon] = buildDaySegments(
|
||||
[period("2026-03-08T16:00:00.000Z", "2026-03-08T18:00:00.000Z")],
|
||||
"2026-03-08",
|
||||
tz,
|
||||
);
|
||||
// 12:00 EDT 必须落在 50%,而不是 elapsed 11h 的 45.8%
|
||||
expect(noon.leftPct).toBeCloseTo(50, 5);
|
||||
expect(noon.widthPct).toBeCloseTo((2 / 24) * 100, 5);
|
||||
|
||||
const [evening] = buildDaySegments(
|
||||
[period("2026-03-09T02:00:00.000Z", "2026-03-09T04:00:00.000Z")],
|
||||
"2026-03-08",
|
||||
tz,
|
||||
);
|
||||
// 22:00 EST → 日界 00:00 EDT:日界必须用挂钟零点,不能加 24h
|
||||
expect(evening.leftPct).toBeCloseTo((22 / 24) * 100, 5);
|
||||
expect(evening.widthPct).toBeCloseTo((2 / 24) * 100, 5);
|
||||
});
|
||||
|
||||
it("keeps a full day at 100% on a fall-back day", () => {
|
||||
const [fullDay] = buildDaySegments(
|
||||
[period("2026-11-01T04:00:00.000Z", "2026-11-02T05:00:00.000Z")],
|
||||
"2026-11-01",
|
||||
"America/New_York",
|
||||
);
|
||||
expect(fullDay.leftPct).toBe(0);
|
||||
expect(fullDay.widthPct).toBe(100);
|
||||
});
|
||||
|
||||
it("marks only the segment ending at the live session end as ongoing", () => {
|
||||
const periods = [
|
||||
period("2026-10-01T22:00:00.000Z", "2026-10-02T06:00:00.000Z", true),
|
||||
];
|
||||
const [previousDay] = buildDaySegments(periods, "2026-10-01", "UTC");
|
||||
const [currentDay] = buildDaySegments(periods, "2026-10-02", "UTC");
|
||||
|
||||
// 前一天被日界裁剪,不能显示为 Ongoing
|
||||
expect(previousDay.live).toBe(false);
|
||||
expect(previousDay.segEnd.format("HH:mm")).toBe("00:00");
|
||||
// 当天这一段才真正结束于会话末端
|
||||
expect(currentDay.live).toBe(true);
|
||||
expect(currentDay.leftPct).toBe(0);
|
||||
expect(currentDay.widthPct).toBeCloseTo((6 / 24) * 100, 5);
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,15 @@ const apiMocks = vi.hoisted(() => ({
|
||||
fetchService: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/nezha-api", () => apiMocks);
|
||||
vi.mock("@/lib/nezha-api", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/nezha-api")>("@/lib/nezha-api");
|
||||
return {
|
||||
...actual,
|
||||
fetchServerGroup: apiMocks.fetchServerGroup,
|
||||
fetchService: apiMocks.fetchService,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/GlobalMap", () => ({
|
||||
default: ({ serverList }: { serverList: NezhaServer[] }) => (
|
||||
@@ -237,6 +245,32 @@ describe("Servers page", () => {
|
||||
expect(screen.getByText("group failed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("prompts guests to log in when the API returns ApiErrorUnauthorized", async () => {
|
||||
apiMocks.fetchServerGroup.mockRejectedValue(
|
||||
new Error("ApiErrorUnauthorized"),
|
||||
);
|
||||
|
||||
renderServerPage({
|
||||
connected: false,
|
||||
lastData: null,
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByText("error.loginRequiredTitle"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("error.loginRequiredDescription"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "login" })).toHaveAttribute(
|
||||
"href",
|
||||
"/dashboard",
|
||||
);
|
||||
expect(
|
||||
screen.queryByText("error.backendUnavailableTitle"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("ApiErrorUnauthorized")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("summarizes online and offline servers from websocket data", async () => {
|
||||
const online = createServer({ id: 1, name: "alpha" });
|
||||
const offline = createServer({
|
||||
|
||||
@@ -45,6 +45,12 @@ export interface NezhaServerStatus {
|
||||
process_count: number;
|
||||
temperatures: temperature[];
|
||||
gpu: number[];
|
||||
// Per-card figures; index-aligned with host.gpu. Absent on older agents.
|
||||
gpus?: {
|
||||
utilization: number;
|
||||
memory_used?: number;
|
||||
memory_total?: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface temperature {
|
||||
@@ -204,3 +210,36 @@ export interface ServerMetricsResponse {
|
||||
success: boolean;
|
||||
data: ServerMetricsData;
|
||||
}
|
||||
|
||||
export interface OnlinePeriod {
|
||||
start: string;
|
||||
end: string;
|
||||
ongoing: boolean;
|
||||
}
|
||||
|
||||
export interface DailyOnlineStat {
|
||||
date: string;
|
||||
online_seconds: number;
|
||||
}
|
||||
|
||||
export interface OnlineHistoryData {
|
||||
server_id: number;
|
||||
server_name: string;
|
||||
days: number;
|
||||
max_days: number;
|
||||
/** 面板时区(Daily 拆日的日界),前端按同一时区渲染 */
|
||||
timezone?: string;
|
||||
window_start: string;
|
||||
window_end: string;
|
||||
total_online_seconds: number;
|
||||
online_rate: number;
|
||||
currently_online: boolean;
|
||||
last_active?: string;
|
||||
periods: OnlinePeriod[];
|
||||
daily: DailyOnlineStat[];
|
||||
}
|
||||
|
||||
export interface OnlineHistoryResponse {
|
||||
success: boolean;
|
||||
data: OnlineHistoryData;
|
||||
}
|
||||
@@ -4,6 +4,16 @@ import path from "node:path";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// Node ≥25 默认启用实验性 webstorage:其全局 localStorage 在无有效存储文件时
|
||||
// 会遮蔽 jsdom 注入的同名全局(localStorage.clear 变为非函数)。测试 worker
|
||||
// 进程统一禁用,保证 jsdom 的 storage 生效。
|
||||
process.env.NODE_OPTIONS = [
|
||||
process.env.NODE_OPTIONS,
|
||||
"--no-experimental-webstorage",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
// Get git commit hash
|
||||
const getGitHash = () => {
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user