mirror of
https://github.com/Buriburizaem0n/nezha-dash-v1.git
synced 2026-11-04 17:10:20 +00:00
feat: add server online history tab with date-grouped chip wall (#97)
* feat(server-detail): add online history tab Render per-server online periods from GET /api/v1/server/:id/online-history: summary card (current status, online rate, total in window, last active), daily online-time bars, and the raw period list. Window selector offers 1d/7d/30d and locks options beyond the server's max_days; guests hitting the 1d cap see a login hint. i18n keys added for all locales (zh-CN/zh-TW translated, others fall back to English for Weblate). * fix(online-history): keep selector pills above the sliding indicator The active-indicator overlay sits at z-10 and intercepted clicks on the day pills; wrap pills at z-20 like the existing PeriodSelector. * feat(online-history): render daily online as 24h timeline with hover tooltips 每日在线卡片从整日占比进度条改为 0:00–24:00 整轴时间条: 每个在线时段按当日时刻定位为高亮分段,hover 显示起止时间与时长; 进行中时段以亮色高亮。卡片顶部新增共享刻度(0:00 / 12:00 / 24:00)。 * feat(online-history): regroup periods by day as expandable chip wall Rework the Online tab per the chip-wall design: periods are grouped by panel-timezone day with per-day totals, defaulting to the latest day with "Expand more" revealing up to ten more groups per click, plus an Online/Offline switch that derives offline chips as the window complement. - respond to the backend panel timezone (timezone/tz_offset): dayjs 1.11 .tz(input, zone) ignores explicit offsets, so offset-bearing timestamps are parsed first and converted afterwards; naive date strings keep the zone-parse path (panelDay) - guests falling back automatically: a rejected days>1 request locks 7d/30d and retries at 1d instead of surfacing only a login prompt - fix day-selector indicator landing on the wrong pill: useActiveIndicator refs are stored by index, pass the map index not the day value - disable Node experimental webstorage in vitest workers: Node >=25 ships a broken global localStorage that shadows the jsdom one - 14 locales: tabSwitch.Online + serverOnlineHistory.* * feat(online-history): boxed summary stats with desktop/mobile layouts * fix(online-history): align offline gaps, DST coordinates and ongoing chips - extract pure timeline/grouping helpers into src/lib/online-history.ts - clamp offline complements to day and window boundaries, so a gap can no longer run into a later day's first online session - use wall-clock coordinates and true next-midnight boundaries on DST days - label only the segment ending at the live session end as Ongoing - keep group totals equal to the visible chips for cross-day sessions - keep period chips on one line and render next-midnight as 24:00 - add regression tests for multi-day offline gaps, window_start/window_end, spring-forward/fall-back geometry and ongoing clipping * style(online-history): lighten summary metrics and shorten rate bar * feat(online-history): refine UI and unify daily history rendering --------- Co-authored-by: Sam5440 <Sam5440@users.noreply.github.com> Co-authored-by: hamster1963 <1410514192@qq.com>
This commit is contained in:
26 files changed
+2604
-20
No files matched your search
@@ -7,6 +7,13 @@ const serverCount = Number(process.env.MOCK_NEZHA_COUNT || 3000);
|
||||
const updateIntervalMs = Number(
|
||||
process.env.MOCK_NEZHA_INTERVAL_MS || (serverCount > 10_000 ? 0 : 2000),
|
||||
);
|
||||
const dayMs = 86_400_000;
|
||||
const minuteMs = 60_000;
|
||||
const timezoneOffsetMs = 8 * 60 * minuteMs;
|
||||
const startedAt = Math.floor(Date.now() / 1000) * 1000;
|
||||
|
||||
// 前几个节点提供固定 UI 场景;其余节点保留每 17 个离线一个的规则。
|
||||
const isServerOffline = (id) => id === 3 || id === 4 || id % 17 === 0;
|
||||
|
||||
const countries = [
|
||||
"US",
|
||||
@@ -40,14 +47,16 @@ function createServer(index) {
|
||||
const memUsed = memTotal * ((index % 90) / 100);
|
||||
const diskUsed = diskTotal * ((index % 84) / 100);
|
||||
const swapUsed = swapTotal ? swapTotal * ((index % 60) / 100) : 0;
|
||||
const isOffline = id % 17 === 0;
|
||||
const isOffline = isServerOffline(id);
|
||||
|
||||
return {
|
||||
id,
|
||||
name: `mock-server-${String(id).padStart(4, "0")}`,
|
||||
public_note: id % 19 === 0 ? "[[Plan]] bandwidth=1Gbps" : "",
|
||||
last_active: isOffline
|
||||
? new Date(Date.now() - 10 * 60 * 1000).toISOString()
|
||||
? new Date(
|
||||
startedAt - (id === 4 ? 31 * dayMs : 10 * minuteMs),
|
||||
).toISOString()
|
||||
: nowIso(),
|
||||
country_code: countries[index % countries.length],
|
||||
host: {
|
||||
@@ -87,14 +96,16 @@ function createServer(index) {
|
||||
const servers = Array.from({ length: serverCount }, (_, index) =>
|
||||
createServer(index),
|
||||
);
|
||||
let onlineCount = servers.filter((server) => server.id % 17 !== 0).length;
|
||||
let onlineCount = servers.filter(
|
||||
(server) => !isServerOffline(server.id),
|
||||
).length;
|
||||
|
||||
function updateServerMetrics() {
|
||||
const now = Date.now();
|
||||
let nextOnlineCount = 0;
|
||||
|
||||
for (const server of servers) {
|
||||
const isOffline = server.id % 17 === 0;
|
||||
const isOffline = isServerOffline(server.id);
|
||||
if (!isOffline) {
|
||||
server.last_active = new Date(now).toISOString();
|
||||
nextOnlineCount += 1;
|
||||
@@ -126,8 +137,8 @@ function websocketPayload() {
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
function jsonResponse(response, data) {
|
||||
response.writeHead(200, {
|
||||
function jsonResponse(response, data, statusCode = 200) {
|
||||
response.writeHead(statusCode, {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
@@ -135,6 +146,92 @@ function jsonResponse(response, data) {
|
||||
response.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
function createOnlineHistory(server, days) {
|
||||
const windowEnd = Math.floor(Date.now() / 1000) * 1000;
|
||||
const windowStart = windowEnd - days * dayMs;
|
||||
const firstDay =
|
||||
Math.floor((windowStart + timezoneOffsetMs) / dayMs) * dayMs -
|
||||
timezoneOffsetMs;
|
||||
const currentlyOnline = !isServerOffline(server.id);
|
||||
const onlineEnd = currentlyOnline
|
||||
? windowEnd
|
||||
: Date.parse(server.last_active);
|
||||
const periods = [];
|
||||
const daily = [];
|
||||
|
||||
const addPeriod = (start, end) => {
|
||||
if (end <= start) return;
|
||||
periods.push({
|
||||
start: new Date(start).toISOString(),
|
||||
end: new Date(end).toISOString(),
|
||||
ongoing: currentlyOnline && end === windowEnd,
|
||||
});
|
||||
};
|
||||
|
||||
// 1/6: 日常短暂掉线;2: 持续在线;3: 当前离线;4: 无历史;5: 密集短时段。
|
||||
// 先生成离线间隙,再取补集,跨午夜的在线会话保持连续。
|
||||
if (server.id !== 4) {
|
||||
let cursor = windowStart;
|
||||
for (let day = firstDay; day < onlineEnd; day += dayMs) {
|
||||
if (server.id === 2) continue;
|
||||
const dayIndex = Math.floor((day + timezoneOffsetMs) / dayMs);
|
||||
const hours =
|
||||
server.id === 5
|
||||
? [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22]
|
||||
: [2, 8, 15];
|
||||
for (const hour of hours) {
|
||||
const gapStart = Math.max(windowStart, day + hour * 60 * minuteMs);
|
||||
const gapMinutes =
|
||||
server.id === 5 ? 35 : 10 + ((dayIndex + server.id + hour) % 6) * 13;
|
||||
// 最后活跃前半小时保持在线,让时段终点与 WebSocket 状态一致。
|
||||
const gapEnd = Math.min(
|
||||
day + (hour * 60 + gapMinutes) * minuteMs,
|
||||
onlineEnd - 30 * minuteMs,
|
||||
);
|
||||
if (gapEnd <= gapStart) continue;
|
||||
addPeriod(cursor, gapStart);
|
||||
cursor = gapEnd;
|
||||
}
|
||||
}
|
||||
addPeriod(cursor, onlineEnd);
|
||||
}
|
||||
|
||||
// 每日数字和概要都从同一组时段计算,避免时间轴与统计不一致。
|
||||
for (let day = firstDay; day < windowEnd; day += dayMs) {
|
||||
const onlineSeconds = periods.reduce((total, period) => {
|
||||
const start = Math.max(day, Date.parse(period.start));
|
||||
const end = Math.min(day + dayMs, Date.parse(period.end));
|
||||
return total + Math.max(0, (end - start) / 1000);
|
||||
}, 0);
|
||||
daily.push({
|
||||
date: new Date(day + timezoneOffsetMs).toISOString().slice(0, 10),
|
||||
online_seconds: onlineSeconds,
|
||||
});
|
||||
}
|
||||
const totalOnlineSeconds = daily.reduce(
|
||||
(total, day) => total + day.online_seconds,
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
server_id: server.id,
|
||||
server_name: server.name,
|
||||
days,
|
||||
max_days: 30,
|
||||
timezone: "Asia/Shanghai",
|
||||
window_start: new Date(windowStart).toISOString(),
|
||||
window_end: new Date(windowEnd).toISOString(),
|
||||
total_online_seconds: totalOnlineSeconds,
|
||||
online_rate:
|
||||
(totalOnlineSeconds / ((windowEnd - windowStart) / 1000)) * 100,
|
||||
currently_online: currentlyOnline,
|
||||
last_active:
|
||||
server.id === 4 ? undefined : new Date(onlineEnd).toISOString(),
|
||||
periods,
|
||||
daily,
|
||||
};
|
||||
}
|
||||
|
||||
const populatedServerGroups = [
|
||||
{
|
||||
group: {
|
||||
@@ -211,6 +308,35 @@ const httpServer = http.createServer((request, response) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const onlineHistoryMatch = requestUrl.pathname.match(
|
||||
/^\/api\/v1\/server\/(\d+)\/online-history$/,
|
||||
);
|
||||
if (onlineHistoryMatch) {
|
||||
const server = servers[Number(onlineHistoryMatch[1]) - 1];
|
||||
const days = Number(requestUrl.searchParams.get("days") ?? 7);
|
||||
if (!server) {
|
||||
jsonResponse(
|
||||
response,
|
||||
{ success: false, error: "server not found" },
|
||||
404,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(days) || days < 1 || days > 30) {
|
||||
jsonResponse(
|
||||
response,
|
||||
{ success: false, error: "days must be an integer between 1 and 30" },
|
||||
400,
|
||||
);
|
||||
return;
|
||||
}
|
||||
jsonResponse(response, {
|
||||
success: true,
|
||||
data: createOnlineHistory(server, days),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
switch (requestUrl.pathname) {
|
||||
case "/api/v1/setting":
|
||||
jsonResponse(response, {
|
||||
|
||||
Reference in new issue
Block a user