mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 19:25:05 +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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user