diff --git a/README.md b/README.md
index c793e10..fa0bcde 100644
--- a/README.md
+++ b/README.md
@@ -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 节点规模。
diff --git a/scripts/mock-nezha-server.js b/scripts/mock-nezha-server.js
index 1edd80a..de5c5c3 100644
--- a/scripts/mock-nezha-server.js
+++ b/scripts/mock-nezha-server.js
@@ -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, {
diff --git a/src/components/ServerDetailOnlineHistory.tsx b/src/components/ServerDetailOnlineHistory.tsx
new file mode 100644
index 0000000..af93f8c
--- /dev/null
+++ b/src/components/ServerDetailOnlineHistory.tsx
@@ -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 {children};
+}
+
+// 四项指标共用行高,辅助信息在底部对齐。
+function SummaryMetric({
+ label,
+ children,
+ footer,
+}: {
+ label: ReactNode;
+ children: ReactNode;
+ footer: ReactNode;
+}) {
+ return (
+
+
{label}
+
+ {children}
+
+
+ {footer}
+
+
+ );
+}
+
+// 概要卡的信息盒:标签在上、数值在下
+function SummaryBox({
+ label,
+ children,
+}: {
+ label: ReactNode;
+ children: ReactNode;
+}) {
+ return (
+
+ {label}
+ {children}
+
+ );
+}
+
+function WindowShade({
+ left,
+ width,
+ label,
+ striped = false,
+}: {
+ left: number;
+ width: number;
+ label: string;
+ striped?: boolean;
+}) {
+ if (width <= 0) return null;
+ return (
+
+
+
+
+ {label}
+
+ );
+}
+
+function DaysSelector({
+ selectedDays,
+ maxDays,
+ onDaysChange,
+}: {
+ selectedDays: number;
+ maxDays: number;
+ onDaysChange: (days: number) => void;
+}) {
+ const { t } = useTranslation();
+ const { containerRef, enableIndicatorAnimation, indicator, setItemRef } =
+ useActiveIndicator(DAY_OPTIONS, selectedDays);
+
+ return (
+
+ {indicator && (
+
+ )}
+ {DAY_OPTIONS.map((days, index) => {
+ const isLocked = days > maxDays;
+ return (
+
{
+ 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",
+ )}
+ >
+
+ {t(`serverOnlineHistory.days${days}`)}
+
+
+ );
+ })}
+
+ );
+}
+
+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 (
+
+ {indicator && (
+
+ )}
+ {options.map((option, index) => (
+
{
+ 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",
+ )}
+ >
+
+ {t(
+ option === "online"
+ ? "serverOnlineHistory.periodsOnline"
+ : "serverOnlineHistory.periodsOffline",
+ )}
+
+
+ ))}
+
+ );
+}
+
+export default function ServerDetailOnlineHistory({
+ server_id,
+}: {
+ server_id: string;
+}) {
+ const { t } = useTranslation();
+ const { lastData } = useWebSocketContext();
+ const [days, setDays] = useState(DAY_OPTIONS[1]);
+ const [guestMaxDays, setGuestMaxDays] = useState(null);
+ const [periodMode, setPeriodMode] = useState("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 ;
+ }
+
+ return (
+
+
+ {online && (
+
+
+ {online.currently_online ? t("online") : t("offline")}
+
+ )}
+ {
+ setDays(nextDays);
+ setVisibleDays(PERIODS_INITIAL_DAYS);
+ }}
+ />
+
+
+
+ {isError && !online ? (
+
+ {t("serverOnlineHistory.loginRequired")}
+
+ ) : (
+ online &&
+ (isDesktop ? (
+
+ }
+ >
+ {online.online_rate.toFixed(1)}%
+
+
+ {formatDuration(online.total_online_seconds)}
+
+
+ {formatDuration(offlineSeconds)}
+
+
+ {lastActive
+ ? panelDay(lastActive, tz).format("HH:mm:ss")
+ : "N/A"}
+
+
+ ) : (
+
+
+
+
+ {t("serverOnlineHistory.onlineRate")}
+
+
+
+ {online.online_rate.toFixed(1)}%
+
+
+
+
+ {windowRange}
+
+
+
+
+ {t("serverOnlineHistory.lastActive")}
+
+
+ {lastActive
+ ? panelDay(lastActive, tz).format("HH:mm:ss")
+ : "N/A"}
+
+ {lastActive && (
+
+ {panelDay(lastActive, tz).format("YYYY-MM-DD")}
+
+ )}
+
+
+
+
+
+ {formatDuration(online.total_online_seconds)}
+
+
+
+
+ {formatDuration(offlineSeconds)}
+
+
+
+
+ ))
+ )}
+
+
+
+
+
+
+
+ {t("serverOnlineHistory.dailyOnline")}
+
+ {/* 面板时区下的 0:00–24:00 挂钟轴 */}
+
+ 0:00 – 24:00{tz ? ` · ${tz}` : ""}
+
+
+
+ {/* 宽屏两列一行两日;末行为奇数时占满整行 */}
+
+ {dailyRows.map((day) => {
+ const segments = day.segments;
+ return (
+
+
+
+ {day.date}
+
+
+ {formatDuration(day.online_seconds)}
+
+
+
+
+
+
+ {segments.map((segment) => (
+
+
+
+
+
+
+
+ {segment.segStart.format("HH:mm:ss")}
+ {" → "}
+ {segment.live
+ ? t("serverOnlineHistory.ongoing")
+ : formatPeriodMoment(
+ segment.segEnd,
+ segment.segStart,
+ tz,
+ true,
+ )}
+
+
+ {formatDuration(
+ segment.segEnd.diff(
+ segment.segStart,
+ "second",
+ ),
+ )}
+
+
+
+
+ ))}
+ {[25, 50, 75].map((position) => (
+
+ ))}
+
+ {day.window.seconds > 0 &&
+ day.window.rightPct > 0 &&
+ day.window.rightPct < 100 && (
+
+
+
+
+
+ {t("serverOnlineHistory.dataCutoff")}{" "}
+ {day.window.end.format("HH:mm:ss")}
+
+
+ )}
+
+
+ {[0, 6, 12, 18, 24].map((hour) => (
+ {hour}
+ ))}
+
+
+ );
+ })}
+
+
+
+
+ {t("serverOnlineHistory.outsideWindow")}
+
+
+
+ {t("serverOnlineHistory.notYetRecorded")}
+
+ {t("serverOnlineHistory.dataCutoffHint")}
+
+
+
+
+
+
+
+
+
+ {t(
+ periodMode === "online"
+ ? "serverOnlineHistory.periods"
+ : "serverOnlineHistory.offlinePeriods",
+ )}
+
+
{
+ setPeriodMode(nextMode);
+ setVisibleDays(PERIODS_INITIAL_DAYS);
+ }}
+ />
+
+ {(() => {
+ if (!online) {
+ return null;
+ }
+ const groups = periodGroups;
+ const visibleGroups = groups.slice(0, visibleDays);
+ if (groups.length === 0) {
+ return (
+
+ {t("serverOnlineHistory.noData")}
+
+ );
+ }
+ return (
+
+
+ {visibleGroups.map((group) => (
+
+
+
+ {group.day.format("MM-DD")}
+ {group.day.isSame(panelNow(tz), "day") && (
+
+ {" · "}
+ {t("serverOnlineHistory.today")}
+
+ )}
+
+
+ {t(periodMode === "online" ? "online" : "offline")}{" "}
+ {formatDuration(group.total)}
+
+
+
+ {group.chips.map((chip, index) => {
+ const seconds = chip.end.diff(chip.start, "second");
+ return (
+
+
+
+ {chip.ongoing && (
+
+ )}
+ {chip.start.format("HH:mm")}
+ {" → "}
+ {chip.ongoing
+ ? t("serverOnlineHistory.ongoing")
+ : formatPeriodMoment(
+ chip.end,
+ chip.start,
+ tz,
+ )}
+
+ {formatDuration(seconds)}
+
+
+
+
+ {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),
+ )) && (
+
+ {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")}
+
+ )}
+
+
+ );
+ })}
+
+
+ ))}
+ {groups.length > visibleDays && (
+
+ )}
+
+
+ );
+ })()}
+
+
+
+ );
+}
diff --git a/src/components/loading/OnlineHistorySkeleton.tsx b/src/components/loading/OnlineHistorySkeleton.tsx
new file mode 100644
index 0000000..c927d68
--- /dev/null
+++ b/src/components/loading/OnlineHistorySkeleton.tsx
@@ -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 (
+
+ );
+}
+
+function Metric({ footer = true }: { footer?: boolean }) {
+ return (
+
+
+
+
+
+ {footer && (
+
+
+
+ )}
+
+ );
+}
+
+export default function OnlineHistorySkeleton({ days = 7 }: { 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 (
+
+
+
+
+
+
+ {Array.from({ length: 4 }, (_, index) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: dayCount }, (_, index) => (
+
+
+
+
+
+
+
+ {Array.from({ length: 5 }, (_, tick) => (
+
+ ))}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: Math.min(3, dayCount) }, (_, index) => (
+
+ ))}
+ {dayCount > 3 && (
+
+
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/hooks/use-media-query.ts b/src/hooks/use-media-query.ts
new file mode 100644
index 0000000..5e6d725
--- /dev/null
+++ b/src/hooks/use-media-query.ts
@@ -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;
+}
diff --git a/src/lib/nezha-api.ts b/src/lib/nezha-api.ts
index 32a1546..a85d3ac 100644
--- a/src/lib/nezha-api.ts
+++ b/src/lib/nezha-api.ts
@@ -3,6 +3,7 @@ import type {
MetricPeriod,
MetricType,
MonitorResponse,
+ OnlineHistoryResponse,
ServerGroupResponse,
ServerMetricsResponse,
ServiceResponse,
@@ -118,3 +119,18 @@ export const fetchServerMetrics = async (
}
return data;
};
+
+export const fetchServerOnlineHistory = async (
+ server_id: number,
+ days?: number,
+): Promise => {
+ 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;
+};
diff --git a/src/lib/online-history.ts b/src/lib/online-history.ts
new file mode 100644
index 0000000..b6b2ba7
--- /dev/null
+++ b/src/lib/online-history.ts
@@ -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,
+ 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();
+ 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;
+}
diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json
index 2612943..6d792d4 100644
--- a/src/locales/de/translation.json
+++ b/src/locales/de/translation.json
@@ -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"
}
}
diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json
index 0b6a474..bd29516 100644
--- a/src/locales/en/translation.json
+++ b/src/locales/en/translation.json
@@ -121,7 +121,8 @@
},
"tabSwitch": {
"Detail": "Detail",
- "Network": "Network"
+ "Network": "Network",
+ "Online": "Online"
},
"monitor": {
"noData": "No server monitor data, please add a service monitor first",
@@ -176,5 +177,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"
}
}
diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json
index cd9eebc..8fbc77c 100644
--- a/src/locales/es/translation.json
+++ b/src/locales/es/translation.json
@@ -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"
}
}
diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json
index 855ae21..9531861 100644
--- a/src/locales/fr/translation.json
+++ b/src/locales/fr/translation.json
@@ -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"
}
}
diff --git a/src/locales/gl/translation.json b/src/locales/gl/translation.json
index 3de11df..86d21ee 100644
--- a/src/locales/gl/translation.json
+++ b/src/locales/gl/translation.json
@@ -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"
+ }
}
diff --git a/src/locales/id/translation.json b/src/locales/id/translation.json
index 7cb3e74..ec3165a 100644
--- a/src/locales/id/translation.json
+++ b/src/locales/id/translation.json
@@ -114,7 +114,8 @@
},
"tabSwitch": {
"Detail": "Detail",
- "Network": "Jaringan"
+ "Network": "Jaringan",
+ "Online": "Online"
},
"monitor": {
"noData": "Tidak ada data pemantauan server, harap tambahkan pemantauan layanan terlebih dahulu",
@@ -169,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"
}
}
diff --git a/src/locales/ja/translation.json b/src/locales/ja/translation.json
index db7758c..9813326 100644
--- a/src/locales/ja/translation.json
+++ b/src/locales/ja/translation.json
@@ -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"
}
}
diff --git a/src/locales/pt_BR/translation.json b/src/locales/pt_BR/translation.json
index 745af41..8537c75 100644
--- a/src/locales/pt_BR/translation.json
+++ b/src/locales/pt_BR/translation.json
@@ -114,7 +114,8 @@
},
"tabSwitch": {
"Detail": "Detalhes",
- "Network": "Rede"
+ "Network": "Rede",
+ "Online": "Online"
},
"monitor": {
"noData": "Sem servidores para monitorar. por favor adicione um primeiro monitor de serviço",
@@ -165,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"
}
}
diff --git a/src/locales/ro/translation.json b/src/locales/ro/translation.json
index 9638c12..1e78388 100644
--- a/src/locales/ro/translation.json
+++ b/src/locales/ro/translation.json
@@ -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"
+ }
}
diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json
index 4d8d247..0365a39 100644
--- a/src/locales/ru/translation.json
+++ b/src/locales/ru/translation.json
@@ -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"
}
}
diff --git a/src/locales/ta/translation.json b/src/locales/ta/translation.json
index 5378064..1085a1f 100644
--- a/src/locales/ta/translation.json
+++ b/src/locales/ta/translation.json
@@ -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"
}
}
diff --git a/src/locales/uk/translation.json b/src/locales/uk/translation.json
index be0878f..668a903 100644
--- a/src/locales/uk/translation.json
+++ b/src/locales/uk/translation.json
@@ -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"
+ }
}
diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json
index bc421ce..8bd769e 100644
--- a/src/locales/zh-CN/translation.json
+++ b/src/locales/zh-CN/translation.json
@@ -115,7 +115,8 @@
},
"tabSwitch": {
"Detail": "详情",
- "Network": "网络"
+ "Network": "网络",
+ "Online": "在线"
},
"monitor": {
"noData": "没有服务监控数据,请在管理后台服务页添加监控任务",
@@ -170,5 +171,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": "今天"
}
}
diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json
index d88d4a6..9213f19 100644
--- a/src/locales/zh-TW/translation.json
+++ b/src/locales/zh-TW/translation.json
@@ -114,7 +114,8 @@
"detail": "詳細資訊",
"network": "網路",
"Detail": "詳細資訊",
- "Network": "網路"
+ "Network": "網路",
+ "Online": "線上"
},
"monitor": {
"noData": "沒有伺服器監控資料,請先新增服務監控",
@@ -170,5 +171,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"
}
}
diff --git a/src/pages/ServerDetail.tsx b/src/pages/ServerDetail.tsx
index dac573d..1b81de2 100644
--- a/src/pages/ServerDetail.tsx
+++ b/src/pages/ServerDetail.tsx
@@ -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() {
)}
+ {currentTab === tabs[2] && (
+ }>
+
+
+ )}
);
}
diff --git a/src/test/components/server-detail-online-history.test.tsx b/src/test/components/server-detail-online-history.test.tsx
new file mode 100644
index 0000000..207d4a8
--- /dev/null
+++ b/src/test/components/server-detail-online-history.test.tsx
@@ -0,0 +1,446 @@
+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(
+
+ {ui}
+ ,
+ );
+}
+
+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();
+ expect(
+ screen.getByRole("status", { name: "serverOnlineHistory.loading" }),
+ ).toHaveAttribute("aria-busy", "true");
+ expect(screen.getByTestId("online-history-loading")).toBeInTheDocument();
+ expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledWith(7, 7);
+ });
+
+ it("keeps existing content instead of flashing the skeleton while changing days", async () => {
+ apiMocks.fetchServerOnlineHistory
+ .mockResolvedValueOnce(onlineHistoryResponse())
+ .mockReturnValue(new Promise(() => undefined));
+ renderWithQuery();
+ 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();
+ 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();
+
+ 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();
+
+ 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();
+ 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();
+
+ 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();
+ 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();
+ 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();
+ await screen.findByText("serverOnlineHistory.dailyOnline");
+
+ await user.click(screen.getByText("serverOnlineHistory.days30"));
+ expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledTimes(1);
+ expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledWith(7, 7);
+ });
+
+ 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();
+
+ expect(
+ await screen.findByText("serverOnlineHistory.loginRequired"),
+ ).toBeInTheDocument();
+ });
+
+ it("falls back to one day and retries automatically for guests", async () => {
+ const user = userEvent.setup();
+ apiMocks.fetchServerOnlineHistory
+ .mockRejectedValueOnce(
+ new Error("unauthorized: only 1d data available for guests"),
+ )
+ .mockResolvedValue(onlineHistoryResponse());
+
+ renderWithQuery();
+
+ // 首次 7d 被拒后自动以 1d 重试并渲染数据
+ await screen.findByText("serverOnlineHistory.dailyOnline");
+ await waitFor(() => {
+ expect(apiMocks.fetchServerOnlineHistory).toHaveBeenNthCalledWith(
+ 2,
+ 7,
+ 1,
+ );
+ });
+
+ // 更长窗口被锁定(游客上限 1 天),点击不再发起新请求
+ const calls = apiMocks.fetchServerOnlineHistory.mock.calls.length;
+ await user.click(screen.getByText("serverOnlineHistory.days30"));
+ expect(apiMocks.fetchServerOnlineHistory).toHaveBeenNthCalledWith(2, 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();
+ 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();
+ 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();
+ 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();
+ });
+});
diff --git a/src/test/lib/online-history.test.ts b/src/test/lib/online-history.test.ts
new file mode 100644
index 0000000..a0763a8
--- /dev/null
+++ b/src/test/lib/online-history.test.ts
@@ -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,
+) {
+ 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);
+ });
+});
diff --git a/src/types/nezha-api.ts b/src/types/nezha-api.ts
index 4f8f6f8..b1bbf91 100644
--- a/src/types/nezha-api.ts
+++ b/src/types/nezha-api.ts
@@ -205,3 +205,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;
+}
diff --git a/vite.config.ts b/vite.config.ts
index 23f21e5..d195f1f 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -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 {