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 ( + + + + + + {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 ( +
+ +
+ ); +} 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 {