Merge upstream v2.5.1 while preserving domain dashboard extensions

This commit is contained in:
Codex committed 2026-10-22 23:50:03 +08:00
commit e74143b827
38 files changed
+3201 -329

No files matched your search

+25
View File
@@ -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 节点规模。
-2
View File
@@ -74,8 +74,6 @@
<meta name="apple-mobile-web-app-title" content="Nezha Monitoring" />
<link rel="apple-touch-icon" href="/android-chrome-192x192.png" />
<link rel="stylesheet" href="https://fastly.jsdelivr.net/gh/lipis/flag-icons@7.0.0/css/flag-icons.min.css" />
<link rel="stylesheet" href="https://fastly.jsdelivr.net/npm/font-logos@1/assets/font-logos.css" />
</head>
<body>
<div id="root"></div>
+7 -6
View File
@@ -1,7 +1,7 @@
{
"name": "nazha-dash-v2",
"private": true,
"version": "2.0.2",
"version": "2.5.1",
"type": "module",
"engines": {
"node": ">=22",
@@ -52,6 +52,7 @@
"country-flag-icons": "1.6.17",
"d3-geo": "3.1.1",
"dayjs": "1.11.21",
"flag-icons": "7.0.0",
"i18n-iso-countries": "7.14.0",
"i18next": "26.3.1",
"lucide-react": "1.17.0",
@@ -59,7 +60,7 @@
"react": "19.2.7",
"react-dom": "19.2.7",
"react-i18next": "17.0.8",
"react-router-dom": "^7.18.1",
"react-router-dom": "^7.18.4",
"recharts": "3.8.1",
"sonner": "2.0.7",
"tailwind-merge": "3.6.0",
@@ -75,14 +76,14 @@
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"@vitest/coverage-v8": "^4.1.9",
"@vitest/coverage-v8": "^4.1.12",
"globals": "17.6.0",
"jsdom": "^29.1.1",
"postcss": "8.5.15",
"postcss": "8.5.31",
"tailwindcss": "^4.3.2",
"typescript": "^7.0.2",
"vite": "^8.1.3",
"vitest": "^4.1.9",
"vite": "^8.1.6",
"vitest": "^4.1.12",
"ws": "^8.21.0"
},
"packageManager": "pnpm@11.5.1+sha512.93f7b57422ea7068257235b4c16eb60762eb68e1dc23723199cc739043ea9be2c4143274a399d8c6defa2b1176226d9ca1c4b63482d6200c1a8fbaa78c1d1485"
+390 -258
View File
File diff suppressed because it is too large. Load diff
+132 -6
View File
@@ -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, {
+9 -4
View File
@@ -12,7 +12,6 @@ import { useWebSocketContext } from "@/hooks/use-websocket-context";
import { fetchLoginUser, fetchSetting } from "@/lib/nezha-api";
import { cn } from "@/lib/utils";
import AnimateCountClient from "./AnimatedCount";
import { LanguageSwitcher } from "./LanguageSwitcher";
import { Loader, LoadingSpinner } from "./loading/Loader";
import NumericText from "./NumericText";
@@ -355,12 +354,18 @@ function Overview() {
<p className="text-sm font-medium opacity-50">{t("whereTheTimeIs")}</p>
{mounted ? (
<div className="flex items-center font-medium text-sm">
<AnimateCountClient count={time.hh} minDigits={2} />
<span className="tabular-nums">
{String(time.hh).padStart(2, "0")}
</span>
<span className="mb-px font-medium text-sm opacity-50">:</span>
<AnimateCountClient count={time.mm} minDigits={2} />
<span className="tabular-nums">
{String(time.mm).padStart(2, "0")}
</span>
<span className="mb-px font-medium text-sm opacity-50">:</span>
<span className="font-medium text-sm">
<AnimateCountClient count={time.ss} minDigits={2} />
<span className="tabular-nums">
{String(time.ss).padStart(2, "0")}
</span>
</span>
</div>
) : (
+7
View File
@@ -34,6 +34,13 @@ export function LanguageSwitcher() {
{ name: t("language.es-ES"), code: "es-ES" },
{ name: t("language.de-DE"), code: "de-DE" },
{ name: t("language.ta-IN"), code: "ta-IN" },
{ name: t("language.fr"), code: "fr" },
{ name: t("language.gl"), code: "gl" },
{ name: t("language.id"), code: "id" },
{ name: t("language.ja"), code: "ja" },
{ name: t("language.pt_BR"), code: "pt_BR" },
{ name: t("language.ro"), code: "ro" },
{ name: t("language.uk"), code: "uk" },
];
return (
+14
View File
@@ -274,6 +274,8 @@ export default function ServerDetailChart({
const gpuStats = server.state.gpu || [];
const gpuList = server.host.gpu || [];
// Newer agents also report memory per card, index-aligned with the above.
const gpuDetails = server.state.gpus || [];
return (
<section className="flex flex-col">
@@ -298,6 +300,7 @@ export default function ServerDetailChart({
now={nezhaWsData.now}
gpuStat={gpuStats[index]}
gpuName={gpu}
gpuMemory={gpuDetails[index]}
messageHistory={messageHistory}
period={selectedPeriod}
key={index}
@@ -311,6 +314,7 @@ export default function ServerDetailChart({
now={nezhaWsData.now}
gpuStat={gpu}
gpuName={`#${index + 1}`}
gpuMemory={gpuDetails[index]}
messageHistory={messageHistory}
period={selectedPeriod}
key={index}
@@ -426,6 +430,7 @@ function GpuChart({
index,
gpuStat,
gpuName,
gpuMemory,
messageHistory,
period,
}: {
@@ -434,6 +439,7 @@ function GpuChart({
index: number;
gpuStat: number;
gpuName?: string;
gpuMemory?: { memory_used?: number; memory_total?: number };
messageHistory: NezhaWebsocketResponse[];
period: ChartPeriod;
}) {
@@ -532,6 +538,14 @@ function GpuChart({
<section className="flex flex-col items-center gap-2">
{!gpuName && <p className="text-md font-medium">GPU</p>}
{gpuName && <p className="text-xs mt-1 mb-1.5">GPU: {gpuName}</p>}
{gpuMemory?.memory_total ? (
<p className="text-xs text-muted-foreground">
{gpuMemory.memory_used != null
? formatBytes(gpuMemory.memory_used * 1024 * 1024)
: "—"}{" "}
/ {formatBytes(gpuMemory.memory_total * 1024 * 1024)}
</p>
) : null}
</section>
<section className="flex items-center gap-2">
<p className="text-xs text-end w-10 font-medium">
@@ -0,0 +1,784 @@
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import OnlineHistorySkeleton from "@/components/loading/OnlineHistorySkeleton";
import { Card, CardContent } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useActiveIndicator } from "@/hooks/use-active-indicator";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useWebSocketContext } from "@/hooks/use-websocket-context";
import { fetchServerOnlineHistory } from "@/lib/nezha-api";
import {
buildDailyOnlineRows,
buildOfflineGroups,
formatDuration,
formatPeriodMoment,
groupOnlineRows,
panelDay,
panelNow,
} from "@/lib/online-history";
import { cn } from "@/lib/utils";
const DAY_OPTIONS = [1, 7, 30];
// Online periods 默认展开最近 3 天,每次「展开更多」追加 10 天
const PERIODS_INITIAL_DAYS = 3;
const PERIODS_EXPAND_STEP = 10;
type PeriodMode = "online" | "offline";
// 与详情页一致的轻量指标标签。
function SummaryLabel({ children }: { children: ReactNode }) {
return <span className="text-xs text-muted-foreground">{children}</span>;
}
// 四项指标共用行高,辅助信息在底部对齐。
function SummaryMetric({
label,
children,
footer,
}: {
label: ReactNode;
children: ReactNode;
footer: ReactNode;
}) {
return (
<div className="flex min-w-0 flex-col items-center gap-2 px-3 text-center lg:px-5">
<span className="text-xs leading-4 text-muted-foreground">{label}</span>
<div className="text-lg font-medium leading-7 tracking-tight tabular-nums text-foreground lg:text-[22px]">
{children}
</div>
<div className="flex min-h-4 items-center justify-center text-[11px] leading-4 text-muted-foreground tabular-nums">
{footer}
</div>
</div>
);
}
// 概要卡的信息盒:标签在上、数值在下
function SummaryBox({
label,
children,
}: {
label: ReactNode;
children: ReactNode;
}) {
return (
<div className="flex min-w-0 flex-col items-center justify-center gap-1.5 text-center">
<SummaryLabel>{label}</SummaryLabel>
{children}
</div>
);
}
function WindowShade({
left,
width,
label,
striped = false,
}: {
left: number;
width: number;
label: string;
striped?: boolean;
}) {
if (width <= 0) return null;
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={label}
data-testid={striped ? "outside-window" : "unrecorded-window"}
className={cn(
"absolute inset-y-0 text-stone-300 focus-visible:outline-2 focus-visible:outline-ring dark:text-stone-600",
!striped && "bg-background/80",
)}
style={{
left: `${left}%`,
width: `${width}%`,
backgroundImage: striped
? "repeating-linear-gradient(135deg, transparent, transparent 3px, currentColor 3px, currentColor 5px)"
: undefined,
}}
/>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
function DaysSelector({
selectedDays,
maxDays,
onDaysChange,
}: {
selectedDays: number;
maxDays: number;
onDaysChange: (days: number) => void;
}) {
const { t } = useTranslation();
const { containerRef, enableIndicatorAnimation, indicator, setItemRef } =
useActiveIndicator<number>(DAY_OPTIONS, selectedDays);
return (
<div
ref={containerRef}
data-testid="days-selector"
className="relative flex gap-0.5 flex-wrap p-0.5 bg-muted dark:bg-muted/40 rounded-full w-fit border border-border/60 dark:border-border"
>
{indicator && (
<div
className="active-indicator-fade-in absolute left-0 top-0 z-10 bg-white dark:bg-background rounded-full ring-1 ring-border/60 dark:ring-border/40"
style={{
height: indicator.height,
transform: `translate(${indicator.x}px, ${indicator.y}px)`,
transition: indicator.shouldAnimate
? "transform 0.5s var(--timing), width 0.5s var(--timing), height 0.5s var(--timing)"
: "none",
width: indicator.width,
}}
/>
)}
{DAY_OPTIONS.map((days, index) => {
const isLocked = days > maxDays;
return (
<div
key={days}
ref={setItemRef(index)}
onClick={() => {
if (!isLocked) {
if (selectedDays !== days) {
enableIndicatorAnimation();
}
onDaysChange(days);
}
}}
className={cn(
"relative z-20 cursor-pointer rounded-full px-3 py-1.5 text-xs font-medium transition-colors duration-300",
selectedDays === days
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
isLocked && "cursor-not-allowed opacity-40 grayscale",
)}
>
<p className="whitespace-nowrap">
{t(`serverOnlineHistory.days${days}`)}
</p>
</div>
);
})}
</div>
);
}
function PeriodsModeSwitch({
mode,
onModeChange,
}: {
mode: PeriodMode;
onModeChange: (mode: PeriodMode) => void;
}) {
const { t } = useTranslation();
const options: PeriodMode[] = ["online", "offline"];
const { containerRef, enableIndicatorAnimation, indicator, setItemRef } =
useActiveIndicator(options, mode);
return (
<div
ref={containerRef}
data-testid="period-mode-switch"
className="relative ml-auto -mt-1 -mr-2 flex self-start gap-0.5 p-0.5 bg-muted dark:bg-muted/40 rounded-[8px] w-fit border border-border/60 dark:border-border sm:-mr-3"
>
{indicator && (
<div
className="active-indicator-fade-in absolute left-0 top-0 z-10 bg-white dark:bg-background rounded-[5px] ring-1 ring-border/60 dark:ring-border/40"
style={{
height: indicator.height,
transform: `translate(${indicator.x}px, ${indicator.y}px)`,
transition: indicator.shouldAnimate
? "transform 0.5s var(--timing), width 0.5s var(--timing), height 0.5s var(--timing)"
: "none",
width: indicator.width,
}}
/>
)}
{options.map((option, index) => (
<div
key={option}
ref={setItemRef(index)}
onClick={() => {
if (mode !== option) {
enableIndicatorAnimation();
onModeChange(option);
}
}}
className={cn(
"relative z-20 cursor-pointer rounded-[5px] px-2.5 py-1 text-xs font-medium transition-colors duration-300",
mode === option
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
<p className="whitespace-nowrap">
{t(
option === "online"
? "serverOnlineHistory.periodsOnline"
: "serverOnlineHistory.periodsOffline",
)}
</p>
</div>
))}
</div>
);
}
export default function ServerDetailOnlineHistory({
server_id,
}: {
server_id: string;
}) {
const { t } = useTranslation();
const { lastData } = useWebSocketContext();
const [days, setDays] = useState(DAY_OPTIONS[0]);
const [guestMaxDays, setGuestMaxDays] = useState<number | null>(null);
const [periodMode, setPeriodMode] = useState<PeriodMode>("online");
const [visibleDays, setVisibleDays] = useState(PERIODS_INITIAL_DAYS);
// 桌面为四列,手机为两行指标,两端统一使用在线率进度条。
const isDesktop = useMediaQuery("(min-width: 768px)");
const {
data: historyData,
isError,
isPending,
error: queryError,
} = useQuery({
queryKey: ["online-history", server_id, days],
queryFn: () => fetchServerOnlineHistory(Number(server_id), days),
placeholderData: keepPreviousData,
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchInterval: 60000,
});
// 游客仅能查 1 天:服务端拒绝 days>1 时自动回退到 1d 并锁定更长窗口,
// 避免游客首屏只看到登录提示
useEffect(() => {
if (
guestMaxDays === null &&
queryError instanceof Error &&
queryError.message.includes("only 1d data available for guests")
) {
setGuestMaxDays(1);
setDays(1);
}
}, [guestMaxDays, queryError]);
const customBackgroundImage =
(window.CustomBackgroundImage as string) !== ""
? window.CustomBackgroundImage
: undefined;
const online = historyData?.data;
const tz = online?.timezone;
const dailyRows = useMemo(
() =>
online
? buildDailyOnlineRows(
online.periods,
online.daily,
online.window_start,
online.window_end,
tz,
)
: [],
[online, tz],
);
const periodGroups = useMemo(() => {
if (!online) return [];
return periodMode === "online"
? groupOnlineRows(dailyRows, tz)
: buildOfflineGroups(
online.periods,
online.daily,
online.window_start,
online.window_end,
online.currently_online,
tz,
);
}, [online, dailyRows, periodMode, tz]);
// 最后活跃属于节点实时状态,不随 1/7/30 天查询缓存切换。
// 历史尚无活跃记录时继续展示 N/A,实时数据不可用时回退到接口值。
const lastActive = online?.last_active
? lastData?.servers.find((server) => server.id === Number(server_id))
?.last_active || online.last_active
: undefined;
// 使用接口返回的实际窗口,兼容不足整天的记录和时区切换。
const windowSeconds = online
? panelDay(online.window_end, tz).diff(
panelDay(online.window_start, tz),
"second",
)
: 0;
const offlineSeconds = Math.max(
0,
windowSeconds - (online?.total_online_seconds ?? 0),
);
const windowRange = online
? `${panelDay(online.window_start, tz).format("MM-DD")} – ${panelDay(online.window_end, tz).format("MM-DD")}`
: "";
if (isPending) {
return <OnlineHistorySkeleton days={days} />;
}
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
{online && (
<span
className={cn(
"inline-flex items-center gap-2 text-xs font-medium md:px-1",
online.currently_online
? "text-foreground"
: "text-muted-foreground",
)}
>
<span
className={cn(
"size-2 rounded-full",
online.currently_online
? "bg-green-500"
: "bg-stone-400 dark:bg-stone-500",
)}
/>
{online.currently_online ? t("online") : t("offline")}
</span>
)}
<DaysSelector
selectedDays={days}
maxDays={guestMaxDays ?? online?.max_days ?? DAY_OPTIONS[2]}
onDaysChange={(nextDays) => {
setDays(nextDays);
setVisibleDays(PERIODS_INITIAL_DAYS);
}}
/>
</div>
<Card className="border-0 bg-transparent shadow-none ring-0 dark:shadow-none dark:ring-0">
<CardContent className="px-0 py-2 md:px-6 md:py-4">
{isError && !online ? (
<p className="text-xs text-muted-foreground">
{t("serverOnlineHistory.loginRequired")}
</p>
) : (
online &&
(isDesktop ? (
<div
className="grid grid-cols-4 divide-x divide-stone-100 dark:divide-stone-800"
data-testid="summary-desktop"
>
<SummaryMetric
label={t("serverOnlineHistory.onlineRate")}
footer={
<div className="h-1 w-20 overflow-hidden rounded-full bg-stone-100 dark:bg-stone-800">
<div
className="h-full rounded-full bg-green-500"
style={{
width: `${Math.min(100, Math.max(0, online.online_rate))}%`,
}}
/>
</div>
}
>
{online.online_rate.toFixed(1)}%
</SummaryMetric>
<SummaryMetric
label={t("serverOnlineHistory.totalOnline")}
footer={windowRange}
>
{formatDuration(online.total_online_seconds)}
</SummaryMetric>
<SummaryMetric
label={t("serverOnlineHistory.totalOffline")}
footer={windowRange}
>
{formatDuration(offlineSeconds)}
</SummaryMetric>
<SummaryMetric
label={t("serverOnlineHistory.lastActive")}
footer={
lastActive
? panelDay(lastActive, tz).format("YYYY-MM-DD")
: null
}
>
{lastActive
? panelDay(lastActive, tz).format("HH:mm:ss")
: "N/A"}
</SummaryMetric>
</div>
) : (
<div className="flex flex-col gap-3" data-testid="summary-mobile">
<div className="grid grid-cols-2 gap-4">
<div className="flex min-w-0 flex-col items-center justify-center gap-1.5 text-center">
<SummaryLabel>
{t("serverOnlineHistory.onlineRate")}
</SummaryLabel>
<div className="flex min-w-0 flex-col items-center justify-center gap-1.5">
<span className="shrink-0 text-2xl font-medium leading-7 tracking-tight tabular-nums">
{online.online_rate.toFixed(1)}%
</span>
<div className="h-1 w-20 overflow-hidden rounded-full bg-stone-100 dark:bg-stone-800">
<div
className="h-full rounded-full bg-green-500"
style={{
width: `${Math.min(100, Math.max(0, online.online_rate))}%`,
}}
/>
</div>
</div>
<span className="text-[11px] leading-4 text-muted-foreground tabular-nums">
{windowRange}
</span>
</div>
<div className="flex min-w-0 flex-col items-center justify-center gap-1.5 text-center">
<SummaryLabel>
{t("serverOnlineHistory.lastActive")}
</SummaryLabel>
<span className="text-xl font-medium leading-7 tracking-tight tabular-nums">
{lastActive
? panelDay(lastActive, tz).format("HH:mm:ss")
: "N/A"}
</span>
{lastActive && (
<span className="text-[11px] leading-4 text-muted-foreground tabular-nums">
{panelDay(lastActive, tz).format("YYYY-MM-DD")}
</span>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4 border-t border-stone-100 pt-3 dark:border-stone-800">
<SummaryBox label={t("serverOnlineHistory.totalOnline")}>
<span className="text-lg font-medium leading-6 tracking-tight tabular-nums text-foreground">
{formatDuration(online.total_online_seconds)}
</span>
</SummaryBox>
<SummaryBox label={t("serverOnlineHistory.totalOffline")}>
<span className="text-lg font-medium leading-6 tracking-tight tabular-nums text-foreground">
{formatDuration(offlineSeconds)}
</span>
</SummaryBox>
</div>
</div>
))
)}
</CardContent>
</Card>
<Card className={cn({ "bg-card/70": customBackgroundImage })}>
<CardContent className="px-4 py-3 sm:px-5">
<div className="flex min-h-8 flex-wrap items-center justify-between gap-2">
<p className="text-base font-medium">
{t("serverOnlineHistory.dailyOnline")}
</p>
{/* 面板时区下的 0:00–24:00 挂钟轴 */}
<span className="text-[11px] leading-4 text-muted-foreground tabular-nums">
0:00 – 24:00{tz ? ` · ${tz}` : ""}
</span>
</div>
<TooltipProvider delayDuration={100}>
{/* 宽屏两列一行两日;末行为奇数时占满整行 */}
<div className="mt-3 grid grid-cols-1 gap-2 md:grid-cols-2 md:[&>*:last-child:nth-child(odd)]:col-span-2">
{dailyRows.map((day) => {
const segments = day.segments;
return (
<div
key={day.date}
data-testid="daily-online-day"
className="flex min-w-0 flex-col gap-2.5 rounded-lg bg-muted/40 px-2.5 py-2 dark:bg-muted/20"
>
<div className="flex items-baseline justify-between gap-2 text-xs">
<span className="text-foreground tabular-nums">
{day.date}
</span>
<span className="text-muted-foreground tabular-nums">
{formatDuration(day.online_seconds)}
</span>
</div>
<div className="relative">
<div className="relative h-2 overflow-hidden rounded-full bg-stone-200/70 dark:bg-stone-700">
<WindowShade
left={0}
width={day.window.leftPct}
striped
label={t("serverOnlineHistory.outsideWindow")}
/>
<WindowShade
left={day.window.rightPct}
width={100 - day.window.rightPct}
label={t("serverOnlineHistory.notYetRecorded")}
/>
{segments.map((segment) => (
<Tooltip
key={`${segment.leftPct}-${segment.period.start}`}
>
<TooltipTrigger asChild>
<div
data-testid="online-segment"
className={cn(
"absolute inset-y-0 cursor-pointer rounded-full bg-green-500/90 transition-colors hover:bg-green-600",
segment.live && "bg-green-500",
)}
style={{
left: `${segment.leftPct}%`,
width: `${segment.widthPct}%`,
minWidth: 2,
}}
/>
</TooltipTrigger>
<TooltipContent className="px-2 py-1">
<div className="flex flex-col gap-0.5">
<span>
{segment.segStart.format("HH:mm:ss")}
{" → "}
{segment.live
? t("serverOnlineHistory.ongoing")
: formatPeriodMoment(
segment.segEnd,
segment.segStart,
tz,
true,
)}
</span>
<span className="text-muted-foreground">
{formatDuration(
segment.segEnd.diff(
segment.segStart,
"second",
),
)}
</span>
</div>
</TooltipContent>
</Tooltip>
))}
{[25, 50, 75].map((position) => (
<span
key={position}
aria-hidden
className="pointer-events-none absolute inset-y-0 w-px bg-foreground/15"
style={{ left: `${position}%` }}
/>
))}
</div>
{day.window.seconds > 0 &&
day.window.rightPct > 0 &&
day.window.rightPct < 100 && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
data-testid="data-cutoff"
aria-label={`${t("serverOnlineHistory.dataCutoff")} ${day.window.end.format("HH:mm:ss")}`}
className="absolute -top-0.5 -bottom-0.5 w-px bg-stone-500 dark:bg-stone-400"
style={{ left: `${day.window.rightPct}%` }}
>
<span className="absolute -top-0.5 left-1/2 size-1.5 -translate-x-1/2 rounded-full bg-stone-500 dark:bg-stone-400" />
</button>
</TooltipTrigger>
<TooltipContent>
{t("serverOnlineHistory.dataCutoff")}{" "}
{day.window.end.format("HH:mm:ss")}
</TooltipContent>
</Tooltip>
)}
</div>
<div
aria-hidden
className="flex justify-between text-[9px] leading-none text-muted-foreground tabular-nums"
>
{[0, 6, 12, 18, 24].map((hour) => (
<span key={hour}>{hour}</span>
))}
</div>
</div>
);
})}
</div>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-[10px] text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<span
aria-hidden
className="h-2 w-3 rounded-sm"
style={{
backgroundImage:
"repeating-linear-gradient(135deg, transparent, transparent 3px, currentColor 3px, currentColor 4px)",
}}
/>
{t("serverOnlineHistory.outsideWindow")}
</span>
<span className="inline-flex items-center gap-1.5">
<span
aria-hidden
className="h-2 w-3 rounded-sm border border-border bg-background/80"
/>
{t("serverOnlineHistory.notYetRecorded")}
</span>
<span>{t("serverOnlineHistory.dataCutoffHint")}</span>
</div>
</TooltipProvider>
</CardContent>
</Card>
<Card className={cn({ "bg-card/70": customBackgroundImage })}>
<CardContent className="px-4 py-3 sm:px-5">
<div className="flex min-h-8 flex-wrap items-center justify-between gap-2">
<p className="text-base font-medium">
{t(
periodMode === "online"
? "serverOnlineHistory.periods"
: "serverOnlineHistory.offlinePeriods",
)}
</p>
<PeriodsModeSwitch
mode={periodMode}
onModeChange={(nextMode) => {
setPeriodMode(nextMode);
setVisibleDays(PERIODS_INITIAL_DAYS);
}}
/>
</div>
{(() => {
if (!online) {
return null;
}
const groups = periodGroups;
const visibleGroups = groups.slice(0, visibleDays);
if (groups.length === 0) {
return (
<p className="py-8 text-center text-sm text-muted-foreground">
{t("serverOnlineHistory.noData")}
</p>
);
}
return (
<TooltipProvider delayDuration={100}>
<div className="mt-3 flex flex-col gap-3">
{visibleGroups.map((group) => (
<div key={group.date} data-testid="period-day">
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1 pb-2">
<span className="text-xs font-medium">
{group.day.format("MM-DD")}
{group.day.isSame(panelNow(tz), "day") && (
<span className="text-muted-foreground font-normal">
{" · "}
{t("serverOnlineHistory.today")}
</span>
)}
</span>
<span className="text-xs text-muted-foreground">
{t(periodMode === "online" ? "online" : "offline")}{" "}
<span>{formatDuration(group.total)}</span>
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{group.chips.map((chip, index) => {
const seconds = chip.end.diff(chip.start, "second");
return (
<Tooltip key={`${chip.start.valueOf()}-${index}`}>
<TooltipTrigger asChild>
<span
data-testid="period-chip"
className={cn(
"inline-flex max-w-full items-center gap-1.5 rounded-[6px] px-2 py-1.5 text-xs tabular-nums transition-colors cursor-default",
chip.ongoing
? periodMode === "online"
? "bg-green-50 text-green-700 dark:bg-green-500/10 dark:text-green-400"
: "bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400"
: "bg-stone-100 text-stone-600 hover:bg-stone-200/70 dark:bg-stone-800 dark:text-stone-300 dark:hover:bg-stone-700",
)}
>
{chip.ongoing && (
<span className="size-1.5 shrink-0 rounded-full bg-current" />
)}
{chip.start.format("HH:mm")}
{" → "}
{chip.ongoing
? t("serverOnlineHistory.ongoing")
: formatPeriodMoment(
chip.end,
chip.start,
tz,
)}
<span className="text-muted-foreground">
{formatDuration(seconds)}
</span>
</span>
</TooltipTrigger>
<TooltipContent className="px-2 py-1">
{chip.start.format("HH:mm:ss")}
{" → "}
{chip.ongoing
? t("serverOnlineHistory.ongoing")
: formatPeriodMoment(
chip.end,
chip.start,
tz,
true,
)}
{" · "}
{formatDuration(seconds)}
{chip.original &&
(!chip.start.isSame(
panelDay(chip.original.start, tz),
) ||
!chip.end.isSame(
panelDay(chip.original.end, tz),
)) && (
<div className="mt-1 text-muted-foreground">
{t("serverOnlineHistory.originalSession")}{" "}
{panelDay(chip.original.start, tz).format(
"MM-DD HH:mm:ss",
)}
{" → "}
{chip.original.ongoing
? t("serverOnlineHistory.ongoing")
: panelDay(
chip.original.end,
tz,
).format("MM-DD HH:mm:ss")}
</div>
)}
</TooltipContent>
</Tooltip>
);
})}
</div>
</div>
))}
{groups.length > visibleDays && (
<button
type="button"
data-testid="periods-expand"
onClick={() =>
setVisibleDays((d) => d + PERIODS_EXPAND_STEP)
}
className="w-full rounded-md border border-dashed border-border py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring cursor-pointer"
>
{t("serverOnlineHistory.expandMore")}
</button>
)}
</div>
</TooltipProvider>
);
})()}
</CardContent>
</Card>
</div>
);
}
-2
View File
@@ -106,7 +106,6 @@ export default function ServerOverview({
</p>
<div className="flex items-center gap-2">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-500 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-green-500"></span>
</span>
<NumericText value={online} className="text-lg font-semibold" />
@@ -136,7 +135,6 @@ export default function ServerOverview({
</p>
<div className="flex items-center gap-2">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-500 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-red-500"></span>
</span>
<NumericText value={offline} className="text-lg font-semibold" />
@@ -0,0 +1,138 @@
import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
function Bar({ className }: { className: string }) {
return (
<Skeleton
className={cn(
"rounded-[5px] bg-stone-200/70 motion-reduce:animate-none dark:bg-stone-700/60",
className,
)}
/>
);
}
function Metric({ footer = true }: { footer?: boolean }) {
return (
<div className="flex min-w-0 flex-col items-center justify-center gap-2">
<div className="flex h-4 items-center">
<Bar className="h-3 w-14" />
</div>
<Bar className="h-7 w-24 max-w-full" />
{footer && (
<div className="flex h-4 max-w-full items-center">
<Bar className="h-3 w-20 max-w-full" />
</div>
)}
</div>
);
}
export default function OnlineHistorySkeleton({ days = 1 }: { days?: number }) {
const { t } = useTranslation();
const dayCount = Math.min(31, Math.max(1, days) + 1);
const cardClassName = cn({
"bg-card/70":
typeof window !== "undefined" && Boolean(window.CustomBackgroundImage),
});
return (
<div
role="status"
aria-label={t("serverOnlineHistory.loading")}
aria-busy="true"
data-testid="online-history-loading"
>
<div aria-hidden="true" className="flex flex-col gap-4">
<div className="flex min-h-9 items-center justify-between gap-3">
<div className="flex items-center gap-2 md:px-1">
<Bar className="size-2 rounded-full" />
<Bar className="h-3 w-8" />
</div>
<Bar className="h-9 w-36 rounded-full" />
</div>
<div className="px-0 py-2 md:px-6 md:py-4">
<div className="hidden grid-cols-4 divide-x divide-stone-100 dark:divide-stone-800 md:grid">
{Array.from({ length: 4 }, (_, index) => (
<Metric key={index} />
))}
</div>
<div className="flex flex-col gap-3 md:hidden">
<div className="grid grid-cols-2 gap-4">
<Metric />
<Metric />
</div>
<div className="grid grid-cols-2 gap-4 border-t border-stone-100 pt-3 dark:border-stone-800">
<Metric footer={false} />
<Metric footer={false} />
</div>
</div>
</div>
<Card className={cardClassName}>
<CardContent className="px-4 py-3 sm:px-5">
<div className="flex min-h-8 flex-wrap items-center justify-between gap-2">
<Bar className="h-4 w-28" />
<Bar className="h-3 w-32" />
</div>
<div className="mt-3 grid grid-cols-1 gap-2 md:grid-cols-2 md:[&>*:last-child:nth-child(odd)]:col-span-2">
{Array.from({ length: dayCount }, (_, index) => (
<div
key={index}
className="flex min-w-0 flex-col gap-2.5 rounded-lg bg-muted/40 px-2.5 py-2 dark:bg-muted/20"
>
<div className="flex h-4 items-center justify-between gap-2">
<Bar className="h-3 w-20" />
<Bar className="h-3 w-14" />
</div>
<Bar className="h-2 w-full rounded-full" />
<div className="flex h-[9px] items-center justify-between">
{Array.from({ length: 5 }, (_, tick) => (
<Bar key={tick} className="h-1 w-2" />
))}
</div>
</div>
))}
</div>
<div className="mt-3 flex h-4 items-center gap-4">
<Bar className="h-2 w-16" />
<Bar className="h-2 w-16" />
<Bar className="h-2 w-20" />
</div>
</CardContent>
</Card>
<Card className={cardClassName}>
<CardContent className="px-4 py-3 sm:px-5">
<div className="flex min-h-8 items-center justify-between gap-2">
<Bar className="h-4 w-24" />
<Bar className="-mt-1 -mr-2 h-[30px] w-24 self-start rounded-[8px] sm:-mr-3" />
</div>
<div className="mt-3 flex flex-col gap-3">
{Array.from({ length: Math.min(3, dayCount) }, (_, index) => (
<div key={index}>
<div className="flex h-6 items-start justify-between gap-3">
<Bar className="h-3 w-16" />
<Bar className="h-3 w-20" />
</div>
<div className="flex flex-wrap gap-1.5">
<Bar className="h-7 w-36" />
<Bar className="h-7 w-40" />
</div>
</div>
))}
{dayCount > 3 && (
<div className="flex h-8 items-center justify-center rounded-md border border-dashed border-border">
<Bar className="h-3 w-16" />
</div>
)}
</div>
</CardContent>
</Card>
</div>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { useEffect, useState } from "react";
// matchMedia 不可用(如 jsdom)时默认命中桌面布局,保证测试环境渲染完整概要
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => {
if (
typeof window === "undefined" ||
typeof window.matchMedia !== "function"
) {
return true;
}
return window.matchMedia(query).matches;
});
useEffect(() => {
if (typeof window.matchMedia !== "function") {
return;
}
const mediaQueryList = window.matchMedia(query);
const onChange = (event: MediaQueryListEvent) => {
setMatches(event.matches);
};
setMatches(mediaQueryList.matches);
mediaQueryList.addEventListener("change", onChange);
return () => {
mediaQueryList.removeEventListener("change", onChange);
};
}, [query]);
return matches;
}
+28
View File
@@ -4,8 +4,15 @@ import { initReactI18next } from "react-i18next";
import deTranslation from "./locales/de/translation.json";
import enTranslation from "./locales/en/translation.json";
import esTranslation from "./locales/es/translation.json";
import frTranslation from "./locales/fr/translation.json";
import glTranslation from "./locales/gl/translation.json";
import idTranslation from "./locales/id/translation.json";
import jaTranslation from "./locales/ja/translation.json";
import ptBRTranslation from "./locales/pt_BR/translation.json";
import roTranslation from "./locales/ro/translation.json";
import ruTranslation from "./locales/ru/translation.json";
import taTranslation from "./locales/ta/translation.json";
import ukTranslation from "./locales/uk/translation.json";
import zhCNTranslation from "./locales/zh-CN/translation.json";
import zhTWTranslation from "./locales/zh-TW/translation.json";
@@ -25,12 +32,33 @@ const resources = {
"es-ES": {
translation: esTranslation,
},
fr: {
translation: frTranslation,
},
gl: {
translation: glTranslation,
},
id: {
translation: idTranslation,
},
ja: {
translation: jaTranslation,
},
pt_BR: {
translation: ptBRTranslation,
},
ro: {
translation: roTranslation,
},
"ru-RU": {
translation: ruTranslation,
},
"ta-IN": {
translation: taTranslation,
},
uk: {
translation: ukTranslation,
},
};
const getStoredLanguage = () => {
+24
View File
@@ -3,12 +3,21 @@ import type {
MetricPeriod,
MetricType,
MonitorResponse,
OnlineHistoryResponse,
ServerGroupResponse,
ServerMetricsResponse,
ServiceResponse,
SettingResponse,
} from "@/types/nezha-api";
export const API_ERROR_UNAUTHORIZED = "ApiErrorUnauthorized";
export function isUnauthorizedError(error: unknown): boolean {
const message =
error instanceof Error ? error.message : error == null ? "" : String(error);
return message === API_ERROR_UNAUTHORIZED;
}
let lastestRefreshTokenAt = 0;
const getCookieValue = (name: string): string | undefined => {
@@ -110,3 +119,18 @@ export const fetchServerMetrics = async (
}
return data;
};
export const fetchServerOnlineHistory = async (
server_id: number,
days?: number,
): Promise<OnlineHistoryResponse> => {
const query = days ? `?days=${days}` : "";
const response = await fetch(
`/api/v1/server/${server_id}/online-history${query}`,
);
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
return data;
};
+398
View File
@@ -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;
}
+20 -1
View File
@@ -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"
}
}
+38 -3
View File
@@ -98,7 +98,14 @@
"de-DE": "Deutsch",
"es-ES": "Español",
"ru-RU": "Русский",
"ta-IN": "தமிழ்"
"ta-IN": "தமிழ்",
"fr": "Français",
"gl": "Galego",
"id": "Bahasa Indonesia",
"ja": "日本語",
"pt_BR": "Português (Brasil)",
"ro": "Română",
"uk": "Українська"
},
"theme": {
"light": "Light",
@@ -110,11 +117,14 @@
"pageNotFound": "Page not found",
"backToHome": "Back to home",
"backendUnavailableTitle": "Backend API unavailable",
"backendUnavailableDescription": "The page is still available, but data cannot be loaded right now."
"backendUnavailableDescription": "The page is still available, but data cannot be loaded right now.",
"loginRequiredTitle": "Login required",
"loginRequiredDescription": "This site requires a login to load monitoring data."
},
"tabSwitch": {
"Detail": "Detail",
"Network": "Network"
"Network": "Network",
"Online": "Online"
},
"monitor": {
"noData": "No server monitor data, please add a service monitor first",
@@ -176,5 +186,30 @@
},
"group": {
"all": "All"
},
"serverOnlineHistory": {
"loading": "Loading online history",
"originalSession": "Original session",
"dataCutoffHint": "Marker: data cutoff",
"dataCutoff": "Data through",
"notYetRecorded": "Not yet recorded",
"outsideWindow": "Outside selected window",
"days1": "1d",
"days7": "7d",
"days30": "30d",
"onlineRate": "Online rate",
"totalOnline": "Total online",
"totalOffline": "Total offline",
"lastActive": "Last active",
"dailyOnline": "Daily online time",
"periods": "Online periods",
"ongoing": "Ongoing",
"noData": "No online records",
"loginRequired": "This window requires login; guests can switch to 1d",
"offlinePeriods": "Offline periods",
"periodsOnline": "Online",
"periodsOffline": "Offline",
"expandMore": "Expand more",
"today": "Today"
}
}
+20 -1
View File
@@ -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"
}
}
+21
View File
@@ -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"
}
}
+21 -2
View File
@@ -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"
}
}
+23 -2
View File
@@ -108,11 +108,14 @@
"pageNotFound": "Halaman tidak ditemukan",
"backToHome": "Kembali ke beranda",
"backendUnavailableTitle": "API backend tidak tersedia",
"backendUnavailableDescription": "Halaman masih tersedia, tetapi data tidak dapat dimuat saat ini."
"backendUnavailableDescription": "Halaman masih tersedia, tetapi data tidak dapat dimuat saat ini.",
"loginRequiredTitle": "Perlu masuk",
"loginRequiredDescription": "Situs ini memerlukan login untuk memuat data pemantauan."
},
"tabSwitch": {
"Detail": "Detail",
"Network": "Jaringan"
"Network": "Jaringan",
"Online": "Online"
},
"monitor": {
"noData": "Tidak ada data pemantauan server, harap tambahkan pemantauan layanan terlebih dahulu",
@@ -167,5 +170,23 @@
},
"group": {
"all": "Semua"
},
"serverOnlineHistory": {
"days1": "1d",
"days7": "7d",
"days30": "30d",
"onlineRate": "Online rate",
"totalOnline": "Online in window",
"lastActive": "Last active",
"dailyOnline": "Daily online time",
"periods": "Online periods",
"ongoing": "Ongoing",
"noData": "No online records",
"loginRequired": "This window requires login; guests can switch to 1d",
"offlinePeriods": "Offline periods",
"periodsOnline": "Online",
"periodsOffline": "Offline",
"expandMore": "Expand more",
"today": "Today"
}
}
+21
View File
@@ -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"
}
}
+23 -2
View File
@@ -108,11 +108,14 @@
"pageNotFound": "Página não encontrada",
"backToHome": "Voltar ao inicio",
"backendUnavailableTitle": "API Backend indisponível",
"backendUnavailableDescription": "A página ainda está disponível, mas os dados não podem ser carregados agora."
"backendUnavailableDescription": "A página ainda está disponível, mas os dados não podem ser carregados agora.",
"loginRequiredTitle": "Login necessário",
"loginRequiredDescription": "Este site exige login para carregar os dados de monitoramento."
},
"tabSwitch": {
"Detail": "Detalhes",
"Network": "Rede"
"Network": "Rede",
"Online": "Online"
},
"monitor": {
"noData": "Sem servidores para monitorar. por favor adicione um primeiro monitor de serviço",
@@ -163,5 +166,23 @@
},
"group": {
"all": "Todos"
},
"serverOnlineHistory": {
"days1": "1d",
"days7": "7d",
"days30": "30d",
"onlineRate": "Online rate",
"totalOnline": "Online in window",
"lastActive": "Last active",
"dailyOnline": "Daily online time",
"periods": "Online periods",
"ongoing": "Ongoing",
"noData": "No online records",
"loginRequired": "This window requires login; guests can switch to 1d",
"offlinePeriods": "Offline periods",
"periodsOnline": "Online",
"periodsOffline": "Offline",
"expandMore": "Expand more",
"today": "Today"
}
}
+22 -1
View File
@@ -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"
}
}
+20 -1
View File
@@ -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"
}
}
+20 -1
View File
@@ -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"
}
}
+22 -1
View File
@@ -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"
}
}
+30 -2
View File
@@ -111,11 +111,14 @@
"pageNotFound": "页面不存在",
"backToHome": "回到主页",
"backendUnavailableTitle": "后端 API 无法访问",
"backendUnavailableDescription": "页面仍会正常显示,但暂时无法加载监控数据。"
"backendUnavailableDescription": "页面仍会正常显示,但暂时无法加载监控数据。",
"loginRequiredTitle": "需要登录",
"loginRequiredDescription": "当前站点已开启强制登录,登录后即可加载监控数据。"
},
"tabSwitch": {
"Detail": "详情",
"Network": "网络"
"Network": "网络",
"Online": "在线"
},
"monitor": {
"noData": "没有服务监控数据,请在管理后台服务页添加监控任务",
@@ -177,5 +180,30 @@
},
"group": {
"all": "全部"
},
"serverOnlineHistory": {
"loading": "正在加载在线记录",
"originalSession": "原始会话",
"dataCutoffHint": "竖线:数据截止",
"dataCutoff": "数据截止",
"notYetRecorded": "尚未统计",
"outsideWindow": "未纳入统计",
"days1": "1 天",
"days7": "7 天",
"days30": "30 天",
"onlineRate": "在线率",
"totalOnline": "累计在线",
"totalOffline": "累计离线",
"lastActive": "最后活跃",
"dailyOnline": "每日在线时长",
"periods": "在线时间段",
"ongoing": "进行中",
"noData": "暂无在线记录",
"loginRequired": "当前窗口需要登录后查看,游客可切换到 1 天",
"offlinePeriods": "不在线时间段",
"periodsOnline": "在线",
"periodsOffline": "离线",
"expandMore": "展开更多",
"today": "今天"
}
}
+58 -25
View File
@@ -2,11 +2,11 @@
"nezha": "哪吒監控",
"overview": "概覽",
"dashboard": "管理後台",
"login": "登錄",
"online": "在線",
"login": "登入",
"online": "線上",
"offline": "離線",
"whereTheTimeIs": "目前時間",
"refreshing": "刷新中",
"refreshing": "重新整理中",
"info": {
"websocketConnecting": "WebSocket 連線中",
"websocketConnected": "WebSocket 連線成功",
@@ -19,7 +19,7 @@
"totalServers": "總伺服器",
"onlineServers": "線上伺服器",
"offlineServers": "離線伺服器",
"totalBandwidth": "總帶寬",
"totalBandwidth": "總頻寬",
"speed": "速率",
"network": "網路",
"totalDomains": "總域名數"
@@ -30,14 +30,14 @@
"Servers": "個伺服器"
},
"serverCard": {
"mem": "內存",
"stg": "存儲",
"mem": "記憶體",
"stg": "儲存空間",
"days": "天",
"hours": "小時",
"upload": "上傳",
"download": "下載",
"system": "系統",
"uptime": "運行時間",
"uptime": "運作時間",
"totalUpload": "總上傳",
"totalDownload": "總下載"
},
@@ -47,8 +47,8 @@
"nextUpdate": "下次更新"
},
"serviceTracker": {
"noService": "無服務數據",
"uptime": "在線率",
"noService": "無服務資料",
"uptime": "上線率",
"delay": "延遲",
"daysAgo": "天前",
"today": "今天",
@@ -61,26 +61,32 @@
"hours": "小時",
"offline": "離線",
"unknown": "未知",
"uptime": "運行時間",
"uptime": "運作時間",
"version": "版本",
"arch": "架構",
"mem": "內存",
"disk": "磁盤",
"mem": "記憶體",
"disk": "磁碟",
"region": "地區",
"system": "系統",
"upload": "上傳",
"download": "下載",
"lastActive": "最後上報時間",
"lastActive": "最後回報時間",
"temperature": "溫度",
"bootTime": "啟動時間"
},
"serverDetailChart": {
"process": "進程數",
"disk": "磁盤",
"mem": "內存",
"process": "程序數",
"disk": "磁碟",
"mem": "記憶體",
"swap": "虛擬記憶體",
"upload": "上傳",
"download": "下載"
"download": "下載",
"realtime": "即時",
"period1d": "1 天",
"period7d": "7 天",
"period30d": "30 天",
"tsdbRequired": "請啟用 TSDB 以使用歷史資料",
"loginRequired": "請先登入才能查看"
},
"footer": {
"themeBy": "主題-"
@@ -102,7 +108,7 @@
},
"error": {
"pageNotFound": "頁面不存在",
"backToHome": "回到主頁",
"backToHome": "回到首頁",
"backendUnavailableTitle": "後端 API 無法存取",
"backendUnavailableDescription": "頁面仍會正常顯示,但暫時無法載入監控資料。"
},
@@ -110,23 +116,25 @@
"detail": "詳細資訊",
"network": "網路",
"Detail": "詳細資訊",
"Network": "網路"
"Network": "網路",
"Online": "線上"
},
"monitor": {
"noData": "沒有服務監控數據,請在管理後台服務新增監控任務",
"noData": "沒有伺服器監控資料,請先新增服務監控",
"status": "狀態",
"avgDelay": "延遲",
"monitorCount": "個監控",
"packetLoss": "丟包率",
"packetLoss": "封包遺失率",
"clearSelections": "清除",
"peakCut": "削峰",
"period1d": "1 天",
"period7d": "7 天",
"period30d": "30 天"
"period30d": "30 天",
"loginRequired": "請先登入才能查看"
},
"billingInfo": {
"remaining": "剩餘天數",
"error": "獲取失敗",
"error": "取得失敗",
"indefinite": "無限期",
"expired": "已過期",
"days": "天",
@@ -151,7 +159,7 @@
},
"pwa": {
"offlineReady": "可離線使用之應用程式",
"newContent": "有新内容可用",
"newContent": "有新內容可用",
"reload": "更新"
},
"sort": {
@@ -159,7 +167,7 @@
"types": {
"default": "預設",
"name": "名稱",
"uptime": "運行時間",
"uptime": "運作時間",
"system": "系統",
"cpu": "CPU",
"mem": "記憶體",
@@ -172,5 +180,30 @@
},
"group": {
"all": "全部"
},
"serverOnlineHistory": {
"loading": "正在載入在線記錄",
"originalSession": "原始連線",
"dataCutoffHint": "直線:資料截止",
"dataCutoff": "資料截止",
"notYetRecorded": "尚未統計",
"outsideWindow": "未納入統計",
"days1": "1 天",
"days7": "7 天",
"days30": "30 天",
"onlineRate": "線上率",
"totalOnline": "累計在線",
"totalOffline": "累計離線",
"lastActive": "最後活躍",
"dailyOnline": "每日線上時長",
"periods": "線上時間段",
"ongoing": "進行中",
"noData": "暫無線上紀錄",
"loginRequired": "此視窗需要登入後查看,訪客可切換到 1 天",
"offlinePeriods": "Offline periods",
"periodsOnline": "在線",
"periodsOffline": "離線",
"expandMore": "Expand more",
"today": "Today"
}
}
+4
View File
@@ -14,6 +14,10 @@ import { WebSocketProvider } from "./context/websocket-provider";
import { initCustomConfig } from "./lib/custom-config";
import "./i18n";
import "./index.css";
// Bundled instead of loaded from jsdelivr: the CDN <link> in index.html was
// render-blocking and unreliable on mainland networks. Vite hashes the flag
// sprites into assets/, so they inherit the immutable cache headers.
import "flag-icons/css/flag-icons.min.css";
initCustomConfig();
+27 -7
View File
@@ -19,11 +19,16 @@ import ServerCard from "@/components/ServerCard";
import ServerCardInline from "@/components/ServerCardInline";
import ServerOverview from "@/components/ServerOverview";
import { ServiceTracker } from "@/components/ServiceTracker";
import { Button } from "@/components/ui/button";
import { SORT_TYPES } from "@/context/sort-context";
import { useSort } from "@/hooks/use-sort";
import { useStatus } from "@/hooks/use-status";
import { useWebSocketContext } from "@/hooks/use-websocket-context";
import { fetchServerGroup, fetchService } from "@/lib/nezha-api";
import {
fetchServerGroup,
fetchService,
isUnauthorizedError,
} from "@/lib/nezha-api";
import { cn } from "@/lib/utils";
import type { NezhaServer, ServerGroup } from "@/types/nezha-api";
@@ -55,21 +60,36 @@ const getErrorMessage = (error: unknown) => {
function BackendErrorState({ error }: { error: unknown }) {
const { t } = useTranslation();
const unauthorized = isUnauthorizedError(error);
const message = getErrorMessage(error);
return (
<div className="flex min-h-96 flex-col items-center justify-center px-4 text-center">
<div className="flex max-w-md flex-col items-center gap-2">
<p className="text-sm font-semibold text-stone-900 dark:text-stone-100">
{t("error.backendUnavailableTitle")}
{t(
unauthorized
? "error.loginRequiredTitle"
: "error.backendUnavailableTitle",
)}
</p>
<p className="text-sm text-muted-foreground">
{t("error.backendUnavailableDescription")}
{t(
unauthorized
? "error.loginRequiredDescription"
: "error.backendUnavailableDescription",
)}
</p>
{message && (
<p className="mt-1 max-w-full break-words rounded-md bg-stone-100 px-2 py-1 text-xs text-stone-500 dark:bg-stone-800 dark:text-stone-400">
{message}
</p>
{unauthorized ? (
<Button asChild className="mt-2">
<a href="/dashboard">{t("login")}</a>
</Button>
) : (
message && (
<p className="mt-1 max-w-full break-words rounded-md bg-stone-100 px-2 py-1 text-xs text-stone-500 dark:bg-stone-800 dark:text-stone-400">
{message}
</p>
)
)}
</div>
</div>
+13 -1
View File
@@ -1,5 +1,6 @@
import { lazy, Suspense, useEffect, useState } from "react";
import { Navigate, useParams } from "react-router-dom";
import OnlineHistorySkeleton from "@/components/loading/OnlineHistorySkeleton";
import NetworkChartLoading from "@/components/NetworkChartLoading";
import ServerDetailChart from "@/components/ServerDetailChart";
import ServerDetailOverview from "@/components/ServerDetailOverview";
@@ -12,12 +13,18 @@ const NetworkChart = lazy(() =>
})),
);
const ServerDetailOnlineHistory = lazy(() =>
import("@/components/ServerDetailOnlineHistory").then((module) => ({
default: module.default,
})),
);
export default function ServerDetail() {
useEffect(() => {
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
}, []);
const tabs = ["Detail", "Network"];
const tabs = ["Detail", "Network", "Online"];
const [currentTab, setCurrentTab] = useState(tabs[0]);
const { id: server_id } = useParams();
@@ -51,6 +58,11 @@ export default function ServerDetail() {
<NetworkChart server_id={Number(server_id)} show={true} />
</Suspense>
)}
{currentTab === tabs[2] && (
<Suspense fallback={<OnlineHistorySkeleton />}>
<ServerDetailOnlineHistory server_id={server_id} />
</Suspense>
)}
</div>
);
}
@@ -0,0 +1,450 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import dayjs from "dayjs";
import type { ReactElement } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import ServerDetailOnlineHistory from "@/components/ServerDetailOnlineHistory";
import { createTestQueryClient } from "@/test/utils";
import type { OnlineHistoryResponse } from "@/types/nezha-api";
const websocketMocks = vi.hoisted(() => ({
lastData: null as { servers: { id: number; last_active: string }[] } | null,
}));
vi.mock("@/hooks/use-websocket-context", () => ({
useWebSocketContext: () => websocketMocks,
}));
const apiMocks = vi.hoisted(() => ({
fetchServerOnlineHistory: vi.fn(),
}));
vi.mock("@/lib/nezha-api", () => ({
fetchServerOnlineHistory: apiMocks.fetchServerOnlineHistory,
}));
function onlineHistoryResponse(maxDays = 30): OnlineHistoryResponse {
return {
success: true,
data: {
server_id: 7,
server_name: "edge-online",
days: 7,
max_days: maxDays,
timezone: "UTC",
window_start: "2026-10-07T04:00:00.000Z",
window_end: "2026-10-14T12:00:00.000Z",
total_online_seconds: 5400,
online_rate: 0.89,
currently_online: true,
last_active: "2026-10-14T03:30:00.000Z",
periods: [
{
start: "2026-10-13T08:00:00.000Z",
end: "2026-10-13T09:30:00.000Z",
ongoing: false,
},
{
start: "2026-10-14T10:00:00.000Z",
end: "2026-10-14T11:00:00.000Z",
ongoing: true,
},
],
daily: [
{ date: "2026-10-13", online_seconds: 5400 },
{ date: "2026-10-14", online_seconds: 3600 },
],
},
};
}
function renderWithQuery(ui: ReactElement) {
return render(
<QueryClientProvider client={createTestQueryClient()}>
{ui}
</QueryClientProvider>,
);
}
describe("ServerDetailOnlineHistory", () => {
beforeEach(() => {
websocketMocks.lastData = null;
apiMocks.fetchServerOnlineHistory.mockReset();
});
it("renders the loading state while history data is unavailable", () => {
apiMocks.fetchServerOnlineHistory.mockReturnValue(
new Promise(() => undefined),
);
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
expect(
screen.getByRole("status", { name: "serverOnlineHistory.loading" }),
).toHaveAttribute("aria-busy", "true");
expect(screen.getByTestId("online-history-loading")).toBeInTheDocument();
expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledWith(7, 1);
});
it("keeps existing content instead of flashing the skeleton while changing days", async () => {
apiMocks.fetchServerOnlineHistory
.mockResolvedValueOnce(onlineHistoryResponse())
.mockReturnValue(new Promise(() => undefined));
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
await screen.findByText("0.9%");
expect(
screen.queryByTestId("online-history-loading"),
).not.toBeInTheDocument();
await userEvent
.setup()
.click(screen.getByText("serverOnlineHistory.days30"));
await waitFor(() =>
expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledWith(7, 30),
);
expect(screen.getByText("0.9%")).toBeInTheDocument();
expect(
screen.queryByTestId("online-history-loading"),
).not.toBeInTheDocument();
});
it("renders daily totals and all groups when fewer than three days are available", async () => {
apiMocks.fetchServerOnlineHistory.mockResolvedValue(
onlineHistoryResponse(),
);
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
await screen.findByText("serverOnlineHistory.dailyOnline");
expect(screen.getAllByText("online")).toHaveLength(3);
expect(screen.getByText("0.9%")).toBeInTheDocument();
expect(screen.getAllByText("1h 30m")).toHaveLength(4);
expect(screen.getAllByText("1h 0m")).toHaveLength(3);
expect(screen.getByText("10-13")).toBeInTheDocument();
expect(
screen.queryByText("serverOnlineHistory.expandMore"),
).not.toBeInTheDocument();
expect(screen.getAllByTestId("period-chip")).toHaveLength(2);
});
it.each([
[1800, "1h 30m"],
[7200, "0m"],
[7500, "0m"],
])("calculates offline time from the actual window with %i online seconds", async (seconds, expected) => {
const response = onlineHistoryResponse();
response.data.window_start = "2026-10-14T10:00:00.000Z";
response.data.window_end = "2026-10-14T12:00:00.000Z";
response.data.total_online_seconds = seconds;
apiMocks.fetchServerOnlineHistory.mockResolvedValue(response);
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
const label = await screen.findByText("serverOnlineHistory.totalOffline");
expect(label.parentElement).toHaveTextContent(expected);
});
it("maps each day's periods onto the shared 0:00-24:00 timeline", async () => {
apiMocks.fetchServerOnlineHistory.mockResolvedValue(
onlineHistoryResponse(),
);
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
expect(await screen.findByText(/0:00 – 24:00/)).toBeInTheDocument();
// 时间轴按面板时区(UTC)的挂钟定位:08:00 在 33.33%,1.5h 宽 6.25%
const segments = await screen.findAllByTestId("online-segment");
expect(segments).toHaveLength(2);
expect(Number.parseFloat(segments[0].style.left)).toBeCloseTo(
(8 / 24) * 100,
2,
);
expect(Number.parseFloat(segments[0].style.width)).toBeCloseTo(
(1.5 / 24) * 100,
2,
);
expect(segments[0].style.minWidth).toBe("2px");
});
it("keeps daily totals, carry-over chips and window markers consistent", async () => {
const response = onlineHistoryResponse();
Object.assign(response.data, {
window_start: "2026-10-13T06:00:00.000Z",
window_end: "2026-10-14T12:00:00.000Z",
total_online_seconds: 9 * 3600,
daily: [
{ date: "2026-10-13", online_seconds: 7200 },
{ date: "2026-10-14", online_seconds: 7 * 3600 },
],
periods: [
{
start: "2026-10-13T22:00:00.000Z",
end: "2026-10-14T02:00:00.000Z",
ongoing: false,
},
{
start: "2026-10-14T03:00:00.000Z",
end: "2026-10-14T04:00:00.000Z",
ongoing: false,
},
{
start: "2026-10-14T08:00:00.000Z",
end: "2026-10-14T12:00:00.000Z",
ongoing: true,
},
],
});
apiMocks.fetchServerOnlineHistory.mockResolvedValue(response);
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
const rows = await screen.findAllByTestId("daily-online-day");
expect(within(rows[1]).getByText("7h 0m")).toBeInTheDocument();
expect(within(rows[1]).getAllByTestId("online-segment")).toHaveLength(3);
const latestGroup = screen.getAllByTestId("period-day")[0];
expect(within(latestGroup).getByText("7h 0m")).toBeInTheDocument();
const chips = within(latestGroup).getAllByTestId("period-chip");
expect(chips).toHaveLength(3);
expect(chips[0]).toHaveTextContent("00:00 → 02:00");
expect(screen.getByTestId("outside-window")).toHaveStyle({ width: "25%" });
expect(screen.getByTestId("unrecorded-window")).toHaveStyle({
left: "50%",
width: "50%",
});
expect(screen.getByTestId("data-cutoff")).toHaveAccessibleName(
"serverOnlineHistory.dataCutoff 12:00:00",
);
await userEvent.setup().hover(chips[0]);
expect(await screen.findByRole("tooltip")).toHaveTextContent(
"serverOnlineHistory.originalSession 10-13 22:00:00 → 10-14 02:00:00",
);
});
it("renders the compact mobile summary below the md breakpoint", async () => {
apiMocks.fetchServerOnlineHistory.mockResolvedValue(
onlineHistoryResponse(),
);
vi.stubGlobal(
"matchMedia",
vi.fn().mockImplementation(() => ({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})),
);
try {
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
expect(await screen.findByTestId("summary-mobile")).toBeInTheDocument();
expect(screen.queryByTestId("summary-desktop")).not.toBeInTheDocument();
// 移动端:在线率 + 在线/离线双指标 + 最近活跃。
expect(screen.getByText("0.9%")).toBeInTheDocument();
expect(
screen.getByText("serverOnlineHistory.totalOffline"),
).toBeInTheDocument();
expect(
screen.getByText("serverOnlineHistory.totalOnline"),
).toBeInTheDocument();
expect(
screen.getByText("serverOnlineHistory.lastActive"),
).toBeInTheDocument();
} finally {
vi.unstubAllGlobals();
}
});
it("keeps the live last-active time when switching history windows", async () => {
const user = userEvent.setup();
websocketMocks.lastData = {
servers: [{ id: 7, last_active: "2026-10-14T12:34:56.000Z" }],
};
apiMocks.fetchServerOnlineHistory.mockImplementation((_id, days) => {
const response = onlineHistoryResponse();
response.data.online_rate = days === 30 ? 50 : 90;
response.data.last_active =
days === 30 ? "2026-10-14T10:00:00.000Z" : "2026-10-14T11:00:00.000Z";
return Promise.resolve(response);
});
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
expect(await screen.findByText("12:34:56")).toBeInTheDocument();
await user.click(screen.getByText("serverOnlineHistory.days30"));
expect(await screen.findByText("50.0%")).toBeInTheDocument();
await waitFor(() =>
expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledWith(7, 30),
);
expect(screen.getByText("12:34:56")).toBeInTheDocument();
await user.click(screen.getByText("serverOnlineHistory.days7"));
expect(screen.getByText("12:34:56")).toBeInTheDocument();
});
it("switches the query window via the day selector", async () => {
const user = userEvent.setup();
apiMocks.fetchServerOnlineHistory.mockResolvedValue(
onlineHistoryResponse(),
);
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
await screen.findByText("serverOnlineHistory.dailyOnline");
await user.click(screen.getByText("serverOnlineHistory.days30"));
await waitFor(() => {
expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledWith(7, 30);
});
});
it("locks day options beyond the configured max window", async () => {
const user = userEvent.setup();
apiMocks.fetchServerOnlineHistory.mockResolvedValue(
onlineHistoryResponse(7),
);
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
await screen.findByText("serverOnlineHistory.dailyOnline");
await user.click(screen.getByText("serverOnlineHistory.days30"));
expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledTimes(1);
expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledWith(7, 1);
});
it("shows the login hint when the API rejects anonymous guests", async () => {
apiMocks.fetchServerOnlineHistory.mockRejectedValue(
new Error("unauthorized: only 1d data available for guests"),
);
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
expect(
await screen.findByText("serverOnlineHistory.loginRequired"),
).toBeInTheDocument();
});
it("falls back to one day when guests request a longer window", async () => {
const user = userEvent.setup();
apiMocks.fetchServerOnlineHistory
.mockResolvedValueOnce(onlineHistoryResponse())
.mockRejectedValueOnce(
new Error("unauthorized: only 1d data available for guests"),
)
.mockResolvedValue(onlineHistoryResponse(1));
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
// 首屏直接请求 1d;手动选择 7d 被拒后自动回退到 1d。
await screen.findByText("serverOnlineHistory.dailyOnline");
expect(apiMocks.fetchServerOnlineHistory).toHaveBeenNthCalledWith(1, 7, 1);
await user.click(screen.getByText("serverOnlineHistory.days7"));
await waitFor(() => {
expect(apiMocks.fetchServerOnlineHistory).toHaveBeenNthCalledWith(
3,
7,
1,
);
});
expect(apiMocks.fetchServerOnlineHistory).toHaveBeenNthCalledWith(2, 7, 7);
// 更长窗口被锁定(游客上限 1 天),点击不再发起新请求
const calls = apiMocks.fetchServerOnlineHistory.mock.calls.length;
await user.click(screen.getByText("serverOnlineHistory.days30"));
expect(apiMocks.fetchServerOnlineHistory).toHaveBeenNthCalledWith(3, 7, 1);
expect(apiMocks.fetchServerOnlineHistory.mock.calls.length).toBe(calls);
});
it("groups periods by the panel timezone from the response", async () => {
const data = onlineHistoryResponse().data;
// 面板时区非 UTC 才能暴露 dayjs .tz(input, zone) 忽略显式偏移的问题
data.timezone = "Asia/Shanghai";
data.window_start = "2026-01-01T16:00:00.000Z";
data.window_end = "2026-01-02T16:00:00.000Z";
data.daily = [{ date: "2026-01-02", online_seconds: 7200 }];
data.periods = [
{
start: "2026-01-01T20:00:00.000Z",
end: "2026-01-01T22:00:00.000Z",
ongoing: false,
},
];
apiMocks.fetchServerOnlineHistory.mockResolvedValue({
success: true,
data,
});
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
await screen.findByText("serverOnlineHistory.dailyOnline");
// 20:00Z 在面板时区(UTC+8)为 01-02 04:00:分组落到 01-02,段内时刻同步换算
expect(screen.getByText("01-02")).toBeInTheDocument();
expect(screen.queryByText("01-01")).not.toBeInTheDocument();
const chip = screen.getByTestId("period-chip");
expect(chip.textContent).toContain("04:00");
expect(chip.textContent).toContain("06:00");
});
it("shows three days initially and expands ten days per click", async () => {
const user = userEvent.setup();
const data = onlineHistoryResponse().data;
const dates = Array.from({ length: 25 }, (_, i) =>
dayjs("2026-10-14").subtract(i, "day").format("YYYY-MM-DD"),
);
data.window_start = `${dates[dates.length - 1]}T00:00:00.000Z`;
data.daily = dates.map((date) => ({ date, online_seconds: 3600 }));
data.periods = dates.map((date) => ({
start: `${date}T06:00:00.000Z`,
end: `${date}T07:00:00.000Z`,
ongoing: false,
}));
apiMocks.fetchServerOnlineHistory.mockResolvedValue({
success: true,
data,
});
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
await screen.findByText("serverOnlineHistory.dailyOnline");
expect(screen.getAllByTestId("period-day")).toHaveLength(3);
await user.click(screen.getByText("serverOnlineHistory.expandMore"));
expect(screen.getAllByTestId("period-day")).toHaveLength(13);
await user.click(screen.getByText("serverOnlineHistory.expandMore"));
expect(screen.getAllByTestId("period-day")).toHaveLength(23);
await user.click(screen.getByText("serverOnlineHistory.expandMore"));
expect(screen.getAllByTestId("period-day")).toHaveLength(25);
expect(
screen.queryByText("serverOnlineHistory.expandMore"),
).not.toBeInTheDocument();
});
it("derives offline periods as the complement of online sessions", async () => {
const user = userEvent.setup();
const data = onlineHistoryResponse().data;
data.window_start = "2026-10-14T00:00:00.000Z";
data.window_end = "2026-10-14T12:00:00.000Z";
data.currently_online = false;
data.daily = [{ date: "2026-10-14", online_seconds: 7200 }];
data.periods = [
{
start: "2026-10-14T02:00:00.000Z",
end: "2026-10-14T04:00:00.000Z",
ongoing: false,
},
];
apiMocks.fetchServerOnlineHistory.mockResolvedValue({
success: true,
data,
});
renderWithQuery(<ServerDetailOnlineHistory server_id="7" />);
await screen.findByText("serverOnlineHistory.dailyOnline");
await user.click(screen.getByText("serverOnlineHistory.periodsOffline"));
// 在线 02:00-04:00 两侧的空档各成一段,且服务器当前离线 → 末段进行中
const chips = screen.getAllByTestId("period-chip");
expect(chips).toHaveLength(2);
expect(chips[0].textContent).toContain("00:00");
expect(chips[0].textContent).toContain("2h 0m");
expect(chips[1].textContent).toContain("04:00");
expect(chips[1].textContent).toContain("8h 0m");
expect(chips[1].textContent).toContain("serverOnlineHistory.ongoing");
// 组头合计 = 窗口时长 12h − 当日在线 2h = 10h
expect(screen.getByText("10h 0m")).toBeInTheDocument();
});
});
+7
View File
@@ -6,6 +6,7 @@ import {
fetchServerMetrics,
fetchService,
fetchSetting,
isUnauthorizedError,
} from "@/lib/nezha-api";
const jsonResponse = (body: unknown, init?: ResponseInit) =>
@@ -52,6 +53,12 @@ describe("nezha api fetchers", () => {
await expect(fetchService()).rejects.toThrow("service unavailable");
});
it("detects guest-auth errors from the dashboard API", () => {
expect(isUnauthorizedError(new Error("ApiErrorUnauthorized"))).toBe(true);
expect(isUnauthorizedError(new Error("settings failed"))).toBe(false);
expect(isUnauthorizedError(null)).toBe(false);
});
it("adds monitor and metrics query parameters when periods are provided", async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(jsonResponse({ success: true, data: [] }))
+270
View File
@@ -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);
});
});
+35 -1
View File
@@ -16,7 +16,15 @@ const apiMocks = vi.hoisted(() => ({
fetchService: vi.fn(),
}));
vi.mock("@/lib/nezha-api", () => apiMocks);
vi.mock("@/lib/nezha-api", async () => {
const actual =
await vi.importActual<typeof import("@/lib/nezha-api")>("@/lib/nezha-api");
return {
...actual,
fetchServerGroup: apiMocks.fetchServerGroup,
fetchService: apiMocks.fetchService,
};
});
vi.mock("@/components/GlobalMap", () => ({
default: ({ serverList }: { serverList: NezhaServer[] }) => (
@@ -237,6 +245,32 @@ describe("Servers page", () => {
expect(screen.getByText("group failed")).toBeInTheDocument();
});
it("prompts guests to log in when the API returns ApiErrorUnauthorized", async () => {
apiMocks.fetchServerGroup.mockRejectedValue(
new Error("ApiErrorUnauthorized"),
);
renderServerPage({
connected: false,
lastData: null,
});
expect(
await screen.findByText("error.loginRequiredTitle"),
).toBeInTheDocument();
expect(
screen.getByText("error.loginRequiredDescription"),
).toBeInTheDocument();
expect(screen.getByRole("link", { name: "login" })).toHaveAttribute(
"href",
"/dashboard",
);
expect(
screen.queryByText("error.backendUnavailableTitle"),
).not.toBeInTheDocument();
expect(screen.queryByText("ApiErrorUnauthorized")).not.toBeInTheDocument();
});
it("summarizes online and offline servers from websocket data", async () => {
const online = createServer({ id: 1, name: "alpha" });
const offline = createServer({
+39
View File
@@ -45,6 +45,12 @@ export interface NezhaServerStatus {
process_count: number;
temperatures: temperature[];
gpu: number[];
// Per-card figures; index-aligned with host.gpu. Absent on older agents.
gpus?: {
utilization: number;
memory_used?: number;
memory_total?: number;
}[];
}
interface temperature {
@@ -204,3 +210,36 @@ export interface ServerMetricsResponse {
success: boolean;
data: ServerMetricsData;
}
export interface OnlinePeriod {
start: string;
end: string;
ongoing: boolean;
}
export interface DailyOnlineStat {
date: string;
online_seconds: number;
}
export interface OnlineHistoryData {
server_id: number;
server_name: string;
days: number;
max_days: number;
/** 面板时区(Daily 拆日的日界),前端按同一时区渲染 */
timezone?: string;
window_start: string;
window_end: string;
total_online_seconds: number;
online_rate: number;
currently_online: boolean;
last_active?: string;
periods: OnlinePeriod[];
daily: DailyOnlineStat[];
}
export interface OnlineHistoryResponse {
success: boolean;
data: OnlineHistoryData;
}
+10
View File
@@ -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 {