feat: samba, pre-startup checks, auth: users/groups, storage: file manager

This commit is contained in:
hayzamjs
2025-07-15 21:38:34 +04:00
parent b3dce1b188
commit a21a8a3365
66 changed files with 3750 additions and 316 deletions
+3 -3
View File
@@ -5,7 +5,7 @@ BIN_DIR := bin
all: build
build: depcheck
build: build-depcheck
npm install --prefix web
npm run build --prefix web
cp -rf web/build/* internal/assets/web-files
@@ -21,5 +21,5 @@ run: build
test:
go test ./...
depcheck:
@./scripts/check_deps.sh
build-depcheck:
@./scripts/build-deps-check.sh
-1
View File
@@ -35,7 +35,6 @@ We also need to enable some services in order to run Sylve, you can drop these i
```sh
ntpd_enable="YES" # Optional
ntpd_sync_on_start="YES" # Optional
smartd_enable="YES"
zfs_enable="YES"
linux_enable="YES"
libvirtd_enable="YES"
+3
View File
@@ -30,6 +30,7 @@ import (
"sylve/internal/services/info"
"sylve/internal/services/libvirt"
"sylve/internal/services/network"
"sylve/internal/services/samba"
"sylve/internal/services/system"
"sylve/internal/services/utilities"
"sylve/internal/services/zfs"
@@ -55,6 +56,7 @@ func main() {
uS := serviceRegistry.UtilitiesService
sysS := serviceRegistry.SystemService
lvS := serviceRegistry.LibvirtService
smbS := serviceRegistry.SambaService
err := sS.Initialize(aS.(*auth.Service))
@@ -87,6 +89,7 @@ func main() {
uS.(*utilities.Service),
sysS.(*system.Service),
lvS.(*libvirt.Service),
smbS.(*samba.Service),
d,
)
+20 -4
View File
@@ -13,11 +13,13 @@ import (
"sylve/internal/db/models"
infoModels "sylve/internal/db/models/info"
networkModels "sylve/internal/db/models/network"
sambaModels "sylve/internal/db/models/samba"
utilitiesModels "sylve/internal/db/models/utilities"
vmModels "sylve/internal/db/models/vm"
zfsModels "sylve/internal/db/models/zfs"
"sylve/internal/logger"
"sylve/pkg/system"
"sylve/pkg/system/samba"
"sylve/pkg/utils"
"gorm.io/driver/sqlite"
@@ -51,6 +53,7 @@ func SetupDatabase(cfg *internal.SylveConfig, isTest bool) *gorm.DB {
err = db.AutoMigrate(
&models.System{},
&models.User{},
&models.Group{},
&models.Token{},
&models.SystemSecrets{},
@@ -78,6 +81,9 @@ func SetupDatabase(cfg *internal.SylveConfig, isTest bool) *gorm.DB {
&utilitiesModels.DownloadedFile{},
&utilitiesModels.Downloads{},
&sambaModels.SambaSettings{},
&sambaModels.SambaShare{},
)
if err != nil {
@@ -133,8 +139,6 @@ func setupInitUsers(db *gorm.DB, cfg *internal.SylveConfig) error {
return err
}
logger.L.Info().Msgf("User %s promoted to admin", admin.Email)
} else {
logger.L.Info().Msgf("User %s already has admin privileges", admin.Email)
}
}
@@ -150,8 +154,20 @@ func setupInitUsers(db *gorm.DB, cfg *internal.SylveConfig) error {
return err
}
logger.L.Info().Msgf("Unix user %s created successfully", admin.Username)
} else {
logger.L.Info().Msgf("Unix user %s already exists", admin.Username)
}
smbExists, err := samba.SambaUserExists(admin.Username)
if err != nil {
logger.L.Error().Msgf("Error checking if Samba user %s exists: %v", admin.Username, err)
return err
}
if !smbExists {
err = samba.CreateSambaUser(admin.Username, admin.Password)
if err != nil {
logger.L.Error().Msgf("Failed to create Samba user %s: %v", admin.Username, err)
return err
}
}
}
return nil
+10 -1
View File
@@ -15,7 +15,7 @@ import (
type User struct {
ID uint `gorm:"primarykey" json:"id"`
Username string `gorm:"unique" json:"username"`
Email string `gorm:"unique" json:"email"`
Email string `json:"email"`
Password string `json:"-"`
Notes string `json:"notes"`
TOTP string `json:"totp"`
@@ -27,6 +27,15 @@ type User struct {
Tokens []Token `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"tokens,omitempty"`
}
type Group struct {
ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"unique" json:"name"`
Notes string `json:"notes"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"createdAt"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updatedAt"`
Users []User `gorm:"many2many:user_groups;constraint:OnDelete:CASCADE" json:"users,omitempty"`
}
type Token struct {
ID uint `gorm:"primarykey" json:"id,omitempty"`
UserID uint `json:"userId,omitempty"`
+10
View File
@@ -0,0 +1,10 @@
package sambaModels
type SambaSettings struct {
ID int `json:"id" gorm:"primaryKey"`
UnixCharset string `json:"unixCharset"`
Workgroup string `json:"workgroup"`
ServerString string `json:"serverString" gorm:"default:'Sylve SMB Server'"`
Interfaces string `json:"interfaces" gorm:"default:'lo0'"`
BindInterfacesOnly bool `json:"bindInterfacesOnly" gorm:"default:true"`
}
+18
View File
@@ -0,0 +1,18 @@
package sambaModels
import (
"sylve/internal/db/models"
"time"
)
type SambaShare struct {
ID int `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"uniqueIndex"`
Dataset string `json:"dataset" gorm:"uniqueIndex"`
ReadOnlyGroups []models.Group `json:"readOnlyGroups" gorm:"many2many:samba_share_read_only_groups;"`
WriteableGroups []models.Group `json:"writeableGroups" gorm:"many2many:samba_share_writeable_groups;"`
CreateMask string `json:"createMask" gorm:"default:'0664'"`
DirectoryMask string `json:"directoryMask" gorm:"default:'2775'"`
CreatedAt time.Time `json:"createdAt" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updatedAt" gorm:"autoUpdateTime"`
}
+194
View File
@@ -0,0 +1,194 @@
package authHandlers
import (
"net/http"
"strconv"
"sylve/internal"
"sylve/internal/db/models"
"sylve/internal/services/auth"
"github.com/gin-gonic/gin"
)
type CreateGroupRequest struct {
Name string `json:"name" binding:"required"`
Members []string `json:"members" binding:"required"`
}
type AddUsersToGroupRequest struct {
Usernames []string `json:"usernames" binding:"required"`
Group string `json:"group" binding:"required"`
}
// @Summary List Groups
// @Description List all groups in the system
// @Tags Groups
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} internal.APIResponse[[]models.Group] "Success"
// @Failure 400 {object} internal.APIResponse[any] "Bad Request"
// @Failure 500 {object} internal.APIResponse[any] "Internal Server Error"
// @Router /auth/groups [get]
func ListGroupsHandler(authService *auth.Service) gin.HandlerFunc {
return func(c *gin.Context) {
groups, err := authService.ListGroups()
if err != nil {
c.JSON(http.StatusInternalServerError, internal.APIResponse[any]{
Status: "error",
Message: "failed_to_list_groups",
Error: err.Error(),
Data: nil,
})
return
}
c.JSON(http.StatusOK, internal.APIResponse[[]models.Group]{
Status: "success",
Message: "groups_listed_successfully",
Error: "",
Data: groups,
})
}
}
// @Summary Create Group
// @Description Create a new group with specified members
// @Tags Groups
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body CreateGroupRequest true "Group creation request"
// @Success 201 {object} internal.APIResponse[any] "Group created successfully"
// @Failure 400 {object} internal.APIResponse[any] "Bad Request"
// @Failure 500 {object} internal.APIResponse[any] "Internal Server Error"
// @Router /auth/groups [post]
func CreateGroupHandler(authService *auth.Service) gin.HandlerFunc {
return func(c *gin.Context) {
var req CreateGroupRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, internal.APIResponse[any]{
Status: "error",
Message: "invalid_request",
Error: err.Error(),
Data: nil,
})
return
}
if err := authService.CreateGroup(req.Name, req.Members); err != nil {
c.JSON(http.StatusInternalServerError, internal.APIResponse[any]{
Status: "error",
Message: "failed_to_create_group",
Error: err.Error(),
Data: nil,
})
return
}
c.JSON(http.StatusCreated, internal.APIResponse[any]{
Status: "success",
Message: "group_created_successfully",
Error: "",
Data: nil,
})
}
}
// @Summary Delete Group
// @Description Delete a group by ID
// @Tags Groups
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "Group ID"
// @Success 204 {object} internal.APIResponse[any] "Group deleted successfully"
// @Failure 400 {object} internal.APIResponse[any] "Bad Request"
// @Failure 500 {object} internal.APIResponse[any] "Internal Server Error"
// @Router /auth/groups/:id [delete]
func DeleteGroupHandler(authService *auth.Service) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, internal.APIResponse[any]{
Status: "error",
Message: "group_id_required",
Error: "group ID is required",
Data: nil,
})
return
}
idInt, err := strconv.Atoi(id)
if err != nil {
c.JSON(http.StatusBadRequest, internal.APIResponse[any]{
Status: "error",
Message: "invalid_group_id",
Error: "invalid group ID format",
Data: nil,
})
return
}
if err := authService.DeleteGroup(uint(idInt)); err != nil {
c.JSON(http.StatusInternalServerError, internal.APIResponse[any]{
Status: "error",
Message: "failed_to_delete_group",
Error: err.Error(),
Data: nil,
})
return
}
c.JSON(200, internal.APIResponse[any]{
Status: "success",
Message: "group_deleted_successfully",
Error: "",
Data: nil,
})
}
}
// @Summary Add Users to Group
// @Description Add users to a specified group
// @Tags Groups
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body AddUsersToGroupRequest true "Add users to group request"
// @Success 200 {object} internal.APIResponse[any] "User added to group successfully"
// @Failure 400 {object} internal.APIResponse[any] "Bad Request"
// @Failure 500 {object} internal.APIResponse[any] "Internal Server Error"
// @Router /auth/groups/users [post]
func AddUsersToGroupHandler(authService *auth.Service) gin.HandlerFunc {
return func(c *gin.Context) {
var req AddUsersToGroupRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, internal.APIResponse[any]{
Status: "error",
Message: "invalid_request",
Error: err.Error(),
Data: nil,
})
return
}
if err := authService.AddUsersToGroup(req.Usernames, req.Group); err != nil {
c.JSON(http.StatusInternalServerError, internal.APIResponse[any]{
Status: "error",
Message: "failed_to_add_user_to_group",
Error: err.Error(),
Data: nil,
})
return
}
c.JSON(http.StatusOK, internal.APIResponse[any]{
Status: "success",
Message: "user_added_to_group_successfully",
Error: "",
Data: nil,
})
}
}
+30
View File
@@ -22,6 +22,7 @@ import (
infoHandlers "sylve/internal/handlers/info"
"sylve/internal/handlers/middleware"
networkHandlers "sylve/internal/handlers/network"
sambaHandlers "sylve/internal/handlers/samba"
systemHandlers "sylve/internal/handlers/system"
utilitiesHandlers "sylve/internal/handlers/utilities"
vmHandlers "sylve/internal/handlers/vm"
@@ -31,6 +32,7 @@ import (
infoService "sylve/internal/services/info"
"sylve/internal/services/libvirt"
networkService "sylve/internal/services/network"
"sylve/internal/services/samba"
systemService "sylve/internal/services/system"
utilitiesService "sylve/internal/services/utilities"
zfsService "sylve/internal/services/zfs"
@@ -68,6 +70,7 @@ func RegisterRoutes(r *gin.Engine,
utilitiesService *utilitiesService.Service,
systemService *systemService.Service,
libvirtService *libvirt.Service,
sambaService *samba.Service,
db *gorm.DB,
) {
api := r.Group("/api")
@@ -153,6 +156,18 @@ func RegisterRoutes(r *gin.Engine,
}
}
samba := api.Group("/samba")
samba.Use(middleware.EnsureAuthenticated(authService))
samba.Use(middleware.RequestLoggerMiddleware(db, authService))
{
samba.GET("/config", sambaHandlers.GetGlobalConfig(sambaService))
samba.POST("/config", sambaHandlers.SetGlobalConfig(sambaService))
samba.GET("/shares", sambaHandlers.GetShares(sambaService))
samba.POST("/shares", sambaHandlers.CreateShare(sambaService))
samba.DELETE("/shares/:id", sambaHandlers.DeleteShare(sambaService))
}
disk := api.Group("/disk")
disk.Use(middleware.EnsureAuthenticated(authService))
disk.Use(middleware.RequestLoggerMiddleware(db, authService))
@@ -186,6 +201,13 @@ func RegisterRoutes(r *gin.Engine,
system.DELETE("/ppt-devices/:id", systemHandlers.RemovePPTDevice(systemService))
}
fileExplorer := system.Group("/file-explorer")
fileExplorer.Use(middleware.EnsureAuthenticated(authService))
fileExplorer.Use(middleware.RequestLoggerMiddleware(db, authService))
{
fileExplorer.GET("/files", systemHandlers.Files(systemService))
}
vm := api.Group("/vm")
vm.Use(middleware.EnsureAuthenticated(authService))
vm.Use(middleware.RequestLoggerMiddleware(db, authService))
@@ -234,6 +256,14 @@ func RegisterRoutes(r *gin.Engine,
users.DELETE("/:id", authHandlers.DeleteUserHandler(authService))
}
groups := auth.Group("/groups")
{
groups.GET("", authHandlers.ListGroupsHandler(authService))
groups.POST("", authHandlers.CreateGroupHandler(authService))
groups.DELETE("/:id", authHandlers.DeleteGroupHandler(authService))
groups.POST("/users", authHandlers.AddUsersToGroupHandler(authService))
}
vnc := api.Group("/vnc")
vnc.Use(middleware.EnsureAuthenticated(authService))
vnc.Use(middleware.RequestLoggerMiddleware(db, authService))
+101
View File
@@ -0,0 +1,101 @@
package sambaHandlers
import (
"net/http"
"sylve/internal"
sambaModels "sylve/internal/db/models/samba"
"sylve/internal/services/samba"
"github.com/gin-gonic/gin"
)
type SambaConfigRequest struct {
UnixCharset string `json:"unixCharset"`
Workgroup string `json:"workgroup"`
ServerString string `json:"serverString"`
Interfaces string `json:"interfaces"`
BindInterfacesOnly *bool `json:"bindInterfacesOnly"`
}
// @Summary Get Samba Global Configuration
// @Description Retrieve Samba global configuration settings
// @Tags Samba
// @Accept json
// @Produce json
// @Success 200 {object} internal.APIResponse[[]sambaModels.SambaSettings] "Samba global configuration"
// @Failure 500 {string} string "Internal server error"
// @Router /samba/config [get]
func GetGlobalConfig(smbService *samba.Service) gin.HandlerFunc {
return func(c *gin.Context) {
settings, err := smbService.GetGlobalConfig()
if err != nil {
c.JSON(http.StatusInternalServerError, internal.APIResponse[any]{
Status: "error",
Message: "failed_to_get_samba_config",
Error: err.Error(),
Data: nil,
})
return
}
c.JSON(http.StatusOK, internal.APIResponse[sambaModels.SambaSettings]{
Status: "success",
Message: "samba_global_config_retrieved",
Error: "",
Data: settings,
})
}
}
// @Summary Set Samba Global Configuration
// @Description Set Samba global configuration settings
// @Tags Samba
// @Accept json
// @Produce json
// @Param request body SambaConfigRequest true "Samba Global Configuration"
// @Success 200 {string} string "Samba global configuration updated successfully"
// @Failure 400 {string} string "Invalid request"
// @Failure 500 {string} string "Internal server error"
// @Router /samba/config [post]
func SetGlobalConfig(smbService *samba.Service) gin.HandlerFunc {
return func(c *gin.Context) {
var req SambaConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, internal.APIResponse[any]{
Status: "error",
Message: "invalid_request",
Error: err.Error(),
Data: nil,
})
return
}
bindInterfaces := false
if req.BindInterfacesOnly != nil {
bindInterfaces = *req.BindInterfacesOnly
}
err := smbService.SetGlobalConfig(req.UnixCharset,
req.Workgroup,
req.ServerString,
req.Interfaces,
bindInterfaces)
if err != nil {
c.JSON(http.StatusInternalServerError, internal.APIResponse[any]{
Status: "error",
Message: "failed_to_set_samba_config",
Error: err.Error(),
Data: nil,
})
return
}
c.JSON(http.StatusOK, internal.APIResponse[any]{
Status: "success",
Message: "samba_global_config_updated",
Error: "",
Data: nil,
})
}
}
+143
View File
@@ -0,0 +1,143 @@
package sambaHandlers
import (
"net/http"
"strconv"
"sylve/internal"
sambaModels "sylve/internal/db/models/samba"
"sylve/internal/services/samba"
"github.com/gin-gonic/gin"
)
type CreateSambaShareRequest struct {
Name string `json:"name"`
Dataset string `json:"dataset"`
ReadOnlyGroups []string `json:"readOnlyGroups"`
WriteableGroups []string `json:"writeableGroups"`
CreateMask string `json:"createMask"`
DirectoryMask string `json:"directoryMask"`
}
// @Summary Get Samba Shares
// @Description Retrieve all Samba shares
// @Tags Samba
// @Accept json
// @Produce json
// @Success 200 {object} internal.APIResponse[[]sambaModels.SambaShare] "Success"
// @Failure 500 {object} internal.APIResponse[any] "Internal Server Error"
// @Router /samba/shares [get]
func GetShares(smbService *samba.Service) gin.HandlerFunc {
return func(c *gin.Context) {
shares, err := smbService.GetShares()
if err != nil {
c.JSON(http.StatusInternalServerError, internal.APIResponse[any]{
Status: "error",
Message: "failed_to_get_shares",
Error: err.Error(),
Data: nil,
})
return
}
c.JSON(http.StatusOK, internal.APIResponse[[]sambaModels.SambaShare]{
Status: "success",
Message: "shares_retrieved",
Error: "",
Data: shares,
})
}
}
// @Summary Create Samba Share
// @Description Create a new Samba share with specified settings
// @Tags Samba
// @Accept json
// @Produce json
// @Param request body CreateSambaShareRequest true "Create Samba Share Request"
// @Success 200 {string} string "Samba share created successfully"
// @Failure 400 {string} string "Invalid request"
// @Failure 500 {string} string "Internal server error"
// @Router /samba/shares [post]
func CreateShare(smbService *samba.Service) gin.HandlerFunc {
return func(c *gin.Context) {
var request CreateSambaShareRequest
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
}
if err := smbService.CreateShare(
request.Name,
request.Dataset,
request.ReadOnlyGroups,
request.WriteableGroups,
request.CreateMask,
request.DirectoryMask,
); err != nil {
c.JSON(http.StatusInternalServerError, internal.APIResponse[any]{
Status: "error",
Message: "failed_to_create_share",
Error: err.Error(),
Data: nil,
})
return
}
c.JSON(http.StatusOK, internal.APIResponse[any]{
Status: "success",
Message: "Samba share created successfully",
Error: "",
Data: nil,
})
}
}
// @Summary Delete Samba Share
// @Description Delete a Samba share by ID
// @Tags Samba
// @Accept json
// @Produce json
// @Param id path uint true "Share ID"
// @Success 200 {string} string "Samba share deleted successfully"
// @Failure 400 {string} string "Invalid request"
// @Failure 500 {string} string "Internal server error"
// @Router /samba/shares/{id} [delete]
func DeleteShare(smbService *samba.Service) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
idInt, err := strconv.ParseUint(id, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, internal.APIResponse[any]{
Status: "error",
Message: "invalid_share_id",
Error: err.Error(),
Data: nil,
})
return
}
if err := smbService.DeleteShare(uint(idInt)); err != nil {
c.JSON(http.StatusInternalServerError, internal.APIResponse[any]{
Status: "error",
Message: "failed_to_delete_share",
Error: err.Error(),
Data: nil,
})
return
}
c.JSON(http.StatusOK, internal.APIResponse[any]{
Status: "success",
Message: "Samba share deleted successfully",
Error: "",
Data: nil,
})
}
}
+45
View File
@@ -0,0 +1,45 @@
package systemHandlers
import (
"net/http"
"sylve/internal"
systemServiceInterfaces "sylve/internal/interfaces/services/system"
"sylve/internal/services/system"
"github.com/gin-gonic/gin"
)
// /api/files?id="
// @Summary Find Files on System
// @Description Find files on the system based on a search term
// @Tags System
// @Accept json
// @Produce json
// @Security BearerAuth
// @Query id string "Search term"
// @Success 200 {object} internal.APIResponse[[]systemServiceInterfaces.FileNode]
// @Failure 500 {object} internal.APIResponse[any] "Internal Server Error"
// @Router /system/file-explorer/files [get]
func Files(systemService *system.Service) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Query("id")
nodes, err := systemService.Traverse(id)
if err != nil {
c.JSON(http.StatusInternalServerError, internal.APIResponse[any]{
Status: "error",
Message: "internal_server_error",
Error: err.Error(),
Data: nil,
})
return
}
c.JSON(http.StatusOK, internal.APIResponse[[]systemServiceInterfaces.FileNode]{
Status: "success",
Message: "files_listed",
Error: "",
Data: nodes,
})
}
}
@@ -0,0 +1,5 @@
package sambaServiceInterfaces
type SambaServiceInterface interface {
WriteConfig(reload bool) error
}
@@ -0,0 +1,11 @@
package systemServiceInterfaces
import "time"
type FileNode struct {
ID string `json:"id"`
Date time.Time `json:"date"`
Type string `json:"type"`
Lazy bool `json:"lazy,omitempty"`
Size int64 `json:"size,omitempty"`
}
+1 -1
View File
@@ -75,7 +75,7 @@ func (s *Service) CreateJWT(username, password, authType string, remember bool)
return "", fmt.Errorf("invalid_credentials")
}
user.ID = utils.StringToUint(username)
user.ID = utils.StringToUintId(username)
user.Username = username
} else {
return "", fmt.Errorf("invalid_auth_type")
+130
View File
@@ -0,0 +1,130 @@
package auth
import (
"errors"
"fmt"
"sylve/internal/db/models"
"sylve/pkg/system"
"sylve/pkg/utils"
"gorm.io/gorm"
)
func (s *Service) ListGroups() ([]models.Group, error) {
var groups []models.Group
if err := s.DB.Preload("Users").Find(&groups).Error; err != nil {
return nil, fmt.Errorf("failed_to_list_groups: %w", err)
}
return groups, nil
}
func (s *Service) CreateGroup(name string, members []string) error {
valid := utils.IsValidGroupName(name)
if !valid {
return fmt.Errorf("invalid_group_name: %s", name)
}
exists := system.UnixGroupExists(name)
if exists {
return fmt.Errorf("group_already_exists: %s", name)
}
var users []models.User
for _, member := range members {
var user models.User
if err := s.DB.Where("username = ?", member).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("user_not_found: %s", member)
}
return fmt.Errorf("failed_to_check_user: %w", err)
}
users = append(users, user)
}
group := models.Group{
Name: name,
Users: users,
}
if err := s.DB.Create(&group).Error; err != nil {
return fmt.Errorf("failed_to_create_group: %w", err)
}
if err := system.CreateUnixGroup(name); err != nil {
s.DB.Delete(&group)
return fmt.Errorf("failed_to_create_unix_group: %w", err)
}
return nil
}
func (s *Service) DeleteGroup(id uint) error {
var group models.Group
if err := s.DB.First(&group, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("group_not_found: %d", id)
}
return fmt.Errorf("failed_to_find_group: %w", err)
}
if err := s.DB.Delete(&group).Error; err != nil {
return fmt.Errorf("failed_to_delete_group: %w", err)
}
if err := system.DeleteUnixGroup(group.Name); err != nil {
return fmt.Errorf("failed_to_delete_unix_group: %w", err)
}
return nil
}
func (s *Service) AddUsersToGroup(usernames []string, groupName string) error {
var group models.Group
if err := s.DB.Where("name = ?", groupName).First(&group).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("group_not_found: %s", groupName)
}
return fmt.Errorf("failed_to_find_group: %w", err)
}
for _, username := range usernames {
var user models.User
if err := s.DB.Where("username = ?", username).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("user_not_found: %s", username)
}
return fmt.Errorf("failed_to_find_user: %w", err)
}
var cnt int64
if err := s.DB.
Table("user_groups").
Where("user_id = ? AND group_id = ?", user.ID, group.ID).
Count(&cnt).Error; err != nil {
return fmt.Errorf("failed_to_check_membership: %w", err)
}
if cnt > 0 {
continue
}
if err := s.DB.Model(&group).Association("Users").Append(&user); err != nil {
return fmt.Errorf("failed_to_add_user_to_group: %w", err)
}
inGroup, err := system.IsUserInGroup(user.Username, groupName)
if err != nil {
return err
}
if !inGroup {
if err := system.AddUserToGroup(user.Username, groupName); err != nil {
s.DB.Model(&group).Association("Users").Delete(&user)
return fmt.Errorf("failed_to_add_user_to_unix_group: %w", err)
}
}
}
return nil
}
+11
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"sylve/internal/db/models"
"sylve/pkg/system"
"sylve/pkg/system/samba"
"sylve/pkg/utils"
"time"
)
@@ -41,6 +42,8 @@ func (s *Service) CreateUser(user *models.User) error {
return fmt.Errorf("invalid_username_format: %s", user.Username)
}
pwCopy := user.Password
hashed, err := utils.HashPassword(user.Password)
if err != nil {
return fmt.Errorf("failed_to_hash_password: %w", err)
@@ -61,6 +64,10 @@ func (s *Service) CreateUser(user *models.User) error {
return fmt.Errorf("failed_to_create_unix_user: %w", err)
}
if err := samba.CreateSambaUser(user.Username, pwCopy); err != nil {
return fmt.Errorf("failed_to_create_samba_user: %w", err)
}
if err := s.DB.Create(user).Error; err != nil {
return fmt.Errorf("failed_to_create_user: %w", err)
}
@@ -85,6 +92,10 @@ func (s *Service) DeleteUser(userID uint) error {
return fmt.Errorf("user_not_found: %d", userID)
}
if err := samba.DeleteSambaUser(user.Username); err != nil {
return fmt.Errorf("failed_to_delete_samba_user: %w", err)
}
if err := system.DeleteUnixUser(user.Username, true); err != nil {
return fmt.Errorf("failed_to_delete_unix_user: %w", err)
}
+12 -2
View File
@@ -14,6 +14,7 @@ import (
infoServiceInterfaces "sylve/internal/interfaces/services/info"
libvirtServiceInterfaces "sylve/internal/interfaces/services/libvirt"
networkServiceInterfaces "sylve/internal/interfaces/services/network"
sambaServiceInterfaces "sylve/internal/interfaces/services/samba"
systemServiceInterfaces "sylve/internal/interfaces/services/system"
utilitiesServiceInterfaces "sylve/internal/interfaces/services/utilities"
zfsServiceInterfaces "sylve/internal/interfaces/services/zfs"
@@ -22,6 +23,7 @@ import (
"sylve/internal/services/info"
"sylve/internal/services/libvirt"
"sylve/internal/services/network"
"sylve/internal/services/samba"
"sylve/internal/services/startup"
"sylve/internal/services/system"
"sylve/internal/services/utilities"
@@ -40,6 +42,7 @@ type ServiceRegistry struct {
LibvirtService libvirtServiceInterfaces.LibvirtServiceInterface
UtilitiesService utilitiesServiceInterfaces.UtilitiesServiceInterface
SystemService systemServiceInterfaces.SystemServiceInterface
SambaService sambaServiceInterfaces.SambaServiceInterface
}
func NewService[T any](db *gorm.DB, dependencies ...interface{}) interface{} {
@@ -55,8 +58,9 @@ func NewService[T any](db *gorm.DB, dependencies ...interface{}) interface{} {
libvirtService := dependencies[3].(libvirtServiceInterfaces.LibvirtServiceInterface)
utilitiesService := dependencies[4].(utilitiesServiceInterfaces.UtilitiesServiceInterface)
systemService := dependencies[5].(systemServiceInterfaces.SystemServiceInterface)
sambaService := dependencies[6].(sambaServiceInterfaces.SambaServiceInterface)
return startup.NewStartupService(db, infoService, zfsService, networkService, libvirtService, utilitiesService, systemService)
return startup.NewStartupService(db, infoService, zfsService, networkService, libvirtService, utilitiesService, systemService, sambaService)
case *info.Service:
return info.NewInfoService(db)
case *zfs.Service:
@@ -69,6 +73,10 @@ func NewService[T any](db *gorm.DB, dependencies ...interface{}) interface{} {
return libvirt.NewLibvirtService(db)
case *utilities.Service:
return utilities.NewUtilitiesService(db)
case *samba.Service:
zfsService := dependencies[0].(zfsServiceInterfaces.ZfsServiceInterface)
return samba.NewSambaService(db, zfsService)
default:
return nil
}
@@ -82,10 +90,11 @@ func NewServiceRegistry(db *gorm.DB) *ServiceRegistry {
zfsService := NewService[zfs.Service](db, libvirtService)
utilitiesService := NewService[utilities.Service](db)
systemService := NewService[system.Service](db)
sambaService := NewService[samba.Service](db, zfsService)
return &ServiceRegistry{
AuthService: authService.(serviceInterfaces.AuthServiceInterface),
StartupService: NewService[startup.Service](db, infoService, zfsService, networkService, libvirtService, utilitiesService, systemService).(*startup.Service),
StartupService: NewService[startup.Service](db, infoService, zfsService, networkService, libvirtService, utilitiesService, systemService, sambaService).(*startup.Service),
InfoService: infoService.(infoServiceInterfaces.InfoServiceInterface),
ZfsService: zfsService.(*zfs.Service),
DiskService: NewService[disk.Service](db, zfsService).(*disk.Service),
@@ -93,5 +102,6 @@ func NewServiceRegistry(db *gorm.DB) *ServiceRegistry {
LibvirtService: libvirtService.(libvirtServiceInterfaces.LibvirtServiceInterface),
UtilitiesService: utilitiesService.(utilitiesServiceInterfaces.UtilitiesServiceInterface),
SystemService: systemService.(systemServiceInterfaces.SystemServiceInterface),
SambaService: sambaService.(sambaServiceInterfaces.SambaServiceInterface),
}
}
+218
View File
@@ -0,0 +1,218 @@
package samba
import (
"fmt"
"os"
"strings"
sambaModels "sylve/internal/db/models/samba"
"sylve/pkg/system"
"sylve/pkg/utils"
"sylve/pkg/zfs"
iface "sylve/pkg/network/iface"
)
func (s *Service) GetGlobalConfig() (sambaModels.SambaSettings, error) {
var settings sambaModels.SambaSettings
if err := s.DB.First(&settings).Error; err != nil {
return sambaModels.SambaSettings{}, fmt.Errorf("failed to retrieve Samba settings: %w", err)
}
return settings, nil
}
func (s *Service) SetGlobalConfig(unixCharset string,
workgroup string,
serverString string,
interfaces string,
bindInterfacesOnly bool) error {
if unixCharset == "" || workgroup == "" || serverString == "" {
return fmt.Errorf("unixCharset, workgroup, and serverString cannot be empty")
}
if interfaces == "" {
interfaces = "lo0"
}
supportedCharsets := utils.GetSupportedCharsets()
if !utils.StringInSlice(unixCharset, supportedCharsets) {
return fmt.Errorf("unsupported unixCharset: %s", unixCharset)
}
if !utils.IsValidWorkgroup(workgroup) {
return fmt.Errorf("invalid workgroup name: %s", workgroup)
}
if !utils.IsValidServerString(serverString) {
return fmt.Errorf("invalid server string: %s", serverString)
}
interfacesList := strings.Split(interfaces, ",")
interfacesList = utils.RemoveDuplicates(interfacesList)
for _, eIface := range interfacesList {
eIface = strings.TrimSpace(eIface)
_, err := iface.Get(eIface)
if err != nil {
return fmt.Errorf("invalid interface '%s': %w", eIface, err)
}
}
if len(interfacesList) > 0 {
interfaces = strings.Join(interfacesList, ",")
} else {
interfaces = "lo0"
}
var settings sambaModels.SambaSettings
if err := s.DB.First(&settings).Error; err != nil {
return fmt.Errorf("failed to retrieve Samba settings: %w", err)
}
settings.UnixCharset = unixCharset
settings.Workgroup = workgroup
settings.ServerString = serverString
settings.Interfaces = interfaces
settings.BindInterfacesOnly = bindInterfacesOnly
if err := s.DB.Save(&settings).Error; err != nil {
return fmt.Errorf("failed to update Samba settings: %w", err)
}
return s.WriteConfig(true)
}
func (s *Service) GlobalConfig() (string, error) {
settings, err := s.GetGlobalConfig()
if err != nil {
return "", fmt.Errorf("failed to get global Samba settings: %w", err)
}
var config string
config += "# === This file is automatically generated by Sylve, don't edit! ===\n"
config += "[global]\n"
config += fmt.Sprintf("unix charset = %s\n", settings.UnixCharset)
config += fmt.Sprintf("workgroup = %s\n", settings.Workgroup)
config += fmt.Sprintf("server string = %s\n", settings.ServerString)
interfaces := settings.Interfaces
if interfaces == "" {
interfaces = "lo0"
} else {
interfaces = strings.ReplaceAll(interfaces, ",", " ")
}
config += fmt.Sprintf("interfaces = %s\n", interfaces)
if settings.BindInterfacesOnly {
config += "bind interfaces only = yes\n"
} else {
config += "bind interfaces only = no\n"
}
return config, nil
}
func (s *Service) ShareConfig() (string, error) {
shares := []sambaModels.SambaShare{}
if err := s.DB.Preload("ReadOnlyGroups").Preload("WriteableGroups").Find(&shares).Error; err != nil {
return "", fmt.Errorf("failed to retrieve Samba shares: %w", err)
}
datasets, err := zfs.Datasets("")
if err != nil {
return "", fmt.Errorf("failed to fetch datasets: %v", err)
}
var config strings.Builder
for _, share := range shares {
var dataset *zfs.Dataset
for _, ds := range datasets {
dProps, err := ds.GetAllProperties()
if err != nil {
return "", fmt.Errorf("failed to get properties for dataset %s: %v", share.Dataset, err)
}
if dProps["guid"] == share.Dataset {
dataset = ds
break
}
}
if dataset == nil {
return "", fmt.Errorf("dataset not found for share %s", share.Name)
}
config.WriteString(fmt.Sprintf("[%s]\n", share.Name))
config.WriteString(fmt.Sprintf("\tpath = %s\n", dataset.Mountpoint))
config.WriteString(fmt.Sprintf("\tguest ok = no\n"))
rGroups := make([]string, 0)
wGroups := make([]string, 0)
if len(share.ReadOnlyGroups) > 0 {
for _, group := range share.ReadOnlyGroups {
rGroups = append(rGroups, group.Name)
}
}
if len(share.WriteableGroups) > 0 {
for _, group := range share.WriteableGroups {
wGroups = append(wGroups, group.Name)
}
}
aGroups := utils.JoinStringSlices(rGroups, wGroups)
writeList := fmt.Sprintf("%s%s", "@", strings.Join(wGroups, " @"))
if len(aGroups) > 0 {
config.WriteString(fmt.Sprintf("\tvalid users = %s\n", "@"+strings.Join(aGroups, " @")))
}
config.WriteString(fmt.Sprintf("\tread only = yes\n"))
if len(wGroups) > 0 {
config.WriteString(fmt.Sprintf("\twrite list = %s\n", writeList))
}
config.WriteString(fmt.Sprintf("\tcreate mask = %s\n", share.CreateMask))
config.WriteString(fmt.Sprintf("\tdirectory mask = %s\n", share.DirectoryMask))
config.WriteString("\n\n")
}
return config.String(), nil
}
func (s *Service) WriteConfig(reload bool) error {
gCfg, err := s.GlobalConfig()
if err != nil {
return err
}
if gCfg == "" {
return fmt.Errorf("global configuration is empty")
}
shareCfg, err := s.ShareConfig()
if err != nil {
return fmt.Errorf("failed to get share configuration: %w", err)
}
fullConfig := gCfg + "\n" + shareCfg
filePath := "/usr/local/etc/smb4.conf"
if err := os.WriteFile(filePath, []byte(fullConfig), 0644); err != nil {
return fmt.Errorf("failed to write Samba configuration to %s: %w", filePath, err)
}
if reload {
if err := system.ServiceAction("samba_server", "reload"); err != nil {
return fmt.Errorf("failed to reload Samba service: %w", err)
}
}
return nil
}
+22
View File
@@ -0,0 +1,22 @@
package samba
import (
sambaServiceInterfaces "sylve/internal/interfaces/services/samba"
zfsServiceInterfaces "sylve/internal/interfaces/services/zfs"
"gorm.io/gorm"
)
var _ sambaServiceInterfaces.SambaServiceInterface = (*Service)(nil)
type Service struct {
DB *gorm.DB
ZFS zfsServiceInterfaces.ZfsServiceInterface
}
func NewSambaService(db *gorm.DB, zfs zfsServiceInterfaces.ZfsServiceInterface) sambaServiceInterfaces.SambaServiceInterface {
return &Service{
DB: db,
ZFS: zfs,
}
}
+115
View File
@@ -0,0 +1,115 @@
package samba
import (
"fmt"
"sylve/internal/db/models"
sambaModels "sylve/internal/db/models/samba"
"sylve/pkg/utils"
"sylve/pkg/zfs"
)
func (s *Service) GetShares() ([]sambaModels.SambaShare, error) {
var shares []sambaModels.SambaShare
if err := s.DB.Preload("ReadOnlyGroups").Preload("WriteableGroups").Find(&shares).Error; err != nil {
return nil, fmt.Errorf("failed_to_get_shares: %w", err)
}
return shares, nil
}
func (s *Service) CreateShare(
name string,
dataset string,
readOnlyGroups []string,
writeableGroups []string,
createMask string,
directoryMask string) error {
if err := s.DB.Where("name = ?", name).First(&sambaModels.SambaShare{}).Error; err == nil {
return fmt.Errorf("share_with_name_exists")
}
datasets, err := zfs.Datasets("")
if err != nil {
return fmt.Errorf("failed_to_fetch_datasets: %v", err)
}
var fDataset *zfs.Dataset
for _, ds := range datasets {
properties, err := ds.GetAllProperties()
if err != nil {
return fmt.Errorf("failed_to_get_properties_for_dataset: %v", err)
}
if properties["guid"] == dataset {
fDataset = ds
break
}
}
if fDataset == nil {
return fmt.Errorf("dataset_not_found")
}
if fDataset.Mountpoint == "" {
return fmt.Errorf("dataset_not_mounted")
}
allGroups := utils.JoinStringSlices(readOnlyGroups, writeableGroups)
if len(allGroups) == 0 {
return fmt.Errorf("no_groups_provided")
}
for _, group := range allGroups {
if err := s.DB.Where("name = ?", group).First(&models.Group{}).Error; err != nil {
return fmt.Errorf("group_not_found: %s", group)
}
}
var roGroups []models.Group
var wrGroups []models.Group
for _, group := range readOnlyGroups {
var g models.Group
if err := s.DB.Where("name = ?", group).First(&g).Error; err != nil {
return fmt.Errorf("read_only_group_not_found: %s", group)
}
roGroups = append(roGroups, g)
}
for _, group := range writeableGroups {
var g models.Group
if err := s.DB.Where("name = ?", group).First(&g).Error; err != nil {
return fmt.Errorf("writeable_group_not_found: %s", group)
}
wrGroups = append(wrGroups, g)
}
share := sambaModels.SambaShare{
Name: name,
Dataset: dataset,
ReadOnlyGroups: roGroups,
WriteableGroups: wrGroups,
CreateMask: createMask,
DirectoryMask: directoryMask,
}
if err := s.DB.Create(&share).Error; err != nil {
return fmt.Errorf("failed_to_create_share: %w", err)
}
return s.WriteConfig(true)
}
func (s *Service) DeleteShare(id uint) error {
var share sambaModels.SambaShare
if err := s.DB.Where("id = ?", id).First(&share).Error; err != nil {
return fmt.Errorf("share_not_found: %w", err)
}
if err := s.DB.Delete(&share).Error; err != nil {
return fmt.Errorf("failed_to_delete_share: %w", err)
}
return s.WriteConfig(true)
}
+215
View File
@@ -0,0 +1,215 @@
package startup
import (
"fmt"
"strings"
"sylve/internal/logger"
"sylve/pkg/pkg"
"sylve/pkg/rcconf"
"sylve/pkg/utils"
sysctl "sylve/pkg/utils/sysctl"
"sync"
)
func (s *Service) SysctlSync() error {
intVals := map[string]int32{
"net.inet.ip.forwarding": 1,
"net.link.bridge.inherit_mac": 1,
}
for k, v := range intVals {
_, err := sysctl.GetInt64(k)
if err != nil {
logger.L.Error().Msgf("Error getting sysctl %s: %v, skipping!", k, err)
continue
}
err = sysctl.SetInt32(k, v)
if err != nil {
logger.L.Error().Msgf("Error setting sysctl %s: %v", k, err)
}
}
return nil
}
func (s *Service) InitFirewall() error {
return nil
}
func (s *Service) FreeBSDCheck() error {
minMajor := uint64(14)
minMinor := uint64(3)
output, err := utils.RunCommand("uname", "-r")
output = strings.TrimSpace(output)
if err != nil {
return fmt.Errorf("failed to run uname command: %w", err)
}
parts := strings.Split(output, "-")
if len(parts) < 1 {
return fmt.Errorf("unexpected output from uname command: %s", output)
}
versionParts := strings.Split(parts[0], ".")
if len(versionParts) < 2 {
return fmt.Errorf("unexpected version format: %s", parts[0])
}
majorVersion := utils.StringToUint64(versionParts[0])
minorVersion := utils.StringToUint64(versionParts[1])
if majorVersion < minMajor || (majorVersion == minMajor && minorVersion < minMinor) {
return fmt.Errorf("unsupported FreeBSD version: %s, minimum required is %d.%d", output, minMajor, minMinor)
}
return nil
}
func (s *Service) CheckPackageDependencies() error {
requiredPackages := []string{
"libvirt",
"bhyve-firmware",
"smartmontools",
"tmux",
"samba419",
}
var wg sync.WaitGroup
errCh := make(chan error, len(requiredPackages))
for _, p := range requiredPackages {
p := p
wg.Add(1)
go func() {
defer wg.Done()
if !pkg.IsPackageInstalled(p) {
errCh <- fmt.Errorf("Required package %s is not installed", p)
}
}()
}
wg.Wait()
close(errCh)
for err := range errCh {
if err != nil {
return err
}
}
return nil
}
func (s *Service) CheckServiceDependencies() error {
const rcConfPath = "/etc/rc.conf"
enabledServices := []string{
"zfs_enable",
"libvirtd_enable",
"dnsmasq_enable",
"rpcbind_enable",
"samba_server_enable",
}
serviceNames := map[string]string{
"libvirtd_enable": "libvirtd",
"dnsmasq_enable": "dnsmasq",
"rpcbind_enable": "rpcbind",
"samba_server_enable": "samba_server",
}
config, err := rcconf.Parse(rcConfPath)
if err != nil {
return fmt.Errorf("failed to parse %s: %w", rcConfPath, err)
}
for _, key := range enabledServices {
val, ok := config[key]
if !ok || val != "YES" {
return fmt.Errorf("required service %s is not enabled in %s", key, rcConfPath)
}
if key == "zfs_enable" || key == "samba_server_enable" {
continue
}
service := serviceNames[key]
if err := ensureServiceRunning(service); err != nil {
return fmt.Errorf("failed to ensure service %s is running: %w", service, err)
}
}
return nil
}
func (s *Service) CheckLoaderConf() error {
const loaderConfPath = "/boot/loader.conf"
required := map[string]string{
"kern.geom.label.disk_ident.enable": "0",
"kern.geom.label.gptid.enable": "0",
"cryptodev_load": "YES",
"zfs_load": "YES",
"vmm_load": "YES",
"nmdm_load": "YES",
"if_tap_load": "YES",
"if_bridge_load": "YES",
"hw.vmm.iommu.passthrough": "1",
}
config, err := rcconf.Parse(loaderConfPath)
if err != nil {
return fmt.Errorf("failed to parse %s: %w", loaderConfPath, err)
}
for key, expected := range required {
val, ok := config[key]
if !ok {
return fmt.Errorf("missing required key in %s: %s", loaderConfPath, key)
}
if val != expected {
return fmt.Errorf("invalid value for %s in %s: got %q, want %q", key, loaderConfPath, val, expected)
}
}
return nil
}
func (s *Service) CheckKernelModules() error {
requiredModules := []string{
"vmm",
"nmdm",
"if_bridge",
}
output, err := utils.RunCommand("kldstat", "-q")
if err != nil {
return fmt.Errorf("failed to run kldstat command: %w", err)
}
for _, module := range requiredModules {
if !strings.Contains(output, fmt.Sprintf("%s.ko", module)) {
return fmt.Errorf("required kernel module %s is not loaded", module)
}
}
return nil
}
func ensureServiceRunning(service string) error {
_, err := utils.RunCommand("service", service, "status")
if err == nil {
return nil
}
_, startErr := utils.RunCommand("service", service, "start")
if startErr != nil {
return fmt.Errorf("could not start service %s: %w", service, startErr)
}
return nil
}
+52
View File
@@ -0,0 +1,52 @@
package startup
import (
"os"
"strings"
sambaModels "sylve/internal/db/models/samba"
"sylve/pkg/system"
"sylve/pkg/utils"
)
func (s *Service) InitSamba() error {
const marker = "# === This file is automatically generated by Sylve, don't edit! ==="
cfgPath := "/usr/local/etc/smb4.conf"
backupPath := "/usr/local/etc/smb4.conf.pre-sylve"
data, err := os.ReadFile(cfgPath)
if err != nil && !os.IsNotExist(err) {
return err
}
if !strings.Contains(string(data), marker) {
exists, err := utils.FileExists(backupPath)
if err != nil {
return err
}
if !exists {
if err := utils.CopyFile(cfgPath, backupPath); err != nil {
return err
}
}
}
var count int64
if err := s.DB.Model(&sambaModels.SambaSettings{}).Count(&count).Error; err != nil {
return err
}
if count == 0 {
defaultSettings := sambaModels.SambaSettings{
UnixCharset: "UTF-8",
Workgroup: "WORKGROUP",
}
if err := s.DB.Create(&defaultSettings).Error; err != nil {
return err
}
}
if err := s.Samba.WriteConfig(false); err != nil {
return err
}
return system.ServiceAction("samba_server", "restart")
}
+23 -65
View File
@@ -16,16 +16,13 @@ import (
infoServiceInterfaces "sylve/internal/interfaces/services/info"
libvirtServiceInterfaces "sylve/internal/interfaces/services/libvirt"
networkServiceInterfaces "sylve/internal/interfaces/services/network"
sambaServiceInterfaces "sylve/internal/interfaces/services/samba"
systemServiceInterfaces "sylve/internal/interfaces/services/system"
utilitiesServiceInterfaces "sylve/internal/interfaces/services/utilities"
zfsServiceInterfaces "sylve/internal/interfaces/services/zfs"
"sylve/internal/logger"
"sync"
"time"
"sylve/pkg/pkg"
sysctl "sylve/pkg/utils/sysctl"
"gorm.io/gorm"
)
@@ -39,6 +36,7 @@ type Service struct {
Libvirt libvirtServiceInterfaces.LibvirtServiceInterface
Utilities utilitiesServiceInterfaces.UtilitiesServiceInterface
System systemServiceInterfaces.SystemServiceInterface
Samba sambaServiceInterfaces.SambaServiceInterface
}
func NewStartupService(db *gorm.DB,
@@ -48,6 +46,7 @@ func NewStartupService(db *gorm.DB,
libvirt libvirtServiceInterfaces.LibvirtServiceInterface,
utiliies utilitiesServiceInterfaces.UtilitiesServiceInterface,
system systemServiceInterfaces.SystemServiceInterface,
samba sambaServiceInterfaces.SambaServiceInterface,
) serviceInterfaces.StartupServiceInterface {
return &Service{
DB: db,
@@ -57,6 +56,7 @@ func NewStartupService(db *gorm.DB,
Libvirt: libvirt,
Utilities: utiliies,
System: system,
Samba: samba,
}
}
@@ -72,75 +72,37 @@ func (s *Service) InitKeys(authService serviceInterfaces.AuthServiceInterface) e
return nil
}
func (s *Service) SysctlSync() error {
intVals := map[string]int32{
"net.inet.ip.forwarding": 1,
"net.link.bridge.inherit_mac": 1,
func (s *Service) PreFlightChecklist() error {
if err := s.FreeBSDCheck(); err != nil {
return err
}
for k, v := range intVals {
_, err := sysctl.GetInt64(k)
if err != nil {
logger.L.Error().Msgf("Error getting sysctl %s: %v, skipping!", k, err)
continue
}
err = sysctl.SetInt32(k, v)
if err != nil {
logger.L.Error().Msgf("Error setting sysctl %s: %v", k, err)
}
if err := s.CheckPackageDependencies(); err != nil {
return err
}
return nil
}
func (s *Service) InitFirewall() error {
// if len(config.ParsedConfig.WANInterfaces) == 0 {
// return fmt.Errorf("no WAN interfaces found in config")
// }
return nil
}
func (s *Service) CheckPackageDepdencies() error {
requiredPackages := []string{
"libvirt",
"bhyve-firmware",
"smartmontools",
"tmux",
if err := s.CheckServiceDependencies(); err != nil {
return err
}
var wg sync.WaitGroup
errCh := make(chan error, len(requiredPackages))
for _, p := range requiredPackages {
p := p
wg.Add(1)
go func() {
defer wg.Done()
if !pkg.IsPackageInstalled(p) {
errCh <- fmt.Errorf("Required package %s is not installed", p)
}
}()
if err := s.CheckLoaderConf(); err != nil {
return err
}
wg.Wait()
close(errCh)
for err := range errCh {
if err != nil {
return err
}
if err := s.CheckKernelModules(); err != nil {
return err
}
return nil
}
func (s *Service) Initialize(authService serviceInterfaces.AuthServiceInterface) error {
if err := s.CheckPackageDepdencies(); err != nil {
return err
if err := s.PreFlightChecklist(); err != nil {
return fmt.Errorf("Pre-flight check failed: %w", err)
}
s.SysctlSync()
if err := s.InitKeys(authService); err != nil {
return err
}
@@ -153,10 +115,6 @@ func (s *Service) Initialize(authService serviceInterfaces.AuthServiceInterface)
return err
}
if err := s.InitFirewall(); err != nil {
return err
}
if err := s.Libvirt.StartTPM(); err != nil {
return err
}
@@ -166,10 +124,6 @@ func (s *Service) Initialize(authService serviceInterfaces.AuthServiceInterface)
go s.ZFS.StartSnapshotScheduler(context.Background())
go s.Libvirt.StoreVMUsage()
if err := s.SysctlSync(); err != nil {
return err
}
err := s.Network.SyncStandardSwitches(nil, "sync")
if err != nil {
logger.L.Error().Msgf("Error syncing standard switches: %v", err)
@@ -179,6 +133,10 @@ func (s *Service) Initialize(authService serviceInterfaces.AuthServiceInterface)
return fmt.Errorf("failed to sync passthrough devices: %w", err)
}
if err := s.InitSamba(); err != nil {
return fmt.Errorf("failed to initialize Samba: %w", err)
}
go func() {
for {
err := s.Utilities.SyncDownloadProgress()
+46
View File
@@ -0,0 +1,46 @@
package system
import (
"os"
"path/filepath"
systemServiceInterfaces "sylve/internal/interfaces/services/system"
)
func (s *Service) Traverse(path string) ([]systemServiceInterfaces.FileNode, error) {
if path == "" {
path = "/"
}
if !filepath.IsAbs(path) {
path = "/" + path
}
entries, err := os.ReadDir(path)
if err != nil {
return nil, err
}
var nodes []systemServiceInterfaces.FileNode
for _, e := range entries {
full := filepath.Join(path, e.Name())
info, err := e.Info()
if err != nil {
continue
}
node := systemServiceInterfaces.FileNode{
ID: full,
Date: info.ModTime(),
}
if info.IsDir() {
node.Type = "folder"
node.Lazy = true
} else {
node.Type = "file"
node.Size = info.Size()
}
nodes = append(nodes, node)
}
return nodes, nil
}
+68
View File
@@ -0,0 +1,68 @@
package samba
import (
"fmt"
"strings"
"sylve/pkg/system"
"sylve/pkg/utils"
)
func SambaUserExists(name string) (bool, error) {
out, err := utils.RunCommand("pdbedit", "-L", name)
if err != nil {
low := strings.ToLower(out)
if strings.Contains(low, "no such user") ||
strings.Contains(low, "nt_status_no_such_user") ||
strings.Contains(low, "username not found!") {
return false, nil
}
return false, fmt.Errorf("pdbedit lookup failed: %v: %s", err, out)
}
return true, nil
}
func CreateSambaUser(name, password string) error {
exists, err := system.UnixUserExists(name)
if err != nil {
return fmt.Errorf("failed to check if user exists: %v", err)
}
if !exists {
return fmt.Errorf("user %s does not exist in the system", name)
}
input := fmt.Sprintf("%[1]s\n%[1]s\n", password)
out, err := utils.RunCommandWithInput("smbpasswd", input, "-s", "-a", name)
if err != nil {
return fmt.Errorf("smbpasswd -a %s failed: %v: %s", name, err, out)
}
return nil
}
func EditSambaUser(name, newPassword string) error {
exists, err := system.UnixUserExists(name)
if err != nil {
return fmt.Errorf("failed to check if user exists: %v", err)
}
if !exists {
return fmt.Errorf("user %s does not exist in the system", name)
}
input := fmt.Sprintf("%[1]s\n%[1]s\n", newPassword)
out, err := utils.RunCommandWithInput("smbpasswd", input, "-s", name)
if err != nil {
return fmt.Errorf("smbpasswd change %s failed: %v: %s", name, err, out)
}
return nil
}
func DeleteSambaUser(name string) error {
out, err := utils.RunCommand("smbpasswd", "-x", name)
if err != nil {
return fmt.Errorf("smbpasswd -x %s failed: %v: %s", name, err, out)
}
return nil
}
+18
View File
@@ -0,0 +1,18 @@
package system
import (
"fmt"
"sylve/pkg/utils"
)
func ServiceAction(name string, action string) error {
args := []string{name, action}
_, err := utils.RunCommand("service", args...)
if err != nil {
return fmt.Errorf("failed to %s service %s: %w", action, name, err)
}
return nil
}
+94
View File
@@ -58,3 +58,97 @@ func DeleteUnixUser(name string, removeHome bool) error {
return nil
}
func UnixGroupExists(name string) bool {
output, _ := utils.RunCommand("getent", "group", name)
if output == "" {
return false
}
return true
}
func CreateUnixGroup(name string) error {
if exists := UnixGroupExists(name); exists {
return fmt.Errorf("group %s already exists", name)
}
_, err := utils.RunCommand("pw", "groupadd", name)
if err != nil {
return fmt.Errorf("failed to create group %s: %w", name, err)
}
return nil
}
func DeleteUnixGroup(name string) error {
if exists := UnixGroupExists(name); !exists {
return fmt.Errorf("group %s does not exist", name)
}
_, err := utils.RunCommand("pw", "groupdel", name)
if err != nil {
return fmt.Errorf("failed to delete group %s: %w", name, err)
}
return nil
}
func IsUserInGroup(user string, group string) (bool, error) {
if exists, _ := UnixUserExists(user); !exists {
return false, fmt.Errorf("user %s does not exist", user)
}
if exists := UnixGroupExists(group); !exists {
return false, fmt.Errorf("group %s does not exist", group)
}
output, err := utils.RunCommand("id", "-nG", user)
if err != nil {
return false, fmt.Errorf("failed to check group membership for user %s: %w", user, err)
}
groups := strings.Fields(output)
for _, g := range groups {
if g == group {
return true, nil
}
}
return false, nil
}
func AddUserToGroup(user string, group string) error {
if exists, _ := UnixUserExists(user); !exists {
return fmt.Errorf("user %s does not exist", user)
}
if exists := UnixGroupExists(group); !exists {
return fmt.Errorf("group %s does not exist", group)
}
_, err := utils.RunCommand("pw", "groupmod", group, "-m", user)
if err != nil {
return fmt.Errorf("failed to add user %s to group %s: %w", user, group, err)
}
return nil
}
func RenameGroup(oldName, newName string) error {
if exists := UnixGroupExists(oldName); !exists {
return fmt.Errorf("group %s does not exist", oldName)
}
if exists := UnixGroupExists(newName); exists {
return fmt.Errorf("group %s already exists", newName)
}
_, err := utils.RunCommand("pw", "groupmod", oldName, "-n", newName)
if err != nil {
return fmt.Errorf("failed to rename group %s to %s: %w", oldName, newName, err)
}
return nil
}
+14
View File
@@ -13,6 +13,7 @@ import (
"context"
"fmt"
"os/exec"
"strings"
)
var execCommand = exec.Command
@@ -34,6 +35,19 @@ func RunCommand(command string, args ...string) (string, error) {
return output, nil
}
func RunCommandWithInput(command string, input string, args ...string) (string, error) {
cmd := exec.Command(command, args...)
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
cmd.Stdin = strings.NewReader(input)
if err := cmd.Run(); err != nil {
return out.String(), fmt.Errorf("command execution failed: %v, output: %s", err, out.String())
}
return out.String(), nil
}
func RunCommandWithContext(ctx context.Context, command string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, command, args...)
+68 -1
View File
@@ -20,6 +20,7 @@ import (
"strconv"
"strings"
"unicode"
"unicode/utf8"
"github.com/go-playground/validator/v10"
"github.com/golang-jwt/jwt/v4"
@@ -64,7 +65,7 @@ func RemoveSpaces(input string) string {
return strings.ReplaceAll(input, " ", "")
}
func StringToUint(s string) uint {
func StringToUintId(s string) uint {
hasher := fnv.New64a()
hasher.Write([]byte(s))
return uint(hasher.Sum64())
@@ -366,3 +367,69 @@ func IsValidUsername(username string) bool {
regex := regexp.MustCompile(`^[a-z_]([a-z0-9_-]{0,31}|[a-z0-9_-]{0,30}\$)$`)
return regex.MatchString(username)
}
func IsValidWorkgroup(name string) bool {
if len(name) == 0 || len(name) > 15 {
return false
}
validPattern := regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
if !validPattern.MatchString(name) {
return false
}
if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "-") {
return false
}
return true
}
func IsValidServerString(s string) bool {
return utf8.ValidString(s) && len(s) <= 100
}
func RemoveDuplicates(input []string) []string {
seen := make(map[string]struct{})
var result []string
for _, val := range input {
val = strings.TrimSpace(val)
if _, ok := seen[val]; !ok && val != "" {
seen[val] = struct{}{}
result = append(result, val)
}
}
return result
}
func IsValidGroupName(name string) bool {
if len(name) == 0 || len(name) > 32 {
return false
}
validPattern := regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
if !validPattern.MatchString(name) {
return false
}
if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "-") {
return false
}
return true
}
func JoinStringSlices(slices ...[]string) []string {
if len(slices) == 0 {
return nil
}
result := make([]string, 0)
for _, slice := range slices {
result = append(result, slice...)
}
return RemoveDuplicates(result)
}
+3 -3
View File
@@ -156,7 +156,7 @@ func TestRemoveSpaces(t *testing.T) {
}
}
func TestStringToUint(t *testing.T) {
func TestStringToUintId(t *testing.T) {
tests := []struct {
input string
expected uint
@@ -185,9 +185,9 @@ func TestStringToUint(t *testing.T) {
for _, tt := range tests {
t.Run(fmt.Sprintf("input=%q", tt.input), func(t *testing.T) {
result := StringToUint(tt.input)
result := StringToUintId(tt.input)
if result != tt.expected {
t.Errorf("StringToUint(%q) = %d; want %d", tt.input, result, tt.expected)
t.Errorf("StringToUintId(%q) = %d; want %d", tt.input, result, tt.expected)
}
})
}
+22
View File
@@ -123,3 +123,25 @@ func IsGPT(sector []byte) bool {
func KillProcess(pid int) error {
return syscall.Kill(pid, syscall.SIGKILL)
}
func GetSupportedCharsets() []string {
output, err := RunCommand("iconv", "-l")
if err != nil {
return nil
}
lines := strings.Split(output, "\n")
charsets := make([]string, 0)
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
tokens := strings.Fields(line)
charsets = append(charsets, tokens...)
}
return charsets
}
+1 -1
View File
@@ -246,7 +246,7 @@ func (z *zfs) EditFilesystem(name string, props map[string]string) error {
}
if _, ok := props["quota"]; ok {
if props["quota"] == "" {
if props["quota"] == "" || props["quota"] == "0B" {
delete(props, "quota")
}
}
+28
View File
@@ -0,0 +1,28 @@
#!/bin/sh
echo "=== Checking system dependencies for building Sylve ==="
if [ "$(uname)" != "FreeBSD" ]; then
echo "❌ Error: This script must be run on FreeBSD."
exit 1
fi
echo "✅ OS Check: Running on FreeBSD."
check_command() {
if command -v "$1" >/dev/null 2>&1; then
echo "$1 found: $($2)"
else
echo "❌ Error: $1 is required but not found. Install using '$3'"
exit 1
fi
}
check_command node "node -v" "pkg install node20"
check_command npm "npm -v" "pkg install npm-node20"
check_command go "go version" "pkg install go"
check_command tmux "tmux -V" "pkg install tmux"
check_command virsh "virsh --version" "pkg install libvirt"
echo "=== Dependency check completed ==="
echo
exit 0
-66
View File
@@ -1,66 +0,0 @@
#!/bin/sh
echo "=== Checking system dependencies for Sylve ==="
if [ "$(uname)" != "FreeBSD" ]; then
echo "❌ Error: This script must be run on FreeBSD."
exit 1
fi
echo "✅ OS Check: Running on FreeBSD."
RELEASE=$(freebsd-version | cut -d '.' -f 1)
if [ "$RELEASE" -lt 14 ]; then
echo "⚠️ Error: This script requires FreeBSD 14.0 or newer. Detected version: $(freebsd-version)"
exit 1
else
echo "✅ FreeBSD version: $(freebsd-version)"
fi
check_command() {
if command -v "$1" >/dev/null 2>&1; then
echo "$1 found: $($2)"
else
echo "❌ Error: $1 is required but not found. Install using '$3'"
exit 1
fi
}
check_command node "node -v" "pkg install node20"
check_command npm "npm -v" "pkg install npm-node20"
check_command go "go version" "pkg install go"
check_command tmux "tmux -V" "pkg install tmux"
check_command virsh "virsh --version" "pkg install libvirt"
RC_CONF="/etc/rc.conf"
check_rcconf() {
if grep -q "^$1=\"YES\"" "$RC_CONF"; then
echo "$1 is enabled in rc.conf"
else
echo "❌ Error: $1 is not enabled in rc.conf. Add '$1=\"YES\"' to enable it."
exit 1
fi
}
LOADER_CONF="/boot/loader.conf"
check_loaderconf() {
if grep -q "^$1" "$LOADER_CONF"; then
echo "$1 is set in loader.conf"
else
echo "❌ Error: $1 is not set in loader.conf. Add '$1' to enable it."
exit 1
fi
}
check_rcconf smartd_enable
check_rcconf linux_enable
check_rcconf libvirtd_enable
check_rcconf gateway_enable
# check_rcconf pf_enable # We'll skip this for now, as it may not be needed for all setups
check_loaderconf vmm_load
check_loaderconf if_bridge_load
check_loaderconf nmdm_load
echo "=== Dependency check completed ==="
echo
exit 0
+101 -101
View File
@@ -4,117 +4,117 @@
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.3rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.95 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--radius: 0.3rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.95 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.2002 0 0);
--foreground: oklch(0.94 0 0);
--card: oklch(0.2615 0 0);
--card-foreground: oklch(0.94 0 0);
--popover: oklch(0.37 0 0);
--popover-foreground: oklch(0.94 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.43 0 0);
--secondary-foreground: oklch(1 0 0);
--muted: oklch(0.3446 0 0);
--muted-foreground: oklch(0.84 0 0);
--accent: oklch(0.53 0 0);
--accent-foreground: oklch(1 0 0);
--destructive: oklch(0.62 0.27 25);
--border: oklch(0.31 0 0);
--input: oklch(0.37 0 0);
--ring: oklch(0.556 0 0);
--sidebar: oklch(0.26 0 0);
--sidebar-foreground: oklch(0.94 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.37 0 0);
--sidebar-accent-foreground: oklch(0.94 0 0);
--sidebar-border: oklch(0.43 0 0);
--sidebar-ring: oklch(0.556 0 0);
--background: oklch(0.2002 0 0);
--foreground: oklch(0.94 0 0);
--card: oklch(0.2615 0 0);
--card-foreground: oklch(0.94 0 0);
--popover: oklch(0.37 0 0);
--popover-foreground: oklch(0.94 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.43 0 0);
--secondary-foreground: oklch(1 0 0);
--muted: oklch(0.3446 0 0);
--muted-foreground: oklch(0.84 0 0);
--accent: oklch(0.53 0 0);
--accent-foreground: oklch(1 0 0);
--destructive: oklch(0.62 0.27 25);
--border: oklch(0.31 0 0);
--input: oklch(0.37 0 0);
--ring: oklch(0.556 0 0);
--sidebar: oklch(0.26 0 0);
--sidebar-foreground: oklch(0.94 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.37 0 0);
--sidebar-accent-foreground: oklch(0.94 0 0);
--sidebar-border: oklch(0.43 0 0);
--sidebar-ring: oklch(0.556 0 0);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
body {
@apply bg-background text-foreground;
}
}
@layer components {
@import './css/tabulator_simple.css';
}
@import './css/tabulator_simple.css';
}
+30
View File
@@ -0,0 +1,30 @@
import { GroupSchema, UserSchema, type Group, type User } from '$lib/types/auth';
import { APIResponseSchema, type APIResponse } from '$lib/types/common';
import { apiRequest } from '$lib/utils/http';
import { z } from 'zod/v4';
export async function listGroups(): Promise<Group[]> {
return await apiRequest('/auth/groups', z.array(GroupSchema), 'GET');
}
export async function createGroup(name: string, members: string[]): Promise<APIResponse> {
const requestBody = {
name,
members
};
return await apiRequest('/auth/groups', APIResponseSchema, 'POST', requestBody);
}
export async function deleteGroup(id: number): Promise<APIResponse> {
return await apiRequest(`/auth/groups/${id}`, APIResponseSchema, 'DELETE');
}
export async function addUsersToGroup(usernames: string[], group: string): Promise<APIResponse> {
const requestBody = {
usernames: usernames,
group
};
return await apiRequest('/auth/groups/users', APIResponseSchema, 'POST', requestBody);
}
+11
View File
@@ -0,0 +1,11 @@
import { APIResponseSchema, type APIResponse } from '$lib/types/common';
import { SambaConfigSchema, type SambaConfig } from '$lib/types/samba/config';
import { apiRequest } from '$lib/utils/http';
export async function getSambaConfig(): Promise<SambaConfig> {
return await apiRequest('/samba/config', SambaConfigSchema, 'GET');
}
export async function updateSambaConfig(config: Partial<SambaConfig>): Promise<APIResponse> {
return await apiRequest('/samba/config', APIResponseSchema, 'POST', config);
}
+30
View File
@@ -0,0 +1,30 @@
import { APIResponseSchema, type APIResponse } from '$lib/types/common';
import { SambaShareSchema, type SambaShare } from '$lib/types/samba/shares';
import { apiRequest } from '$lib/utils/http';
import { z } from 'zod/v4';
export async function getSambaShares(): Promise<SambaShare[]> {
return await apiRequest('/samba/shares', z.array(SambaShareSchema), 'GET');
}
export async function createSambaShare(
name: string,
dataset: string,
readOnlyGroups: string[] = [],
writeableGroups: string[] = [],
createMask: string = '',
directoryMask: string = ''
): Promise<APIResponse> {
return await apiRequest('/samba/shares', APIResponseSchema, 'POST', {
name,
dataset,
readOnlyGroups,
writeableGroups,
createMask,
directoryMask
});
}
export async function deleteSambaShare(id: number): Promise<APIResponse> {
return await apiRequest(`/samba/shares/${id}`, APIResponseSchema, 'DELETE');
}
+13
View File
@@ -0,0 +1,13 @@
import { APIResponseSchema, type APIResponse } from '$lib/types/common';
import { FileNodeSchema, type FileNode } from '$lib/types/system/file-explorer';
import { apiRequest } from '$lib/utils/http';
export async function getFiles(id?: string): Promise<FileNode[]> {
let url = '/system/file-explorer/files';
if (id) {
url += `?id=${encodeURIComponent(id)}`;
}
return await apiRequest(url, FileNodeSchema.array(), 'GET');
}
@@ -0,0 +1,119 @@
<script lang="ts">
import SimpleSelect from '$lib/components/custom/SimpleSelect.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import CustomComboBox from '$lib/components/ui/custom-input/combobox.svelte';
import CustomValueInput from '$lib/components/ui/custom-input/value.svelte';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import Icon from '@iconify/svelte';
interface Props {
open: boolean;
title: string;
icon?: string;
type: 'text' | 'number' | 'select' | 'combobox';
placeholder?: string;
value: string;
options?: {
label: string;
value: string;
}[];
onSave: () => void;
}
let {
open = $bindable(),
title,
type,
placeholder = '',
icon = 'mdi:pencil',
value = $bindable(),
options = [],
onSave
}: Props = $props();
let comboBox = $state({
open: false,
value: value.split(',').map((v) => v.trim()),
data: options.map((o) => ({ value: o.value, label: o.label })),
onValueChange: (val: string | string[]) => {
if (Array.isArray(val)) {
value = val.join(',');
} else {
value = val;
}
},
placeholder: placeholder,
disabled: false,
disallowEmpty: true,
multiple: true
});
</script>
<Dialog.Root bind:open>
<Dialog.Content
class="flex flex-col p-5"
onInteractOutside={() => {
open = false;
}}
>
<Dialog.Header class="p-0">
<Dialog.Title class="flex justify-between gap-1 text-left">
<div class="flex items-center gap-2">
<Icon {icon} class="h-6 w-6" />
<span>{title}</span>
</div>
<div class="flex items-center gap-0.5">
<Button
size="sm"
variant="link"
class="h-4"
title={'Close'}
onclick={() => {
open = false;
}}
>
<Icon icon="material-symbols:close-rounded" class="pointer-events-none h-4 w-4" />
<span class="sr-only">Close</span>
</Button>
</div>
</Dialog.Title>
</Dialog.Header>
{#if type === 'text' || type === 'number'}
<CustomValueInput
placeholder={placeholder || 'Enter value'}
bind:value
classes="flex-1 space-y-1.5"
/>
{/if}
{#if type === 'select'}
<SimpleSelect
placeholder={placeholder || 'Select an option'}
{options}
bind:value
onChange={(v) => (value = v)}
/>
{/if}
{#if type === 'combobox'}
<CustomComboBox
bind:open={comboBox.open}
bind:value={comboBox.value}
data={comboBox.data}
onValueChange={comboBox.onValueChange}
placeholder={comboBox.placeholder}
disabled={comboBox.disabled}
disallowEmpty={comboBox.disallowEmpty}
multiple={comboBox.multiple}
width="w-full"
/>
{/if}
<Dialog.Footer class="flex justify-end">
<div class="flex w-full items-center justify-end gap-2">
<Button onclick={() => onSave()} type="submit" size="sm">{'Save'}</Button>
</div>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,244 @@
<script lang="ts">
import { createSambaShare } from '$lib/api/samba/share';
import Button from '$lib/components/ui/button/button.svelte';
import CustomComboBox from '$lib/components/ui/custom-input/combobox.svelte';
import CustomValueInput from '$lib/components/ui/custom-input/value.svelte';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import type { Group } from '$lib/types/auth';
import type { SambaShare } from '$lib/types/samba/shares';
import type { Dataset } from '$lib/types/zfs/dataset';
import Icon from '@iconify/svelte';
import { toast } from 'svelte-sonner';
interface Props {
open: boolean;
shares: SambaShare[];
datasets: Dataset[];
groups: Group[];
}
let { open = $bindable(), shares, datasets, groups }: Props = $props();
let options = {
name: '',
dataset: {
combobox: {
open: false,
value: '',
options: datasets
.filter(
(dataset) =>
dataset.properties.mountpoint !== '-' &&
dataset.properties.mountpoint !== null &&
dataset.properties.mountpoint !== '' &&
dataset.properties.mountpoint !== '/' &&
dataset.properties.mounted
)
.map((dataset) => ({
label: dataset.name,
value: dataset.properties.guid ? dataset.properties.guid : dataset.name
}))
}
},
readOnlyGroups: {
combobox: {
open: false,
value: [] as string[],
options: groups.map((group) => ({
label: group.name,
value: group.name
}))
}
},
writeableGroups: {
combobox: {
open: false,
value: [] as string[],
options: groups.map((group) => ({
label: group.name,
value: group.name
}))
}
},
createMask: '0664',
directoryMask: '2775'
};
let properties = $state(options);
async function create() {
let error = '';
if (shares.some((share) => share.name === properties.name)) {
error = 'Share name already exists';
}
if (properties.name === '') {
error = 'Name is required';
} else if (properties.dataset.combobox.value === '') {
error = 'Dataset is required';
} else if (
properties.readOnlyGroups.combobox.value.length === 0 &&
properties.writeableGroups.combobox.value.length === 0
) {
error = 'No groups selected';
}
if (
properties.readOnlyGroups.combobox.value.some((group) =>
properties.writeableGroups.combobox.value.includes(group)
)
) {
error = 'Share cannot have overlapping groups';
}
if (error) {
toast.error(error, {
position: 'bottom-center'
});
return;
}
const response = await createSambaShare(
properties.name,
properties.dataset.combobox.value,
properties.readOnlyGroups.combobox.value,
properties.writeableGroups.combobox.value,
properties.createMask,
properties.directoryMask
);
if (response.status === 'error') {
toast.error('Failed to create Samba share', {
position: 'bottom-center'
});
return;
}
toast.success('Samba share created', {
position: 'bottom-center'
});
open = false;
properties = options;
}
</script>
<Dialog.Root bind:open>
<Dialog.Content
class="flex flex-col p-5"
onInteractOutside={() => {
properties = options;
open = false;
}}
>
<Dialog.Header class="p-0">
<Dialog.Title class="flex justify-between gap-1 text-left">
<div class="flex items-center gap-2">
<Icon icon="mdi:folder-network" class="h-6 w-6" />
<span>Create Samba Share</span>
</div>
<div class="flex items-center gap-0.5">
<Button
size="sm"
variant="link"
class="h-4"
title={'Reset'}
onclick={() => {
properties = options;
}}
>
<Icon icon="radix-icons:reset" class="pointer-events-none h-4 w-4" />
<span class="sr-only">Reset</span>
</Button>
<Button
size="sm"
variant="link"
class="h-4"
title={'Close'}
onclick={() => {
open = false;
properties = options;
}}
>
<Icon icon="material-symbols:close-rounded" class="pointer-events-none h-4 w-4" />
<span class="sr-only">Close</span>
</Button>
</div>
</Dialog.Title>
</Dialog.Header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<CustomValueInput
label={'Name'}
placeholder="share"
bind:value={properties.name}
classes="flex-1 space-y-1.5"
/>
<CustomComboBox
label={'Dataset'}
placeholder="Select dataset"
bind:open={properties.dataset.combobox.open}
bind:value={properties.dataset.combobox.value}
data={properties.dataset.combobox.options}
multiple={false}
width="w-full"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<CustomComboBox
label={'Read-Only Groups'}
placeholder="Select groups"
bind:open={properties.readOnlyGroups.combobox.open}
bind:value={properties.readOnlyGroups.combobox.value}
data={properties.readOnlyGroups.combobox.options}
multiple={true}
width="w-full"
/>
<CustomComboBox
label={'Writeable Groups'}
placeholder="Select groups"
bind:open={properties.writeableGroups.combobox.open}
bind:value={properties.writeableGroups.combobox.value}
data={properties.writeableGroups.combobox.options}
multiple={true}
width="w-full"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<CustomValueInput
label={'Create Mask'}
placeholder="0664"
bind:value={properties.createMask}
classes="flex-1 space-y-1.5"
/>
<CustomValueInput
label={'Directory Mask'}
placeholder="2775"
bind:value={properties.directoryMask}
classes="flex-1 space-y-1.5"
/>
</div>
<Dialog.Footer class="mt-4">
<div class="flex items-center justify-end space-x-4">
<Button
size="sm"
type="button"
class="h-8 w-full lg:w-28"
onclick={() => {
create();
}}
>
Create
</Button>
</div>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -13,10 +13,11 @@
};
onChange: (value: string) => void;
disabled?: boolean;
single?: boolean;
}
let {
label = 'Select',
label,
placeholder = 'Select an option',
options,
classes = { parent: 'flex-1 space-y-1', label: 'w-24 whitespace-nowrap text-sm' },
@@ -27,7 +28,9 @@
</script>
<div class={classes.parent}>
<Label class={classes.label}>{label}</Label>
{#if label}
<Label class={classes.label}>{label}</Label>
{/if}
<Select.Root
type="single"
bind:value
@@ -52,7 +52,9 @@
dedup: 'off',
encryption: 'off',
encryptionKey: '',
quota: ''
quota: '',
aclinherit: 'passthrough',
aclmode: 'passthrough'
};
let zfsProperties = $state(createFSProps);
@@ -128,7 +130,9 @@
dedup: properties.dedup,
encryption: properties.encryption,
encryptionKey: properties.encryptionKey,
quota: properties.quota
quota: properties.quota,
aclinherit: properties.aclinherit,
aclmode: properties.aclmode
});
if (response.status === 'error') {
@@ -295,6 +299,22 @@
placeholder="256M (Empty for no quota)"
/>
</div>
<SimpleSelect
label="ACL Inherit"
placeholder="Select ACL Inherit"
options={zfsProperties.aclInherit}
bind:value={properties.aclinherit}
onChange={(value) => (properties.aclinherit = value)}
/>
<SimpleSelect
label="ACL Mode"
placeholder="Select ACL Mode"
options={zfsProperties.aclMode}
bind:value={properties.aclmode}
onChange={(value) => (properties.aclmode = value)}
/>
</div>
</div>
@@ -23,7 +23,9 @@
checksum: dataset.properties.checksum || 'on',
compression: dataset.properties.compression || 'on',
dedup: dataset.properties.dedup || 'off',
quota: dataset.properties.quota ? bytesToHumanReadable(dataset.properties.quota) : ''
quota: dataset.properties.quota ? bytesToHumanReadable(dataset.properties.quota) : '',
aclinherit: dataset.properties.aclinherit || 'passthrough',
aclmode: dataset.properties.aclmode || 'passthrough'
};
let zfsProperties = $state(createFSProps);
@@ -44,7 +46,9 @@
checksum: properties.checksum,
compression: properties.compression,
dedup: properties.dedup,
quota: parseQuotaToZFSBytes(properties.quota)
quota: parseQuotaToZFSBytes(properties.quota),
aclinherit: properties.aclinherit,
aclmode: properties.aclmode
});
if (response.status === 'error') {
@@ -157,6 +161,22 @@
placeholder="256M (Empty for no quota)"
/>
</div>
<SimpleSelect
label="ACL Inherit"
placeholder="Select ACL Inherit"
options={zfsProperties.aclInherit}
bind:value={properties.aclinherit}
onChange={(value) => (properties.aclinherit = value)}
/>
<SimpleSelect
label="ACL Mode"
placeholder="Select ACL Mode"
options={zfsProperties.aclMode}
bind:value={properties.aclmode}
onChange={(value) => (properties.aclmode = value)}
/>
</div>
</div>
@@ -8,7 +8,7 @@
interface Props {
open: boolean;
label: string;
label?: string;
value: string | string[];
data: { value: string; label: string }[];
onValueChange?: (value: string | string[]) => void;
@@ -80,9 +80,11 @@
</script>
<div class={classes}>
<Label class="w-full whitespace-nowrap text-sm" for={label.toLowerCase()}>
{label}
</Label>
{#if label}
<Label class="w-full whitespace-nowrap text-sm" for={label.toLowerCase()}>
{label}
</Label>
{/if}
<Popover.Root bind:open>
<Popover.Trigger class={triggerWidth}>
<Button
@@ -6,7 +6,7 @@
import type { FullAutoFill } from 'svelte/elements';
interface Props {
label: string;
label?: string;
labelHTML?: boolean;
value: string | number;
placeholder: string;
+10
View File
@@ -22,5 +22,15 @@ export const UserSchema = z.object({
lastLoginTime: z.string()
});
export const GroupSchema = z.object({
id: z.number().int(),
name: z.string(),
notes: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
users: z.array(UserSchema).optional()
});
export type JWTClaims = z.infer<typeof JWTClaimsSchema>;
export type User = z.infer<typeof UserSchema>;
export type Group = z.infer<typeof GroupSchema>;
+12
View File
@@ -0,0 +1,12 @@
import { z } from 'zod/v4';
export const SambaConfigSchema = z.object({
id: z.number(),
unixCharset: z.string(),
workgroup: z.string(),
serverString: z.string().default('Sylve SMB Server'),
interfaces: z.string().default('lo0'),
bindInterfacesOnly: z.boolean().default(true)
});
export type SambaConfig = z.infer<typeof SambaConfigSchema>;
+16
View File
@@ -0,0 +1,16 @@
import { z } from 'zod/v4';
import { GroupSchema } from '../auth';
export const SambaShareSchema = z.object({
id: z.number(),
name: z.string(),
dataset: z.string(),
readOnlyGroups: z.preprocess((val) => (val == null ? [] : val), z.array(GroupSchema)),
writeableGroups: z.preprocess((val) => (val == null ? [] : val), z.array(GroupSchema)),
createMask: z.string(),
directoryMask: z.string(),
createdAt: z.string(),
updatedAt: z.string()
});
export type SambaShare = z.infer<typeof SambaShareSchema>;
+11
View File
@@ -0,0 +1,11 @@
import { z } from 'zod/v4';
export const FileNodeSchema = z.object({
id: z.string(),
date: z.string().transform((s) => new Date(s)),
type: z.string(),
lazy: z.boolean().optional(),
size: z.number().min(0).optional()
});
export type FileNode = z.infer<typeof FileNodeSchema>;
+44
View File
@@ -125,6 +125,50 @@ export const createFSProps = {
label: 'aes-256-gcm',
value: 'aes-256-gcm'
}
],
aclInherit: [
{
label: 'Discard',
value: 'discard'
},
{
label: 'No Allow',
value: 'noallow'
},
{
label: 'Restricted',
value: 'restricted'
},
{
label: 'Passthrough',
value: 'passthrough'
},
{
label: 'Passthrough-X',
value: 'passthrough-x'
}
],
aclMode: [
{
label: 'Discard',
value: 'discard'
},
{
label: 'Group Mask',
value: 'groupmask'
},
{
label: 'Passthrough',
value: 'passthrough'
},
{
label: 'Passthrough-X',
value: 'passthrough-x'
},
{
label: 'Restricted',
value: 'restricted'
}
]
};
+33 -1
View File
@@ -87,6 +87,11 @@
label: 'Storage',
icon: 'mdi:storage',
children: [
{
label: 'Explorer',
icon: 'bxs:folder-open',
href: `/${node}/storage/explorer`
},
{
label: 'Disks',
icon: 'mdi:harddisk',
@@ -128,6 +133,22 @@
]
}
]
},
{
label: 'Samba',
icon: 'material-symbols:smb-share',
children: [
{
label: 'Shares',
icon: 'mdi:folder-network',
href: `/${node}/storage/samba/shares`
},
{
label: 'Settings',
icon: 'mdi:folder-settings-variant',
href: `/${node}/storage/samba/settings`
}
]
}
]
},
@@ -154,7 +175,18 @@
{
label: 'Authentication',
icon: 'mdi:shield-key',
href: `/${node}/settings/authentication`
children: [
{
label: 'Users',
icon: 'mdi:account',
href: `/${node}/settings/authentication/users`
},
{
label: 'Groups',
icon: 'mdi:account-group',
href: `/${node}/settings/authentication/groups`
}
]
}
]
}
@@ -0,0 +1,436 @@
<script lang="ts">
import { addUsersToGroup, createGroup, deleteGroup, listGroups } from '$lib/api/auth/groups';
import { listUsers } from '$lib/api/auth/local';
import AlertDialog from '$lib/components/custom/Dialog/Alert.svelte';
import TreeTable from '$lib/components/custom/TreeTable.svelte';
import Search from '$lib/components/custom/TreeTable/Search.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import CustomComboBox from '$lib/components/ui/custom-input/combobox.svelte';
import CustomValueInput from '$lib/components/ui/custom-input/value.svelte';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import type { Group, User } from '$lib/types/auth';
import type { Column, Row } from '$lib/types/components/tree-table';
import { handleAPIError, updateCache } from '$lib/utils/http';
import { convertDbTime } from '$lib/utils/time';
import Icon from '@iconify/svelte';
import { useQueries } from '@sveltestack/svelte-query';
import { toast } from 'svelte-sonner';
import type { CellComponent } from 'tabulator-tables';
interface Data {
users: User[];
groups: Group[];
}
let { data }: { data: Data } = $props();
const results = useQueries([
{
queryKey: ['users'],
queryFn: async () => {
return (await listUsers()) as User[];
},
refetchInterval: 1000,
keepPreviousData: true,
initialData: data.users,
onSuccess: (data: User[]) => {
updateCache('users', data);
}
},
{
queryKey: ['groups'],
queryFn: async () => {
return (await listGroups()) as Group[];
},
refetchInterval: 1000,
keepPreviousData: true,
initialData: data.groups,
onSuccess: (data: Group[]) => {
updateCache('groups', data);
}
}
]);
let users = $derived($results[0].data as User[]);
let groups = $derived($results[1].data as Group[]);
let usersOptions = $derived.by(() => {
return users.map((user) => ({
label: user.username,
value: user.username
}));
});
let options = {
create: {
open: false,
name: '',
users: {
open: false,
value: [] as string[],
data: (() => $state.snapshot(usersOptions))()
}
},
delete: {
open: false,
id: 0
},
addUsers: {
open: false,
combobox: {
open: false,
value: [] as string[],
data: (() => $state.snapshot(usersOptions))()
}
}
};
let properties = $state(options);
async function onCreate() {
let error = '';
if (!properties.create.name.trim() || properties.create.users.value.length === 0) {
error = 'Name and users are required';
} else if (groups.some((g) => g.name === properties.create.name.trim())) {
error = 'Group name already exists';
}
if (error) {
toast.error(error, {
position: 'bottom-center'
});
return;
}
const response = await createGroup(
properties.create.name.trim(),
properties.create.users.value
);
if (response.error) {
handleAPIError(response);
toast.error('Failed to create group', {
position: 'bottom-center'
});
return;
} else {
toast.success('Group created', {
position: 'bottom-center'
});
properties.create.open = false;
properties.create.name = '';
properties.create.users.value = [];
}
}
async function onAddUsers() {
if (properties.addUsers.combobox.value.length === 0) {
toast.error('No users selected', {
position: 'bottom-center'
});
return;
}
const response = await addUsersToGroup(
properties.addUsers.combobox.value,
activeRow ? activeRow.name : ''
);
if (response.status === 'error') {
handleAPIError(response);
toast.error('Failed to add users to group', {
position: 'bottom-center'
});
return;
} else {
toast.success('Users added to group', {
position: 'bottom-center'
});
properties.addUsers.open = false;
properties.addUsers.combobox.value = [];
}
}
function generateTableData(users: User[], groups: Group[]): { rows: Row[]; columns: Column[] } {
const columns: Column[] = [
{
field: 'id',
title: 'ID',
visible: false
},
{
field: 'name',
title: 'Name'
},
{
field: 'createdAt',
title: 'Created At',
formatter: (cell: CellComponent) => {
const value = cell.getValue();
return convertDbTime(value);
}
}
];
const rows: Row[] = [];
for (const group of groups) {
rows.push({
id: group.id,
name: group.name,
createdAt: group.createdAt,
user: false,
children: group.users?.map((user) => ({
id: user.id,
name: user.username,
createdAt: user.createdAt,
user: true
}))
});
}
return {
columns,
rows
};
}
let tableData = $derived(generateTableData(users, groups));
let query: string = $state('');
let activeRows: Row[] | null = $state(null);
let activeRow: Row | null = $derived(activeRows ? (activeRows[0] as Row) : ({} as Row));
</script>
{#snippet button(type: string)}
{#if activeRows && activeRows.length === 1 && !activeRows[0].user}
{#if type === 'delete'}
<Button
onclick={() => {
properties.delete.open = !properties.delete.open;
properties.delete.id = activeRows ? (activeRows[0].id as number) : 0;
}}
size="sm"
variant="outline"
class="h-6.5"
>
<div class="flex items-center">
<Icon icon="mdi:delete" class="mr-1 h-4 w-4" />
<span>Delete</span>
</div>
</Button>
{/if}
{#if type === 'add-users'}
<Button
onclick={() => {
properties.addUsers.open = !properties.addUsers.open;
if (activeRows) {
properties.addUsers.combobox.value =
activeRows[0].children?.map((user) => user.name) || [];
}
}}
size="sm"
variant="outline"
class="h-6.5"
>
<div class="flex items-center">
<Icon icon="material-symbols:group-add" class="mr-1 h-4 w-4" />
<span>Add Users</span>
</div>
</Button>
{/if}
{/if}
{/snippet}
<div class="flex h-full flex-col overflow-hidden">
<div class="flex h-10 w-full items-center gap-2 border-b p-2">
<Search bind:query />
<Button
onclick={() => (properties.create.open = !properties.create.open)}
size="sm"
class="h-6"
>
<div class="flex items-center">
<Icon icon="gg:add" class="mr-1 h-4 w-4" />
<span>New</span>
</div>
</Button>
{@render button('add-users')}
{@render button('delete')}
</div>
<TreeTable
data={tableData}
name={'tt-groups'}
bind:parentActiveRow={activeRows}
multipleSelect={false}
bind:query
/>
</div>
{#if properties.create.open}
<Dialog.Root bind:open={properties.create.open}>
<Dialog.Content
class="sm:max-w-[425px]"
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeydown={(e) => e.preventDefault()}
>
<Dialog.Header>
<Dialog.Title class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Icon icon="mdi:account-group" class="h-5 w-5" />
<span>New Group</span>
</div>
<div class="flex items-center gap-0.5">
<Button
size="sm"
variant="link"
title={'Reset'}
class="h-4"
onclick={() => {
properties = options;
}}
>
<Icon icon="radix-icons:reset" class="pointer-events-none h-4 w-4" />
<span class="sr-only">{'Reset'}</span>
</Button>
<Button
size="sm"
variant="link"
class="h-4"
title={'Close'}
onclick={() => {
properties = options;
properties.create.open = false;
}}
>
<Icon icon="material-symbols:close-rounded" class="pointer-events-none h-4 w-4" />
<span class="sr-only">{'Close'}</span>
</Button>
</div>
</Dialog.Title>
</Dialog.Header>
<CustomValueInput
label={'Name'}
placeholder="c-level"
bind:value={properties.create.name}
classes="flex-1 space-y-1.5"
/>
<CustomComboBox
bind:open={properties.create.users.open}
bind:value={properties.create.users.value}
data={properties.create.users.data}
onValueChange={(v) => {
properties.create.users.value = v as string[];
}}
placeholder={'Select users'}
multiple={true}
width="w-full"
/>
<Dialog.Footer class="flex justify-end">
<div class="flex w-full items-center justify-end gap-2">
<Button onclick={() => onCreate()} type="submit" size="sm">{'Create'}</Button>
</div>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
{/if}
{#if properties.addUsers.open}
<Dialog.Root bind:open={properties.addUsers.open}>
<Dialog.Content
class="sm:max-w-[425px]"
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeydown={(e) => e.preventDefault()}
>
<Dialog.Header>
<Dialog.Title class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Icon icon="material-symbols:group-add" class="h-5 w-5" />
<span>Add Users</span>
</div>
<div class="flex items-center gap-0.5">
<Button
size="sm"
variant="link"
title={'Reset'}
class="h-4"
onclick={() => {
properties = options;
}}
>
<Icon icon="radix-icons:reset" class="pointer-events-none h-4 w-4" />
<span class="sr-only">{'Reset'}</span>
</Button>
<Button
size="sm"
variant="link"
class="h-4"
title={'Close'}
onclick={() => {
properties = options;
properties.addUsers.open = false;
}}
>
<Icon icon="material-symbols:close-rounded" class="pointer-events-none h-4 w-4" />
<span class="sr-only">{'Close'}</span>
</Button>
</div>
</Dialog.Title>
</Dialog.Header>
<CustomComboBox
bind:open={properties.addUsers.combobox.open}
bind:value={properties.addUsers.combobox.value}
data={properties.addUsers.combobox.data}
onValueChange={(v) => {
properties.addUsers.combobox.value = v as string[];
}}
placeholder={'Select users'}
multiple={true}
width="w-full"
/>
<Dialog.Footer class="flex justify-end">
<div class="flex w-full items-center justify-end gap-2">
<Button onclick={() => onAddUsers()} type="submit" size="sm">{'Add Users'}</Button>
</div>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
{/if}
<AlertDialog
open={properties.delete.open}
names={{ parent: 'group', element: activeRow?.name || '' }}
actions={{
onConfirm: async () => {
const result = await deleteGroup(properties.delete.id);
if (result.status === 'error') {
handleAPIError(result);
toast.error('Failed to delete group', {
position: 'bottom-center'
});
return;
} else {
toast.success('Group deleted', {
position: 'bottom-center'
});
properties.delete.open = false;
properties.delete.id = 0;
}
},
onCancel: () => {
properties.delete.open = false;
properties.delete.id = 0;
}
}}
></AlertDialog>
@@ -0,0 +1,17 @@
import { listGroups } from '$lib/api/auth/groups';
import { listUsers } from '$lib/api/auth/local';
import { SEVEN_DAYS } from '$lib/utils';
import { cachedFetch } from '$lib/utils/http';
export async function load() {
const cacheDuration = SEVEN_DAYS;
const [users, groups] = await Promise.all([
cachedFetch('users', async () => await listUsers(), cacheDuration),
cachedFetch('groups', async () => await listGroups(), cacheDuration)
]);
return {
users: users || [],
groups: groups || []
};
}
@@ -4,9 +4,9 @@ import { cachedFetch } from '$lib/utils/http';
export async function load() {
const cacheDuration = SEVEN_DAYS;
const [users] = await Promise.all([cachedFetch('users', async () => await listUsers(), 1)]);
console.log('Loaded users:', users);
const [users] = await Promise.all([
cachedFetch('users', async () => await listUsers(), cacheDuration)
]);
return {
users: users || []
@@ -0,0 +1,169 @@
<script lang="ts">
import { getFiles } from '$lib/api/system/file-explorer';
import { type FileNode } from '$lib/types/system/file-explorer';
import { mode } from 'mode-watcher';
//@ts-ignore next-line
import { Filemanager, Willow, WillowDark } from 'wx-svelte-filemanager';
interface Data {
files: FileNode[];
}
let { data }: { data: Data } = $props();
let api;
let rawData = $state(data.files as FileNode[]);
async function loadData(req: { id?: string }) {
if (req && req.id) {
const response = await getFiles(req.id);
rawData = response;
}
}
</script>
<div class="h-full w-full">
{#if mode.current === 'light'}
<Willow>
<Filemanager bind:this={api} data={rawData} onrequestdata={loadData} /></Willow
>
{:else}
<WillowDark>
<Filemanager bind:this={api} data={rawData} onrequestdata={loadData} /></WillowDark
>
{/if}
</div>
<style>
:global(.wx-willow-theme) {
--wx-theme-name: willow;
--wx-color-primary: var(--primary) !important;
--wx-fm-background: var(--background) !important;
--wx-fm-select-background: var(--muted) !important;
--wx-fm-segmented-background: var(--background) !important;
--wx-fm-box-shadow: 0px 1px 2px rgba(44, 47, 60, 0.06), 0px 3px 10px rgba(44, 47, 60, 0.12);
--wx-fm-tree: var(--background);
--wx-fm-grid-border: 1px solid var(--border);
--wx-fm-grid-header-color: #fafafb;
--wx-fm-button-font-color: #9fa1ae;
--wx-fm-toolbar-height: 56px;
}
:global(.wx-willow-dark-theme) {
--wx-theme-name: willow-dark;
color-scheme: dark;
--wx-color-primary: var(--primary) !important;
--wx-fm-background: var(--background) !important;
--wx-fm-select-background: var(--muted) !important;
--wx-fm-segmented-background: var(--background) !important;
--wx-fm-box-shadow: none;
--wx-fm-grid-border: 1px solid #384047;
--wx-fm-grid-header-color: var(--background);
--wx-fm-button-font-color: #9fa1ae;
--wx-fm-toolbar-height: 56px;
--wx-fm-select-color: rgb(80, 90, 100);
--wx-table-select-background: rgba(33, 195, 255, 0.15);
--wx-table-select-focus-background: rgba(139, 0, 0);
}
:global(.wx-willow-dark-theme .wx-filemanager .wx-breadcrumbs .wx-item) {
border: none !important;
}
:global(.wx-willow-dark-theme .wx-sidebar .wx-wrapper) {
background-color: var(--sidebar) !important;
border: 1px solid var(--sidebar) !important;
}
:global(.wx-willow-dark-theme .wx-sidebar .wx-wrapper .wx-button) {
padding: 0 !important;
border-radius: 0.3rem !important;
height: 35px;
}
:global(.wx-willow-dark-theme .wx-filemanager .wx-breadcrumbs) {
background-color: var(--background) !important;
}
:global(.wx-willow-dark-theme .wx-filemanager .wx-toolbar) {
background-color: var(--sidebar) !important;
}
:global(.wx-willow-dark-theme .wx-filemanager .wx-segmented-background) {
background-color: var(--background) !important;
}
:global(.wx-willow-dark-theme .wx-filemanager .wx-button) {
background-color: var(--primary) !important;
color: black !important;
}
:global(.wx-willow-dark-theme .wx-toolbar .wx-right) {
background-color: var(--sidebar) !important;
}
:global(.wx-willow-dark-theme .wx-toolbar .wx-modes) {
background-color: var(--sidebar) !important;
}
:global(.wx-willow-dark-theme .wx-toolbar .wx-segmented) {
background-color: var(--sidebar) !important;
}
:global(.wx-willow-dark-theme .wx-filemanager .wx-selected) {
background-color: var(--muted) !important;
color: var(--foreground) !important;
}
:global(.wx-willow-dark-theme .wx-filemanager .wx-selected:hover) {
background-color: var(--muted) !important;
color: var(--foreground) !important;
}
:global(.wx-willow-dark-theme) {
border-radius: 10px !important;
overflow: hidden !important;
border: var(--border) !important;
}
:global(.wx-willow-dark-theme .wx-filemanager .wx-text) {
background-color: var(--background) !important;
border: 1pz solid var(--border) !important;
border-radius: var(--radius) !important;
}
:global(.wx-willow-dark-theme .wx-filemanager .wx-item) {
background-color: var(--background) !important;
border: 1px solid var(--border) !important;
border-radius: var(--radius) !important;
}
:global(.wx-willow-dark-theme .wx-filemanager .wx-item:hover) {
background-color: var(--muted) !important;
border: 1px solid var(--border) !important;
border-radius: var(--radius) !important;
}
:global(.wx-willow-dark-theme .wx-filemanager .wx-table-box .wx-row) {
background-color: var(--background) !important;
border: 1px solid var(--border) !important;
}
:global(.wx-willow-dark-theme .wx-filemanager .wx-table-box .wx-row:hover) {
background-color: var(--muted) !important;
}
:global(.wx-willow-dark-theme .wx-filemanager .wx-list) {
background-color: var(--background) !important;
border: var(--border) !important;
}
:global(.wx-willow-dark-theme .wx-menu .wx-item) {
background-color: var(--muted) !important;
border: var(--border) !important;
}
:global(.wx-willow-dark-theme .wx-menu .wx-item:hover) {
background-color: var(--background) !important;
border: var(--border) !important;
}
</style>
@@ -0,0 +1,12 @@
import { getFiles } from '$lib/api/system/file-explorer';
import { SEVEN_DAYS } from '$lib/utils';
import { cachedFetch } from '$lib/utils/http';
export async function load() {
const cacheDuration = SEVEN_DAYS;
const [files] = await Promise.all([cachedFetch('fx-files', async () => await getFiles(), 1)]);
return {
files
};
}
@@ -0,0 +1,301 @@
<script lang="ts">
import { getInterfaces } from '$lib/api/network/iface';
import { getSambaConfig, updateSambaConfig } from '$lib/api/samba/config';
import SingleValueDialog from '$lib/components/custom/Dialog/SingleValue.svelte';
import TreeTable from '$lib/components/custom/TreeTable.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import type { Row } from '$lib/types/components/tree-table';
import type { Iface } from '$lib/types/network/iface';
import type { SambaConfig } from '$lib/types/samba/config';
import { handleAPIError, updateCache } from '$lib/utils/http';
import { generateNanoId } from '$lib/utils/string';
import Icon from '@iconify/svelte';
import { useQueries } from '@sveltestack/svelte-query';
import { toast } from 'svelte-sonner';
import type { CellComponent } from 'tabulator-tables';
interface Data {
sambaConfig: SambaConfig;
interfaces: Iface[];
}
let { data }: { data: Data } = $props();
const results = useQueries([
{
queryKey: ['samba-config'],
queryFn: async () => {
return await getSambaConfig();
},
refetchInterval: 1000,
keepPreviousData: true,
initialData: data.sambaConfig,
onSuccess: (data: SambaConfig) => {
updateCache('samba-config', data);
}
},
{
queryKey: ['networkInterfaces'],
queryFn: async () => {
return await getInterfaces();
},
refetchInterval: 1000,
keepPreviousData: true,
initialData: data.interfaces,
onSuccess: (data: Iface[]) => {
updateCache('networkInterfaces', data);
}
}
]);
let sambaConfig: SambaConfig = $derived($results[0].data as SambaConfig);
let interfaces: Iface[] = $derived($results[1].data as Iface[]);
let usableIfaces = $derived.by(() => {
let filtered = [];
for (const iface of interfaces) {
if (iface.groups && iface.groups.length > 0) {
if (!iface.groups.includes('tap')) {
filtered.push(iface);
}
} else {
filtered.push(iface);
}
}
return filtered;
});
$inspect(usableIfaces, 'Usable Interfaces');
let options = {
unixCharset: {
value: (() => $state.snapshot(sambaConfig.unixCharset))(),
open: false
},
workgroup: {
value: (() => $state.snapshot(sambaConfig.workgroup))(),
open: false
},
serverString: {
value: (() => $state.snapshot(sambaConfig.serverString))(),
open: false
},
interfaces: {
value: (() => $state.snapshot(sambaConfig.interfaces))(),
open: false
},
bindInterfaces: {
value: (() => ($state.snapshot(sambaConfig.bindInterfacesOnly) ? 'Yes' : 'No'))(),
open: false
}
};
let properties = $state(options);
let table = $derived({
columns: [
{ title: 'Property', field: 'property' },
{
title: 'Value',
field: 'value',
formatter: (cell: CellComponent) => {
const row = cell.getRow();
const property = row.getData().property;
const value = cell.getValue();
console.log('Property:', property);
if (property === 'Interfaces') {
const value = cell.getValue();
const arr = Array.isArray(value) ? value : value.split(',');
const formattedValue = arr.map((v: string) => {
const iface = usableIfaces.find((i) => i.name === v);
return iface ? (iface.description !== '' ? iface.description : iface.name) : v;
});
let v = '';
if (formattedValue.length > 0) {
for (const val of formattedValue) {
v += `<span class="bg-gray-100 text-gray-800 text-xs font-medium me-1 px-2.5 py-0.5 rounded-lg dark:bg-gray-700 dark:text-gray-300">${val}</span>`;
}
}
return v;
}
return value;
}
}
],
rows: [
{
id: generateNanoId(`${sambaConfig.unixCharset}`),
property: 'Unix Charset',
value: sambaConfig.unixCharset
},
{
id: generateNanoId(`${sambaConfig.workgroup}`),
property: 'Workgroup',
value: sambaConfig.workgroup
},
{
id: generateNanoId(`${sambaConfig.serverString}`),
property: 'Server String',
value: sambaConfig.serverString
},
{
id: generateNanoId(`${sambaConfig.interfaces}`),
property: 'Interfaces',
value: sambaConfig.interfaces
},
{
id: generateNanoId(`${sambaConfig.bindInterfacesOnly}`),
property: 'Bind Interfaces Only',
value: sambaConfig.bindInterfacesOnly ? 'Yes' : 'No'
}
]
});
let activeRows: Row[] | null = $state(null);
let activeRow: Row | null = $derived(activeRows ? (activeRows[0] as Row) : ({} as Row));
let query = $state('');
async function save() {
const updatedConfig: Partial<SambaConfig> = {
unixCharset: properties.unixCharset.value,
workgroup: properties.workgroup.value,
serverString: properties.serverString.value,
interfaces: properties.interfaces.value,
bindInterfacesOnly: properties.bindInterfaces.value === 'Yes'
};
const response = await updateSambaConfig(updatedConfig);
if (response.error) {
properties = options;
handleAPIError(response);
toast.error('Failed to update Samba configuration', {
position: 'bottom-center'
});
} else {
toast.success('Samba configuration updated', {
position: 'bottom-center'
});
}
}
$inspect(interfaces);
</script>
<div class="flex h-full w-full flex-col">
{#if activeRows && activeRows?.length !== 0}
<div class="flex h-10 w-full items-center gap-2 border-b p-2">
{#if activeRow && activeRow.property !== ''}
<Button
onclick={() => {
switch (activeRow.property) {
case 'Unix Charset':
properties.unixCharset.open = true;
break;
case 'Workgroup':
properties.workgroup.open = true;
break;
case 'Server String':
properties.serverString.open = true;
break;
case 'Interfaces':
properties.interfaces.open = true;
break;
case 'Bind Interfaces Only':
properties.bindInterfaces.open = true;
break;
}
}}
size="sm"
variant="outline"
class="h-6.5"
>
<div class="flex items-center">
<Icon icon="mdi:pencil" class="mr-1 h-4 w-4" />
<span>Edit {activeRow.property}</span>
</div>
</Button>
{/if}
</div>
{/if}
<div class="flex h-full flex-col overflow-hidden">
<TreeTable
data={table}
name={'hardware-tt'}
bind:parentActiveRow={activeRows}
multipleSelect={false}
bind:query
/>
</div>
</div>
<SingleValueDialog
bind:open={properties.workgroup.open}
title="Workgroup"
type="text"
placeholder="Enter Workgroup"
bind:value={properties.workgroup.value}
onSave={() => {
save();
properties.workgroup.open = false;
}}
/>
<SingleValueDialog
bind:open={properties.unixCharset.open}
title="Unix Charset"
type="text"
placeholder="Enter Unix Charset"
bind:value={properties.unixCharset.value}
onSave={() => {
save();
properties.unixCharset.open = false;
}}
/>
<SingleValueDialog
bind:open={properties.serverString.open}
title="Server String"
type="text"
placeholder="Enter Server String"
bind:value={properties.serverString.value}
onSave={() => {
save();
properties.serverString.open = false;
}}
/>
<SingleValueDialog
bind:open={properties.bindInterfaces.open}
title="Bind Interfaces Only"
type="select"
placeholder=""
bind:value={properties.bindInterfaces.value}
options={[
{ label: 'Yes', value: 'Yes' },
{ label: 'No', value: 'No' }
]}
onSave={() => {
save();
properties.bindInterfaces.open = false;
}}
/>
<SingleValueDialog
bind:open={properties.interfaces.open}
title="Interfaces"
type="combobox"
placeholder="Select Interfaces"
bind:value={properties.interfaces.value}
options={usableIfaces.map((iface) => ({
label: iface.description !== '' ? iface.description : iface.name,
value: iface.name
}))}
onSave={() => {
save();
properties.interfaces.open = false;
}}
/>
@@ -0,0 +1,17 @@
import { getInterfaces } from '$lib/api/network/iface';
import { getSambaConfig } from '$lib/api/samba/config';
import { SEVEN_DAYS } from '$lib/utils';
import { cachedFetch } from '$lib/utils/http';
export async function load() {
const cacheDuration = SEVEN_DAYS;
const [interfaces, sambaConfig] = await Promise.all([
cachedFetch('networkInterfaces', async () => await getInterfaces(), cacheDuration),
cachedFetch('samba-config', async () => await getSambaConfig(), cacheDuration)
]);
return {
interfaces,
sambaConfig
};
}
@@ -0,0 +1,244 @@
<script lang="ts">
import { listGroups } from '$lib/api/auth/groups';
import { deleteSambaShare, getSambaShares } from '$lib/api/samba/share';
import { getDatasets } from '$lib/api/zfs/datasets';
import AlertDialog from '$lib/components/custom/Dialog/Alert.svelte';
import Create from '$lib/components/custom/Samba/Create.svelte';
import TreeTable from '$lib/components/custom/TreeTable.svelte';
import Search from '$lib/components/custom/TreeTable/Search.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import type { Group } from '$lib/types/auth';
import type { Column, Row } from '$lib/types/components/tree-table';
import type { SambaShare } from '$lib/types/samba/shares';
import type { Dataset } from '$lib/types/zfs/dataset';
import { handleAPIError, updateCache } from '$lib/utils/http';
import { convertDbTime } from '$lib/utils/time';
import Icon from '@iconify/svelte';
import { useQueries } from '@sveltestack/svelte-query';
import { toast } from 'svelte-sonner';
import type { CellComponent } from 'tabulator-tables';
interface Data {
shares: SambaShare[];
datasets: Dataset[];
groups: Group[];
}
let { data }: { data: Data } = $props();
const results = useQueries([
{
queryKey: ['datasetList'],
queryFn: async () => {
return await getDatasets();
},
refetchInterval: 1000,
keepPreviousData: false,
initialData: data.datasets,
onSuccess: (data: Dataset[]) => {
updateCache('datasets', data);
}
},
{
queryKey: ['samba-shares'],
queryFn: async () => {
return await getSambaShares();
},
refetchInterval: 1000,
keepPreviousData: false,
initialData: data.shares,
onSuccess: (data: SambaShare[]) => {
updateCache('samba-shares', data);
}
},
{
queryKey: ['groups'],
queryFn: async () => {
return (await listGroups()) as Group[];
},
refetchInterval: 1000,
keepPreviousData: true,
initialData: data.groups,
onSuccess: (data: Group[]) => {
updateCache('groups', data);
}
}
]);
let datasets: Dataset[] = $derived($results[0].data as Dataset[]);
let shares: SambaShare[] = $derived($results[1].data as SambaShare[]);
let groups: Group[] = $derived($results[2].data as Group[]);
let activeRows: Row[] | null = $state(null);
let activeRow: Row | null = $derived(activeRows ? (activeRows[0] as Row) : ({} as Row));
let options = {
create: {
open: false
},
delete: {
open: false
}
};
let properties = $state(options);
let query = $state('');
function generateTableData(
shares: SambaShare[],
datasets: Dataset[],
groups: Group[]
): {
rows: Row[];
columns: Column[];
} {
function groupFormatter(cell: CellComponent) {
const groups = cell.getValue() as Group[];
if (!groups?.length) return '-';
const shown = groups
.slice(0, 5)
.map((g) => g.name)
.join(', ');
return groups.length > 5 ? `${shown}, …` : shown;
}
const rows: Row[] = [];
const columns: Column[] = [
{
field: 'id',
title: 'ID',
visible: false
},
{
field: 'name',
title: 'Name'
},
{
field: 'mountpoint',
title: 'Mount Point'
},
{
field: 'readOnlyGroups',
title: 'Read-Only Groups',
formatter: groupFormatter
},
{
field: 'writeableGroups',
title: 'Writeable Groups',
formatter: groupFormatter
},
{
field: 'created',
title: 'Created At',
formatter: (cell: CellComponent) => {
const value = cell.getValue();
return convertDbTime(value);
}
}
];
for (const share of shares) {
const dataset = datasets.find((ds) => ds.properties.guid === share.dataset);
const row: Row = {
id: share.id,
name: share.name,
mountpoint: dataset ? dataset.properties.mountpoint : '-',
readOnlyGroups: share.readOnlyGroups || [],
writeableGroups: share.writeableGroups || [],
created: share.createdAt
};
rows.push(row);
}
return {
rows: rows,
columns: columns
};
}
let tableData = $derived(generateTableData(shares, datasets, groups));
</script>
{#snippet button(type: string)}
{#if activeRows !== null && activeRows.length === 1}
{#if type === 'delete'}
<Button
onclick={() => {
properties.delete.open = true;
}}
size="sm"
variant="outline"
class="h-6.5"
>
<div class="flex items-center">
<Icon icon="mdi:delete" class="mr-1 h-4 w-4" />
<span>Delete Share</span>
</div>
</Button>
{/if}
{/if}
{/snippet}
<div class="flex h-full w-full flex-col">
<div class="flex h-10 w-full items-center gap-2 border-b p-2">
<Search bind:query />
<Button
onclick={() => {
properties.create.open = true;
}}
size="sm"
class="h-6"
>
<div class="flex items-center">
<Icon icon="gg:add" class="mr-1 h-4 w-4" />
<span>New</span>
</div>
</Button>
{@render button('delete')}
</div>
<TreeTable
data={tableData}
name={'shares-tt'}
bind:parentActiveRow={activeRows}
multipleSelect={true}
bind:query
/>
</div>
{#if properties.create.open}
<Create bind:open={properties.create.open} {shares} {datasets} {groups} />
{/if}
<AlertDialog
open={properties.delete.open}
names={{ parent: 'Samba share', element: activeRow ? activeRow.name : '' }}
actions={{
onConfirm: async () => {
if (activeRow) {
const response = await deleteSambaShare(Number(activeRow.id));
if (response.status === 'error') {
handleAPIError(response);
toast.error('Failed to delete Samba share', {
position: 'bottom-center'
});
return;
}
toast.success('Samba share deleted', {
position: 'bottom-center'
});
properties.delete.open = false;
activeRows = null;
}
},
onCancel: () => {
properties.delete.open = false;
}
}}
></AlertDialog>
@@ -0,0 +1,22 @@
import { listGroups } from '$lib/api/auth/groups';
import { getInterfaces } from '$lib/api/network/iface';
import { getSambaConfig } from '$lib/api/samba/config';
import { getSambaShares } from '$lib/api/samba/share';
import { getDatasets } from '$lib/api/zfs/datasets';
import { SEVEN_DAYS } from '$lib/utils';
import { cachedFetch } from '$lib/utils/http';
export async function load() {
const cacheDuration = SEVEN_DAYS;
const [datasets, shares, groups] = await Promise.all([
cachedFetch('datasets', async () => await getDatasets(), cacheDuration),
cachedFetch('samba-shares', async () => await getSambaShares(), cacheDuration),
cachedFetch('groups', async () => await listGroups(), cacheDuration)
]);
return {
datasets,
shares,
groups
};
}
@@ -20,6 +20,7 @@
import Icon from '@iconify/svelte';
import { useQueries } from '@sveltestack/svelte-query';
import type { Chart } from 'chart.js';
import { untrack } from 'svelte';
interface Data {
pools: Zpool[];
@@ -133,28 +134,22 @@
}
}
let comboBoxes = $derived({
let comboBoxes = $state({
poolUsage: {
open: false,
value: pools[0]?.name || '',
data: pools.map((pool) => ({
value: pool.name,
label: pool.name
}))
value: '',
data: [] as { value: string; label: string }[]
},
datasetCompression: {
open: false,
value: pools[0]?.name || '',
data: pools.map((pool) => ({
value: pool.name,
label: pool.name
}))
value: '',
data: [] as { value: string; label: string }[]
},
poolStats: {
interval: {
open: false,
value: poolStatsInterval || '1',
data: poolStats?.intervalMap
value: '1',
data: [] as { value: string; label: string }[]
},
statType: {
open: false,
@@ -169,6 +164,30 @@
}
});
$effect(() => {
if (pools) {
comboBoxes.poolUsage.value = pools[0]?.name || '';
comboBoxes.poolUsage.data = pools.map((pool) => ({
value: pool.name,
label: pool.name
}));
comboBoxes.datasetCompression.value = pools[0]?.name || '';
comboBoxes.datasetCompression.data = pools.map((pool) => ({
value: pool.name,
label: pool.name
}));
}
if (poolStats?.intervalMap) {
comboBoxes.poolStats.interval.data = poolStats.intervalMap;
}
if (poolStatsInterval) {
comboBoxes.poolStats.interval.value = poolStatsInterval;
}
});
let pieCharts = $derived.by(() => {
return {
poolUsage: {
@@ -7,7 +7,6 @@
} from '$lib/api/zfs/datasets';
import { getPools } from '$lib/api/zfs/pool';
import AlertDialogModal from '$lib/components/custom/Dialog/Alert.svelte';
import FileSystem from '$lib/components/custom/FileSystem.svelte';
import TreeTable from '$lib/components/custom/TreeTable.svelte';
import Search from '$lib/components/custom/TreeTable/Search.svelte';
import CreateFS from '$lib/components/custom/ZFS/datasets/fs/Create.svelte';
@@ -154,9 +153,6 @@
},
delete: {
open: false
},
fileExplorer: {
open: false
}
},
bulk: {
@@ -315,43 +311,21 @@
</div>
</Button>
<Button
onclick={() => {
if (modals.fs.fileExplorer.open) {
activeRows = null;
}
modals.fs.fileExplorer.open = !modals.fs.fileExplorer.open;
}}
size="sm"
class="h-6"
>
<Icon icon="eos-icons:file-system" class="mr-1 h-4 w-4" />
<span>
{modals.fs.fileExplorer.open ? 'Close Explorer ' : 'Open Explorer'}
</span>
</Button>
{#if !modals.fs.fileExplorer.open}
{@render button('create-snapshot')}
{@render button('rollback-snapshot')}
{@render button('delete-snapshot')}
{@render button('edit-filesystem')}
{@render button('delete-filesystem')}
{@render button('bulk-delete')}
{/if}
{@render button('create-snapshot')}
{@render button('rollback-snapshot')}
{@render button('delete-snapshot')}
{@render button('edit-filesystem')}
{@render button('delete-filesystem')}
{@render button('bulk-delete')}
</div>
{#if modals.fs.fileExplorer.open}
<FileSystem />
{:else}
<TreeTable
data={tableData}
name={tableName}
bind:parentActiveRow={activeRows}
multipleSelect={true}
bind:query
/>
{/if}
<TreeTable
data={tableData}
name={tableName}
bind:parentActiveRow={activeRows}
multipleSelect={true}
bind:query
/>
</div>
<!-- Create Snapshot -->