P8(1): restore homepage overview curves on a memory-only sampling ring
Backend: new overviewMetrics store — a single-tier (2s x 360 = 12min) in-memory ring fed by ServerService.RefreshStatus for exactly the eight overview series (cpu/mem/swap/diskUsage/netUp/netDown/tcpCount/udpCount). No persistence, no gob snapshot, no system-history page: the only surface is GET /panel/api/server/history/:metric/:bucket restricted to those keys, which seeds the frontend curves. ~46KB bounded by construction; the ring starts empty on every panel start. Frontend: restore useOverviewHistory + the VitalTile/ThroughputCard/ ConnectionsCard sparklines and the IndexPage wiring exactly as they were before P7 removed them along with the history module.
This commit is contained in:
@@ -1,18 +1,38 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card, theme } from 'antd';
|
import { Card, theme } from 'antd';
|
||||||
|
|
||||||
|
import { Sparkline } from '@/components/viz';
|
||||||
import type { Status } from '@/models/status';
|
import type { Status } from '@/models/status';
|
||||||
|
|
||||||
interface ConnectionsCardProps {
|
interface ConnectionsCardProps {
|
||||||
status: Status;
|
status: Status;
|
||||||
|
tcp: number[];
|
||||||
|
udp: number[];
|
||||||
|
labels: string[];
|
||||||
|
isMobile: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ConnectionsCard({ status }: ConnectionsCardProps) {
|
export default function ConnectionsCard({
|
||||||
|
status,
|
||||||
|
tcp,
|
||||||
|
udp,
|
||||||
|
labels,
|
||||||
|
isMobile,
|
||||||
|
}: ConnectionsCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
const accent = token.colorPrimary;
|
const accent = token.colorPrimary;
|
||||||
const udpColor = token.colorTextTertiary;
|
const udpColor = token.colorTextTertiary;
|
||||||
|
|
||||||
|
const referenceLines = useMemo(
|
||||||
|
() => [
|
||||||
|
{ y: status.udpCount, color: udpColor, dash: '2 4' },
|
||||||
|
{ y: status.tcpCount, color: accent, dash: '2 4' },
|
||||||
|
],
|
||||||
|
[status.tcpCount, status.udpCount, accent, udpColor],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card hoverable styles={{ body: { padding: 0 } }}>
|
<Card hoverable styles={{ body: { padding: 0 } }}>
|
||||||
<div className="ov-wide-head ov-wide-head-stack">
|
<div className="ov-wide-head ov-wide-head-stack">
|
||||||
@@ -35,6 +55,26 @@ export default function ConnectionsCard({ status }: ConnectionsCardProps) {
|
|||||||
<span className="ov-legend-num">{status.udpCount.toLocaleString()}</span>
|
<span className="ov-legend-num">{status.udpCount.toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="ov-wide-chart">
|
||||||
|
<Sparkline
|
||||||
|
data={tcp}
|
||||||
|
data2={udp}
|
||||||
|
labels={labels}
|
||||||
|
height={isMobile ? 120 : 170}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
fillOpacity={0.24}
|
||||||
|
showTooltip
|
||||||
|
showLegend={false}
|
||||||
|
valueMax={null}
|
||||||
|
stroke={accent}
|
||||||
|
stroke2={udpColor}
|
||||||
|
name1="TCP"
|
||||||
|
name2="UDP"
|
||||||
|
yFormatter={(v) => Math.round(v).toLocaleString()}
|
||||||
|
referenceLines={referenceLines}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import VitalTile from './VitalTile';
|
|||||||
import ThroughputCard from './ThroughputCard';
|
import ThroughputCard from './ThroughputCard';
|
||||||
import ConnectionsCard from './ConnectionsCard';
|
import ConnectionsCard from './ConnectionsCard';
|
||||||
import SystemStrip from './SystemStrip';
|
import SystemStrip from './SystemStrip';
|
||||||
|
import { mean, peak, useOverviewHistory } from './useOverviewHistory';
|
||||||
import type { PanelUpdateInfo } from './PanelUpdateModal';
|
import type { PanelUpdateInfo } from './PanelUpdateModal';
|
||||||
const JsonEditor = lazy(() => import('@/components/form/JsonEditor'));
|
const JsonEditor = lazy(() => import('@/components/form/JsonEditor'));
|
||||||
const PanelUpdateModal = lazy(() => import('./PanelUpdateModal'));
|
const PanelUpdateModal = lazy(() => import('./PanelUpdateModal'));
|
||||||
@@ -44,6 +45,8 @@ export default function IndexPage() {
|
|||||||
setMessageInstance(messageApi);
|
setMessageInstance(messageApi);
|
||||||
}, [messageApi]);
|
}, [messageApi]);
|
||||||
|
|
||||||
|
const history = useOverviewHistory(status, fetched && !fetchError);
|
||||||
|
|
||||||
const [devChannelEnable, setDevChannelEnable] = useState(false);
|
const [devChannelEnable, setDevChannelEnable] = useState(false);
|
||||||
const [panelUpdateInfo, setPanelUpdateInfo] = useState<PanelUpdateInfo>({
|
const [panelUpdateInfo, setPanelUpdateInfo] = useState<PanelUpdateInfo>({
|
||||||
currentVersion: '',
|
currentVersion: '',
|
||||||
@@ -192,6 +195,9 @@ export default function IndexPage() {
|
|||||||
percent={status.cpu.percent}
|
percent={status.cpu.percent}
|
||||||
statusColor={status.cpu.color}
|
statusColor={status.cpu.color}
|
||||||
detail={`${CPUFormatter.cpuCoreFormat(status.cpuCores)} / ${status.logicalPro}T · ${CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz)}`}
|
detail={`${CPUFormatter.cpuCoreFormat(status.cpuCores)} / ${status.logicalPro}T · ${CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz)}`}
|
||||||
|
footLeft={`${t('pages.index.avg')} ${mean(history.series.cpu).toFixed(0)}%`}
|
||||||
|
footRight={`${t('pages.index.peak')} ${peak(history.series.cpu).toFixed(0)}%`}
|
||||||
|
data={history.series.cpu}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
<VitalTile
|
<VitalTile
|
||||||
@@ -200,6 +206,9 @@ export default function IndexPage() {
|
|||||||
percent={status.mem.percent}
|
percent={status.mem.percent}
|
||||||
statusColor={status.mem.color}
|
statusColor={status.mem.color}
|
||||||
detail={`${SizeFormatter.sizeFormat(status.mem.current)} / ${SizeFormatter.sizeFormat(status.mem.total)}`}
|
detail={`${SizeFormatter.sizeFormat(status.mem.current)} / ${SizeFormatter.sizeFormat(status.mem.total)}`}
|
||||||
|
footLeft={`${t('pages.index.avg')} ${mean(history.series.mem).toFixed(0)}%`}
|
||||||
|
footRight={`${t('pages.index.peak')} ${peak(history.series.mem).toFixed(0)}%`}
|
||||||
|
data={history.series.mem}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
<VitalTile
|
<VitalTile
|
||||||
@@ -208,6 +217,9 @@ export default function IndexPage() {
|
|||||||
percent={status.swap.percent}
|
percent={status.swap.percent}
|
||||||
statusColor={status.swap.color}
|
statusColor={status.swap.color}
|
||||||
detail={`${SizeFormatter.sizeFormat(status.swap.current)} / ${SizeFormatter.sizeFormat(status.swap.total)}`}
|
detail={`${SizeFormatter.sizeFormat(status.swap.current)} / ${SizeFormatter.sizeFormat(status.swap.total)}`}
|
||||||
|
footLeft={`${t('pages.index.avg')} ${mean(history.series.swap).toFixed(1)}%`}
|
||||||
|
footRight={`${t('pages.index.peak')} ${peak(history.series.swap).toFixed(0)}%`}
|
||||||
|
data={history.series.swap}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
<VitalTile
|
<VitalTile
|
||||||
@@ -217,13 +229,27 @@ export default function IndexPage() {
|
|||||||
statusColor={status.disk.color}
|
statusColor={status.disk.color}
|
||||||
detail={`${SizeFormatter.sizeFormat(status.disk.current)} / ${SizeFormatter.sizeFormat(totalDisk)}`}
|
detail={`${SizeFormatter.sizeFormat(status.disk.current)} / ${SizeFormatter.sizeFormat(totalDisk)}`}
|
||||||
footLeft={`${t('pages.index.free')} ${SizeFormatter.sizeFormat(freeDisk)}`}
|
footLeft={`${t('pages.index.free')} ${SizeFormatter.sizeFormat(freeDisk)}`}
|
||||||
|
footRight={`${t('pages.index.avg')} ${mean(history.series.diskUsage).toFixed(1)}%`}
|
||||||
|
data={history.series.diskUsage}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ov-mid">
|
<div className="ov-mid">
|
||||||
<ThroughputCard status={status} />
|
<ThroughputCard
|
||||||
<ConnectionsCard status={status} />
|
status={status}
|
||||||
|
up={history.series.netUp}
|
||||||
|
down={history.series.netDown}
|
||||||
|
labels={history.labels}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
|
<ConnectionsCard
|
||||||
|
status={status}
|
||||||
|
tcp={history.series.tcpCount}
|
||||||
|
udp={history.series.udpCount}
|
||||||
|
labels={history.labels}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SystemStrip
|
<SystemStrip
|
||||||
|
|||||||
@@ -1,26 +1,49 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card, theme } from 'antd';
|
import { Card, theme } from 'antd';
|
||||||
import { ArrowDownOutlined, ArrowUpOutlined } from '@ant-design/icons';
|
import { ArrowDownOutlined, ArrowUpOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { SizeFormatter } from '@/utils';
|
import { SizeFormatter } from '@/utils';
|
||||||
|
import { Sparkline } from '@/components/viz';
|
||||||
import type { Status } from '@/models/status';
|
import type { Status } from '@/models/status';
|
||||||
|
import { mean, peak } from './useOverviewHistory';
|
||||||
|
|
||||||
interface ThroughputCardProps {
|
interface ThroughputCardProps {
|
||||||
status: Status;
|
status: Status;
|
||||||
|
up: number[];
|
||||||
|
down: number[];
|
||||||
|
labels: string[];
|
||||||
|
isMobile: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ThroughputCard({ status }: ThroughputCardProps) {
|
export default function ThroughputCard({
|
||||||
|
status,
|
||||||
|
up,
|
||||||
|
down,
|
||||||
|
labels,
|
||||||
|
isMobile,
|
||||||
|
}: ThroughputCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
const accent = token.colorPrimary;
|
const accent = token.colorPrimary;
|
||||||
const downColor = token.colorTextTertiary;
|
const downColor = token.colorTextTertiary;
|
||||||
|
|
||||||
|
const referenceLines = useMemo(
|
||||||
|
() => [
|
||||||
|
{ y: status.netIO.down, color: downColor, dash: '2 4' },
|
||||||
|
{ y: status.netIO.up, color: accent, dash: '2 4' },
|
||||||
|
],
|
||||||
|
[status.netIO.up, status.netIO.down, accent, downColor],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card hoverable styles={{ body: { padding: 0 } }}>
|
<Card hoverable styles={{ body: { padding: 0 } }}>
|
||||||
<div className="ov-wide-head">
|
<div className="ov-wide-head">
|
||||||
<div>
|
<div>
|
||||||
<div className="ov-kicker">{t('pages.index.overallSpeed')}</div>
|
<div className="ov-kicker">{t('pages.index.overallSpeed')}</div>
|
||||||
<div className="ov-sub">{t('pages.index.throughputSub')}</div>
|
<div className="ov-sub">
|
||||||
|
{`${t('pages.index.throughputSub')} · ${t('pages.index.peak')} ${SizeFormatter.speedFormat(peak(down))}`}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ov-wide-legend">
|
<div className="ov-wide-legend">
|
||||||
<div className="ov-legend-label">
|
<div className="ov-legend-label">
|
||||||
@@ -36,6 +59,26 @@ export default function ThroughputCard({ status }: ThroughputCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="ov-wide-chart">
|
||||||
|
<Sparkline
|
||||||
|
data={up}
|
||||||
|
data2={down}
|
||||||
|
labels={labels}
|
||||||
|
height={isMobile ? 140 : 186}
|
||||||
|
strokeWidth={1.75}
|
||||||
|
fillOpacity={0.24}
|
||||||
|
showTooltip
|
||||||
|
showLegend={false}
|
||||||
|
valueMax={null}
|
||||||
|
stroke={accent}
|
||||||
|
stroke2={downColor}
|
||||||
|
name1={t('pages.index.upload')}
|
||||||
|
name2={t('pages.index.download')}
|
||||||
|
yFormatter={SizeFormatter.speedFormat}
|
||||||
|
referenceLines={referenceLines}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="ov-wide-foot">
|
<div className="ov-wide-foot">
|
||||||
<div>
|
<div>
|
||||||
<div className="ov-kicker">{t('pages.index.sent')}</div>
|
<div className="ov-kicker">{t('pages.index.sent')}</div>
|
||||||
@@ -46,6 +89,14 @@ export default function ThroughputCard({ status }: ThroughputCardProps) {
|
|||||||
<div className="ov-kicker">{t('pages.index.received')}</div>
|
<div className="ov-kicker">{t('pages.index.received')}</div>
|
||||||
<div className="ov-foot-value">{SizeFormatter.sizeFormat(status.netTraffic.recv)}</div>
|
<div className="ov-foot-value">{SizeFormatter.sizeFormat(status.netTraffic.recv)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<span className="ov-foot-sep" />
|
||||||
|
<div>
|
||||||
|
<div className="ov-kicker">{t('pages.index.avgWindow')}</div>
|
||||||
|
<div className="ov-foot-value">
|
||||||
|
<span className="ov-foot-part">{`↑ ${SizeFormatter.speedFormat(mean(up))}`}</span>{' '}
|
||||||
|
<span className="ov-foot-part">{`↓ ${SizeFormatter.speedFormat(mean(down))}`}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Card } from 'antd';
|
import { Card, theme } from 'antd';
|
||||||
|
|
||||||
|
import { Sparkline } from '@/components/viz';
|
||||||
|
import { mean, peak } from './useOverviewHistory';
|
||||||
|
|
||||||
interface VitalTileProps {
|
interface VitalTileProps {
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
@@ -7,11 +11,31 @@ interface VitalTileProps {
|
|||||||
percent: number;
|
percent: number;
|
||||||
statusColor: string;
|
statusColor: string;
|
||||||
detail: string;
|
detail: string;
|
||||||
footLeft?: string;
|
footLeft: string;
|
||||||
|
footRight: string;
|
||||||
|
data: number[];
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VitalTile({ icon, label, percent, detail, footLeft }: VitalTileProps) {
|
export default function VitalTile({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
percent,
|
||||||
|
statusColor,
|
||||||
|
detail,
|
||||||
|
footLeft,
|
||||||
|
footRight,
|
||||||
|
data,
|
||||||
|
isMobile,
|
||||||
|
}: VitalTileProps) {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const meanColor = token.colorTextTertiary;
|
||||||
|
|
||||||
|
const referenceLines = useMemo(
|
||||||
|
() => (data.length > 1 ? [{ y: mean(data), dash: '3 4', color: meanColor }] : []),
|
||||||
|
[data, meanColor],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card hoverable className="ov-tile" styles={{ body: { padding: 0 } }}>
|
<Card hoverable className="ov-tile" styles={{ body: { padding: 0 } }}>
|
||||||
<div className="ov-tile-head">
|
<div className="ov-tile-head">
|
||||||
@@ -26,11 +50,26 @@ export default function VitalTile({ icon, label, percent, detail, footLeft }: Vi
|
|||||||
|
|
||||||
<div className="ov-tile-detail">{detail}</div>
|
<div className="ov-tile-detail">{detail}</div>
|
||||||
|
|
||||||
{footLeft ? (
|
<div className="ov-tile-foot">
|
||||||
<div className="ov-tile-foot">
|
<span>{footLeft}</span>
|
||||||
<span>{footLeft}</span>
|
<span>{footRight}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
|
<div className="ov-tile-chart">
|
||||||
|
<Sparkline
|
||||||
|
data={data}
|
||||||
|
height={isMobile ? 48 : 62}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
fillOpacity={0.3}
|
||||||
|
showGrid={false}
|
||||||
|
showMarker={false}
|
||||||
|
valueMax={peak(data) > 0 ? null : 100}
|
||||||
|
stroke={statusColor}
|
||||||
|
referenceLines={referenceLines}
|
||||||
|
yFormatter={(v) => `${v.toFixed(0)}%`}
|
||||||
|
name1={label}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { HttpUtil, TimeFormatter } from '@/utils';
|
||||||
|
import type { Status } from '@/models/status';
|
||||||
|
|
||||||
|
const OVERVIEW_WINDOW = 72;
|
||||||
|
const SEED_BUCKET_SECONDS = 2;
|
||||||
|
|
||||||
|
const SERIES_KEYS = [
|
||||||
|
'cpu',
|
||||||
|
'mem',
|
||||||
|
'swap',
|
||||||
|
'diskUsage',
|
||||||
|
'netUp',
|
||||||
|
'netDown',
|
||||||
|
'tcpCount',
|
||||||
|
'udpCount',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type OverviewSeriesKey = (typeof SERIES_KEYS)[number];
|
||||||
|
|
||||||
|
export interface OverviewHistory {
|
||||||
|
series: Record<OverviewSeriesKey, number[]>;
|
||||||
|
labels: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HistoryPoint {
|
||||||
|
t: number;
|
||||||
|
v: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HistoryWindow {
|
||||||
|
series: Record<OverviewSeriesKey, number[]>;
|
||||||
|
times: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptySeries(): Record<OverviewSeriesKey, number[]> {
|
||||||
|
return Object.fromEntries(SERIES_KEYS.map((key) => [key, [] as number[]])) as Record<
|
||||||
|
OverviewSeriesKey,
|
||||||
|
number[]
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyWindow(): HistoryWindow {
|
||||||
|
return { series: emptySeries(), times: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleOf(status: Status): Record<OverviewSeriesKey, number> {
|
||||||
|
return {
|
||||||
|
cpu: status.cpu.percent,
|
||||||
|
mem: status.mem.percent,
|
||||||
|
swap: status.swap.percent,
|
||||||
|
diskUsage: status.disk.percent,
|
||||||
|
netUp: status.netIO.up,
|
||||||
|
netDown: status.netIO.down,
|
||||||
|
tcpCount: status.tcpCount,
|
||||||
|
udpCount: status.udpCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tailWindow<T>(values: T[]): T[] {
|
||||||
|
return values.slice(-OVERVIEW_WINDOW);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mean(values: number[]): number {
|
||||||
|
if (values.length === 0) return 0;
|
||||||
|
let total = 0;
|
||||||
|
for (const v of values) total += v;
|
||||||
|
return total / values.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function peak(values: number[]): number {
|
||||||
|
let max = 0;
|
||||||
|
for (const v of values) if (v > max) max = v;
|
||||||
|
return max;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* the seed bucket must be in the backend's allowedHistoryBuckets whitelist;
|
||||||
|
2s is the smallest and matches the status poll cadence */
|
||||||
|
export function useOverviewHistory(status: Status, hasData: boolean): OverviewHistory {
|
||||||
|
const [trend, setTrend] = useState<HistoryWindow>(emptyWindow);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const seed = async () => {
|
||||||
|
const responses = new Map<OverviewSeriesKey, HistoryPoint[]>();
|
||||||
|
await Promise.all(
|
||||||
|
SERIES_KEYS.map(async (key) => {
|
||||||
|
const msg = await HttpUtil.get<HistoryPoint[]>(
|
||||||
|
`/panel/api/server/history/${key}/${SEED_BUCKET_SECONDS}`,
|
||||||
|
undefined,
|
||||||
|
{ silent: true },
|
||||||
|
);
|
||||||
|
if (msg?.success && Array.isArray(msg.obj)) responses.set(key, msg.obj);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (cancelled || responses.size === 0) return;
|
||||||
|
|
||||||
|
let axis: HistoryPoint[] = [];
|
||||||
|
for (const points of responses.values()) {
|
||||||
|
if (points.length > axis.length) axis = points;
|
||||||
|
}
|
||||||
|
axis = tailWindow(axis);
|
||||||
|
if (axis.length === 0) return;
|
||||||
|
|
||||||
|
const seedTimes = axis.map((p) => Number(p.t) || 0);
|
||||||
|
const seedSeries = emptySeries();
|
||||||
|
for (const key of SERIES_KEYS) {
|
||||||
|
const byTs = new Map<number, number>();
|
||||||
|
for (const p of responses.get(key) ?? []) byTs.set(Number(p.t) || 0, Number(p.v) || 0);
|
||||||
|
seedSeries[key] = seedTimes.map((ts) => byTs.get(ts) ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTrend((prev) => {
|
||||||
|
const merged = emptyWindow();
|
||||||
|
merged.times = tailWindow(seedTimes.concat(prev.times));
|
||||||
|
for (const key of SERIES_KEYS) {
|
||||||
|
merged.series[key] = tailWindow(seedSeries[key].concat(prev.series[key]));
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
seed().catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Each polled status is appended during render; an effect would show the
|
||||||
|
// chart one sample behind the numbers beside it.
|
||||||
|
const [sampledStatus, setSampledStatus] = useState<Status | null>(null);
|
||||||
|
if (hasData && status !== sampledStatus) {
|
||||||
|
setSampledStatus(status);
|
||||||
|
setTrend((prev) => {
|
||||||
|
const point = sampleOf(status);
|
||||||
|
const next = emptyWindow();
|
||||||
|
next.times = tailWindow(prev.times.concat(Math.floor(Date.now() / 1000)));
|
||||||
|
for (const key of SERIES_KEYS) {
|
||||||
|
next.series[key] = tailWindow(prev.series[key].concat(point[key]));
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = useMemo(() => trend.times.map(TimeFormatter.formatClock), [trend.times]);
|
||||||
|
|
||||||
|
return useMemo(() => ({ series: trend.series, labels }), [trend.series, labels]);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
@@ -39,6 +40,7 @@ func NewServerController(g *gin.RouterGroup) *ServerController {
|
|||||||
// initRouter sets up the routes for server status, Xray management, and utility endpoints.
|
// initRouter sets up the routes for server status, Xray management, and utility endpoints.
|
||||||
func (a *ServerController) initRouter(g *gin.RouterGroup) {
|
func (a *ServerController) initRouter(g *gin.RouterGroup) {
|
||||||
g.GET("/status", a.status)
|
g.GET("/status", a.status)
|
||||||
|
g.GET("/history/:metric/:bucket", a.getOverviewHistoryBucket)
|
||||||
g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo)
|
g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo)
|
||||||
g.GET("/getUpdateStatus", a.getUpdateStatus)
|
g.GET("/getUpdateStatus", a.getUpdateStatus)
|
||||||
g.GET("/getConfigJson", a.getConfigJson)
|
g.GET("/getConfigJson", a.getConfigJson)
|
||||||
@@ -86,6 +88,25 @@ func (a *ServerController) startTask() {
|
|||||||
// status returns the current server status information.
|
// status returns the current server status information.
|
||||||
func (a *ServerController) status(c *gin.Context) { jsonObj(c, a.serverService.LastStatus(), nil) }
|
func (a *ServerController) status(c *gin.Context) { jsonObj(c, a.serverService.LastStatus(), nil) }
|
||||||
|
|
||||||
|
// getOverviewHistoryBucket returns up to 60 buckets of one homepage
|
||||||
|
// overview metric (see service.OverviewMetricKeys). It backs only the
|
||||||
|
// index-page sparkline seed; the store behind it is the memory-only
|
||||||
|
// overview ring — not the removed system-history API (no other metrics,
|
||||||
|
// no persistence, nothing to browse beyond the overview window).
|
||||||
|
func (a *ServerController) getOverviewHistoryBucket(c *gin.Context) {
|
||||||
|
metric := c.Param("metric")
|
||||||
|
if !slices.Contains(service.OverviewMetricKeys, metric) {
|
||||||
|
jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bucket, err := strconv.Atoi(c.Param("bucket"))
|
||||||
|
if err != nil || bucket <= 0 || !service.IsAllowedHistoryBucket(bucket) {
|
||||||
|
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonObj(c, a.serverService.AggregateOverviewMetric(metric, bucket, 60), nil)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *ServerController) getFail2banStatus(c *gin.Context) {
|
func (a *ServerController) getFail2banStatus(c *gin.Context) {
|
||||||
jsonObj(c, a.serverService.GetFail2banStatus(), nil)
|
jsonObj(c, a.serverService.GetFail2banStatus(), nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,8 +87,12 @@ type series struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newSeries() *series {
|
func newSeries() *series {
|
||||||
s := &series{tiers: make([]*tierBuf, len(metricTiers))}
|
return newSeriesWithTiers(metricTiers)
|
||||||
for i, spec := range metricTiers {
|
}
|
||||||
|
|
||||||
|
func newSeriesWithTiers(specs []tierSpec) *series {
|
||||||
|
s := &series{tiers: make([]*tierBuf, len(specs))}
|
||||||
|
for i, spec := range specs {
|
||||||
s.tiers[i] = &tierBuf{resolution: spec.resolution, capacity: spec.capacity}
|
s.tiers[i] = &tierBuf{resolution: spec.resolution, capacity: spec.capacity}
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
@@ -112,14 +116,20 @@ func (s *series) pickTier(spanSeconds int64) *tierBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// metricHistory is a thread-safe, in-memory store of tiered series keyed by
|
// metricHistory is a thread-safe, in-memory store of tiered series keyed by
|
||||||
// arbitrary strings. One singleton lives below: per-node metrics.
|
// arbitrary strings. Two singletons live below: per-node metrics (full
|
||||||
|
// rollup ladder) and the homepage overview series (one small fixed ring).
|
||||||
type metricHistory struct {
|
type metricHistory struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
tiers []tierSpec
|
||||||
series map[string]*series
|
series map[string]*series
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMetricHistory() *metricHistory {
|
func newMetricHistory() *metricHistory {
|
||||||
return &metricHistory{series: map[string]*series{}}
|
return newMetricHistoryWithTiers(metricTiers)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMetricHistoryWithTiers(tiers []tierSpec) *metricHistory {
|
||||||
|
return &metricHistory{tiers: tiers, series: map[string]*series{}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// append stores a single sample for the given metric across all tiers.
|
// append stores a single sample for the given metric across all tiers.
|
||||||
@@ -128,7 +138,7 @@ func (h *metricHistory) append(metric string, t time.Time, v float64) {
|
|||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
s := h.series[metric]
|
s := h.series[metric]
|
||||||
if s == nil {
|
if s == nil {
|
||||||
s = newSeries()
|
s = newSeriesWithTiers(h.tiers)
|
||||||
h.series[metric] = s
|
h.series[metric] = s
|
||||||
}
|
}
|
||||||
s.add(t.Unix(), v)
|
s.add(t.Unix(), v)
|
||||||
@@ -208,12 +218,27 @@ func (h *metricHistory) aggregate(metric string, bucketSeconds int, maxPoints in
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// nodeMetrics is the remaining singleton: per-node CPU/Mem/net series fed by
|
// nodeMetrics holds per-node CPU/Mem/net series fed by NodeHeartbeatJob
|
||||||
// NodeHeartbeatJob (via UpdateHeartbeat) and read by the node history panel.
|
// (via UpdateHeartbeat) and read by the node history panel. The whole-host
|
||||||
// The whole-host system series and the xray expvar series that used to live
|
// system series and the xray expvar series that used to live here were
|
||||||
// here were removed with the system-history and xray-metrics features,
|
// removed with the system-history and xray-metrics features, together with
|
||||||
// together with the gob persistence of the host series.
|
// the gob persistence of the host series.
|
||||||
var nodeMetrics = newMetricHistory()
|
var nodeMetrics = newMetricHistory()
|
||||||
|
|
||||||
// NodeMetricKeys lists the per-node metric names NodeHeartbeatJob writes.
|
// NodeMetricKeys lists the per-node metric names NodeHeartbeatJob writes.
|
||||||
var NodeMetricKeys = []string{"cpu", "mem", "netUp", "netDown"}
|
var NodeMetricKeys = []string{"cpu", "mem", "netUp", "netDown"}
|
||||||
|
|
||||||
|
// overviewMetrics is the homepage overview store (P8): a deliberately small,
|
||||||
|
// memory-only ring feeding the index-page sparkline seed — NOT a revival of
|
||||||
|
// the removed system-history feature. One tier at the status cadence (2s)
|
||||||
|
// holding 360 samples = a 12-minute window; eight series of 360 samples are
|
||||||
|
// ~46 KB of payload, bounded by construction. Nothing is persisted: the
|
||||||
|
// ring starts empty on every panel start and the overview curves simply
|
||||||
|
// accumulate from there.
|
||||||
|
var overviewMetrics = newMetricHistoryWithTiers([]tierSpec{{resolution: 2, capacity: 360}})
|
||||||
|
|
||||||
|
// OverviewMetricKeys lists the host metric names ServerService writes on
|
||||||
|
// every status sample for the homepage overview. It matches the frontend
|
||||||
|
// useOverviewHistory SERIES_KEYS exactly; the controller validates incoming
|
||||||
|
// history requests against this allow-list.
|
||||||
|
var OverviewMetricKeys = []string{"cpu", "mem", "swap", "diskUsage", "netUp", "netDown", "tcpCount", "udpCount"}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The overview ring is a fresh store per test (the package singleton is fed
|
||||||
|
// by RefreshStatus in production); these tests pin its contract: only the
|
||||||
|
// overview keys are written by AppendOverviewSample, samples come back
|
||||||
|
// through AggregateOverviewMetric, and the window is hard-bounded by the
|
||||||
|
// single tier's capacity no matter how long the panel runs.
|
||||||
|
|
||||||
|
func overviewTestStatus(cpu float64) *Status {
|
||||||
|
st := &Status{Cpu: cpu, TcpCount: 3, UdpCount: 2}
|
||||||
|
st.Mem.Current = 1
|
||||||
|
st.Mem.Total = 4
|
||||||
|
st.Swap.Current = 0
|
||||||
|
st.Swap.Total = 0
|
||||||
|
st.Disk.Current = 1
|
||||||
|
st.Disk.Total = 2
|
||||||
|
st.NetIO.Up = 100
|
||||||
|
st.NetIO.Down = 200
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverviewRingAppendAndAggregate(t *testing.T) {
|
||||||
|
s := &ServerService{}
|
||||||
|
base := time.Now().Add(-10 * time.Second).Truncate(2 * time.Second)
|
||||||
|
for i := range 5 {
|
||||||
|
s.AppendOverviewSample(base.Add(time.Duration(i*2)*time.Second), overviewTestStatus(float64(i)))
|
||||||
|
}
|
||||||
|
// overviewMetrics is the package singleton; the samples just appended
|
||||||
|
// must be visible through the aggregate the controller serves.
|
||||||
|
out := s.AggregateOverviewMetric("cpu", 2, 60)
|
||||||
|
if len(out) == 0 {
|
||||||
|
t.Fatal("AggregateOverviewMetric(cpu) returned no points after appends")
|
||||||
|
}
|
||||||
|
last := out[len(out)-1]
|
||||||
|
if got, _ := last["v"].(float64); got != 4 {
|
||||||
|
t.Fatalf("last cpu point = %v, want 4", last["v"])
|
||||||
|
}
|
||||||
|
for _, key := range OverviewMetricKeys {
|
||||||
|
if pts := s.AggregateOverviewMetric(key, 2, 60); len(pts) == 0 {
|
||||||
|
t.Fatalf("overview key %q has no points after AppendOverviewSample", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverviewRingIsBounded(t *testing.T) {
|
||||||
|
h := newMetricHistoryWithTiers([]tierSpec{{resolution: 2, capacity: 360}})
|
||||||
|
base := time.Unix(1_700_000_000, 0)
|
||||||
|
// Feed ten windows' worth of samples; the ring must not grow past its
|
||||||
|
// single tier capacity.
|
||||||
|
for i := range 3600 {
|
||||||
|
h.append("cpu", base.Add(time.Duration(i*2)*time.Second), float64(i))
|
||||||
|
}
|
||||||
|
// readSamples returns the closed buckets plus the still-open one, so the
|
||||||
|
// hard bound is capacity+1.
|
||||||
|
raw := h.series["cpu"].tiers[0].readSamples()
|
||||||
|
if len(raw) > 361 {
|
||||||
|
t.Fatalf("overview tier retained %d samples, want <= 361 (capacity + open bucket)", len(raw))
|
||||||
|
}
|
||||||
|
if len(raw) == 0 {
|
||||||
|
t.Fatal("overview tier retained no samples")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -177,8 +177,8 @@ var allowedHistoryBuckets = map[int]bool{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IsAllowedHistoryBucket reports whether a bucket-seconds value is in the
|
// IsAllowedHistoryBucket reports whether a bucket-seconds value is in the
|
||||||
// whitelist used by /server/history, /server/cpuHistory, /server/xrayMetricsHistory,
|
// whitelist used by the time-series aggregation endpoints
|
||||||
// /server/xrayObservatoryHistory, and /nodes/history.
|
// (/server/history for the homepage overview, /nodes/history).
|
||||||
func IsAllowedHistoryBucket(bucketSeconds int) bool {
|
func IsAllowedHistoryBucket(bucketSeconds int) bool {
|
||||||
return allowedHistoryBuckets[bucketSeconds]
|
return allowedHistoryBuckets[bucketSeconds]
|
||||||
}
|
}
|
||||||
@@ -242,7 +242,7 @@ func (s *ServerService) isFail2banInstalled() bool {
|
|||||||
// Returns the new snapshot (may be nil if collection failed). Called by the
|
// Returns the new snapshot (may be nil if collection failed). Called by the
|
||||||
// background ticker; the caller is responsible for any side effects
|
// background ticker; the caller is responsible for any side effects
|
||||||
// (websocket broadcast). The realtime status served to the UI comes from
|
// (websocket broadcast). The realtime status served to the UI comes from
|
||||||
// this snapshot — the removed system-history series used to be fed here too.
|
// this snapshot; the homepage overview ring is fed here too.
|
||||||
func (s *ServerService) RefreshStatus() *Status {
|
func (s *ServerService) RefreshStatus() *Status {
|
||||||
next := s.GetStatus(s.LastStatus())
|
next := s.GetStatus(s.LastStatus())
|
||||||
if next == nil {
|
if next == nil {
|
||||||
@@ -251,9 +251,47 @@ func (s *ServerService) RefreshStatus() *Status {
|
|||||||
s.lastStatusMu.Lock()
|
s.lastStatusMu.Lock()
|
||||||
s.lastStatus = next
|
s.lastStatus = next
|
||||||
s.lastStatusMu.Unlock()
|
s.lastStatusMu.Unlock()
|
||||||
|
s.AppendOverviewSample(time.Now(), next)
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AppendOverviewSample writes one tick of the homepage overview metrics —
|
||||||
|
// CPU, memory/swap/disk percents, network throughput (bytes/s) and TCP/UDP
|
||||||
|
// connection counts — into the memory-only overview ring. Called by
|
||||||
|
// RefreshStatus on the @2s status cadence, so all series stay aligned.
|
||||||
|
// Only the overview keys are kept; this is not the removed system-history
|
||||||
|
// sampler (no extra series, no persistence).
|
||||||
|
func (s *ServerService) AppendOverviewSample(t time.Time, status *Status) {
|
||||||
|
if status == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
overviewMetrics.append("cpu", t, status.Cpu)
|
||||||
|
if status.Mem.Total > 0 {
|
||||||
|
overviewMetrics.append("mem", t, float64(status.Mem.Current)*100.0/float64(status.Mem.Total))
|
||||||
|
}
|
||||||
|
if status.Swap.Total > 0 {
|
||||||
|
overviewMetrics.append("swap", t, float64(status.Swap.Current)*100.0/float64(status.Swap.Total))
|
||||||
|
} else {
|
||||||
|
overviewMetrics.append("swap", t, 0)
|
||||||
|
}
|
||||||
|
overviewMetrics.append("netUp", t, float64(status.NetIO.Up))
|
||||||
|
overviewMetrics.append("netDown", t, float64(status.NetIO.Down))
|
||||||
|
if status.Disk.Total > 0 {
|
||||||
|
overviewMetrics.append("diskUsage", t, float64(status.Disk.Current)*100.0/float64(status.Disk.Total))
|
||||||
|
}
|
||||||
|
overviewMetrics.append("tcpCount", t, float64(status.TcpCount))
|
||||||
|
overviewMetrics.append("udpCount", t, float64(status.UdpCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AggregateOverviewMetric returns up to maxPoints averaged buckets of size
|
||||||
|
// bucketSeconds for one overview metric (see OverviewMetricKeys). Output
|
||||||
|
// points have keys {"t": unixSec, "v": value}. It backs the seed fetch of
|
||||||
|
// the homepage overview curves; the ring behind it is memory-only, so
|
||||||
|
// shortly after a panel restart it legitimately returns few or no points.
|
||||||
|
func (s *ServerService) AggregateOverviewMetric(metric string, bucketSeconds int, maxPoints int) []map[string]any {
|
||||||
|
return overviewMetrics.aggregate(metric, bucketSeconds, maxPoints)
|
||||||
|
}
|
||||||
|
|
||||||
func getPublicIP(url string) string {
|
func getPublicIP(url string) string {
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Timeout: 3 * time.Second,
|
Timeout: 3 * time.Second,
|
||||||
|
|||||||
Reference in New Issue
Block a user