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:
VAIO73
2026-10-24 20:24:34 +02:00
committed by GitHub
parent 2c18cbe1cd
commit 746bdbe9f4
34 changed files with 1710 additions and 131 deletions
@@ -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);
},
};
+7 -1
View File
@@ -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)}`}
+6 -2
View File
@@ -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>
+7 -5
View File
@@ -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">
+35 -7
View File
@@ -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;
});
+17 -6
View File
@@ -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>