From 49b13af91c680a526f80853dc4843b0f44ef54d3 Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Mon, 8 Jun 2026 20:10:34 +0800 Subject: [PATCH] release: v1.1.5 --- backend/internal/api/disk_stat_fallback.go | 7 + backend/internal/api/disk_stat_linux.go | 21 + backend/internal/api/host.go | 1212 +++++++++++++++++++- backend/internal/server/server.go | 2 + backend/internal/server/web/.gitkeep | 2 +- backend/internal/version/version.go | 2 +- frontend/package.json | 2 +- frontend/src/App.tsx | 2 + frontend/src/components/Sidebar.tsx | 14 + frontend/src/pages/HostReport.tsx | 328 ++++++ frontend/src/pages/Login.tsx | 2 +- frontend/src/pages/Settings.tsx | 126 +- frontend/src/services/api.ts | 100 ++ 13 files changed, 1745 insertions(+), 75 deletions(-) create mode 100644 backend/internal/api/disk_stat_fallback.go create mode 100644 backend/internal/api/disk_stat_linux.go create mode 100644 frontend/src/pages/HostReport.tsx diff --git a/backend/internal/api/disk_stat_fallback.go b/backend/internal/api/disk_stat_fallback.go new file mode 100644 index 0000000..2537115 --- /dev/null +++ b/backend/internal/api/disk_stat_fallback.go @@ -0,0 +1,7 @@ +//go:build !linux + +package api + +func getRootDiskInfo() (DiskInfo, bool) { + return DiskInfo{}, false +} diff --git a/backend/internal/api/disk_stat_linux.go b/backend/internal/api/disk_stat_linux.go new file mode 100644 index 0000000..4bc6a61 --- /dev/null +++ b/backend/internal/api/disk_stat_linux.go @@ -0,0 +1,21 @@ +//go:build linux + +package api + +import "golang.org/x/sys/unix" + +func getRootDiskInfo() (DiskInfo, bool) { + var stat unix.Statfs_t + if err := unix.Statfs("/", &stat); err != nil { + return DiskInfo{}, false + } + + total := float64(int64(stat.Blocks)*int64(stat.Bsize)) / (1024 * 1024 * 1024) + free := float64(int64(stat.Bavail)*int64(stat.Bsize)) / (1024 * 1024 * 1024) + + return DiskInfo{ + TotalGB: total, + UsedGB: total - free, + FreeGB: free, + }, true +} diff --git a/backend/internal/api/host.go b/backend/internal/api/host.go index 5f41680..c189488 100644 --- a/backend/internal/api/host.go +++ b/backend/internal/api/host.go @@ -2,17 +2,22 @@ package api import ( "bufio" + "context" + "encoding/json" + "fmt" + "net" + "net/http" "os" "os/exec" + "path/filepath" "runtime" + "sort" "strconv" "strings" "sync" "time" "clicd/internal/lxc" - - "golang.org/x/sys/unix" ) type HostInfo struct { @@ -24,6 +29,148 @@ type HostInfo struct { Load LoadInfo `json:"load"` } +type HostProbeReport struct { + GeneratedAt string `json:"generated_at"` + Hostname string `json:"hostname"` + Kernel string `json:"kernel"` + OS string `json:"os"` + CPU HostCPUProbe `json:"cpu"` + Memory HostMemoryProbe `json:"memory"` + Disks []HostDiskProbe `json:"disks"` + NetworkInterfaces []HostNICProbe `json:"network_interfaces"` + PublicIPv4 []string `json:"public_ipv4"` + IPv4Addresses []HostIPProbe `json:"ipv4_addresses"` + IPv4Prefixes []HostIPv4PrefixProbe `json:"ipv4_prefixes"` + IPv6Addresses []HostIPProbe `json:"ipv6_addresses"` + IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"` + Gateways []HostGatewayProbe `json:"gateways"` + GPUs []HostGPUProbe `json:"gpus"` + Runtime HostRuntimeProbe `json:"runtime"` + System HostSystemProbe `json:"system"` + Environment []HostEnvCheck `json:"environment"` +} + +type HostCPUProbe struct { + Model string `json:"model"` + Cores int `json:"cores"` + Threads int `json:"threads"` + Architecture string `json:"architecture"` + Flags []string `json:"flags"` + HasIntegratedGPU bool `json:"has_integrated_gpu"` + Virtualization bool `json:"virtualization"` + VirtualizationKey string `json:"virtualization_key"` +} + +type HostMemoryProbe struct { + TotalMB int64 `json:"total_mb"` + UsedMB int64 `json:"used_mb"` + FreeMB int64 `json:"free_mb"` + Modules []HostMemoryModule `json:"modules"` +} + +type HostMemoryModule struct { + Locator string `json:"locator"` + Size string `json:"size"` + Type string `json:"type"` + Speed string `json:"speed"` + Manufacturer string `json:"manufacturer"` + PartNumber string `json:"part_number"` + SerialNumber string `json:"serial_number"` +} + +type HostDiskProbe struct { + Name string `json:"name"` + Path string `json:"path"` + Model string `json:"model"` + Serial string `json:"serial"` + SizeBytes uint64 `json:"size_bytes"` + Type string `json:"type"` + Rotational bool `json:"rotational"` + Mountpoints []string `json:"mountpoints"` + Health string `json:"health"` + HealthDetail string `json:"health_detail"` + SMART HostDiskSMARTProbe `json:"smart"` +} + +type HostDiskSMARTProbe struct { + Available bool `json:"available"` + LifeUsedPercent *int `json:"life_used_percent,omitempty"` + PowerOnHours int64 `json:"power_on_hours,omitempty"` + PowerCycleCount int64 `json:"power_cycle_count,omitempty"` + ReadDataBytes uint64 `json:"read_data_bytes,omitempty"` + WrittenDataBytes uint64 `json:"written_data_bytes,omitempty"` + ReadCommands uint64 `json:"read_commands,omitempty"` + WriteCommands uint64 `json:"write_commands,omitempty"` + WearLevelingCount string `json:"wear_leveling_count,omitempty"` + EraseCount string `json:"erase_count,omitempty"` + MediaErrors uint64 `json:"media_errors,omitempty"` +} + +type HostNICProbe struct { + Name string `json:"name"` + MAC string `json:"mac"` + State string `json:"state"` + SpeedMbps int `json:"speed_mbps"` + Driver string `json:"driver"` + Model string `json:"model"` + IPv4 []HostIPProbe `json:"ipv4"` + IPv6 []HostIPProbe `json:"ipv6"` +} + +type HostIPProbe struct { + Interface string `json:"interface"` + Address string `json:"address"` + PrefixLen int `json:"prefix_len"` + Scope string `json:"scope"` + Gateway string `json:"gateway,omitempty"` +} + +type HostIPv4PrefixProbe struct { + Interface string `json:"interface"` + Address string `json:"address"` + Prefix string `json:"prefix"` + PrefixLen int `json:"prefix_len"` + SubnetMask string `json:"subnet_mask"` + Gateway string `json:"gateway"` + Source string `json:"source"` +} + +type HostGatewayProbe struct { + Family string `json:"family"` + Interface string `json:"interface"` + Gateway string `json:"gateway"` +} + +type HostGPUProbe struct { + Name string `json:"name"` + Vendor string `json:"vendor"` + Driver string `json:"driver"` + Type string `json:"type"` +} + +type HostRuntimeProbe struct { + LXCAvailable bool `json:"lxc_available"` + KVMAvailable bool `json:"kvm_available"` + DevKVM bool `json:"dev_kvm"` + NestedVirtualization bool `json:"nested_virtualization"` + NestedDetail string `json:"nested_detail"` + SupportMode string `json:"support_mode"` +} + +type HostSystemProbe struct { + UptimeSeconds int64 `json:"uptime_seconds"` + UptimeText string `json:"uptime_text"` + ProcessCount int `json:"process_count"` +} + +type HostEnvCheck struct { + Key string `json:"key"` + Label string `json:"label"` + OK bool `json:"ok"` + Required bool `json:"required"` + Detail string `json:"detail"` +} + type LoadInfo struct { Load1 float64 `json:"load1"` Load5 float64 `json:"load5"` @@ -136,8 +283,11 @@ func getMemoryInfo() MemoryInfo { } func getDiskInfo() DiskInfo { - var stat unix.Statfs_t - if err := unix.Statfs("/", &stat); err != nil { + if info, ok := getRootDiskInfo(); ok { + return info + } + + { // Try command-based fallback cmd := exec.Command("df", "-BG", "/") output, err := cmd.Output() @@ -155,16 +305,6 @@ func getDiskInfo() DiskInfo { } return DiskInfo{} } - - total := float64(int64(stat.Blocks)*int64(stat.Bsize)) / (1024 * 1024 * 1024) - free := float64(int64(stat.Bavail)*int64(stat.Bsize)) / (1024 * 1024 * 1024) - used := total - free - - return DiskInfo{ - TotalGB: total, - UsedGB: used, - FreeGB: free, - } } func getCPUUsage() float64 { @@ -375,3 +515,1047 @@ func getLoadInfo() LoadInfo { load15, _ := strconv.ParseFloat(fields[2], 64) return LoadInfo{Load1: load1, Load5: load5, Load15: load15} } + +func HandleHostReport(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + if !requireScope(w, r, "host:read") { + return + } + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: getHostProbeReport()}) +} + +func getHostProbeReport() HostProbeReport { + host, _ := os.Hostname() + mem := getMemoryInfo() + report := HostProbeReport{ + GeneratedAt: time.Now().Format("2006-01-02 15:04:05"), + Hostname: host, + Kernel: strings.TrimSpace(runCommandOutput(2*time.Second, "uname", "-srmo")), + OS: detectOSRelease(), + CPU: detectHostCPUProbe(), + Memory: HostMemoryProbe{TotalMB: mem.TotalMB, UsedMB: mem.UsedMB, FreeMB: mem.FreeMB, Modules: detectMemoryModules()}, + Disks: detectHostDisks(), + NetworkInterfaces: detectHostNICs(), + PublicIPv4: detectAllPublicIPv4(), + IPv6Prefixes: lxc.DetectPublicIPv6Prefixes(), + Gateways: detectGateways(), + GPUs: detectGPUs(), + System: detectSystemProbe(), + Environment: detectHostEnvironment(), + } + report.IPv6Addresses = collectIPv6Addresses(report.NetworkInterfaces) + report.IPv4Addresses = collectIPv4Addresses(report.NetworkInterfaces) + report.IPv4Prefixes = detectPublicIPv4Prefixes(report.NetworkInterfaces, report.Gateways) + report.Runtime = detectRuntimeProbe(report.Environment) + report.CPU.HasIntegratedGPU = hasIntegratedGPU(report.GPUs) + return report +} + +func detectOSRelease() string { + values := readKeyValueFile("/etc/os-release", "=") + if pretty := trimOSReleaseValue(values["PRETTY_NAME"]); pretty != "" { + return pretty + } + if name := trimOSReleaseValue(values["NAME"]); name != "" { + return name + } + return strings.TrimSpace(runCommandOutput(2*time.Second, "uname", "-o")) +} + +func trimOSReleaseValue(value string) string { + return strings.Trim(strings.TrimSpace(value), `"`) +} + +func detectHostCPUProbe() HostCPUProbe { + probe := HostCPUProbe{Cores: runtime.NumCPU(), Threads: runtime.NumCPU(), Architecture: runtime.GOARCH} + if data, err := os.ReadFile("/proc/cpuinfo"); err == nil { + seenFlags := map[string]bool{} + for _, line := range strings.Split(string(data), "\n") { + fields := strings.SplitN(line, ":", 2) + if len(fields) != 2 { + continue + } + key := strings.TrimSpace(fields[0]) + value := strings.TrimSpace(fields[1]) + switch key { + case "model name", "Hardware", "Processor": + if probe.Model == "" { + probe.Model = value + } + case "cpu cores": + if cores, err := strconv.Atoi(value); err == nil && cores > probe.Cores { + probe.Cores = cores + } + case "flags", "Features": + for _, flag := range strings.Fields(value) { + if flag == "vmx" || flag == "svm" { + probe.Virtualization = true + probe.VirtualizationKey = flag + } + if !seenFlags[flag] { + seenFlags[flag] = true + probe.Flags = append(probe.Flags, flag) + } + } + } + } + sort.Strings(probe.Flags) + } + if probe.Model == "" { + probe.Model = "Unknown" + } + return probe +} + +func detectMemoryModules() []HostMemoryModule { + if !commandExists("dmidecode") { + return nil + } + out := runCommandOutput(4*time.Second, "dmidecode", "-t", "memory") + modules := make([]HostMemoryModule, 0) + var current HostMemoryModule + inDevice := false + flush := func() { + if !inDevice { + return + } + if current.Size != "" && !strings.EqualFold(current.Size, "No Module Installed") { + modules = append(modules, current) + } + current = HostMemoryModule{} + } + for _, line := range strings.Split(out, "\n") { + if strings.HasPrefix(line, "Memory Device") { + flush() + inDevice = true + continue + } + if !inDevice { + continue + } + parts := strings.SplitN(strings.TrimSpace(line), ":", 2) + if len(parts) != 2 { + continue + } + key, value := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) + switch key { + case "Locator": + current.Locator = value + case "Size": + current.Size = value + case "Type": + current.Type = value + case "Speed": + current.Speed = value + case "Manufacturer": + current.Manufacturer = value + case "Part Number": + current.PartNumber = value + case "Serial Number": + current.SerialNumber = value + } + } + flush() + return modules +} + +func detectHostDisks() []HostDiskProbe { + disks := make([]HostDiskProbe, 0) + entries, err := os.ReadDir("/sys/block") + if err != nil { + return disks + } + mounts := detectMountpointsByDevice() + for _, entry := range entries { + name := entry.Name() + if strings.HasPrefix(name, "loop") || strings.HasPrefix(name, "ram") || strings.HasPrefix(name, "fd") || strings.HasPrefix(name, "sr") { + continue + } + base := filepath.Join("/sys/block", name) + path := "/dev/" + name + disk := HostDiskProbe{ + Name: name, + Path: path, + Model: strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "device/model"), filepath.Join(base, "device/name"))), + Serial: strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "device/serial"), filepath.Join(base, "serial"))), + SizeBytes: readUintFile(filepath.Join(base, "size")) * 512, + Type: detectDiskType(base, name), + Rotational: strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "queue/rotational"))) == "1", + Mountpoints: mounts[name], + } + disk.SMART = detectDiskSMART(path) + disk.Health = disk.SMARTHealth() + disk.HealthDetail = disk.SMARTDetail() + disks = append(disks, disk) + } + sort.Slice(disks, func(i, j int) bool { return disks[i].Name < disks[j].Name }) + return disks +} + +func detectDiskType(base, name string) string { + if strings.HasPrefix(name, "nvme") { + return "NVMe" + } + if strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "queue/rotational"))) == "1" { + return "HDD" + } + return "SSD" +} + +func (disk HostDiskProbe) SMARTHealth() string { + if disk.SMART.Available && disk.Health != "" { + return disk.Health + } + return disk.SMART.Health() +} + +func (disk HostDiskProbe) SMARTDetail() string { + return disk.SMART.Detail() +} + +func (smart HostDiskSMARTProbe) Health() string { + if !smart.Available { + return "unknown" + } + if smart.MediaErrors > 0 { + return "failed" + } + return "ok" +} + +func (smart HostDiskSMARTProbe) Detail() string { + if !smart.Available { + return "smartctl not installed or no SMART output" + } + parts := []string{"SMART passed"} + if smart.LifeUsedPercent != nil { + parts = append(parts, fmt.Sprintf("寿命已用 %d%%", *smart.LifeUsedPercent)) + } + if smart.PowerOnHours > 0 { + parts = append(parts, fmt.Sprintf("通电 %dh", smart.PowerOnHours)) + } + if smart.WrittenDataBytes > 0 { + parts = append(parts, fmt.Sprintf("写入 %s", formatBytesText(smart.WrittenDataBytes))) + } + if smart.ReadDataBytes > 0 { + parts = append(parts, fmt.Sprintf("读取 %s", formatBytesText(smart.ReadDataBytes))) + } + if smart.MediaErrors > 0 { + parts = append(parts, fmt.Sprintf("介质错误 %d", smart.MediaErrors)) + } + return strings.Join(parts, " | ") +} + +func detectDiskSMART(path string) HostDiskSMARTProbe { + smart := HostDiskSMARTProbe{} + if !commandExists("smartctl") { + return smart + } + out := runCommandOutput(8*time.Second, "smartctl", "-a", "-j", path) + if strings.TrimSpace(out) == "" { + return smart + } + var data smartctlOutput + if err := json.Unmarshal([]byte(out), &data); err != nil { + return smart + } + smart.Available = true + smart.PowerOnHours = data.PowerOnTime.Hours + smart.PowerCycleCount = data.PowerCycleCount + if data.NVMe.PowerOnHours > 0 { + smart.PowerOnHours = int64(data.NVMe.PowerOnHours) + } + if data.NVMe.PowerCycles > 0 { + smart.PowerCycleCount = int64(data.NVMe.PowerCycles) + } + if data.NVMe.PercentageUsed > 0 { + used := int(data.NVMe.PercentageUsed) + smart.LifeUsedPercent = &used + } + smart.ReadDataBytes = data.NVMe.DataUnitsRead * 512000 + smart.WrittenDataBytes = data.NVMe.DataUnitsWritten * 512000 + smart.ReadCommands = data.NVMe.HostReadCommands + smart.WriteCommands = data.NVMe.HostWriteCommands + smart.MediaErrors = data.NVMe.MediaErrors + + parseATAAttributes(&smart, data.ATASmartAttributes.Table) + if data.SmartStatus != nil && !data.SmartStatus.Passed { + smart.MediaErrors++ + } + return smart +} + +type smartctlOutput struct { + SmartStatus *struct { + Passed bool `json:"passed"` + } `json:"smart_status"` + PowerOnTime struct { + Hours int64 `json:"hours"` + } `json:"power_on_time"` + PowerCycleCount int64 `json:"power_cycle_count"` + ATASmartAttributes struct { + Table []smartctlAttribute `json:"table"` + } `json:"ata_smart_attributes"` + NVMe struct { + PercentageUsed uint64 `json:"percentage_used"` + DataUnitsRead uint64 `json:"data_units_read"` + DataUnitsWritten uint64 `json:"data_units_written"` + HostReadCommands uint64 `json:"host_reads"` + HostWriteCommands uint64 `json:"host_writes"` + PowerOnHours uint64 `json:"power_on_hours"` + PowerCycles uint64 `json:"power_cycles"` + MediaErrors uint64 `json:"media_errors"` + } `json:"nvme_smart_health_information_log"` +} + +type smartctlAttribute struct { + ID int `json:"id"` + Name string `json:"name"` + Value int `json:"value"` + Raw struct { + Value json.Number `json:"value"` + String string `json:"string"` + } `json:"raw"` +} + +func parseATAAttributes(smart *HostDiskSMARTProbe, attrs []smartctlAttribute) { + for _, attr := range attrs { + name := normalizeSMARTAttrName(attr.Name) + raw := smartAttrRawUint(attr) + rawText := smartAttrRawText(attr) + switch name { + case "poweronhours": + if smart.PowerOnHours == 0 { + smart.PowerOnHours = int64(raw) + } + case "powercyclecount": + if smart.PowerCycleCount == 0 { + smart.PowerCycleCount = int64(raw) + } + case "totallbaswritten": + if smart.WrittenDataBytes == 0 { + smart.WrittenDataBytes = raw * 512 + } + case "totallbasread": + if smart.ReadDataBytes == 0 { + smart.ReadDataBytes = raw * 512 + } + case "hostwrites32mib": + if smart.WrittenDataBytes == 0 { + smart.WrittenDataBytes = raw * 32 * 1024 * 1024 + } + case "hostreads32mib": + if smart.ReadDataBytes == 0 { + smart.ReadDataBytes = raw * 32 * 1024 * 1024 + } + case "hostwritecommands": + smart.WriteCommands = raw + case "hostreadcommands": + smart.ReadCommands = raw + case "wearlevelingcount": + smart.WearLevelingCount = rawText + if smart.LifeUsedPercent == nil && attr.Value > 0 && attr.Value <= 100 { + used := 100 - attr.Value + if used < 0 { + used = 0 + } + smart.LifeUsedPercent = &used + } + case "percentlifetimeremain", "mediawearoutindicator": + if smart.LifeUsedPercent == nil { + remaining := attr.Value + if raw > 0 && raw <= 100 { + remaining = int(raw) + } + used := 100 - remaining + if used < 0 { + used = 0 + } + if used <= 100 { + smart.LifeUsedPercent = &used + } + } + case "percentageused": + if smart.LifeUsedPercent == nil && raw <= 255 { + used := int(raw) + smart.LifeUsedPercent = &used + } + case "erasefailcounttotal", "erasecount", "nandwrites", "programfailcnttotal": + if smart.EraseCount == "" && rawText != "" { + smart.EraseCount = rawText + } + case "mediaerrors": + smart.MediaErrors = raw + } + } +} + +func normalizeSMARTAttrName(name string) string { + name = strings.ToLower(name) + var b strings.Builder + for _, ch := range name { + if ch >= 'a' && ch <= 'z' || ch >= '0' && ch <= '9' { + b.WriteRune(ch) + } + } + return b.String() +} + +func smartAttrRawText(attr smartctlAttribute) string { + if attr.Raw.String != "" { + return attr.Raw.String + } + if attr.Raw.Value != "" { + return attr.Raw.Value.String() + } + return "" +} + +func smartAttrRawUint(attr smartctlAttribute) uint64 { + text := smartAttrRawText(attr) + if value, err := strconv.ParseUint(text, 10, 64); err == nil { + return value + } + digits := firstUintText(text) + if digits == "" { + return 0 + } + value, _ := strconv.ParseUint(digits, 10, 64) + return value +} + +func firstUintText(value string) string { + start := -1 + for i, ch := range value { + if ch >= '0' && ch <= '9' { + if start < 0 { + start = i + } + continue + } + if start >= 0 { + return value[start:i] + } + } + if start >= 0 { + return value[start:] + } + return "" +} + +func detectMountpointsByDevice() map[string][]string { + result := map[string][]string{} + f, err := os.Open("/proc/mounts") + if err != nil { + return result + } + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) < 2 || !strings.HasPrefix(fields[0], "/dev/") { + continue + } + dev := strings.TrimPrefix(filepath.Base(fields[0]), "/dev/") + parent := diskParentName(dev) + result[parent] = append(result[parent], fields[1]) + } + return result +} + +func diskParentName(dev string) string { + for _, suffix := range []string{"p1", "p2", "p3", "p4", "p5", "p6", "p7", "p8", "p9"} { + if strings.HasSuffix(dev, suffix) && strings.HasPrefix(dev, "nvme") { + return strings.TrimSuffix(dev, suffix) + } + } + for len(dev) > 0 && dev[len(dev)-1] >= '0' && dev[len(dev)-1] <= '9' { + dev = dev[:len(dev)-1] + } + return dev +} + +func detectHostNICs() []HostNICProbe { + entries, err := os.ReadDir("/sys/class/net") + if err != nil { + return nil + } + ipv4, ipv6 := detectInterfaceIPs() + nics := make([]HostNICProbe, 0) + for _, entry := range entries { + name := entry.Name() + if name == "lo" { + continue + } + base := filepath.Join("/sys/class/net", name) + speed, _ := strconv.Atoi(strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "speed")))) + nic := HostNICProbe{ + Name: name, + MAC: strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "address"))), + State: strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "operstate"))), + SpeedMbps: speed, + Driver: strings.TrimSpace(runCommandOutput(2*time.Second, "sh", "-c", fmt.Sprintf("basename $(readlink -f /sys/class/net/%s/device/driver 2>/dev/null) 2>/dev/null", shellQuoteSimple(name)))), + Model: detectNICModel(name), + IPv4: ipv4[name], + IPv6: ipv6[name], + } + nics = append(nics, nic) + } + sort.Slice(nics, func(i, j int) bool { return nics[i].Name < nics[j].Name }) + return nics +} + +func detectNICModel(name string) string { + out := runCommandOutput(2*time.Second, "sh", "-c", fmt.Sprintf("lspci -D 2>/dev/null | grep -iE 'ethernet|network' | head -n 1 || true")) + if out != "" { + return strings.TrimSpace(out) + } + return strings.TrimSpace(readFirstExistingFile(filepath.Join("/sys/class/net", name, "device", "uevent"))) +} + +func detectInterfaceIPs() (map[string][]HostIPProbe, map[string][]HostIPProbe) { + ipv4 := map[string][]HostIPProbe{} + ipv6 := map[string][]HostIPProbe{} + for _, family := range []struct { + arg string + dst map[string][]HostIPProbe + }{{"-4", ipv4}, {"-6", ipv6}} { + out := runCommandOutput(3*time.Second, "ip", "-o", family.arg, "addr", "show") + for _, line := range strings.Split(out, "\n") { + fields := strings.Fields(line) + if len(fields) < 4 { + continue + } + iface := strings.TrimSuffix(fields[1], ":") + addr := fields[3] + ip, network, err := net.ParseCIDR(addr) + if err != nil || ip == nil || network == nil { + continue + } + ones, _ := network.Mask.Size() + scope := "" + for i, field := range fields { + if field == "scope" && i+1 < len(fields) { + scope = fields[i+1] + } + } + family.dst[iface] = append(family.dst[iface], HostIPProbe{ + Interface: iface, + Address: ip.String(), + PrefixLen: ones, + Scope: scope, + }) + } + } + return ipv4, ipv6 +} + +func detectAllPublicIPv4() []string { + seen := map[string]bool{} + result := make([]string, 0) + if pub := lxc.DetectPublicIPv4(); pub.Address != "" { + if ip := net.ParseIP(pub.Address); isPublicIPv4(ip) { + seen[pub.Address] = true + result = append(result, pub.Address) + } + } + out := runCommandOutput(3*time.Second, "ip", "-o", "-4", "addr", "show", "scope", "global") + for _, line := range strings.Split(out, "\n") { + fields := strings.Fields(line) + if len(fields) < 4 { + continue + } + ip, _, err := net.ParseCIDR(fields[3]) + if err != nil || ip == nil { + continue + } + if !isPublicIPv4(ip) { + continue + } + value := ip.String() + if !seen[value] { + seen[value] = true + result = append(result, value) + } + } + return result +} + +func collectIPv4Addresses(nics []HostNICProbe) []HostIPProbe { + result := make([]HostIPProbe, 0) + for _, nic := range nics { + for _, ip := range nic.IPv4 { + parsed := net.ParseIP(ip.Address) + if isPublicIPv4(parsed) { + result = append(result, ip) + } + } + } + return result +} + +func detectPublicIPv4Prefixes(nics []HostNICProbe, gateways []HostGatewayProbe) []HostIPv4PrefixProbe { + result := make([]HostIPv4PrefixProbe, 0) + seen := map[string]bool{} + gatewayByIface := map[string]string{} + for _, gateway := range gateways { + if gateway.Family == "ipv4" && gateway.Interface != "" && gateway.Gateway != "" { + gatewayByIface[gateway.Interface] = gateway.Gateway + } + } + add := func(item HostIPv4PrefixProbe) { + if item.Prefix == "" || item.Interface == "" { + return + } + key := item.Interface + "|" + item.Prefix + if seen[key] { + return + } + seen[key] = true + result = append(result, item) + } + for _, nic := range nics { + for _, ip := range nic.IPv4 { + parsed := net.ParseIP(ip.Address) + if !isPublicIPv4(parsed) || ip.PrefixLen <= 0 || ip.PrefixLen > 32 { + continue + } + prefix, subnet := ipv4PrefixAndMask(parsed, ip.PrefixLen) + add(HostIPv4PrefixProbe{ + Interface: nic.Name, + Address: ip.Address, + Prefix: prefix, + PrefixLen: ip.PrefixLen, + SubnetMask: subnet, + Gateway: gatewayByIface[nic.Name], + Source: "address", + }) + } + } + for _, item := range detectIPv4RoutePrefixes(gatewayByIface) { + add(item) + } + sort.SliceStable(result, func(i, j int) bool { + if result[i].Interface == result[j].Interface { + return result[i].Prefix < result[j].Prefix + } + return result[i].Interface < result[j].Interface + }) + return result +} + +func detectIPv4RoutePrefixes(gatewayByIface map[string]string) []HostIPv4PrefixProbe { + out := runCommandOutput(3*time.Second, "ip", "-4", "route", "show") + result := make([]HostIPv4PrefixProbe, 0) + for _, line := range strings.Split(out, "\n") { + fields := strings.Fields(line) + if len(fields) == 0 || fields[0] == "default" { + continue + } + _, network, err := net.ParseCIDR(fields[0]) + if err != nil || network == nil || network.IP.To4() == nil { + continue + } + if !isPublicIPv4(network.IP) { + continue + } + iface := "" + gateway := "" + src := "" + for i, field := range fields { + if field == "dev" && i+1 < len(fields) { + iface = fields[i+1] + } + if field == "via" && i+1 < len(fields) { + gateway = fields[i+1] + } + if field == "src" && i+1 < len(fields) { + src = fields[i+1] + } + } + if iface == "" || isContainerLikeInterfaceName(iface) { + continue + } + ones, bits := network.Mask.Size() + if bits != 32 || ones <= 0 || ones > 32 { + continue + } + if gateway == "" { + gateway = gatewayByIface[iface] + } + prefix, subnet := ipv4PrefixAndMask(network.IP, ones) + result = append(result, HostIPv4PrefixProbe{ + Interface: iface, + Address: src, + Prefix: prefix, + PrefixLen: ones, + SubnetMask: subnet, + Gateway: gateway, + Source: "route", + }) + } + return result +} + +func ipv4PrefixAndMask(ip net.IP, prefixLen int) (string, string) { + v4 := ip.To4() + if v4 == nil { + return "", "" + } + mask := net.CIDRMask(prefixLen, 32) + network := v4.Mask(mask) + subnet := fmt.Sprintf("%d.%d.%d.%d", mask[0], mask[1], mask[2], mask[3]) + return fmt.Sprintf("%s/%d", network.String(), prefixLen), subnet +} + +func isPublicIPv4(ip net.IP) bool { + ip = ip.To4() + if ip == nil { + return false + } + return !ip.IsPrivate() && !ip.IsLoopback() && !ip.IsLinkLocalUnicast() && !ip.IsMulticast() && !ip.IsUnspecified() +} + +func isContainerLikeInterfaceName(iface string) bool { + prefixes := []string{"lo", "lxc", "docker", "br-", "veth", "virbr", "cni", "flannel", "cali", "kube", "dummy", "ifb"} + for _, prefix := range prefixes { + if iface == prefix || strings.HasPrefix(iface, prefix) { + return true + } + } + return false +} + +func collectIPv6Addresses(nics []HostNICProbe) []HostIPProbe { + result := make([]HostIPProbe, 0) + for _, nic := range nics { + for _, ip := range nic.IPv6 { + if ip.Scope == "global" { + result = append(result, ip) + } + } + } + return result +} + +func detectGateways() []HostGatewayProbe { + gateways := make([]HostGatewayProbe, 0) + for _, item := range []struct { + family string + args []string + }{{"ipv4", []string{"-4", "route", "show", "default"}}, {"ipv6", []string{"-6", "route", "show", "default"}}} { + out := runCommandOutput(3*time.Second, "ip", item.args...) + for _, line := range strings.Split(out, "\n") { + fields := strings.Fields(line) + gw := "" + iface := "" + for i, field := range fields { + if field == "via" && i+1 < len(fields) { + gw = fields[i+1] + } + if field == "dev" && i+1 < len(fields) { + iface = fields[i+1] + } + } + if gw != "" || iface != "" { + gateways = append(gateways, HostGatewayProbe{Family: item.family, Interface: iface, Gateway: gw}) + } + } + } + return gateways +} + +func detectGPUs() []HostGPUProbe { + gpus := make([]HostGPUProbe, 0) + out := runCommandOutput(3*time.Second, "sh", "-c", "lspci -nnk 2>/dev/null | grep -iEA3 'vga|3d|display' || true") + var current *HostGPUProbe + for _, line := range strings.Split(out, "\n") { + trimmed := strings.TrimSpace(line) + lower := strings.ToLower(trimmed) + if strings.Contains(lower, "vga") || strings.Contains(lower, "3d controller") || strings.Contains(lower, "display controller") { + gpus = append(gpus, HostGPUProbe{Name: trimmed, Vendor: detectGPUVendor(trimmed), Type: detectGPUType(trimmed)}) + current = &gpus[len(gpus)-1] + continue + } + if current != nil && strings.HasPrefix(trimmed, "Kernel driver in use:") { + current.Driver = strings.TrimSpace(strings.TrimPrefix(trimmed, "Kernel driver in use:")) + } + } + return gpus +} + +func detectGPUVendor(value string) string { + lower := strings.ToLower(value) + switch { + case strings.Contains(lower, "intel"): + return "Intel" + case strings.Contains(lower, "nvidia"): + return "NVIDIA" + case strings.Contains(lower, "amd") || strings.Contains(lower, "ati"): + return "AMD" + default: + return "Unknown" + } +} + +func detectGPUType(value string) string { + lower := strings.ToLower(value) + if strings.Contains(lower, "intel") { + return "integrated" + } + return "discrete" +} + +func hasIntegratedGPU(gpus []HostGPUProbe) bool { + for _, gpu := range gpus { + if gpu.Type == "integrated" { + return true + } + } + return false +} + +func detectRuntimeProbe(env []HostEnvCheck) HostRuntimeProbe { + devKVM := fileExists("/dev/kvm") + nested, detail := detectNestedVirtualization() + lxcOK := envCheckOK(env, "lxc-create") + probe := HostRuntimeProbe{ + LXCAvailable: lxcOK, + KVMAvailable: devKVM && envCheckOK(env, "virsh"), + DevKVM: devKVM, + NestedVirtualization: nested, + NestedDetail: detail, + SupportMode: "unsupported", + } + if probe.KVMAvailable { + probe.SupportMode = "kvm_lxc" + } else if probe.LXCAvailable { + probe.SupportMode = "lxc_only" + } + return probe +} + +func detectNestedVirtualization() (bool, string) { + paths := []string{ + "/sys/module/kvm_intel/parameters/nested", + "/sys/module/kvm_amd/parameters/nested", + } + for _, path := range paths { + value := strings.TrimSpace(readFirstExistingFile(path)) + if value == "" { + continue + } + enabled := strings.EqualFold(value, "Y") || value == "1" + return enabled, filepath.Base(filepath.Dir(filepath.Dir(path))) + "=" + value + } + if fileExists("/dev/kvm") { + return true, "/dev/kvm present" + } + return false, "no kvm nested parameter or /dev/kvm" +} + +func detectSystemProbe() HostSystemProbe { + uptime := int64(0) + if data, err := os.ReadFile("/proc/uptime"); err == nil { + first := strings.Fields(string(data)) + if len(first) > 0 { + value, _ := strconv.ParseFloat(first[0], 64) + uptime = int64(value) + } + } + return HostSystemProbe{ + UptimeSeconds: uptime, + UptimeText: formatDurationText(uptime), + ProcessCount: countProcesses(), + } +} + +func detectHostEnvironment() []HostEnvCheck { + checks := []HostEnvCheck{ + commandCheck("service-manager", "服务管理器 systemd/OpenRC", true, "systemctl", "systemd"), + commandCheck("lxc-create", "LXC 创建工具", true, "lxc-create", ""), + commandCheck("lxc-start", "LXC 启动工具", true, "lxc-start", ""), + commandCheck("iptables", "iptables 网络规则", true, "iptables", ""), + commandCheck("ip", "iproute2 网络工具", true, "ip", ""), + commandCheck("conntrack", "conntrack 安全扫描", false, "conntrack", ""), + commandCheck("virsh", "libvirt virsh", false, "virsh", ""), + commandCheck("qemu-system-x86_64", "QEMU/KVM 虚拟机", false, "qemu-system-x86_64", ""), + commandCheck("genisoimage", "KVM cloud-init ISO 工具", false, "genisoimage", "xorriso/mkisofs 可替代"), + commandCheck("xorriso", "ISO 备用工具", false, "xorriso", ""), + commandCheck("smartctl", "硬盘健康检测", false, "smartctl", ""), + } + checks = append(checks, HostEnvCheck{Key: "dev-kvm", Label: "/dev/kvm 硬件虚拟化", OK: fileExists("/dev/kvm"), Required: false, Detail: boolDetail(fileExists("/dev/kvm"))}) + checks = append(checks, HostEnvCheck{Key: "ipv4-forward", Label: "IPv4 转发", OK: strings.TrimSpace(readFirstExistingFile("/proc/sys/net/ipv4/ip_forward")) == "1", Required: true, Detail: strings.TrimSpace(readFirstExistingFile("/proc/sys/net/ipv4/ip_forward"))}) + checks = append(checks, HostEnvCheck{Key: "lxcfs", Label: "lxcfs 服务", OK: serviceActive("lxcfs"), Required: false, Detail: serviceDetail("lxcfs")}) + checks = append(checks, HostEnvCheck{Key: "libvirt", Label: "libvirt 服务", OK: serviceActive("libvirtd") || serviceActive("virtqemud"), Required: false, Detail: firstNonEmpty(serviceDetail("libvirtd"), serviceDetail("virtqemud"))}) + return checks +} + +func commandCheck(key, label string, required bool, cmd string, fallback string) HostEnvCheck { + ok := commandExists(cmd) + detail := "missing" + if ok { + detail = strings.TrimSpace(runCommandOutput(2*time.Second, "sh", "-c", cmd+" --version 2>&1 | head -n 1")) + if detail == "" { + detail = "installed" + } + } else if fallback != "" { + detail = fallback + } + return HostEnvCheck{Key: key, Label: label, OK: ok, Required: required, Detail: detail} +} + +func envCheckOK(checks []HostEnvCheck, key string) bool { + for _, check := range checks { + if check.Key == key { + return check.OK + } + } + return false +} + +func serviceActive(name string) bool { + if commandExists("systemctl") { + return strings.TrimSpace(runCommandOutput(2*time.Second, "systemctl", "is-active", name)) == "active" + } + if commandExists("rc-service") { + return strings.Contains(runCommandOutput(2*time.Second, "rc-service", name, "status"), "started") + } + return false +} + +func serviceDetail(name string) string { + if commandExists("systemctl") { + return strings.TrimSpace(runCommandOutput(2*time.Second, "systemctl", "is-active", name)) + } + if commandExists("rc-service") { + return strings.TrimSpace(runCommandOutput(2*time.Second, "rc-service", name, "status")) + } + return "unknown" +} + +func readKeyValueFile(path, sep string) map[string]string { + result := map[string]string{} + data, err := os.ReadFile(path) + if err != nil { + return result + } + for _, line := range strings.Split(string(data), "\n") { + parts := strings.SplitN(line, sep, 2) + if len(parts) == 2 { + result[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + } + return result +} + +func readFirstExistingFile(paths ...string) string { + for _, path := range paths { + data, err := os.ReadFile(path) + if err == nil { + return string(data) + } + } + return "" +} + +func commandExists(name string) bool { + _, err := exec.LookPath(name) + return err == nil +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func runCommandOutput(timeout time.Duration, name string, args ...string) string { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + out, err := exec.CommandContext(ctx, name, args...).CombinedOutput() + if err != nil && len(out) == 0 { + return "" + } + return strings.TrimSpace(string(out)) +} + +func shortCommandDetail(value string) string { + value = strings.TrimSpace(value) + lines := strings.Split(value, "\n") + if len(lines) > 4 { + lines = lines[:4] + } + return strings.Join(lines, " | ") +} + +func shellQuoteSimple(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" +} + +func formatDurationText(seconds int64) string { + days := seconds / 86400 + seconds %= 86400 + hours := seconds / 3600 + seconds %= 3600 + minutes := seconds / 60 + if days > 0 { + return fmt.Sprintf("%dd %dh %dm", days, hours, minutes) + } + return fmt.Sprintf("%dh %dm", hours, minutes) +} + +func formatBytesText(value uint64) string { + if value == 0 { + return "0 B" + } + units := []string{"B", "KB", "MB", "GB", "TB", "PB"} + next := float64(value) + index := 0 + for next >= 1024 && index < len(units)-1 { + next /= 1024 + index++ + } + if index == 0 { + return fmt.Sprintf("%d %s", value, units[index]) + } + return fmt.Sprintf("%.1f %s", next, units[index]) +} + +func countProcesses() int { + entries, err := os.ReadDir("/proc") + if err != nil { + return 0 + } + count := 0 + for _, entry := range entries { + if _, err := strconv.Atoi(entry.Name()); err == nil { + count++ + } + } + return count +} + +func boolDetail(ok bool) string { + if ok { + return "available" + } + return "missing" +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" && value != "inactive" && value != "unknown" { + return value + } + } + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 6d2349b..ae80f67 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -86,6 +86,7 @@ func setupRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages)))) mux.HandleFunc("/api/dashboard", corsMiddleware(api.AdminMiddleware(api.HandleDashboard))) mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo))) + mux.HandleFunc("/api/host-report", corsMiddleware(api.AdminMiddleware(api.HandleHostReport))) mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots))) mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting))) mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status))) @@ -125,6 +126,7 @@ func setupRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/v1/images/toggle", corsMiddleware(api.AuthMiddleware(api.HandleImageToggle))) mux.HandleFunc("/api/v1/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages)))) mux.HandleFunc("/api/v1/host-info", corsMiddleware(api.AuthMiddleware(api.HandleHostInfo))) + mux.HandleFunc("/api/v1/host-report", corsMiddleware(api.AuthMiddleware(api.HandleHostReport))) mux.HandleFunc("/api/v1/snapshots", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("snapshot:read", api.HandleSnapshots)))) mux.HandleFunc("/api/v1/routing", corsMiddleware(api.AuthMiddleware(api.HandleRouting))) mux.HandleFunc("/api/v1/ipv6/status", corsMiddleware(api.AuthMiddleware(api.HandleIPv6Status))) diff --git a/backend/internal/server/web/.gitkeep b/backend/internal/server/web/.gitkeep index 30259b2..5fa68e9 100644 --- a/backend/internal/server/web/.gitkeep +++ b/backend/internal/server/web/.gitkeep @@ -1 +1 @@ - +? diff --git a/backend/internal/version/version.go b/backend/internal/version/version.go index 011f49a..80e8357 100644 --- a/backend/internal/version/version.go +++ b/backend/internal/version/version.go @@ -1,7 +1,7 @@ package version var ( - Version = "1.1.4" + Version = "1.1.5" Repo = "MengMengCode/CLICD" ) diff --git a/frontend/package.json b/frontend/package.json index 4a47e82..2a88aaf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "clicd-frontend", "private": true, - "version": "1.1.4", + "version": "1.1.5", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7d07f9a..9442ba7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import ContainerDetail from './pages/ContainerDetail' import Security from './pages/Security' import AuditLogs from './pages/AuditLogs' import ApiIntegration from './pages/ApiIntegration' +import HostReport from './pages/HostReport' import Settings from './pages/Settings' import ImageManagement from './pages/ImageManagement' import Snapshots from './pages/Snapshots' @@ -64,6 +65,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index c9892cc..e2a5f8f 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -4,6 +4,7 @@ import { ChevronLeft, ChevronRight, Code2, + Cpu, Camera, LayoutDashboard, LogOut, @@ -71,6 +72,7 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) { const isRoutingPage = location.pathname.startsWith('/routing') const isAuditLogsPage = location.pathname.startsWith('/audit-logs') const isApiIntegrationPage = location.pathname.startsWith('/api-integration') + const isHostReportPage = location.pathname.startsWith('/host-report') const isSecurityPage = location.pathname.startsWith('/security') const isSettingsPage = location.pathname.startsWith('/settings') @@ -222,6 +224,18 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) { {!collapsed && API 集成} + + + + + {loading && !report ? ( +
正在探测宿主机环境...
+ ) : !report ? ( +
暂未获取到宿主机信息
+ ) : ( +
+
+ } label="CPU" value={report.cpu.model || 'Unknown'} sub={`${report.cpu.cores} 核 / ${report.cpu.threads} 线程`} /> + } label="RAM" value={formatMB(report.memory.total_mb)} sub={`${formatMB(report.memory.used_mb)} 已用`} /> + } label="DISK" value={`${report.disks.length} 块硬盘`} sub={report.disks.map(d => d.type).filter(Boolean).join(' / ') || 'Unknown'} /> + } label="运行状态" value={report.system.uptime_text} sub={`${report.system.process_count} 个进程`} /> +
+ + + + + + + `${ip.address}/${ip.prefix_len} (${ip.interface})`).join('\n') : '未检测到'], + ['IPv6 段', report.ipv6_prefixes?.length ? report.ipv6_prefixes.map(formatIPv6Prefix).join('\n') : '未检测到'], + ['网关', report.gateways.length ? report.gateways.map(g => `${g.family}: ${g.gateway || '-'} dev ${g.interface || '-'}`).join('\n') : '未检测到'], + ]} /> + + + [ + m.locator || '-', + m.size || '-', + m.type || '-', + m.speed || '-', + m.manufacturer || '-', + [m.part_number, m.serial_number].filter(Boolean).join(' / ') || '-', + ])} + /> + + [ + `${d.path || d.name}\n${d.serial || ''}`, + d.model || '-', + formatBytes(d.size_bytes), + d.type || (d.rotational ? 'HDD' : 'SSD'), + d.mountpoints?.length ? d.mountpoints.join('\n') : '-', + `${diskHealthLabel(d.health)}\n${d.health_detail || ''}`, + formatLifeUsed(d.smart?.life_used_percent), + d.smart?.power_on_hours ? `${d.smart.power_on_hours} 小时\n${formatPowerOnDays(d.smart.power_on_hours)}` : '-', + formatBytes(d.smart?.read_data_bytes || 0), + formatBytes(d.smart?.written_data_bytes || 0), + formatCommands(d.smart?.read_commands, d.smart?.write_commands), + formatWear(d.smart?.wear_leveling_count, d.smart?.erase_count, d.smart?.power_cycle_count), + ])} + /> + + [ + `${n.name}\n${n.model || ''}`, + n.state || '-', + `${n.driver || '-'}\n${n.speed_mbps > 0 ? `${n.speed_mbps} Mbps` : '-'}`, + n.mac || '-', + n.ipv4?.length ? n.ipv4.map(ip => `${ip.address}/${ip.prefix_len}`).join('\n') : '-', + n.ipv6?.length ? n.ipv6.map(ip => `${ip.address}/${ip.prefix_len} ${ip.scope}`).join('\n') : '-', + ])} + /> + + [g.name, g.vendor || '-', gpuTypeLabel(g.type), g.driver || '-'])} + /> + + +
+ {report.environment.map(item => ( +
+ {item.ok ? : } +
+
+ {item.label} + + {item.required ? '必要' : '可选'} + +
+
{item.detail || '-'}
+
+
+ ))} +
+
+
+ )} + + ) +} + +function ProbeMetric({ icon, label, value, sub }: { icon: ReactNode; label: string; value: string; sub: string }) { + return ( +
+
+ {icon} + {label} +
+
{value}
+
{sub}
+
+ ) +} + +function ProbeSection({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ) +} + +function ProbeRows({ rows }: { rows: Array<[string, string]> }) { + return ( +
+ {rows.map(([label, value]) => ( +
+
{label}
+
{value || '-'}
+
+ ))} +
+ ) +} + +function ProbeTable({ title, headers, rows, empty }: { title: string; headers: string[]; rows: string[][]; empty: string }) { + return ( +
+

{title}

+ {rows.length === 0 ? ( +
{empty}
+ ) : ( +
+ + + + {headers.map(header => )} + + + + {rows.map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => ( + + ))} + + ))} + +
{header}
+ {cell || '-'} +
+
+ )} +
+ ) +} + +function formatIPv4Address(ip: HostProbeReport['ipv4_addresses'][number]) { + return `${ip.address}/${ip.prefix_len} (${ip.interface})` +} + +function formatIPv4Prefix(prefix: HostProbeReport['ipv4_prefixes'][number]) { + const parts = [ + prefix.prefix || '-', + prefix.subnet_mask ? `mask ${prefix.subnet_mask}` : '', + prefix.gateway ? `via ${prefix.gateway}` : '', + prefix.interface ? `dev ${prefix.interface}` : '', + prefix.source ? `[${prefix.source}]` : '', + ].filter(Boolean) + return parts.join(' ') +} + +function formatIPv6Prefix(prefix: HostProbeReport['ipv6_prefixes'][number]) { + const value = prefix.prefix || prefix.address || '-' + const cidr = value.includes('/') || !prefix.prefix_len ? value : `${value}/${prefix.prefix_len}` + return `${cidr} via ${prefix.gateway || '-'}` +} + +function formatMB(value: number) { + if (!value) return '-' + if (value >= 1024) return `${(value / 1024).toFixed(1)} GB` + return `${value} MB` +} + +function formatBytes(value: number) { + if (!value) return '-' + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] + let next = value + let index = 0 + while (next >= 1024 && index < units.length - 1) { + next /= 1024 + index++ + } + return `${next.toFixed(index === 0 ? 0 : 1)} ${units[index]}` +} + +function formatLifeUsed(value?: number) { + if (value === undefined || value === null) return '-' + return `${value}% 已用\n${Math.max(0, 100 - value)}% 剩余` +} + +function formatPowerOnDays(hours: number) { + const days = Math.floor(hours / 24) + const rest = hours % 24 + return days > 0 ? `${days} 天 ${rest} 小时` : `${hours} 小时` +} + +function formatCommands(read?: number, write?: number) { + if (!read && !write) return '-' + return `读 ${formatCount(read || 0)}\n写 ${formatCount(write || 0)}` +} + +function formatCount(value: number) { + if (!value) return '-' + if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)}B` + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M` + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K` + return `${value}` +} + +function formatWear(wear?: string, erase?: string, powerCycles?: number) { + const rows: string[] = [] + if (wear) rows.push(`磨损 ${wear}`) + if (erase) rows.push(`擦写 ${erase}`) + if (powerCycles) rows.push(`启停 ${powerCycles}`) + return rows.length ? rows.join('\n') : '-' +} + +function runtimeModeLabel(value: string) { + switch (value) { + case 'kvm_lxc': + return '支持 KVM + LXC' + case 'lxc_only': + return '仅支持 LXC' + default: + return '未满足运行环境' + } +} + +function diskHealthLabel(value: string) { + switch (value) { + case 'ok': + return '健康' + case 'failed': + return '异常' + default: + return '未知' + } +} + +function gpuTypeLabel(value: string) { + if (value === 'integrated') return '核显' + if (value === 'discrete') return '独显' + return value || '-' +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 20e55b3..d1abe06 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -106,7 +106,7 @@ export default function Login() { -

CLICD v1.1.4

+

CLICD v1.1.5

) diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 906cb7f..5f295ff 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,5 +1,5 @@ -import { useState, useEffect, useCallback } from 'react' -import { UserCog, Key, LogIn, Monitor, Clock, Globe } from 'lucide-react' +import { useCallback, useEffect, useState } from 'react' +import { Clock, Globe, LogIn, Monitor, UserCog } from 'lucide-react' import { changePassword, changeUsername, @@ -20,7 +20,6 @@ export default function Settings() { const [oldPwd, setOldPwd] = useState('') const [newPwd, setNewPwd] = useState('') const [newUsername, setNewUsername] = useState('') - const [pwdForUser, setPwdForUser] = useState('') const fetchLogs = useCallback(async () => { try { @@ -33,30 +32,45 @@ export default function Settings() { } }, []) - useEffect(() => { fetchLogs(); const t = setInterval(fetchLogs, 15000); return () => clearInterval(t) }, [fetchLogs]) + useEffect(() => { + fetchLogs() + const timer = setInterval(fetchLogs, 15000) + return () => clearInterval(timer) + }, [fetchLogs]) const handleSaveAccount = async () => { - if (!oldPwd) { dialog.alert('提示', '请输入当前密码以确认修改'); return } - if (!newPwd && !newUsername) { dialog.alert('提示', '至少填写新密码或新用户名中的一项'); return } - if (newPwd && newPwd.length < 6) { dialog.alert('提示', '新密码至少 6 位'); return } - if (newUsername && newUsername.length < 3) { dialog.alert('提示', '用户名至少 3 位'); return } + if (!oldPwd) { + dialog.alert('提示', '请输入当前密码以确认修改') + return + } + if (!newPwd && !newUsername) { + dialog.alert('提示', '至少填写新密码或新用户名中的一项') + return + } + if (newPwd && newPwd.length < 6) { + dialog.alert('提示', '新密码至少 6 位') + return + } + if (newUsername && newUsername.length < 3) { + dialog.alert('提示', '用户名至少 3 位') + return + } - let results: string[] = [] + const results: string[] = [] try { - // 先改用户名(用旧密码验证),再改密码,否则改完密码后旧密码就失效了 if (newUsername) { const res = await changeUsername(newUsername, oldPwd) - if (res.data.success) results.push('用户名已修改') - else results.push('用户名修改失败') + results.push(res.data.success ? '用户名已修改' : '用户名修改失败') } if (newPwd) { const res = await changePassword(oldPwd, newPwd) - if (res.data.success) results.push('密码已修改') - else results.push('密码修改失败') + results.push(res.data.success ? '密码已修改' : '密码修改失败') } if (results.length > 0) { - dialog.alert('完成', results.join(',') + '。下次登录生效') - setOldPwd(''); setNewPwd(''); setNewUsername('') + dialog.alert('完成', `${results.join(',')}。下次登录生效`) + setOldPwd('') + setNewPwd('') + setNewUsername('') } } catch (err: unknown) { const e = err as { response?: { data?: { message?: string } } } @@ -67,48 +81,48 @@ export default function Settings() { if (loading) { return (
-
+
) } + const totalPages = Math.ceil(logs.length / pageSize) + return (

面板设置

-

账号管理与登录日志

+

账号管理与登录日志

- {/* Account Settings */} -
-

- 账号设置 +
+

+ 账号设置

- - + +
- - setNewUsername(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white" placeholder="至少 3 位" /> + + setNewUsername(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 3 位" />
- - setNewPwd(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white" placeholder="至少 6 位" /> + + setNewPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 6 位" />
- - setOldPwd(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white" placeholder="输入当前密码以确认修改" /> + + setOldPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="输入当前密码以确认修改" />
- +
- {/* Login Logs */} -
-

- 登录日志 +
+

+ 登录日志

{logs.length === 0 ? (

暂无登录记录

@@ -117,23 +131,23 @@ export default function Settings() {
- - - - - - + + + + + + - {logs.slice((logPage - 1) * pageSize, logPage * pageSize).map((log, i) => ( - - + {logs.slice((logPage - 1) * pageSize, logPage * pageSize).map((log, index) => ( + + - - + + @@ -143,23 +157,22 @@ export default function Settings() {
时间用户名IP设备结果
时间用户名IP设备结果
{log.time}
{log.time} {log.username}{log.ip}{formatUA(log.user_agent)}{log.ip}{formatUA(log.user_agent)} - + {log.success ? '成功' : '失败'}
{logs.length > pageSize && ( -
- 共 {logs.length} 条,第 {logPage}/{Math.ceil(logs.length / pageSize)} 页 +
+ 共 {logs.length} 条,第 {logPage}/{totalPages} 页
- - - {Array.from({length: Math.min(5, Math.ceil(logs.length / pageSize))}, (_, i) => { - const totalPages = Math.ceil(logs.length / pageSize) + + + {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { let start = Math.max(1, logPage - 2) if (start + 4 > totalPages) start = Math.max(1, totalPages - 4) const page = start + i if (page > totalPages) return null return ( - + ) })} - - + +
)} @@ -171,7 +184,6 @@ export default function Settings() { } function formatUA(ua: string): string { - // Extract browser/OS info from UA string const parts: string[] = [] if (ua.includes('Windows NT')) parts.push('Windows') else if (ua.includes('Mac OS X')) parts.push('macOS') diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 7ef6b2c..90b3388 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -136,6 +136,16 @@ export interface IPv6Status { prefixes: IPv6PrefixInfo[] } +export interface IPv4PrefixInfo { + interface: string + address: string + prefix: string + prefix_len: number + subnet_mask: string + gateway: string + source: string +} + export interface DashboardStats { total_containers: number running: number @@ -161,6 +171,93 @@ export interface HostInfo { load: { load1: number; load5: number; load15: number } } +export interface HostProbeReport { + generated_at: string + hostname: string + kernel: string + os: string + cpu: { + model: string + cores: number + threads: number + architecture: string + flags: string[] + has_integrated_gpu: boolean + virtualization: boolean + virtualization_key: string + } + memory: { + total_mb: number + used_mb: number + free_mb: number + modules: Array<{ + locator: string + size: string + type: string + speed: string + manufacturer: string + part_number: string + serial_number: string + }> + } + disks: Array<{ + name: string + path: string + model: string + serial: string + size_bytes: number + type: string + rotational: boolean + mountpoints: string[] + health: string + health_detail: string + smart?: { + available: boolean + life_used_percent?: number + power_on_hours?: number + power_cycle_count?: number + read_data_bytes?: number + written_data_bytes?: number + read_commands?: number + write_commands?: number + wear_leveling_count?: string + erase_count?: string + media_errors?: number + } + }> + network_interfaces: Array<{ + name: string + mac: string + state: string + speed_mbps: number + driver: string + model: string + ipv4: Array<{ interface: string; address: string; prefix_len: number; scope: string; gateway?: string }> + ipv6: Array<{ interface: string; address: string; prefix_len: number; scope: string; gateway?: string }> + }> + public_ipv4: string[] + ipv4_addresses: Array<{ interface: string; address: string; prefix_len: number; scope: string; gateway?: string }> + ipv4_prefixes: IPv4PrefixInfo[] + ipv6_addresses: Array<{ interface: string; address: string; prefix_len: number; scope: string; gateway?: string }> + ipv6_prefixes: IPv6PrefixInfo[] + gateways: Array<{ family: string; interface: string; gateway: string }> + gpus: Array<{ name: string; vendor: string; driver: string; type: string }> + runtime: { + lxc_available: boolean + kvm_available: boolean + dev_kvm: boolean + nested_virtualization: boolean + nested_detail: string + support_mode: string + } + system: { + uptime_seconds: number + uptime_text: string + process_count: number + } + environment: Array<{ key: string; label: string; ok: boolean; required: boolean; detail: string }> +} + export interface ContainerUsage { memory_usage_bytes: number memory_total_bytes?: number @@ -393,6 +490,9 @@ export const getDashboard = () => export const getHostInfo = () => api.get>('/host-info') +export const getHostReport = () => + api.get>('/host-report') + // Snapshots export interface Snapshot { id: string