mirror of
https://github.com/AlchemillaHQ/Sylve.git
synced 2026-06-14 00:46:34 +03:00
feat: samba, pre-startup checks, auth: users/groups, storage: file manager
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+28
@@ -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
|
||||
@@ -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
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
@@ -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 || []
|
||||
};
|
||||
}
|
||||
+3
-3
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user