mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-25 15:25:01 +03:00
fix(monitoring): read LXC CPU, swap and uptime from the container (#6920)
In an LXC container with LXCFS the Overview showed the container's RAM beside the host's swap, the host's CPU topology and a CPU percentage from the host's view. Swap and uptime now come from LXCFS's virtualised files and CPU from the container's cgroup v2 counter, normalised by the visible vCPUs and any quota; container CPU and swap history is kept apart from the earlier host samples. A value the reader cannot measure (the first CPU sample, or a layout it cannot verify such as cgroup v1) is reported as unavailable instead of 0: the dashboard, the node list, node CPU history and the Telegram and Discord uptime show a dash, and nodes keep the flags in a new unavailable_metrics column.
This commit is contained in:
@@ -3740,6 +3740,13 @@
|
||||
"transitive": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"unavailableMetrics": {
|
||||
"description": "Nil on legacy rows and responses: zero remains a valid measured value.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"updatedAt": {
|
||||
"example": 1700000000,
|
||||
"format": "int64",
|
||||
@@ -4048,6 +4055,12 @@
|
||||
"example": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"unavailableMetrics": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"updatedAt": {
|
||||
"example": 1700003600,
|
||||
"format": "int64",
|
||||
@@ -4260,6 +4273,12 @@
|
||||
"example": "online",
|
||||
"type": "string"
|
||||
},
|
||||
"unavailableMetrics": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"uptimeSecs": {
|
||||
"example": 86400,
|
||||
"format": "int64",
|
||||
@@ -11163,6 +11182,9 @@
|
||||
"status": "online",
|
||||
"tlsVerifyMode": "verify",
|
||||
"transitive": false,
|
||||
"unavailableMetrics": [
|
||||
""
|
||||
],
|
||||
"updatedAt": 1700003600,
|
||||
"uptimeSecs": 86400,
|
||||
"xrayError": "",
|
||||
@@ -11364,6 +11386,9 @@
|
||||
"status": "online",
|
||||
"tlsVerifyMode": "verify",
|
||||
"transitive": false,
|
||||
"unavailableMetrics": [
|
||||
""
|
||||
],
|
||||
"updatedAt": 1700003600,
|
||||
"uptimeSecs": 86400,
|
||||
"xrayError": "",
|
||||
@@ -11514,6 +11539,9 @@
|
||||
"status": "online",
|
||||
"tlsVerifyMode": "verify",
|
||||
"transitive": false,
|
||||
"unavailableMetrics": [
|
||||
""
|
||||
],
|
||||
"updatedAt": 1700003600,
|
||||
"uptimeSecs": 86400,
|
||||
"xrayError": "",
|
||||
@@ -11737,6 +11765,9 @@
|
||||
"memPct": 45.2,
|
||||
"panelVersion": "v3.x.x",
|
||||
"status": "online",
|
||||
"unavailableMetrics": [
|
||||
""
|
||||
],
|
||||
"uptimeSecs": 86400,
|
||||
"xrayError": "",
|
||||
"xrayState": "",
|
||||
@@ -16710,6 +16741,9 @@
|
||||
"status": "online",
|
||||
"tlsVerifyMode": "verify",
|
||||
"transitive": false,
|
||||
"unavailableMetrics": [
|
||||
""
|
||||
],
|
||||
"updatedAt": 1700003600,
|
||||
"uptimeSecs": 86400,
|
||||
"xrayError": "",
|
||||
|
||||
@@ -944,6 +944,9 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"status": "online",
|
||||
"tlsVerifyMode": "verify",
|
||||
"transitive": false,
|
||||
"unavailableMetrics": [
|
||||
""
|
||||
],
|
||||
"updatedAt": 1700000000,
|
||||
"uptimeSecs": 86400,
|
||||
"xrayError": "",
|
||||
@@ -1009,6 +1012,9 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"status": "online",
|
||||
"tlsVerifyMode": "verify",
|
||||
"transitive": false,
|
||||
"unavailableMetrics": [
|
||||
""
|
||||
],
|
||||
"updatedAt": 1700003600,
|
||||
"uptimeSecs": 86400,
|
||||
"xrayError": "",
|
||||
@@ -1047,6 +1053,9 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"memPct": 45.2,
|
||||
"panelVersion": "v3.x.x",
|
||||
"status": "online",
|
||||
"unavailableMetrics": [
|
||||
""
|
||||
],
|
||||
"uptimeSecs": 86400,
|
||||
"xrayError": "",
|
||||
"xrayState": "",
|
||||
|
||||
@@ -3714,6 +3714,13 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"transitive": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"unavailableMetrics": {
|
||||
"description": "Nil on legacy rows and responses: zero remains a valid measured value.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"updatedAt": {
|
||||
"example": 1700000000,
|
||||
"format": "int64",
|
||||
@@ -4022,6 +4029,12 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"example": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"unavailableMetrics": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"updatedAt": {
|
||||
"example": 1700003600,
|
||||
"format": "int64",
|
||||
@@ -4234,6 +4247,12 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"example": "online",
|
||||
"type": "string"
|
||||
},
|
||||
"unavailableMetrics": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"uptimeSecs": {
|
||||
"example": 86400,
|
||||
"format": "int64",
|
||||
|
||||
@@ -855,6 +855,7 @@ export interface Node {
|
||||
status: string;
|
||||
tlsVerifyMode: string;
|
||||
transitive?: boolean;
|
||||
unavailableMetrics?: string[];
|
||||
updatedAt: number;
|
||||
uptimeSecs: number;
|
||||
xrayError: string;
|
||||
@@ -918,6 +919,7 @@ export interface NodeView {
|
||||
status: string;
|
||||
tlsVerifyMode: string;
|
||||
transitive?: boolean;
|
||||
unavailableMetrics?: string[];
|
||||
updatedAt: number;
|
||||
uptimeSecs: number;
|
||||
xrayError: string;
|
||||
@@ -960,6 +962,7 @@ export interface ProbeResultUI {
|
||||
memPct: number;
|
||||
panelVersion: string;
|
||||
status: string;
|
||||
unavailableMetrics?: string[];
|
||||
uptimeSecs: number;
|
||||
xrayError: string;
|
||||
xrayState: string;
|
||||
|
||||
@@ -908,6 +908,7 @@ export const NodeSchema = z.object({
|
||||
status: z.string(),
|
||||
tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']),
|
||||
transitive: z.boolean().optional(),
|
||||
unavailableMetrics: z.array(z.string()).optional(),
|
||||
updatedAt: z.number().int(),
|
||||
uptimeSecs: z.number().int(),
|
||||
xrayError: z.string(),
|
||||
@@ -973,6 +974,7 @@ export const NodeViewSchema = z.object({
|
||||
status: z.string(),
|
||||
tlsVerifyMode: z.string(),
|
||||
transitive: z.boolean().optional(),
|
||||
unavailableMetrics: z.array(z.string()).optional(),
|
||||
updatedAt: z.number().int(),
|
||||
uptimeSecs: z.number().int(),
|
||||
xrayError: z.string(),
|
||||
@@ -1019,6 +1021,7 @@ export const ProbeResultUISchema = z.object({
|
||||
memPct: z.number(),
|
||||
panelVersion: z.string(),
|
||||
status: z.string(),
|
||||
unavailableMetrics: z.array(z.string()).optional(),
|
||||
uptimeSecs: z.number().int(),
|
||||
xrayError: z.string(),
|
||||
xrayState: z.string(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NumberFormatter } from '@/utils';
|
||||
import type { StatusInput as ParsedStatusInput } from '@/schemas/status';
|
||||
|
||||
export const USAGE_WARN_PERCENT = 80;
|
||||
export const USAGE_CRIT_PERCENT = 90;
|
||||
@@ -66,7 +67,7 @@ export interface AmneziaWGInfo {
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
interface StatusInput {
|
||||
interface StatusInput extends Pick<ParsedStatusInput, 'resourceScope' | 'unavailableMetrics'> {
|
||||
cpu?: number;
|
||||
cpuCores?: number;
|
||||
logicalPro?: number;
|
||||
@@ -88,6 +89,13 @@ interface StatusInput {
|
||||
}
|
||||
|
||||
export class Status {
|
||||
resourceScope = '';
|
||||
unavailableMetrics: string[] = [];
|
||||
|
||||
metricAvailable(metric: string): boolean {
|
||||
return !this.unavailableMetrics.includes(metric);
|
||||
}
|
||||
|
||||
cpu: CurTotal = new CurTotal(0, 0);
|
||||
cpuCores = 0;
|
||||
logicalPro = 0;
|
||||
@@ -110,6 +118,8 @@ export class Status {
|
||||
constructor(data?: StatusInput | null) {
|
||||
if (data == null) return;
|
||||
|
||||
this.resourceScope = data.resourceScope ?? '';
|
||||
this.unavailableMetrics = data.unavailableMetrics ?? [];
|
||||
this.cpu = new CurTotal(data.cpu ?? 0, 100);
|
||||
this.cpuCores = data.cpuCores ?? 0;
|
||||
this.logicalPro = data.logicalPro ?? 0;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { DatabaseOutlined } from '@ant-design/icons';
|
||||
import { expect, within } from 'storybook/test';
|
||||
|
||||
import { Status } from '@/models/status';
|
||||
import VitalTile from './VitalTile';
|
||||
import SystemStrip from './SystemStrip';
|
||||
import './IndexPage.css';
|
||||
|
||||
function ContainerMetricsPreview({ unavailable = false }: { unavailable?: boolean }) {
|
||||
const status = new Status({
|
||||
resourceScope: 'container',
|
||||
unavailableMetrics: unavailable ? ['swap', 'uptime'] : [],
|
||||
swap: { current: 19357696, total: 536870912 },
|
||||
uptime: 52624,
|
||||
appStats: { mem: 104857600, threads: 37, uptime: 50000 },
|
||||
});
|
||||
return (
|
||||
<div style={{ maxWidth: 1000, display: 'grid', gap: 16 }}>
|
||||
<div style={{ width: 340 }}>
|
||||
<VitalTile
|
||||
icon={<DatabaseOutlined />}
|
||||
label="SWAP"
|
||||
available={status.metricAvailable('swap')}
|
||||
percent={status.swap.percent}
|
||||
statusColor={status.swap.color}
|
||||
detail="18.46 MiB / 512 MiB"
|
||||
footLeft="Container"
|
||||
footRight="2 vCPU"
|
||||
data={unavailable ? [] : [3.5, 3.6, 3.6]}
|
||||
isMobile={false}
|
||||
/>
|
||||
</div>
|
||||
<SystemStrip status={status} showIp={false} onToggleIp={() => undefined} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Monitoring/Container metrics',
|
||||
component: ContainerMetricsPreview,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'padded' },
|
||||
} satisfies Meta<typeof ContainerMetricsPreview>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Measured: Story = {
|
||||
args: { unavailable: false },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText('18.46 MiB / 512 MiB')).toBeVisible();
|
||||
await expect(canvas.getByText('CT')).toBeVisible();
|
||||
await expect(canvas.queryByText('OS')).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const Unavailable: Story = {
|
||||
args: { unavailable: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.queryByText('18.46 MiB / 512 MiB')).not.toBeInTheDocument();
|
||||
await expect(canvas.queryByText('%')).not.toBeInTheDocument();
|
||||
await expect(canvas.getAllByText('—').length).toBeGreaterThan(0);
|
||||
},
|
||||
};
|
||||
@@ -229,9 +229,14 @@ export default function IndexPage() {
|
||||
<VitalTile
|
||||
icon={<DashboardOutlined aria-hidden />}
|
||||
label={t('pages.index.cpu')}
|
||||
available={status.metricAvailable('cpu')}
|
||||
percent={status.cpu.percent}
|
||||
statusColor={status.cpu.color}
|
||||
detail={`${CPUFormatter.cpuCoreFormat(status.cpuCores)} / ${status.logicalPro}T · ${CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz)}`}
|
||||
detail={
|
||||
status.resourceScope === 'container'
|
||||
? `${status.logicalPro} vCPU`
|
||||
: `${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}
|
||||
@@ -251,6 +256,7 @@ export default function IndexPage() {
|
||||
<VitalTile
|
||||
icon={<SwapOutlined aria-hidden />}
|
||||
label={t('pages.index.swap')}
|
||||
available={status.metricAvailable('swap')}
|
||||
percent={status.swap.percent}
|
||||
statusColor={status.swap.color}
|
||||
detail={`${SizeFormatter.sizeFormat(status.swap.current)} / ${SizeFormatter.sizeFormat(status.swap.total)}`}
|
||||
|
||||
@@ -38,8 +38,12 @@ export default function SystemStrip({ status, showIp, onToggleIp }: SystemStripP
|
||||
</div>
|
||||
<span className="ov-strip-split-sep" />
|
||||
<div>
|
||||
<div className="ov-strip-sub">OS</div>
|
||||
<div className="ov-strip-value">{TimeFormatter.formatSecond(status.uptime)}</div>
|
||||
<div className="ov-strip-sub">
|
||||
{status.resourceScope === 'container' ? 'CT' : 'OS'}
|
||||
</div>
|
||||
<div className="ov-strip-value">
|
||||
{status.metricAvailable('uptime') ? TimeFormatter.formatSecond(status.uptime) : '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Sparkline } from '@/components/viz';
|
||||
import { mean, peak } from './useOverviewHistory';
|
||||
|
||||
interface VitalTileProps {
|
||||
available?: boolean;
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
percent: number;
|
||||
@@ -18,6 +19,7 @@ interface VitalTileProps {
|
||||
}
|
||||
|
||||
export default function VitalTile({
|
||||
available = true,
|
||||
icon,
|
||||
label,
|
||||
percent,
|
||||
@@ -44,15 +46,15 @@ export default function VitalTile({
|
||||
</div>
|
||||
|
||||
<div className="ov-tile-value">
|
||||
<span className="ov-tile-number">{percent.toFixed(1)}</span>
|
||||
<span className="ov-tile-unit">%</span>
|
||||
<span className="ov-tile-number">{available ? percent.toFixed(1) : '—'}</span>
|
||||
{available && <span className="ov-tile-unit">%</span>}
|
||||
</div>
|
||||
|
||||
<div className="ov-tile-detail">{detail}</div>
|
||||
<div className="ov-tile-detail">{available ? detail : '—'}</div>
|
||||
|
||||
<div className="ov-tile-foot">
|
||||
<span>{footLeft}</span>
|
||||
<span>{footRight}</span>
|
||||
<span>{available ? footLeft : '—'}</span>
|
||||
<span>{available ? footRight : '—'}</span>
|
||||
</div>
|
||||
|
||||
<div className="ov-tile-chart">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { HttpUtil, TimeFormatter } from '@/utils';
|
||||
import type { Status } from '@/models/status';
|
||||
@@ -80,13 +80,27 @@ export function peak(values: number[]): number {
|
||||
export function useOverviewHistory(status: Status, hasData: boolean): OverviewHistory {
|
||||
const [trend, setTrend] = useState<HistoryWindow>(emptyWindow);
|
||||
|
||||
const seededUnscoped = useRef(false);
|
||||
const seedState = useRef<{ scope: string; complete: boolean } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// The placeholder has no resource scope. Wait for the first real status,
|
||||
// then seed each scope once even if polling temporarily loses data.
|
||||
if (
|
||||
!hasData ||
|
||||
(seedState.current?.scope === status.resourceScope && seedState.current.complete)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
seedState.current = { scope: status.resourceScope, complete: false };
|
||||
let cancelled = false;
|
||||
const seedUnscoped = !seededUnscoped.current;
|
||||
const keys = seedUnscoped ? SERIES_KEYS : (['cpu', 'swap'] as const);
|
||||
|
||||
const seed = async () => {
|
||||
const responses = new Map<OverviewSeriesKey, HistoryPoint[]>();
|
||||
await Promise.all(
|
||||
SERIES_KEYS.map(async (key) => {
|
||||
keys.map(async (key) => {
|
||||
const msg = await HttpUtil.get<HistoryPoint[]>(
|
||||
`/panel/api/server/history/${key}/${SEED_BUCKET_SECONDS}`,
|
||||
undefined,
|
||||
@@ -96,6 +110,8 @@ export function useOverviewHistory(status: Status, hasData: boolean): OverviewHi
|
||||
}),
|
||||
);
|
||||
if (cancelled || responses.size === 0) return;
|
||||
seedState.current = { scope: status.resourceScope, complete: true };
|
||||
seededUnscoped.current = true;
|
||||
|
||||
let axis: HistoryPoint[] = [];
|
||||
for (const points of responses.values()) {
|
||||
@@ -109,14 +125,20 @@ export function useOverviewHistory(status: Status, hasData: boolean): OverviewHi
|
||||
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);
|
||||
seedSeries[key] =
|
||||
status.resourceScope && (key === 'cpu' || key === 'swap')
|
||||
? seedTimes.flatMap((ts) => (byTs.has(ts) ? [byTs.get(ts)!] : []))
|
||||
: seedTimes.map((ts) => byTs.get(ts) ?? 0);
|
||||
}
|
||||
|
||||
setTrend((prev) => {
|
||||
const merged = emptyWindow();
|
||||
merged.times = tailWindow(seedTimes.concat(prev.times));
|
||||
merged.times = seedUnscoped ? tailWindow(seedTimes.concat(prev.times)) : prev.times;
|
||||
for (const key of SERIES_KEYS) {
|
||||
merged.series[key] = tailWindow(seedSeries[key].concat(prev.series[key]));
|
||||
merged.series[key] =
|
||||
seedUnscoped || key === 'cpu' || key === 'swap'
|
||||
? tailWindow(seedSeries[key].concat(prev.series[key]))
|
||||
: prev.series[key];
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
@@ -126,7 +148,7 @@ export function useOverviewHistory(status: Status, hasData: boolean): OverviewHi
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}, [hasData, status.resourceScope]);
|
||||
|
||||
// Each polled status is appended during render; an effect would show the
|
||||
// chart one sample behind the numbers beside it.
|
||||
@@ -134,11 +156,17 @@ export function useOverviewHistory(status: Status, hasData: boolean): OverviewHi
|
||||
if (hasData && status !== sampledStatus) {
|
||||
setSampledStatus(status);
|
||||
setTrend((prev) => {
|
||||
const scopeChanged = sampledStatus && sampledStatus.resourceScope !== status.resourceScope;
|
||||
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]));
|
||||
const scoped = key === 'cpu' || key === 'swap';
|
||||
const previous = scoped && scopeChanged ? [] : prev.series[key];
|
||||
next.series[key] =
|
||||
!scoped || status.metricAvailable(key)
|
||||
? tailWindow(previous.concat(point[key]))
|
||||
: previous;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
@@ -130,12 +130,14 @@ function StatusLabel({ status, xrayState }: HealthProps) {
|
||||
return <span>{t(`pages.nodes.statusValues.${status || 'unknown'}`)}</span>;
|
||||
}
|
||||
|
||||
function formatPct(p?: number): string {
|
||||
function formatPct(p?: number, available = true): string {
|
||||
if (!available) return '—';
|
||||
if (typeof p !== 'number' || Number.isNaN(p)) return '-';
|
||||
return `${p.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function formatUptime(secs?: number): string {
|
||||
function formatUptime(secs?: number, available = true): string {
|
||||
if (!available) return '—';
|
||||
if (!secs) return '-';
|
||||
const days = Math.floor(secs / 86400);
|
||||
const hours = Math.floor((secs % 86400) / 3600);
|
||||
@@ -404,7 +406,8 @@ export default function NodeList({
|
||||
dataIndex: 'cpuPct',
|
||||
align: 'center',
|
||||
width: 90,
|
||||
render: (_value, record) => formatPct(record.cpuPct),
|
||||
render: (_value, record) =>
|
||||
formatPct(record.cpuPct, !record.unavailableMetrics?.includes('cpu')),
|
||||
},
|
||||
{
|
||||
title: t('pages.nodes.mem'),
|
||||
@@ -452,7 +455,8 @@ export default function NodeList({
|
||||
title: t('pages.nodes.uptime'),
|
||||
dataIndex: 'uptimeSecs',
|
||||
align: 'center',
|
||||
render: (_value, record) => formatUptime(record.uptimeSecs),
|
||||
render: (_value, record) =>
|
||||
formatUptime(record.uptimeSecs, !record.unavailableMetrics?.includes('uptime')),
|
||||
},
|
||||
{
|
||||
title: t('clients'),
|
||||
@@ -792,7 +796,9 @@ export default function NodeList({
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.nodes.cpu')}</span>
|
||||
<Tag>{formatPct(statsNode.cpuPct)}</Tag>
|
||||
<Tag>
|
||||
{formatPct(statsNode.cpuPct, !statsNode.unavailableMetrics?.includes('cpu'))}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.nodes.mem')}</span>
|
||||
@@ -810,7 +816,12 @@ export default function NodeList({
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.nodes.uptime')}</span>
|
||||
<Tag>{formatUptime(statsNode.uptimeSecs)}</Tag>
|
||||
<Tag>
|
||||
{formatUptime(
|
||||
statsNode.uptimeSecs,
|
||||
!statsNode.unavailableMetrics?.includes('uptime'),
|
||||
)}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{t('pages.nodes.latency')}</span>
|
||||
|
||||
@@ -19,6 +19,7 @@ export const NodeRecordSchema = z
|
||||
xrayVersion: z.string().optional(),
|
||||
panelVersion: z.string().optional(),
|
||||
uptimeSecs: z.number().optional(),
|
||||
unavailableMetrics: z.array(z.string()).optional(),
|
||||
inboundCount: z.number().optional(),
|
||||
clientCount: z.number().optional(),
|
||||
onlineCount: z.number().optional(),
|
||||
@@ -52,6 +53,7 @@ export const NodeListSchema = z.array(NodeRecordSchema);
|
||||
export const ProbeResultSchema = z
|
||||
.object({
|
||||
status: z.string(),
|
||||
unavailableMetrics: z.array(z.string()).optional(),
|
||||
latencyMs: z.number().optional(),
|
||||
xrayVersion: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
|
||||
@@ -36,6 +36,8 @@ export const XrayInfoSchema = z
|
||||
.partial();
|
||||
|
||||
export const StatusSchema = z.object({
|
||||
resourceScope: z.string().optional(),
|
||||
unavailableMetrics: z.array(z.string()).optional(),
|
||||
cpu: z.number().optional(),
|
||||
cpuCores: z.number().optional(),
|
||||
logicalPro: z.number().optional(),
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Status } from '@/models/status';
|
||||
import { useOverviewHistory } from '@/pages/index/useOverviewHistory';
|
||||
|
||||
const { get } = vi.hoisted(() => ({ get: vi.fn() }));
|
||||
vi.mock('@/utils', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('@/utils')>();
|
||||
return { ...original, HttpUtil: { get } };
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
});
|
||||
|
||||
it('keeps missing samples out of trends and resets a changed metric scope', async () => {
|
||||
get.mockImplementation(async (path: string) => ({
|
||||
success: true,
|
||||
obj: path.includes('/cpu/')
|
||||
? [{ t: 1, v: 10 }]
|
||||
: [
|
||||
{ t: 1, v: 5 },
|
||||
{ t: 2, v: 6 },
|
||||
],
|
||||
}));
|
||||
const unavailable = new Status({
|
||||
resourceScope: 'container',
|
||||
cpu: 0,
|
||||
unavailableMetrics: ['cpu'],
|
||||
});
|
||||
const { result, rerender } = renderHook(({ status }) => useOverviewHistory(status, true), {
|
||||
initialProps: { status: unavailable },
|
||||
});
|
||||
await waitFor(() => expect(result.current.series.cpu).toEqual([10]));
|
||||
rerender({ status: new Status({ resourceScope: 'container', cpu: 20 }) });
|
||||
expect(result.current.series.cpu).toEqual([10, 20]);
|
||||
rerender({ status: new Status({ cpu: 50 }) });
|
||||
expect(result.current.series.cpu).toEqual([50]);
|
||||
});
|
||||
|
||||
it('ignores a legacy seed that resolves after the scope changes', async () => {
|
||||
let release!: (value: { success: boolean; obj: { t: number; v: number }[] }) => void;
|
||||
const legacy = new Promise<{ success: boolean; obj: { t: number; v: number }[] }>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let calls = 0;
|
||||
get.mockImplementation(() => {
|
||||
calls += 1;
|
||||
return calls <= 8 ? legacy : Promise.resolve({ success: true, obj: [] });
|
||||
});
|
||||
const { result, rerender } = renderHook(({ status }) => useOverviewHistory(status, true), {
|
||||
initialProps: { status: new Status({ cpu: 90 }) },
|
||||
});
|
||||
rerender({ status: new Status({ resourceScope: 'container', cpu: 10 }) });
|
||||
await act(async () => release({ success: true, obj: [{ t: 1, v: 90 }] }));
|
||||
expect(result.current.series.cpu).toEqual([10]);
|
||||
});
|
||||
|
||||
it('preserves unrelated samples and their seed when only CPU/swap scope changes', async () => {
|
||||
get.mockImplementation(async (path: string) => ({
|
||||
success: true,
|
||||
obj: [{ t: 1, v: path.includes('/netUp/') ? 7 : 5 }],
|
||||
}));
|
||||
const { result, rerender } = renderHook(({ status }) => useOverviewHistory(status, true), {
|
||||
initialProps: { status: new Status({ cpu: 90, netIO: { up: 8, down: 0 } }) },
|
||||
});
|
||||
await waitFor(() => expect(result.current.series.netUp).toEqual([7, 8]));
|
||||
get.mockClear();
|
||||
rerender({
|
||||
status: new Status({ resourceScope: 'container', cpu: 10, netIO: { up: 9, down: 0 } }),
|
||||
});
|
||||
expect(result.current.series.netUp).toEqual([7, 8, 9]);
|
||||
expect(result.current.series.cpu).toEqual([10]);
|
||||
await waitFor(() => expect(get).toHaveBeenCalledTimes(2));
|
||||
expect(get.mock.calls.map((args) => args[0]).sort()).toEqual([
|
||||
'/panel/api/server/history/cpu/2',
|
||||
'/panel/api/server/history/swap/2',
|
||||
]);
|
||||
await waitFor(() => expect(result.current.series.cpu).toEqual([5, 10]));
|
||||
expect(result.current.series.netUp).toEqual([7, 8, 9]);
|
||||
});
|
||||
|
||||
it('retains legacy padding for unrelated seed gaps while skipping missing container CPU', async () => {
|
||||
get.mockImplementation(async (path: string) => ({
|
||||
success: true,
|
||||
obj: path.includes('/netUp/')
|
||||
? [
|
||||
{ t: 1, v: 7 },
|
||||
{ t: 2, v: 8 },
|
||||
]
|
||||
: [{ t: 1, v: 5 }],
|
||||
}));
|
||||
const status = new Status({
|
||||
resourceScope: 'container',
|
||||
unavailableMetrics: ['cpu', 'swap'],
|
||||
netIO: { up: 9, down: 0 },
|
||||
});
|
||||
const { result } = renderHook(() => useOverviewHistory(status, true));
|
||||
await waitFor(() => expect(result.current.series.netUp).toEqual([7, 8, 9]));
|
||||
expect(result.current.series.mem).toEqual([5, 0, 0]);
|
||||
expect(result.current.series.cpu).toEqual([5]);
|
||||
expect(result.current.series.swap).toEqual([5]);
|
||||
});
|
||||
|
||||
it('waits for the first real status before seeding and never doubles that window', async () => {
|
||||
get.mockImplementation(async (path: string) => ({
|
||||
success: true,
|
||||
obj: path.includes('/cpu/')
|
||||
? [{ t: 1, v: 10 }]
|
||||
: path.includes('/swap/')
|
||||
? [{ t: 2, v: 6 }]
|
||||
: [
|
||||
{ t: 1, v: 5 },
|
||||
{ t: 2, v: 6 },
|
||||
],
|
||||
}));
|
||||
const { result, rerender } = renderHook(
|
||||
({ status, hasData }) => useOverviewHistory(status, hasData),
|
||||
{
|
||||
initialProps: { status: new Status(), hasData: false },
|
||||
},
|
||||
);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
rerender({
|
||||
status: new Status({ resourceScope: 'container', cpu: 20, swap: { current: 0, total: 0 } }),
|
||||
hasData: true,
|
||||
});
|
||||
await waitFor(() => expect(result.current.series.cpu).toEqual([10, 20]));
|
||||
expect(result.current.series.swap).toEqual([6, 0]);
|
||||
expect(get).toHaveBeenCalledTimes(8);
|
||||
rerender({ status: new Status({ resourceScope: 'container', cpu: 30 }), hasData: true });
|
||||
expect(result.current.series.cpu).toEqual([10, 20, 30]);
|
||||
expect(get).toHaveBeenCalledTimes(8);
|
||||
});
|
||||
|
||||
it('does not prepend the same seed after a temporary status error', async () => {
|
||||
get.mockResolvedValue({ success: true, obj: [{ t: 1, v: 10 }] });
|
||||
const status = new Status({ resourceScope: 'container', cpu: 20 });
|
||||
const { result, rerender } = renderHook(({ hasData }) => useOverviewHistory(status, hasData), {
|
||||
initialProps: { hasData: true },
|
||||
});
|
||||
await waitFor(() => expect(result.current.series.cpu).toEqual([10, 20]));
|
||||
rerender({ hasData: false });
|
||||
rerender({ hasData: true });
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(get).toHaveBeenCalledTimes(8);
|
||||
expect(result.current.series.cpu).toEqual([10, 20]);
|
||||
});
|
||||
|
||||
it('does not reseed unrelated metrics after an empty initial history', async () => {
|
||||
get.mockResolvedValue({ success: true, obj: [] });
|
||||
const { rerender } = renderHook(({ status }) => useOverviewHistory(status, true), {
|
||||
initialProps: { status: new Status({ cpu: 50 }) },
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
get.mockClear();
|
||||
rerender({ status: new Status({ resourceScope: 'container', cpu: 20 }) });
|
||||
await waitFor(() => expect(get).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('can return to a previously seeded scope while a different seed is in flight', async () => {
|
||||
get.mockResolvedValue({ success: true, obj: [{ t: 1, v: 10 }] });
|
||||
const { result, rerender } = renderHook(({ status }) => useOverviewHistory(status, true), {
|
||||
initialProps: { status: new Status({ cpu: 50 }) },
|
||||
});
|
||||
await waitFor(() => expect(result.current.series.cpu).toEqual([10, 50]));
|
||||
let release!: (value: { success: boolean; obj: { t: number; v: number }[] }) => void;
|
||||
const pending = new Promise<{ success: boolean; obj: { t: number; v: number }[] }>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
get.mockReturnValue(pending);
|
||||
rerender({ status: new Status({ resourceScope: 'container', cpu: 20 }) });
|
||||
get.mockResolvedValue({ success: true, obj: [{ t: 1, v: 10 }] });
|
||||
rerender({ status: new Status({ cpu: 60 }) });
|
||||
await waitFor(() => expect(result.current.series.cpu).toEqual([10, 60]));
|
||||
await act(async () => release({ success: true, obj: [{ t: 1, v: 99 }] }));
|
||||
expect(result.current.series.cpu).toEqual([10, 60]);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { Status } from '@/models/status';
|
||||
import { StatusSchema } from '@/schemas/status';
|
||||
|
||||
describe('container metric scope', () => {
|
||||
it('preserves backend scope and availability through validation into the UI model', () => {
|
||||
const raw = StatusSchema.parse({
|
||||
resourceScope: 'container',
|
||||
unavailableMetrics: ['cpu'],
|
||||
cpu: 0,
|
||||
cpuCores: 2,
|
||||
logicalPro: 2,
|
||||
mem: { current: 134217728, total: 2147483648 },
|
||||
swap: { current: 19357696, total: 536870912 },
|
||||
uptime: 52624,
|
||||
loads: [1.25, 2.5, 3.75],
|
||||
});
|
||||
const status = new Status(raw);
|
||||
expect(status.resourceScope).toBe('container');
|
||||
expect(status.logicalPro).toBe(2);
|
||||
expect(status.metricAvailable('cpu')).toBe(false);
|
||||
expect(status.metricAvailable('swap')).toBe(true);
|
||||
expect(status.swap.current).toBe(19357696);
|
||||
expect(status.swap.total).toBe(536870912);
|
||||
expect(status.swap.percent).toBeCloseTo(3.605688, 1);
|
||||
expect(status.loads).toEqual([1.25, 2.5, 3.75]);
|
||||
expect(status.metricAvailable('load')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps legacy responses available and distinguishes disabled from missing swap', () => {
|
||||
const legacy = new Status({ swap: { current: 0, total: 0 } });
|
||||
expect(legacy.resourceScope).toBe('');
|
||||
expect(legacy.metricAvailable('swap')).toBe(true);
|
||||
expect(legacy.swap.percent).toBe(0);
|
||||
const missing = new Status({ unavailableMetrics: ['swap'] });
|
||||
expect(missing.metricAvailable('swap')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { fireEvent, screen, within } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import NodeList from '@/pages/nodes/NodeList';
|
||||
import { NodeRecordSchema } from '@/schemas/node';
|
||||
import { renderWithProviders } from './test-utils';
|
||||
const noop = () => {};
|
||||
|
||||
describe('node metric availability', () => {
|
||||
it.each([false, true])(
|
||||
'does not present unavailable CPU as measured zero (mobile=%s)',
|
||||
(isMobile) => {
|
||||
const node = NodeRecordSchema.parse({
|
||||
id: 1,
|
||||
name: 'edge',
|
||||
status: 'online',
|
||||
enable: true,
|
||||
cpuPct: 0,
|
||||
memPct: 44.4,
|
||||
uptimeSecs: 0,
|
||||
unavailableMetrics: ['cpu', 'uptime'],
|
||||
});
|
||||
renderWithProviders(
|
||||
<NodeList
|
||||
nodes={[node]}
|
||||
isMobile={isMobile}
|
||||
selectedIds={[]}
|
||||
onSelectionChange={noop}
|
||||
onAdd={noop}
|
||||
onMtls={noop}
|
||||
onEdit={noop}
|
||||
onDelete={noop}
|
||||
onProbe={noop}
|
||||
onToggleEnable={noop}
|
||||
onUpdateNode={noop}
|
||||
onUpdateSelected={noop}
|
||||
/>,
|
||||
);
|
||||
if (isMobile) fireEvent.click(screen.getByRole('button', { name: 'More Information' }));
|
||||
expect(screen.queryByText('0.0%')).toBeNull();
|
||||
expect(screen.getAllByText('—').length).toBeGreaterThanOrEqual(2);
|
||||
},
|
||||
);
|
||||
it('keeps a measured zero CPU and an unprobed uptime apart', () => {
|
||||
const node = NodeRecordSchema.parse({
|
||||
id: 1,
|
||||
name: 'edge',
|
||||
status: 'online',
|
||||
enable: true,
|
||||
cpuPct: 0,
|
||||
memPct: 44.4,
|
||||
uptimeSecs: 0,
|
||||
});
|
||||
renderWithProviders(
|
||||
<NodeList
|
||||
nodes={[node]}
|
||||
selectedIds={[]}
|
||||
onSelectionChange={noop}
|
||||
onAdd={noop}
|
||||
onMtls={noop}
|
||||
onEdit={noop}
|
||||
onDelete={noop}
|
||||
onProbe={noop}
|
||||
onToggleEnable={noop}
|
||||
onUpdateNode={noop}
|
||||
onUpdateSelected={noop}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('0.0%')).not.toBeNull();
|
||||
const headers = screen.getAllByRole('columnheader').map((h) => h.textContent);
|
||||
const cells = within(screen.getAllByRole('row')[1]).getAllByRole('cell');
|
||||
expect(cells[headers.indexOf('Uptime')].textContent).toBe('-');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user