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:
authored and GitHub committed 2026-10-22 12:01:21 +08:00
1 parent 2a250378fe
commit c59c18de17
26 files changed
+2604 -20

No files matched your search

+132 -6
View File
@@ -7,6 +7,13 @@ const serverCount = Number(process.env.MOCK_NEZHA_COUNT || 3000);
const updateIntervalMs = Number(
process.env.MOCK_NEZHA_INTERVAL_MS || (serverCount > 10_000 ? 0 : 2000),
);
const dayMs = 86_400_000;
const minuteMs = 60_000;
const timezoneOffsetMs = 8 * 60 * minuteMs;
const startedAt = Math.floor(Date.now() / 1000) * 1000;
// 前几个节点提供固定 UI 场景;其余节点保留每 17 个离线一个的规则。
const isServerOffline = (id) => id === 3 || id === 4 || id % 17 === 0;
const countries = [
"US",
@@ -40,14 +47,16 @@ function createServer(index) {
const memUsed = memTotal * ((index % 90) / 100);
const diskUsed = diskTotal * ((index % 84) / 100);
const swapUsed = swapTotal ? swapTotal * ((index % 60) / 100) : 0;
const isOffline = id % 17 === 0;
const isOffline = isServerOffline(id);
return {
id,
name: `mock-server-${String(id).padStart(4, "0")}`,
public_note: id % 19 === 0 ? "[[Plan]] bandwidth=1Gbps" : "",
last_active: isOffline
? new Date(Date.now() - 10 * 60 * 1000).toISOString()
? new Date(
startedAt - (id === 4 ? 31 * dayMs : 10 * minuteMs),
).toISOString()
: nowIso(),
country_code: countries[index % countries.length],
host: {
@@ -87,14 +96,16 @@ function createServer(index) {
const servers = Array.from({ length: serverCount }, (_, index) =>
createServer(index),
);
let onlineCount = servers.filter((server) => server.id % 17 !== 0).length;
let onlineCount = servers.filter(
(server) => !isServerOffline(server.id),
).length;
function updateServerMetrics() {
const now = Date.now();
let nextOnlineCount = 0;
for (const server of servers) {
const isOffline = server.id % 17 === 0;
const isOffline = isServerOffline(server.id);
if (!isOffline) {
server.last_active = new Date(now).toISOString();
nextOnlineCount += 1;
@@ -126,8 +137,8 @@ function websocketPayload() {
return JSON.stringify(payload);
}
function jsonResponse(response, data) {
response.writeHead(200, {
function jsonResponse(response, data, statusCode = 200) {
response.writeHead(statusCode, {
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-store",
"Content-Type": "application/json; charset=utf-8",
@@ -135,6 +146,92 @@ function jsonResponse(response, data) {
response.end(JSON.stringify(data));
}
function createOnlineHistory(server, days) {
const windowEnd = Math.floor(Date.now() / 1000) * 1000;
const windowStart = windowEnd - days * dayMs;
const firstDay =
Math.floor((windowStart + timezoneOffsetMs) / dayMs) * dayMs -
timezoneOffsetMs;
const currentlyOnline = !isServerOffline(server.id);
const onlineEnd = currentlyOnline
? windowEnd
: Date.parse(server.last_active);
const periods = [];
const daily = [];
const addPeriod = (start, end) => {
if (end <= start) return;
periods.push({
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
ongoing: currentlyOnline && end === windowEnd,
});
};
// 1/6: 日常短暂掉线;2: 持续在线;3: 当前离线;4: 无历史;5: 密集短时段。
// 先生成离线间隙,再取补集,跨午夜的在线会话保持连续。
if (server.id !== 4) {
let cursor = windowStart;
for (let day = firstDay; day < onlineEnd; day += dayMs) {
if (server.id === 2) continue;
const dayIndex = Math.floor((day + timezoneOffsetMs) / dayMs);
const hours =
server.id === 5
? [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22]
: [2, 8, 15];
for (const hour of hours) {
const gapStart = Math.max(windowStart, day + hour * 60 * minuteMs);
const gapMinutes =
server.id === 5 ? 35 : 10 + ((dayIndex + server.id + hour) % 6) * 13;
// 最后活跃前半小时保持在线,让时段终点与 WebSocket 状态一致。
const gapEnd = Math.min(
day + (hour * 60 + gapMinutes) * minuteMs,
onlineEnd - 30 * minuteMs,
);
if (gapEnd <= gapStart) continue;
addPeriod(cursor, gapStart);
cursor = gapEnd;
}
}
addPeriod(cursor, onlineEnd);
}
// 每日数字和概要都从同一组时段计算,避免时间轴与统计不一致。
for (let day = firstDay; day < windowEnd; day += dayMs) {
const onlineSeconds = periods.reduce((total, period) => {
const start = Math.max(day, Date.parse(period.start));
const end = Math.min(day + dayMs, Date.parse(period.end));
return total + Math.max(0, (end - start) / 1000);
}, 0);
daily.push({
date: new Date(day + timezoneOffsetMs).toISOString().slice(0, 10),
online_seconds: onlineSeconds,
});
}
const totalOnlineSeconds = daily.reduce(
(total, day) => total + day.online_seconds,
0,
);
return {
server_id: server.id,
server_name: server.name,
days,
max_days: 30,
timezone: "Asia/Shanghai",
window_start: new Date(windowStart).toISOString(),
window_end: new Date(windowEnd).toISOString(),
total_online_seconds: totalOnlineSeconds,
online_rate:
(totalOnlineSeconds / ((windowEnd - windowStart) / 1000)) * 100,
currently_online: currentlyOnline,
last_active:
server.id === 4 ? undefined : new Date(onlineEnd).toISOString(),
periods,
daily,
};
}
const populatedServerGroups = [
{
group: {
@@ -211,6 +308,35 @@ const httpServer = http.createServer((request, response) => {
return;
}
const onlineHistoryMatch = requestUrl.pathname.match(
/^\/api\/v1\/server\/(\d+)\/online-history$/,
);
if (onlineHistoryMatch) {
const server = servers[Number(onlineHistoryMatch[1]) - 1];
const days = Number(requestUrl.searchParams.get("days") ?? 7);
if (!server) {
jsonResponse(
response,
{ success: false, error: "server not found" },
404,
);
return;
}
if (!Number.isInteger(days) || days < 1 || days > 30) {
jsonResponse(
response,
{ success: false, error: "days must be an integer between 1 and 30" },
400,
);
return;
}
jsonResponse(response, {
success: true,
data: createOnlineHistory(server, days),
});
return;
}
switch (requestUrl.pathname) {
case "/api/v1/setting":
jsonResponse(response, {