Browse Source

Added working disk info api

TC 3 weeks ago
parent
commit
cbf56691f2

+ 126 - 0
api.go

@@ -0,0 +1,126 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"log"
+	"net/http"
+	"strings"
+
+	"imuslab.com/bokofs/bokofsd/mod/diskinfo"
+)
+
+/*
+	API Router
+
+	This module handle routing of the API calls
+*/
+
+// Primary handler for the API router
+func HandlerAPIcalls() http.Handler {
+	return http.StripPrefix("/api", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		// Get the disk ID from the URL path
+		pathParts := strings.Split(r.URL.Path, "/")
+		if len(pathParts) < 2 {
+			http.Error(w, "Bad Request", http.StatusBadRequest)
+			return
+		}
+
+		diskID := pathParts[1]
+		if diskID == "" {
+			http.Error(w, "Bad Request", http.StatusBadRequest)
+			return
+		}
+
+		if diskID == "system" {
+			HandleSystemAPIcalls().ServeHTTP(w, r)
+			return
+		}
+
+		fmt.Fprintf(w, "API call for disk ID: %s\n", diskID)
+	}))
+}
+
+// Handler for system API calls
+func HandleSystemAPIcalls() http.Handler {
+	return http.StripPrefix("/system/", 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 "diskinfo":
+			if len(pathParts) < 2 {
+				http.Error(w, "Bad Request - Invalid disk name", http.StatusBadRequest)
+				return
+			}
+			diskID := pathParts[1]
+			if diskID == "" {
+				http.Error(w, "Bad Request - Invalid disk name", http.StatusBadRequest)
+				return
+			}
+
+			if !diskinfo.DevicePathIsValidDisk(diskID) {
+				log.Println("Invalid disk ID:", diskID)
+				http.Error(w, "Bad Request", http.StatusBadRequest)
+				return
+			}
+
+			// Handle diskinfo API calls
+			targetDiskInfo, err := diskinfo.GetDiskInfo(diskID)
+			if err != nil {
+				http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+				return
+			}
+
+			// Convert the disk info to JSON and write it to the response
+			js, _ := json.Marshal(targetDiskInfo)
+			w.Header().Set("Content-Type", "application/json")
+			w.WriteHeader(http.StatusOK)
+			w.Write(js)
+			return
+		case "partinfo":
+			if len(pathParts) < 2 {
+				http.Error(w, "Bad Request - Missing parition name", http.StatusBadRequest)
+				return
+			}
+			partID := pathParts[1]
+			if partID == "" {
+				http.Error(w, "Bad Request - Missing parition name", http.StatusBadRequest)
+				return
+			}
+
+			if !diskinfo.DevicePathIsValidPartition(partID) {
+				log.Println("Invalid partition name:", partID)
+				http.Error(w, "Bad Request - Invalid parition name", http.StatusBadRequest)
+				return
+			}
+
+			// Handle partinfo API calls
+			targetPartInfo, err := diskinfo.GetPartitionInfo(partID)
+			if err != nil {
+				http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+				return
+			}
+
+			// Convert the partition info to JSON and write it to the response
+			js, _ := json.Marshal(targetPartInfo)
+			w.Header().Set("Content-Type", "application/json")
+			w.WriteHeader(http.StatusOK)
+			w.Write(js)
+
+			return
+		case "partlist":
+
+			// Handle partlist API calls
+			return
+
+		case "diskusage":
+			// Handle diskusage API calls
+			fmt.Fprintln(w, "Disk usage API call")
+		default:
+			fmt.Println("Unknown API call:", subPath)
+			http.Error(w, "Not Found", http.StatusNotFound)
+		}
+
+	}))
+}

+ 2 - 0
main.go

@@ -84,6 +84,8 @@ func main() {
 		fmt.Fprintln(w, "Meta handler not implemented yet")
 	}))
 
+	http.Handle("/api/", HandlerAPIcalls())
+
 	addr := fmt.Sprintf(":%d", *httpPort)
 	fmt.Printf("Starting static web server on %s\n", addr)
 	if err := http.ListenAndServe(addr, nil); err != nil {

+ 11 - 6
mod/diskinfo/blkid/blkid.go

@@ -1,5 +1,10 @@
 package blkid
 
+/*
+Package blkid provides functions to retrieve block device information
+Usually this will only return partitions info
+*/
+
 import (
 	"bufio"
 	"errors"
@@ -10,12 +15,12 @@ import (
 )
 
 type BlockDevice struct {
-	Device    string
-	UUID      string
-	BlockSize int
-	Type      string
-	PartUUID  string
-	PartLabel string
+	Device    string // Device name (e.g., /dev/sda1)
+	UUID      string // UUID of the device
+	BlockSize int    // Block size in bytes
+	Type      string // Type of the device (e.g., ext4, ntfs)
+	PartUUID  string // Partition UUID
+	PartLabel string // Partition label
 }
 
 // GetBlockDevices retrieves block devices using the `blkid` command.

+ 96 - 0
mod/diskinfo/df/df.go

@@ -0,0 +1,96 @@
+package df
+
+import (
+	"bytes"
+	"errors"
+	"os/exec"
+	"strconv"
+	"strings"
+)
+
+type DiskInfo struct {
+	DevicePath string
+	Blocks     int64
+	Used       int64
+	Available  int64
+	UsePercent int
+	MountedOn  string
+}
+
+// GetDiskUsageByPath retrieves disk usage information for a specific path.
+// e.g. "/dev/sda1" or "sda1" will return the disk usage for the partition mounted on "/dev/sda1".
+func GetDiskUsageByPath(path string) (*DiskInfo, error) {
+	//Make sure the path has a prefix and a trailing slash
+	if !strings.HasPrefix(path, "/dev/") {
+		path = "/dev/" + path
+	}
+
+	path = strings.TrimSuffix(path, "/")
+
+	diskUsages, err := GetDiskUsage()
+	if err != nil {
+		return nil, err
+	}
+
+	for _, diskInfo := range diskUsages {
+		if strings.HasPrefix(diskInfo.DevicePath, path) {
+			return &diskInfo, nil
+		}
+	}
+
+	return nil, errors.New("disk usage not found for path: " + path)
+}
+
+// GetDiskUsage retrieves disk usage information for all mounted filesystems.
+func GetDiskUsage() ([]DiskInfo, error) {
+	cmd := exec.Command("df", "-k")
+	var out bytes.Buffer
+	cmd.Stdout = &out
+	err := cmd.Run()
+	if err != nil {
+		return nil, err
+	}
+
+	lines := strings.Split(out.String(), "\n")
+	if len(lines) < 2 {
+		return nil, nil
+	}
+
+	var diskInfos []DiskInfo
+	for _, line := range lines[1:] {
+		fields := strings.Fields(line)
+		if len(fields) < 6 {
+			continue
+		}
+		usePercent, err := strconv.Atoi(strings.TrimSuffix(fields[4], "%"))
+		if err != nil {
+			return nil, err
+		}
+
+		blocks, err := strconv.ParseInt(fields[1], 10, 64)
+		if err != nil {
+			return nil, err
+		}
+
+		used, err := strconv.ParseInt(fields[2], 10, 64)
+		if err != nil {
+			return nil, err
+		}
+
+		available, err := strconv.ParseInt(fields[3], 10, 64)
+		if err != nil {
+			return nil, err
+		}
+
+		diskInfos = append(diskInfos, DiskInfo{
+			DevicePath: fields[0],
+			Blocks:     blocks,
+			Used:       used,
+			Available:  available,
+			UsePercent: usePercent,
+			MountedOn:  fields[5],
+		})
+	}
+
+	return diskInfos, nil
+}

+ 9 - 16
mod/diskinfo/diskinfo.go

@@ -3,31 +3,24 @@ package diskinfo
 import (
 	"errors"
 	"os"
+	"strings"
 
 	"imuslab.com/bokofs/bokofsd/mod/diskinfo/blkid"
 	"imuslab.com/bokofs/bokofsd/mod/diskinfo/lsblk"
 )
 
-// Disk represents a disk device with its attributes.
-type Disk struct {
-	UUID       string `json:"uuid"`
-	Name       string `json:"name"`
-	Path       string `json:"path"`
-	Size       int64  `json:"size"`
-	BlockSize  int    `json:"blocksize"`
-	BlockType  string `json:"blocktype"`
-	FsType     string `json:"fstype"`
-	MountPoint string `json:"mountpoint,omitempty"`
-}
+// Get a disk by its device path, accept both /dev/sda and sda
+func NewBlockFromDevicePath(devpath string) (*Block, error) {
+	if !strings.HasPrefix(devpath, "/dev/") {
+		devpath = "/dev/" + devpath
+	}
 
-// Get a disk by its device path
-func NewDiskFromDevicePath(devpath string) (*Disk, error) {
 	if _, err := os.Stat(devpath); errors.Is(err, os.ErrNotExist) {
 		return nil, errors.New("device path does not exist")
 	}
 
 	//Create a new disk object
-	thisDisk := &Disk{
+	thisDisk := &Block{
 		Path: devpath,
 	}
 
@@ -41,7 +34,7 @@ func NewDiskFromDevicePath(devpath string) (*Disk, error) {
 }
 
 // UpdateProperties updates the properties of the disk.
-func (d *Disk) UpdateProperties() error {
+func (d *Block) UpdateProperties() error {
 	//Try to get the block device info
 	blockDeviceInfo, err := lsblk.GetBlockDeviceInfoFromDevicePath(d.Path)
 	if err != nil {
@@ -52,7 +45,7 @@ func (d *Disk) UpdateProperties() error {
 	d.Name = blockDeviceInfo.Name
 	d.Size = blockDeviceInfo.Size
 	d.BlockType = blockDeviceInfo.Type
-	d.MountPoint = blockDeviceInfo.MountPoint
+	//d.MountPoint = blockDeviceInfo.MountPoint
 
 	if d.BlockType == "disk" {
 		//This block is a disk not a partition. There is no partition ID info

+ 197 - 0
mod/diskinfo/diskutil.go

@@ -0,0 +1,197 @@
+package diskinfo
+
+import (
+	"errors"
+	"path/filepath"
+	"strings"
+
+	"imuslab.com/bokofs/bokofsd/mod/diskinfo/blkid"
+	"imuslab.com/bokofs/bokofsd/mod/diskinfo/df"
+	"imuslab.com/bokofs/bokofsd/mod/diskinfo/fdisk"
+	"imuslab.com/bokofs/bokofsd/mod/diskinfo/lsblk"
+)
+
+// 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
+	if !strings.HasPrefix(path, "/dev/") {
+		path = "/dev/" + path
+	}
+
+	path = strings.TrimSuffix(path, "/")
+
+	allBlockDevices, err := lsblk.GetLSBLKOutput()
+	if err != nil {
+		return false
+	}
+
+	for _, blockDevice := range allBlockDevices {
+		if "/dev/"+blockDevice.Name == path {
+			return blockDevice.Type == "disk"
+		}
+	}
+
+	return false
+}
+
+// DevicePathIsPartition checks if the given device path is a valid partition.
+func DevicePathIsValidPartition(path string) bool {
+	//Make sure the path has a prefix and a trailing slash
+	if !strings.HasPrefix(path, "/dev/") {
+		path = "/dev/" + path
+	}
+
+	path = strings.TrimSuffix(path, "/")
+
+	allBlockDevices, err := lsblk.GetLSBLKOutput()
+	if err != nil {
+		return false
+	}
+
+	for _, blockDevice := range allBlockDevices {
+		if !strings.HasPrefix(path, "/dev/"+blockDevice.Name) {
+			//Skip this block device
+			//This is not a partition of this block device
+			continue
+		}
+		for _, child := range blockDevice.Children {
+			if "/dev/"+child.Name == path {
+				//As there are too many partition types
+				//We can only check if the block device is not a disk and exists
+				return true
+			}
+		}
+	}
+
+	return false
+}
+
+// GetDiskInfo retrieves the disk information for a given disk name.
+// e.g. "sda"
+// for partitions, use the GetPartitionInfo function
+func GetDiskInfo(diskname string) (*Disk, error) {
+	if diskname == "" {
+		return nil, errors.New("disk name is empty")
+	}
+	//Make sure the diskname is something like sda
+	diskname = strings.TrimPrefix(diskname, "/dev/")
+
+	//Create a new disk object
+	thisDisk := &Disk{
+		Name:       diskname,
+		Size:       0,
+		BlockType:  "disk",
+		Partitions: []*Partition{},
+	}
+
+	//Try to get the disk model and identifier
+	diskInfo, err := fdisk.GetDiskModelAndIdentifier(diskname)
+	if err == nil {
+		thisDisk.Model = diskInfo.Model
+		thisDisk.Identifier = diskInfo.Identifier
+		thisDisk.DiskLabel = diskInfo.DiskLabel
+	}
+
+	//Calculation variables for total disk used space
+	totalDiskUseSpace := int64(0)
+
+	//Populate the partitions
+	allBlockDevices, err := lsblk.GetLSBLKOutput()
+	if err != nil {
+		return nil, err
+	}
+
+	for _, blockDevice := range allBlockDevices {
+		if blockDevice.Name == diskname {
+			thisDisk.Size = blockDevice.Size
+			for _, partition := range blockDevice.Children {
+				//Get the partition information from blkid
+				partition := &Partition{
+					Name:       partition.Name,
+					Size:       partition.Size,
+					Path:       filepath.Join("/dev", partition.Name),
+					BlockType:  partition.Type,
+					MountPoint: partition.MountPoint,
+				}
+
+				//Get the partition ID
+				blkInfo, err := blkid.GetPartitionIDFromDevicePath(partition.Name)
+				if err == nil {
+					partition.UUID = blkInfo.UUID
+					partition.PartUUID = blkInfo.PartUUID
+					partition.PartLabel = blkInfo.PartLabel
+					partition.BlockSize = blkInfo.BlockSize
+					partition.BlockType = blkInfo.Type
+					partition.FsType = blkInfo.Type
+				}
+
+				//Get the disk usage information
+				diskUsage, err := df.GetDiskUsageByPath(partition.Name)
+				if err == nil {
+					partition.Used = diskUsage.Used
+					partition.Free = diskUsage.Available
+				}
+
+				thisDisk.Partitions = append(thisDisk.Partitions, partition)
+			}
+
+		}
+	}
+
+	//Calculate the total disk used space
+	for _, partition := range thisDisk.Partitions {
+		totalDiskUseSpace += partition.Used
+	}
+	thisDisk.Used = totalDiskUseSpace
+	thisDisk.Free = thisDisk.Size - totalDiskUseSpace
+	return thisDisk, nil
+}
+
+func GetPartitionInfo(partitionName string) (*Partition, error) {
+	partition := &Partition{
+		Name: partitionName,
+	}
+	partInfo, err := blkid.GetPartitionIDFromDevicePath(partitionName)
+	if err == nil {
+		partition.UUID = partInfo.UUID
+		partition.PartUUID = partInfo.PartUUID
+		partition.PartLabel = partInfo.PartLabel
+		partition.BlockSize = partInfo.BlockSize
+		partition.BlockType = partInfo.Type
+		partition.FsType = partInfo.Type
+	}
+	//Get the disk usage information
+	diskUsage, err := df.GetDiskUsageByPath(partitionName)
+	if err == nil {
+		partition.Used = diskUsage.Used
+		partition.Free = diskUsage.Available
+		partition.MountPoint = diskUsage.MountedOn
+
+	}
+
+	return partition, nil
+}
+
+// GetDevicePathFromPartitionID retrieves the device path for a given partition ID.
+func GetDevicePathFromPartitionID(diskID string) (string, error) {
+	if diskID == "" {
+		return "", errors.New("disk ID is empty")
+	}
+
+	// Try to get the block device info
+	allBlockDevices, err := lsblk.GetLSBLKOutput()
+	if err != nil {
+		return "", err
+	}
+
+	for _, blockDevice := range allBlockDevices {
+		//Check each of the children to see if there is a partition with the given ID
+		for _, child := range blockDevice.Children {
+			if child.Name == diskID {
+				return child.Name, nil
+			}
+		}
+	}
+
+	return "", errors.New("disk ID not found")
+}

+ 50 - 0
mod/diskinfo/fdisk/fdisk.go

@@ -0,0 +1,50 @@
+package fdisk
+
+import (
+	"bytes"
+	"os/exec"
+	"strings"
+)
+
+type DiskInfo struct {
+	Name       string //e.g. /dev/sda
+	Model      string //e.g. Samsung SSD 860 EVO 1TB
+	DiskLabel  string //e.g. gpt
+	Identifier string //e.g. 0x12345678
+}
+
+func GetDiskModelAndIdentifier(disk string) (*DiskInfo, error) {
+	//Make sure there is /dev/ prefix
+	if !strings.HasPrefix(disk, "/dev/") {
+		disk = "/dev/" + disk
+	}
+	//Make sure there is no trailing slash
+	disk = strings.TrimSuffix(disk, "/")
+
+	cmd := exec.Command("sudo", "fdisk", "-l", disk)
+	var out bytes.Buffer
+	cmd.Stdout = &out
+	err := cmd.Run()
+	if err != nil {
+		return nil, err
+	}
+
+	lines := strings.Split(out.String(), "\n")
+
+	//Only extracting the upper section of disk info
+	var info DiskInfo = DiskInfo{
+		Name: disk,
+	}
+	for _, line := range lines {
+		line = strings.TrimSpace(line)
+		if strings.HasPrefix(line, "Disk model:") {
+			info.Model = strings.TrimPrefix(line, "Disk model: ")
+		} else if strings.HasPrefix(line, "Disklabel type:") {
+			info.DiskLabel = strings.TrimPrefix(line, "Disklabel type: ")
+		} else if strings.HasPrefix(line, "Disk identifier:") {
+			info.Identifier = strings.TrimPrefix(line, "Disk identifier: ")
+		}
+	}
+
+	return &info, nil
+}

+ 5 - 5
mod/diskinfo/lsblk/lsblk.go

@@ -10,11 +10,11 @@ import (
 
 // BlockDevice represents a block device and its attributes.
 type BlockDevice struct {
-	Name       string        `json:"name"`
-	Size       int64         `json:"size"`
-	Type       string        `json:"type"`
-	MountPoint string        `json:"mountpoint,omitempty"`
-	Children   []BlockDevice `json:"children,omitempty"`
+	Name       string        `json:"name"`                 //e.g. sda (disk) or sda1 (partition)
+	Size       int64         `json:"size"`                 // Size in bytes (manufacturer size)
+	Type       string        `json:"type"`                 // Type of the block device (e.g., disk, part)
+	MountPoint string        `json:"mountpoint,omitempty"` // Mount point of the device
+	Children   []BlockDevice `json:"children,omitempty"`   // List of child devices (e.g., partitions)
 }
 
 // parseLSBLKJSONOutput parses the JSON output of the `lsblk` command into a slice of BlockDevice structs.

+ 42 - 0
mod/diskinfo/typedef.go

@@ -0,0 +1,42 @@
+package diskinfo
+
+// Disk represents a disk device with its attributes.
+type Disk struct {
+	Name       string       `json:"name"`                 // Name of the disk, e.g. sda
+	Identifier string       `json:"identifier"`           // Disk identifier, e.g. 0x12345678
+	Model      string       `json:"model"`                // Disk model, e.g. Samsung SSD 860 EVO 1TB
+	Size       int64        `json:"size"`                 // Size of the disk in bytes
+	Used       int64        `json:"used"`                 // Used space in bytes, calculated from partitions
+	Free       int64        `json:"free"`                 // Free space in bytes, calculated as Size - Used
+	DiskLabel  string       `json:"disklabel"`            // Disk label type, e.g. gpt
+	BlockType  string       `json:"blocktype"`            // Type of the block device, e.g. disk
+	Partitions []*Partition `json:"partitions,omitempty"` // List of partitions on the disk
+}
+
+// Partition represents a partition on a disk with its attributes.
+type Partition struct {
+	UUID       string `json:"uuid"`                 // UUID of the file system
+	PartUUID   string `json:"partuuid"`             // Partition UUID
+	PartLabel  string `json:"partlabel"`            // Partition label
+	Name       string `json:"name"`                 // Name of the partition, e.g. sda1
+	Path       string `json:"path"`                 // Path of the partition, e.g. /dev/sda1
+	Size       int64  `json:"size"`                 // Size of the partition in bytes
+	Used       int64  `json:"used"`                 // Used space in bytes
+	Free       int64  `json:"free"`                 // Free space in bytes
+	BlockSize  int    `json:"blocksize"`            // Block size in bytes, e.g. 4096
+	BlockType  string `json:"blocktype"`            // Type of the block device, e.g. part
+	FsType     string `json:"fstype"`               // File system type, e.g. ext4
+	MountPoint string `json:"mountpoint,omitempty"` // Mount point of the partition, e.g. /mnt/data
+}
+
+// Block represents a block device with its attributes.
+type Block struct {
+	UUID       string `json:"uuid"`
+	Name       string `json:"name"`
+	Path       string `json:"path"`
+	Size       int64  `json:"size"`
+	BlockSize  int    `json:"blocksize"`
+	BlockType  string `json:"blocktype"`
+	FsType     string `json:"fstype"`
+	MountPoint string `json:"mountpoint,omitempty"`
+}

+ 0 - 0
web/components/connections.html


+ 0 - 0
web/components/disks.html


+ 0 - 0
web/components/raid.html


+ 7 - 0
web/components/status.html

@@ -0,0 +1,7 @@
+<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>
+</div>

+ 40 - 24
web/index.html

@@ -37,45 +37,61 @@
                 </button>
             </div>
             <div class="ts-tab is-pilled">
-                <div href="#!" class="item" style="pointer-events: none; user-select: none;">
+                <a href="" class="item" style="user-select: none;">
                     <img id="sysicon" class="ts-image" style="height: 30px" src="img/logo.svg"></img>
-                </div>
-                <a href="#!" class="item is-active" i18n>
+                </a>
+                <button href="#!" class="item is-active" data-tab="tab-status" i18n>
                     Status
                     // 系統狀態
-                </a>
-                <a href="#!" class="item" i18n>
+                </button>
+                <button href="#!" class="item" data-tab="tab-connections" i18n>
+                    Connections
+                    // 連接
+                </button>
+                <button href="#!" class="item" data-tab="tab-disks" i18n>
                     Disks
-                    // 磁碟
-                </a>
-                <a href="#!" class="item" i18n>
+                    // 磁碟資訊
+                </button>
+                <button href="#!" class="item" data-tab="tab-raid" i18n>
                     RAID
                     // 磁碟陣列
-                </a>
-                <a href="#!" class="item" i18n>
-                    Network
-                    // 網路
-                </a>
-                <a href="#!" class="item" i18n>
+                </button>
+                <button href="#!" class="item" data-tab="tab-tools" i18n>
                     Tools
                     // 工具
-                </a>
+                </button>
+                <button href="#!" class="item" data-tab="tab-logs" i18n>
+                    Logs
+                    // 日誌
+                </button>
+                <button href="#!" class="item" data-tab="tab-settings" i18n>
+                    Settings
+                    // 設定
+                </button>
             </div>
-            
         </div>
     </div>
     <div class="ts-divider"></div>
     <div class="ts-content is-vertically-padded">
         <div class="ts-container">
-            <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>
-            </div>
-        </div>
+            <div class="ts-content boko-panel-component" id="tab-status" component="status.html">Status</div>
+            <div class="ts-content boko-panel-component" id="tab-connections" component="connections.html">Connections</div>
+            <div class="ts-content boko-panel-component" id="tab-disks" component="disks.html">Disks</div>
+            <div class="ts-content boko-panel-component" id="tab-raid" component="raid.html">RAID</div>
+            <div class="ts-content boko-panel-component" id="tab-tools">Tools</div>
+            <div class="ts-content boko-panel-component" id="tab-logs">Logs</div>
+            <div class="ts-content boko-panel-component" id="tab-settings">Settings</div>
+        </div>  
     </div>
+    <script>
+        $(".boko-panel-component").each(function(){
+            var component = $(this).attr("component");
+            if (component) {
+                $(this).load("./components/" + component);
+            }
+        })
+
+    </script>
     <script src="./js/locale.js"></script>
 </body>
 </html>