mirror of
https://github.com/Buriburizaem0n/nezha-dash-v1.git
synced 2026-11-04 01:00:20 +00:00
feat: add server online history tab with date-grouped chip wall (#97)
* feat(server-detail): add online history tab Render per-server online periods from GET /api/v1/server/:id/online-history: summary card (current status, online rate, total in window, last active), daily online-time bars, and the raw period list. Window selector offers 1d/7d/30d and locks options beyond the server's max_days; guests hitting the 1d cap see a login hint. i18n keys added for all locales (zh-CN/zh-TW translated, others fall back to English for Weblate). * fix(online-history): keep selector pills above the sliding indicator The active-indicator overlay sits at z-10 and intercepted clicks on the day pills; wrap pills at z-20 like the existing PeriodSelector. * feat(online-history): render daily online as 24h timeline with hover tooltips 每日在线卡片从整日占比进度条改为 0:00–24:00 整轴时间条: 每个在线时段按当日时刻定位为高亮分段,hover 显示起止时间与时长; 进行中时段以亮色高亮。卡片顶部新增共享刻度(0:00 / 12:00 / 24:00)。 * feat(online-history): regroup periods by day as expandable chip wall Rework the Online tab per the chip-wall design: periods are grouped by panel-timezone day with per-day totals, defaulting to the latest day with "Expand more" revealing up to ten more groups per click, plus an Online/Offline switch that derives offline chips as the window complement. - respond to the backend panel timezone (timezone/tz_offset): dayjs 1.11 .tz(input, zone) ignores explicit offsets, so offset-bearing timestamps are parsed first and converted afterwards; naive date strings keep the zone-parse path (panelDay) - guests falling back automatically: a rejected days>1 request locks 7d/30d and retries at 1d instead of surfacing only a login prompt - fix day-selector indicator landing on the wrong pill: useActiveIndicator refs are stored by index, pass the map index not the day value - disable Node experimental webstorage in vitest workers: Node >=25 ships a broken global localStorage that shadows the jsdom one - 14 locales: tabSwitch.Online + serverOnlineHistory.* * feat(online-history): boxed summary stats with desktop/mobile layouts * fix(online-history): align offline gaps, DST coordinates and ongoing chips - extract pure timeline/grouping helpers into src/lib/online-history.ts - clamp offline complements to day and window boundaries, so a gap can no longer run into a later day's first online session - use wall-clock coordinates and true next-midnight boundaries on DST days - label only the segment ending at the live session end as Ongoing - keep group totals equal to the visible chips for cross-day sessions - keep period chips on one line and render next-midnight as 24:00 - add regression tests for multi-day offline gaps, window_start/window_end, spring-forward/fall-back geometry and ongoing clipping * style(online-history): lighten summary metrics and shorten rate bar * feat(online-history): refine UI and unify daily history rendering --------- Co-authored-by: Sam5440 <Sam5440@users.noreply.github.com> Co-authored-by: hamster1963 <1410514192@qq.com>
This commit is contained in:
26 files changed
+2604
-20
No files matched your search
@@ -5,3 +5,28 @@
|
||||
> [!NOTE]
|
||||
> 此项目为 nezha-dash 的官方实现,作为哪吒监控的默认前端,功能上可能与 nezha-dash 有所不同。
|
||||
> https://github.com/hamster1963/nezha-dash
|
||||
|
||||
## 本地调整 UI
|
||||
|
||||
使用 Node.js >= 22、pnpm >= 11,在项目目录运行:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
MOCK_NEZHA_COUNT=6 pnpm dev:mock
|
||||
```
|
||||
|
||||
该命令同时启动本地 mock API(8008 端口)和 Vite,使用仓库 `.cert` 下的 HTTPS 证书。打开终端显示的地址(通常为 `https://localhost:5173`);若浏览器提示本地证书不受信任,允许访问本地开发地址。
|
||||
|
||||
进入 `/server/1`,点击「在线 / Online」标签。在线历史支持 1/7/30 天切换、时间轴提示、在线/离线时段和「展开更多」。数据按当前时间生成,使用 `Asia/Shanghai` 时区;查询窗口是最近 N × 24 小时,因此每日列表通常会包含首尾两个不完整日期。
|
||||
|
||||
| 详情页 | 预览场景 |
|
||||
| --- | --- |
|
||||
| `/server/1`、`/server/6` | 正常在线,包含短暂掉线和跨日会话 |
|
||||
| `/server/2` | 持续在线,在线率 100% |
|
||||
| `/server/3` | 当前离线,包含进行中的离线时段 |
|
||||
| `/server/4` | 无在线历史,在线率 0%,最后活跃显示 N/A |
|
||||
| `/server/5` | 频繁掉线,用于查看密集时段标签和换行 |
|
||||
|
||||
主要样式位于 `src/components/ServerDetailOnlineHistory.tsx` 的 `className`,保存后自动热更新;桌面与手机布局的切换点是 768px。修改 mock 脚本后需重启 `pnpm dev:mock`,按 `Ctrl+C` 可停止前端和 mock 服务。
|
||||
|
||||
在线历史 mock 允许查询最多 30 天数据,无需真实后端或登录。不要同时运行占用 8008 端口的后端;省略 `MOCK_NEZHA_COUNT` 时仍使用原有的 3000 节点规模。
|
||||
@@ -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, {
|
||||
|
||||
@@ -0,0 +1,784 @@
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import OnlineHistorySkeleton from "@/components/loading/OnlineHistorySkeleton";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useActiveIndicator } from "@/hooks/use-active-indicator";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { useWebSocketContext } from "@/hooks/use-websocket-context";
|
||||
import { fetchServerOnlineHistory } from "@/lib/nezha-api";
|
||||
import {
|
||||
buildDailyOnlineRows,
|
||||
buildOfflineGroups,
|
||||
formatDuration,
|
||||
formatPeriodMoment,
|
||||
groupOnlineRows,
|
||||
panelDay,
|
||||
panelNow,
|
||||
} from "@/lib/online-history";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DAY_OPTIONS = [1, 7, 30];
|
||||
// Online periods 默认展开最近 3 天,每次「展开更多」追加 10 天
|
||||
const PERIODS_INITIAL_DAYS = 3;
|
||||
const PERIODS_EXPAND_STEP = 10;
|
||||
|
||||
type PeriodMode = "online" | "offline";
|
||||
|
||||
// 与详情页一致的轻量指标标签。
|
||||
function SummaryLabel({ children }: { children: ReactNode }) {
|
||||
return <span className="text-xs text-muted-foreground">{children}</span>;
|
||||
}
|
||||
|
||||
// 四项指标共用行高,辅助信息在底部对齐。
|
||||
function SummaryMetric({
|
||||
label,
|
||||
children,
|
||||
footer,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
children: ReactNode;
|
||||
footer: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col items-center gap-2 px-3 text-center lg:px-5">
|
||||
<span className="text-xs leading-4 text-muted-foreground">{label}</span>
|
||||
<div className="text-lg font-medium leading-7 tracking-tight tabular-nums text-foreground lg:text-[22px]">
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex min-h-4 items-center justify-center text-[11px] leading-4 text-muted-foreground tabular-nums">
|
||||
{footer}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 概要卡的信息盒:标签在上、数值在下
|
||||
function SummaryBox({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col items-center justify-center gap-1.5 text-center">
|
||||
<SummaryLabel>{label}</SummaryLabel>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WindowShade({
|
||||
left,
|
||||
width,
|
||||
label,
|
||||
striped = false,
|
||||
}: {
|
||||
left: number;
|
||||
width: number;
|
||||
label: string;
|
||||
striped?: boolean;
|
||||
}) {
|
||||
if (width <= 0) return null;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
data-testid={striped ? "outside-window" : "unrecorded-window"}
|
||||
className={cn(
|
||||
"absolute inset-y-0 text-stone-300 focus-visible:outline-2 focus-visible:outline-ring dark:text-stone-600",
|
||||
!striped && "bg-background/80",
|
||||
)}
|
||||
style={{
|
||||
left: `${left}%`,
|
||||
width: `${width}%`,
|
||||
backgroundImage: striped
|
||||
? "repeating-linear-gradient(135deg, transparent, transparent 3px, currentColor 3px, currentColor 5px)"
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function DaysSelector({
|
||||
selectedDays,
|
||||
maxDays,
|
||||
onDaysChange,
|
||||
}: {
|
||||
selectedDays: number;
|
||||
maxDays: number;
|
||||
onDaysChange: (days: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { containerRef, enableIndicatorAnimation, indicator, setItemRef } =
|
||||
useActiveIndicator<number>(DAY_OPTIONS, selectedDays);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-testid="days-selector"
|
||||
className="relative flex gap-0.5 flex-wrap p-0.5 bg-muted dark:bg-muted/40 rounded-full w-fit border border-border/60 dark:border-border"
|
||||
>
|
||||
{indicator && (
|
||||
<div
|
||||
className="active-indicator-fade-in absolute left-0 top-0 z-10 bg-white dark:bg-background rounded-full ring-1 ring-border/60 dark:ring-border/40"
|
||||
style={{
|
||||
height: indicator.height,
|
||||
transform: `translate(${indicator.x}px, ${indicator.y}px)`,
|
||||
transition: indicator.shouldAnimate
|
||||
? "transform 0.5s var(--timing), width 0.5s var(--timing), height 0.5s var(--timing)"
|
||||
: "none",
|
||||
width: indicator.width,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{DAY_OPTIONS.map((days, index) => {
|
||||
const isLocked = days > maxDays;
|
||||
return (
|
||||
<div
|
||||
key={days}
|
||||
ref={setItemRef(index)}
|
||||
onClick={() => {
|
||||
if (!isLocked) {
|
||||
if (selectedDays !== days) {
|
||||
enableIndicatorAnimation();
|
||||
}
|
||||
onDaysChange(days);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"relative z-20 cursor-pointer rounded-full px-3 py-1.5 text-xs font-medium transition-colors duration-300",
|
||||
selectedDays === days
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
isLocked && "cursor-not-allowed opacity-40 grayscale",
|
||||
)}
|
||||
>
|
||||
<p className="whitespace-nowrap">
|
||||
{t(`serverOnlineHistory.days${days}`)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PeriodsModeSwitch({
|
||||
mode,
|
||||
onModeChange,
|
||||
}: {
|
||||
mode: PeriodMode;
|
||||
onModeChange: (mode: PeriodMode) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const options: PeriodMode[] = ["online", "offline"];
|
||||
const { containerRef, enableIndicatorAnimation, indicator, setItemRef } =
|
||||
useActiveIndicator(options, mode);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-testid="period-mode-switch"
|
||||
className="relative ml-auto -mt-1 -mr-2 flex self-start gap-0.5 p-0.5 bg-muted dark:bg-muted/40 rounded-[8px] w-fit border border-border/60 dark:border-border sm:-mr-3"
|
||||
>
|
||||
{indicator && (
|
||||
<div
|
||||
className="active-indicator-fade-in absolute left-0 top-0 z-10 bg-white dark:bg-background rounded-[5px] ring-1 ring-border/60 dark:ring-border/40"
|
||||
style={{
|
||||
height: indicator.height,
|
||||
transform: `translate(${indicator.x}px, ${indicator.y}px)`,
|
||||
transition: indicator.shouldAnimate
|
||||
? "transform 0.5s var(--timing), width 0.5s var(--timing), height 0.5s var(--timing)"
|
||||
: "none",
|
||||
width: indicator.width,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{options.map((option, index) => (
|
||||
<div
|
||||
key={option}
|
||||
ref={setItemRef(index)}
|
||||
onClick={() => {
|
||||
if (mode !== option) {
|
||||
enableIndicatorAnimation();
|
||||
onModeChange(option);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"relative z-20 cursor-pointer rounded-[5px] px-2.5 py-1 text-xs font-medium transition-colors duration-300",
|
||||
mode === option
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<p className="whitespace-nowrap">
|
||||
{t(
|
||||
option === "online"
|
||||
? "serverOnlineHistory.periodsOnline"
|
||||
: "serverOnlineHistory.periodsOffline",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ServerDetailOnlineHistory({
|
||||
server_id,
|
||||
}: {
|
||||
server_id: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { lastData } = useWebSocketContext();
|
||||
const [days, setDays] = useState(DAY_OPTIONS[1]);
|
||||
const [guestMaxDays, setGuestMaxDays] = useState<number | null>(null);
|
||||
const [periodMode, setPeriodMode] = useState<PeriodMode>("online");
|
||||
const [visibleDays, setVisibleDays] = useState(PERIODS_INITIAL_DAYS);
|
||||
// 桌面为四列,手机为两行指标,两端统一使用在线率进度条。
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
const {
|
||||
data: historyData,
|
||||
isError,
|
||||
isPending,
|
||||
error: queryError,
|
||||
} = useQuery({
|
||||
queryKey: ["online-history", server_id, days],
|
||||
queryFn: () => fetchServerOnlineHistory(Number(server_id), days),
|
||||
placeholderData: keepPreviousData,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchInterval: 60000,
|
||||
});
|
||||
|
||||
// 游客仅能查 1 天:服务端拒绝 days>1 时自动回退到 1d 并锁定更长窗口,
|
||||
// 避免游客首屏只看到登录提示
|
||||
useEffect(() => {
|
||||
if (
|
||||
guestMaxDays === null &&
|
||||
queryError instanceof Error &&
|
||||
queryError.message.includes("only 1d data available for guests")
|
||||
) {
|
||||
setGuestMaxDays(1);
|
||||
setDays(1);
|
||||
}
|
||||
}, [guestMaxDays, queryError]);
|
||||
|
||||
const customBackgroundImage =
|
||||
(window.CustomBackgroundImage as string) !== ""
|
||||
? window.CustomBackgroundImage
|
||||
: undefined;
|
||||
|
||||
const online = historyData?.data;
|
||||
const tz = online?.timezone;
|
||||
const dailyRows = useMemo(
|
||||
() =>
|
||||
online
|
||||
? buildDailyOnlineRows(
|
||||
online.periods,
|
||||
online.daily,
|
||||
online.window_start,
|
||||
online.window_end,
|
||||
tz,
|
||||
)
|
||||
: [],
|
||||
[online, tz],
|
||||
);
|
||||
const periodGroups = useMemo(() => {
|
||||
if (!online) return [];
|
||||
return periodMode === "online"
|
||||
? groupOnlineRows(dailyRows, tz)
|
||||
: buildOfflineGroups(
|
||||
online.periods,
|
||||
online.daily,
|
||||
online.window_start,
|
||||
online.window_end,
|
||||
online.currently_online,
|
||||
tz,
|
||||
);
|
||||
}, [online, dailyRows, periodMode, tz]);
|
||||
// 最后活跃属于节点实时状态,不随 1/7/30 天查询缓存切换。
|
||||
// 历史尚无活跃记录时继续展示 N/A,实时数据不可用时回退到接口值。
|
||||
const lastActive = online?.last_active
|
||||
? lastData?.servers.find((server) => server.id === Number(server_id))
|
||||
?.last_active || online.last_active
|
||||
: undefined;
|
||||
// 使用接口返回的实际窗口,兼容不足整天的记录和时区切换。
|
||||
const windowSeconds = online
|
||||
? panelDay(online.window_end, tz).diff(
|
||||
panelDay(online.window_start, tz),
|
||||
"second",
|
||||
)
|
||||
: 0;
|
||||
const offlineSeconds = Math.max(
|
||||
0,
|
||||
windowSeconds - (online?.total_online_seconds ?? 0),
|
||||
);
|
||||
const windowRange = online
|
||||
? `${panelDay(online.window_start, tz).format("MM-DD")} – ${panelDay(online.window_end, tz).format("MM-DD")}`
|
||||
: "";
|
||||
|
||||
if (isPending) {
|
||||
return <OnlineHistorySkeleton days={days} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
{online && (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 text-xs font-medium md:px-1",
|
||||
online.currently_online
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"size-2 rounded-full",
|
||||
online.currently_online
|
||||
? "bg-green-500"
|
||||
: "bg-stone-400 dark:bg-stone-500",
|
||||
)}
|
||||
/>
|
||||
{online.currently_online ? t("online") : t("offline")}
|
||||
</span>
|
||||
)}
|
||||
<DaysSelector
|
||||
selectedDays={days}
|
||||
maxDays={guestMaxDays ?? online?.max_days ?? DAY_OPTIONS[2]}
|
||||
onDaysChange={(nextDays) => {
|
||||
setDays(nextDays);
|
||||
setVisibleDays(PERIODS_INITIAL_DAYS);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Card className="border-0 bg-transparent shadow-none ring-0 dark:shadow-none dark:ring-0">
|
||||
<CardContent className="px-0 py-2 md:px-6 md:py-4">
|
||||
{isError && !online ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("serverOnlineHistory.loginRequired")}
|
||||
</p>
|
||||
) : (
|
||||
online &&
|
||||
(isDesktop ? (
|
||||
<div
|
||||
className="grid grid-cols-4 divide-x divide-stone-100 dark:divide-stone-800"
|
||||
data-testid="summary-desktop"
|
||||
>
|
||||
<SummaryMetric
|
||||
label={t("serverOnlineHistory.onlineRate")}
|
||||
footer={
|
||||
<div className="h-1 w-20 overflow-hidden rounded-full bg-stone-100 dark:bg-stone-800">
|
||||
<div
|
||||
className="h-full rounded-full bg-green-500"
|
||||
style={{
|
||||
width: `${Math.min(100, Math.max(0, online.online_rate))}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{online.online_rate.toFixed(1)}%
|
||||
</SummaryMetric>
|
||||
<SummaryMetric
|
||||
label={t("serverOnlineHistory.totalOnline")}
|
||||
footer={windowRange}
|
||||
>
|
||||
{formatDuration(online.total_online_seconds)}
|
||||
</SummaryMetric>
|
||||
<SummaryMetric
|
||||
label={t("serverOnlineHistory.totalOffline")}
|
||||
footer={windowRange}
|
||||
>
|
||||
{formatDuration(offlineSeconds)}
|
||||
</SummaryMetric>
|
||||
<SummaryMetric
|
||||
label={t("serverOnlineHistory.lastActive")}
|
||||
footer={
|
||||
lastActive
|
||||
? panelDay(lastActive, tz).format("YYYY-MM-DD")
|
||||
: null
|
||||
}
|
||||
>
|
||||
{lastActive
|
||||
? panelDay(lastActive, tz).format("HH:mm:ss")
|
||||
: "N/A"}
|
||||
</SummaryMetric>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3" data-testid="summary-mobile">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex min-w-0 flex-col items-center justify-center gap-1.5 text-center">
|
||||
<SummaryLabel>
|
||||
{t("serverOnlineHistory.onlineRate")}
|
||||
</SummaryLabel>
|
||||
<div className="flex min-w-0 flex-col items-center justify-center gap-1.5">
|
||||
<span className="shrink-0 text-2xl font-medium leading-7 tracking-tight tabular-nums">
|
||||
{online.online_rate.toFixed(1)}%
|
||||
</span>
|
||||
<div className="h-1 w-20 overflow-hidden rounded-full bg-stone-100 dark:bg-stone-800">
|
||||
<div
|
||||
className="h-full rounded-full bg-green-500"
|
||||
style={{
|
||||
width: `${Math.min(100, Math.max(0, online.online_rate))}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[11px] leading-4 text-muted-foreground tabular-nums">
|
||||
{windowRange}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col items-center justify-center gap-1.5 text-center">
|
||||
<SummaryLabel>
|
||||
{t("serverOnlineHistory.lastActive")}
|
||||
</SummaryLabel>
|
||||
<span className="text-xl font-medium leading-7 tracking-tight tabular-nums">
|
||||
{lastActive
|
||||
? panelDay(lastActive, tz).format("HH:mm:ss")
|
||||
: "N/A"}
|
||||
</span>
|
||||
{lastActive && (
|
||||
<span className="text-[11px] leading-4 text-muted-foreground tabular-nums">
|
||||
{panelDay(lastActive, tz).format("YYYY-MM-DD")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 border-t border-stone-100 pt-3 dark:border-stone-800">
|
||||
<SummaryBox label={t("serverOnlineHistory.totalOnline")}>
|
||||
<span className="text-lg font-medium leading-6 tracking-tight tabular-nums text-foreground">
|
||||
{formatDuration(online.total_online_seconds)}
|
||||
</span>
|
||||
</SummaryBox>
|
||||
<SummaryBox label={t("serverOnlineHistory.totalOffline")}>
|
||||
<span className="text-lg font-medium leading-6 tracking-tight tabular-nums text-foreground">
|
||||
{formatDuration(offlineSeconds)}
|
||||
</span>
|
||||
</SummaryBox>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className={cn({ "bg-card/70": customBackgroundImage })}>
|
||||
<CardContent className="px-4 py-3 sm:px-5">
|
||||
<div className="flex min-h-8 flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-base font-medium">
|
||||
{t("serverOnlineHistory.dailyOnline")}
|
||||
</p>
|
||||
{/* 面板时区下的 0:00–24:00 挂钟轴 */}
|
||||
<span className="text-[11px] leading-4 text-muted-foreground tabular-nums">
|
||||
0:00 – 24:00{tz ? ` · ${tz}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<TooltipProvider delayDuration={100}>
|
||||
{/* 宽屏两列一行两日;末行为奇数时占满整行 */}
|
||||
<div className="mt-3 grid grid-cols-1 gap-2 md:grid-cols-2 md:[&>*:last-child:nth-child(odd)]:col-span-2">
|
||||
{dailyRows.map((day) => {
|
||||
const segments = day.segments;
|
||||
return (
|
||||
<div
|
||||
key={day.date}
|
||||
data-testid="daily-online-day"
|
||||
className="flex min-w-0 flex-col gap-2.5 rounded-lg bg-muted/40 px-2.5 py-2 dark:bg-muted/20"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2 text-xs">
|
||||
<span className="text-foreground tabular-nums">
|
||||
{day.date}
|
||||
</span>
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{formatDuration(day.online_seconds)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="relative h-2 overflow-hidden rounded-full bg-stone-200/70 dark:bg-stone-700">
|
||||
<WindowShade
|
||||
left={0}
|
||||
width={day.window.leftPct}
|
||||
striped
|
||||
label={t("serverOnlineHistory.outsideWindow")}
|
||||
/>
|
||||
<WindowShade
|
||||
left={day.window.rightPct}
|
||||
width={100 - day.window.rightPct}
|
||||
label={t("serverOnlineHistory.notYetRecorded")}
|
||||
/>
|
||||
{segments.map((segment) => (
|
||||
<Tooltip
|
||||
key={`${segment.leftPct}-${segment.period.start}`}
|
||||
>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
data-testid="online-segment"
|
||||
className={cn(
|
||||
"absolute inset-y-0 cursor-pointer rounded-full bg-green-500/90 transition-colors hover:bg-green-600",
|
||||
segment.live && "bg-green-500",
|
||||
)}
|
||||
style={{
|
||||
left: `${segment.leftPct}%`,
|
||||
width: `${segment.widthPct}%`,
|
||||
minWidth: 2,
|
||||
}}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="px-2 py-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span>
|
||||
{segment.segStart.format("HH:mm:ss")}
|
||||
{" → "}
|
||||
{segment.live
|
||||
? t("serverOnlineHistory.ongoing")
|
||||
: formatPeriodMoment(
|
||||
segment.segEnd,
|
||||
segment.segStart,
|
||||
tz,
|
||||
true,
|
||||
)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatDuration(
|
||||
segment.segEnd.diff(
|
||||
segment.segStart,
|
||||
"second",
|
||||
),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
{[25, 50, 75].map((position) => (
|
||||
<span
|
||||
key={position}
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-y-0 w-px bg-foreground/15"
|
||||
style={{ left: `${position}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{day.window.seconds > 0 &&
|
||||
day.window.rightPct > 0 &&
|
||||
day.window.rightPct < 100 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="data-cutoff"
|
||||
aria-label={`${t("serverOnlineHistory.dataCutoff")} ${day.window.end.format("HH:mm:ss")}`}
|
||||
className="absolute -top-0.5 -bottom-0.5 w-px bg-stone-500 dark:bg-stone-400"
|
||||
style={{ left: `${day.window.rightPct}%` }}
|
||||
>
|
||||
<span className="absolute -top-0.5 left-1/2 size-1.5 -translate-x-1/2 rounded-full bg-stone-500 dark:bg-stone-400" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("serverOnlineHistory.dataCutoff")}{" "}
|
||||
{day.window.end.format("HH:mm:ss")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
aria-hidden
|
||||
className="flex justify-between text-[9px] leading-none text-muted-foreground tabular-nums"
|
||||
>
|
||||
{[0, 6, 12, 18, 24].map((hour) => (
|
||||
<span key={hour}>{hour}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-[10px] text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2 w-3 rounded-sm"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"repeating-linear-gradient(135deg, transparent, transparent 3px, currentColor 3px, currentColor 4px)",
|
||||
}}
|
||||
/>
|
||||
{t("serverOnlineHistory.outsideWindow")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2 w-3 rounded-sm border border-border bg-background/80"
|
||||
/>
|
||||
{t("serverOnlineHistory.notYetRecorded")}
|
||||
</span>
|
||||
<span>{t("serverOnlineHistory.dataCutoffHint")}</span>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className={cn({ "bg-card/70": customBackgroundImage })}>
|
||||
<CardContent className="px-4 py-3 sm:px-5">
|
||||
<div className="flex min-h-8 flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-base font-medium">
|
||||
{t(
|
||||
periodMode === "online"
|
||||
? "serverOnlineHistory.periods"
|
||||
: "serverOnlineHistory.offlinePeriods",
|
||||
)}
|
||||
</p>
|
||||
<PeriodsModeSwitch
|
||||
mode={periodMode}
|
||||
onModeChange={(nextMode) => {
|
||||
setPeriodMode(nextMode);
|
||||
setVisibleDays(PERIODS_INITIAL_DAYS);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{(() => {
|
||||
if (!online) {
|
||||
return null;
|
||||
}
|
||||
const groups = periodGroups;
|
||||
const visibleGroups = groups.slice(0, visibleDays);
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t("serverOnlineHistory.noData")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
{visibleGroups.map((group) => (
|
||||
<div key={group.date} data-testid="period-day">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1 pb-2">
|
||||
<span className="text-xs font-medium">
|
||||
{group.day.format("MM-DD")}
|
||||
{group.day.isSame(panelNow(tz), "day") && (
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{" · "}
|
||||
{t("serverOnlineHistory.today")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t(periodMode === "online" ? "online" : "offline")}{" "}
|
||||
<span>{formatDuration(group.total)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{group.chips.map((chip, index) => {
|
||||
const seconds = chip.end.diff(chip.start, "second");
|
||||
return (
|
||||
<Tooltip key={`${chip.start.valueOf()}-${index}`}>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
data-testid="period-chip"
|
||||
className={cn(
|
||||
"inline-flex max-w-full items-center gap-1.5 rounded-[6px] px-2 py-1.5 text-xs tabular-nums transition-colors cursor-default",
|
||||
chip.ongoing
|
||||
? periodMode === "online"
|
||||
? "bg-green-50 text-green-700 dark:bg-green-500/10 dark:text-green-400"
|
||||
: "bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400"
|
||||
: "bg-stone-100 text-stone-600 hover:bg-stone-200/70 dark:bg-stone-800 dark:text-stone-300 dark:hover:bg-stone-700",
|
||||
)}
|
||||
>
|
||||
{chip.ongoing && (
|
||||
<span className="size-1.5 shrink-0 rounded-full bg-current" />
|
||||
)}
|
||||
{chip.start.format("HH:mm")}
|
||||
{" → "}
|
||||
{chip.ongoing
|
||||
? t("serverOnlineHistory.ongoing")
|
||||
: formatPeriodMoment(
|
||||
chip.end,
|
||||
chip.start,
|
||||
tz,
|
||||
)}
|
||||
<span className="text-muted-foreground">
|
||||
{formatDuration(seconds)}
|
||||
</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="px-2 py-1">
|
||||
{chip.start.format("HH:mm:ss")}
|
||||
{" → "}
|
||||
{chip.ongoing
|
||||
? t("serverOnlineHistory.ongoing")
|
||||
: formatPeriodMoment(
|
||||
chip.end,
|
||||
chip.start,
|
||||
tz,
|
||||
true,
|
||||
)}
|
||||
{" · "}
|
||||
{formatDuration(seconds)}
|
||||
{chip.original &&
|
||||
(!chip.start.isSame(
|
||||
panelDay(chip.original.start, tz),
|
||||
) ||
|
||||
!chip.end.isSame(
|
||||
panelDay(chip.original.end, tz),
|
||||
)) && (
|
||||
<div className="mt-1 text-muted-foreground">
|
||||
{t("serverOnlineHistory.originalSession")}{" "}
|
||||
{panelDay(chip.original.start, tz).format(
|
||||
"MM-DD HH:mm:ss",
|
||||
)}
|
||||
{" → "}
|
||||
{chip.original.ongoing
|
||||
? t("serverOnlineHistory.ongoing")
|
||||
: panelDay(
|
||||
chip.original.end,
|
||||
tz,
|
||||
).format("MM-DD HH:mm:ss")}
|
||||
</div>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{groups.length > visibleDays && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="periods-expand"
|
||||
onClick={() =>
|
||||
setVisibleDays((d) => d + PERIODS_EXPAND_STEP)
|
||||
}
|
||||
className="w-full rounded-md border border-dashed border-border py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring cursor-pointer"
|
||||
>
|
||||
{t("serverOnlineHistory.expandMore")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
})()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Bar({ className }: { className: string }) {
|
||||
return (
|
||||
<Skeleton
|
||||
className={cn(
|
||||
"rounded-[5px] bg-stone-200/70 motion-reduce:animate-none dark:bg-stone-700/60",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ footer = true }: { footer?: boolean }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col items-center justify-center gap-2">
|
||||
<div className="flex h-4 items-center">
|
||||
<Bar className="h-3 w-14" />
|
||||
</div>
|
||||
<Bar className="h-7 w-24 max-w-full" />
|
||||
{footer && (
|
||||
<div className="flex h-4 max-w-full items-center">
|
||||
<Bar className="h-3 w-20 max-w-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OnlineHistorySkeleton({ days = 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 (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={t("serverOnlineHistory.loading")}
|
||||
aria-busy="true"
|
||||
data-testid="online-history-loading"
|
||||
>
|
||||
<div aria-hidden="true" className="flex flex-col gap-4">
|
||||
<div className="flex min-h-9 items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 md:px-1">
|
||||
<Bar className="size-2 rounded-full" />
|
||||
<Bar className="h-3 w-8" />
|
||||
</div>
|
||||
<Bar className="h-9 w-36 rounded-full" />
|
||||
</div>
|
||||
|
||||
<div className="px-0 py-2 md:px-6 md:py-4">
|
||||
<div className="hidden grid-cols-4 divide-x divide-stone-100 dark:divide-stone-800 md:grid">
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
<Metric key={index} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Metric />
|
||||
<Metric />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 border-t border-stone-100 pt-3 dark:border-stone-800">
|
||||
<Metric footer={false} />
|
||||
<Metric footer={false} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className={cardClassName}>
|
||||
<CardContent className="px-4 py-3 sm:px-5">
|
||||
<div className="flex min-h-8 flex-wrap items-center justify-between gap-2">
|
||||
<Bar className="h-4 w-28" />
|
||||
<Bar className="h-3 w-32" />
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-1 gap-2 md:grid-cols-2 md:[&>*:last-child:nth-child(odd)]:col-span-2">
|
||||
{Array.from({ length: dayCount }, (_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex min-w-0 flex-col gap-2.5 rounded-lg bg-muted/40 px-2.5 py-2 dark:bg-muted/20"
|
||||
>
|
||||
<div className="flex h-4 items-center justify-between gap-2">
|
||||
<Bar className="h-3 w-20" />
|
||||
<Bar className="h-3 w-14" />
|
||||
</div>
|
||||
<Bar className="h-2 w-full rounded-full" />
|
||||
<div className="flex h-[9px] items-center justify-between">
|
||||
{Array.from({ length: 5 }, (_, tick) => (
|
||||
<Bar key={tick} className="h-1 w-2" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 flex h-4 items-center gap-4">
|
||||
<Bar className="h-2 w-16" />
|
||||
<Bar className="h-2 w-16" />
|
||||
<Bar className="h-2 w-20" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className={cardClassName}>
|
||||
<CardContent className="px-4 py-3 sm:px-5">
|
||||
<div className="flex min-h-8 items-center justify-between gap-2">
|
||||
<Bar className="h-4 w-24" />
|
||||
<Bar className="-mt-1 -mr-2 h-[30px] w-24 self-start rounded-[8px] sm:-mr-3" />
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
{Array.from({ length: Math.min(3, dayCount) }, (_, index) => (
|
||||
<div key={index}>
|
||||
<div className="flex h-6 items-start justify-between gap-3">
|
||||
<Bar className="h-3 w-16" />
|
||||
<Bar className="h-3 w-20" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Bar className="h-7 w-36" />
|
||||
<Bar className="h-7 w-40" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{dayCount > 3 && (
|
||||
<div className="flex h-8 items-center justify-center rounded-md border border-dashed border-border">
|
||||
<Bar className="h-3 w-16" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// matchMedia 不可用(如 jsdom)时默认命中桌面布局,保证测试环境渲染完整概要
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(() => {
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
typeof window.matchMedia !== "function"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return window.matchMedia(query).matches;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window.matchMedia !== "function") {
|
||||
return;
|
||||
}
|
||||
const mediaQueryList = window.matchMedia(query);
|
||||
const onChange = (event: MediaQueryListEvent) => {
|
||||
setMatches(event.matches);
|
||||
};
|
||||
setMatches(mediaQueryList.matches);
|
||||
mediaQueryList.addEventListener("change", onChange);
|
||||
return () => {
|
||||
mediaQueryList.removeEventListener("change", onChange);
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
@@ -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<OnlineHistoryResponse> => {
|
||||
const query = days ? `?days=${days}` : "";
|
||||
const response = await fetch(
|
||||
`/api/v1/server/${server_id}/online-history${query}`,
|
||||
);
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
@@ -0,0 +1,398 @@
|
||||
import dayjs from "dayjs";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import type { DailyOnlineStat, OnlinePeriod } from "@/types/nezha-api";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
export type PeriodChip = {
|
||||
start: dayjs.Dayjs;
|
||||
end: dayjs.Dayjs;
|
||||
ongoing: boolean;
|
||||
original?: OnlinePeriod;
|
||||
};
|
||||
|
||||
export type PeriodDayGroup = {
|
||||
date: string;
|
||||
day: dayjs.Dayjs;
|
||||
total: number;
|
||||
chips: PeriodChip[];
|
||||
};
|
||||
|
||||
export type DaySegment = {
|
||||
period: OnlinePeriod;
|
||||
segStart: dayjs.Dayjs;
|
||||
segEnd: dayjs.Dayjs;
|
||||
leftPct: number;
|
||||
widthPct: number;
|
||||
/** 该段是否仍然进行中(只有结束于真实会话末尾的段才为 true) */
|
||||
live: boolean;
|
||||
};
|
||||
|
||||
// 后端按面板时区拆日;响应带 timezone 时前端用同一时区解释时间,
|
||||
// 保证跨时区访客的时间轴、分组与每日数字一致
|
||||
export function panelDay(value: string, tz?: string): dayjs.Dayjs {
|
||||
// dayjs 的 .tz(input, zone) 解析模式会忽略输入里的显式偏移(Z / ±HH:MM):
|
||||
// 带偏移的时间戳须先按原义解析再换算时区,无偏移的日期串才用 zone 解析
|
||||
if (tz) {
|
||||
return /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value)
|
||||
? dayjs(value).tz(tz)
|
||||
: dayjs.tz(value, tz);
|
||||
}
|
||||
return dayjs(value);
|
||||
}
|
||||
|
||||
export function panelNow(tz?: string): dayjs.Dayjs {
|
||||
return tz ? dayjs().tz(tz) : dayjs();
|
||||
}
|
||||
|
||||
// 下一天的日历日期用 UTC 做纯日期运算,避免 dayjs .add(1, "day")
|
||||
// 在 DST 切换日加的是 24 小时而不是下一个挂钟零点
|
||||
function nextDate(date: string): string {
|
||||
const [year, month, day] = date.split("-").map(Number);
|
||||
return new Date(Date.UTC(year, month - 1, day + 1))
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
export function panelNextDayStart(date: string, tz?: string): dayjs.Dayjs {
|
||||
return panelDay(nextDate(date), tz);
|
||||
}
|
||||
|
||||
// 结束于次日零点的段在 chip 上显示为 24:00,避免出现“00:00 → 00:00”这种读不通的全天段
|
||||
export function formatPeriodMoment(
|
||||
value: dayjs.Dayjs,
|
||||
anchor: dayjs.Dayjs,
|
||||
tz?: string,
|
||||
withSeconds = false,
|
||||
): string {
|
||||
const dayEnd = panelNextDayStart(anchor.format("YYYY-MM-DD"), tz);
|
||||
if (value.valueOf() === dayEnd.valueOf()) {
|
||||
return withSeconds ? "24:00:00" : "24:00";
|
||||
}
|
||||
return value.format(withSeconds ? "HH:mm:ss" : "HH:mm");
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (d > 0) {
|
||||
return `${d}d ${h}h ${m}m`;
|
||||
}
|
||||
if (h > 0) {
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
// 时间轴刻度是固定的挂钟 0:00–24:00,定位必须用面板时区的挂钟时间;
|
||||
// 用 elapsed seconds / 86400 会在 DST 切换日(23h/25h)偏移
|
||||
function wallClockPercent(value: dayjs.Dayjs): number {
|
||||
const seconds =
|
||||
value.hour() * 3600 +
|
||||
value.minute() * 60 +
|
||||
value.second() +
|
||||
value.millisecond() / 1000;
|
||||
return (seconds / 86400) * 100;
|
||||
}
|
||||
|
||||
// 每天实际纳入统计的区间;挂钟坐标用于绘图,秒数用于时长/在线率。
|
||||
export function buildDayWindow(
|
||||
date: string,
|
||||
windowStart: string,
|
||||
windowEnd: string,
|
||||
tz?: string,
|
||||
) {
|
||||
const dayStart = panelDay(date, tz);
|
||||
const dayEnd = panelNextDayStart(date, tz);
|
||||
const start = panelDay(windowStart, tz);
|
||||
const end = panelDay(windowEnd, tz);
|
||||
const effectiveStart =
|
||||
start.valueOf() > dayStart.valueOf() ? start : dayStart;
|
||||
const effectiveEnd = end.valueOf() < dayEnd.valueOf() ? end : dayEnd;
|
||||
const percent = (value: dayjs.Dayjs) =>
|
||||
value.valueOf() < dayStart.valueOf() ||
|
||||
value.valueOf() === dayStart.valueOf()
|
||||
? 0
|
||||
: value.valueOf() > dayEnd.valueOf() ||
|
||||
value.valueOf() === dayEnd.valueOf()
|
||||
? 100
|
||||
: wallClockPercent(value);
|
||||
return {
|
||||
dayStart,
|
||||
dayEnd,
|
||||
start: effectiveStart,
|
||||
end: effectiveEnd,
|
||||
seconds: Math.max(0, effectiveEnd.diff(effectiveStart, "second")),
|
||||
leftPct: percent(effectiveStart),
|
||||
rightPct: percent(effectiveEnd),
|
||||
};
|
||||
}
|
||||
|
||||
export function dailyOnlineRate(onlineSeconds: number, windowSeconds: number) {
|
||||
return windowSeconds > 0
|
||||
? Math.max(0, Math.min(100, (onlineSeconds / windowSeconds) * 100))
|
||||
: 0;
|
||||
}
|
||||
|
||||
// 每份响应只解析一次时段和窗口边界;后续按天裁剪仅比较时间戳。
|
||||
function preparePeriods(
|
||||
periods: OnlinePeriod[],
|
||||
tz?: string,
|
||||
windowStart?: string,
|
||||
windowEnd?: string,
|
||||
) {
|
||||
const lower = windowStart ? panelDay(windowStart, tz) : null;
|
||||
const upper = windowEnd ? panelDay(windowEnd, tz) : null;
|
||||
return periods.flatMap((period) => {
|
||||
const originalStart = panelDay(period.start, tz);
|
||||
const originalEnd = panelDay(period.end, tz);
|
||||
const start =
|
||||
lower && lower.valueOf() > originalStart.valueOf()
|
||||
? lower
|
||||
: originalStart;
|
||||
const end =
|
||||
upper && upper.valueOf() < originalEnd.valueOf() ? upper : originalEnd;
|
||||
return start.isValid() && end.isValid() && end.valueOf() > start.valueOf()
|
||||
? [
|
||||
{
|
||||
period,
|
||||
start,
|
||||
end,
|
||||
live: period.ongoing && end.valueOf() === originalEnd.valueOf(),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
function buildParsedDaySegments(
|
||||
periods: ReturnType<typeof preparePeriods>,
|
||||
dayStart: dayjs.Dayjs,
|
||||
dayEnd: dayjs.Dayjs,
|
||||
): DaySegment[] {
|
||||
const lower = dayStart.valueOf(),
|
||||
upper = dayEnd.valueOf();
|
||||
return periods.flatMap(({ period, start, end, live }) => {
|
||||
if (end.valueOf() <= lower || start.valueOf() >= upper) return [];
|
||||
const segStart = start.valueOf() > lower ? start : dayStart;
|
||||
const segEnd = end.valueOf() < upper ? end : dayEnd;
|
||||
const leftPct =
|
||||
segStart.valueOf() === lower ? 0 : wallClockPercent(segStart);
|
||||
const rightPct =
|
||||
segEnd.valueOf() === upper ? 100 : wallClockPercent(segEnd);
|
||||
const widthPct = Math.max(0, Math.min(rightPct - leftPct, 100 - leftPct));
|
||||
return widthPct > 0
|
||||
? [
|
||||
{
|
||||
period,
|
||||
segStart,
|
||||
segEnd,
|
||||
leftPct,
|
||||
widthPct,
|
||||
live: live && segEnd.valueOf() === end.valueOf(),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
export function buildDaySegments(
|
||||
periods: OnlinePeriod[],
|
||||
date: string,
|
||||
tz?: string,
|
||||
windowStart?: string,
|
||||
windowEnd?: string,
|
||||
): DaySegment[] {
|
||||
return buildParsedDaySegments(
|
||||
preparePeriods(periods, tz, windowStart, windowEnd),
|
||||
panelDay(date, tz),
|
||||
panelNextDayStart(date, tz),
|
||||
);
|
||||
}
|
||||
|
||||
// 时间轴、每日总计及在线列表共享这份结果,不再各自遍历并转换整份历史。
|
||||
export function buildDailyOnlineRows(
|
||||
periods: OnlinePeriod[],
|
||||
daily: DailyOnlineStat[],
|
||||
windowStart: string,
|
||||
windowEnd: string,
|
||||
tz?: string,
|
||||
) {
|
||||
const prepared = preparePeriods(periods, tz, windowStart, windowEnd);
|
||||
return daily.map((day) => {
|
||||
const window = buildDayWindow(day.date, windowStart, windowEnd, tz);
|
||||
const segments = buildParsedDaySegments(
|
||||
prepared,
|
||||
window.dayStart,
|
||||
window.dayEnd,
|
||||
);
|
||||
return {
|
||||
...day,
|
||||
segments,
|
||||
window,
|
||||
online_seconds: segments.reduce(
|
||||
(sum, segment) => sum + segment.segEnd.diff(segment.segStart, "second"),
|
||||
0,
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function groupOnlineRows(
|
||||
rows: { date: string; segments: DaySegment[] }[],
|
||||
tz?: string,
|
||||
): PeriodDayGroup[] {
|
||||
return rows
|
||||
.filter((row) => row.segments.length > 0)
|
||||
.map((row) => ({
|
||||
date: row.date,
|
||||
day: panelDay(row.date, tz),
|
||||
chips: row.segments.map((segment) => ({
|
||||
start: segment.segStart,
|
||||
end: segment.segEnd,
|
||||
ongoing: segment.live,
|
||||
original: segment.period,
|
||||
})),
|
||||
total: row.segments.reduce(
|
||||
(sum, segment) => sum + segment.segEnd.diff(segment.segStart, "second"),
|
||||
0,
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => b.date.localeCompare(a.date));
|
||||
}
|
||||
|
||||
// 合并重叠/相邻的时间段,按开始时间排序
|
||||
export function mergePeriods(
|
||||
periods: OnlinePeriod[],
|
||||
tz?: string,
|
||||
): PeriodChip[] {
|
||||
const sorted = periods
|
||||
.map((period) => ({
|
||||
start: panelDay(period.start, tz),
|
||||
end: panelDay(period.end, tz),
|
||||
ongoing: period.ongoing,
|
||||
}))
|
||||
.sort((a, b) => a.start.valueOf() - b.start.valueOf());
|
||||
const merged: PeriodChip[] = [];
|
||||
for (const period of sorted) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && period.start.valueOf() < last.end.valueOf()) {
|
||||
if (period.end.valueOf() > last.end.valueOf()) {
|
||||
last.end = period.end;
|
||||
}
|
||||
last.ongoing = last.ongoing || period.ongoing;
|
||||
} else {
|
||||
merged.push({ ...period });
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
// 独立调用也复用同一预解析/裁剪流程。
|
||||
export function groupOnlinePeriods(
|
||||
periods: OnlinePeriod[],
|
||||
tz?: string,
|
||||
windowStart?: string,
|
||||
windowEnd?: string,
|
||||
): PeriodDayGroup[] {
|
||||
const prepared = preparePeriods(periods, tz, windowStart, windowEnd);
|
||||
const dates = new Set<string>();
|
||||
for (const { start, end } of prepared) {
|
||||
const endDate = end.format("YYYY-MM-DD");
|
||||
for (
|
||||
let date = start.format("YYYY-MM-DD");
|
||||
date <= endDate;
|
||||
date = nextDate(date)
|
||||
)
|
||||
dates.add(date);
|
||||
}
|
||||
return groupOnlineRows(
|
||||
[...dates].map((date) => ({
|
||||
date,
|
||||
segments: buildParsedDaySegments(
|
||||
prepared,
|
||||
panelDay(date, tz),
|
||||
panelNextDayStart(date, tz),
|
||||
),
|
||||
})),
|
||||
tz,
|
||||
);
|
||||
}
|
||||
|
||||
// 离线模式:对窗口内每一天求在线时段的补集。计算区间先与查询窗口取交集,
|
||||
// 避免在窗口开始前/结束后凭空生成离线段;服务器当前离线时,结束于窗口末端的
|
||||
// 最新一段标记为进行中。
|
||||
export function buildOfflineGroups(
|
||||
periods: OnlinePeriod[],
|
||||
daily: DailyOnlineStat[],
|
||||
windowStart: string,
|
||||
windowEnd: string,
|
||||
currentlyOnline: boolean,
|
||||
tz?: string,
|
||||
): PeriodDayGroup[] {
|
||||
const merged = mergePeriods(periods, tz);
|
||||
const windowStartTime = panelDay(windowStart, tz);
|
||||
const windowEndTime = panelDay(windowEnd, tz);
|
||||
const groups = daily
|
||||
.map((day) => day.date)
|
||||
.sort((a, b) => (a < b ? 1 : -1))
|
||||
.flatMap((date): PeriodDayGroup[] => {
|
||||
const dayStart = panelDay(date, tz);
|
||||
const dayEnd = panelNextDayStart(date, tz);
|
||||
// 当天与查询窗口的交集;首日/末日通常不是完整 24 小时
|
||||
const effectiveStart =
|
||||
dayStart.valueOf() > windowStartTime.valueOf()
|
||||
? dayStart
|
||||
: windowStartTime;
|
||||
const effectiveEnd =
|
||||
dayEnd.valueOf() < windowEndTime.valueOf() ? dayEnd : windowEndTime;
|
||||
if (!(effectiveEnd.valueOf() > effectiveStart.valueOf())) {
|
||||
return [];
|
||||
}
|
||||
const chips: PeriodChip[] = [];
|
||||
let cursor = effectiveStart;
|
||||
for (const segment of merged) {
|
||||
if (!(segment.end.valueOf() > effectiveStart.valueOf())) {
|
||||
continue;
|
||||
}
|
||||
// merged 按开始时间升序;到达当天区间之后的时段即可停止,
|
||||
// 否则会把未来日期的开始时刻当成当天的 gap 终点
|
||||
if (!(segment.start.valueOf() < effectiveEnd.valueOf())) {
|
||||
break;
|
||||
}
|
||||
const segStart =
|
||||
segment.start.valueOf() > cursor.valueOf() ? segment.start : cursor;
|
||||
if (segStart.valueOf() > cursor.valueOf()) {
|
||||
chips.push({ start: cursor, end: segStart, ongoing: false });
|
||||
}
|
||||
const segEnd =
|
||||
segment.end.valueOf() < effectiveEnd.valueOf()
|
||||
? segment.end
|
||||
: effectiveEnd;
|
||||
if (segEnd.valueOf() > cursor.valueOf()) {
|
||||
cursor = segEnd;
|
||||
}
|
||||
}
|
||||
if (cursor.valueOf() < effectiveEnd.valueOf()) {
|
||||
chips.push({ start: cursor, end: effectiveEnd, ongoing: false });
|
||||
}
|
||||
const total = chips.reduce(
|
||||
(sum, chip) => sum + chip.end.diff(chip.start, "second"),
|
||||
0,
|
||||
);
|
||||
return [{ date, day: dayStart, total, chips }];
|
||||
})
|
||||
.filter((group) => group.chips.length > 0);
|
||||
if (!currentlyOnline && groups.length > 0) {
|
||||
const newest = groups[0].chips;
|
||||
const last = newest[newest.length - 1];
|
||||
// 只有真正持续到窗口末端的离线段才是进行中;旧日期的段不能误标
|
||||
if (last?.end.isSame(windowEndTime)) {
|
||||
last.ongoing = true;
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
@@ -89,7 +89,8 @@
|
||||
},
|
||||
"tabSwitch": {
|
||||
"Network": "Netzwerk",
|
||||
"Detail": "Detail"
|
||||
"Detail": "Detail",
|
||||
"Online": "Online"
|
||||
},
|
||||
"nezha": "Nezha Monitoring",
|
||||
"dashboard": "Dashboard",
|
||||
@@ -140,5 +141,23 @@
|
||||
"up_total": "Upload ges.",
|
||||
"down_total": "Download ges."
|
||||
}
|
||||
},
|
||||
"serverOnlineHistory": {
|
||||
"days1": "1d",
|
||||
"days7": "7d",
|
||||
"days30": "30d",
|
||||
"onlineRate": "Online rate",
|
||||
"totalOnline": "Online in window",
|
||||
"lastActive": "Last active",
|
||||
"dailyOnline": "Daily online time",
|
||||
"periods": "Online periods",
|
||||
"ongoing": "Ongoing",
|
||||
"noData": "No online records",
|
||||
"loginRequired": "This window requires login; guests can switch to 1d",
|
||||
"offlinePeriods": "Offline periods",
|
||||
"periodsOnline": "Online",
|
||||
"periodsOffline": "Offline",
|
||||
"expandMore": "Expand more",
|
||||
"today": "Today"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,8 @@
|
||||
},
|
||||
"tabSwitch": {
|
||||
"Detail": "Detalle",
|
||||
"Network": "Red"
|
||||
"Network": "Red",
|
||||
"Online": "Online"
|
||||
},
|
||||
"monitor": {
|
||||
"avgDelay": "Latencia",
|
||||
@@ -155,5 +156,23 @@
|
||||
"up_total": "Subida total",
|
||||
"down_total": "Bajada total"
|
||||
}
|
||||
},
|
||||
"serverOnlineHistory": {
|
||||
"days1": "1d",
|
||||
"days7": "7d",
|
||||
"days30": "30d",
|
||||
"onlineRate": "Online rate",
|
||||
"totalOnline": "Online in window",
|
||||
"lastActive": "Last active",
|
||||
"dailyOnline": "Daily online time",
|
||||
"periods": "Online periods",
|
||||
"ongoing": "Ongoing",
|
||||
"noData": "No online records",
|
||||
"loginRequired": "This window requires login; guests can switch to 1d",
|
||||
"offlinePeriods": "Offline periods",
|
||||
"periodsOnline": "Online",
|
||||
"periodsOffline": "Offline",
|
||||
"expandMore": "Expand more",
|
||||
"today": "Today"
|
||||
}
|
||||
}
|
||||
@@ -12,5 +12,26 @@
|
||||
},
|
||||
"serverDetail": {
|
||||
"download": "Téléchargement"
|
||||
},
|
||||
"tabSwitch": {
|
||||
"Online": "Online"
|
||||
},
|
||||
"serverOnlineHistory": {
|
||||
"days1": "1d",
|
||||
"days7": "7d",
|
||||
"days30": "30d",
|
||||
"onlineRate": "Online rate",
|
||||
"totalOnline": "Online in window",
|
||||
"lastActive": "Last active",
|
||||
"dailyOnline": "Daily online time",
|
||||
"periods": "Online periods",
|
||||
"ongoing": "Ongoing",
|
||||
"noData": "No online records",
|
||||
"loginRequired": "This window requires login; guests can switch to 1d",
|
||||
"offlinePeriods": "Offline periods",
|
||||
"periodsOnline": "Online",
|
||||
"periodsOffline": "Offline",
|
||||
"expandMore": "Expand more",
|
||||
"today": "Today"
|
||||
}
|
||||
}
|
||||
@@ -102,7 +102,8 @@
|
||||
},
|
||||
"tabSwitch": {
|
||||
"Detail": "Detalle",
|
||||
"Network": "Rede"
|
||||
"Network": "Rede",
|
||||
"Online": "Online"
|
||||
},
|
||||
"monitor": {
|
||||
"noData": "Non hai datos de monitor de servidor. Engada primeiro un monitor de servizo",
|
||||
@@ -133,5 +134,23 @@
|
||||
"ToggleLightMode": "Cambiar ao modo claro",
|
||||
"ToggleDarkMode": "Cambiar ao modo escuro",
|
||||
"ToggleSystemMode": "Cambiar ao determinado polo sistema",
|
||||
"Home": "Páxina principal"
|
||||
"Home": "Páxina principal",
|
||||
"serverOnlineHistory": {
|
||||
"days1": "1d",
|
||||
"days7": "7d",
|
||||
"days30": "30d",
|
||||
"onlineRate": "Online rate",
|
||||
"totalOnline": "Online in window",
|
||||
"lastActive": "Last active",
|
||||
"dailyOnline": "Daily online time",
|
||||
"periods": "Online periods",
|
||||
"ongoing": "Ongoing",
|
||||
"noData": "No online records",
|
||||
"loginRequired": "This window requires login; guests can switch to 1d",
|
||||
"offlinePeriods": "Offline periods",
|
||||
"periodsOnline": "Online",
|
||||
"periodsOffline": "Offline",
|
||||
"expandMore": "Expand more",
|
||||
"today": "Today"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -4,5 +4,26 @@
|
||||
"light": "Mod luminos",
|
||||
"dark": "Mod întunecat"
|
||||
},
|
||||
"Servers": "Servere"
|
||||
"Servers": "Servere",
|
||||
"tabSwitch": {
|
||||
"Online": "Online"
|
||||
},
|
||||
"serverOnlineHistory": {
|
||||
"days1": "1d",
|
||||
"days7": "7d",
|
||||
"days30": "30d",
|
||||
"onlineRate": "Online rate",
|
||||
"totalOnline": "Online in window",
|
||||
"lastActive": "Last active",
|
||||
"dailyOnline": "Daily online time",
|
||||
"periods": "Online periods",
|
||||
"ongoing": "Ongoing",
|
||||
"noData": "No online records",
|
||||
"loginRequired": "This window requires login; guests can switch to 1d",
|
||||
"offlinePeriods": "Offline periods",
|
||||
"periodsOnline": "Online",
|
||||
"periodsOffline": "Offline",
|
||||
"expandMore": "Expand more",
|
||||
"today": "Today"
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,8 @@
|
||||
},
|
||||
"tabSwitch": {
|
||||
"Detail": "Детали",
|
||||
"Network": "Сеть"
|
||||
"Network": "Сеть",
|
||||
"Online": "Online"
|
||||
},
|
||||
"whereTheTimeIs": "Где время",
|
||||
"theme": {
|
||||
@@ -159,5 +160,23 @@
|
||||
"up_total": "Загружено",
|
||||
"down_total": "Скачано"
|
||||
}
|
||||
},
|
||||
"serverOnlineHistory": {
|
||||
"days1": "1d",
|
||||
"days7": "7d",
|
||||
"days30": "30d",
|
||||
"onlineRate": "Online rate",
|
||||
"totalOnline": "Online in window",
|
||||
"lastActive": "Last active",
|
||||
"dailyOnline": "Daily online time",
|
||||
"periods": "Online periods",
|
||||
"ongoing": "Ongoing",
|
||||
"noData": "No online records",
|
||||
"loginRequired": "This window requires login; guests can switch to 1d",
|
||||
"offlinePeriods": "Offline periods",
|
||||
"periodsOnline": "Online",
|
||||
"periodsOffline": "Offline",
|
||||
"expandMore": "Expand more",
|
||||
"today": "Today"
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,8 @@
|
||||
},
|
||||
"tabSwitch": {
|
||||
"Detail": "விவரம்",
|
||||
"Network": "பிணையம்"
|
||||
"Network": "பிணையம்",
|
||||
"Online": "Online"
|
||||
},
|
||||
"monitor": {
|
||||
"noData": "சேவையக மானிட்டர் தரவு இல்லை, முதலில் ஒரு பணி மானிட்டரைச் சேர்க்கவும்",
|
||||
@@ -140,5 +141,23 @@
|
||||
"up_total": "மொத்த பதிவேற்றம்",
|
||||
"down_total": "மொத்த பதிவிறக்கம்"
|
||||
}
|
||||
},
|
||||
"serverOnlineHistory": {
|
||||
"days1": "1d",
|
||||
"days7": "7d",
|
||||
"days30": "30d",
|
||||
"onlineRate": "Online rate",
|
||||
"totalOnline": "Online in window",
|
||||
"lastActive": "Last active",
|
||||
"dailyOnline": "Daily online time",
|
||||
"periods": "Online periods",
|
||||
"ongoing": "Ongoing",
|
||||
"noData": "No online records",
|
||||
"loginRequired": "This window requires login; guests can switch to 1d",
|
||||
"offlinePeriods": "Offline periods",
|
||||
"periodsOnline": "Online",
|
||||
"periodsOffline": "Offline",
|
||||
"expandMore": "Expand more",
|
||||
"today": "Today"
|
||||
}
|
||||
}
|
||||
@@ -69,5 +69,26 @@
|
||||
"today": "Сьогодні",
|
||||
"loading": "Завантаження..."
|
||||
},
|
||||
"login": "Логін"
|
||||
"login": "Логін",
|
||||
"tabSwitch": {
|
||||
"Online": "Online"
|
||||
},
|
||||
"serverOnlineHistory": {
|
||||
"days1": "1d",
|
||||
"days7": "7d",
|
||||
"days30": "30d",
|
||||
"onlineRate": "Online rate",
|
||||
"totalOnline": "Online in window",
|
||||
"lastActive": "Last active",
|
||||
"dailyOnline": "Daily online time",
|
||||
"periods": "Online periods",
|
||||
"ongoing": "Ongoing",
|
||||
"noData": "No online records",
|
||||
"loginRequired": "This window requires login; guests can switch to 1d",
|
||||
"offlinePeriods": "Offline periods",
|
||||
"periodsOnline": "Online",
|
||||
"periodsOffline": "Offline",
|
||||
"expandMore": "Expand more",
|
||||
"today": "Today"
|
||||
}
|
||||
}
|
||||
@@ -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": "今天"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { Navigate, useParams } from "react-router-dom";
|
||||
import OnlineHistorySkeleton from "@/components/loading/OnlineHistorySkeleton";
|
||||
import NetworkChartLoading from "@/components/NetworkChartLoading";
|
||||
import ServerDetailChart from "@/components/ServerDetailChart";
|
||||
import ServerDetailOverview from "@/components/ServerDetailOverview";
|
||||
@@ -12,12 +13,18 @@ const NetworkChart = lazy(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
const ServerDetailOnlineHistory = lazy(() =>
|
||||
import("@/components/ServerDetailOnlineHistory").then((module) => ({
|
||||
default: module.default,
|
||||
})),
|
||||
);
|
||||
|
||||
export default function ServerDetail() {
|
||||
useEffect(() => {
|
||||
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
|
||||
}, []);
|
||||
|
||||
const tabs = ["Detail", "Network"];
|
||||
const tabs = ["Detail", "Network", "Online"];
|
||||
const [currentTab, setCurrentTab] = useState(tabs[0]);
|
||||
|
||||
const { id: server_id } = useParams();
|
||||
@@ -51,6 +58,11 @@ export default function ServerDetail() {
|
||||
<NetworkChart server_id={Number(server_id)} show={true} />
|
||||
</Suspense>
|
||||
)}
|
||||
{currentTab === tabs[2] && (
|
||||
<Suspense fallback={<OnlineHistorySkeleton />}>
|
||||
<ServerDetailOnlineHistory server_id={server_id} />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
{ui}
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ServerDetailOnlineHistory", () => {
|
||||
beforeEach(() => {
|
||||
websocketMocks.lastData = null;
|
||||
apiMocks.fetchServerOnlineHistory.mockReset();
|
||||
});
|
||||
|
||||
it("renders the loading state while history data is unavailable", () => {
|
||||
apiMocks.fetchServerOnlineHistory.mockReturnValue(
|
||||
new Promise(() => undefined),
|
||||
);
|
||||
|
||||
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
|
||||
expect(
|
||||
screen.getByRole("status", { name: "serverOnlineHistory.loading" }),
|
||||
).toHaveAttribute("aria-busy", "true");
|
||||
expect(screen.getByTestId("online-history-loading")).toBeInTheDocument();
|
||||
expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledWith(7, 7);
|
||||
});
|
||||
|
||||
it("keeps existing content instead of flashing the skeleton while changing days", async () => {
|
||||
apiMocks.fetchServerOnlineHistory
|
||||
.mockResolvedValueOnce(onlineHistoryResponse())
|
||||
.mockReturnValue(new Promise(() => undefined));
|
||||
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
|
||||
await screen.findByText("0.9%");
|
||||
expect(
|
||||
screen.queryByTestId("online-history-loading"),
|
||||
).not.toBeInTheDocument();
|
||||
await userEvent
|
||||
.setup()
|
||||
.click(screen.getByText("serverOnlineHistory.days30"));
|
||||
await waitFor(() =>
|
||||
expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledWith(7, 30),
|
||||
);
|
||||
expect(screen.getByText("0.9%")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("online-history-loading"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders daily totals and all groups when fewer than three days are available", async () => {
|
||||
apiMocks.fetchServerOnlineHistory.mockResolvedValue(
|
||||
onlineHistoryResponse(),
|
||||
);
|
||||
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
|
||||
await screen.findByText("serverOnlineHistory.dailyOnline");
|
||||
expect(screen.getAllByText("online")).toHaveLength(3);
|
||||
expect(screen.getByText("0.9%")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("1h 30m")).toHaveLength(4);
|
||||
expect(screen.getAllByText("1h 0m")).toHaveLength(3);
|
||||
expect(screen.getByText("10-13")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("serverOnlineHistory.expandMore"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getAllByTestId("period-chip")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[1800, "1h 30m"],
|
||||
[7200, "0m"],
|
||||
[7500, "0m"],
|
||||
])("calculates offline time from the actual window with %i online seconds", async (seconds, expected) => {
|
||||
const response = onlineHistoryResponse();
|
||||
response.data.window_start = "2026-10-14T10:00:00.000Z";
|
||||
response.data.window_end = "2026-10-14T12:00:00.000Z";
|
||||
response.data.total_online_seconds = seconds;
|
||||
apiMocks.fetchServerOnlineHistory.mockResolvedValue(response);
|
||||
|
||||
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
|
||||
|
||||
const label = await screen.findByText("serverOnlineHistory.totalOffline");
|
||||
expect(label.parentElement).toHaveTextContent(expected);
|
||||
});
|
||||
|
||||
it("maps each day's periods onto the shared 0:00-24:00 timeline", async () => {
|
||||
apiMocks.fetchServerOnlineHistory.mockResolvedValue(
|
||||
onlineHistoryResponse(),
|
||||
);
|
||||
|
||||
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
|
||||
|
||||
expect(await screen.findByText(/0:00 – 24:00/)).toBeInTheDocument();
|
||||
|
||||
// 时间轴按面板时区(UTC)的挂钟定位:08:00 在 33.33%,1.5h 宽 6.25%
|
||||
const segments = await screen.findAllByTestId("online-segment");
|
||||
expect(segments).toHaveLength(2);
|
||||
expect(Number.parseFloat(segments[0].style.left)).toBeCloseTo(
|
||||
(8 / 24) * 100,
|
||||
2,
|
||||
);
|
||||
expect(Number.parseFloat(segments[0].style.width)).toBeCloseTo(
|
||||
(1.5 / 24) * 100,
|
||||
2,
|
||||
);
|
||||
expect(segments[0].style.minWidth).toBe("2px");
|
||||
});
|
||||
|
||||
it("keeps daily totals, carry-over chips and window markers consistent", async () => {
|
||||
const response = onlineHistoryResponse();
|
||||
Object.assign(response.data, {
|
||||
window_start: "2026-10-13T06:00:00.000Z",
|
||||
window_end: "2026-10-14T12:00:00.000Z",
|
||||
total_online_seconds: 9 * 3600,
|
||||
daily: [
|
||||
{ date: "2026-10-13", online_seconds: 7200 },
|
||||
{ date: "2026-10-14", online_seconds: 7 * 3600 },
|
||||
],
|
||||
periods: [
|
||||
{
|
||||
start: "2026-10-13T22:00:00.000Z",
|
||||
end: "2026-10-14T02:00:00.000Z",
|
||||
ongoing: false,
|
||||
},
|
||||
{
|
||||
start: "2026-10-14T03:00:00.000Z",
|
||||
end: "2026-10-14T04:00:00.000Z",
|
||||
ongoing: false,
|
||||
},
|
||||
{
|
||||
start: "2026-10-14T08:00:00.000Z",
|
||||
end: "2026-10-14T12:00:00.000Z",
|
||||
ongoing: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
apiMocks.fetchServerOnlineHistory.mockResolvedValue(response);
|
||||
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
|
||||
const rows = await screen.findAllByTestId("daily-online-day");
|
||||
expect(within(rows[1]).getByText("7h 0m")).toBeInTheDocument();
|
||||
expect(within(rows[1]).getAllByTestId("online-segment")).toHaveLength(3);
|
||||
const latestGroup = screen.getAllByTestId("period-day")[0];
|
||||
expect(within(latestGroup).getByText("7h 0m")).toBeInTheDocument();
|
||||
const chips = within(latestGroup).getAllByTestId("period-chip");
|
||||
expect(chips).toHaveLength(3);
|
||||
expect(chips[0]).toHaveTextContent("00:00 → 02:00");
|
||||
expect(screen.getByTestId("outside-window")).toHaveStyle({ width: "25%" });
|
||||
expect(screen.getByTestId("unrecorded-window")).toHaveStyle({
|
||||
left: "50%",
|
||||
width: "50%",
|
||||
});
|
||||
expect(screen.getByTestId("data-cutoff")).toHaveAccessibleName(
|
||||
"serverOnlineHistory.dataCutoff 12:00:00",
|
||||
);
|
||||
await userEvent.setup().hover(chips[0]);
|
||||
expect(await screen.findByRole("tooltip")).toHaveTextContent(
|
||||
"serverOnlineHistory.originalSession 10-13 22:00:00 → 10-14 02:00:00",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the compact mobile summary below the md breakpoint", async () => {
|
||||
apiMocks.fetchServerOnlineHistory.mockResolvedValue(
|
||||
onlineHistoryResponse(),
|
||||
);
|
||||
vi.stubGlobal(
|
||||
"matchMedia",
|
||||
vi.fn().mockImplementation(() => ({
|
||||
matches: false,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
})),
|
||||
);
|
||||
try {
|
||||
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
|
||||
|
||||
expect(await screen.findByTestId("summary-mobile")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("summary-desktop")).not.toBeInTheDocument();
|
||||
// 移动端:在线率 + 在线/离线双指标 + 最近活跃。
|
||||
expect(screen.getByText("0.9%")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("serverOnlineHistory.totalOffline"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("serverOnlineHistory.totalOnline"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("serverOnlineHistory.lastActive"),
|
||||
).toBeInTheDocument();
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the live last-active time when switching history windows", async () => {
|
||||
const user = userEvent.setup();
|
||||
websocketMocks.lastData = {
|
||||
servers: [{ id: 7, last_active: "2026-10-14T12:34:56.000Z" }],
|
||||
};
|
||||
apiMocks.fetchServerOnlineHistory.mockImplementation((_id, days) => {
|
||||
const response = onlineHistoryResponse();
|
||||
response.data.online_rate = days === 30 ? 50 : 90;
|
||||
response.data.last_active =
|
||||
days === 30 ? "2026-10-14T10:00:00.000Z" : "2026-10-14T11:00:00.000Z";
|
||||
return Promise.resolve(response);
|
||||
});
|
||||
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
|
||||
expect(await screen.findByText("12:34:56")).toBeInTheDocument();
|
||||
await user.click(screen.getByText("serverOnlineHistory.days30"));
|
||||
expect(await screen.findByText("50.0%")).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledWith(7, 30),
|
||||
);
|
||||
expect(screen.getByText("12:34:56")).toBeInTheDocument();
|
||||
await user.click(screen.getByText("serverOnlineHistory.days7"));
|
||||
expect(screen.getByText("12:34:56")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches the query window via the day selector", async () => {
|
||||
const user = userEvent.setup();
|
||||
apiMocks.fetchServerOnlineHistory.mockResolvedValue(
|
||||
onlineHistoryResponse(),
|
||||
);
|
||||
|
||||
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
|
||||
await screen.findByText("serverOnlineHistory.dailyOnline");
|
||||
|
||||
await user.click(screen.getByText("serverOnlineHistory.days30"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledWith(7, 30);
|
||||
});
|
||||
});
|
||||
|
||||
it("locks day options beyond the configured max window", async () => {
|
||||
const user = userEvent.setup();
|
||||
apiMocks.fetchServerOnlineHistory.mockResolvedValue(
|
||||
onlineHistoryResponse(7),
|
||||
);
|
||||
|
||||
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
|
||||
await screen.findByText("serverOnlineHistory.dailyOnline");
|
||||
|
||||
await user.click(screen.getByText("serverOnlineHistory.days30"));
|
||||
expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledTimes(1);
|
||||
expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledWith(7, 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(<ServerDetailOnlineHistory server_id="7" />);
|
||||
|
||||
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(<ServerDetailOnlineHistory server_id="7" />);
|
||||
|
||||
// 首次 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(<ServerDetailOnlineHistory server_id="7" />);
|
||||
await screen.findByText("serverOnlineHistory.dailyOnline");
|
||||
|
||||
// 20:00Z 在面板时区(UTC+8)为 01-02 04:00:分组落到 01-02,段内时刻同步换算
|
||||
expect(screen.getByText("01-02")).toBeInTheDocument();
|
||||
expect(screen.queryByText("01-01")).not.toBeInTheDocument();
|
||||
const chip = screen.getByTestId("period-chip");
|
||||
expect(chip.textContent).toContain("04:00");
|
||||
expect(chip.textContent).toContain("06:00");
|
||||
});
|
||||
|
||||
it("shows three days initially and expands ten days per click", async () => {
|
||||
const user = userEvent.setup();
|
||||
const data = onlineHistoryResponse().data;
|
||||
const dates = Array.from({ length: 25 }, (_, i) =>
|
||||
dayjs("2026-10-14").subtract(i, "day").format("YYYY-MM-DD"),
|
||||
);
|
||||
data.window_start = `${dates[dates.length - 1]}T00:00:00.000Z`;
|
||||
data.daily = dates.map((date) => ({ date, online_seconds: 3600 }));
|
||||
data.periods = dates.map((date) => ({
|
||||
start: `${date}T06:00:00.000Z`,
|
||||
end: `${date}T07:00:00.000Z`,
|
||||
ongoing: false,
|
||||
}));
|
||||
apiMocks.fetchServerOnlineHistory.mockResolvedValue({
|
||||
success: true,
|
||||
data,
|
||||
});
|
||||
|
||||
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
|
||||
await screen.findByText("serverOnlineHistory.dailyOnline");
|
||||
|
||||
expect(screen.getAllByTestId("period-day")).toHaveLength(3);
|
||||
|
||||
await user.click(screen.getByText("serverOnlineHistory.expandMore"));
|
||||
expect(screen.getAllByTestId("period-day")).toHaveLength(13);
|
||||
|
||||
await user.click(screen.getByText("serverOnlineHistory.expandMore"));
|
||||
expect(screen.getAllByTestId("period-day")).toHaveLength(23);
|
||||
|
||||
await user.click(screen.getByText("serverOnlineHistory.expandMore"));
|
||||
expect(screen.getAllByTestId("period-day")).toHaveLength(25);
|
||||
expect(
|
||||
screen.queryByText("serverOnlineHistory.expandMore"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("derives offline periods as the complement of online sessions", async () => {
|
||||
const user = userEvent.setup();
|
||||
const data = onlineHistoryResponse().data;
|
||||
data.window_start = "2026-10-14T00:00:00.000Z";
|
||||
data.window_end = "2026-10-14T12:00:00.000Z";
|
||||
data.currently_online = false;
|
||||
data.daily = [{ date: "2026-10-14", online_seconds: 7200 }];
|
||||
data.periods = [
|
||||
{
|
||||
start: "2026-10-14T02:00:00.000Z",
|
||||
end: "2026-10-14T04:00:00.000Z",
|
||||
ongoing: false,
|
||||
},
|
||||
];
|
||||
apiMocks.fetchServerOnlineHistory.mockResolvedValue({
|
||||
success: true,
|
||||
data,
|
||||
});
|
||||
|
||||
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
|
||||
await screen.findByText("serverOnlineHistory.dailyOnline");
|
||||
|
||||
await user.click(screen.getByText("serverOnlineHistory.periodsOffline"));
|
||||
|
||||
// 在线 02:00-04:00 两侧的空档各成一段,且服务器当前离线 → 末段进行中
|
||||
const chips = screen.getAllByTestId("period-chip");
|
||||
expect(chips).toHaveLength(2);
|
||||
expect(chips[0].textContent).toContain("00:00");
|
||||
expect(chips[0].textContent).toContain("2h 0m");
|
||||
expect(chips[1].textContent).toContain("04:00");
|
||||
expect(chips[1].textContent).toContain("8h 0m");
|
||||
expect(chips[1].textContent).toContain("serverOnlineHistory.ongoing");
|
||||
|
||||
// 组头合计 = 窗口时长 12h − 当日在线 2h = 10h
|
||||
expect(screen.getByText("10h 0m")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildDaySegments,
|
||||
buildDayWindow,
|
||||
buildOfflineGroups,
|
||||
dailyOnlineRate,
|
||||
formatPeriodMoment,
|
||||
groupOnlinePeriods,
|
||||
panelDay,
|
||||
panelNextDayStart,
|
||||
} from "@/lib/online-history";
|
||||
import type { DailyOnlineStat, OnlinePeriod } from "@/types/nezha-api";
|
||||
|
||||
function daily(date: string, onlineSeconds: number): DailyOnlineStat {
|
||||
return { date, online_seconds: onlineSeconds };
|
||||
}
|
||||
|
||||
function period(start: string, end: string, ongoing = false): OnlinePeriod {
|
||||
return { start, end, ongoing };
|
||||
}
|
||||
|
||||
function expectChipsInsideTheirDay(
|
||||
groups: ReturnType<typeof buildOfflineGroups>,
|
||||
) {
|
||||
for (const group of groups) {
|
||||
const dayStart = panelDay(group.date, "UTC");
|
||||
const dayEnd = panelNextDayStart(group.date, "UTC");
|
||||
for (const chip of group.chips) {
|
||||
expect(chip.start.valueOf()).toBeGreaterThanOrEqual(dayStart.valueOf());
|
||||
expect(chip.end.valueOf()).toBeLessThanOrEqual(dayEnd.valueOf());
|
||||
expect(chip.end.valueOf()).toBeGreaterThan(chip.start.valueOf());
|
||||
}
|
||||
for (let index = 1; index < group.chips.length; index += 1) {
|
||||
expect(group.chips[index].start.valueOf()).toBeGreaterThanOrEqual(
|
||||
group.chips[index - 1].end.valueOf(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("buildOfflineGroups", () => {
|
||||
it("never lets a gap run into a later day's online segment", () => {
|
||||
const groups = buildOfflineGroups(
|
||||
[
|
||||
period("2026-10-02T10:00:00.000Z", "2026-10-02T12:00:00.000Z"),
|
||||
period("2026-10-03T10:00:00.000Z", "2026-10-03T12:00:00.000Z"),
|
||||
],
|
||||
[
|
||||
daily("2026-10-01", 0),
|
||||
daily("2026-10-02", 7200),
|
||||
daily("2026-10-03", 7200),
|
||||
],
|
||||
"2026-10-01T00:00:00.000Z",
|
||||
"2026-10-04T00:00:00.000Z",
|
||||
true,
|
||||
"UTC",
|
||||
);
|
||||
|
||||
expect(groups).toHaveLength(3);
|
||||
expectChipsInsideTheirDay(groups);
|
||||
|
||||
// 10-01 没有在线段:应是一整段 00:00–24:00,而不是跨到 10-02 10:00
|
||||
const oldest = groups[2];
|
||||
expect(oldest.date).toBe("2026-10-01");
|
||||
expect(oldest.chips).toHaveLength(1);
|
||||
expect(oldest.chips[0].start.format("HH:mm")).toBe("00:00");
|
||||
expect(oldest.chips[0].end.format("HH:mm")).toBe("00:00");
|
||||
expect(oldest.chips[0].end.diff(oldest.chips[0].start, "hour")).toBe(24);
|
||||
expect(oldest.total).toBe(86400);
|
||||
|
||||
// 10-02 / 10-03 各自只有 00:00–10:00 与 12:00–24:00 两段
|
||||
for (const group of [groups[1], groups[0]]) {
|
||||
expect(group.chips).toHaveLength(2);
|
||||
expect(group.total).toBe(86400 - 7200);
|
||||
}
|
||||
});
|
||||
|
||||
it("starts the first day at window_start and ends the last day at window_end", () => {
|
||||
const groups = buildOfflineGroups(
|
||||
[period("2026-10-02T07:00:00.000Z", "2026-10-02T08:00:00.000Z")],
|
||||
[daily("2026-10-02", 3600), daily("2026-10-03", 0)],
|
||||
"2026-10-02T06:00:00.000Z",
|
||||
"2026-10-03T12:00:00.000Z",
|
||||
false,
|
||||
"UTC",
|
||||
);
|
||||
|
||||
expectChipsInsideTheirDay(groups);
|
||||
|
||||
const first = groups[1];
|
||||
expect(first.date).toBe("2026-10-02");
|
||||
// 窗口从 06:00 开始,不能凭空生成 00:00–06:00 的离线
|
||||
expect(first.chips[0].start.format("HH:mm")).toBe("06:00");
|
||||
expect(first.chips[0].end.format("HH:mm")).toBe("07:00");
|
||||
// 18h 窗口 − 1h 在线 = 17h
|
||||
expect(first.total).toBe(17 * 3600);
|
||||
|
||||
const last = groups[0];
|
||||
expect(last.date).toBe("2026-10-03");
|
||||
expect(last.chips).toHaveLength(1);
|
||||
expect(last.chips[0].start.format("HH:mm")).toBe("00:00");
|
||||
expect(last.chips[0].end.format("HH:mm")).toBe("12:00");
|
||||
expect(last.total).toBe(12 * 3600);
|
||||
// 服务器当前离线,且末段确实结束于窗口末端 → 标记进行中
|
||||
expect(last.chips[0].ongoing).toBe(true);
|
||||
});
|
||||
|
||||
it("does not mark an old day's last gap as ongoing", () => {
|
||||
const groups = buildOfflineGroups(
|
||||
[],
|
||||
[daily("2026-10-02", 0)],
|
||||
"2026-10-02T00:00:00.000Z",
|
||||
"2026-10-02T12:00:00.000Z",
|
||||
false,
|
||||
"UTC",
|
||||
);
|
||||
|
||||
// 窗口在当天中午结束,末段确实持续到窗口末端
|
||||
expect(groups[0].chips[groups[0].chips.length - 1]?.ongoing).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupOnlinePeriods", () => {
|
||||
it("splits cross-midnight sessions into the same segments as the daily timeline", () => {
|
||||
const periods = [
|
||||
period("2026-10-19T22:00:00.000Z", "2026-10-20T03:00:00.000Z", true),
|
||||
];
|
||||
const groups = groupOnlinePeriods(periods, "UTC");
|
||||
expect(groups.map((g) => [g.date, g.total])).toEqual([
|
||||
["2026-10-20", 10800],
|
||||
["2026-10-19", 7200],
|
||||
]);
|
||||
for (const group of groups) {
|
||||
const segments = buildDaySegments(periods, group.date, "UTC");
|
||||
expect(group.total).toBe(
|
||||
segments.reduce(
|
||||
(sum, s) => sum + s.segEnd.diff(s.segStart, "second"),
|
||||
0,
|
||||
),
|
||||
);
|
||||
expect(group.chips[0].original).toEqual(periods[0]);
|
||||
}
|
||||
expect(groups[0].chips[0].ongoing).toBe(true);
|
||||
expect(groups[1].chips[0].ongoing).toBe(false);
|
||||
});
|
||||
|
||||
it("clips both timeline and chips to the actual query window", () => {
|
||||
const periods = [
|
||||
period("2026-10-19T22:00:00.000Z", "2026-10-21T03:00:00.000Z", true),
|
||||
];
|
||||
const start = "2026-10-20T06:00:00.000Z",
|
||||
end = "2026-10-20T12:00:00.000Z";
|
||||
const [group] = groupOnlinePeriods(periods, "UTC", start, end);
|
||||
expect(group.total).toBe(21600);
|
||||
expect(group.chips[0].start.toISOString()).toBe(start);
|
||||
expect(group.chips[0].end.toISOString()).toBe(end);
|
||||
expect(group.chips[0].ongoing).toBe(false);
|
||||
const [segment] = buildDaySegments(periods, group.date, "UTC", start, end);
|
||||
expect(segment.leftPct).toBe(25);
|
||||
expect(segment.widthPct).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDayWindow", () => {
|
||||
it("distinguishes excluded hours from a partially observed day", () => {
|
||||
const window = buildDayWindow(
|
||||
"2026-10-20",
|
||||
"2026-10-20T06:00:00Z",
|
||||
"2026-10-20T12:00:00Z",
|
||||
"UTC",
|
||||
);
|
||||
expect(window.leftPct).toBe(25);
|
||||
expect(window.rightPct).toBe(50);
|
||||
expect(window.seconds).toBe(21600);
|
||||
expect(dailyOnlineRate(21600, window.seconds)).toBe(100);
|
||||
expect(dailyOnlineRate(0, 0)).toBe(0);
|
||||
});
|
||||
it.each([
|
||||
["2026-03-08", "2026-03-08T05:00:00Z", "2026-03-09T04:00:00Z", 23],
|
||||
["2026-11-01", "2026-11-01T04:00:00Z", "2026-11-02T05:00:00Z", 25],
|
||||
])("uses actual elapsed time across DST on %s", (date, start, end, hours) => {
|
||||
const window = buildDayWindow(date, start, end, "America/New_York");
|
||||
expect(window.seconds).toBe(Number(hours) * 3600);
|
||||
expect(window.leftPct).toBe(0);
|
||||
expect(window.rightPct).toBe(100);
|
||||
expect(dailyOnlineRate(window.seconds, window.seconds)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatPeriodMoment", () => {
|
||||
it("renders a segment ending at the next midnight as 24:00", () => {
|
||||
const day = panelDay("2026-10-01", "UTC");
|
||||
expect(
|
||||
formatPeriodMoment(panelNextDayStart("2026-10-01", "UTC"), day, "UTC"),
|
||||
).toBe("24:00");
|
||||
expect(
|
||||
formatPeriodMoment(
|
||||
panelNextDayStart("2026-10-01", "UTC"),
|
||||
day,
|
||||
"UTC",
|
||||
true,
|
||||
),
|
||||
).toBe("24:00:00");
|
||||
expect(
|
||||
formatPeriodMoment(
|
||||
panelDay("2026-10-01T23:00:00.000Z", "UTC"),
|
||||
day,
|
||||
"UTC",
|
||||
),
|
||||
).toBe("23:00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDaySegments", () => {
|
||||
it("maps positions by panel wall clock on a spring-forward day", () => {
|
||||
const tz = "America/New_York";
|
||||
const [fullDay] = buildDaySegments(
|
||||
[period("2026-03-08T05:00:00.000Z", "2026-03-09T04:00:00.000Z")],
|
||||
"2026-03-08",
|
||||
tz,
|
||||
);
|
||||
// 00:00 EST → 次日 00:00 EDT,全天应铺满 0–100%
|
||||
expect(fullDay.leftPct).toBe(0);
|
||||
expect(fullDay.widthPct).toBe(100);
|
||||
|
||||
const [noon] = buildDaySegments(
|
||||
[period("2026-03-08T16:00:00.000Z", "2026-03-08T18:00:00.000Z")],
|
||||
"2026-03-08",
|
||||
tz,
|
||||
);
|
||||
// 12:00 EDT 必须落在 50%,而不是 elapsed 11h 的 45.8%
|
||||
expect(noon.leftPct).toBeCloseTo(50, 5);
|
||||
expect(noon.widthPct).toBeCloseTo((2 / 24) * 100, 5);
|
||||
|
||||
const [evening] = buildDaySegments(
|
||||
[period("2026-03-09T02:00:00.000Z", "2026-03-09T04:00:00.000Z")],
|
||||
"2026-03-08",
|
||||
tz,
|
||||
);
|
||||
// 22:00 EST → 日界 00:00 EDT:日界必须用挂钟零点,不能加 24h
|
||||
expect(evening.leftPct).toBeCloseTo((22 / 24) * 100, 5);
|
||||
expect(evening.widthPct).toBeCloseTo((2 / 24) * 100, 5);
|
||||
});
|
||||
|
||||
it("keeps a full day at 100% on a fall-back day", () => {
|
||||
const [fullDay] = buildDaySegments(
|
||||
[period("2026-11-01T04:00:00.000Z", "2026-11-02T05:00:00.000Z")],
|
||||
"2026-11-01",
|
||||
"America/New_York",
|
||||
);
|
||||
expect(fullDay.leftPct).toBe(0);
|
||||
expect(fullDay.widthPct).toBe(100);
|
||||
});
|
||||
|
||||
it("marks only the segment ending at the live session end as ongoing", () => {
|
||||
const periods = [
|
||||
period("2026-10-01T22:00:00.000Z", "2026-10-02T06:00:00.000Z", true),
|
||||
];
|
||||
const [previousDay] = buildDaySegments(periods, "2026-10-01", "UTC");
|
||||
const [currentDay] = buildDaySegments(periods, "2026-10-02", "UTC");
|
||||
|
||||
// 前一天被日界裁剪,不能显示为 Ongoing
|
||||
expect(previousDay.live).toBe(false);
|
||||
expect(previousDay.segEnd.format("HH:mm")).toBe("00:00");
|
||||
// 当天这一段才真正结束于会话末端
|
||||
expect(currentDay.live).toBe(true);
|
||||
expect(currentDay.leftPct).toBe(0);
|
||||
expect(currentDay.widthPct).toBeCloseTo((6 / 24) * 100, 5);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -4,6 +4,16 @@ import path from "node:path";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// Node ≥25 默认启用实验性 webstorage:其全局 localStorage 在无有效存储文件时
|
||||
// 会遮蔽 jsdom 注入的同名全局(localStorage.clear 变为非函数)。测试 worker
|
||||
// 进程统一禁用,保证 jsdom 的 storage 生效。
|
||||
process.env.NODE_OPTIONS = [
|
||||
process.env.NODE_OPTIONS,
|
||||
"--no-experimental-webstorage",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
// Get git commit hash
|
||||
const getGitHash = () => {
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user