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
+1 -4
View File
@@ -487,9 +487,6 @@ func (g *GatewayClient) sendStatus(ctx context.Context) {
hostname = "3x-ui"
}
days := status.Uptime / 86400
hours := (status.Uptime % 86400) / 3600
var onlines []string
if process := service.XrayProcess(); process != nil && process.IsRunning() {
onlines = process.GetOnlineClients()
@@ -515,7 +512,7 @@ func (g *GatewayClient) sendStatus(ctx context.Context) {
Fields: []EmbedField{
{Name: tr("discord.fields.panelVersion"), Value: config.GetPanelVersion(), Inline: true},
{Name: tr("discord.fields.xrayCore"), Value: fmt.Sprintf("%s (%s)", status.Xray.Version, status.Xray.State), Inline: true},
{Name: tr("pages.index.uptime"), Value: tr("discord.values.uptime", "Days=="+fmt.Sprint(days), "Hours=="+fmt.Sprint(hours)), Inline: true},
{Name: tr("pages.index.uptime"), Value: formatStatusUptime(status, tr), Inline: true},
{Name: tr("discord.fields.systemLoad"), Value: fmt.Sprintf("%.2f, %.2f, %.2f", load1, load2, load3), Inline: true},
{Name: tr("pages.index.memory"), Value: fmt.Sprintf("%s / %s", common.FormatTraffic(int64(status.Mem.Current)), common.FormatTraffic(int64(status.Mem.Total))), Inline: true},
{Name: tr("pages.index.historyTitleOnline"), Value: strconv.Itoa(len(onlines)), Inline: true},
+9 -3
View File
@@ -102,9 +102,7 @@ func (s *DiscordService) BuildReport(ctx context.Context, server ServerProvider,
}
tr := translator(s.settingService)
days := status.Uptime / 86400
hours := (status.Uptime % 86400) / 3600
uptimeStr := tr("discord.values.uptime", "Days=="+fmt.Sprint(days), "Hours=="+fmt.Sprint(hours))
uptimeStr := formatStatusUptime(status, tr)
ramStr := fmt.Sprintf("%s / %s", common.FormatTraffic(int64(status.Mem.Current)), common.FormatTraffic(int64(status.Mem.Total)))
trafficStr := tr("discord.values.traffic",
@@ -239,3 +237,11 @@ func getInterfaceIPs() (ipv4, ipv6 string) {
}
return strings.Join(v4s, ", "), strings.Join(v6s, ", ")
}
// formatStatusUptime is shared by scheduled reports and the status command.
func formatStatusUptime(status *service.Status, tr func(string, ...string) string) string {
if !status.MetricAvailable("uptime") {
return "—"
}
return tr("discord.values.uptime", "Days=="+fmt.Sprint(status.Uptime/86400), "Hours=="+fmt.Sprint((status.Uptime%86400)/3600))
}
@@ -229,3 +229,61 @@ func TestSendReport_DeliversEmbedWhenBackupUploadIsRejected(t *testing.T) {
t.Fatal("report embed was never delivered: it rode on the rejected backup upload")
}
}
func TestBuildReportShowsUnavailableUptime(t *testing.T) {
for _, unavailable := range []bool{true, false} {
t.Run(map[bool]string{true: "unavailable", false: "valid zero"}[unavailable], func(t *testing.T) {
settings := setupTestDB(t)
_ = settings.SetDiscordLang("en-US")
_ = settings.SetDiscordBotBackup(false)
status := &service.Status{Loads: []float64{0, 0, 0}}
if unavailable {
status.UnavailableMetrics = []string{"uptime"}
}
server := &mockServerProvider{status: status}
svc := NewDiscordService(settings)
report, _, err := svc.BuildReport(context.Background(), server, nil)
if err != nil {
t.Fatal(err)
}
want := "0d 0h"
if unavailable {
want = "—"
}
check := func(fields []EmbedField) {
t.Helper()
for _, f := range fields {
if f.Name == "Uptime" {
if f.Value != want {
t.Fatalf("uptime=%q want %q", f.Value, want)
}
return
}
}
t.Fatal("uptime field missing")
}
check(report.Embeds[0].Fields)
_ = settings.SetDiscordBotToken("synthetic-test")
_ = settings.SetDiscordChannelId("synthetic-channel")
sent := make(chan MessagePayload, 1)
rest := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var payload MessagePayload
_ = json.NewDecoder(r.Body).Decode(&payload)
sent <- payload
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"synthetic-message"}`))
}))
defer rest.Close()
svc.SetBaseURL(rest.URL)
svc.SetHTTPClient(rest.Client())
g := NewGatewayClient(svc, settings, server, nil, nil)
g.sendStatus(context.Background())
select {
case msg := <-sent:
check(msg.Embeds[0].Fields)
default:
t.Fatal("no local status payload")
}
})
}
}