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