Files
nezha-dash-v1/src/components/loading/OnlineHistorySkeleton.tsx
T
c59c18de17 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>
2026-10-22 12:01:21 +08:00

139 lines
4.4 KiB
TypeScript

import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
function Bar({ className }: { className: string }) {
return (
<Skeleton
className={cn(
"rounded-[5px] bg-stone-200/70 motion-reduce:animate-none dark:bg-stone-700/60",
className,
)}
/>
);
}
function Metric({ footer = true }: { footer?: boolean }) {
return (
<div className="flex min-w-0 flex-col items-center justify-center gap-2">
<div className="flex h-4 items-center">
<Bar className="h-3 w-14" />
</div>
<Bar className="h-7 w-24 max-w-full" />
{footer && (
<div className="flex h-4 max-w-full items-center">
<Bar className="h-3 w-20 max-w-full" />
</div>
)}
</div>
);
}
export default function OnlineHistorySkeleton({ days = 7 }: { days?: number }) {
const { t } = useTranslation();
const dayCount = Math.min(31, Math.max(1, days) + 1);
const cardClassName = cn({
"bg-card/70":
typeof window !== "undefined" && Boolean(window.CustomBackgroundImage),
});
return (
<div
role="status"
aria-label={t("serverOnlineHistory.loading")}
aria-busy="true"
data-testid="online-history-loading"
>
<div aria-hidden="true" className="flex flex-col gap-4">
<div className="flex min-h-9 items-center justify-between gap-3">
<div className="flex items-center gap-2 md:px-1">
<Bar className="size-2 rounded-full" />
<Bar className="h-3 w-8" />
</div>
<Bar className="h-9 w-36 rounded-full" />
</div>
<div className="px-0 py-2 md:px-6 md:py-4">
<div className="hidden grid-cols-4 divide-x divide-stone-100 dark:divide-stone-800 md:grid">
{Array.from({ length: 4 }, (_, index) => (
<Metric key={index} />
))}
</div>
<div className="flex flex-col gap-3 md:hidden">
<div className="grid grid-cols-2 gap-4">
<Metric />
<Metric />
</div>
<div className="grid grid-cols-2 gap-4 border-t border-stone-100 pt-3 dark:border-stone-800">
<Metric footer={false} />
<Metric footer={false} />
</div>
</div>
</div>
<Card className={cardClassName}>
<CardContent className="px-4 py-3 sm:px-5">
<div className="flex min-h-8 flex-wrap items-center justify-between gap-2">
<Bar className="h-4 w-28" />
<Bar className="h-3 w-32" />
</div>
<div className="mt-3 grid grid-cols-1 gap-2 md:grid-cols-2 md:[&>*:last-child:nth-child(odd)]:col-span-2">
{Array.from({ length: dayCount }, (_, index) => (
<div
key={index}
className="flex min-w-0 flex-col gap-2.5 rounded-lg bg-muted/40 px-2.5 py-2 dark:bg-muted/20"
>
<div className="flex h-4 items-center justify-between gap-2">
<Bar className="h-3 w-20" />
<Bar className="h-3 w-14" />
</div>
<Bar className="h-2 w-full rounded-full" />
<div className="flex h-[9px] items-center justify-between">
{Array.from({ length: 5 }, (_, tick) => (
<Bar key={tick} className="h-1 w-2" />
))}
</div>
</div>
))}
</div>
<div className="mt-3 flex h-4 items-center gap-4">
<Bar className="h-2 w-16" />
<Bar className="h-2 w-16" />
<Bar className="h-2 w-20" />
</div>
</CardContent>
</Card>
<Card className={cardClassName}>
<CardContent className="px-4 py-3 sm:px-5">
<div className="flex min-h-8 items-center justify-between gap-2">
<Bar className="h-4 w-24" />
<Bar className="-mt-1 -mr-2 h-[30px] w-24 self-start rounded-[8px] sm:-mr-3" />
</div>
<div className="mt-3 flex flex-col gap-3">
{Array.from({ length: Math.min(3, dayCount) }, (_, index) => (
<div key={index}>
<div className="flex h-6 items-start justify-between gap-3">
<Bar className="h-3 w-16" />
<Bar className="h-3 w-20" />
</div>
<div className="flex flex-wrap gap-1.5">
<Bar className="h-7 w-36" />
<Bar className="h-7 w-40" />
</div>
</div>
))}
{dayCount > 3 && (
<div className="flex h-8 items-center justify-center rounded-md border border-dashed border-border">
<Bar className="h-3 w-16" />
</div>
)}
</div>
</CardContent>
</Card>
</div>
</div>
);
}