mirror of
https://github.com/AlchemillaHQ/Sylve.git
synced 2026-06-14 00:46:34 +03:00
feat: implement ZFS executor and error handling, add ZFS API functions
This commit is contained in:
@@ -76,8 +76,6 @@ func WipeDisk(diskService *disk.Service, infoService *info.Service) gin.HandlerF
|
||||
return func(c *gin.Context) {
|
||||
var r DiskActionRequest
|
||||
|
||||
fmt.Println("WipeDisk")
|
||||
|
||||
if err := c.ShouldBindJSON(&r); err != nil {
|
||||
validationErrors := utils.MapValidationErrors(err, DiskActionRequest{})
|
||||
|
||||
@@ -93,8 +91,6 @@ func WipeDisk(diskService *disk.Service, infoService *info.Service) gin.HandlerF
|
||||
id := infoService.StartAuditLog(c.GetString("Token"), fmt.Sprintf("wipe_disk|-|%s", r.Device), "started")
|
||||
err := diskUtils.DestroyDisk(r.Device)
|
||||
|
||||
fmt.Println("WipeDisk", r.Device, id)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, internal.APIResponse[any]{
|
||||
Status: "error",
|
||||
|
||||
@@ -63,11 +63,14 @@ func HandleTerminalWebsocket(c *gin.Context) {
|
||||
|
||||
w := c.Writer
|
||||
r := c.Request
|
||||
conn, err := WSUpgrader.Upgrade(w, r, nil)
|
||||
subprotocols := websocket.Subprotocols(r)
|
||||
conn, err := WSUpgrader.Upgrade(w, r, http.Header{"Sec-WebSocket-Protocol": {subprotocols[0]}})
|
||||
|
||||
if err != nil {
|
||||
logger.L.Error().Msgf("WebSocket upgrade failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
defer conn.Close()
|
||||
|
||||
var wsWriteMu sync.Mutex
|
||||
|
||||
@@ -90,8 +90,8 @@ func RegisterRoutes(r *gin.Engine,
|
||||
pools := zfs.Group("/pools")
|
||||
{
|
||||
pools.GET("", zfsHandlers.GetPools(zfsService))
|
||||
pools.POST("", zfsHandlers.GetPools(zfsService))
|
||||
pools.DELETE("/:name", zfsHandlers.GetPools(zfsService))
|
||||
pools.POST("", zfsHandlers.CreatePool(zfsService))
|
||||
pools.DELETE("/:name", zfsHandlers.DeletePool(zfsService))
|
||||
}
|
||||
|
||||
zfs.GET("/pool/io-delay", zfsHandlers.AvgIODelay(zfsService))
|
||||
|
||||
@@ -10,29 +10,21 @@ package zfsHandlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sylve/internal"
|
||||
infoModels "sylve/internal/db/models/info"
|
||||
zfsServiceInterfaces "sylve/internal/interfaces/services/zfs"
|
||||
"sylve/internal/services/zfs"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
zfsUtils "sylve/pkg/zfs"
|
||||
)
|
||||
|
||||
type AvgIODelayResponse struct {
|
||||
Delay float64 `json:"delay"`
|
||||
}
|
||||
|
||||
type CreatePoolRequest struct {
|
||||
Name string `json:"name" binding:"required,min=3,max=128"`
|
||||
Vdevs []string `json:"vdevs" binding:"required"`
|
||||
Raid string `json:"raid"`
|
||||
Options map[string]string `json:"options" binding:"required"`
|
||||
}
|
||||
|
||||
type DeletePoolRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// @Summary Get Average IO Delay
|
||||
// @Description Get the average IO delay of all pools
|
||||
// @Tags ZFS
|
||||
@@ -44,7 +36,7 @@ type DeletePoolRequest struct {
|
||||
// @Router /zfs/avg-io-delay [get]
|
||||
func AvgIODelay(zfsSerice *zfs.Service) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
info := zfsSerice.GetTotalIODelay()
|
||||
info := zfsUtils.GetTotalIODelay()
|
||||
c.JSON(http.StatusOK, internal.APIResponse[AvgIODelayResponse]{
|
||||
Status: "success",
|
||||
Message: "avg_io_delay",
|
||||
@@ -91,12 +83,12 @@ func AvgIODelayHistorical(zfsSerice *zfs.Service) gin.HandlerFunc {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} internal.APIResponse[[]zfsServiceInterfaces.Zpool] "Success"
|
||||
// @Success 200 {object} internal.APIResponse[[]*zfsUtils.Zpool] "Success"
|
||||
// @Failure 500 {object} internal.APIResponse[any] "Internal Server Error"
|
||||
// @Router /zfs/pools [get]
|
||||
func GetPools(zfsSerice *zfs.Service) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
pools, err := zfsSerice.GetPools()
|
||||
pools, err := zfsUtils.ListZpools()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, internal.APIResponse[any]{
|
||||
Status: "error",
|
||||
@@ -107,7 +99,7 @@ func GetPools(zfsSerice *zfs.Service) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, internal.APIResponse[[]zfsServiceInterfaces.Zpool]{
|
||||
c.JSON(http.StatusOK, internal.APIResponse[[]*zfsUtils.Zpool]{
|
||||
Status: "success",
|
||||
Message: "pools",
|
||||
Error: "",
|
||||
@@ -122,13 +114,13 @@ func GetPools(zfsSerice *zfs.Service) gin.HandlerFunc {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body CreatePoolRequest true "Request"
|
||||
// @Param request body zfsServiceInterfaces.Zpool true "Request"
|
||||
// @Success 200 {object} internal.APIResponse[any] "Success"
|
||||
// @Failure 500 {object} internal.APIResponse[any] "Internal Server Error"
|
||||
// @Router /zfs/pools [post]
|
||||
func CreatePool(zfsSerice *zfs.Service) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request CreatePoolRequest
|
||||
var request zfsServiceInterfaces.Zpool
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, internal.APIResponse[any]{
|
||||
Status: "error",
|
||||
@@ -139,7 +131,7 @@ func CreatePool(zfsSerice *zfs.Service) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
err := zfsSerice.CreatePool(request.Name, request.Vdevs, request.Raid, request.Options)
|
||||
err := zfsSerice.CreatePool(request)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, internal.APIResponse[any]{
|
||||
Status: "error",
|
||||
@@ -165,25 +157,25 @@ func CreatePool(zfsSerice *zfs.Service) gin.HandlerFunc {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body DeletePoolRequest true "Request"
|
||||
// @Success 200 {object} internal.APIResponse[any] "Success"
|
||||
// @Failure 500 {object} internal.APIResponse[any] "Internal Server Error"
|
||||
// @Router /zfs/pools/{name} [delete]
|
||||
func DeletePool(zfsSerice *zfs.Service) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request DeletePoolRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, internal.APIResponse[any]{
|
||||
Status: "error",
|
||||
Message: "invalid_request",
|
||||
Error: err.Error(),
|
||||
Data: nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
name := c.Param("name")
|
||||
|
||||
err := zfsSerice.DestroyPool(request.Name)
|
||||
err := zfsUtils.DestroyPool(name)
|
||||
if err != nil {
|
||||
if strings.HasPrefix(err.Error(), "error_getting_pool") {
|
||||
c.JSON(http.StatusNotFound, internal.APIResponse[any]{
|
||||
Status: "error",
|
||||
Message: "pool_not_found",
|
||||
Error: err.Error(),
|
||||
Data: nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusInternalServerError, internal.APIResponse[any]{
|
||||
Status: "error",
|
||||
Message: "pool_delete_failed",
|
||||
|
||||
@@ -8,28 +8,14 @@
|
||||
|
||||
package zfsServiceInterfaces
|
||||
|
||||
type RW struct {
|
||||
Read uint64 `json:"read"`
|
||||
Write uint64 `json:"write"`
|
||||
}
|
||||
|
||||
type Vdev struct {
|
||||
Name string `json:"name"`
|
||||
Alloc uint64 `json:"alloc"`
|
||||
Free uint64 `json:"free"`
|
||||
Operations RW `json:"operations"`
|
||||
Bandwidth RW `json:"bandwidth"`
|
||||
Name string `json:"name"`
|
||||
VdevDevices []string `json:"devices"`
|
||||
}
|
||||
|
||||
type Zpool struct {
|
||||
Name string `json:"name"`
|
||||
Health string `json:"health"`
|
||||
Allocated uint64 `json:"allocated"`
|
||||
Size uint64 `json:"size"`
|
||||
Free uint64 `json:"free"`
|
||||
ReadOnly bool `json:"readOnly"`
|
||||
Freeing uint64 `json:"freeing"`
|
||||
Leaked uint64 `json:"leaked"`
|
||||
DedupRatio float64 `json:"dedupRatio"`
|
||||
Vdevs []Vdev `json:"vdevs"`
|
||||
Name string `json:"name" binding:"required,alphanum,min=1,max=24"`
|
||||
RaidType string `json:"raidType" binding:"omitempty,oneof= mirror raidz raidz2 raidz3"`
|
||||
Vdevs []Vdev `json:"vdevs"`
|
||||
Properties map[string]string `json:"properties"`
|
||||
}
|
||||
|
||||
@@ -11,12 +11,8 @@ package zfsServiceInterfaces
|
||||
import infoModels "sylve/internal/db/models/info"
|
||||
|
||||
type ZfsServiceInterface interface {
|
||||
GetPoolNames() ([]string, error)
|
||||
GetPool(name string) (Zpool, error)
|
||||
GetPools() ([]Zpool, error)
|
||||
GetPoolIODelay(poolName string) float64
|
||||
GetTotalIODelay() float64
|
||||
GetTotalIODelayHisorical() ([]infoModels.IODelay, error)
|
||||
CreatePool(Zpool) error
|
||||
|
||||
Cron()
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"sylve/internal/logger"
|
||||
diskUtils "sylve/pkg/disk"
|
||||
"sylve/pkg/utils"
|
||||
"sylve/pkg/zfs"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
@@ -209,13 +210,13 @@ func (s *Service) GetDiskDevices() ([]diskServiceInterfaces.Disk, error) {
|
||||
|
||||
if len(disk.Partitions) == 0 {
|
||||
found := false
|
||||
pools, err := s.ZFS.GetPools()
|
||||
pools, err := zfs.ListZpools()
|
||||
|
||||
if err == nil {
|
||||
for _, pool := range pools {
|
||||
for _, vdev := range pool.Vdevs {
|
||||
if vdev.Name == "/dev/"+d.Name {
|
||||
disk.Usage = "ZFS Vdev"
|
||||
disk.Usage = "ZFS"
|
||||
found = true
|
||||
break
|
||||
}
|
||||
|
||||
@@ -9,172 +9,11 @@
|
||||
package zfs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sylve/internal/db"
|
||||
infoModels "sylve/internal/db/models/info"
|
||||
zfsServiceInterfaces "sylve/internal/interfaces/services/zfs"
|
||||
"sylve/internal/logger"
|
||||
diskUtils "sylve/pkg/disk"
|
||||
"sylve/pkg/utils"
|
||||
)
|
||||
|
||||
func (s *Service) GetPoolNames() ([]string, error) {
|
||||
output, err := utils.RunCommand("zpool", "list", "-H", "-o", "name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
poolNames := strings.Fields(output)
|
||||
|
||||
return poolNames, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetPool(name string) (zfsServiceInterfaces.Zpool, error) {
|
||||
pools, err := utils.RunCommand("zpool", "list", "-H", "-p", "-o", "name,health,alloc,size,free,readonly,freeing,leaked,dedupratio", name)
|
||||
if err != nil {
|
||||
return zfsServiceInterfaces.Zpool{}, err
|
||||
}
|
||||
|
||||
vdevs, err := utils.RunCommand("zpool", "iostat", "-v", "-H", "-P", "-p", name)
|
||||
if err != nil {
|
||||
return zfsServiceInterfaces.Zpool{}, err
|
||||
}
|
||||
|
||||
zpool, err := utils.ParseZpoolListOutput(pools, vdevs)
|
||||
|
||||
return *zpool, err
|
||||
}
|
||||
|
||||
func (s *Service) GetPools() ([]zfsServiceInterfaces.Zpool, error) {
|
||||
names, err := s.GetPoolNames()
|
||||
if err != nil {
|
||||
return []zfsServiceInterfaces.Zpool{}, err
|
||||
}
|
||||
|
||||
var pools []zfsServiceInterfaces.Zpool
|
||||
|
||||
for _, name := range names {
|
||||
pool, err := s.GetPool(name)
|
||||
if err != nil {
|
||||
return []zfsServiceInterfaces.Zpool{}, err
|
||||
}
|
||||
pools = append(pools, pool)
|
||||
}
|
||||
|
||||
return pools, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetPoolIODelay(poolName string) float64 {
|
||||
names, err := s.GetPoolNames()
|
||||
|
||||
if err != nil {
|
||||
logger.L.Debug().Msgf("Error getting pool names: %v", err)
|
||||
return 0.0
|
||||
}
|
||||
|
||||
if !utils.StringInSlice(poolName, names) {
|
||||
logger.L.Debug().Msgf("Pool %s not found", poolName)
|
||||
return 0.0
|
||||
}
|
||||
|
||||
output, err := utils.RunCommand("zpool", "iostat", "-l", "-H", "-v", poolName, "1", "2")
|
||||
if err != nil {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(output), "\n")
|
||||
|
||||
var samples [][]string
|
||||
var currentSample []string
|
||||
seenPools := make(map[string]bool)
|
||||
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
if len(currentSample) > 0 {
|
||||
samples = append(samples, currentSample)
|
||||
currentSample = nil
|
||||
seenPools = make(map[string]bool)
|
||||
}
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
pool := fields[0]
|
||||
if seenPools[pool] {
|
||||
samples = append(samples, currentSample)
|
||||
currentSample = nil
|
||||
seenPools = make(map[string]bool)
|
||||
}
|
||||
seenPools[pool] = true
|
||||
currentSample = append(currentSample, line)
|
||||
}
|
||||
|
||||
if len(currentSample) > 0 {
|
||||
samples = append(samples, currentSample)
|
||||
}
|
||||
|
||||
if len(samples) < 2 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
secondSample := samples[1]
|
||||
sampleInterval := int64(1000000)
|
||||
|
||||
for _, line := range secondSample {
|
||||
if len(line) > 0 && (line[0] == ' ' || line[0] == '\t') {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 9 || fields[0] != poolName {
|
||||
continue
|
||||
}
|
||||
|
||||
readOps, err1 := strconv.ParseInt(fields[3], 10, 64)
|
||||
writeOps, err2 := strconv.ParseInt(fields[4], 10, 64)
|
||||
if err1 != nil || err2 != nil || (readOps+writeOps) == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
totalReadWait := utils.ParseZfsTimeUnit(fields[7])
|
||||
totalWriteWait := utils.ParseZfsTimeUnit(fields[8])
|
||||
totalWaitAccumulated := (readOps * totalReadWait) + (writeOps * totalWriteWait)
|
||||
averageWait := totalWaitAccumulated / (readOps + writeOps)
|
||||
|
||||
return (float64(averageWait) / float64(sampleInterval)) * 100
|
||||
}
|
||||
|
||||
return 0.0
|
||||
}
|
||||
|
||||
func (s *Service) GetTotalIODelay() float64 {
|
||||
names, err := s.GetPoolNames()
|
||||
if err != nil {
|
||||
logger.L.Debug().Msgf("Error getting pool names: %v", err)
|
||||
return 0.0
|
||||
}
|
||||
|
||||
var totalDelay float64
|
||||
count := 0
|
||||
|
||||
for _, name := range names {
|
||||
delay := s.GetPoolIODelay(name)
|
||||
if delay > 0 {
|
||||
totalDelay += delay
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
return totalDelay / float64(count)
|
||||
}
|
||||
|
||||
func (s *Service) GetTotalIODelayHisorical() ([]infoModels.IODelay, error) {
|
||||
historicalData, err := db.GetHistorical[infoModels.IODelay](s.DB, 128)
|
||||
|
||||
@@ -185,77 +24,6 @@ func (s *Service) GetTotalIODelayHisorical() ([]infoModels.IODelay, error) {
|
||||
return historicalData, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreatePool(poolName string, vdevs []string, raidType string, options map[string]string) error {
|
||||
if poolName == "" {
|
||||
return fmt.Errorf("no pool name specified")
|
||||
}
|
||||
|
||||
if len(vdevs) == 0 {
|
||||
return fmt.Errorf("no vdevs specified")
|
||||
}
|
||||
|
||||
pools, err := s.GetPools()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting existing pools: %v", err)
|
||||
}
|
||||
|
||||
for _, pool := range pools {
|
||||
if pool.Name == poolName {
|
||||
return fmt.Errorf("pool %s already exists", poolName)
|
||||
}
|
||||
|
||||
for _, vdev := range pool.Vdevs {
|
||||
for _, newVdev := range vdevs {
|
||||
if vdev.Name == newVdev {
|
||||
return fmt.Errorf("vdev %s already in use by pool %s", newVdev, pool.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var args []string
|
||||
|
||||
for k, v := range options {
|
||||
args = append(args, "-O", fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
|
||||
args = append(args, "-f")
|
||||
args = append(args, poolName)
|
||||
|
||||
if raidType != "" {
|
||||
args = append(args, raidType)
|
||||
}
|
||||
|
||||
args = append(args, vdevs...)
|
||||
|
||||
_, err = utils.RunCommand("zpool", append([]string{"create"}, args...)...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create pool: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) DestroyPool(poolName string) error {
|
||||
pool, err := s.GetPool(poolName)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting pool: %v", err)
|
||||
}
|
||||
|
||||
_, err = utils.RunCommand("zpool", "destroy", "-f", pool.Name)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, vdev := range pool.Vdevs {
|
||||
err = diskUtils.DestroyDisk(vdev.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error destroying disk %s: %v", vdev.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) CreatePool(zfsServiceInterfaces.Zpool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"sylve/internal/db"
|
||||
infoModels "sylve/internal/db/models/info"
|
||||
zfsServiceInterfaces "sylve/internal/interfaces/services/zfs"
|
||||
"sylve/pkg/zfs"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -30,7 +31,7 @@ func NewZfsService(db *gorm.DB) zfsServiceInterfaces.ZfsServiceInterface {
|
||||
}
|
||||
|
||||
func (s *Service) StoreStats() {
|
||||
d := s.GetTotalIODelay()
|
||||
d := zfs.GetTotalIODelay()
|
||||
db.StoreAndTrimRecords(s.DB, &infoModels.IODelay{Delay: d}, 128)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package exe
|
||||
|
||||
import (
|
||||
"io"
|
||||
)
|
||||
|
||||
type Executor interface {
|
||||
Run(stdin io.Reader, stdout io.Writer, stderr io.Writer, cmd string, args ...string) error
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package exe
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
func NewLocalExecutor() Executor {
|
||||
return &localExec{}
|
||||
}
|
||||
|
||||
type localExec struct{}
|
||||
|
||||
func (l *localExec) Run(stdin io.Reader, stdout io.Writer, stderr io.Writer, cmd string, args ...string) error {
|
||||
c := exec.Command(cmd, args...)
|
||||
if stdin != nil {
|
||||
c.Stdin = stdin
|
||||
}
|
||||
if stdout != nil {
|
||||
c.Stdout = stdout
|
||||
}
|
||||
if stderr != nil {
|
||||
c.Stderr = stderr
|
||||
}
|
||||
return c.Run()
|
||||
}
|
||||
+40
-1
@@ -76,7 +76,12 @@ func StringInSlice(a string, list []string) bool {
|
||||
}
|
||||
|
||||
func StringToUint64(s string) uint64 {
|
||||
r, _ := strconv.ParseUint(s, 10, 64)
|
||||
r, error := strconv.ParseUint(s, 10, 64)
|
||||
|
||||
if error != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -130,3 +135,37 @@ func BytesToSize(toType string, bytes float64) float64 {
|
||||
return bytes
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* from zfs diff`s escape function:
|
||||
*
|
||||
* Prints a file name out a character at a time. If the character is
|
||||
* not in the range of what we consider "printable" ASCII, display it
|
||||
* as an escaped 3-digit octal value. ASCII values less than a space
|
||||
* are all control characters and we declare the upper end as the
|
||||
* DELete character. This also is the last 7-bit ASCII character.
|
||||
* We choose to treat all 8-bit ASCII as not printable for this
|
||||
* application.
|
||||
*/
|
||||
func UnescapeFilepath(path string) (string, error) {
|
||||
buf := make([]byte, 0, len(path))
|
||||
llen := len(path)
|
||||
for i := 0; i < llen; {
|
||||
if path[i] == '\\' {
|
||||
if llen < i+4 {
|
||||
return "", fmt.Errorf("invalid octal code: too short")
|
||||
}
|
||||
octalCode := path[(i + 1):(i + 4)]
|
||||
val, err := strconv.ParseUint(octalCode, 8, 8)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid octal code: %w", err)
|
||||
}
|
||||
buf = append(buf, byte(val))
|
||||
i += 4
|
||||
} else {
|
||||
buf = append(buf, path[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
//
|
||||
// Copyright (c) 2025 The FreeBSD Foundation.
|
||||
//
|
||||
// This software was developed by Hayzam Sherif <hayzam@alchemilla.io>
|
||||
// of Alchemilla Ventures Pvt. Ltd. <hello@alchemilla.io>,
|
||||
// under sponsorship from the FreeBSD Foundation.
|
||||
|
||||
package utils
|
||||
|
||||
import (
|
||||
"strings"
|
||||
zfsServiceInterfaces "sylve/internal/interfaces/services/zfs"
|
||||
)
|
||||
|
||||
func ParseZpoolListOutput(pools string, vdevs string) (*zfsServiceInterfaces.Zpool, error) {
|
||||
poolSlice := strings.Split(strings.TrimSpace(pools), "\n")
|
||||
vdevSlice := strings.Split(strings.TrimSpace(vdevs), "\n")
|
||||
|
||||
zpool := &zfsServiceInterfaces.Zpool{}
|
||||
|
||||
for _, pool := range poolSlice {
|
||||
if pool == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.Fields(pool)
|
||||
if len(parts) < 9 {
|
||||
continue
|
||||
}
|
||||
|
||||
zpool.Name = parts[0]
|
||||
zpool.Health = parts[1]
|
||||
zpool.Allocated = StringToUint64(parts[2])
|
||||
zpool.Size = StringToUint64(parts[3])
|
||||
zpool.Free = StringToUint64(parts[4])
|
||||
zpool.ReadOnly = parts[5] == "on"
|
||||
zpool.Freeing = StringToUint64(parts[6])
|
||||
zpool.Leaked = StringToUint64(parts[7])
|
||||
zpool.DedupRatio = StringToFloat64(parts[8])
|
||||
|
||||
for _, vdev := range vdevSlice {
|
||||
if strings.HasPrefix(vdev, zpool.Name) {
|
||||
continue
|
||||
}
|
||||
|
||||
vdevParts := strings.Fields(vdev)
|
||||
|
||||
if len(vdevParts) < 7 {
|
||||
continue
|
||||
}
|
||||
|
||||
vdev := zfsServiceInterfaces.Vdev{}
|
||||
|
||||
vdev.Name = vdevParts[0]
|
||||
vdev.Alloc = StringToUint64(vdevParts[1])
|
||||
vdev.Free = StringToUint64(vdevParts[2])
|
||||
vdev.Operations.Read = StringToUint64(vdevParts[3])
|
||||
vdev.Operations.Write = StringToUint64(vdevParts[4])
|
||||
vdev.Bandwidth.Read = StringToUint64(vdevParts[5])
|
||||
vdev.Bandwidth.Write = StringToUint64(vdevParts[6])
|
||||
|
||||
zpool.Vdevs = append(zpool.Vdevs, vdev)
|
||||
}
|
||||
}
|
||||
|
||||
return zpool, nil
|
||||
}
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
Parts of the code in this package are derived from the go-zfs package which has been modified to add some more functionality and also tighter integration with Sylve. Please refer to the license of the original go-zfs project below:
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "{}"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright (c) 2014, OmniTI Computer Consulting, Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,311 @@
|
||||
package zfs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Dataset struct {
|
||||
z *zfs `json:"-"`
|
||||
Name string `json:"name"`
|
||||
Origin string `json:"origin"`
|
||||
Used uint64 `json:"used"`
|
||||
Avail uint64 `json:"avail"`
|
||||
Mountpoint string `json:"mountpoint"`
|
||||
Compression string `json:"compression"`
|
||||
Type string `json:"type"`
|
||||
Written uint64 `json:"written"`
|
||||
Volsize uint64 `json:"volsize"`
|
||||
Logicalused uint64 `json:"logicalused"`
|
||||
Usedbydataset uint64 `json:"usedbydataset"`
|
||||
Quota uint64 `json:"quota"`
|
||||
Referenced uint64 `json:"referenced"`
|
||||
|
||||
props map[string]string `json:"props"`
|
||||
}
|
||||
|
||||
func (d *Dataset) Clone(dest string, properties map[string]string) (*Dataset, error) {
|
||||
if d.Type != DatasetSnapshot {
|
||||
return nil, errors.New("can only clone snapshots")
|
||||
}
|
||||
args := make([]string, 2, 4)
|
||||
args[0] = "clone"
|
||||
args[1] = "-p"
|
||||
if properties != nil {
|
||||
args = append(args, propsSlice(properties)...)
|
||||
}
|
||||
args = append(args, []string{d.Name, dest}...)
|
||||
if err := d.z.do(args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d.z.GetDataset(dest)
|
||||
}
|
||||
|
||||
func (d *Dataset) Unmount(force bool) (*Dataset, error) {
|
||||
if d.Type == DatasetSnapshot {
|
||||
return nil, errors.New("cannot unmount snapshots")
|
||||
}
|
||||
args := make([]string, 1, 3)
|
||||
args[0] = "umount"
|
||||
if force {
|
||||
args = append(args, "-f")
|
||||
}
|
||||
args = append(args, d.Name)
|
||||
if err := d.z.do(args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d.z.GetDataset(d.Name)
|
||||
}
|
||||
|
||||
func (d *Dataset) Mount(overlay bool, options []string) (*Dataset, error) {
|
||||
if d.Type == DatasetSnapshot {
|
||||
return nil, errors.New("cannot mount snapshots")
|
||||
}
|
||||
args := make([]string, 1, 5)
|
||||
args[0] = "mount"
|
||||
if overlay {
|
||||
args = append(args, "-O")
|
||||
}
|
||||
if options != nil {
|
||||
args = append(args, "-o")
|
||||
args = append(args, strings.Join(options, ","))
|
||||
}
|
||||
args = append(args, d.Name)
|
||||
if err := d.z.do(args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d.z.GetDataset(d.Name)
|
||||
}
|
||||
|
||||
func (d *Dataset) Destroy(flags DestroyFlag) error {
|
||||
args := make([]string, 1, 3)
|
||||
args[0] = "destroy"
|
||||
if flags&DestroyRecursive != 0 {
|
||||
args = append(args, "-r")
|
||||
}
|
||||
|
||||
if flags&DestroyRecursiveClones != 0 {
|
||||
args = append(args, "-R")
|
||||
}
|
||||
|
||||
if flags&DestroyDeferDeletion != 0 {
|
||||
args = append(args, "-d")
|
||||
}
|
||||
|
||||
if flags&DestroyForceUmount != 0 {
|
||||
args = append(args, "-f")
|
||||
}
|
||||
|
||||
args = append(args, d.Name)
|
||||
err := d.z.do(args...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Dataset) SetProperty(key, val string) error {
|
||||
prop := strings.Join([]string{key, val}, "=")
|
||||
if err := d.z.do("set", prop, d.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
d.props[strings.ToLower(key)] = val
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Dataset) SetProperties(keyValPairs ...string) error {
|
||||
if len(keyValPairs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(keyValPairs)%2 != 0 {
|
||||
return errors.New("keyValPairs must be an even number of strings")
|
||||
}
|
||||
args := []string{"set"}
|
||||
props := make(map[string]string)
|
||||
for i := 0; i < len(keyValPairs); i += 2 {
|
||||
props[strings.ToLower(keyValPairs[i])] = keyValPairs[i+1]
|
||||
args = append(args, strings.Join(keyValPairs[i:i+2], "="))
|
||||
}
|
||||
args = append(args, d.Name)
|
||||
if err := d.z.do(args...); err != nil {
|
||||
return err
|
||||
}
|
||||
for k, v := range props {
|
||||
d.props[k] = v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Dataset) GetProperty(key string) (string, error) {
|
||||
if v, ok := d.props[strings.ToLower(key)]; ok {
|
||||
return v, nil
|
||||
}
|
||||
// custom properties does not return error
|
||||
if strings.Contains(key, ":") {
|
||||
return "-", nil
|
||||
}
|
||||
out, err := d.z.doOutput("get", "-H", "-p", key, d.Name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return out[0][2], nil
|
||||
}
|
||||
|
||||
func (d *Dataset) GetProperties(keys ...string) ([]string, error) {
|
||||
if len(keys) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
props, failed := make([]string, 0, len(keys)), false
|
||||
for _, v := range keys {
|
||||
val, ok := d.props[strings.ToLower(v)]
|
||||
if failed = !ok && !strings.Contains(v, ":"); failed {
|
||||
props = make([]string, 0, len(keys))
|
||||
break
|
||||
}
|
||||
if val == "" {
|
||||
val = "-"
|
||||
}
|
||||
props = append(props, val)
|
||||
}
|
||||
if !failed {
|
||||
return props, nil
|
||||
}
|
||||
out, err := d.z.doOutput("get", "-H", "-p", strings.Join(keys, ","), d.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, v := range out {
|
||||
props = append(props, v[2])
|
||||
}
|
||||
return props, nil
|
||||
}
|
||||
|
||||
func (d *Dataset) GetAllProperties() (map[string]string, error) {
|
||||
out, err := d.z.doOutput("get", "-H", "-p", "all", d.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
props := make(map[string]string)
|
||||
for _, v := range out {
|
||||
props[v[1]] = v[2]
|
||||
}
|
||||
return props, nil
|
||||
}
|
||||
|
||||
func (d *Dataset) Rename(name string, createParent, recursiveRenameSnapshots bool) (*Dataset, error) {
|
||||
args := make([]string, 3, 5)
|
||||
args[0] = "rename"
|
||||
args[1] = d.Name
|
||||
args[2] = name
|
||||
if createParent {
|
||||
args = append(args, "-p")
|
||||
}
|
||||
if recursiveRenameSnapshots {
|
||||
args = append(args, "-r")
|
||||
}
|
||||
if err := d.z.do(args...); err != nil {
|
||||
return d, err
|
||||
}
|
||||
|
||||
return d.z.GetDataset(name)
|
||||
}
|
||||
|
||||
func (d *Dataset) Snapshots() ([]*Dataset, error) {
|
||||
return d.z.Snapshots(d.Name)
|
||||
}
|
||||
|
||||
func (d *Dataset) SendSnapshot(output io.Writer) error {
|
||||
if d.Type != DatasetSnapshot {
|
||||
return errors.New("can only send snapshots")
|
||||
}
|
||||
_, err := d.z.run(nil, output, "zfs", "send", d.Name)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Dataset) IncrementalSend(baseSnapshot *Dataset, output io.Writer) error {
|
||||
if d.Type != DatasetSnapshot || baseSnapshot.Type != DatasetSnapshot {
|
||||
return errors.New("can only send snapshots")
|
||||
}
|
||||
_, err := d.z.run(nil, output, "zfs", "send", "-i", baseSnapshot.Name, d.Name)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Dataset) Snapshot(name string, recursive bool) (*Dataset, error) {
|
||||
args := make([]string, 1, 4)
|
||||
args[0] = "snapshot"
|
||||
if recursive {
|
||||
args = append(args, "-r")
|
||||
}
|
||||
snapName := fmt.Sprintf("%s@%s", d.Name, name)
|
||||
args = append(args, snapName)
|
||||
if err := d.z.do(args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d.z.GetDataset(snapName)
|
||||
}
|
||||
|
||||
func (d *Dataset) Rollback(destroyMoreRecent bool) error {
|
||||
if d.Type != DatasetSnapshot {
|
||||
return errors.New("can only rollback snapshots")
|
||||
}
|
||||
|
||||
args := make([]string, 1, 3)
|
||||
args[0] = "rollback"
|
||||
if destroyMoreRecent {
|
||||
args = append(args, "-r")
|
||||
}
|
||||
args = append(args, d.Name)
|
||||
|
||||
err := d.z.do(args...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Dataset) Children(depth uint64) ([]*Dataset, error) {
|
||||
args := []string{"list"}
|
||||
if depth > 0 {
|
||||
args = append(args, "-d")
|
||||
args = append(args, strconv.FormatUint(depth, 10))
|
||||
} else {
|
||||
args = append(args, "-r")
|
||||
}
|
||||
args = append(args, "-t", "all", "-p", "-o", "all")
|
||||
args = append(args, d.Name)
|
||||
|
||||
out, err := d.z.doOutput(args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var datasets []*Dataset
|
||||
name := ""
|
||||
var ds *Dataset
|
||||
for _, line := range out[1:] {
|
||||
if name != line[0] {
|
||||
name = line[0]
|
||||
ds = &Dataset{z: d.z, Name: name, props: make(map[string]string)}
|
||||
datasets = append(datasets, ds)
|
||||
}
|
||||
if err := ds.parseProps([][]string{out[0], line}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return datasets[1:], nil
|
||||
}
|
||||
|
||||
func (d *Dataset) Diff(snapshot string) ([]*InodeChange, error) {
|
||||
args := []string{"diff", "-FH", snapshot, d.Name}
|
||||
out, err := d.z.doOutput(args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inodeChanges, err := parseInodeChanges(out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return inodeChanges, nil
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package zfs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sylve/pkg/exe"
|
||||
)
|
||||
|
||||
var z ZFS = &zfs{exec: exe.NewLocalExecutor(), sudo: false}
|
||||
|
||||
func SetDefault(zfs ZFS) {
|
||||
if zfs != nil {
|
||||
z = zfs
|
||||
}
|
||||
}
|
||||
|
||||
func Datasets(filter string) ([]*Dataset, error) {
|
||||
return z.Datasets(filter)
|
||||
}
|
||||
|
||||
func Snapshots(filter string) ([]*Dataset, error) {
|
||||
return z.Snapshots(filter)
|
||||
}
|
||||
|
||||
func GetZpool(name string) (*Zpool, error) {
|
||||
return z.GetZpool(name)
|
||||
}
|
||||
|
||||
func ListZpools() ([]*Zpool, error) {
|
||||
return z.ListZpools()
|
||||
}
|
||||
|
||||
func GetPoolIODelay(poolName string) (float64, error) {
|
||||
return z.GetPoolIODelay(poolName)
|
||||
}
|
||||
|
||||
func GetTotalIODelay() float64 {
|
||||
return z.GetTotalIODelay()
|
||||
}
|
||||
|
||||
func DestroyPool(poolName string) error {
|
||||
var pools []*Zpool
|
||||
pools, err := ListZpools()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var found *Zpool
|
||||
|
||||
for _, pool := range pools {
|
||||
if pool.Name == poolName {
|
||||
found = pool
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if found == nil {
|
||||
return fmt.Errorf("error_getting_pool: pool %s not found", poolName)
|
||||
}
|
||||
|
||||
err = found.Destroy()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to destroy pool: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package zfs
|
||||
|
||||
import "fmt"
|
||||
|
||||
type Error struct {
|
||||
Err error
|
||||
Debug string
|
||||
Stderr string
|
||||
}
|
||||
|
||||
func (e Error) Error() string {
|
||||
return fmt.Sprintf("%s: %q => %s", e.Err, e.Debug, e.Stderr)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package zfs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sylve/pkg/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
_ = iota
|
||||
BlockDevice InodeType = iota
|
||||
CharacterDevice
|
||||
Directory
|
||||
Door
|
||||
NamedPipe
|
||||
SymbolicLink
|
||||
EventPort
|
||||
Socket
|
||||
File
|
||||
)
|
||||
|
||||
const (
|
||||
_ = iota
|
||||
Removed ChangeType = iota
|
||||
Created
|
||||
Modified
|
||||
Renamed
|
||||
)
|
||||
|
||||
var (
|
||||
dsPropList = []string{"name", "origin", "used", "avail", "mountpoint", "compression", "type", "volsize", "quota", "referenced", "written", "logicalused", "usedbydataset"}
|
||||
zpoolPropList = []string{"name", "health", "allocated", "size", "free", "readonly", "dedupratio", "fragmentation", "freeing", "leaked"}
|
||||
zpoolPropListOptions = strings.Join(zpoolPropList, ",")
|
||||
zpoolArgs = []string{"get", "-Hp", zpoolPropListOptions}
|
||||
|
||||
zpoolVdevArgs = []string{"list", "-HPpv"}
|
||||
)
|
||||
|
||||
var changeTypeMap = map[string]ChangeType{
|
||||
"-": Removed,
|
||||
"+": Created,
|
||||
"M": Modified,
|
||||
"R": Renamed,
|
||||
}
|
||||
|
||||
var inodeTypeMap = map[string]InodeType{
|
||||
"B": BlockDevice,
|
||||
"C": CharacterDevice,
|
||||
"/": Directory,
|
||||
">": Door,
|
||||
"|": NamedPipe,
|
||||
"@": SymbolicLink,
|
||||
"P": EventPort,
|
||||
"=": Socket,
|
||||
"F": File,
|
||||
}
|
||||
|
||||
var referenceCountRegex = regexp.MustCompile(`\(([+-]\d+?)\)`)
|
||||
|
||||
func parseInodeChange(line []string) (*InodeChange, error) {
|
||||
llen := len(line)
|
||||
if llen < 1 {
|
||||
return nil, fmt.Errorf("empty line passed")
|
||||
}
|
||||
|
||||
changeType := changeTypeMap[line[0]]
|
||||
if changeType == 0 {
|
||||
return nil, fmt.Errorf("unknown change type '%s'", line[0])
|
||||
}
|
||||
|
||||
switch changeType {
|
||||
case Renamed:
|
||||
if llen != 4 {
|
||||
return nil, fmt.Errorf("mismatching number of fields: expect 4, got: %d", llen)
|
||||
}
|
||||
case Modified:
|
||||
if llen != 4 && llen != 3 {
|
||||
return nil, fmt.Errorf("mismatching number of fields: expect 3..4, got: %d", llen)
|
||||
}
|
||||
default:
|
||||
if llen != 3 {
|
||||
return nil, fmt.Errorf("mismatching number of fields: expect 3, got: %d", llen)
|
||||
}
|
||||
}
|
||||
|
||||
inodeType := inodeTypeMap[line[1]]
|
||||
if inodeType == 0 {
|
||||
return nil, fmt.Errorf("unknown inode type '%s'", line[1])
|
||||
}
|
||||
|
||||
path, err := utils.UnescapeFilepath(line[2])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse filename: %w", err)
|
||||
}
|
||||
|
||||
var newPath string
|
||||
var referenceCount int
|
||||
switch changeType {
|
||||
case Renamed:
|
||||
newPath, err = utils.UnescapeFilepath(line[3])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse filename: %w", err)
|
||||
}
|
||||
case Modified:
|
||||
if llen == 4 {
|
||||
referenceCount, err = parseReferenceCount(line[3])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse reference count: %w", err)
|
||||
}
|
||||
}
|
||||
default:
|
||||
newPath = ""
|
||||
}
|
||||
|
||||
return &InodeChange{
|
||||
Change: changeType,
|
||||
Type: inodeType,
|
||||
Path: path,
|
||||
NewPath: newPath,
|
||||
ReferenceCountChange: referenceCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseInodeChanges(lines [][]string) ([]*InodeChange, error) {
|
||||
changes := make([]*InodeChange, len(lines))
|
||||
|
||||
for i, line := range lines {
|
||||
c, err := parseInodeChange(line)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse line %d of zfs diff: %w, got: '%s'", i, err, line)
|
||||
}
|
||||
changes[i] = c
|
||||
}
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
func setString(field *string, value string) {
|
||||
v := ""
|
||||
if value != "-" {
|
||||
v = value
|
||||
}
|
||||
*field = v
|
||||
}
|
||||
|
||||
func setUint(field *uint64, value string) error {
|
||||
var v uint64
|
||||
if value != "-" {
|
||||
var err error
|
||||
v, err = strconv.ParseUint(value, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
*field = v
|
||||
return nil
|
||||
}
|
||||
|
||||
func propsSlice(properties map[string]string) []string {
|
||||
args := make([]string, 0, len(properties)*3)
|
||||
for k, v := range properties {
|
||||
args = append(args, "-o")
|
||||
args = append(args, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func parseReferenceCount(field string) (int, error) {
|
||||
matches := referenceCountRegex.FindStringSubmatch(field)
|
||||
if matches == nil {
|
||||
return 0, fmt.Errorf("regexp does not match")
|
||||
}
|
||||
return strconv.Atoi(matches[1])
|
||||
}
|
||||
|
||||
func ParseTimeUnit(value string) uint64 {
|
||||
if value == "-" {
|
||||
return 0
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`([\d.]+)([a-zA-Z]*)`)
|
||||
matches := re.FindStringSubmatch(value)
|
||||
|
||||
if len(matches) != 3 {
|
||||
return 0
|
||||
}
|
||||
|
||||
num, err := strconv.ParseFloat(matches[1], 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
unit := matches[2]
|
||||
switch unit {
|
||||
case "us":
|
||||
return uint64(num)
|
||||
case "ms":
|
||||
return uint64(num * 1000)
|
||||
case "s":
|
||||
return uint64(num * 1000000)
|
||||
default:
|
||||
return uint64(num)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package zfs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (z *zfs) listByType(t, filter string) ([]*Dataset, error) {
|
||||
args := []string{"list", "-rp", "-t", t, "-o", "all"}
|
||||
|
||||
if filter != "" {
|
||||
args = append(args, filter)
|
||||
}
|
||||
out, err := z.doOutput(args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var datasets []*Dataset
|
||||
|
||||
name := ""
|
||||
var ds *Dataset
|
||||
for _, line := range out[1:] {
|
||||
if name != line[0] {
|
||||
name = line[0]
|
||||
ds = &Dataset{z: z, Name: name, props: make(map[string]string)}
|
||||
datasets = append(datasets, ds)
|
||||
}
|
||||
if err := ds.parseProps([][]string{out[0], line}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return datasets, nil
|
||||
}
|
||||
|
||||
func (d *Dataset) parseProps(out [][]string) error {
|
||||
var err error
|
||||
|
||||
if len(out) != 2 {
|
||||
return errors.New("output does not match what is expected on this platform")
|
||||
}
|
||||
for i, v := range out[0] {
|
||||
val := "-"
|
||||
if i < len(out[1]) {
|
||||
val = out[1][i]
|
||||
}
|
||||
d.props[strings.ToLower(v)] = val
|
||||
}
|
||||
|
||||
if len(d.props) <= len(dsPropList) {
|
||||
return errors.New("output does not match what is expected on this platform")
|
||||
}
|
||||
setString(&d.Name, d.props["name"])
|
||||
setString(&d.Origin, d.props["origin"])
|
||||
|
||||
if err = setUint(&d.Used, d.props["used"]); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = setUint(&d.Avail, d.props["avail"]); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
setString(&d.Mountpoint, d.props["mountpoint"])
|
||||
setString(&d.Compression, d.props["compress"])
|
||||
setString(&d.Type, d.props["type"])
|
||||
|
||||
if err = setUint(&d.Volsize, d.props["volsize"]); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = setUint(&d.Quota, d.props["quota"]); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = setUint(&d.Referenced, d.props["refer"]); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if runtime.GOOS == "solaris" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err = setUint(&d.Written, d.props["written"]); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = setUint(&d.Logicalused, d.props["lused"]); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = setUint(&d.Usedbydataset, d.props["usedds"]); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (z *zfs) run(in io.Reader, out io.Writer, cmd string, args ...string) ([][]string, error) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
if z.sudo {
|
||||
args = append([]string{cmd}, args...)
|
||||
cmd = "sudo"
|
||||
}
|
||||
|
||||
cmdOut := out
|
||||
if cmdOut == nil {
|
||||
cmdOut = &stdout
|
||||
}
|
||||
|
||||
// id := uuid.New().String()
|
||||
joinedArgs := strings.Join(args, " ")
|
||||
|
||||
// z.logger.Log([]string{"ID:" + id, "START", joinedArgs})
|
||||
if err := z.exec.Run(in, cmdOut, &stderr, cmd, args...); err != nil {
|
||||
return nil, &Error{
|
||||
Err: err,
|
||||
Debug: strings.Join([]string{cmd, joinedArgs}, " "),
|
||||
Stderr: stderr.String(),
|
||||
}
|
||||
}
|
||||
|
||||
// z.logger.Log([]string{"ID:" + id, "FINISH"})
|
||||
|
||||
// assume if you passed in something for stdout, that you know what to do with it
|
||||
if out != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
lines := strings.Split(stdout.String(), "\n")
|
||||
|
||||
// last line is always blank
|
||||
lines = lines[0 : len(lines)-1]
|
||||
output := make([][]string, len(lines))
|
||||
|
||||
for i, l := range lines {
|
||||
output[i] = strings.Fields(l)
|
||||
}
|
||||
|
||||
return output, nil
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
package zfs
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strconv"
|
||||
"sylve/pkg/exe"
|
||||
)
|
||||
|
||||
type InodeType int
|
||||
type ChangeType int
|
||||
type DestroyFlag int
|
||||
|
||||
const (
|
||||
DatasetFilesystem = "filesystem"
|
||||
DatasetSnapshot = "snapshot"
|
||||
DatasetVolume = "volume"
|
||||
)
|
||||
|
||||
const (
|
||||
DestroyDefault DestroyFlag = 1 << iota
|
||||
DestroyRecursive = 1 << iota
|
||||
DestroyRecursiveClones = 1 << iota
|
||||
DestroyDeferDeletion = 1 << iota
|
||||
DestroyForceUmount = 1 << iota
|
||||
)
|
||||
|
||||
type zfs struct {
|
||||
exec exe.Executor
|
||||
sudo bool
|
||||
}
|
||||
|
||||
type InodeChange struct {
|
||||
Change ChangeType
|
||||
Type InodeType
|
||||
Path string
|
||||
NewPath string
|
||||
ReferenceCountChange int
|
||||
}
|
||||
|
||||
type ZFS interface {
|
||||
Datasets(filter string) ([]*Dataset, error)
|
||||
GetDataset(name string) (*Dataset, error)
|
||||
CreateFilesystem(name string, properties map[string]string) (*Dataset, error)
|
||||
Filesystems(filter string) ([]*Dataset, error)
|
||||
CreateVolume(name string, size uint64, properties map[string]string) (*Dataset, error)
|
||||
Volumes(filter string) ([]*Dataset, error)
|
||||
Snapshots(filter string) ([]*Dataset, error)
|
||||
ReceiveSnapshot(input io.Reader, name string, force ...bool) (*Dataset, error)
|
||||
|
||||
ListZpools() ([]*Zpool, error)
|
||||
GetZpool(name string) (*Zpool, error)
|
||||
CreateZpool(name string, properties map[string]string, args ...string) (*Zpool, error)
|
||||
GetPoolIODelay(poolName string) (float64, error)
|
||||
GetTotalIODelay() float64
|
||||
}
|
||||
|
||||
func (z *zfs) do(arg ...string) error {
|
||||
_, err := z.doOutput(arg...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (z *zfs) doOutput(arg ...string) ([][]string, error) {
|
||||
return z.run(nil, nil, "zfs", arg...)
|
||||
}
|
||||
|
||||
func (z *zfs) Datasets(filter string) ([]*Dataset, error) {
|
||||
return z.listByType("all", filter)
|
||||
}
|
||||
|
||||
func (z *zfs) Snapshots(filter string) ([]*Dataset, error) {
|
||||
return z.listByType(DatasetSnapshot, filter)
|
||||
}
|
||||
|
||||
func (z *zfs) Filesystems(filter string) ([]*Dataset, error) {
|
||||
return z.listByType(DatasetFilesystem, filter)
|
||||
}
|
||||
|
||||
func (z *zfs) Volumes(filter string) ([]*Dataset, error) {
|
||||
return z.listByType(DatasetVolume, filter)
|
||||
}
|
||||
|
||||
func (z *zfs) GetDataset(name string) (*Dataset, error) {
|
||||
out, err := z.doOutput("list", "-p", "-o", "all", name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ds := &Dataset{z: z, Name: name, props: make(map[string]string)}
|
||||
return ds, ds.parseProps(out)
|
||||
}
|
||||
|
||||
func (z *zfs) ReceiveSnapshot(input io.Reader, name string, force ...bool) (*Dataset, error) {
|
||||
args := []string{"receive"}
|
||||
if len(force) > 0 && force[0] {
|
||||
args = append(args, "-F")
|
||||
}
|
||||
args = append(args, name)
|
||||
if _, err := z.run(input, nil, "zfs", args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return z.GetDataset(name)
|
||||
}
|
||||
|
||||
func (z *zfs) CreateVolume(name string, size uint64, properties map[string]string) (*Dataset, error) {
|
||||
args := make([]string, 4, 5)
|
||||
args[0] = "create"
|
||||
args[1] = "-p"
|
||||
args[2] = "-V"
|
||||
args[3] = strconv.FormatUint(size, 10)
|
||||
if properties != nil {
|
||||
args = append(args, propsSlice(properties)...)
|
||||
}
|
||||
args = append(args, name)
|
||||
if err := z.do(args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return z.GetDataset(name)
|
||||
}
|
||||
|
||||
// https://openzfs.github.io/openzfs-docs/man/7/zfsprops.7.html.
|
||||
func (z *zfs) CreateFilesystem(name string, properties map[string]string) (*Dataset, error) {
|
||||
args := make([]string, 1, 4)
|
||||
args[0] = "create"
|
||||
|
||||
if properties != nil {
|
||||
args = append(args, propsSlice(properties)...)
|
||||
}
|
||||
|
||||
args = append(args, name)
|
||||
if err := z.do(args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return z.GetDataset(name)
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package zfs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sylve/pkg/utils"
|
||||
)
|
||||
|
||||
type RW struct {
|
||||
Read uint64 `json:"read"`
|
||||
Write uint64 `json:"write"`
|
||||
}
|
||||
|
||||
type VdevDevice struct {
|
||||
Name string `json:"name"`
|
||||
Size uint64 `json:"size"`
|
||||
}
|
||||
|
||||
type Vdev struct {
|
||||
Name string `json:"name"`
|
||||
Alloc uint64 `json:"alloc"`
|
||||
Free uint64 `json:"free"`
|
||||
Operations RW `json:"operations"`
|
||||
Bandwidth RW `json:"bandwidth"`
|
||||
VdevDevices []VdevDevice `json:"devices"`
|
||||
}
|
||||
|
||||
type Zpool struct {
|
||||
z *zfs `json:"-"`
|
||||
Name string `json:"name"`
|
||||
Health string `json:"health"`
|
||||
Allocated uint64 `json:"allocated"`
|
||||
Size uint64 `json:"size"`
|
||||
Free uint64 `json:"free"`
|
||||
Fragmentation uint64 `json:"fragmentation"`
|
||||
ReadOnly bool `json:"readOnly"`
|
||||
Freeing uint64 `json:"freeing"`
|
||||
Leaked uint64 `json:"leaked"`
|
||||
DedupRatio float64 `json:"dedupRatio"`
|
||||
Vdevs []Vdev `json:"vdevs"`
|
||||
}
|
||||
|
||||
func (z *zfs) zpool(arg ...string) error {
|
||||
_, err := z.zpoolOutput(arg...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (z *zfs) zpoolOutput(arg ...string) ([][]string, error) {
|
||||
return z.run(nil, nil, "zpool", arg...)
|
||||
}
|
||||
|
||||
func (z *Zpool) parseLine(line []string) error {
|
||||
prop := line[1]
|
||||
val := line[2]
|
||||
|
||||
var err error
|
||||
|
||||
switch prop {
|
||||
case "name":
|
||||
setString(&z.Name, val)
|
||||
case "health":
|
||||
setString(&z.Health, val)
|
||||
case "allocated":
|
||||
err = setUint(&z.Allocated, val)
|
||||
case "size":
|
||||
err = setUint(&z.Size, val)
|
||||
case "free":
|
||||
err = setUint(&z.Free, val)
|
||||
case "fragmentation":
|
||||
// Trim trailing "%" before parsing uint
|
||||
i := strings.Index(val, "%")
|
||||
if i < 0 {
|
||||
i = len(val)
|
||||
}
|
||||
err = setUint(&z.Fragmentation, val[:i])
|
||||
case "readonly":
|
||||
z.ReadOnly = val == "on"
|
||||
case "freeing":
|
||||
err = setUint(&z.Freeing, val)
|
||||
case "leaked":
|
||||
err = setUint(&z.Leaked, val)
|
||||
case "dedupratio":
|
||||
// Trim trailing "x" before parsing float64
|
||||
z.DedupRatio, err = strconv.ParseFloat(val[:len(val)-1], 64)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (z *zfs) GetZpool(name string) (*Zpool, error) {
|
||||
args := zpoolArgs
|
||||
args = append(args, name)
|
||||
out, err := z.zpoolOutput(args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pool := &Zpool{z: z, Name: name}
|
||||
for _, line := range out {
|
||||
if err := pool.parseLine(line); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
vdevOut, err := z.zpoolOutput(append(zpoolVdevArgs, name)...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var vdevPtrs []*Vdev
|
||||
var currentVdev *Vdev
|
||||
|
||||
for i, line := range vdevOut {
|
||||
name := line[0]
|
||||
|
||||
if i == 0 && name == pool.Name {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(name, "mirror") || strings.HasPrefix(name, "raidz") {
|
||||
currentVdev = &Vdev{
|
||||
Name: name,
|
||||
Alloc: utils.StringToUint64(line[1]),
|
||||
Free: utils.StringToUint64(line[3]),
|
||||
Operations: RW{Read: utils.StringToUint64(line[5]), Write: utils.StringToUint64(line[6])},
|
||||
Bandwidth: RW{Read: utils.StringToUint64(line[7]), Write: utils.StringToUint64(line[8])},
|
||||
}
|
||||
vdevPtrs = append(vdevPtrs, currentVdev)
|
||||
} else if strings.HasPrefix(name, "/dev/") {
|
||||
device := VdevDevice{
|
||||
Name: name,
|
||||
Size: utils.StringToUint64(line[1]),
|
||||
}
|
||||
|
||||
if currentVdev != nil {
|
||||
currentVdev.VdevDevices = append(currentVdev.VdevDevices, device)
|
||||
} else {
|
||||
vdev := &Vdev{
|
||||
Name: name,
|
||||
Alloc: utils.StringToUint64(line[1]),
|
||||
Free: utils.StringToUint64(line[2]),
|
||||
Operations: RW{Read: utils.StringToUint64(line[5]), Write: utils.StringToUint64(line[6])},
|
||||
Bandwidth: RW{Read: utils.StringToUint64(line[7]), Write: utils.StringToUint64(line[8])},
|
||||
VdevDevices: []VdevDevice{
|
||||
device,
|
||||
},
|
||||
}
|
||||
vdevPtrs = append(vdevPtrs, vdev)
|
||||
}
|
||||
} else {
|
||||
currentVdev = nil
|
||||
}
|
||||
}
|
||||
|
||||
var vdevs []Vdev
|
||||
for _, v := range vdevPtrs {
|
||||
vdevs = append(vdevs, *v)
|
||||
}
|
||||
pool.Vdevs = vdevs
|
||||
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
func (z *Zpool) Datasets() ([]*Dataset, error) {
|
||||
return z.z.Datasets(z.Name)
|
||||
}
|
||||
|
||||
func (z *Zpool) Snapshots() ([]*Dataset, error) {
|
||||
return z.z.Snapshots(z.Name)
|
||||
}
|
||||
|
||||
func (z *zfs) CreateZpool(name string, properties map[string]string, args ...string) (*Zpool, error) {
|
||||
cli := make([]string, 1, 4)
|
||||
cli[0] = "create"
|
||||
if properties != nil {
|
||||
cli = append(cli, propsSlice(properties)...)
|
||||
}
|
||||
cli = append(cli, name)
|
||||
cli = append(cli, args...)
|
||||
if err := z.zpool(cli...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Zpool{z: z, Name: name}, nil
|
||||
}
|
||||
|
||||
func (z *Zpool) Destroy() error {
|
||||
err := z.z.zpool("destroy", z.Name)
|
||||
return err
|
||||
}
|
||||
|
||||
func (z *zfs) ListZpools() ([]*Zpool, error) {
|
||||
args := []string{"list", "-Ho", "name"}
|
||||
out, err := z.zpoolOutput(args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var pools []*Zpool
|
||||
|
||||
for _, line := range out {
|
||||
z, err := z.GetZpool(line[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pools = append(pools, z)
|
||||
}
|
||||
return pools, nil
|
||||
}
|
||||
|
||||
func (z *zfs) GetPoolIODelay(poolName string) (float64, error) {
|
||||
pool, err := z.GetZpool(poolName)
|
||||
if err != nil {
|
||||
return 0.0, err
|
||||
}
|
||||
|
||||
rows, err := z.zpoolOutput("iostat", "-l", "-H", "-v", pool.Name, "1", "2")
|
||||
if err != nil {
|
||||
return 0.0, err
|
||||
}
|
||||
|
||||
var sampleIndices []int
|
||||
for i, row := range rows {
|
||||
if len(row) > 0 && row[0] == poolName {
|
||||
sampleIndices = append(sampleIndices, i)
|
||||
}
|
||||
}
|
||||
|
||||
if len(sampleIndices) < 2 {
|
||||
return 0.0, fmt.Errorf("not enough samples for pool %s", poolName)
|
||||
}
|
||||
|
||||
secondSampleRow := rows[sampleIndices[1]]
|
||||
if len(secondSampleRow) < 9 {
|
||||
return 0.0, fmt.Errorf("not enough fields in iostat output")
|
||||
}
|
||||
|
||||
readOps := utils.StringToUint64(secondSampleRow[3])
|
||||
writeOps := utils.StringToUint64(secondSampleRow[4])
|
||||
if (readOps + writeOps) == 0 {
|
||||
return 0.0, nil
|
||||
}
|
||||
|
||||
readWait := ParseTimeUnit(secondSampleRow[7])
|
||||
writeWait := ParseTimeUnit(secondSampleRow[8])
|
||||
|
||||
totalWait := (readOps * readWait) + (writeOps * writeWait)
|
||||
avgWait := totalWait / (readOps + writeOps)
|
||||
delayPercentage := (float64(avgWait) / 1_000_000.0) * 100
|
||||
|
||||
return delayPercentage, nil
|
||||
}
|
||||
|
||||
func (z *zfs) GetTotalIODelay() float64 {
|
||||
pools, err := z.ListZpools()
|
||||
if err != nil {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
var totalDelay float64
|
||||
count := 0
|
||||
|
||||
for _, pool := range pools {
|
||||
delay, _ := GetPoolIODelay(pool.Name)
|
||||
if delay > 0 {
|
||||
totalDelay += delay
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
return totalDelay / float64(count)
|
||||
}
|
||||
Generated
+42
@@ -14,6 +14,7 @@
|
||||
"@layerstack/svelte-stores": "^1.0.0",
|
||||
"@svelte-put/shortcut": "^4.1.0",
|
||||
"@sveltestack/svelte-query": "^1.6.0",
|
||||
"@thisux/sveltednd": "^0.0.20",
|
||||
"adze": "^2.2.1",
|
||||
"axios": "^1.8.2",
|
||||
"d3-array": "^3.2.4",
|
||||
@@ -2002,6 +2003,47 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/@thisux/sveltednd": {
|
||||
"version": "0.0.20",
|
||||
"resolved": "https://registry.npmjs.org/@thisux/sveltednd/-/sveltednd-0.0.20.tgz",
|
||||
"integrity": "sha512-VE0HopIlHIvNOfSZ1SsiIzl1AmTU0VSd2Nz2Q2nIdqIUsT/UP4hiLw2ed2jFqTKTbqC+kbCYL9vuBpkYnEw+Kw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@thisux/sveltednd": "^0.0.18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@thisux/sveltednd/node_modules/@thisux/sveltednd": {
|
||||
"version": "0.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@thisux/sveltednd/-/sveltednd-0.0.18.tgz",
|
||||
"integrity": "sha512-MO+iR9ZRHApvtwgWujmqwUbhv9mKs0jLOmeG4tn98GmU1/9wVOi02jrNqeggKg3c8dXz6pMmUHfOfmcq7WwYZg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@thisux/sveltednd": "^0.0.17"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@thisux/sveltednd/node_modules/@thisux/sveltednd/node_modules/@thisux/sveltednd": {
|
||||
"version": "0.0.17",
|
||||
"resolved": "https://registry.npmjs.org/@thisux/sveltednd/-/sveltednd-0.0.17.tgz",
|
||||
"integrity": "sha512-lRninjw439phhA8xAHqCpMAX0hnwFMdbXW4M0XJgAdnGxeum+QsLiIC4P3HnkNXAygsVKUqxRcbS84CxDZ9hPw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@thisux/sveltednd": "^0.0.14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@thisux/sveltednd/node_modules/@thisux/sveltednd/node_modules/@thisux/sveltednd/node_modules/@thisux/sveltednd": {
|
||||
"version": "0.0.14",
|
||||
"resolved": "https://registry.npmjs.org/@thisux/sveltednd/-/sveltednd-0.0.14.tgz",
|
||||
"integrity": "sha512-Vbq69SU3HUomPg6oCXtb89OG89hka0YIkdaErYibn3waK7tYE66IcQxD/Fzg8YNW3EVsXoA9kc7kW5EUBCSQGg=="
|
||||
},
|
||||
"node_modules/@types/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"@layerstack/svelte-stores": "^1.0.0",
|
||||
"@svelte-put/shortcut": "^4.1.0",
|
||||
"@sveltestack/svelte-query": "^1.6.0",
|
||||
"@thisux/sveltednd": "^0.0.20",
|
||||
"adze": "^2.2.1",
|
||||
"axios": "^1.8.2",
|
||||
"d3-array": "^3.2.4",
|
||||
|
||||
@@ -37,10 +37,14 @@ export async function createPool(
|
||||
raid: string,
|
||||
options: Record<string, string>
|
||||
) {
|
||||
return await apiRequest('/zfs/pool', APIResponseSchema, 'POST', {
|
||||
return await apiRequest('/zfs/pools', APIResponseSchema, 'POST', {
|
||||
name,
|
||||
vdevs,
|
||||
raid,
|
||||
options
|
||||
});
|
||||
}
|
||||
|
||||
export async function deletePool(name: string) {
|
||||
return await apiRequest(`/zfs/pools/${name}`, APIResponseSchema, 'DELETE');
|
||||
}
|
||||
|
||||
@@ -1,25 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { default as TreeView } from '$lib/components/custom/TreeView.svelte';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
import * as Select from '$lib/components/ui/select/index.js';
|
||||
import { hostname } from '$lib/stores/basic';
|
||||
import Settings from 'lucide-svelte/icons/settings';
|
||||
|
||||
let openCategories: { [key: string]: boolean } = $state({});
|
||||
let node = $hostname;
|
||||
|
||||
const toggleCategory = (label: string) => {
|
||||
openCategories[label] = !openCategories[label];
|
||||
};
|
||||
|
||||
let node = $hostname;
|
||||
const options = [
|
||||
{ value: 'server', label: 'Server view' },
|
||||
{ value: 'folder', label: 'Folder View' },
|
||||
{ value: 'pool', label: 'Pool View' }
|
||||
];
|
||||
|
||||
const tree = [
|
||||
{
|
||||
label: 'datacenter',
|
||||
@@ -30,21 +20,11 @@
|
||||
icon: 'mdi:dns',
|
||||
href: `/${node}`,
|
||||
children: [
|
||||
{
|
||||
label: '100 (Firewall)',
|
||||
icon: 'tabler:prison',
|
||||
href: `/${node}/100_firewall`
|
||||
},
|
||||
{
|
||||
label: '101 (Windows)',
|
||||
icon: 'mi:computer',
|
||||
href: `/${node}/100_firewall`
|
||||
},
|
||||
{
|
||||
label: '102 (test-store)',
|
||||
icon: 'mdi:database',
|
||||
href: `/${node}/106_tg_wallet`
|
||||
}
|
||||
// {
|
||||
// label: '100 (Firewall)',
|
||||
// icon: 'tabler:prison',
|
||||
// href: `/${node}/100_firewall`
|
||||
// },
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
buttonClass="h-8 mt-4"
|
||||
/>
|
||||
<CreateDialog
|
||||
title="Create: LXC Container"
|
||||
title="Create: Jail"
|
||||
tabs={ctTabs}
|
||||
icon="ph:cube-fill"
|
||||
buttonText="Create Jail"
|
||||
@@ -147,7 +147,7 @@
|
||||
/>
|
||||
|
||||
<CreateDialog
|
||||
title="Create: LXC Container"
|
||||
title="Create: Jail"
|
||||
tabs={ctTabs}
|
||||
icon="tabler:prison"
|
||||
buttonText="Create Jail"
|
||||
|
||||
@@ -80,7 +80,7 @@ export function parseSMART(disk: Disk): SmartAttribute | SmartAttribute[] {
|
||||
'Unsafe Shutdowns': (disk.SmartData as SmartNVME).unsafeShutdowns,
|
||||
'Warning Composite Temp Time': (disk.SmartData as SmartNVME).warningCompositeTempTime
|
||||
};
|
||||
} else if (disk.Type === 'HDD') {
|
||||
} else if (disk.Type === 'HDD' || disk.Type === 'SSD') {
|
||||
const data = disk.SmartData as SmartCtl;
|
||||
const attributes: SmartAttribute[] = [];
|
||||
|
||||
|
||||
@@ -42,3 +42,50 @@ export function isDeviceVdev(device: string, pools: Zpool[]): boolean {
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getHealthHelpers(health: string): { icon: string; color: string; text: string } {
|
||||
switch (health) {
|
||||
case 'ONLINE':
|
||||
return {
|
||||
icon: 'carbon:checkmark-filled',
|
||||
color: 'text-green-600 dark:text-green-500',
|
||||
text: 'Online'
|
||||
};
|
||||
case 'DEGRADED':
|
||||
return {
|
||||
icon: 'carbon:warning-filled',
|
||||
color: 'text-yellow-600 dark:text-yellow-500',
|
||||
text: 'Degraded'
|
||||
};
|
||||
case 'FAULTED':
|
||||
return {
|
||||
icon: 'carbon:close-filled',
|
||||
color: 'text-red-600 dark:text-red-500',
|
||||
text: 'Faulted'
|
||||
};
|
||||
case 'OFFLINE':
|
||||
return {
|
||||
icon: 'material-symbols:offline-pin-off',
|
||||
color: 'text-red-600 dark:text-red-500',
|
||||
text: 'Offline'
|
||||
};
|
||||
case 'UNAVAIL':
|
||||
return {
|
||||
icon: 'carbon:warning-alt-filled',
|
||||
color: 'text-yellow-600 dark:text-yellow-500',
|
||||
text: 'Unavailable'
|
||||
};
|
||||
case 'REMOVED':
|
||||
return {
|
||||
icon: 'carbon:warning-alt-filled',
|
||||
color: 'text-yellow-600 dark:text-yellow-500',
|
||||
text: 'Removed'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: 'carbon:warning-alt-filled',
|
||||
color: 'text-yellow-600 dark:text-yellow-500',
|
||||
text: 'Unknown'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,46 +25,11 @@
|
||||
icon: 'basil:document-outline',
|
||||
href: `/${node}/summary`
|
||||
},
|
||||
{
|
||||
label: 'search',
|
||||
icon: 'mdi:magnify',
|
||||
href: `/${node}/search`
|
||||
},
|
||||
{
|
||||
label: 'notes',
|
||||
icon: 'arcticons:notes',
|
||||
href: `/${node}/notes`
|
||||
},
|
||||
{
|
||||
label: 'certificates',
|
||||
icon: 'mdi:certificate'
|
||||
},
|
||||
{
|
||||
label: 'firewall',
|
||||
icon: 'mdi:firewall',
|
||||
children: [
|
||||
{
|
||||
label: 'Options',
|
||||
icon: 'mdi:phone',
|
||||
href: '/dashboard/telephony'
|
||||
},
|
||||
{
|
||||
label: 'Security Group',
|
||||
icon: 'mdi:phone',
|
||||
href: '/dashboard/telephony'
|
||||
},
|
||||
{
|
||||
label: 'Alias',
|
||||
icon: 'mdi:phone',
|
||||
href: '/dashboard/telephony'
|
||||
},
|
||||
{
|
||||
label: 'IPSet',
|
||||
icon: 'mdi:phone',
|
||||
href: '/dashboard/telephony'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Storage',
|
||||
icon: 'mdi:storage',
|
||||
|
||||
@@ -166,8 +166,9 @@
|
||||
smartModal.KV = parseSMART($state.snapshot(activeDisk));
|
||||
smartModal.open = true;
|
||||
smartModal.type = 'kv';
|
||||
} else if (activeDisk.Type === 'HDD') {
|
||||
} else if (activeDisk.Type === 'HDD' || activeDisk.Type === 'SSD') {
|
||||
smartModal.KV = parseSMART($state.snapshot(activeDisk));
|
||||
console.log(smartModal.KV);
|
||||
smartModal.open = true;
|
||||
smartModal.type = 'array';
|
||||
}
|
||||
@@ -226,22 +227,6 @@
|
||||
visibleColumns.value = Object.fromEntries(keys.map((key) => [key, true]));
|
||||
}
|
||||
|
||||
function generateTitle(t: string, disk?: Disk | null, partition?: Partition | null) {
|
||||
if (t === 'create-partition') {
|
||||
if (disk) {
|
||||
if (disk.GPT) {
|
||||
if (disk.Partitions.length > 0) {
|
||||
const usedSizes = disk.Partitions.map((p) => p.size);
|
||||
const remainingSpace = disk.Size - usedSizes.reduce((a, b) => a + b, 0);
|
||||
if (remainingSpace < 128 * 1024 * 1024) {
|
||||
return 'No space available';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let buttonAbilities = $state({
|
||||
smart: {
|
||||
ability: false,
|
||||
@@ -281,7 +266,7 @@
|
||||
);
|
||||
}
|
||||
|
||||
if (activeDisk.Usage === 'ZFS Vdev') {
|
||||
if (activeDisk.Usage === 'ZFS') {
|
||||
buttonAbilities.gpt.ability = false;
|
||||
buttonAbilities.gpt.reason = getTranslation(
|
||||
'disk.zfs_vdev',
|
||||
@@ -289,9 +274,9 @@
|
||||
);
|
||||
}
|
||||
|
||||
if (activeDisk.Usage === 'ZFS Vdev' || activeDisk.Usage === 'Unused') {
|
||||
if (activeDisk.Usage === 'ZFS' || activeDisk.Usage === 'Unused') {
|
||||
buttonAbilities.wipe.ability = false;
|
||||
if (activeDisk.Usage === 'ZFS Vdev') {
|
||||
if (activeDisk.Usage === 'ZFS') {
|
||||
buttonAbilities.wipe.reason = getTranslation(
|
||||
'disk.zfs_vdev',
|
||||
'ZFS Vdev cannot be wiped'
|
||||
@@ -308,10 +293,10 @@
|
||||
buttonAbilities.createPartition.ability =
|
||||
activeDisk.GPT &&
|
||||
diskSpaceAvailable(activeDisk, 128 * 1024 * 1024) &&
|
||||
activeDisk.Usage !== 'ZFS Vdev';
|
||||
activeDisk.Usage !== 'ZFS';
|
||||
|
||||
if (!buttonAbilities.createPartition.ability) {
|
||||
if (activeDisk.Usage === 'ZFS Vdev') {
|
||||
if (activeDisk.Usage === 'ZFS') {
|
||||
buttonAbilities.createPartition.reason = getTranslation(
|
||||
'disk.zfs_vdev',
|
||||
'ZFS Vdev cannot be partitioned'
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { listDisks } from '$lib/api/disk/disk';
|
||||
import { getPools } from '$lib/api/zfs/pool';
|
||||
import { simplifyDisks } from '$lib/utils/disk';
|
||||
import { cachedFetch } from '$lib/utils/http';
|
||||
|
||||
export async function load() {
|
||||
let [disks, pools] = await Promise.all([simplifyDisks(await listDisks()), getPools()]);
|
||||
const cacheDuration = 3600 * 1000;
|
||||
const [disks, pools] = await Promise.all([
|
||||
cachedFetch('disks', async () => simplifyDisks(await listDisks()), cacheDuration),
|
||||
cachedFetch('pools', getPools, cacheDuration)
|
||||
]);
|
||||
|
||||
return {
|
||||
disks,
|
||||
|
||||
@@ -1,393 +1,153 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { listDisks } from '$lib/api/disk/disk';
|
||||
import { getPools } from '$lib/api/zfs/pool';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import CircleHelp from 'lucide-svelte/icons/circle-help';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||
import * as ContextMenu from '$lib/components/ui/context-menu';
|
||||
import { localStore } from '$lib/stores/localStore.svelte';
|
||||
import { TableHandler } from '@vincjo/datatables';
|
||||
import * as Select from '$lib/components/ui/select/index.js';
|
||||
import { slide } from 'svelte/transition';
|
||||
import type { Disk } from '$lib/types/disk/disk';
|
||||
import type { Zpool } from '$lib/types/zfs/pool';
|
||||
import { simplifyDisks } from '$lib/utils/disk';
|
||||
import Icon from '@iconify/svelte';
|
||||
import { useQueries } from '@sveltestack/svelte-query';
|
||||
|
||||
import MultiSelect from 'svelte-multiselect';
|
||||
|
||||
const ui_libs = [
|
||||
{
|
||||
label: 'harddisk',
|
||||
disabled: false,
|
||||
preselected: false,
|
||||
defaultDisabledTitle: 'This is a disabled option'
|
||||
},
|
||||
{
|
||||
label: 'harddisk1',
|
||||
disabled: false,
|
||||
preselected: false
|
||||
},
|
||||
{
|
||||
label: 'ssd',
|
||||
disabled: false,
|
||||
preselected: false
|
||||
}
|
||||
];
|
||||
|
||||
let selected = $state([]);
|
||||
|
||||
const raid = [
|
||||
{ value: 'mirror', label: 'Mirror' },
|
||||
{ value: 'raidz1', label: 'RAIDZ1' },
|
||||
{ value: 'raidz2', label: 'RAIDZ2' },
|
||||
{ value: 'raidz3', label: 'RAIDZ3' }
|
||||
];
|
||||
|
||||
const compression = [
|
||||
{ value: 'lz4', label: 'LZ4' },
|
||||
{ value: 'zstd', label: 'ZSTD' },
|
||||
{ value: 'gzip', label: 'GZIP' },
|
||||
{ value: 'zle', label: 'ZLE' }
|
||||
];
|
||||
|
||||
const ashift = [
|
||||
{ value: 'lz4', label: 'LZ4' },
|
||||
{ value: 'zstd', label: 'ZSTD' },
|
||||
{ value: 'gzip', label: 'GZIP' },
|
||||
{ value: 'zle', label: 'ZLE' }
|
||||
];
|
||||
|
||||
let modalIsOpen = $state(true);
|
||||
let advancedChecked = $state(false);
|
||||
|
||||
interface ZfsData {
|
||||
Health: string;
|
||||
Percentage_Used: string;
|
||||
Total_Size: string;
|
||||
interface Data {
|
||||
disks: Disk[];
|
||||
pools: Zpool[];
|
||||
}
|
||||
|
||||
const dummyData = [
|
||||
interface UnusedDisk {
|
||||
name: string;
|
||||
size: number;
|
||||
gpt: boolean;
|
||||
type: string;
|
||||
}
|
||||
|
||||
let { data }: { data: Data } = $props();
|
||||
|
||||
const results = useQueries([
|
||||
{
|
||||
Health: 'Healthy',
|
||||
Percentage_Used: '23%',
|
||||
Total_Size: '256 GB'
|
||||
queryKey: ['diskList'],
|
||||
queryFn: async () => {
|
||||
return await simplifyDisks(await listDisks());
|
||||
},
|
||||
refetchInterval: 1000,
|
||||
keepPreviousData: true,
|
||||
initialData: data.disks
|
||||
},
|
||||
{
|
||||
Health: 'Warning',
|
||||
Percentage_Used: '75%',
|
||||
Total_Size: '512 GB'
|
||||
},
|
||||
{
|
||||
Health: 'Critical',
|
||||
Percentage_Used: '95%',
|
||||
Total_Size: '1 TB'
|
||||
queryKey: ['poolList'],
|
||||
queryFn: async () => {
|
||||
return await getPools();
|
||||
},
|
||||
refetchInterval: 1000,
|
||||
keepPreviousData: true,
|
||||
initialData: data.pools
|
||||
}
|
||||
];
|
||||
]);
|
||||
|
||||
const table = new TableHandler(dummyData);
|
||||
let disks = $derived($results[0].data as Disk[]);
|
||||
let pools = $results[1].data as Zpool[];
|
||||
|
||||
const keys = ['Health', 'Percentage_Used', 'Total_Size'];
|
||||
let sortHandlers: Record<string, any> = {};
|
||||
let useableDisks = $derived.by(() => {
|
||||
const unusedDisks: UnusedDisk[] = [];
|
||||
for (const disk of disks) {
|
||||
if (disk.Usage === 'Unused' && disk.GPT === false) {
|
||||
unusedDisks.push({
|
||||
name: disk.Device,
|
||||
size: disk.Size,
|
||||
gpt: disk.GPT,
|
||||
type: disk.Type
|
||||
});
|
||||
}
|
||||
|
||||
keys.forEach((key) => {
|
||||
sortHandlers[key] = table.createSort(key as keyof ZfsData, {
|
||||
locales: 'en',
|
||||
options: { numeric: true, sensitivity: 'base' }
|
||||
});
|
||||
if (disk.Usage === 'Partitions') {
|
||||
for (const partition of disk.Partitions) {
|
||||
for (const pool of pools) {
|
||||
for (const vdev of pool.vdevs) {
|
||||
if (
|
||||
vdev.name !== `/dev/${partition.name}` &&
|
||||
vdev.name !== partition.name &&
|
||||
partition.usage === 'ZFS'
|
||||
) {
|
||||
unusedDisks.push({
|
||||
name: `/dev/${partition.name}`,
|
||||
size: partition.size,
|
||||
gpt: disk.GPT,
|
||||
type: 'Partition'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return unusedDisks;
|
||||
});
|
||||
|
||||
let visibleColumns = localStore(
|
||||
'zfsVisibleColumns',
|
||||
Object.fromEntries(keys.map((key) => [key, true]))
|
||||
);
|
||||
$effect(() => {
|
||||
console.log('unused disks', useableDisks);
|
||||
});
|
||||
|
||||
let openContextMenuId = $state<string | null>(null);
|
||||
let open: boolean = $state(false);
|
||||
let name: string = $state('');
|
||||
let vdevCount: number = $state(1);
|
||||
let createEnabled: boolean = $state(false);
|
||||
|
||||
function handleContextMenuOpen(id: string) {
|
||||
openContextMenuId = id;
|
||||
}
|
||||
$effect(() => {
|
||||
vdevCount = Math.max(1, Math.min(128, vdevCount));
|
||||
});
|
||||
|
||||
function handleContextMenuClose() {
|
||||
openContextMenuId = null;
|
||||
}
|
||||
|
||||
function toggleColumnVisibility(columnKey: string) {
|
||||
const wouldHideAll =
|
||||
Object.entries(visibleColumns.value).filter(([k, v]) => k !== columnKey && v).length === 0 &&
|
||||
visibleColumns.value[columnKey];
|
||||
|
||||
if (!wouldHideAll) {
|
||||
visibleColumns.value[columnKey] = !visibleColumns.value[columnKey];
|
||||
}
|
||||
}
|
||||
|
||||
function resetColumns() {
|
||||
visibleColumns.value = Object.fromEntries(keys.map((key) => [key, true]));
|
||||
}
|
||||
|
||||
//modal keyValue field
|
||||
|
||||
let pairs: { key: string; value: string }[] = $state([{ key: '', value: '' }]);
|
||||
|
||||
function addPair() {
|
||||
pairs = [...pairs, { key: '', value: '' }];
|
||||
}
|
||||
|
||||
function removePair(index: number) {
|
||||
pairs = pairs.filter((_, i) => i !== index);
|
||||
function close() {
|
||||
open = false;
|
||||
name = '';
|
||||
vdevCount = 1;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full w-full flex-col">
|
||||
<div class="flex h-10 w-full items-center border p-2">
|
||||
<Button
|
||||
onclick={() => (modalIsOpen = true)}
|
||||
size="sm"
|
||||
class="h-6 bg-muted-foreground/40 text-black dark:bg-muted dark:text-white"
|
||||
>
|
||||
<Icon icon="gg:add" class="mr-1 h-4 w-4" /> New
|
||||
</Button>
|
||||
</div>
|
||||
<div class="relative flex h-full w-full cursor-pointer flex-col">
|
||||
<div class="flex-1">
|
||||
<div class="h-full overflow-y-auto">
|
||||
<table class="mb-10 w-full min-w-max border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
{#each keys as key}
|
||||
{#if visibleColumns.value[key]}
|
||||
<th
|
||||
class="group h-8 w-48 whitespace-nowrap border border-neutral-300 px-3 text-left text-black dark:border-neutral-800 dark:text-white"
|
||||
>
|
||||
<ContextMenu.Root
|
||||
open={openContextMenuId === key}
|
||||
closeOnItemClick={false}
|
||||
onOpenChange={(open) =>
|
||||
open ? handleContextMenuOpen(key) : handleContextMenuClose()}
|
||||
>
|
||||
<ContextMenu.Trigger class="flex h-full w-full">
|
||||
<button
|
||||
class="relative flex w-full items-center"
|
||||
onclick={() => sortHandlers[key].set()}
|
||||
>
|
||||
<span>{key}</span>
|
||||
<Icon
|
||||
icon={sortHandlers[key].direction === 'asc'
|
||||
? 'lucide:sort-asc'
|
||||
: 'lucide:sort-desc'}
|
||||
class="ml-2 mt-1 h-4 w-4 opacity-0 transition-opacity duration-200 group-hover:opacity-100"
|
||||
/>
|
||||
</button>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Content>
|
||||
<ContextMenu.Label>Toggle Columns</ContextMenu.Label>
|
||||
<ContextMenu.Separator />
|
||||
{#each keys as columnKey}
|
||||
<ContextMenu.CheckboxItem
|
||||
checked={visibleColumns.value[columnKey]}
|
||||
onCheckedChange={(e: boolean | 'indeterminate') => {
|
||||
toggleColumnVisibility(columnKey);
|
||||
}}
|
||||
>
|
||||
{columnKey}
|
||||
</ContextMenu.CheckboxItem>
|
||||
{/each}
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item onclick={resetColumns}>Reset Columns</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
</th>
|
||||
{/if}
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<Button on:click={() => (open = !open)}>Toggle Dialog</Button>
|
||||
|
||||
<tbody>
|
||||
{#each table.rows as row, index}
|
||||
<tr>
|
||||
{#each keys as key}
|
||||
{#if visibleColumns.value[key]}
|
||||
<td
|
||||
class="h-8 w-48 whitespace-nowrap border border-neutral-300 px-3 text-left text-black dark:border-neutral-800 dark:text-white"
|
||||
>
|
||||
{row[key]}
|
||||
</td>
|
||||
{/if}
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Root bind:open={modalIsOpen}>
|
||||
<Dialog.Content class=" w-[80%] gap-0 p-0 lg:max-w-3xl">
|
||||
<div class="flex h-12 items-center justify-between border-b">
|
||||
<Dialog.Header class="flex justify-between">
|
||||
<Dialog.Title class="p-4 text-left">zfs</Dialog.Title>
|
||||
<Dialog.Root bind:open onOutsideClick={() => close()}>
|
||||
<Dialog.Content
|
||||
class="fixed left-1/2 top-1/2 max-h-[90vh] w-[80%] -translate-x-1/2 -translate-y-1/2 transform gap-0 overflow-y-auto p-0 transition-all duration-300 ease-in-out lg:max-w-3xl"
|
||||
>
|
||||
<div class="flex items-center justify-between p-4">
|
||||
<Dialog.Header class="p-0">
|
||||
<Dialog.Title>Create ZFS Pool</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<Dialog.Close
|
||||
class="mr-4 flex h-4 w-4 items-center justify-center rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
|
||||
class="flex h-5 w-5 items-center justify-center rounded-sm opacity-70 transition-opacity hover:opacity-100"
|
||||
onclick={() => close()}
|
||||
>
|
||||
<Icon icon="lucide:x" class="h-4 w-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
<Icon icon="material-symbols:close-rounded" class="h-5 w-5" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 p-4">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<Label class="w-24 whitespace-nowrap text-sm" for="terms">Pool Name:</Label>
|
||||
<Input class="h-8" type="text" id="name" placeholder="pool name" />
|
||||
</div>
|
||||
<div>
|
||||
<Label class="w-24 whitespace-nowrap text-sm" for="terms">vDevs:</Label>
|
||||
<div>
|
||||
<MultiSelect
|
||||
bind:selected
|
||||
options={ui_libs}
|
||||
placeholder="Select disks"
|
||||
ulOptionsClass="dark:bg-[#0c0a09] mt-1 border: 1px solid #f00 overflow-y-auto"
|
||||
liActiveOptionClass="bg-muted dark:text-white"
|
||||
on:change={(event) => console.log(event.detail)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 p-4">
|
||||
<div class="flex-1">
|
||||
<Label for="name">Name</Label>
|
||||
<Input type="text" id="name" placeholder="tank" bind:value={name} />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<Label for="vdev_count">Virtual Devices</Label>
|
||||
<Input type="number" id="vdev_count" placeholder="1" min={1} bind:value={vdevCount} />
|
||||
</div>
|
||||
|
||||
{#if selected.length > 0}
|
||||
<div transition:slide class=" flex items-center justify-center space-x-2">
|
||||
{#each selected as item}
|
||||
{#if item.label.includes('harddisk')}
|
||||
<div transition:slide class="flex items-center justify-center space-x-2">
|
||||
<Icon icon="mdi:harddisk" class="m-4 h-12 w-12 text-green-500" />
|
||||
</div>
|
||||
{:else}
|
||||
<div transition:slide class="flex items-center justify-center space-x-2">
|
||||
<Icon icon="clarity:ssd-solid" class="m-4 h-12 w-12 text-gray-500" />
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div transition:slide class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<Label class="w-24 whitespace-nowrap text-sm" for="terms">RAID:</Label>
|
||||
<Select.Root portal={null}>
|
||||
<Select.Trigger class="w-full">
|
||||
<Select.Value placeholder="Select a RAID" />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
{#each raid as fruit}
|
||||
<Select.Item value={fruit.value} label={fruit.label}>{fruit.label}</Select.Item>
|
||||
{/each}
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
<Select.Input name="favoriteFruit" />
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="w-24 whitespace-nowrap text-sm" for="terms">Compression:</Label>
|
||||
<Select.Root portal={null}>
|
||||
<Select.Trigger class="w-full">
|
||||
<Select.Value placeholder="Select a Compression" />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
{#each compression as fruit}
|
||||
<Select.Item value={fruit.value} label={fruit.label}>{fruit.label}</Select.Item>
|
||||
{/each}
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
<Select.Input name="favoriteFruit" />
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="w-24 whitespace-nowrap text-sm" for="terms">ASHIFT:</Label>
|
||||
<Select.Root portal={null}>
|
||||
<Select.Trigger class="w-full">
|
||||
<Select.Value placeholder="Select a ASHIFT" />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
{#each ashift as fruit}
|
||||
<Select.Item value={fruit.value} label={fruit.label}>{fruit.label}</Select.Item>
|
||||
{/each}
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
<Select.Input name="favoriteFruit" />
|
||||
</Select.Root>
|
||||
</div>
|
||||
</div>
|
||||
<div transition:slide class="mt-2 flex items-center space-x-2 md:mt-0">
|
||||
<Label
|
||||
id="terms-label"
|
||||
for="terms"
|
||||
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Advanced
|
||||
</Label>
|
||||
<Checkbox id="terms" bind:checked={advancedChecked} aria-labelledby="terms-label" />
|
||||
</div>
|
||||
|
||||
{#if advancedChecked}
|
||||
<div transition:slide class="max-h-[250px] space-y-2 overflow-y-auto">
|
||||
{#each pairs as pair, index}
|
||||
<div transition:slide class="flex items-center gap-4">
|
||||
<Input class="h-8" type="text" id="name" placeholder="key" bind:value={pair.key} />
|
||||
|
||||
<Input
|
||||
class="h-8"
|
||||
type="text"
|
||||
id="name"
|
||||
placeholder="value"
|
||||
bind:value={pair.value}
|
||||
/>
|
||||
|
||||
{#if pairs.length > 1}
|
||||
<button
|
||||
onclick={() => removePair(index)}
|
||||
class="rounded px-2 py-1 text-white hover:bg-muted"
|
||||
>
|
||||
<Icon icon="ic:twotone-remove" class="h-5 w-5" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div transition:slide class="flex justify-end">
|
||||
<button onclick={addPair} class=" rounded px-3 py-1 text-white hover:bg-muted">
|
||||
<Icon icon="icons8:plus" class="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer class="h-12">
|
||||
<div class="flex w-full justify-end border-t px-3 py-3 md:flex-row">
|
||||
<div class="flex flex-col items-center gap-2 space-x-3 md:flex-row">
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
class="h-7 w-full bg-blue-700 text-white hover:bg-blue-600"
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
<Dialog.Footer class="flex justify-between gap-2 border-t px-6 py-4">
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" class="h-8 disabled:!pointer-events-auto" on:click={() => close()}
|
||||
>Cancel</Button
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="h-8 disabled:!pointer-events-auto"
|
||||
on:click={() => close()}
|
||||
disabled={createEnabled !== true}>Create</Button
|
||||
>
|
||||
</div>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<style>
|
||||
:global(div.multiselect > ul.options) {
|
||||
border: 1px solid #292524;
|
||||
}
|
||||
:global(div.multiselect) {
|
||||
@apply h-[32px] rounded border border-[#292524] p-1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { listDisks } from '$lib/api/disk/disk';
|
||||
import { getPools } from '$lib/api/zfs/pool';
|
||||
import { simplifyDisks } from '$lib/utils/disk';
|
||||
import { cachedFetch } from '$lib/utils/http';
|
||||
|
||||
export async function load() {
|
||||
const cacheDuration = 3600 * 1000;
|
||||
const [disks, pools] = await Promise.all([
|
||||
cachedFetch('disks', async () => simplifyDisks(await listDisks()), cacheDuration),
|
||||
cachedFetch('pools', getPools, cacheDuration)
|
||||
]);
|
||||
|
||||
return {
|
||||
disks,
|
||||
pools
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user