소스 검색

Added smart disk detection

TC 2 주 전
부모
커밋
1869d61883
19개의 변경된 파일1524개의 추가작업 그리고 41개의 파일을 삭제
  1. 131 3
      api.go
  2. 27 0
      check.go
  3. 6 2
      def.go
  4. 5 0
      go.mod
  5. 12 0
      go.sum
  6. 17 0
      main.go
  7. 22 0
      mod/diskinfo/diskutil.go
  8. 342 0
      mod/diskinfo/smart/smart.go
  9. 76 0
      mod/diskinfo/smart/typedef.go
  10. 1 1
      mod/disktool/smartgo/extract.go
  11. 1 1
      mod/disktool/smartgo/smartgo.go
  12. 219 0
      mod/netstat/netstat.go
  13. 55 0
      mod/netstat/nic.go
  14. 81 26
      web/components/disks.html
  15. 429 5
      web/components/status.html
  16. 43 2
      web/index.html
  17. 12 0
      web/js/chart.js
  18. 34 1
      web/js/locale.js
  19. 11 0
      web/js/theme.js

+ 131 - 3
api.go

@@ -9,6 +9,8 @@ import (
 
 	"imuslab.com/bokofs/bokofsd/mod/diskinfo"
 	"imuslab.com/bokofs/bokofsd/mod/diskinfo/lsblk"
+	"imuslab.com/bokofs/bokofsd/mod/diskinfo/smart"
+	"imuslab.com/bokofs/bokofsd/mod/netstat"
 )
 
 /*
@@ -33,23 +35,147 @@ func HandlerAPIcalls() http.Handler {
 			return
 		}
 
-		if diskID == "info" {
+		switch diskID {
+		case "info":
+			// Request to /api/info/*
 			HandleInfoAPIcalls().ServeHTTP(w, r)
 			return
+		case "smart":
+			// Request to /api/smart/*
+			HandleSMARTCalls().ServeHTTP(w, r)
+			return
+		default:
+			http.Error(w, "Not Found", http.StatusNotFound)
+			return
 		}
+	}))
+}
 
-		fmt.Fprintf(w, "API call for disk ID: %s\n", diskID)
+// Handler for SMART API calls
+func HandleSMARTCalls() http.Handler {
+	return http.StripPrefix("/smart/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		pathParts := strings.Split(r.URL.Path, "/")
+		if len(pathParts) < 2 {
+			http.Error(w, "Bad Request - Missing disk name", http.StatusBadRequest)
+			return
+		}
+		subPath := pathParts[0]
+		diskName := pathParts[1]
+		if diskName == "" {
+			http.Error(w, "Bad Request - Missing disk name", http.StatusBadRequest)
+			return
+		}
+		switch subPath {
+		case "health":
+			if diskName == "all" {
+				// Get the SMART information for all disks
+				allDisks, err := diskinfo.GetAllDisks()
+				if err != nil {
+					log.Println("Error getting all disks:", err)
+					http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+					return
+				}
+
+				// Create a map to hold the SMART information for each disk
+				diskInfoMap := []*smart.DriveHealthInfo{}
+				for _, disk := range allDisks {
+					diskName := disk.Name
+					health, err := smart.GetDiskSMARTHealthSummary(diskName)
+					if err != nil {
+						log.Println("Error getting disk health:", err)
+						continue
+					}
+
+					diskInfoMap = append(diskInfoMap, health)
+				}
+				// Convert the disk information to JSON and write it to the response
+				js, _ := json.Marshal(diskInfoMap)
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusOK)
+				w.Write(js)
+				return
+			}
+
+			// Get the health status of the disk
+			health, err := smart.GetDiskSMARTHealthSummary(diskName)
+			if err != nil {
+				log.Println("Error getting disk health:", err)
+				http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+				return
+			}
+			// Convert the health status to JSON and write it to the response
+			js, _ := json.Marshal(health)
+			w.Header().Set("Content-Type", "application/json")
+			w.WriteHeader(http.StatusOK)
+			w.Write(js)
+			return
+		case "info":
+			// Handle SMART API calls
+			dt, err := smart.GetDiskType(diskName)
+			if err != nil {
+				log.Println("Error getting disk type:", err)
+				http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+				return
+			}
+
+			if dt == smart.DiskType_SATA {
+				// Get SATA disk information
+				sataInfo, err := smart.GetSATAInfo(diskName)
+				if err != nil {
+					log.Println("Error getting SATA disk info:", err)
+					http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+					return
+				}
+
+				// Convert the SATA info to JSON and write it to the response
+				js, _ := json.Marshal(sataInfo)
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusOK)
+				w.Write(js)
+			} else if dt == smart.DiskType_NVMe {
+				// Get NVMe disk information
+				nvmeInfo, err := smart.GetNVMEInfo(diskName)
+				if err != nil {
+					log.Println("Error getting NVMe disk info:", err)
+					http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+					return
+				}
+
+				// Convert the NVMe info to JSON and write it to the response
+				js, _ := json.Marshal(nvmeInfo)
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusOK)
+				w.Write(js)
+			} else {
+				log.Println("Unknown disk type:", dt)
+				http.Error(w, "Bad Request - Unknown disk type", http.StatusBadRequest)
+				return
+			}
+			return
+		default:
+			http.Error(w, "Not Found", http.StatusNotFound)
+			return
+		}
 	}))
 }
 
-// Handler for system API calls
+// Handler for info API calls
 func HandleInfoAPIcalls() http.Handler {
 	return http.StripPrefix("/info/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		//Check the next part of the URL
 		pathParts := strings.Split(r.URL.Path, "/")
 		subPath := pathParts[0]
 		switch subPath {
+		case "netstat":
+			// Get the current network statistics
+			netstatBuffer.HandleGetBufferedNetworkInterfaceStats(w, r)
+			return
+		case "iface":
+			// Get the list of network interfaces
+			netstat.HandleListNetworkInterfaces(w, r)
+			return
 		case "list":
+			// List all block devices and their partitions
 			blockDevices, err := lsblk.GetLSBLKOutput()
 			if err != nil {
 				log.Println("Error getting block devices:", err)
@@ -75,6 +201,7 @@ func HandleInfoAPIcalls() http.Handler {
 			w.WriteHeader(http.StatusOK)
 			w.Write(js)
 		case "disk":
+			// Get the disk info for a particular disk, e.g. sda
 			if len(pathParts) < 2 {
 				http.Error(w, "Bad Request - Invalid disk name", http.StatusBadRequest)
 				return
@@ -105,6 +232,7 @@ func HandleInfoAPIcalls() http.Handler {
 			w.Write(js)
 			return
 		case "part":
+			// Get the partition info for a particular partition, e.g. sda1
 			if len(pathParts) < 2 {
 				http.Error(w, "Bad Request - Missing parition name", http.StatusBadRequest)
 				return

+ 27 - 0
check.go

@@ -0,0 +1,27 @@
+package main
+
+import (
+	"fmt"
+	"os/exec"
+)
+
+// commandExists checks if a given command exists on the system
+func commandExists(cmd string) bool {
+	_, err := exec.LookPath(cmd)
+	return err == nil
+}
+
+// checkRuntimeEnvironment checks if the required commands are available in the runtime environment
+func checkRuntimeEnvironment() bool {
+	packageMissing := false
+	commands := []string{"ffmpeg", "smartctl", "lsblk", "blkid", "df"}
+	for _, cmd := range commands {
+		if commandExists(cmd) {
+			fmt.Printf("\033[32m✔\033[0m '%s' exists\n", cmd)
+		} else {
+			packageMissing = true
+			fmt.Printf("\033[31m✘\033[0m '%s' does not exist\n", cmd)
+		}
+	}
+	return !packageMissing
+}

+ 6 - 2
def.go

@@ -1,6 +1,10 @@
 package main
 
-import "flag"
+import (
+	"flag"
+
+	"imuslab.com/bokofs/bokofsd/mod/netstat"
+)
 
 var (
 	/* Start Flags */
@@ -10,5 +14,5 @@ var (
 	serveSecure = flag.Bool("s", false, "Serve HTTPS. Default false")
 
 	/* Runtime Variables */
-
+	netstatBuffer *netstat.NetStatBuffers
 )

+ 5 - 0
go.mod

@@ -9,12 +9,17 @@ require (
 	github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
 	github.com/oliamb/cutter v0.2.2
 	github.com/oov/psd v0.0.0-20220121172623-5db5eafcecbb
+	github.com/shirou/gopsutil/v4 v4.25.3
 	golang.org/x/net v0.21.0
 )
 
 require (
+	github.com/ebitengine/purego v0.8.2 // indirect
 	github.com/fogleman/simplify v0.0.0-20170216171241-d32f302d5046 // indirect
+	github.com/go-ole/go-ole v1.2.6 // indirect
 	github.com/gopherjs/gopherjs v1.17.2 // indirect
+	github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
+	github.com/yusufpapurcu/wmi v1.2.4 // indirect
 	golang.org/x/crypto v0.33.0 // indirect
 	golang.org/x/sys v0.30.0 // indirect
 )

+ 12 - 0
go.sum

@@ -6,10 +6,14 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4mA/WVIYtpzVm63vLVAPzJXigg=
 github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
+github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
+github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
 github.com/fogleman/fauxgl v0.0.0-20250110135958-abf826acbbbd h1:8bZGm26jDoW+JQ1ZPugRU0ADy5k45DRb42sOxEeufNo=
 github.com/fogleman/fauxgl v0.0.0-20250110135958-abf826acbbbd/go.mod h1:7f7F8EvO8MWvDx9sIoloOfZBCKzlWuZV/h3TjpXOO3k=
 github.com/fogleman/simplify v0.0.0-20170216171241-d32f302d5046 h1:n3RPbpwXSFT0G8FYslzMUBDO09Ix8/dlqzvUkcJm4Jk=
 github.com/fogleman/simplify v0.0.0-20170216171241-d32f302d5046/go.mod h1:KDwyDqFmVUxUmo7tmqXtyaaJMdGon06y8BD2jmh84CQ=
+github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
+github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
 github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
 github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
@@ -22,14 +26,22 @@ github.com/oov/psd v0.0.0-20220121172623-5db5eafcecbb h1:JF9kOhBBk4WPF7luXFu5yR+
 github.com/oov/psd v0.0.0-20220121172623-5db5eafcecbb/go.mod h1:GHI1bnmAcbp96z6LNfBJvtrjxhaXGkbsk967utPlvL8=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
+github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
+github.com/shirou/gopsutil/v4 v4.25.3 h1:SeA68lsu8gLggyMbmCn8cmp97V1TI9ld9sVzAUcKcKE=
+github.com/shirou/gopsutil/v4 v4.25.3/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA=
 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/tmc/scp v0.0.0-20170824174625-f7b48647feef h1:7D6Nm4D6f0ci9yttWaKjM1TMAXrH5Su72dojqYGntFY=
 github.com/tmc/scp v0.0.0-20170824174625-f7b48647feef/go.mod h1:WLFStEdnJXpjK8kd4qKLwQKX/1vrDzp5BcDyiZJBHJM=
+github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
+github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
 golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
 golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
 golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
 golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
+golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
 golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

+ 17 - 0
main.go

@@ -10,6 +10,7 @@ import (
 
 	"imuslab.com/bokofs/bokofsd/mod/bokofs"
 	"imuslab.com/bokofs/bokofsd/mod/bokofs/bokoworker"
+	"imuslab.com/bokofs/bokofsd/mod/netstat"
 )
 
 //go:embed web/*
@@ -18,6 +19,7 @@ var embeddedFiles embed.FS
 func main() {
 	flag.Parse()
 
+	/* File system handler */
 	var fileSystem http.FileSystem
 	if *devMode {
 		fmt.Println("Development mode enabled. Serving files from ./web directory.")
@@ -44,6 +46,21 @@ func main() {
 		}
 	}
 
+	/* Network statistics */
+	nsb, err := netstat.NewNetStatBuffer(300)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Error creating network statistics buffer: %v\n", err)
+		os.Exit(1)
+	}
+	defer netstatBuffer.Close()
+	netstatBuffer = nsb
+
+	/* Package Check */
+	if !checkRuntimeEnvironment() {
+		fmt.Println("Runtime environment check failed. Please install the missing packages.")
+		os.Exit(1)
+	}
+
 	//DEBUG
 	wds, err := bokofs.NewWebdavInterfaceServer("/disk/", "/thumb/")
 	if err != nil {

+ 22 - 0
mod/diskinfo/diskutil.go

@@ -11,6 +11,28 @@ import (
 	"imuslab.com/bokofs/bokofsd/mod/diskinfo/lsblk"
 )
 
+// GetAllDisks retrieves all disks on the system.
+func GetAllDisks() ([]*Disk, error) {
+	allBlockDevices, err := lsblk.GetLSBLKOutput()
+	if err != nil {
+		return nil, err
+	}
+
+	disks := []*Disk{}
+
+	for _, blockDevice := range allBlockDevices {
+		if blockDevice.Type == "disk" {
+			thisDisk, err := GetDiskInfo(blockDevice.Name)
+			if err != nil {
+				return nil, err
+			}
+			disks = append(disks, thisDisk)
+		}
+	}
+
+	return disks, nil
+}
+
 // DevicePathIsValidDisk checks if the given device path is a disk.
 func DevicePathIsValidDisk(path string) bool {
 	//Make sure the path has a prefix and a trailing slash

+ 342 - 0
mod/diskinfo/smart/smart.go

@@ -0,0 +1,342 @@
+package smart
+
+/*
+	SMART.go
+
+	This script uses the smartctl command to retrieve information about the disk.
+	It supports both NVMe and SATA disks on Linux systems only.
+*/
+
+import (
+	"bufio"
+	"errors"
+	"os/exec"
+	"strconv"
+	"strings"
+
+	"imuslab.com/bokofs/bokofsd/mod/diskinfo"
+)
+
+// GetDiskType checks if the disk is NVMe or SATA
+func GetDiskType(disk string) (DiskType, error) {
+	if !strings.HasPrefix(disk, "/dev/") {
+		disk = "/dev/" + disk
+	}
+
+	//Make sure the target is a disk
+	if !diskinfo.DevicePathIsValidDisk(disk) {
+		return DiskType_Unknown, errors.New("disk is not a valid disk")
+	}
+
+	//Check if the disk is a NVMe or SATA disk
+	if strings.HasPrefix(disk, "/dev/nvme") {
+		return DiskType_NVMe, nil
+	} else if strings.HasPrefix(disk, "/dev/sd") {
+		return DiskType_SATA, nil
+	}
+	return DiskType_Unknown, errors.New("disk is not NVMe or SATA")
+}
+
+// GetNVMEInfo retrieves NVMe disk information using smartctl
+func GetNVMEInfo(disk string) (*NVMEInfo, error) {
+	if !strings.HasPrefix(disk, "/dev/") {
+		disk = "/dev/" + disk
+	}
+
+	cmd := exec.Command("smartctl", "-i", disk)
+	output, err := cmd.Output()
+	if err != nil {
+		return nil, err
+	}
+
+	scanner := bufio.NewScanner(strings.NewReader(string(output)))
+	info := &NVMEInfo{}
+
+	for scanner.Scan() {
+		line := scanner.Text()
+		if strings.HasPrefix(line, "Model Number:") {
+			info.ModelNumber = strings.TrimSpace(strings.TrimPrefix(line, "Model Number:"))
+		} else if strings.HasPrefix(line, "Serial Number:") {
+			info.SerialNumber = strings.TrimSpace(strings.TrimPrefix(line, "Serial Number:"))
+		} else if strings.HasPrefix(line, "Firmware Version:") {
+			info.FirmwareVersion = strings.TrimSpace(strings.TrimPrefix(line, "Firmware Version:"))
+		} else if strings.HasPrefix(line, "PCI Vendor/Subsystem ID:") {
+			info.PCIVendorSubsystemID = strings.TrimSpace(strings.TrimPrefix(line, "PCI Vendor/Subsystem ID:"))
+		} else if strings.HasPrefix(line, "IEEE OUI Identifier:") {
+			info.IEEEOUIIdentifier = strings.TrimSpace(strings.TrimPrefix(line, "IEEE OUI Identifier:"))
+		} else if strings.HasPrefix(line, "Total NVM Capacity:") {
+			info.TotalNVMeCapacity = strings.TrimSpace(strings.TrimPrefix(line, "Total NVM Capacity:"))
+		} else if strings.HasPrefix(line, "Unallocated NVM Capacity:") {
+			info.UnallocatedNVMeCapacity = strings.TrimSpace(strings.TrimPrefix(line, "Unallocated NVM Capacity:"))
+		} else if strings.HasPrefix(line, "Controller ID:") {
+			info.ControllerID = strings.TrimSpace(strings.TrimPrefix(line, "Controller ID:"))
+		} else if strings.HasPrefix(line, "NVMe Version:") {
+			info.NVMeVersion = strings.TrimSpace(strings.TrimPrefix(line, "NVMe Version:"))
+		} else if strings.HasPrefix(line, "Number of Namespaces:") {
+			info.NumberOfNamespaces = strings.TrimSpace(strings.TrimPrefix(line, "Number of Namespaces:"))
+		} else if strings.HasPrefix(line, "Namespace 1 Size/Capacity:") {
+			info.NamespaceSizeCapacity = strings.TrimSpace(strings.TrimPrefix(line, "Namespace 1 Size/Capacity:"))
+		} else if strings.HasPrefix(line, "Namespace 1 Utilization:") {
+			info.NamespaceUtilization = strings.TrimSpace(strings.TrimPrefix(line, "Namespace 1 Utilization:"))
+		} else if strings.HasPrefix(line, "Namespace 1 Formatted LBA Size:") {
+			info.NamespaceFormattedLBASize = strings.TrimSpace(strings.TrimPrefix(line, "Namespace 1 Formatted LBA Size:"))
+		} else if strings.HasPrefix(line, "Namespace 1 IEEE EUI-64:") {
+			info.NamespaceIEEE_EUI_64 = strings.TrimSpace(strings.TrimPrefix(line, "Namespace 1 IEEE EUI-64:"))
+		}
+	}
+
+	if err := scanner.Err(); err != nil {
+		return nil, err
+	}
+
+	return info, nil
+}
+
+// GetSATADiskInfo retrieves SATA disk information using smartctl
+func GetSATAInfo(disk string) (*SATADiskInfo, error) {
+	if !strings.HasPrefix(disk, "/dev/") {
+		disk = "/dev/" + disk
+	}
+
+	cmd := exec.Command("smartctl", "-i", disk)
+	output, err := cmd.Output()
+	if err != nil {
+		return nil, err
+	}
+
+	scanner := bufio.NewScanner(strings.NewReader(string(output)))
+	info := &SATADiskInfo{}
+
+	for scanner.Scan() {
+		line := scanner.Text()
+		if strings.HasPrefix(line, "Model Family:") {
+			info.ModelFamily = strings.TrimSpace(strings.TrimPrefix(line, "Model Family:"))
+		} else if strings.HasPrefix(line, "Device Model:") {
+			info.DeviceModel = strings.TrimSpace(strings.TrimPrefix(line, "Device Model:"))
+		} else if strings.HasPrefix(line, "Serial Number:") {
+			info.SerialNumber = strings.TrimSpace(strings.TrimPrefix(line, "Serial Number:"))
+		} else if strings.HasPrefix(line, "Firmware Version:") {
+			info.Firmware = strings.TrimSpace(strings.TrimPrefix(line, "Firmware Version:"))
+		} else if strings.HasPrefix(line, "User Capacity:") {
+			info.UserCapacity = strings.TrimSpace(strings.TrimPrefix(line, "User Capacity:"))
+		} else if strings.HasPrefix(line, "Sector Size:") {
+			info.SectorSize = strings.TrimSpace(strings.TrimPrefix(line, "Sector Size:"))
+		} else if strings.HasPrefix(line, "Rotation Rate:") {
+			info.RotationRate = strings.TrimSpace(strings.TrimPrefix(line, "Rotation Rate:"))
+		} else if strings.HasPrefix(line, "Form Factor:") {
+			info.FormFactor = strings.TrimSpace(strings.TrimPrefix(line, "Form Factor:"))
+		} else if strings.HasPrefix(line, "SMART support is:") {
+			info.SmartSupport = strings.TrimSpace(strings.TrimPrefix(line, "SMART support is:")) == "Enabled"
+		}
+	}
+
+	if err := scanner.Err(); err != nil {
+		return nil, err
+	}
+
+	return info, nil
+}
+
+// SetSMARTEnableOnDisk enables or disables SMART on the specified disk
+func SetSMARTEnableOnDisk(disk string, isEnabled bool) error {
+	if !strings.HasPrefix(disk, "/dev/") {
+		disk = "/dev/" + disk
+	}
+
+	enableCmd := "off"
+	if isEnabled {
+		enableCmd = "on"
+	}
+
+	cmd := exec.Command("smartctl", "-s", enableCmd, disk)
+	output, err := cmd.Output()
+	if err != nil {
+		return err
+	}
+
+	if strings.Contains(string(output), "SMART Enabled") {
+		return nil
+	} else {
+		// Print the command output to STDOUT if enabling SMART failed
+		println(string(output))
+		return errors.New("failed to enable SMART on disk")
+	}
+}
+
+// GetDiskSMARTCheck retrieves the SMART health status of the specified disk
+// Usually only returns "PASSED" or "FAILED"
+func GetDiskSMARTCheck(diskname string) (*SMARTTestResult, error) {
+	if !strings.HasPrefix(diskname, "/dev/") {
+		diskname = "/dev/" + diskname
+	}
+
+	cmd := exec.Command("smartctl", "-H", "-A", diskname)
+	output, err := cmd.Output()
+	if err != nil {
+		// Check if the error is due to exit code 32 (non-critical error for some disks)
+		if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 32 {
+			// Ignore the error and proceed
+		} else {
+			// Print the command output to STDOUT if the command fails
+			println(string(output))
+			return nil, err
+		}
+	}
+
+	scanner := bufio.NewScanner(strings.NewReader(string(output)))
+	result := &SMARTTestResult{
+		TestResult:         "Unknown",
+		MarginalAttributes: make([]SMARTAttribute, 0),
+	}
+	var inAttributesSection bool = false
+
+	for scanner.Scan() {
+		line := scanner.Text()
+		//fmt.Println(line)
+		// Check for overall health result
+		if strings.HasPrefix(line, "SMART overall-health self-assessment test result:") {
+			result.TestResult = strings.TrimSpace(strings.TrimPrefix(line, "SMART overall-health self-assessment test result:"))
+		}
+
+		// Detect the start of the attributes section
+		if strings.HasPrefix(line, "ID# ATTRIBUTE_NAME") {
+			inAttributesSection = true
+			continue
+		}
+
+		// Parse marginal attributes
+		if inAttributesSection {
+			fields := strings.Fields(line)
+			if len(fields) >= 10 {
+				id, err := strconv.Atoi(fields[0])
+				if err != nil {
+					continue
+				}
+				value, err := strconv.Atoi(fields[3])
+				if err != nil {
+					continue
+				}
+				worst, err := strconv.Atoi(fields[4])
+				if err != nil {
+					continue
+				}
+				threshold, err := strconv.Atoi(fields[5])
+				if err != nil {
+					continue
+				}
+
+				attribute := SMARTAttribute{
+					ID:         id,
+					Name:       fields[1],
+					Flag:       fields[2],
+					Value:      value,
+					Worst:      worst,
+					Threshold:  threshold,
+					Type:       fields[6],
+					Updated:    fields[7],
+					WhenFailed: fields[8],
+					RawValue:   strings.Join(fields[9:], " "),
+				}
+				result.MarginalAttributes = append(result.MarginalAttributes, attribute)
+
+			}
+		}
+	}
+
+	if err := scanner.Err(); err != nil {
+		// Print the command output to STDOUT if parsing failed
+		println(string(output))
+		return nil, err
+	}
+
+	if result.TestResult == "" {
+		return nil, errors.New("unable to determine SMART health status")
+	}
+
+	return result, nil
+}
+
+func GetDiskSMARTHealthSummary(diskname string) (*DriveHealthInfo, error) {
+	smartCheck, err := GetDiskSMARTCheck(diskname)
+	if err != nil {
+		return nil, err
+	}
+
+	healthInfo := &DriveHealthInfo{
+		DeviceName: diskname,
+		IsHealthy:  strings.ToUpper(smartCheck.TestResult) == "PASSED",
+	}
+
+	//Populate the device model and serial number from SMARTInfo
+	dt, err := GetDiskType(diskname)
+	if err != nil {
+		return nil, err
+	}
+
+	if dt == DiskType_SATA {
+		sataInfo, err := GetSATAInfo(diskname)
+		if err != nil {
+			return nil, err
+		}
+		healthInfo.DeviceModel = sataInfo.DeviceModel
+		healthInfo.SerialNumber = sataInfo.SerialNumber
+		healthInfo.IsSSD = strings.Contains(sataInfo.RotationRate, "Solid State")
+	} else if dt == DiskType_NVMe {
+		nvmeInfo, err := GetNVMEInfo(diskname)
+		if err != nil {
+			return nil, err
+		}
+		healthInfo.DeviceModel = nvmeInfo.ModelNumber
+		healthInfo.SerialNumber = nvmeInfo.SerialNumber
+		healthInfo.IsNVMe = true
+	} else {
+		return nil, errors.New("unsupported disk type")
+	}
+
+	for _, attr := range smartCheck.MarginalAttributes {
+		switch attr.Name {
+		case "Power_On_Hours":
+			if value, err := strconv.ParseUint(attr.RawValue, 10, 64); err == nil {
+				healthInfo.PowerOnHours = value
+			}
+		case "Power_Cycle_Count":
+			if value, err := strconv.ParseUint(attr.RawValue, 10, 64); err == nil {
+				healthInfo.PowerCycleCount = value
+			}
+		case "Reallocated_Sector_Ct":
+			if value, err := strconv.ParseUint(attr.RawValue, 10, 64); err == nil {
+				healthInfo.ReallocatedSectors = value
+			}
+		case "Wear_Leveling_Count":
+			if value, err := strconv.ParseUint(attr.RawValue, 10, 64); err == nil {
+				healthInfo.WearLevelingCount = value
+			}
+		case "Uncorrectable_Error_Cnt":
+			if value, err := strconv.ParseUint(attr.RawValue, 10, 64); err == nil {
+				healthInfo.UncorrectableErrors = value
+			}
+		case "Current_Pending_Sector":
+			if value, err := strconv.ParseUint(attr.RawValue, 10, 64); err == nil {
+				healthInfo.PendingSectors = value
+			}
+		case "ECC_Recovered":
+			if value, err := strconv.ParseUint(attr.RawValue, 10, 64); err == nil {
+				healthInfo.ECCRecovered = value
+			}
+		case "UDMA_CRC_Error_Count":
+			if value, err := strconv.ParseUint(attr.RawValue, 10, 64); err == nil {
+				healthInfo.UDMACRCErrors = value
+			}
+		case "Total_LBAs_Written":
+			if value, err := strconv.ParseUint(attr.RawValue, 10, 64); err == nil {
+				healthInfo.TotalLBAWritten = value
+			}
+		case "Total_LBAs_Read":
+			if value, err := strconv.ParseUint(attr.RawValue, 10, 64); err == nil {
+				healthInfo.TotalLBARead = value
+			}
+		}
+	}
+
+	return healthInfo, nil
+}

+ 76 - 0
mod/diskinfo/smart/typedef.go

@@ -0,0 +1,76 @@
+package smart
+
+type SATADiskInfo struct {
+	ModelFamily  string
+	DeviceModel  string
+	SerialNumber string
+	Firmware     string
+	UserCapacity string
+	SectorSize   string
+	RotationRate string
+	FormFactor   string
+	SmartSupport bool
+}
+
+type NVMEInfo struct {
+	ModelNumber               string
+	SerialNumber              string
+	FirmwareVersion           string
+	PCIVendorSubsystemID      string
+	IEEEOUIIdentifier         string
+	TotalNVMeCapacity         string
+	UnallocatedNVMeCapacity   string
+	ControllerID              string
+	NVMeVersion               string
+	NumberOfNamespaces        string
+	NamespaceSizeCapacity     string
+	NamespaceUtilization      string
+	NamespaceFormattedLBASize string
+	NamespaceIEEE_EUI_64      string
+}
+
+type DiskType int
+
+const (
+	DiskType_Unknown DiskType = iota
+	DiskType_NVMe
+	DiskType_SATA
+)
+
+type SMARTTestResult struct {
+	TestResult         string
+	MarginalAttributes []SMARTAttribute
+}
+
+type SMARTAttribute struct {
+	ID         int
+	Name       string
+	Flag       string
+	Value      int
+	Worst      int
+	Threshold  int
+	Type       string
+	Updated    string
+	WhenFailed string
+	RawValue   string
+}
+
+type DriveHealthInfo struct {
+	DeviceName           string // e.g., sda
+	DeviceModel          string
+	SerialNumber         string
+	PowerOnHours         uint64
+	PowerCycleCount      uint64
+	ReallocatedSectors   uint64 // HDD
+	ReallocateNANDBlocks uint64 // SSD
+	WearLevelingCount    uint64 // SSD/NVMe
+	UncorrectableErrors  uint64
+	PendingSectors       uint64 // HDD
+	ECCRecovered         uint64
+	UDMACRCErrors        uint64
+	TotalLBAWritten      uint64
+	TotalLBARead         uint64
+	IsSSD                bool
+	IsNVMe               bool
+	IsHealthy            bool //true if the test Passed
+}

+ 1 - 1
mod/disktool/SMART/extract.go → mod/disktool/smartgo/extract.go

@@ -1,4 +1,4 @@
-package smart
+package smartgo
 
 import (
 	"fmt"

+ 1 - 1
mod/disktool/SMART/smart.go → mod/disktool/smartgo/smartgo.go

@@ -1,4 +1,4 @@
-package smart
+package smartgo
 
 import (
 	"errors"

+ 219 - 0
mod/netstat/netstat.go

@@ -0,0 +1,219 @@
+package netstat
+
+import (
+	"encoding/json"
+	"errors"
+	"log"
+	"net/http"
+	"time"
+
+	"github.com/shirou/gopsutil/v4/net"
+	"imuslab.com/bokofs/bokofsd/mod/utils"
+)
+
+// Float stat store the change of RX and TX
+type FlowStat struct {
+	RX int64
+	TX int64
+}
+
+// A new type of FloatStat that save the raw value from rx tx
+type RawFlowStat struct {
+	RX int64
+	TX int64
+}
+
+type NetStatBuffers struct {
+	StatRecordCount int          //No. of record number to keep
+	PreviousStat    *RawFlowStat //The value of the last instance of netstats
+	Stats           []*FlowStat  //Statistic of the flow
+	StopChan        chan bool    //Channel to stop the ticker
+	EventTicker     *time.Ticker //Ticker for event logging
+}
+
+// Get a new network statistic buffers
+func NewNetStatBuffer(recordCount int) (*NetStatBuffers, error) {
+	//Flood fill the stats with 0
+	initialStats := []*FlowStat{}
+	for i := 0; i < recordCount; i++ {
+		initialStats = append(initialStats, &FlowStat{
+			RX: 0,
+			TX: 0,
+		})
+	}
+
+	//Setup a timer to get the value from NIC accumulation stats
+	ticker := time.NewTicker(time.Second)
+
+	//Setup a stop channel
+	stopCh := make(chan bool)
+
+	currnetNetSpec := RawFlowStat{
+		RX: 0,
+		TX: 0,
+	}
+
+	thisNetBuffer := NetStatBuffers{
+		StatRecordCount: recordCount,
+		PreviousStat:    &currnetNetSpec,
+		Stats:           initialStats,
+		StopChan:        stopCh,
+		EventTicker:     ticker,
+	}
+
+	//Get the initial measurements of netstats
+	rx, tx, err := thisNetBuffer.GetNetworkInterfaceStats()
+	if err != nil {
+		log.Println("netstat", "Unable to get NIC stats: ", err)
+	}
+
+	retryCount := 0
+	for rx == 0 && tx == 0 && retryCount < 10 {
+		//Strange. Retry
+		log.Println("netstat", "NIC stats return all 0. Retrying...", nil)
+		rx, tx, err = thisNetBuffer.GetNetworkInterfaceStats()
+		if err != nil {
+			log.Println("netstat", "Unable to get NIC stats: ", err)
+		}
+		retryCount++
+	}
+
+	thisNetBuffer.PreviousStat = &RawFlowStat{
+		RX: rx,
+		TX: tx,
+	}
+
+	// Update the buffer every second
+	go func(n *NetStatBuffers) {
+		for {
+			select {
+			case <-n.StopChan:
+				log.Println("netstat", "Netstats listener stopped", nil)
+				return
+
+			case <-ticker.C:
+				if n.PreviousStat.RX == 0 && n.PreviousStat.TX == 0 {
+					//Initiation state is still not done. Ignore request
+					log.Println("netstat", "No initial states. Waiting", nil)
+					return
+				}
+				// Get the latest network interface stats
+				rx, tx, err := thisNetBuffer.GetNetworkInterfaceStats()
+				if err != nil {
+					// Log the error, but don't stop the buffer
+					log.Println("netstat", "Failed to get network interface stats", err)
+					continue
+				}
+
+				//Calculate the difference between this and last values
+				drx := rx - n.PreviousStat.RX
+				dtx := tx - n.PreviousStat.TX
+
+				// Push the new stats to the buffer
+				newStat := &FlowStat{
+					RX: drx,
+					TX: dtx,
+				}
+
+				//Set current rx tx as the previous rxtx
+				n.PreviousStat = &RawFlowStat{
+					RX: rx,
+					TX: tx,
+				}
+
+				newStats := n.Stats[1:]
+				newStats = append(newStats, newStat)
+
+				n.Stats = newStats
+			}
+		}
+	}(&thisNetBuffer)
+
+	return &thisNetBuffer, nil
+}
+
+func (n *NetStatBuffers) HandleGetBufferedNetworkInterfaceStats(w http.ResponseWriter, r *http.Request) {
+	arr, _ := utils.GetPara(r, "array")
+	if arr == "true" {
+		//Restructure it into array
+		rx := []int{}
+		tx := []int{}
+
+		for _, state := range n.Stats {
+			rx = append(rx, int(state.RX))
+			tx = append(tx, int(state.TX))
+		}
+
+		type info struct {
+			Rx []int
+			Tx []int
+		}
+
+		js, _ := json.Marshal(info{
+			Rx: rx,
+			Tx: tx,
+		})
+		utils.SendJSONResponse(w, string(js))
+	} else {
+		js, _ := json.Marshal(n.Stats)
+		utils.SendJSONResponse(w, string(js))
+	}
+
+}
+
+func (n *NetStatBuffers) Close() {
+	//Fixed issue #394 for stopping netstat listener on platforms not supported platforms
+	if n.StopChan != nil {
+		n.StopChan <- true
+		time.Sleep(300 * time.Millisecond)
+	}
+
+	if n.EventTicker != nil {
+		n.EventTicker.Stop()
+	}
+
+}
+
+func (n *NetStatBuffers) HandleGetNetworkInterfaceStats(w http.ResponseWriter, r *http.Request) {
+	rx, tx, err := n.GetNetworkInterfaceStats()
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	currnetNetSpec := struct {
+		RX int64
+		TX int64
+	}{
+		rx,
+		tx,
+	}
+
+	js, _ := json.Marshal(currnetNetSpec)
+	utils.SendJSONResponse(w, string(js))
+}
+
+// Get network interface stats, return accumulated rx bits, tx bits and error if any
+func (n *NetStatBuffers) GetNetworkInterfaceStats() (int64, int64, error) {
+	// Get aggregated network I/O stats for all interfaces
+	counters, err := net.IOCounters(false)
+	if err != nil {
+		return 0, 0, err
+	}
+	if len(counters) == 0 {
+		return 0, 0, errors.New("no network interfaces found")
+	}
+
+	var totalRx, totalTx uint64
+	for _, counter := range counters {
+		totalRx += counter.BytesRecv
+		totalTx += counter.BytesSent
+	}
+
+	// Convert bytes to bits with overflow check
+	const maxInt64 = int64(^uint64(0) >> 1)
+	if totalRx*8 > uint64(maxInt64) || totalTx*8 > uint64(maxInt64) {
+		return 0, 0, errors.New("overflow detected when converting uint64 to int64")
+	}
+	return int64(totalRx * 8), int64(totalTx * 8), nil
+}

+ 55 - 0
mod/netstat/nic.go

@@ -0,0 +1,55 @@
+package netstat
+
+import (
+	"encoding/json"
+	"net"
+	"net/http"
+
+	"imuslab.com/bokofs/bokofsd/mod/utils"
+)
+
+type NetworkInterface struct {
+	Name string
+	ID   int
+	IPs  []string
+}
+
+func HandleListNetworkInterfaces(w http.ResponseWriter, r *http.Request) {
+	nic, err := ListNetworkInterfaces()
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	js, _ := json.Marshal(nic)
+	utils.SendJSONResponse(w, string(js))
+}
+
+func ListNetworkInterfaces() ([]NetworkInterface, error) {
+	var interfaces []NetworkInterface
+
+	ifaces, err := net.Interfaces()
+	if err != nil {
+		return nil, err
+	}
+
+	for _, iface := range ifaces {
+		var ips []string
+		addrs, err := iface.Addrs()
+		if err != nil {
+			return nil, err
+		}
+
+		for _, addr := range addrs {
+			ips = append(ips, addr.String())
+		}
+
+		interfaces = append(interfaces, NetworkInterface{
+			Name: iface.Name,
+			ID:   iface.Index,
+			IPs:  ips,
+		})
+	}
+
+	return interfaces, nil
+}

+ 81 - 26
web/components/disks.html

@@ -1,36 +1,32 @@
 
 <div id="disk-list">
     <div class="ts-box ts-content disk-info">
-        <div class="ts-grid mobile:is-stacked">
-            <div class="column is-fluid">
-                <div class="ts-item">
-                    <div class="ts-header">DiskName</div>
-                    <span>Size</span>
-                    <span>Status</span>
-                </div>
-            </div>
-            <div class="column is-6-wide">
-                <div class="ts-wrap is-middle-aligned">
-                    <div class="ts-gauge is-small is-circular">
-                        <div class="bar" style="--value: 38">
-                            <div class="text">38%</div>
-                        </div>
-                    </div>
-                    <div>
-                        <div class="ts-text is-bold">空間</div>
-                        19.12 GB / 50 GB
-                    </div>
-                </div>
-            </div>
-        </div>
+        <span class="ts-icon is-spinning is-circle-notch-icon"></span>
+        <span class="has-start-padded-small" i18n>
+            Loading...
+        </span>
     </div>
 </div>
+<div class="ts-wrap is-end-aligned">
+    <button id="refresh_disk_list_btn" class="ts-button is-start-icon has-top-spaced-large" >
+        <span class="ts-icon is-rotate-icon"></span>
+        <span i18n>
+            Refresh
+            // 重新整理
+    </button>
+</div>
 <script>
     function humanFileSize(size) {
         var i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
         return +((size / Math.pow(1024, i)).toFixed(1)) * 1 + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i];
     }
 
+    $("#refresh_disk_list_btn").click(function(){
+        $("#disk-list").html("");
+        loadDiskInfo();
+        msgbox(i18nc('disk_info_refreshed'));
+    });
+
     function loadDiskInfo(){
         $.get("./api/info/list", function(data){
             if (data) {
@@ -38,7 +34,56 @@
                 var diskList = $("#disk-list");
                 diskList.empty();
                 for (var i = 0; i < disks.length; i++) {
-                    var disk = disks[i];
+                    let disk = disks[i];
+                    let partitionDOM = "";
+                    let partitionTabs = "";
+
+                    //Render the partition tabs
+                    for (var j = 0; j < disk.partitions.length; j++) {
+                        let partition = disk.partitions[j];
+                        partitionTabs += `<a class="item ${j==0?"is-active":""}" data-tab="diskinfo_partition_${partition.name}">${partition.name}</a>`;
+                    }
+                    //Render the partition dom elements
+                    for (var j = 0; j < disk.partitions.length; j++) {
+                        let partition = disk.partitions[j];
+                        partitionDOM += `<div id="diskinfo_partition_${partition.name}" class="ts-box has-top-spaced-small">
+                                            <div class="ts-content">
+                                                <div class="ts-header">${partition.name}</div>
+                                                <div class="ts-grid mobile:is-stacked">
+                                                    <div class="column is-fluid">
+                                                        <div class="ts-text is-description has-top-spaced-small">
+                                                            UUID: ${partition.uuid} <br>
+                                                            PartUUID: ${partition.partuuid} <br>
+                                                            PartLabel: ${partition.partlabel} <br>
+                                                            Path: ${partition.path} <br>
+                                                            Block Size: ${partition.blocksize} <br>
+                                                            Block Type: ${partition.blocktype} <br>
+                                                            File System Type: ${partition.fstype} <br>
+                                                            Mount Point: ${partition.mountpoint==undefined?"":partition.mountpoint} <br>
+                                                        </div>
+                                                    </div>
+                                                    <div class="column is-6-wide">
+                                                        <div class="ts-wrap is-middle-aligned has-top-spaced-small">
+                                                            <div class="ts-gauge is-small is-circular">
+                                                                <div class="bar" style="--value: ${parseInt(partition.used / partition.size * 100)}">
+                                                                    <div class="text">${parseInt(partition.used / partition.size * 100)}%</div>
+                                                                </div>
+                                                            </div>
+                                                            <div>
+                                                                <div class="ts-text is-bold" i18n>
+                                                                    Used Space
+                                                                    // 已使用空間
+                                                                </div>
+                                                            ${humanFileSize(partition.used)} / ${humanFileSize(partition.size)}
+                                                            </div>
+                                                        </div>
+                                                    </div>
+                                                </div>
+                                                
+                                            </div>
+                                        </div>`;
+                    }
+
                     $(diskList).append(`<div class="ts-box ts-content has-top-spaced-small disk-info">
                             <div class="ts-grid mobile:is-stacked">
                                 <div class="column is-fluid">
@@ -47,25 +92,35 @@
                                         <div class="ts-text is-description has-top-spaced-small">
                                             ${disk.identifier} 
                                         </div>
-                                        <span>/dev/${disk.name} (含 ${disk.partitions.length} 個分割區)</span>
+                                        <span>/dev/${disk.name}</span>
                                     </div>
                                 </div>
                                 <div class="column is-6-wide">
-                                    <div class="ts-wrap is-middle-aligned">
+                                    <div class="ts-wrap is-middle-aligned has-top-spaced-small">
                                         <div class="ts-gauge is-small is-circular">
                                             <div class="bar" style="--value: ${parseInt(disk.used / disk.size * 100)}">
                                                 <div class="text">${parseInt(disk.used / disk.size * 100)}%</div>
                                             </div>
                                         </div>
                                         <div>
-                                            <div class="ts-text is-bold">已使用空間</div>
+                                            <div class="ts-text is-bold" i18n>
+                                                Total Space Used
+                                                // 總空間使用率
+                                            </div>
                                         ${humanFileSize(disk.used)} / ${humanFileSize(disk.size)}
                                         </div>
                                     </div>
                                 </div>
                             </div>
+                            <div class="has-top-spaced-big">
+                                <div class="ts-tab is-segmented">
+                                    ${partitionTabs}
+                                </div>
+                                ${partitionDOM}
+                            </div>
                     </div>`);
                 }
+                relocale();
             } else {
                 console.error("Failed to load disk info: " + data.message);
             }

+ 429 - 5
web/components/status.html

@@ -1,7 +1,431 @@
 <div class="ts-content is-rounded is-padded has-top-spaced-large" style="background: var(--ts-gray-800); color: var(--ts-gray-50)">
-    <div style="max-width: 300px">
-        <div class="ts-header is-huge is-heavy">數十年前被魚群圍毆的 Teacat 開發者</div>
-        <p>你能夠想像一個試圖釣魚卻又被魚群圍毆的Teacat 開發者? 2016 年一個驚為天人的祕密,這史書尚未記載的故事將在此完全揭露。</p>
-        <a href="#!" class="ts-button is-outlined" style="color: var(--ts-gray-50)">繼續閱讀</a>
+    <div style="max-width: 480px">
+        <div class="ts-header is-huge is-heavy">
+            <span class="ts-icon is-positive is-heading is-check-icon" style="color: var(--ts-positive-500);"></span>
+            <span i18n>Looks Good
+                // 看起來不錯
+            </span>
+        </div>
+        <p i18n>This status shows you the general idea on how this storage node is doing in terms of disk health and other system conditions. See system analytic report for more details.
+            // 此狀態顯示了這個儲存節點在磁碟健康和其他系統條件方面的整體情況。 有關詳細資訊,請參閱系統分析報告。
+        </p>
+        <a href="#!" class="ts-button is-outlined" style="color: var(--ts-gray-50)" i18n>
+            Analytics Report
+            // 分析報告
+        </a>
     </div>
-</div>
+</div>
+<!-- Network IO -->
+<div class="ts-box has-top-spaced-large is-rounded is-padded ">
+    <div class="ts-content">
+        <div class="ts-header" i18n>Real-time Network IO
+            // 即時網路流量
+        </div>
+        <div id="networkActWrapper" class="has-top-spaced-large" style="position: relative;">
+            <canvas id="networkActivity"></canvas>
+        </div>
+        <div id="networkActivityPlaceHolder" class="ts-blankslate is-secondary" style="display:none;"> 
+            <div class="header" i18n>Graph Render Paused 
+                // 已暫停圖表運算
+            </div>
+            <div class="description" i18n>Graph resumes after resizing or refocus
+                // 當頁面調整大小或重新聚焦後,圖表將恢復運算
+            </div>
+        </div>
+    </div>
+    <div class="ts-content is-dense">
+        <i class="ts-icon is-end-spaced is-circle-down-icon" style="color: #1890ff;"></i>
+        <span i18n>Inbound Traffic
+            // 進站流量
+        </span>
+        <i class="ts-icon is-end-spaced has-start-spaced-large is-circle-up-icon" style="color: #52c41a;"></i>
+        <span i18n>Outbound Traffic
+            // 出站流量
+        </span>
+    
+    </div>
+</div>  
+<!-- Disk SMARTs -->
+<div class="has-top-spaced-large is-padded">
+    <div id="disk-smart-overview" class="ts-grid has-top-spaced-large is-relaxed is-3-columns is-stretched mobile:is-stacked">
+        <div class="column">
+            <div class="ts-content is-rounded is-padded">
+                <div class="ts-header is-truncated is-large is-heavy" i18n>
+                    SMART Status
+                    // 磁碟健康狀態
+                </div>
+                <p>
+                    <span class="ts-icon is-spinning is-circle-notch-icon"></span>
+                    <span i18n>Loading
+                        // 載入中
+                    </span>
+                </p>
+            </div>
+        </div>
+    </div>
+</div>  
+
+
+<script>
+
+    // Function to format power-on hours into a human-readable format
+    function formatPowerOnHours(hours) {
+        const hoursInDay = 24;
+        const hoursInMonth = hoursInDay * 30; // Approximate month duration
+        const hoursInYear = hoursInDay * 365; // Approximate year duration
+
+        if (hours >= hoursInYear) {
+            return [(hours / hoursInYear).toFixed(1),`
+                years
+                // 年
+            `];
+        } else if (hours >= hoursInMonth) {
+            return [(hours / hoursInMonth).toFixed(1), `
+                months
+                // 個月
+            `];
+        } else if (hours >= hoursInDay) {
+            return [(hours / hoursInDay).toFixed(1), `
+                days
+                // 天
+            `];
+        } else {
+            return [hours.toFixed(1), `
+                hours
+                // 個小時
+            `];
+        }
+    }
+
+    // Function to format power-on hours into a human-readable format
+    function evaluateDriveHealth(info) {
+        if (!info) return 'unknown';
+
+        // Shortcut for IsHealthy flag from backend
+        if (!info.IsHealthy) return 'not_healthy';
+
+        // Thresholds based on SMART data experience
+        const thresholds = {
+            reallocated: 10,         // more than 10 sectors or blocks is a red flag
+            pending: 1,              // any pending sectors is worrying
+            uncorrectable: 1,        // same
+            udmaCrc: 10,             // interface problems
+            powerCycleHigh: 1000,    // maybe indicates hardware or power issues
+            wearLevel: 1000,         // beyond this, flash wear is a concern
+        };
+
+        let issues = 0;
+
+        if (info.ReallocatedSectors > thresholds.reallocated ||
+            info.ReallocateNANDBlocks > thresholds.reallocated) {
+            // Reallocated sectors or blocks
+            if (info.ReallocatedSectors > thresholds.reallocated * 3) {
+                return 'not_healthy';
+            }
+            issues++;
+            
+        }
+
+        if (info.PendingSectors >= thresholds.pending) issues++;
+        if (info.UncorrectableErrors >= thresholds.uncorrectable) issues++;
+        if (info.UDMACRCErrors >= thresholds.udmaCrc) issues++;
+
+        if (info.WearLevelingCount >= thresholds.wearLevel) issues++;
+
+        // SSDs may silently degrade with increasing wear even if no reallocation yet
+        if (info.IsSSD && info.WearLevelingCount > 0 && info.WearLevelingCount < 100) {
+            return 'attention';
+        }
+
+        if (issues === 0) {
+            return 'healthy';
+        } else if (issues === 1) {
+            return 'attention';
+        } else {
+            return 'not_healthy';
+        }
+    }
+
+
+    function initDiskSmartHealthOverview(){
+        $.get("./api/smart/health/all", function(data){
+            $("#disk-smart-overview").html("");
+            for (var i = 0; i < data.length; i++){
+                let disk = data[i];
+                let healthState = evaluateDriveHealth(disk);
+                let iconClass = ``;
+                let iconColor = ``;
+                let tsBoxExtraCss = ``;
+                if (healthState == "healthy"){
+                    iconClass = "ts-icon is-positive is-heading is-circle-check-icon";
+                    iconColor = "var(--ts-positive-500)";
+                }else if (healthState == "attention"){
+                    iconClass = "ts-icon is-warning is-heading is-circle-exclamation-icon";
+                    iconColor = "var(--ts-warning-500)";
+                }else if (healthState == "not_healthy"){
+                    iconClass = "ts-icon is-danger is-heading is-circle-xmark-icon";
+                    iconColor = "var(--ts-gray-300)";
+                    tsBoxExtraCss = `background-color: var(--ts-negative-400);`;
+                }else{
+                    iconClass = "ts-icon is-heading is-circle-question-icon";
+                    iconColor = "var(--ts-gray-500)";
+                }
+
+                let poweronDuration = formatPowerOnHours(disk.PowerOnHours);
+                $("#disk-smart-overview").append(`<div class="column">
+                    <div class="ts-box ts-content is-rounded is-padded" style="${tsBoxExtraCss}">
+                        <div class="ts-header is-truncated is-heavy">
+                            ${disk.DeviceModel}
+                        </div>
+                        <div class="ts-text has-top-spaced-small">
+                            <span class="ts-badge">/dev/${disk.DeviceName}</span> 
+                            <span class="ts-badge">${disk.SerialNumber}</span> 
+                        </div>
+                        <div class="ts-grid is-evenly-divided has-top-spaced-large">
+                            <div class="column">
+                                <div class="ts-text is-label" i18n>
+                                    Power-on Time
+                                    // 運行時間
+                                </div>
+                                <div class="ts-statistic">
+                                    <div class="value">${poweronDuration[0]}</div>
+                                    <div class="unit" i18n>${poweronDuration[1]}</div>
+                                </div>
+                            </div>
+                            <div class="column">
+                                <div class="ts-text is-label" i18n>Power Cycles
+                                    // 開機次數
+                                </div>
+                                <div class="ts-statistic">
+                                    <div class="value">${disk.PowerCycleCount}</div>
+                                    <div class="unit" i18n>
+                                        // 次</div>
+                                </div>
+                            </div>
+                        </div>
+                        <div class="symbol">
+                            <span style="color: ${iconColor}; opacity: 0.4; z-index: 0;" class="${iconClass}"></span>
+                        </div>
+                    </div>
+                </div>`);
+            }
+            if (data.length == 0){
+                $("#disk-smart-overview").append(`<div class="column">
+                    <div class="ts-box ts-content is-rounded is-padded">
+                        <div class="ts-text" i18n>
+                            No SMART data available
+                            // 沒有可用的磁碟健康資料
+                        </div>
+                         <div class="symbol">
+                            <span style="color: var(--ts-positive-400); opacity: 0.4; z-index: 0;" class="ts-icon is-circle-check-icon"></span>
+                        </div>
+                    </div>
+                </div>`);
+            }
+
+            relocale();
+        });
+    }
+
+    $(document).ready(function(){
+        initDiskSmartHealthOverview();
+    });
+   
+</script>
+
+<!-- Network IO Chart -->
+<script src="./js/chart.js"></script>
+<script>
+    /*
+        Render Network Activity Graph
+    */
+
+    let rxValues = [];
+    let txValues = [];
+    let dataCount = 300;
+    let timestamps = [];
+
+    for(var i = 0; i < dataCount; i++){
+        timestamps.push(new Date(Date.now() + i * 1000).toLocaleString().replace(',', ''));
+    }
+
+    function fetchData() {
+        $.ajax({
+            url: './api/info/netstat?array=true',
+            success: function(data){
+                if (rxValues.length == 0){
+                    rxValues.push(...data.Rx);
+                }else{
+                    rxValues.push(data.Rx[dataCount-1]);
+                    rxValues.shift();
+                }
+
+                if (txValues.length == 0){
+                    txValues.push(...data.Tx);
+                }else{
+                    txValues.push(data.Tx[dataCount-1]);
+                    txValues.shift();
+                }
+                
+                timestamps.push(new Date(Date.now()).toLocaleString().replace(',', ''));
+                timestamps.shift();
+                updateChart();
+            }
+        })
+    }
+
+    function formatBandwidth(bps) {
+        const KBPS = 1000;
+        const MBPS = 1000 * KBPS;
+        const GBPS = 1000 * MBPS;
+
+        if (bps >= GBPS) {
+            return (bps / GBPS).toFixed(1) + " Gbps";
+        } else if (bps >= MBPS) {
+            return (bps / MBPS).toFixed(1) + " Mbps";
+        } else if (bps >= KBPS) {
+            return (bps / KBPS).toFixed(1) + " Kbps";
+        } else {
+            return bps.toFixed(1) + " bps";
+        }
+    }
+
+    function changeScaleTextColor(color){
+        networkStatisticChart.options.scales.y.ticks.color = color;
+        networkStatisticChart.update();
+    }
+
+    var networkStatisticChart;
+    function initChart(){
+        $.get("./api/info/netstat", function(data){
+        networkStatisticChart = new Chart(
+                document.getElementById('networkActivity'),
+                {
+                    type: 'line',
+                    responsive: true,
+                    resizeDelay: 300,
+                    options: {
+                        animation: false,
+                        maintainAspectRatio: false,
+                        bezierCurve: true,
+                        tooltips: {enabled: false},
+                        hover: {mode: null},
+                        //stepped: 'middle',
+                        plugins: {
+                            legend: {
+                                display: false,
+                                position: "right",
+                            },
+                            title: {
+                                display: false,
+                                text: 'Network Statistic'
+                            },
+                        },
+                        scales: {
+                            x: {
+                                display: false,
+                                },
+                            y: {
+                                display: true,
+                                scaleLabel: {
+                                    display: true,
+                                    labelString: 'Value'
+                                },
+                                ticks: {
+                                    stepSize: 10000000,
+                                    callback: function(label, index, labels) {
+                                        return formatBandwidth(parseInt(label));
+                                    },
+                                    color: $("html").hasClass("is-dark") ? "#ffffff" : "#000000",
+                                },
+                                gridLines: {
+                                    display: true
+                                }
+                            }
+                        }
+                    },
+                    data: {
+                        labels: timestamps,
+                        datasets: [
+                            {
+                                label: 'In (bps)',
+                                data: rxValues,
+                                borderColor: "#1890ff",
+                                borderWidth: 1,
+                                backgroundColor: '#1890ff',
+                                fill: true,
+                                pointStyle: false,
+                            },
+                            {
+                                label: 'Out (bps)',
+                                data: txValues,
+                                borderColor: '#52c41a',
+                                borderWidth: 1,
+                                backgroundColor: '#52c41a',
+                                fill: true,
+                                pointStyle: false,
+                            }
+                        ]
+                    }
+                }
+            );
+        });
+    }
+
+    function updateChart() {
+        //Do not remove these 3 lines, it will cause memory leak
+        if (typeof(networkStatisticChart) == "undefined"){
+            return;
+        }
+        networkStatisticChart.data.datasets[0].data = rxValues;
+        networkStatisticChart.data.datasets[1].data = txValues;
+        networkStatisticChart.data.labels = timestamps;
+        if (networkStatisticChart != undefined){
+            networkStatisticChart.update();
+        }
+    }
+
+    function updateChartSize(){
+        let newSize = $("#networkActWrapper").width() - 300;
+        if (window.innerWidth > 750){
+            newSize = window.innerWidth - $(".toolbar").width() - 500;
+        }else{
+            newSize = $("#networkActWrapper").width() - 500;
+        }
+        if (networkStatisticChart != undefined){
+            networkStatisticChart.resize(newSize, 200);
+        }
+    }
+
+    function handleChartAccumulateResize(){
+        $("#networkActivity").hide();
+        $("#networkActivityPlaceHolder").show();
+        if (chartResizeTimeout != undefined){
+            clearTimeout(chartResizeTimeout);
+        }
+        chartResizeTimeout = setTimeout(function(){
+            chartResizeTimeout = undefined;
+            $("#networkActivityPlaceHolder").hide();
+            $("#networkActivity").show();
+            updateChartSize();
+        }, 300);
+    }
+
+    var chartResizeTimeout;
+    window.addEventListener('resize', () => {
+        handleChartAccumulateResize();
+    });
+
+    window.addEventListener("focus", function(event){
+        handleChartAccumulateResize();
+    });
+
+    
+    //Initialize chart data
+    initChart();
+    fetchData();
+    setInterval(fetchData, 1000);
+    setTimeout(function(){
+        handleChartAccumulateResize();
+    }, 1000);
+</script>

+ 43 - 2
web/index.html

@@ -19,6 +19,14 @@
     <!-- Locales -->
     <script src="./js/dom-i18n.min.js"></script>
     <script src="./js/theme.js"></script>
+    <style>
+        #msgbox{
+            position: fixed;
+            bottom: 1em;
+            right: 1em;
+            z-index: 9999;
+        }
+    </style>
 </head>
 <body>
     <div class="ts-content ">
@@ -83,14 +91,47 @@
             <div class="ts-content boko-panel-component" id="tab-settings">Settings</div>
         </div>  
     </div>
+    <div class="ts-container">
+        <div class="ts-divider"></div>
+        <div class="ts-content">
+            <div class="ts-text">
+                BokoFS © tobychui 2024 - <span class="thisyear">2025</span>
+            </div>
+        </div>
+    </div>
+    <div id="msgbox" class="ts-snackbar has-start-padded-large has-end-padded-large">
+        <div class="content"></div>
+        <button class="close"></button>
+    </div>
     <script>
+        var ajaxRequests = [];
         $(".boko-panel-component").each(function(){
             var component = $(this).attr("component");
             if (component) {
-                $(this).load("./components/" + component);
+               $(this).load("./components/" + component, function(response, status, xhr) {
+                   if (status == "success") {
+                       console.log("Component loaded successfully:", component);
+                       if (typeof(relocale) != "undefined") {
+                           relocale();
+                       }
+                   } else {
+                       console.error("Failed to load component:", component, xhr.status, xhr.statusText);
+                   }
+               });
             }
-        })
+        });
+
+        $(".thisyear").text(new Date().getFullYear());
+
+        function msgbox(msg, delay=3000){
+            $("#msgbox .content").text(msg);
+            $("#msgbox").stop().finish().fadeIn(200).delay(delay).fadeOut(200);
+        }
 
+        $("#msgbox .close").click(function(){
+            $("#msgbox").stop().finish().fadeOut(200);
+        });
+        $("#msgbox").hide();
     </script>
     <script src="./js/locale.js"></script>
 </body>

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 12 - 0
web/js/chart.js


+ 34 - 1
web/js/locale.js

@@ -6,6 +6,7 @@ let languageNames = {
     'en': 'English',
     'zh': '中文(正體)'
 };
+let currentLanguage = 'en';
 
 //Initialize the i18n dom library
 var i18n = domI18n({
@@ -23,10 +24,42 @@ $(document).ready(function(){
         userLang = 'en';
     }
     i18n.changeLanguage(userLang);
+    currentLanguage = userLang;
 });
 
+// Update language on newly loaded content
+function relocale(){
+    i18n.changeLanguage(currentLanguage);
+}
+
 function setCurrentLanguage(newLanguage){
     let languageName = languageNames[newLanguage];
+    currentLanguage = newLanguage;
     $("#currentLanguage").html(languageName);
     i18n.changeLanguage(newLanguage);
-}
+}
+
+/* Other Translated messages */
+function i18nc(key, language=undefined){
+   if (language === undefined){
+       language = currentLanguage;
+    }
+
+    let translatedMessage = translatedMessages[language][key];
+    if (translatedMessage === undefined){
+        translatedMessage = translatedMessages['en'][key];
+    }
+    if (translatedMessage === undefined){
+        translatedMessage = key;
+    }
+    return translatedMessage;
+}
+
+let translatedMessages = {
+    'en': {
+        'disk_info_refreshed': 'Disk information reloaded',
+    },
+    'zh': {
+        'disk_info_refreshed': '磁碟資訊已重新載入',
+    }
+};

+ 11 - 0
web/js/theme.js

@@ -14,9 +14,20 @@ function updateElementToTheme(isDarkTheme=false){
     if (!isDarkTheme){
         $("#sysicon").attr("src", "./img/logo.svg");
         $("#darkModeToggle").html(`<span class="ts-icon is-sun-icon"></span>`);
+
+        // Update the rendering text color in the garphs
+        if (typeof(changeScaleTextColor) != "undefined"){
+            changeScaleTextColor("black");
+        }
+       
     }else{
         $("#sysicon").attr("src", "./img/logo_white.svg");
         $("#darkModeToggle").html(`<span class="ts-icon is-moon-icon"></span>`);
+        
+        // Update the rendering text color in the garphs
+        if (typeof(changeScaleTextColor) != "undefined"){
+            changeScaleTextColor("white");
+        }
     }
 }
 

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.