mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-25 07:15:01 +03:00
746bdbe9f4
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.
248 lines
7.8 KiB
Go
248 lines
7.8 KiB
Go
package discord
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
)
|
|
|
|
// ServerProvider abstracts server status and database backup operations.
|
|
type ServerProvider interface {
|
|
GetStatus(lastStatus *service.Status) *service.Status
|
|
GetDb() ([]byte, error)
|
|
BackupFilename(requestHost string) string
|
|
}
|
|
|
|
// InboundProvider abstracts inbound management operations.
|
|
type InboundProvider interface {
|
|
GetAllInbounds() ([]*model.Inbound, error)
|
|
}
|
|
|
|
// BuildReport constructs the status report payload and backup attachments.
|
|
func (s *DiscordService) BuildReport(ctx context.Context, server ServerProvider, inbound InboundProvider) (MessagePayload, []FileAttachment, error) {
|
|
hostname, _ := os.Hostname()
|
|
if hostname == "" {
|
|
hostname = "3x-ui"
|
|
}
|
|
|
|
var status *service.Status
|
|
if server != nil {
|
|
status = server.GetStatus(nil)
|
|
}
|
|
if status == nil {
|
|
status = &service.Status{
|
|
Loads: []float64{0, 0, 0},
|
|
}
|
|
status.Xray.State = service.ProcessState("unknown")
|
|
status.Xray.Version = "unknown"
|
|
}
|
|
|
|
var onlines []string
|
|
if process := service.XrayProcess(); process != nil && process.IsRunning() {
|
|
onlines = process.GetOnlineClients()
|
|
}
|
|
|
|
trDiff := int64(0)
|
|
exDiff := int64(0)
|
|
now := time.Now().Unix() * 1000
|
|
|
|
trafficThreshold, err := s.settingService.GetTrafficDiff()
|
|
if err == nil && trafficThreshold > 0 {
|
|
trDiff = int64(trafficThreshold) * 1073741824
|
|
}
|
|
expireThreshold, err := s.settingService.GetExpireDiff()
|
|
if err == nil && expireThreshold > 0 {
|
|
exDiff = int64(expireThreshold) * 86400000
|
|
}
|
|
|
|
var totalInbounds, disabledInbounds, exhaustedInbounds int
|
|
var totalClients, disabledClients, exhaustedClients int
|
|
seenClients := make(map[string]bool)
|
|
|
|
if inbound != nil {
|
|
inbounds, err := inbound.GetAllInbounds()
|
|
if err != nil {
|
|
logger.Warning("Discord report: unable to load inbounds: ", err)
|
|
} else {
|
|
totalInbounds = len(inbounds)
|
|
for _, in := range inbounds {
|
|
if !in.Enable {
|
|
disabledInbounds++
|
|
} else if (in.ExpiryTime > 0 && (in.ExpiryTime-now < exDiff)) ||
|
|
(in.Total > 0 && (in.Total-(in.Up+in.Down) < trDiff)) {
|
|
exhaustedInbounds++
|
|
}
|
|
|
|
for _, client := range in.ClientStats {
|
|
if seenClients[client.Email] {
|
|
continue
|
|
}
|
|
seenClients[client.Email] = true
|
|
totalClients++
|
|
if !client.Enable {
|
|
disabledClients++
|
|
} else if (client.ExpiryTime > 0 && (client.ExpiryTime-now < exDiff)) ||
|
|
(client.Total > 0 && (client.Total-(client.Up+client.Down) < trDiff)) {
|
|
exhaustedClients++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
tr := translator(s.settingService)
|
|
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",
|
|
"Up=="+common.FormatTraffic(int64(status.NetTraffic.Sent)),
|
|
"Down=="+common.FormatTraffic(int64(status.NetTraffic.Recv)),
|
|
"Total=="+common.FormatTraffic(int64(status.NetTraffic.Sent+status.NetTraffic.Recv)),
|
|
)
|
|
load1, load2, load3 := 0.0, 0.0, 0.0
|
|
if len(status.Loads) > 0 {
|
|
load1 = status.Loads[0]
|
|
}
|
|
if len(status.Loads) > 1 {
|
|
load2 = status.Loads[1]
|
|
}
|
|
if len(status.Loads) > 2 {
|
|
load3 = status.Loads[2]
|
|
}
|
|
loadStr := fmt.Sprintf("%.2f, %.2f, %.2f", load1, load2, load3)
|
|
|
|
fields := []EmbedField{
|
|
{Name: tr("host"), Value: hostname, Inline: true},
|
|
{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: uptimeStr, Inline: true},
|
|
{Name: tr("discord.fields.systemLoad"), Value: loadStr, Inline: true},
|
|
{Name: tr("pages.index.memory"), Value: ramStr, Inline: true},
|
|
{Name: tr("discord.fields.networkTraffic"), Value: trafficStr, Inline: false},
|
|
{Name: tr("pages.index.historyTabConnections"), Value: fmt.Sprintf("TCP: %d | UDP: %d", status.TcpCount, status.UdpCount), Inline: true},
|
|
{Name: tr("pages.index.historyTitleOnline"), Value: strconv.Itoa(len(onlines)), Inline: true},
|
|
{Name: tr("tgbot.inbounds"), Value: tr("discord.values.counts", "Total=="+strconv.Itoa(totalInbounds), "Depleting=="+strconv.Itoa(exhaustedInbounds), "Disabled=="+strconv.Itoa(disabledInbounds)), Inline: false},
|
|
{Name: tr("clients"), Value: tr("discord.values.counts", "Total=="+strconv.Itoa(totalClients), "Depleting=="+strconv.Itoa(exhaustedClients), "Disabled=="+strconv.Itoa(disabledClients)), Inline: false},
|
|
}
|
|
|
|
ipv4, ipv6 := getInterfaceIPs()
|
|
if ipv4 != "" {
|
|
fields = append(fields, EmbedField{Name: "IPv4", Value: ipv4, Inline: true})
|
|
}
|
|
if ipv6 != "" {
|
|
fields = append(fields, EmbedField{Name: "IPv6", Value: ipv6, Inline: true})
|
|
}
|
|
|
|
runTime, _ := s.settingService.GetDiscordRunTime()
|
|
if runTime == "" {
|
|
runTime = "@daily"
|
|
}
|
|
|
|
embed := Embed{
|
|
Title: tr("discord.report.title"),
|
|
Description: tr("discord.report.summary", "Host=="+hostname),
|
|
Color: ColorBlue,
|
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
|
Fields: fields,
|
|
Footer: &EmbedFooter{
|
|
Text: tr("discord.report.footer", "RunTime=="+runTime),
|
|
},
|
|
}
|
|
|
|
payload := MessagePayload{
|
|
Embeds: []Embed{embed},
|
|
}
|
|
|
|
var files []FileAttachment
|
|
backupEnabled, err := s.settingService.GetDiscordBotBackup()
|
|
if err == nil && backupEnabled && server != nil {
|
|
dbData, err := server.GetDb()
|
|
if err != nil {
|
|
logger.Warning("Discord report: failed to get DB backup: ", err)
|
|
} else if len(dbData) > 0 {
|
|
filename := server.BackupFilename("")
|
|
if filename == "" {
|
|
filename = "x-ui.db"
|
|
}
|
|
files = append(files, FileAttachment{
|
|
Filename: filename,
|
|
Data: dbData,
|
|
})
|
|
}
|
|
|
|
configPath := xray.GetConfigPath()
|
|
if configData, err := os.ReadFile(configPath); err == nil && len(configData) > 0 {
|
|
files = append(files, FileAttachment{
|
|
Filename: "config.json",
|
|
Data: configData,
|
|
})
|
|
}
|
|
}
|
|
|
|
return payload, files, nil
|
|
}
|
|
|
|
// SendReport generates and sends the periodic report to Discord.
|
|
func (s *DiscordService) SendReport(ctx context.Context, server ServerProvider, inbound InboundProvider) error {
|
|
payload, files, err := s.BuildReport(ctx, server, inbound)
|
|
if err != nil {
|
|
return fmt.Errorf("build discord report: %w", err)
|
|
}
|
|
// Separate messages: a backup over Discord's upload cap must not drop the report with it.
|
|
if err := s.SendMessage(ctx, payload); err != nil {
|
|
return err
|
|
}
|
|
if len(files) == 0 {
|
|
return nil
|
|
}
|
|
return s.SendMessageWithFiles(ctx, MessagePayload{}, files...)
|
|
}
|
|
|
|
func getInterfaceIPs() (ipv4, ipv6 string) {
|
|
netInterfaces, err := net.Interfaces()
|
|
if err != nil {
|
|
return "", ""
|
|
}
|
|
var v4s, v6s []string
|
|
for _, iface := range netInterfaces {
|
|
if (iface.Flags&net.FlagUp) == 0 || (iface.Flags&net.FlagLoopback) != 0 {
|
|
continue
|
|
}
|
|
addrs, err := iface.Addrs()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, addr := range addrs {
|
|
ipnet, ok := addr.(*net.IPNet)
|
|
if !ok || ipnet.IP.IsLoopback() {
|
|
continue
|
|
}
|
|
if ip := ipnet.IP.To4(); ip != nil {
|
|
v4s = append(v4s, ip.String())
|
|
} else if ip := ipnet.IP.To16(); ip != nil && !ipnet.IP.IsLinkLocalUnicast() {
|
|
v6s = append(v6s, ip.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))
|
|
}
|