npm: cleanup, jails: fix stats/graphs

This commit is contained in:
hayzamjs
2025-12-29 23:15:25 +05:30
parent 3f7a8d2878
commit 7d9a86881c
57 changed files with 1134 additions and 2752 deletions
+17 -19
View File
@@ -44,7 +44,7 @@ These only apply to the development version of Sylve, the production version wil
# Runtime Requirements
Sylve is designed to run on FreeBSD 14.3 or later, and it is recommended to use the latest version of FreeBSD for the best experience.
Sylve is designed to run on FreeBSD 15.0 or later, and it is recommended to use the latest version of FreeBSD for the best experience.
## Dependencies
@@ -54,11 +54,11 @@ Running Sylve is pretty easy, but `sylve` depends on some packages that you can
| -------------- | ------------ | -------- | -------- | ------------------------------------------------ |
| smartmontools | 7.4_2 | No | No | Disk health monitoring |
| tmux | 3.2 | No | No | Terminal multiplexer, used for the (web) console |
| libvirt | 11.1.0 | No | No | Virtualization API, used for Bhyve |
| bhyve-firmware | 1.0_2 | No | No | Collection of Firmware for bhyve |
| samba419 | 4.19.9_9 | No | No | SMB file sharing service |
| jansson | 2.14.1 | No | No | JSON library for C |
| swtpm | 0.10.1 | No | No | TPM emulator for VMs |
| libvirt | 11.7.0 | No | Yes | Virtualization API, used for Bhyve |
| bhyve-firmware | 1.0_2 | No | Yes | Collection of Firmware for bhyve |
| samba4XX | 4.XX | No | Yes | SMB file sharing service |
| swtpm | 0.10.1 | No | Yes | TPM emulator for VMs |
| jansson | 2.14.1 | No | No | C library for JSON parsing |
We also need to enable some services in order to run Sylve, you can drop these into `/etc/rc.conf` if you don't have it already:
@@ -66,15 +66,12 @@ We also need to enable some services in order to run Sylve, you can drop these i
sysrc ntpd_enable="YES" # Optional
sysrc ntpd_sync_on_start="YES" # Optional
sysrc zfs_enable="YES"
sysrc libvirtd_enable="YES"
sysrc dnsmasq_enable="YES"
sysrc rpcbind_enable="YES"
sysrc nfs_server_enable="YES"
sysrc mountd_enable="YES"
sysrc samba_server_enable="YES"
sysrc libvirtd_enable="YES" # Optional
sysrc dnsmasq_enable="YES" # Optional
sysrc samba_server_enable="YES" # Optional
```
Enabling `rctl` is required. Do this by adding the following line to `/boot/loader.conf`:
Enabling `rctl` is required if you're using Jails with Sylve. Do this by adding the following line to `/boot/loader.conf`:
```sh
kern.racct.enable=1
@@ -91,7 +88,7 @@ kern.racct.enable=1
Install required packages.
```sh
pkg install git node20 npm-node20 go tmux libvirt bhyve-firmware smartmontools tmux samba419 jansson swtpm
pkg install git node22 npm-node22 go tmux libvirt bhyve-firmware smartmontools tmux samba422 jansson swtpm
```
Clone the repo and build Sylve.
@@ -110,12 +107,13 @@ cp -rf ../config.example.json config.json # Edit the config.json file to your li
./sylve
```
> In order to download an ISO go to:
> Datacenter > Your Host > Utilities > Downloader > + NEW
# Notes
> [!IMPORTANT]
> Bhyve does not support boot orders, so you cannot add installation media after creating the VM.
> So make sure to add an installation media when creating a new VM.
1. Bhyve doesn't support bootorders yet
Since Bhyve doesn't support bootorders yet, you'll need to configure the order using the UEFI boot menu. You can download
2. ARM64 support is still pending for Libvirt so the support is not there yet, for everything else it should just work out of the box.
# Contributing
-2
View File
@@ -114,8 +114,6 @@ func SetupDatabase(cfg *internal.SylveConfig, isTest bool) *gorm.DB {
&clusterModels.Cluster{},
&clusterModels.ClusterNode{},
&clusterModels.ClusterS3Config{},
&clusterModels.ClusterDirectoryConfig{},
&clusterModels.ClusterOption{},
&clusterModels.ClusterNote{},
)
+2 -82
View File
@@ -75,10 +75,8 @@ func (f *FSMDispatcher) Apply(l *raft.Log) any {
// ClusterSnapshot represents the state that will be snapshotted/restored
type ClusterSnapshot struct {
Notes []ClusterNote `json:"notes"`
Options []ClusterOption `json:"options"`
S3Configs []ClusterS3Config `json:"s3Configs"`
DirectoryConfigs []ClusterDirectoryConfig `json:"directoryConfigs"`
Notes []ClusterNote `json:"notes"`
Options []ClusterOption `json:"options"`
// We can add more tables here as needed
}
@@ -92,12 +90,6 @@ func (f *FSMDispatcher) Snapshot() (raft.FSMSnapshot, error) {
if err := f.DB.Find(&snap.Options).Error; err != nil {
return nil, err
}
if err := f.DB.Find(&snap.S3Configs).Error; err != nil {
return nil, err
}
if err := f.DB.Find(&snap.DirectoryConfigs).Error; err != nil {
return nil, err
}
return &snap, nil
}
@@ -118,8 +110,6 @@ func (f *FSMDispatcher) Restore(rc io.ReadCloser) error {
sets := []restoreSet{
{"cluster_notes", snap.Notes, 500},
{"cluster_options", snap.Options, 100},
{"cluster_s3_configs", snap.S3Configs, 100},
{"cluster_directory_configs", snap.DirectoryConfigs, 100},
// We can add more tables here as needed
}
@@ -183,76 +173,6 @@ func RegisterDefaultHandlers(fsm *FSMDispatcher) {
}
})
fsm.Register("s3Configs", func(db *gorm.DB, action string, raw json.RawMessage) error {
var s3Config ClusterS3Config
switch action {
case "create":
if err := json.Unmarshal(raw, &s3Config); err != nil {
return err
}
return upsertS3Cfg(db, &s3Config)
case "update":
if err := json.Unmarshal(raw, &s3Config); err != nil {
return err
}
return db.Model(&ClusterS3Config{}).
Where("id = ?", s3Config.ID).
Updates(s3Config).Error
case "delete":
var payload struct{ ID int }
if err := json.Unmarshal(raw, &payload); err != nil {
return err
}
return db.Delete(&ClusterS3Config{}, payload.ID).Error
case "bulk_delete":
var payload struct{ IDs []int }
if err := json.Unmarshal(raw, &payload); err != nil {
return err
}
if len(payload.IDs) > 0 {
return db.Delete(&ClusterS3Config{}, payload.IDs).Error
}
return nil
default:
return nil
}
})
fsm.Register("directoryConfigs", func(db *gorm.DB, action string, raw json.RawMessage) error {
var dirConfig ClusterDirectoryConfig
switch action {
case "create":
if err := json.Unmarshal(raw, &dirConfig); err != nil {
return err
}
return upsertDirCfg(db, &dirConfig)
case "update":
if err := json.Unmarshal(raw, &dirConfig); err != nil {
return err
}
return db.Model(&ClusterDirectoryConfig{}).
Where("id = ?", dirConfig.ID).
Updates(dirConfig).Error
case "delete":
var payload struct{ ID int }
if err := json.Unmarshal(raw, &payload); err != nil {
return err
}
return db.Delete(&ClusterDirectoryConfig{}, payload.ID).Error
case "bulk_delete":
var payload struct{ IDs []int }
if err := json.Unmarshal(raw, &payload); err != nil {
return err
}
if len(payload.IDs) > 0 {
return db.Delete(&ClusterDirectoryConfig{}, payload.IDs).Error
}
return nil
default:
return nil
}
})
fsm.Register("options", func(db *gorm.DB, action string, raw json.RawMessage) error {
var opt ClusterOption
if err := json.Unmarshal(raw, &opt); err != nil {
-61
View File
@@ -7,64 +7,3 @@
// under sponsorship from the FreeBSD Foundation.
package clusterModels
import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type ClusterS3Config struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"uniqueIndex" json:"name"`
Endpoint string `json:"endpoint"`
Region string `json:"region"`
Bucket string `json:"bucket"`
AccessKey string `json:"accessKey"`
SecretKey string `json:"secretKey"`
}
type ClusterDirectoryConfig struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"uniqueIndex" json:"name"`
Path string `json:"path"`
}
func upsertS3Cfg(db *gorm.DB, n *ClusterS3Config) error {
return db.Transaction(func(tx *gorm.DB) error {
if n.ID == 0 {
var next uint
if err := tx.
Table("cluster_s3_configs").
Select("COALESCE(MAX(id), 0) + 1").
Scan(&next).Error; err != nil {
return err
}
n.ID = next
}
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.AssignmentColumns([]string{"name", "endpoint", "region", "bucket", "access_key", "secret_key"}),
}).Create(n).Error
})
}
func upsertDirCfg(db *gorm.DB, n *ClusterDirectoryConfig) error {
return db.Transaction(func(tx *gorm.DB) error {
if n.ID == 0 {
var next uint
if err := tx.
Table("cluster_directory_configs").
Select("COALESCE(MAX(id), 0) + 1").
Scan(&next).Error; err != nil {
return err
}
n.ID = next
}
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.AssignmentColumns([]string{"name", "path"}),
}).Create(n).Error
})
}
+8
View File
@@ -80,6 +80,14 @@ type JailStats struct {
CreatedAt time.Time `json:"createdAt" gorm:"autoCreateTime"`
}
func (j JailStats) GetID() uint {
return j.ID
}
func (j JailStats) GetCreatedAt() time.Time {
return j.CreatedAt
}
type Storage struct {
ID uint `gorm:"primaryKey" json:"id"`
JailID uint `json:"jid" gorm:"column:jid;index"`
-38
View File
@@ -7,41 +7,3 @@
// under sponsorship from the FreeBSD Foundation.
package clusterHandlers
import (
"github.com/alchemillahq/sylve/internal"
clusterServiceInterfaces "github.com/alchemillahq/sylve/internal/interfaces/services/cluster"
"github.com/alchemillahq/sylve/internal/services/cluster"
"github.com/gin-gonic/gin"
)
// @Summary Get Cluster Storages
// @Description Get all storage backends configured in the cluster (S3, etc.)
// @Tags Cluster
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} internal.APIResponse[clusterServiceInterfaces.Storages] "Success"
// @Failure 500 {object} internal.APIResponse[any] "Internal Server Error"
// @Router /cluster/storage [get]
func Storages(cS *cluster.Service) gin.HandlerFunc {
return func(c *gin.Context) {
storages, err := cS.ListStorages()
if err != nil {
c.JSON(500, internal.APIResponse[any]{
Status: "error",
Message: "list_storages_failed",
Error: err.Error(),
Data: nil,
})
return
}
c.JSON(200, internal.APIResponse[clusterServiceInterfaces.Storages]{
Status: "success",
Message: "storages_listed",
Error: "",
Data: storages,
})
}
}
-167
View File
@@ -7,170 +7,3 @@
// under sponsorship from the FreeBSD Foundation.
package clusterHandlers
import (
"strconv"
"github.com/alchemillahq/sylve/internal"
"github.com/alchemillahq/sylve/internal/services/cluster"
"github.com/gin-gonic/gin"
"github.com/hashicorp/raft"
)
type CreateDirStorageRequest struct {
Name string `json:"name" binding:"required,min=3"`
Path string `json:"path" binding:"required"`
}
// @Summary Create a Directory Storage
// @Description Create a new Directory storage configuration in the cluster
// @Tags Cluster
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body CreateDirStorageRequest true "Create Directory Storage Request"
// @Success 200 {object} internal.APIResponse[any] "Success"
// @Failure 400 {object} internal.APIResponse[any] "Bad Request"
// @Failure 409 {object} internal.APIResponse[any] "Conflict"
// @Failure 500 {object} internal.APIResponse[any] "Internal Server Error"
// @Router /cluster/storage/directory [post]
func CreateDirStorage(cS *cluster.Service) gin.HandlerFunc {
return func(c *gin.Context) {
if cS.Raft == nil {
var req CreateDirStorageRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, internal.APIResponse[any]{
Status: "error",
Message: "invalid_request",
Error: err.Error(),
Data: nil,
})
return
}
if err := cS.ProposeDirectoryConfig(
req.Name, req.Path, true,
); err != nil {
c.JSON(500, internal.APIResponse[any]{
Status: "error",
Message: "storage_create_failed",
Error: err.Error(),
Data: nil,
})
return
}
c.JSON(200, internal.APIResponse[any]{
Status: "success",
Message: "storage_created",
Error: "",
Data: nil,
})
return
}
if cS.Raft.State() != raft.Leader {
forwardToLeader(c, cS)
return
}
var req CreateDirStorageRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, internal.APIResponse[any]{
Status: "error",
Message: "invalid_request",
Error: err.Error(),
Data: nil,
})
return
}
if err := cS.ProposeDirectoryConfig(
req.Name, req.Path, false,
); err != nil {
c.JSON(500, internal.APIResponse[any]{
Status: "error",
Message: "storage_create_failed",
Error: err.Error(),
Data: nil,
})
return
}
c.JSON(200, internal.APIResponse[any]{
Status: "success",
Message: "storage_created",
Error: "",
Data: nil,
})
}
}
// @Summary Delete a Directory Storage
// @Description Delete a Directory storage configuration from the cluster by ID
// @Tags Cluster
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "Storage ID"
// @Success 200 {object} internal.APIResponse[any] "Success"
// @Failure 400 {object} internal.APIResponse[any] "Bad Request"
// @Failure 500 {object} internal.APIResponse[any] "Internal Server Error"
// @Router /cluster/storage/directory/{id} [delete]
func DeleteDirStorage(cS *cluster.Service) gin.HandlerFunc {
return func(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil || id <= 0 {
c.JSON(400, internal.APIResponse[any]{
Status: "error",
Message: "invalid_id",
Error: "id must be a positive integer",
Data: nil,
})
return
}
if cS.Raft == nil {
if err := cS.ProposeDirectoryConfigDelete(uint(id), true); err != nil {
c.JSON(500, internal.APIResponse[any]{
Status: "error",
Message: "storage_delete_failed",
Error: err.Error(),
Data: nil,
})
return
}
c.JSON(200, internal.APIResponse[any]{
Status: "success",
Message: "storage_deleted",
Error: "",
Data: nil,
})
return
}
if cS.Raft.State() != raft.Leader {
forwardToLeader(c, cS)
return
}
if err := cS.ProposeDirectoryConfigDelete(uint(id), false); err != nil {
c.JSON(500, internal.APIResponse[any]{
Status: "error",
Message: "storage_delete_failed",
Error: err.Error(),
Data: nil,
})
return
}
c.JSON(200, internal.APIResponse[any]{
Status: "success",
Message: "storage_deleted",
Error: "",
Data: nil,
})
}
}
-172
View File
@@ -7,175 +7,3 @@
// under sponsorship from the FreeBSD Foundation.
package clusterHandlers
import (
"strconv"
"github.com/alchemillahq/sylve/internal"
"github.com/alchemillahq/sylve/internal/services/cluster"
"github.com/gin-gonic/gin"
"github.com/hashicorp/raft"
)
type CreateS3StorageRequest struct {
Name string `json:"name" binding:"required,min=3"`
Endpoint string `json:"endpoint" binding:"required"`
Region string `json:"region" binding:"required"`
Bucket string `json:"bucket" binding:"required"`
AccessKey string `json:"accessKey" binding:"required"`
SecretKey string `json:"secretKey" binding:"required"`
}
// @Summary Create an S3 Storage
// @Description Create a new S3 storage configuration in the cluster
// @Tags Cluster
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body CreateS3StorageRequest true "Create S3 Storage Request"
// @Success 200 {object} internal.APIResponse[any] "Success"
// @Failure 400 {object} internal.APIResponse[any] "Bad Request"
// @Failure 409 {object} internal.APIResponse[any] "Conflict"
// @Failure 500 {object} internal.APIResponse[any] "Internal Server Error"
// @Router /cluster/storage/s3 [post]
func CreateS3Storage(cS *cluster.Service) gin.HandlerFunc {
return func(c *gin.Context) {
if cS.Raft == nil {
var req CreateS3StorageRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, internal.APIResponse[any]{
Status: "error",
Message: "invalid_request",
Error: err.Error(),
Data: nil,
})
return
}
if err := cS.ProposeS3Config(
req.Name, req.Endpoint, req.Region, req.Bucket, req.AccessKey, req.SecretKey, true,
); err != nil {
c.JSON(500, internal.APIResponse[any]{
Status: "error",
Message: "storage_create_failed",
Error: err.Error(),
Data: nil,
})
return
}
c.JSON(200, internal.APIResponse[any]{
Status: "success",
Message: "storage_created",
Error: "",
Data: nil,
})
return
}
if cS.Raft.State() != raft.Leader {
forwardToLeader(c, cS)
return
}
var req CreateS3StorageRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, internal.APIResponse[any]{
Status: "error",
Message: "invalid_request",
Error: err.Error(),
Data: nil,
})
return
}
if err := cS.ProposeS3Config(
req.Name, req.Endpoint, req.Region, req.Bucket, req.AccessKey, req.SecretKey,
false,
); err != nil {
c.JSON(500, internal.APIResponse[any]{
Status: "error",
Message: "storage_create_failed",
Error: err.Error(),
Data: nil,
})
return
}
c.JSON(200, internal.APIResponse[any]{
Status: "success",
Message: "storage_created",
Error: "",
Data: nil,
})
}
}
// @Summary Delete an S3 Storage
// @Description Delete an S3 storage configuration from the cluster by ID
// @Tags Cluster
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "Storage ID"
// @Success 200 {object} internal.APIResponse[any] "Success"
// @Failure 400 {object} internal.APIResponse[any] "Bad Request"
// @Failure 500 {object} internal.APIResponse[any] "Internal Server Error"
// @Router /cluster/storage/s3/{id} [delete]
func DeleteS3Storage(cS *cluster.Service) gin.HandlerFunc {
return func(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil || id <= 0 {
c.JSON(400, internal.APIResponse[any]{
Status: "error",
Message: "invalid_id",
Error: "id must be a positive integer",
Data: nil,
})
return
}
if cS.Raft == nil {
if err := cS.ProposeS3ConfigDelete(uint(id), true); err != nil {
c.JSON(500, internal.APIResponse[any]{
Status: "error",
Message: "storage_delete_failed",
Error: err.Error(),
Data: nil,
})
return
}
c.JSON(200, internal.APIResponse[any]{
Status: "success",
Message: "storage_deleted",
Error: "",
Data: nil,
})
return
}
if cS.Raft.State() != raft.Leader {
forwardToLeader(c, cS)
return
}
if err := cS.ProposeS3ConfigDelete(uint(id), false); err != nil {
c.JSON(500, internal.APIResponse[any]{
Status: "error",
Message: "storage_delete_failed",
Error: err.Error(),
Data: nil,
})
return
}
c.JSON(200, internal.APIResponse[any]{
Status: "success",
Message: "storage_deleted",
Error: "",
Data: nil,
})
}
}
+9 -11
View File
@@ -12,6 +12,7 @@ import (
"strconv"
"github.com/alchemillahq/sylve/internal"
"github.com/alchemillahq/sylve/internal/db"
jailModels "github.com/alchemillahq/sylve/internal/db/models/jail"
jailServiceInterfaces "github.com/alchemillahq/sylve/internal/interfaces/services/jail"
"github.com/alchemillahq/sylve/internal/services/jail"
@@ -165,7 +166,7 @@ func GetJailLogs(jailService *jail.Service) gin.HandlerFunc {
}
// @Summary Get Jail Statistics
// @Description Retrieve statistics for a specific jail
// @Description Retrieve statistics for a jail by CTID and GFS step
// @Tags Jail
// @Accept json
// @Produce json
@@ -173,26 +174,23 @@ func GetJailLogs(jailService *jail.Service) gin.HandlerFunc {
// @Success 200 {object} internal.APIResponse[[]jailModels.JailStats] "Success"
// @Failure 400 {object} internal.APIResponse[any] "Bad Request"
// @Failure 500 {object} internal.APIResponse[any] "Internal Server Error"
// @Router /jail/stats/:ctId/:limit [get]
// @Router /jail/stats/:ctid/:step [get]
func GetJailStats(jailService *jail.Service) gin.HandlerFunc {
return func(c *gin.Context) {
ctId := c.Param("ctId")
limit := c.Param("limit")
if ctId == "" || limit == "" {
ctId, _ := utils.ParamUint(c, "ctId")
step := c.Param("step")
if ctId == 0 || step == "" {
c.JSON(400, internal.APIResponse[any]{
Status: "error",
Message: "invalid_request",
Data: nil,
Error: "ctId and limit are required",
Error: "ctid and step are required",
})
return
}
stats, err := jailService.GetJailUsage(
uint(utils.StringToUint64(ctId)),
int(utils.StringToUint64(limit)),
)
stats, err := jailService.GetJailUsage(ctId, db.GFSStep(step))
if err != nil {
c.JSON(500, internal.APIResponse[any]{
Status: "error",
+1 -12
View File
@@ -327,7 +327,7 @@ func RegisterRoutes(r *gin.Engine,
jail.GET("/:id/logs", jailHandlers.GetJailLogs(jailService))
jail.PUT("/memory", jailHandlers.UpdateJailMemory(jailService))
jail.PUT("/cpu", jailHandlers.UpdateJailCPU(jailService))
jail.GET("/stats/:ctId/:limit", jailHandlers.GetJailStats(jailService))
jail.GET("/stats/:ctId/:step", jailHandlers.GetJailStats(jailService))
jail.PUT("/resource-limits/:ctId", jailHandlers.UpdateResourceLimits(jailService))
jail.POST("", jailHandlers.CreateJail(jailService))
@@ -408,17 +408,6 @@ func RegisterRoutes(r *gin.Engine,
clusterNotes.DELETE("/:id", clusterHandlers.DeleteNote(clusterService))
}
clusterStorages := cluster.Group("/storage")
{
clusterStorages.GET("", clusterHandlers.Storages(clusterService))
clusterStorages.POST("/s3", clusterHandlers.CreateS3Storage(clusterService))
clusterStorages.DELETE("/s3/:id", clusterHandlers.DeleteS3Storage(clusterService))
clusterStorages.POST("/directory", clusterHandlers.CreateDirStorage(clusterService))
clusterStorages.DELETE("/directory/:id", clusterHandlers.DeleteDirStorage(clusterService))
}
vnc := api.Group("/vnc")
vnc.Use(EnsureCorrectHost(db))
vnc.Use(middleware.EnsureAuthenticated(authService))
@@ -8,9 +8,4 @@
package clusterServiceInterfaces
import clusterModels "github.com/alchemillahq/sylve/internal/db/models/cluster"
type Storages struct {
S3 []clusterModels.ClusterS3Config `json:"s3"`
Directories []clusterModels.ClusterDirectoryConfig `json:"directories"`
}
type Storages struct{}
+1 -1
View File
@@ -117,6 +117,6 @@ type EditJailNetworkRequest struct {
type JailServiceInterface interface {
StoreJailUsage() error
PruneOrphanedJailStats([]uint) error
PruneOrphanedJailStats() error
WatchNetworkObjectChanges() error
}
-56
View File
@@ -172,62 +172,6 @@ func (s *Service) backfillPreClusterState() error {
}
}
{
var s3cfgs []clusterModels.ClusterS3Config
if err := s.DB.Order("id ASC").Find(&s3cfgs).Error; err != nil {
return fmt.Errorf("scan_existing_s3cfgs: %w", err)
}
for _, c := range s3cfgs {
payloadStruct := struct {
ID uint `json:"id"`
Name string `json:"name"`
Endpoint string `json:"endpoint"`
Region string `json:"region"`
Bucket string `json:"bucket"`
AccessKey string `json:"accessKey"`
SecretKey string `json:"secretKey"`
}{
ID: c.ID,
Name: c.Name,
Endpoint: c.Endpoint,
Region: c.Region,
Bucket: c.Bucket,
AccessKey: c.AccessKey,
SecretKey: c.SecretKey,
}
data, _ := json.Marshal(payloadStruct)
cmd := clusterModels.Command{Type: "s3Configs", Action: "create", Data: data}
if err := s.Raft.Apply(utils.MustJSON(cmd), 5*time.Second).Error(); err != nil {
return fmt.Errorf("apply_synth_create_s3cfg id=%d: %w", c.ID, err)
}
}
}
{
var dircfgs []clusterModels.ClusterDirectoryConfig
if err := s.DB.Order("id ASC").Find(&dircfgs).Error; err != nil {
return fmt.Errorf("scan_existing_dircfgs: %w", err)
}
for _, c := range dircfgs {
payloadStruct := struct {
ID uint `json:"id"`
Path string `json:"path"`
}{
ID: c.ID,
Path: c.Path,
}
data, _ := json.Marshal(payloadStruct)
cmd := clusterModels.Command{Type: "directoryConfigs", Action: "create", Data: data}
if err := s.Raft.Apply(utils.MustJSON(cmd), 5*time.Second).Error(); err != nil {
return fmt.Errorf("apply_synth_create_dircfg id=%d: %w", c.ID, err)
}
}
}
if err := s.Raft.Barrier(10 * time.Second).Error(); err != nil {
return fmt.Errorf("barrier_after_backfill: %w", err)
}
-147
View File
@@ -7,150 +7,3 @@
// under sponsorship from the FreeBSD Foundation.
package cluster
import (
"encoding/json"
"fmt"
"time"
clusterModels "github.com/alchemillahq/sylve/internal/db/models/cluster"
clusterServiceInterfaces "github.com/alchemillahq/sylve/internal/interfaces/services/cluster"
"github.com/alchemillahq/sylve/pkg/s3"
)
func (s *Service) ListStorages() (clusterServiceInterfaces.Storages, error) {
var s3 []clusterModels.ClusterS3Config
var directories []clusterModels.ClusterDirectoryConfig
err := s.DB.Order("id ASC").Find(&s3).Error
if err != nil {
return clusterServiceInterfaces.Storages{}, err
}
err = s.DB.Order("id ASC").Find(&directories).Error
if err != nil {
return clusterServiceInterfaces.Storages{}, err
}
return clusterServiceInterfaces.Storages{S3: s3, Directories: directories}, nil
}
func (s *Service) ProposeS3Config(name,
endpoint,
region,
bucket,
accessKey,
secretKey string,
bypassRaft bool) error {
err := s3.ValidateConfig(endpoint, region, bucket, accessKey, secretKey)
if err != nil {
return fmt.Errorf("s3_config_invalid: %w", err)
}
if bypassRaft {
s3 := clusterModels.ClusterS3Config{
Name: name,
Endpoint: endpoint,
Region: region,
Bucket: bucket,
AccessKey: accessKey,
SecretKey: secretKey,
}
err := s.DB.Create(&s3).Error
if err != nil {
return err
}
return nil
}
if s.Raft == nil {
return fmt.Errorf("raft_not_initialized")
}
payloadStruct := struct {
Name string `json:"name"`
Endpoint string `json:"endpoint"`
Region string `json:"region"`
Bucket string `json:"bucket"`
AccessKey string `json:"accessKey"`
SecretKey string `json:"secretKey"`
}{
Name: name,
Endpoint: endpoint,
Region: region,
Bucket: bucket,
AccessKey: accessKey,
SecretKey: secretKey,
}
data, err := json.Marshal(payloadStruct)
if err != nil {
return fmt.Errorf("failed_to_marshal_note_payload: %w", err)
}
cmd := clusterModels.Command{
Type: "s3Configs",
Action: "create",
Data: data,
}
payload, err := json.Marshal(cmd)
if err != nil {
return fmt.Errorf("failed_to_marshal_command: %w", err)
}
applyFuture := s.Raft.Apply(payload, 5*time.Second)
if err := applyFuture.Error(); err != nil {
return fmt.Errorf("raft_apply_failed: %w", err)
}
if resp, ok := applyFuture.Response().(error); ok && resp != nil {
return fmt.Errorf("fsm_apply_failed: %w", resp)
}
return nil
}
func (s *Service) ProposeS3ConfigDelete(id uint, bypassRaft bool) error {
if bypassRaft {
return s.DB.Delete(&clusterModels.ClusterS3Config{}, id).Error
}
if s.Raft == nil {
return fmt.Errorf("raft_not_initialized")
}
payloadStruct := struct {
ID uint `json:"id"`
}{ID: id}
data, err := json.Marshal(payloadStruct)
if err != nil {
return fmt.Errorf("failed_to_marshal_delete_payload: %w", err)
}
cmd := clusterModels.Command{
Type: "s3Configs",
Action: "delete",
Data: data,
}
payload, err := json.Marshal(cmd)
if err != nil {
return fmt.Errorf("failed_to_marshal_command: %w", err)
}
applyFuture := s.Raft.Apply(payload, 5*time.Second)
if err := applyFuture.Error(); err != nil {
return fmt.Errorf("raft_apply_failed: %w", err)
}
if resp, ok := applyFuture.Response().(error); ok && resp != nil {
return fmt.Errorf("fsm_apply_failed: %w", resp)
}
return nil
}
-115
View File
@@ -7,118 +7,3 @@
// under sponsorship from the FreeBSD Foundation.
package cluster
import (
"encoding/json"
"fmt"
"time"
clusterModels "github.com/alchemillahq/sylve/internal/db/models/cluster"
"github.com/alchemillahq/sylve/pkg/utils"
)
func (s *Service) ProposeDirectoryConfig(name string, path string, bypassRaft bool) error {
exists, err := utils.IsDir(path)
if err != nil {
return fmt.Errorf("failed to check if directory exists: %w", err)
}
if !exists {
return fmt.Errorf("directory does not exist: %s", path)
}
if bypassRaft {
dir := clusterModels.ClusterDirectoryConfig{
Name: name,
Path: path,
}
err := s.DB.Create(&dir).Error
if err != nil {
return err
}
return nil
}
if s.Raft == nil {
return fmt.Errorf("raft_not_initialized")
}
payloadStruct := struct {
Name string `json:"name"`
Path string `json:"path"`
}{
Name: name,
Path: path,
}
data, err := json.Marshal(payloadStruct)
if err != nil {
return fmt.Errorf("failed_to_marshal_note_payload: %w", err)
}
cmd := clusterModels.Command{
Type: "directoryConfigs",
Action: "create",
Data: data,
}
payload, err := json.Marshal(cmd)
if err != nil {
return fmt.Errorf("failed_to_marshal_command: %w", err)
}
applyFuture := s.Raft.Apply(payload, 5*time.Second)
if err := applyFuture.Error(); err != nil {
return fmt.Errorf("raft_apply_failed: %w", err)
}
if resp, ok := applyFuture.Response().(error); ok && resp != nil {
return fmt.Errorf("fsm_apply_failed: %w", resp)
}
return nil
}
func (s *Service) ProposeDirectoryConfigDelete(id uint, bypassRaft bool) error {
if bypassRaft {
return s.DB.Delete(&clusterModels.ClusterDirectoryConfig{}, id).Error
}
if s.Raft == nil {
return fmt.Errorf("raft_not_initialized")
}
payloadStruct := struct {
ID uint `json:"id"`
}{ID: id}
data, err := json.Marshal(payloadStruct)
if err != nil {
return fmt.Errorf("failed_to_marshal_payload: %w", err)
}
cmd := clusterModels.Command{
Type: "directoryConfigs",
Action: "delete",
Data: data,
}
payload, err := json.Marshal(cmd)
if err != nil {
return fmt.Errorf("failed_to_marshal_command: %w", err)
}
applyFuture := s.Raft.Apply(payload, 5*time.Second)
if err := applyFuture.Error(); err != nil {
return fmt.Errorf("raft_apply_failed: %w", err)
}
if resp, ok := applyFuture.Response().(error); ok && resp != nil {
return fmt.Errorf("fsm_apply_failed: %w", resp)
}
return nil
}
+94 -62
View File
@@ -15,9 +15,12 @@ import (
"os/exec"
"strconv"
"strings"
"time"
"github.com/alchemillahq/sylve/internal/db"
jailModels "github.com/alchemillahq/sylve/internal/db/models/jail"
jailServiceInterfaces "github.com/alchemillahq/sylve/internal/interfaces/services/jail"
"github.com/alchemillahq/sylve/internal/logger"
"github.com/alchemillahq/sylve/pkg/utils"
cpuid "github.com/klauspost/cpuid/v2"
@@ -170,6 +173,79 @@ func (s *Service) IsJailActive(ctId uint) (bool, error) {
return false, nil
}
func (s *Service) PruneOrphanedJailStats() error {
if err := s.DB.
Where(
"jid NOT IN (?)",
s.DB.
Model(&jailModels.Jail{}).
Select("id"),
).
Delete(&jailModels.JailStats{}).
Error; err != nil {
return fmt.Errorf("failed to prune orphaned JailStats: %w", err)
}
return nil
}
func (s *Service) ApplyJailStatsRetention() error {
var jailIDs []uint
if err := s.DB.
Model(&jailModels.JailStats{}).
Select("DISTINCT jid").
Pluck("jid", &jailIDs).Error; err != nil {
return fmt.Errorf("failed_to_get_jail_ids_for_retention: %w", err)
}
now := time.Now()
for _, jailID := range jailIDs {
var stats []jailModels.JailStats
if err := s.DB.
Where("jid = ?", jailID).
Order("created_at ASC").
Find(&stats).Error; err != nil {
return fmt.Errorf("failed_to_get_jail_stats_for_retention: %w", err)
}
if len(stats) == 0 {
continue
}
// Check if jail is active
var jail jailModels.Jail
if err := s.DB.Where("id = ?", jailID).First(&jail).Error; err != nil {
logger.L.Error().Err(err).Uint("jail_id", jailID).Msg("failed_to_get_jail_for_retention")
continue
}
isActive, err := s.IsJailActive(jail.CTID)
if err != nil {
logger.L.Error().Err(err).Uint("jail_id", jailID).Msg("failed_to_check_if_jail_is_active_for_retention")
continue
}
if isActive {
_, deleteIDs := db.ApplyGFS(now, stats)
if len(deleteIDs) == 0 {
continue
}
if err := s.DB.
Where("id IN ?", deleteIDs).
Delete(&jailModels.JailStats{}).Error; err != nil {
return fmt.Errorf("failed_to_delete_old_jail_stats: %w", err)
}
}
}
if err := s.PruneOrphanedJailStats(); err != nil {
return err
}
return nil
}
func (s *Service) StoreJailUsage() error {
if !s.crudMutex.TryLock() {
return nil
@@ -182,10 +258,8 @@ func (s *Service) StoreJailUsage() error {
return fmt.Errorf("failed_to_load_jails: %w", err)
}
jDBIDs := make([]uint, 0, len(jails))
if len(jails) == 0 {
return s.PruneOrphanedJailStats(jDBIDs)
return s.ApplyJailStatsRetention()
}
states, err := s.GetStates()
@@ -233,8 +307,11 @@ func (s *Service) StoreJailUsage() error {
memPct = math.Round((float64(live.MemBytesUsed)/float64(sysRAM))*10000.0) / 100.0
}
cpuPct = math.Max(0, math.Min(100, cpuPct))
memPct = math.Max(0, math.Min(100, memPct))
stat := &jailModels.JailStats{
JailID: (j.ID),
JailID: j.ID,
CPUUsage: cpuPct,
MemoryUsage: memPct,
}
@@ -244,58 +321,10 @@ func (s *Service) StoreJailUsage() error {
}
}
for _, j := range jails {
jDBIDs = append(jDBIDs, j.ID)
}
for _, dbID := range jDBIDs {
var stats []jailModels.JailStats
if err := s.DB.
Where("jid = ?", dbID).
Order("id DESC").
Limit(256).
Find(&stats).Error; err != nil {
return fmt.Errorf("failed_to_get_jail_stats: %w", err)
}
if len(stats) < 256 {
continue
}
cutoff := stats[len(stats)-1].ID
if err := s.DB.
Where("jid = ? AND id < ?", dbID, cutoff).
Delete(&jailModels.JailStats{}).Error; err != nil {
return fmt.Errorf("failed_to_delete_old_jail_stats: %w", err)
}
}
if err := s.PruneOrphanedJailStats(jDBIDs); err != nil {
return err
}
return nil
return s.ApplyJailStatsRetention()
}
func (s *Service) PruneOrphanedJailStats(validJailIds []uint) error {
if len(validJailIds) == 0 {
return s.DB.Where("1 = 1").Delete(&jailModels.JailStats{}).Error
}
valid := make([]int, len(validJailIds))
for i, id := range validJailIds {
valid[i] = int(id)
}
if err := s.DB.
Where("jid NOT IN ?", valid).
Delete(&jailModels.JailStats{}).Error; err != nil {
return fmt.Errorf("failed_to_prune_orphaned_jail_stats: %w", err)
}
return nil
}
func (s *Service) GetJailUsage(ctId uint, limit int) ([]jailModels.JailStats, error) {
func (s *Service) GetJailUsage(ctId uint, step db.GFSStep) ([]jailModels.JailStats, error) {
var jailId uint
if err := s.DB.Model(&jailModels.Jail{}).
Where("ct_id = ?", ctId).
@@ -308,15 +337,18 @@ func (s *Service) GetJailUsage(ctId uint, limit int) ([]jailModels.JailStats, er
return nil, fmt.Errorf("jail_not_found")
}
var jailStats []jailModels.JailStats
sub := s.DB.
Model(&jailModels.JailStats{}).
Where("jid = ?", jailId).
Order("id DESC").
Limit(limit)
window, err := step.Window()
if err != nil {
return nil, err
}
if err := s.DB.Table("(?) as sub", sub).
Order("id ASC").
now := time.Now()
from := now.Add(-window)
var jailStats []jailModels.JailStats
if err := s.DB.
Where("jid = ? AND created_at >= ?", jailId, from).
Order("created_at ASC").
Find(&jailStats).Error; err != nil {
return nil, fmt.Errorf("failed_to_get_jail_usage: %w", err)
}
+7 -7
View File
@@ -118,7 +118,7 @@ func validateValues(oType string, values []string) error {
}
if !utils.IsValidPort(vInt) {
return fmt.Errorf("invalid port value: %s", value)
return fmt.Errorf("invalid port value: %d", vInt)
}
}
@@ -172,11 +172,11 @@ func (s *Service) IsObjectUsed(id uint) (bool, error) {
}
if err := s.DB.Find(&jailNetworks).Error; err != nil {
return true, fmt.Errorf("failed to find jail networks: %w", id, err)
return true, fmt.Errorf("failed to find jail networks: %d %w", id, err)
}
if err := s.DB.Preload("IPObject.Entries").Find(&dhcpLeases).Error; err != nil {
return true, fmt.Errorf("failed to find DHCP leases: %w", id, err)
return true, fmt.Errorf("failed to find DHCP leases: %d %w", id, err)
}
for _, sw := range switches {
@@ -252,7 +252,7 @@ func (s *Service) IsObjectUsed(id uint) (bool, error) {
}
if err := s.DB.Preload("MACObject.Entries").Find(&dhcpLeases).Error; err != nil {
return true, fmt.Errorf("failed to find DHCP leases: %w", id, err)
return true, fmt.Errorf("failed to find DHCP leases: %d %w", id, err)
}
for _, dl := range dhcpLeases {
@@ -290,7 +290,7 @@ func (s *Service) IsObjectUsed(id uint) (bool, error) {
if object.Type == "DUID" {
var dhcpLeases []networkModels.DHCPStaticLease
if err := s.DB.Preload("DUIDObject.Entries").Find(&dhcpLeases).Error; err != nil {
return true, fmt.Errorf("failed to find DHCP leases: %w", id, err)
return true, fmt.Errorf("failed to find DHCP leases: %d %w", id, err)
}
for _, dl := range dhcpLeases {
@@ -465,7 +465,7 @@ func (s *Service) EditObject(id uint, name string, oType string, values []string
}
if err := s.DB.Preload("MACObject.Entries").Where("mac_object_id = ?", id).Find(&dhcpLeases).Error; err != nil {
return fmt.Errorf("failed to find DHCP leases: %w", id, err)
return fmt.Errorf("failed to find DHCP leases: %d %w", id, err)
}
var vm vmModels.VM
@@ -691,7 +691,7 @@ func (s *Service) EditObject(id uint, name string, oType string, values []string
var dhcpLeases []networkModels.DHCPStaticLease
if err := s.DB.Preload("IPObject.Entries").Where("ip_object_id = ?", id).Find(&dhcpLeases).Error; err != nil {
return fmt.Errorf("failed to find DHCP leases: %w", id, err)
return fmt.Errorf("failed to find DHCP leases: %d %w", id, err)
}
/* Object was used in DHCP leases, but now we're changing it to something else, we can't do that */
+1 -1
View File
@@ -39,7 +39,7 @@ func TestGetInt64(t *testing.T) {
// vm.swap_idle_enabled does NOT exist on macOS
switch runtime.GOOS {
case "freebsd":
key = "vm.swap_idle_enabled"
key = "vm.kmem_zmax"
case "darwin":
key = "kern.maxfiles"
}
-102
View File
@@ -12,10 +12,6 @@
"@fontsource/noto-sans": "^5.2.7",
"@layerstack/svelte-stores": "^1.0.2",
"@svelte-put/shortcut": "^4.1.0",
"@tanstack/query-async-storage-persister": "^5.90.12",
"@tanstack/svelte-query": "^6.0.8",
"@tanstack/svelte-query-devtools": "^6.0.0",
"@tanstack/svelte-query-persist-client": "^6.0.10",
"@wuchale/svelte": "^0.17.5",
"@wuchale/vite-plugin": "^0.15.3",
"adze": "^2.2.4",
@@ -2027,104 +2023,6 @@
"vite": "^5.2.0 || ^6 || ^7"
}
},
"node_modules/@tanstack/query-async-storage-persister": {
"version": "5.90.14",
"resolved": "https://registry.npmjs.org/@tanstack/query-async-storage-persister/-/query-async-storage-persister-5.90.14.tgz",
"integrity": "sha512-oU+u40luly1PlrOrezeVxdF7AoF6dYw7BaU16Eg0+xLBwCRbW4cutxtxWG306Pgsnks5jIRDI4DjAPZKFnPSXQ==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.90.12",
"@tanstack/query-persist-client-core": "5.91.11"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.90.12",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz",
"integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-devtools": {
"version": "5.91.1",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.91.1.tgz",
"integrity": "sha512-l8bxjk6BMsCaVQH6NzQEE/bEgFy1hAs5qbgXl0xhzezlaQbPk6Mgz9BqEg2vTLPOHD8N4k+w/gdgCbEzecGyNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-persist-client-core": {
"version": "5.91.11",
"resolved": "https://registry.npmjs.org/@tanstack/query-persist-client-core/-/query-persist-client-core-5.91.11.tgz",
"integrity": "sha512-NNpRGxQY/nVOdzfs5QbevPjGsUVoEiFwqxxaopLyu6todwtDOCfIOfhXSmpMVXBiCxUn7kqaUB1iwaBKqoAVRQ==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.90.12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/svelte-query": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/@tanstack/svelte-query/-/svelte-query-6.0.10.tgz",
"integrity": "sha512-J0kM3JNvRcRCM6cbHLeICs73aLp98N/nsihdVEtiNo3MEN4pAnO45qZ2yxX70MrEZ9vffXaCXMCChwgXs1lZ/Q==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.90.12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"svelte": "^5.25.0"
}
},
"node_modules/@tanstack/svelte-query-devtools": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@tanstack/svelte-query-devtools/-/svelte-query-devtools-6.0.2.tgz",
"integrity": "sha512-UQdtAPsdJQ2HijMVy+W+/CHyrUZmCkubfAi/sqWsx+1fdY+g7ONa5xroBLiaebC+5jJxvk9kij61QMUHzzJgQA==",
"license": "MIT",
"dependencies": {
"@tanstack/query-devtools": "5.91.1",
"esm-env": "^1.2.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/svelte-query": "^6.0.8",
"svelte": "^5.25.0"
}
},
"node_modules/@tanstack/svelte-query-persist-client": {
"version": "6.0.12",
"resolved": "https://registry.npmjs.org/@tanstack/svelte-query-persist-client/-/svelte-query-persist-client-6.0.12.tgz",
"integrity": "sha512-eBPByyOu0jkFa4GWe9vukf1tqPXbeQ78V6y/EHrYiGjfA2txnwwAf+anyOcyjMtlZFO2N2X9rEUOXelG/77Xjg==",
"license": "MIT",
"dependencies": {
"@tanstack/query-persist-client-core": "5.91.11"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/svelte-query": "^6.0.10",
"svelte": "^5.25.0"
}
},
"node_modules/@thaunknown/thirty-two": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@thaunknown/thirty-two/-/thirty-two-1.0.5.tgz",
-4
View File
@@ -67,10 +67,6 @@
"@fontsource/noto-sans": "^5.2.7",
"@layerstack/svelte-stores": "^1.0.2",
"@svelte-put/shortcut": "^4.1.0",
"@tanstack/query-async-storage-persister": "^5.90.12",
"@tanstack/svelte-query": "^6.0.8",
"@tanstack/svelte-query-devtools": "^6.0.0",
"@tanstack/svelte-query-persist-client": "^6.0.10",
"@wuchale/svelte": "^0.17.5",
"@wuchale/vite-plugin": "^0.15.3",
"adze": "^2.2.4",
+2 -2
View File
@@ -99,8 +99,8 @@ export async function getJailLogs(id: number): Promise<JailLogs> {
return await apiRequest(`/jail/${id}/logs`, JailLogsSchema, 'GET');
}
export async function getStats(ctId: number, limit: number): Promise<JailStat[]> {
return await apiRequest(`/jail/stats/${ctId}/${limit}`, z.array(JailStatSchema), 'GET');
export async function getStats(ctId: number, step: string): Promise<JailStat[]> {
return await apiRequest(`/jail/stats/${ctId}/${step}`, z.array(JailStatSchema), 'GET');
}
export async function addNetwork(
-22
View File
@@ -1,14 +1,10 @@
import { APIResponseSchema, type APIResponse } from '$lib/types/common';
import {
IODelayHistoricalSchema,
IODelaySchema,
PoolsDiskUsageSchema,
PoolStatPointsResponseSchema,
ZpoolSchema,
ZPoolStatusPoolSchema,
type CreateZpool,
type IODelay,
type IODelayHistorical,
type PoolsDiskUsage,
type PoolStatPointsResponse,
type ReplaceDevice,
@@ -16,24 +12,6 @@ import {
type ZpoolStatusPool
} from '$lib/types/zfs/pool';
import { apiRequest } from '$lib/utils/http';
import type { QueryFunctionContext } from '@tanstack/svelte-query';
export async function getIODelay(
queryObj: QueryFunctionContext | undefined
): Promise<IODelay | IODelayHistorical> {
if (queryObj) {
if (queryObj.queryKey.includes('ioDelayHistorical')) {
const data = await apiRequest(
'/zfs/pool/io-delay/historical',
IODelayHistoricalSchema,
'GET'
);
return IODelayHistoricalSchema.parse(data);
}
}
return await apiRequest('/zfs/pool/io-delay', IODelaySchema, 'GET');
}
export async function getPoolStatus(guid: string): Promise<ZpoolStatusPool> {
return await apiRequest(`/zfs/pools/${guid}/status`, ZPoolStatusPoolSchema, 'GET');
@@ -1,27 +1,19 @@
<script lang="ts">
import { getNodes } from '$lib/api/cluster/cluster';
import { getJails, newJail } from '$lib/api/jail/jail';
import { getSimpleJails, newJail } from '$lib/api/jail/jail';
import { getNetworkObjects } from '$lib/api/network/object';
import { getSwitches } from '$lib/api/network/switch';
import { getDownloads } from '$lib/api/utilities/downloader';
import { getVMs } from '$lib/api/vm/vm';
import { getDatasets } from '$lib/api/zfs/datasets';
import { getSimpleVMs } from '$lib/api/vm/vm';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import { reload } from '$lib/stores/api.svelte';
import type { ClusterNode } from '$lib/types/cluster/cluster';
import type { CreateData, Jail } from '$lib/types/jail/jail';
import type { NetworkObject } from '$lib/types/network/object';
import type { SwitchList } from '$lib/types/network/switch';
import type { Download } from '$lib/types/utilities/downloader';
import type { VM } from '$lib/types/vm/vm';
import type { Dataset } from '$lib/types/zfs/dataset';
import type { CreateData } from '$lib/types/jail/jail';
import { handleAPIError, updateCache } from '$lib/utils/http';
import { isValidCreateData } from '$lib/utils/jail/jail';
import { getNextId } from '$lib/utils/vm/vm';
import { createQueries } from '@tanstack/svelte-query';
import { resource } from 'runed';
import { resource, watch } from 'runed';
import { toast } from 'svelte-sonner';
import Basic from './Basic.svelte';
import Hardware from './Hardware.svelte';
@@ -44,73 +36,59 @@
{ value: 'advanced', label: 'Advanced' }
];
const results = createQueries(() => ({
queries: [
{
queryKey: ['zfs-datasets'],
queryFn: async () => {
return await getDatasets();
},
keepPreviousData: true,
initialData: [] as Dataset[],
refetchOnMount: 'always'
},
{
queryKey: ['downloads'],
queryFn: async () => {
return await getDownloads();
},
keepPreviousData: true,
initialData: [] as Download[],
refetchOnMount: 'always'
},
{
queryKey: ['network-switches'],
queryFn: async () => {
return await getSwitches();
},
keepPreviousData: true,
initialData: {} as SwitchList,
refetchOnMount: 'always'
},
{
queryKey: ['vm-list'],
queryFn: async () => {
return await getVMs();
},
keepPreviousData: true,
initialData: [] as VM[],
refetchOnMount: 'always'
},
{
queryKey: ['network-objects'],
queryFn: async () => {
return await getNetworkObjects();
},
keepPreviousData: true,
initialData: [] as NetworkObject[],
refetchOnMount: 'always'
},
{
queryKey: ['jail-list'],
queryFn: async () => {
return await getJails();
},
keepPreviousData: true,
initialData: [] as Jail[],
refetchOnMount: 'always'
},
{
queryKey: ['cluster-nodes'],
queryFn: async () => {
return await getNodes();
},
keepPreviousData: true,
initialData: [] as ClusterNode[],
refetchOnMount: 'always'
}
]
}));
let downloads = resource(
() => 'downloads',
async (key, prevKey, { signal }) => {
const downloads = await getDownloads();
updateCache(key, downloads);
return downloads;
}
);
let networkSwitches = resource(
() => 'network-switches',
async (key, prevKey, { signal }) => {
const switches = await getSwitches();
updateCache(key, switches);
return switches;
}
);
let networkObjects = resource(
() => 'network-objects',
async (key, prevKey, { signal }) => {
const objects = await getNetworkObjects();
updateCache(key, objects);
return objects;
}
);
let vms = resource(
() => 'simple-vm-list',
async (key, prevKey, { signal }) => {
const vms = await getSimpleVMs();
updateCache(key, vms);
return vms;
}
);
let jails = resource(
() => 'simple-jail-list',
async (key, prevKey, { signal }) => {
const jails = await getSimpleJails();
updateCache(key, jails);
return jails;
}
);
let nodes = resource(
() => 'cluster-nodes',
async (key, prevKey, { signal }) => {
const nodes = await getNodes();
updateCache(key, nodes);
return nodes;
}
);
let pools = resource(
() => 'pool-list',
@@ -123,22 +101,23 @@
let refetch = $state(false);
$effect(() => {
if (refetch) {
results.forEach((result) => {
result.refetch();
});
watch(
() => refetch,
(value) => {
if (value) {
downloads.refetch();
networkSwitches.refetch();
networkObjects.refetch();
vms.refetch();
jails.refetch();
nodes.refetch();
pools.refetch();
refetch = false;
refetch = false;
}
}
});
);
let downloads = $derived(results[1].data as Download[]);
let networkSwitches: SwitchList = $derived(results[2].data as SwitchList);
let networkObjects = $derived(results[4].data as NetworkObject[]);
let vms: VM[] = $derived(results[3].data as VM[]);
let jails: Jail[] = $derived(results[5].data as Jail[]);
let nodes: ClusterNode[] = $derived(results[6].data as ClusterNode[]);
let creating: boolean = $state(false);
let options = {
@@ -192,12 +171,22 @@
}
};
let nextId = $derived(getNextId(vms, jails));
let nextId = $derived.by(() => {
if (vms.current && jails.current) {
return getNextId(vms.current, jails.current);
}
return 137;
});
let modal: CreateData = $state(options);
$effect(() => {
modal.id = nextId;
});
watch(
() => nextId,
(id) => {
modal.id = id;
}
);
function resetModal() {
modal = options;
@@ -303,7 +292,7 @@
{#each tabs as { value, label }}
<Tabs.Content {value}>
<div>
{#if value === 'basic'}
{#if value === 'basic' && nodes.current}
<Basic
bind:name={modal.name}
bind:id={modal.id}
@@ -311,19 +300,18 @@
bind:description={modal.description}
bind:refetch
bind:node={modal.node}
{nodes}
nodes={nodes.current}
/>
{:else if value === 'storage' && pools.current}
{:else if value === 'storage' && pools.current && downloads.current && jails.current}
<Storage
{downloads}
{jails}
downloads={downloads.current}
pools={pools.current}
ctId={modal.id}
bind:pool={modal.storage.pool}
bind:base={modal.storage.base}
bind:fstab={modal.storage.fstab}
/>
{:else if value === 'network'}
{:else if value === 'network' && networkSwitches.current && networkObjects.current}
<Network
bind:switch={modal.network.switch}
bind:mac={modal.network.mac}
@@ -335,8 +323,8 @@
bind:ipv6Gateway={modal.network.ipv6Gateway}
bind:dhcp={modal.network.dhcp}
bind:slaac={modal.network.slaac}
switches={networkSwitches}
{networkObjects}
switches={networkSwitches.current}
networkObjects={networkObjects.current}
/>
{:else if value === 'hardware'}
<Hardware
@@ -17,7 +17,6 @@
pool: string;
downloads: Download[];
base: string;
jails: Jail[];
fstab: string;
}
@@ -9,9 +9,9 @@
import { ScrollArea } from '$lib/components/ui/scroll-area';
import { reload } from '$lib/stores/api.svelte';
import type { ClusterNode, NodeResource } from '$lib/types/cluster/cluster';
import { createQueries } from '@tanstack/svelte-query';
import { default as TreeViewCluster } from './TreeViewCluster.svelte';
import { DomainState } from '$lib/types/vm/vm';
import { resource, useInterval, watch } from 'runed';
let openIds = $state(new Set<string>(['datacenter']));
@@ -22,29 +22,25 @@
saveOpenIds(openIds);
};
const results = createQueries(() => ({
queries: [
{
queryKey: ['cluster-resources'],
queryFn: async () => await getClusterResources(),
keepPreviousData: true,
refetchInterval: 30000,
initialData: [] as NodeResource[],
refetchOnMount: 'always'
},
{
queryKey: ['cluster-nodes'],
queryFn: async () => await getNodes(),
keepPreviousData: true,
refetchInterval: 30000,
initialData: [] as ClusterNode[],
refetchOnMount: 'always'
}
]
}));
const cluster = resource(
() => 'cluster-resources',
async () => {
return await getClusterResources();
},
{
initialValue: [] as NodeResource[]
}
);
const clusterRes = $derived((results[0]?.data as NodeResource[]) ?? []);
const nodes = $derived((results[1]?.data as ClusterNode[]) ?? []);
const nodes = resource(
() => 'cluster-nodes',
async () => {
return await getNodes();
},
{
initialValue: [] as ClusterNode[]
}
);
const tree = $derived([
{
@@ -52,7 +48,7 @@
label: 'Data Center',
icon: 'ant-design--cluster-outlined',
href: '/datacenter',
children: clusterRes.map((n) => {
children: cluster.current.map((n) => {
const nodeLabel = n.hostname || n.nodeUUID;
let mergedChildren = [
...(n.jails ?? []).map((j) => ({
@@ -75,7 +71,7 @@
}))
].sort((a, b) => a.sortId - b.sortId);
const found = nodes.find((node) => node.nodeUUID === n.nodeUUID);
const found = nodes.current.find((node) => node.nodeUUID === n.nodeUUID);
const isActive = found && found.status === 'online';
return {
@@ -102,11 +98,20 @@
}
});
$effect(() => {
if (reload.leftPanel) {
results[0].refetch();
results[1].refetch();
reload.leftPanel = false;
watch(
() => reload.leftPanel,
(value) => {
if (value) {
cluster.refetch();
nodes.refetch();
reload.leftPanel = false;
}
}
);
useInterval(30000, {
callback: () => {
reload.leftPanel = true;
}
});
</script>
+5 -3
View File
@@ -106,14 +106,16 @@ export async function cachedFetch<T>(
const now = Date.now();
const entry = await kvStorage.getItem<T>(key);
if (entry) {
console.log('cachedFetch:', { key, entry, now, duration });
if (entry && entry.data !== null) {
const isFresh = now - entry.timestamp < duration;
const data = entry.data;
const looksLikeError =
data &&
typeof data === 'object' &&
'status' in (data as any) &&
data !== null &&
'status' in data &&
(data as any).status === 'error';
if (isFresh && !looksLikeError) {
+2 -2
View File
@@ -1,5 +1,5 @@
import type { Jail, SimpleJail } from '$lib/types/jail/jail';
import type { CreateData, VM } from '$lib/types/vm/vm';
import type { CreateData, SimpleVm, VM } from '$lib/types/vm/vm';
import { toast } from 'svelte-sonner';
import { isValidVMName } from '../string';
import type { UTypeGroupedDownload } from '$lib/types/utilities/downloader';
@@ -128,7 +128,7 @@ export function isValidCreateData(
return true;
}
export function getNextId(vms: VM[], jails: Jail[] | SimpleJail[]): number {
export function getNextId(vms: VM[] | SimpleVm[], jails: Jail[] | SimpleJail[]): number {
const usedIds = [...vms.map((vm) => vm.rid), ...jails.map((jail) => jail.ctId)];
if (usedIds.length === 0) return 100;
return Math.max(...usedIds) + 1;
+13 -7
View File
@@ -32,7 +32,6 @@ msgstr "Console"
#: src/lib/components/custom/VM/Create/CreateVM.svelte
#: src/routes/[node]/+layout.svelte
#: src/routes/[node]/+layout.svelte
#: src/routes/datacenter/+layout.svelte
msgid "Storage"
msgstr "Storage"
@@ -116,9 +115,8 @@ msgstr "Explorer"
msgid "Disks"
msgstr "Disks"
#: src/routes/[node]/+layout.svelte
msgid "Dashboard"
msgstr "Dashboard"
#~ msgid "Dashboard"
#~ msgstr "Dashboard"
#: src/routes/[node]/+layout.svelte
#: src/routes/[node]/storage/zfs/dashboard/+page.svelte
@@ -1055,12 +1053,14 @@ msgstr "Note"
msgid "Post Upgrade Summary"
msgstr "Post Upgrade Summary"
#: src/routes/[node]/notes/+page.svelte
#: src/routes/[node]/notes/+page.svelte
#: src/routes/datacenter/notes/+page.svelte
#: src/routes/datacenter/notes/+page.svelte
msgid "Content"
msgstr "Content"
#: src/routes/[node]/notes/+page.svelte
#: src/routes/datacenter/notes/+page.svelte
msgid "This is a note"
msgstr "This is a note"
@@ -1107,8 +1107,6 @@ msgstr "Failed to delete notes"
#: src/lib/components/custom/Charts/Area.svelte
#: src/routes/[node]/jail/[node]/summary/+page.svelte
#: src/routes/[node]/jail/[node]/summary/+page.svelte
#: src/routes/[node]/jail/[node]/summary/+page.svelte
#: src/routes/[node]/summary/+page.svelte
#: src/routes/[node]/summary/+page.svelte
#: src/routes/[node]/summary/+page.svelte
#: src/routes/[node]/vm/[node]/summary/+page.svelte
@@ -1171,7 +1169,7 @@ msgstr "Sylve Version"
#: src/lib/components/custom/Charts/EChart.svelte
#: src/lib/components/custom/Charts/EChartSample.svelte
#: src/routes/[node]/jail/[node]/summary/+page.svelte
#: src/routes/[node]/jail/[node]/summary/+page.svelte
#: src/routes/[node]/vm/[node]/summary/+page.svelte
msgid "Memory Usage"
msgstr "Memory Usage"
@@ -5395,22 +5393,27 @@ msgstr "Storage updated"
#~ msgid "Manage ({0} lpinned)"
#~ msgstr "Manage ({0} lpinned)"
#: src/routes/[node]/jail/[node]/summary/+page.svelte
#: src/routes/[node]/vm/[node]/summary/+page.svelte
msgid "Hourly"
msgstr "Hourly"
#: src/routes/[node]/jail/[node]/summary/+page.svelte
#: src/routes/[node]/vm/[node]/summary/+page.svelte
msgid "Daily"
msgstr "Daily"
#: src/routes/[node]/jail/[node]/summary/+page.svelte
#: src/routes/[node]/vm/[node]/summary/+page.svelte
msgid "Weekly"
msgstr "Weekly"
#: src/routes/[node]/jail/[node]/summary/+page.svelte
#: src/routes/[node]/vm/[node]/summary/+page.svelte
msgid "Monthly"
msgstr "Monthly"
#: src/routes/[node]/jail/[node]/summary/+page.svelte
#: src/routes/[node]/vm/[node]/summary/+page.svelte
msgid "Yearly"
msgstr "Yearly"
@@ -6739,3 +6742,6 @@ msgstr "Manage the devfs ruleset for this jail"
#: src/lib/components/custom/Jail/Options/TextEdit.svelte
msgid "Manage additional options for this jail"
msgstr "Manage additional options for this jail"
#~ msgid "Discord"
#~ msgstr "Discord"
+13 -7
View File
@@ -32,7 +32,6 @@ msgstr "कंसोल"
#: src/lib/components/custom/VM/Create/CreateVM.svelte
#: src/routes/[node]/+layout.svelte
#: src/routes/[node]/+layout.svelte
#: src/routes/datacenter/+layout.svelte
msgid "Storage"
msgstr "भंडारण"
@@ -116,9 +115,8 @@ msgstr "एक्सप्लोरर"
msgid "Disks"
msgstr "डिस्क्स"
#: src/routes/[node]/+layout.svelte
msgid "Dashboard"
msgstr "डैशबोर्ड"
#~ msgid "Dashboard"
#~ msgstr "डैशबोर्ड"
#: src/routes/[node]/+layout.svelte
#: src/routes/[node]/storage/zfs/dashboard/+page.svelte
@@ -1011,12 +1009,14 @@ msgstr "नोट"
msgid "Post Upgrade Summary"
msgstr "पोस्ट अपग्रेड सारांश"
#: src/routes/[node]/notes/+page.svelte
#: src/routes/[node]/notes/+page.svelte
#: src/routes/datacenter/notes/+page.svelte
#: src/routes/datacenter/notes/+page.svelte
msgid "Content"
msgstr "सामग्री"
#: src/routes/[node]/notes/+page.svelte
#: src/routes/datacenter/notes/+page.svelte
msgid "This is a note"
msgstr "यह एक नोट है"
@@ -1105,8 +1105,6 @@ msgstr "क्लस्टर रीसेट के बाद लॉगिन
#: src/lib/components/custom/Charts/Area.svelte
#: src/routes/[node]/jail/[node]/summary/+page.svelte
#: src/routes/[node]/jail/[node]/summary/+page.svelte
#: src/routes/[node]/jail/[node]/summary/+page.svelte
#: src/routes/[node]/summary/+page.svelte
#: src/routes/[node]/summary/+page.svelte
#: src/routes/[node]/summary/+page.svelte
#: src/routes/[node]/vm/[node]/summary/+page.svelte
@@ -1169,7 +1167,7 @@ msgstr "Sylve संस्करण"
#: src/lib/components/custom/Charts/EChart.svelte
#: src/lib/components/custom/Charts/EChartSample.svelte
#: src/routes/[node]/jail/[node]/summary/+page.svelte
#: src/routes/[node]/jail/[node]/summary/+page.svelte
#: src/routes/[node]/vm/[node]/summary/+page.svelte
msgid "Memory Usage"
msgstr "मेमोरी उपयोग"
@@ -5383,22 +5381,27 @@ msgstr ""
#~ msgid "Manage ({0} lpinned)"
#~ msgstr ""
#: src/routes/[node]/jail/[node]/summary/+page.svelte
#: src/routes/[node]/vm/[node]/summary/+page.svelte
msgid "Hourly"
msgstr ""
#: src/routes/[node]/jail/[node]/summary/+page.svelte
#: src/routes/[node]/vm/[node]/summary/+page.svelte
msgid "Daily"
msgstr ""
#: src/routes/[node]/jail/[node]/summary/+page.svelte
#: src/routes/[node]/vm/[node]/summary/+page.svelte
msgid "Weekly"
msgstr ""
#: src/routes/[node]/jail/[node]/summary/+page.svelte
#: src/routes/[node]/vm/[node]/summary/+page.svelte
msgid "Monthly"
msgstr ""
#: src/routes/[node]/jail/[node]/summary/+page.svelte
#: src/routes/[node]/vm/[node]/summary/+page.svelte
msgid "Yearly"
msgstr ""
@@ -6699,3 +6702,6 @@ msgstr ""
#: src/lib/components/custom/Jail/Options/TextEdit.svelte
msgid "Manage additional options for this jail"
msgstr ""
#~ msgid "Discord"
#~ msgstr ""
+13 -7
View File
@@ -32,7 +32,6 @@ msgstr ""
#: src/lib/components/custom/VM/Create/CreateVM.svelte
#: src/routes/[node]/+layout.svelte
#: src/routes/[node]/+layout.svelte
#: src/routes/datacenter/+layout.svelte
msgid "Storage"
msgstr ""
@@ -116,9 +115,8 @@ msgstr ""
msgid "Disks"
msgstr ""
#: src/routes/[node]/+layout.svelte
msgid "Dashboard"
msgstr ""
#~ msgid "Dashboard"
#~ msgstr ""
#: src/routes/[node]/+layout.svelte
#: src/routes/[node]/storage/zfs/dashboard/+page.svelte
@@ -1053,12 +1051,14 @@ msgstr ""
msgid "Post Upgrade Summary"
msgstr ""
#: src/routes/[node]/notes/+page.svelte
#: src/routes/[node]/notes/+page.svelte
#: src/routes/datacenter/notes/+page.svelte
#: src/routes/datacenter/notes/+page.svelte
msgid "Content"
msgstr ""
#: src/routes/[node]/notes/+page.svelte
#: src/routes/datacenter/notes/+page.svelte
msgid "This is a note"
msgstr ""
@@ -1105,8 +1105,6 @@ msgstr ""
#: src/lib/components/custom/Charts/Area.svelte
#: src/routes/[node]/jail/[node]/summary/+page.svelte
#: src/routes/[node]/jail/[node]/summary/+page.svelte
#: src/routes/[node]/jail/[node]/summary/+page.svelte
#: src/routes/[node]/summary/+page.svelte
#: src/routes/[node]/summary/+page.svelte
#: src/routes/[node]/summary/+page.svelte
#: src/routes/[node]/vm/[node]/summary/+page.svelte
@@ -1169,7 +1167,7 @@ msgstr ""
#: src/lib/components/custom/Charts/EChart.svelte
#: src/lib/components/custom/Charts/EChartSample.svelte
#: src/routes/[node]/jail/[node]/summary/+page.svelte
#: src/routes/[node]/jail/[node]/summary/+page.svelte
#: src/routes/[node]/vm/[node]/summary/+page.svelte
msgid "Memory Usage"
msgstr ""
@@ -5377,22 +5375,27 @@ msgstr ""
#~ msgid "Manage ({0} lpinned)"
#~ msgstr ""
#: src/routes/[node]/jail/[node]/summary/+page.svelte
#: src/routes/[node]/vm/[node]/summary/+page.svelte
msgid "Hourly"
msgstr ""
#: src/routes/[node]/jail/[node]/summary/+page.svelte
#: src/routes/[node]/vm/[node]/summary/+page.svelte
msgid "Daily"
msgstr ""
#: src/routes/[node]/jail/[node]/summary/+page.svelte
#: src/routes/[node]/vm/[node]/summary/+page.svelte
msgid "Weekly"
msgstr ""
#: src/routes/[node]/jail/[node]/summary/+page.svelte
#: src/routes/[node]/vm/[node]/summary/+page.svelte
msgid "Monthly"
msgstr ""
#: src/routes/[node]/jail/[node]/summary/+page.svelte
#: src/routes/[node]/vm/[node]/summary/+page.svelte
msgid "Yearly"
msgstr ""
@@ -6693,3 +6696,6 @@ msgstr ""
#: src/lib/components/custom/Jail/Options/TextEdit.svelte
msgid "Manage additional options for this jail"
msgstr ""
#~ msgid "Discord"
#~ msgstr ""
+16 -27
View File
@@ -13,7 +13,6 @@
import { Toaster } from '$lib/components/ui/sonner/index.js';
import '$lib/utils/i18n';
import { addTabulatorFilters } from '$lib/utils/table';
import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
import { ModeWatcher } from 'mode-watcher';
import { onMount } from 'svelte';
import '../locales/main.loader.svelte.js';
@@ -27,14 +26,6 @@
import Reboot from '$lib/components/custom/Initialization/Reboot.svelte';
import { getBasicSettings } from '$lib/api/system/settings.js';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
enabled: browser
}
}
});
let { children } = $props();
let initialized = $state<boolean | null>(null);
let rebooted = $state<boolean>(false);
@@ -183,27 +174,25 @@
{#if loading.throbber}
<Throbber />
{:else if storage.hostname && storage.token && !loading.throbber && !loading.login}
<QueryClientProvider client={queryClient}>
{#if initialized === null}
<Throbber />
{:else if initialized === false || rebooted === false}
{#if !initialized}
<div transition:fade|global={{ duration: 400 }}>
<Initialize bind:initialized />
</div>
{:else if !rebooted}
<div transition:fade|global={{ duration: 400 }}>
<Reboot />
</div>
{/if}
{:else}
{#if initialized === null}
<Throbber />
{:else if initialized === false || rebooted === false}
{#if !initialized}
<div transition:fade|global={{ duration: 400 }}>
<Shell>
{@render children()}
</Shell>
<Initialize bind:initialized />
</div>
{:else if !rebooted}
<div transition:fade|global={{ duration: 400 }}>
<Reboot />
</div>
{/if}
</QueryClientProvider>
{:else}
<div transition:fade|global={{ duration: 400 }}>
<Shell>
{@render children()}
</Shell>
</div>
{/if}
{:else}
<div transition:fade|global={{ duration: 400 }}>
<Login onLogin={handleLogin} loading={loading.login} />
+7 -6
View File
@@ -128,11 +128,12 @@
label: 'ZFS',
icon: 'file-icons--openzfs',
children: [
{
label: 'Dashboard',
icon: 'mdi--monitor-dashboard',
href: `/${node}/storage/zfs/dashboard`
},
// Turned off dashboard for now
// {
// label: 'Dashboard',
// icon: 'mdi--monitor-dashboard',
// href: `/${node}/storage/zfs/dashboard`
// },
{ label: 'Pools', icon: 'bi--hdd-stack-fill', href: `/${node}/storage/zfs/pools` },
{
label: 'Datasets',
@@ -272,7 +273,7 @@
<Button
size="sm"
class="h-6"
onclick={() => (window.location.href = 'https://github.com/AlchemillaHQ/Sylve')}
onclick={() => window.open('https://discord.gg/bJB826JvXK', '_blank')}
>
<div class="flex items-center">
<span class="icon-[lucide--circle-help] mr-2 h-5 w-5"></span>
@@ -1,6 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { getCPUInfo } from '$lib/api/info/cpu';
import { getRAMInfo } from '$lib/api/info/ram';
import * as Dialog from '$lib/components/ui/dialog/index.js';
@@ -13,7 +12,6 @@
jailAction,
updateDescription
} from '$lib/api/jail/jail';
import AreaChart from '$lib/components/custom/Charts/Area.svelte';
import LoadingDialog from '$lib/components/custom/Dialog/Loading.svelte';
import * as AlertDialogRaw from '$lib/components/ui/alert-dialog/index.js';
import { Button } from '$lib/components/ui/button/index.js';
@@ -29,12 +27,14 @@
import type { Jail, JailStat, JailState } from '$lib/types/jail/jail';
import { sleep } from '$lib/utils';
import { updateCache } from '$lib/utils/http';
import { cleanStats } from '$lib/utils/jail/stats';
import { dateToAgo } from '$lib/utils/time';
import humanFormat from 'human-format';
import { toast } from 'svelte-sonner';
import { resource, useInterval, IsDocumentVisible, Debounced } from 'runed';
import { resource, useInterval, IsDocumentVisible, Debounced, watch } from 'runed';
import { untrack } from 'svelte';
import type { GFSStep } from '$lib/types/common';
import SimpleSelect from '$lib/components/custom/SimpleSelect.svelte';
import LineBrush from '$lib/components/custom/Charts/LineBrush/Single.svelte';
interface Data {
ctId: number;
@@ -48,6 +48,7 @@
let visible = new IsDocumentVisible();
let { data }: { data: Data } = $props();
let ctId = data.ctId;
let gfsStep = $state<GFSStep>('hourly');
let modalState = $state({
isDeleteOpen: false,
@@ -98,16 +99,15 @@
}
);
const jailStats = resource(
() => `jail-stats-${ctId}`,
async (key, prevKey, { signal }) => {
const result = await getStats(Number(ctId), 128);
const stats = resource(
[() => gfsStep],
async ([gfsStep]) => {
const result = await getStats(Number(data.jail.ctId), gfsStep);
const key = `jail-stats-${gfsStep}-${data.jail.ctId}`;
updateCache(key, result);
return result;
},
{
initialValue: data.stats
}
{ initialValue: data.stats }
);
const cpuInfo = resource(
@@ -146,42 +146,25 @@
if (visible.current) {
jail.refetch();
jState.refetch();
jailStats.refetch();
stats.refetch();
}
}
});
$effect(() => {
if (visible.current) {
untrack(() => {
watch(
() => visible.current,
(isVisible) => {
if (isVisible) {
jail.refetch();
jState.refetch();
jailStats.refetch();
});
stats.refetch();
}
}
});
);
let showLogs = $state(false);
let logicalCores = $derived(cpuInfo.current?.logicalCores ?? 0);
let totalRAM = $derived(ramInfo.current?.total ?? 0);
let cpuHistoricalData = $derived.by(() => {
return {
field: 'cpuUsage',
label: 'CPU Usage',
color: 'chart-1',
data: cleanStats(jailStats.current, jail.current).cpu.slice(-12)
};
});
let memoryHistoricalData = $derived.by(() => {
return {
field: 'memoryUsage',
label: 'Memory Usage',
color: 'chart-2',
data: cleanStats(jailStats.current, jail.current).memory.slice(-12)
};
});
let jailDesc = $state(jail.current.description || '');
let debouncedDesc = new Debounced(() => jailDesc, 500);
let lastDesc = $state('');
@@ -315,17 +298,37 @@
{/if}
{/if}
<div class="ml-auto flex h-full items-center">
<Button
size="sm"
onclick={() => (showLogs = true)}
class="bg-muted-foreground/40 dark:bg-muted h-6 text-black hover:bg-blue-600 dark:text-white"
>
<div class="flex items-center">
<span class="icon-[mdi--file-document-outline] h-4 w-4"></span>
<span>View Logs</span>
</div>
</Button>
<div class="ml-auto flex h-full items-center gap-2">
{#if logs.current.logs.length > 0}
<Button
size="sm"
onclick={() => {
showLogs = true;
}}
class="bg-muted-foreground/40 dark:bg-muted h-6 text-black hover:bg-blue-600 dark:text-white"
>
<div class="flex items-center">
<span class="icon-[mdi--file-document-outline] h-4 w-4"></span>
<span>View Logs</span>
</div>
</Button>
{/if}
<SimpleSelect
options={[
{ label: 'Hourly', value: 'hourly' },
{ label: 'Daily', value: 'daily' },
{ label: 'Weekly', value: 'weekly' },
{ label: 'Monthly', value: 'monthly' },
{ label: 'Yearly', value: 'yearly' }
]}
bind:value={gfsStep}
onChange={() => {
stats.refetch();
}}
classes={{ trigger: 'h-6!' }}
icon="icon-[mdi--calendar]"
/>
</div>
</div>
@@ -420,26 +423,32 @@
</div>
<div class="space-y-4 p-3">
<AreaChart title="CPU Usage" elements={[cpuHistoricalData]} percentage={true} />
<AreaChart title="Memory Usage" elements={[memoryHistoricalData]} percentage={true} />
<LineBrush
title="CPU Usage"
points={stats.current.map((data) => ({
date: new Date(data.createdAt).getTime(),
value: Number(data.cpuUsage)
}))}
percentage={true}
color="one"
containerContentHeight="h-64"
/>
<LineBrush
title="Memory Usage"
points={stats.current.map((data) => ({
date: new Date(data.createdAt).getTime(),
value: Number(data.memoryUsage)
}))}
percentage={true}
color="two"
containerContentHeight="h-64"
/>
</div>
</ScrollArea>
</div>
</div>
<!-- <AlertDialog
open={modalState.isDeleteOpen}
customTitle={`This will delete Jail ${jail.name} (${jail.ctId})`}
actions={{
onConfirm: async () => {
handleDelete();
},
onCancel: () => {
modalState.isDeleteOpen = false;
}
}}
></AlertDialog> -->
<AlertDialogRaw.Root bind:open={modalState.isDeleteOpen}>
<AlertDialogRaw.Content onInteractOutside={(e) => e.preventDefault()} class="p-5">
<AlertDialogRaw.Header>
@@ -11,7 +11,7 @@ export async function load({ params }) {
const [jail, state, stats, ramInfo, cpuInfo] = await Promise.all([
cachedFetch(`jail-${ctId}`, async () => getJailById(ctId, 'ctid'), cacheDuration),
cachedFetch(`jail-${ctId}-state`, async () => getJailStateById(ctId), cacheDuration),
cachedFetch(`jail-${ctId}-stats`, async () => getStats(ctId, 128), cacheDuration),
cachedFetch(`jail-${ctId}-stats`, async () => getStats(ctId, 'hourly'), cacheDuration),
cachedFetch('system-ram-info', async () => getRAMInfo('current'), cacheDuration),
cachedFetch('system-cpu-info', async () => getCPUInfo('current'), cacheDuration)
]);
@@ -12,7 +12,7 @@
import type { ManualSwitch, StandardSwitch, SwitchList } from '$lib/types/network/switch';
import { updateCache } from '$lib/utils/http';
import { generateNanoId } from '$lib/utils/string';
import { createQueries } from '@tanstack/svelte-query';
import { resource, watch } from 'runed';
import type { CellComponent } from 'tabulator-tables';
interface Data {
@@ -23,58 +23,49 @@
let { data }: { data: Data } = $props();
const results = createQueries(() => ({
queries: [
{
queryKey: ['network-interfaces'],
queryFn: async () => {
return await getInterfaces();
},
keepPreviousData: true,
initialData: data.interfaces,
onSuccess: (data: Iface[]) => {
updateCache('network-interfaces', data);
}
},
{
queryKey: ['network-switches'],
queryFn: async () => {
return await getSwitches();
},
keepPreviousData: true,
initialData: data.switches,
onSuccess: (data: SwitchList) => {
updateCache('network-switches', data);
}
},
{
queryKey: ['dhcp-config'],
queryFn: async () => {
return await getDHCPConfig();
},
keepPreviousData: true,
initialData: data.dhcpConfig,
onSuccess: (data: DHCPConfig) => {
updateCache('dhcp-config', data);
}
}
]
}));
let networkInterfaces = resource(
() => 'network-interfaces',
async (key, prevKey, { signal }) => {
const res = await getInterfaces();
updateCache(key, res);
return res;
},
{ initialValue: data.interfaces }
);
let networkSwitches = resource(
() => 'network-switches',
async (key, prevKey, { signal }) => {
const res = await getSwitches();
updateCache(key, res);
return res;
},
{ initialValue: data.switches }
);
let dhcpConfig = resource(
() => 'dhcp-config',
async (key, prevKey, { signal }) => {
const res = await getDHCPConfig();
updateCache(key, res);
return res;
},
{ initialValue: data.dhcpConfig }
);
let networkInterfaces = $derived(results[0].data as Iface[]);
let networkSwitches = $derived(results[1].data as SwitchList);
let dhcpConfig = $derived(results[2].data as DHCPConfig);
let reload = $state(false);
$effect(() => {
if (reload) {
results.forEach((result) => {
result.refetch();
});
reload = false;
watch(
() => reload,
(current) => {
if (current) {
networkInterfaces.refetch();
networkSwitches.refetch();
dhcpConfig.refetch();
reload = false;
}
}
});
);
let query = $state('');
let tableData = $derived.by(() => {
@@ -122,24 +113,24 @@
{
id: generateNanoId('id'),
property: 'Domain',
value: dhcpConfig.domain
value: dhcpConfig.current.domain
},
{
id: generateNanoId('expandHosts'),
property: 'Expand Hosts',
value: dhcpConfig.expandHosts ? 'Yes' : 'No'
value: dhcpConfig.current.expandHosts ? 'Yes' : 'No'
},
{
id: generateNanoId('dnsServers'),
property: 'DNS Servers',
value: dhcpConfig.dnsServers
value: dhcpConfig.current.dnsServers
},
{
id: generateNanoId('switches'),
property: 'Switches',
value: {
standard: dhcpConfig.standardSwitches,
manual: dhcpConfig.manualSwitches
standard: dhcpConfig.current.standardSwitches,
manual: dhcpConfig.current.manualSwitches
}
}
];
@@ -173,4 +164,10 @@
/>
</div>
<Config bind:open={modalOpen} bind:reload {networkInterfaces} {networkSwitches} {dhcpConfig} />
<Config
bind:open={modalOpen}
bind:reload
networkInterfaces={networkInterfaces.current}
networkSwitches={networkSwitches.current}
dhcpConfig={dhcpConfig.current}
/>
@@ -8,7 +8,6 @@
import type { Iface } from '$lib/types/network/iface';
import type { SwitchList } from '$lib/types/network/switch';
import { handleAPIError, updateCache } from '$lib/utils/http';
import { createQueries } from '@tanstack/svelte-query';
import Button from '$lib/components/ui/button/button.svelte';
import type { NetworkObject } from '$lib/types/network/object';
import { getNetworkObjects } from '$lib/api/network/object';
@@ -19,6 +18,7 @@
import { renderWithIcon } from '$lib/utils/table';
import AlertDialog from '$lib/components/custom/Dialog/Alert.svelte';
import { toast } from 'svelte-sonner';
import { resource, watch } from 'runed';
interface Data {
interfaces: Iface[];
@@ -31,94 +31,82 @@
let { data }: { data: Data } = $props();
const results = createQueries(() => ({
queries: [
{
queryKey: ['network-interfaces'],
queryFn: async () => {
return await getInterfaces();
},
keepPreviousData: true,
initialData: data.interfaces,
onSuccess: (data: Iface[]) => {
updateCache('network-interfaces', data);
}
},
{
queryKey: ['network-switches'],
queryFn: async () => {
return await getSwitches();
},
keepPreviousData: true,
initialData: data.switches,
onSuccess: (data: SwitchList) => {
updateCache('network-switches', data);
}
},
{
queryKey: ['dhcp-config'],
queryFn: async () => {
return await getDHCPConfig();
},
keepPreviousData: true,
initialData: data.dhcpConfig,
onSuccess: (data: DHCPConfig) => {
updateCache('dhcp-config', data);
}
},
{
queryKey: ['dhcp-ranges'],
queryFn: async () => {
return await getDHCPRanges();
},
keepPreviousData: true,
initialData: data.dhcpRanges,
onSuccess: (data: DHCPRange[]) => {
updateCache('dhcp-ranges', data);
}
},
{
queryKey: ['dhcp-leases'],
queryFn: async () => {
return await getLeases();
},
keepPreviousData: true,
initialData: data.dhcpLeases,
onSuccess: (data: Leases) => {
updateCache('dhcp-leases', data);
}
},
{
queryKey: ['network-objects'],
queryFn: async () => {
return await getNetworkObjects();
},
keepPreviousData: true,
initialData: data.networkObjects,
onSuccess: (data: NetworkObject[]) => {
updateCache('network-objects', data);
}
}
]
}));
let networkInterfaces = resource(
() => 'network-interfaces',
async (key, prevKey, { signal }) => {
const res = await getInterfaces();
updateCache(key, res);
return res;
},
{ initialValue: data.interfaces }
);
let networkSwitches = resource(
() => 'network-switches',
async (key, prevKey, { signal }) => {
const res = await getSwitches();
updateCache(key, res);
return res;
},
{ initialValue: data.switches }
);
let dhcpConfig = resource(
() => 'dhcp-config',
async (key, prevKey, { signal }) => {
const res = await getDHCPConfig();
updateCache(key, res);
return res;
},
{ initialValue: data.dhcpConfig }
);
let dhcpRanges = resource(
() => 'dhcp-ranges',
async (key, prevKey, { signal }) => {
const res = await getDHCPRanges();
updateCache(key, res);
return res;
},
{ initialValue: data.dhcpRanges }
);
let dhcpLeases = resource(
() => 'dhcp-leases',
async (key, prevKey, { signal }) => {
const res = await getLeases();
updateCache(key, res);
return res;
},
{ initialValue: data.dhcpLeases }
);
let networkObjects = resource(
() => 'network-objects',
async (key, prevKey, { signal }) => {
const res = await getNetworkObjects();
updateCache(key, res);
return res;
},
{ initialValue: data.networkObjects }
);
let networkInterfaces = $derived(results[0].data as Iface[]);
let networkSwitches = $derived(results[1].data as SwitchList);
let dhcpConfig = $derived(results[2].data as DHCPConfig);
let dhcpRanges = $derived(results[3].data as DHCPRange[]);
let dhcpLeases = $derived(results[4].data as Leases);
let networkObjects = $derived(results[5].data as NetworkObject[]);
let reload = $state(false);
$effect(() => {
if (reload) {
results.forEach((result) => {
result.refetch();
});
reload = false;
watch(
() => reload,
(current) => {
if (current) {
networkInterfaces.refetch();
networkSwitches.refetch();
dhcpConfig.refetch();
dhcpRanges.refetch();
dhcpLeases.refetch();
networkObjects.refetch();
reload = false;
}
}
});
);
let modals = $state({
create: {
@@ -204,7 +192,7 @@
];
const rows: Row[] = [];
for (const entry of dhcpLeases.db) {
for (const entry of dhcpLeases.current.db) {
const range = `${entry.dhcpRange?.startIp} - ${entry.dhcpRange?.endIp}`;
const sw = entry.dhcpRange?.standardSwitchId
? entry.dhcpRange?.standardSwitch?.name
@@ -229,8 +217,8 @@
});
}
for (const entry of dhcpLeases.file) {
const found = dhcpLeases.db.find((e) => {
for (const entry of dhcpLeases.current.file) {
const found = dhcpLeases.current.db.find((e) => {
const ips = e.ipObject?.entries ? e.ipObject?.entries.map((i) => i.value) : [];
return e.hostname === entry.hostname && ips.includes(entry.ip);
});
@@ -327,13 +315,13 @@
{#if modals.create.open}
<CreateOrEdit
{networkInterfaces}
{networkSwitches}
{dhcpConfig}
{dhcpRanges}
{dhcpLeases}
networkInterfaces={networkInterfaces.current}
networkSwitches={networkSwitches.current}
dhcpConfig={dhcpConfig.current}
dhcpRanges={dhcpRanges.current}
dhcpLeases={dhcpLeases.current}
bind:reload
{networkObjects}
networkObjects={networkObjects.current}
bind:open={modals.create.open}
selectedLease={null}
/>
@@ -341,13 +329,13 @@
{#if modals.edit.open}
<CreateOrEdit
{networkInterfaces}
{networkSwitches}
{dhcpConfig}
{dhcpRanges}
{dhcpLeases}
networkInterfaces={networkInterfaces.current}
networkSwitches={networkSwitches.current}
dhcpConfig={dhcpConfig.current}
dhcpRanges={dhcpRanges.current}
dhcpLeases={dhcpLeases.current}
bind:reload
{networkObjects}
networkObjects={networkObjects.current}
bind:open={modals.edit.open}
selectedLease={modals.edit.id}
/>
@@ -10,11 +10,12 @@
import type { Column, Row } from '$lib/types/components/tree-table';
import type { DHCPConfig, DHCPRange } from '$lib/types/network/dhcp';
import type { Iface } from '$lib/types/network/iface';
import type { NetworkObject } from '$lib/types/network/object';
import type { SwitchList } from '$lib/types/network/switch';
import { handleAPIError, updateCache } from '$lib/utils/http';
import { secondsToDnsmasq } from '$lib/utils/string';
import { renderWithIcon } from '$lib/utils/table';
import { createQueries } from '@tanstack/svelte-query';
import { resource, watch } from 'runed';
import { toast } from 'svelte-sonner';
interface Data {
@@ -22,73 +23,65 @@
switches: SwitchList;
dhcpConfig: DHCPConfig;
dhcpRanges: DHCPRange[];
networkObjects: NetworkObject[];
}
let { data }: { data: Data } = $props();
const results = createQueries(() => ({
queries: [
{
queryKey: ['network-interfaces'],
queryFn: async () => {
return await getInterfaces();
},
keepPreviousData: true,
initialData: data.interfaces,
onSuccess: (data: Iface[]) => {
updateCache('network-interfaces', data);
}
},
{
queryKey: ['network-switches'],
queryFn: async () => {
return await getSwitches();
},
keepPreviousData: true,
initialData: data.switches,
onSuccess: (data: SwitchList) => {
updateCache('network-switches', data);
}
},
{
queryKey: ['dhcp-config'],
queryFn: async () => {
return await getDHCPConfig();
},
keepPreviousData: true,
initialData: data.dhcpConfig,
onSuccess: (data: DHCPConfig) => {
updateCache('dhcp-config', data);
}
},
{
queryKey: ['dhcp-ranges'],
queryFn: async () => {
return await getDHCPRanges();
},
keepPreviousData: true,
initialData: data.dhcpRanges,
onSuccess: (data: DHCPRange[]) => {
updateCache('dhcp-ranges', data);
}
}
]
}));
let networkInterfaces = resource(
() => 'network-interfaces',
async (key, prevKey, { signal }) => {
const res = await getInterfaces();
updateCache(key, res);
return res;
},
{ initialValue: data.interfaces }
);
let networkSwitches = resource(
() => 'network-switches',
async (key, prevKey, { signal }) => {
const res = await getSwitches();
updateCache(key, res);
return res;
},
{ initialValue: data.switches }
);
let dhcpConfig = resource(
() => 'dhcp-config',
async (key, prevKey, { signal }) => {
const res = await getDHCPConfig();
updateCache(key, res);
return res;
},
{ initialValue: data.dhcpConfig }
);
let dhcpRanges = resource(
() => 'dhcp-ranges',
async (key, prevKey, { signal }) => {
const res = await getDHCPRanges();
updateCache(key, res);
return res;
},
{ initialValue: data.dhcpRanges }
);
let networkInterfaces = $derived(results[0].data as Iface[]);
let networkSwitches = $derived(results[1].data as SwitchList);
let dhcpConfig = $derived(results[2].data as DHCPConfig);
let dhcpRanges = $derived(results[3].data as DHCPRange[]);
let reload = $state(false);
$effect(() => {
if (reload) {
results.forEach((result) => {
result.refetch();
});
reload = false;
watch(
() => reload,
(current) => {
if (current) {
networkInterfaces.refetch();
networkSwitches.refetch();
dhcpConfig.refetch();
dhcpRanges.refetch();
reload = false;
}
}
});
);
let modals = $state({
create: {
@@ -164,14 +157,14 @@
const rows: Row[] = [];
if (!dhcpRanges || dhcpRanges.length === 0) {
if (!dhcpRanges || dhcpRanges.current.length === 0) {
return {
columns,
rows
};
}
for (const range of dhcpRanges) {
for (const range of dhcpRanges.current) {
let swName = 'N/A';
if (range.standardSwitch) {
swName = range.standardSwitch.name;
@@ -196,7 +189,9 @@
let activeRow: Row | null = $derived(activeRows ? (activeRows[0] as Row) : ({} as Row));
let selectedRange = $derived(
dhcpRanges && activeRow ? dhcpRanges.find((r) => r.id === Number(activeRow.id)) || null : null
dhcpRanges && activeRow
? dhcpRanges.current.find((r) => r.id === Number(activeRow.id)) || null
: null
);
</script>
@@ -251,11 +246,11 @@
<CreateOrEdit
bind:open={modals.create.open}
bind:reload
{networkInterfaces}
{networkSwitches}
{dhcpConfig}
networkInterfaces={networkInterfaces.current}
networkSwitches={networkSwitches.current}
dhcpConfig={dhcpConfig.current}
selectedRange={null}
{dhcpRanges}
dhcpRanges={dhcpRanges.current}
/>
{/if}
@@ -263,11 +258,11 @@
<CreateOrEdit
bind:open={modals.edit.open}
bind:reload
{networkInterfaces}
{networkSwitches}
{dhcpConfig}
networkInterfaces={networkInterfaces.current}
networkSwitches={networkSwitches.current}
dhcpConfig={dhcpConfig.current}
{selectedRange}
{dhcpRanges}
dhcpRanges={dhcpRanges.current}
/>
{/if}
@@ -2,6 +2,7 @@ import { getDHCPConfig, getDHCPRanges } from '$lib/api/network/dhcp';
import { getInterfaces } from '$lib/api/network/iface';
import { getSwitches } from '$lib/api/network/switch';
import { cachedFetch } from '$lib/utils/http';
import { getNetworkObjects } from '$lib/api/network/object';
export async function load() {
const cacheDuration = 1000 * 60000;
@@ -9,7 +9,6 @@
import { updateCache } from '$lib/utils/http';
import { generateTableData, getCleanIfaceData } from '$lib/utils/network/iface';
import { renderWithIcon } from '$lib/utils/table';
import { createQueries } from '@tanstack/svelte-query';
import type { CellComponent } from 'tabulator-tables';
import { getNetworkObjects } from '$lib/api/network/object';
import type { NetworkObject } from '$lib/types/network/object';
@@ -18,6 +17,7 @@
import { isMACNearOrEqual } from '$lib/utils/mac';
import type { VM } from '$lib/types/vm/vm';
import { getVMs } from '$lib/api/vm/vm';
import { resource } from 'runed';
interface Data {
interfaces: Iface[];
@@ -28,58 +28,35 @@
let { data }: { data: Data } = $props();
const results = createQueries(() => ({
queries: [
{
queryKey: ['networkInterfaces'],
queryFn: async () => {
return await getInterfaces();
},
refetchInterval: 1000,
keepPreviousData: true,
initialData: data.interfaces,
onSuccess: (data: Iface[]) => {
updateCache('networkInterfaces', data);
}
},
{
queryKey: ['networkObjects'],
queryFn: async () => {
return await getNetworkObjects();
},
keepPreviousData: true,
initialData: data.objects,
onSuccess: (data: NetworkObject[]) => {
updateCache('networkObjects', data);
}
},
{
queryKey: ['jail-list'],
queryFn: async () => {
return await getJails();
},
initialData: data.jails,
keepPreviousData: true,
refetchOnMount: 'always'
},
{
queryKey: ['vm-list'],
queryFn: async () => {
return await getVMs();
},
refetchInterval: 1000,
initialData: data.vms,
keepPreviousData: true,
refetchOnMount: 'always',
onSuccess: (data: VM[]) => {
updateCache('vm-list', data);
}
}
]
}));
let networkInterfaces = resource(
() => 'network-interfaces',
async (key, prevKey, { signal }) => {
const res = await getInterfaces();
updateCache(key, res);
return res;
},
{ initialValue: data.interfaces }
);
let jails = $derived(results[2].data as Jail[]);
let vms = $derived(results[3].data as VM[]);
let jails = resource(
() => 'jail-list',
async (key, prevKey, { signal }) => {
const res = await getJails();
updateCache(key, res);
return res;
},
{ initialValue: data.jails }
);
let vms = resource(
() => 'vm-list',
async (key, prevKey, { signal }) => {
const res = await getVMs();
updateCache(key, res);
return res;
},
{ initialValue: data.vms }
);
let columns: Column[] = $derived([
{
@@ -105,7 +82,7 @@
}
if (data.isEpair) {
const jail = jails.find((jail) =>
const jail = jails.current.find((jail) =>
jail?.networks?.some((net) =>
net?.macObj?.entries?.some(
(entry) =>
@@ -120,7 +97,7 @@
}
if (data.isTap) {
const vm = vms.find((vm) =>
const vm = vms.current.find((vm) =>
vm?.networks?.some((net) =>
net?.macObj?.entries?.some(
(entry) =>
@@ -201,7 +178,7 @@
}
]);
let tableData = $derived(generateTableData(columns, results[0].data as Iface[]));
let tableData = $derived(generateTableData(columns, networkInterfaces.current));
let activeRow: Row[] | null = $state(null);
let query: string = $state('');
let viewModal = $state({
@@ -219,7 +196,7 @@
});
function viewInterface(iface: string) {
const ifaceData = results[0].data?.find((i: Iface) => i.name === iface);
const ifaceData = networkInterfaces.current.find((i: Iface) => i.name === iface);
if (ifaceData) {
viewModal.KV = getCleanIfaceData(ifaceData);
viewModal.title = `Details - ${ifaceData.name}`;
@@ -11,7 +11,7 @@
import type { SwitchList } from '$lib/types/network/switch';
import { isAPIResponse, updateCache } from '$lib/utils/http';
import { generateTableData } from '$lib/utils/network/switch/manual';
import { createQueries } from '@tanstack/svelte-query';
import { resource, watch } from 'runed';
import { toast } from 'svelte-sonner';
interface Data {
@@ -21,43 +21,35 @@
let { data }: { data: Data } = $props();
const results = createQueries(() => ({
queries: [
{
queryKey: ['network-interfaces'],
queryFn: async () => {
return await getInterfaces();
},
keepPreviousData: true,
initialData: data.interfaces,
onSuccess: (data: Iface[]) => {
updateCache('network-interfaces', data);
}
},
{
queryKey: ['network-switches'],
queryFn: async () => {
return await getSwitches();
},
keepPreviousData: true,
initialData: data.switches,
onSuccess: (data: SwitchList) => {
updateCache('network-switches', data);
}
}
]
}));
let networkInterfaces = resource(
() => 'network-interfaces',
async (key, prevKey, { signal }) => {
const res = await getInterfaces();
updateCache(key, res);
return res;
},
{ initialValue: data.interfaces }
);
let networkSwitches = resource(
() => 'network-switches',
async (key, prevKey, { signal }) => {
const res = await getSwitches();
updateCache(key, res);
return res;
},
{ initialValue: data.switches }
);
const interfaces = $derived(results[0].data);
const switches = $derived(results[1].data);
const usable = $derived.by(() => {
const result: string[] = [];
const ifaces = interfaces ? interfaces.filter((iface) => iface.groups?.includes('bridge')) : [];
const ifaces = networkInterfaces.current
? networkInterfaces.current.filter((iface) => iface.groups?.includes('bridge'))
: [];
if (!ifaces.length) return [];
const standard = switches ? switches['standard'] || [] : [];
const manual = switches ? switches['manual'] || [] : [];
const standard = networkSwitches.current ? networkSwitches.current['standard'] || [] : [];
const manual = networkSwitches.current ? networkSwitches.current['manual'] || [] : [];
for (const iface of ifaces) {
const usedInStandard = standard.some((sw) => sw.bridgeName === iface.name);
const usedInManual = manual.some((sw) => sw.bridge === iface.name);
@@ -70,24 +62,22 @@
return result;
});
let tableData = $derived(generateTableData(switches));
let tableData = $derived(generateTableData(networkSwitches.current));
let activeRows: Row[] | null = $state(null);
let activeRow: Row | null = $derived(activeRows ? (activeRows[0] as Row) : ({} as Row));
let query: string = $state('');
function reloadData() {
results.forEach((result) => {
result.refetch();
});
}
let reload = $state(false);
$effect(() => {
if (reload) {
reloadData();
reload = false;
watch(
() => reload,
(current) => {
if (current) {
networkInterfaces.refetch();
networkSwitches.refetch();
reload = false;
}
}
});
);
let modals = $state({
newSwitch: {
@@ -159,7 +149,7 @@
actions={{
onConfirm: async () => {
const result = await deleteManualSwitch(modals.deleteSwitch.id);
reloadData();
reload = true;
if (isAPIResponse(result) && result.status === 'success') {
toast.success(`Switch ${modals.deleteSwitch.name} deleted`, {
position: 'bottom-center'
@@ -20,7 +20,7 @@
import { generateTableData } from '$lib/utils/network/switch/standard';
import { isValidMTU, isValidVLAN } from '$lib/utils/numbers';
import { isValidSwitchName } from '$lib/utils/string';
import { createQueries } from '@tanstack/svelte-query';
import { resource, watch } from 'runed';
import { toast } from 'svelte-sonner';
interface Data {
@@ -31,54 +31,42 @@
let { data }: { data: Data } = $props();
const results = createQueries(() => ({
queries: [
{
queryKey: ['network-interfaces'],
queryFn: async () => {
return await getInterfaces();
},
keepPreviousData: true,
initialData: data.interfaces,
onSuccess: (data: Iface[]) => {
updateCache('network-interfaces', data);
}
},
{
queryKey: ['network-switches'],
queryFn: async () => {
return await getSwitches();
},
keepPreviousData: true,
initialData: data.switches,
onSuccess: (data: SwitchList) => {
updateCache('network-switches', data);
}
},
{
queryKey: ['network-objects'],
queryFn: async () => {
return await getNetworkObjects();
},
keepPreviousData: true,
initialData: data.objects,
onSuccess: (data: NetworkObject[]) => {
updateCache('network-objects', data);
}
}
]
}));
const networkInterfaces = resource(
() => 'network-interfaces',
async (key, prevKey, { signal }) => {
const res = await getInterfaces();
updateCache(key, res);
return res;
},
{ initialValue: data.interfaces }
);
const interfaces = $derived(results[0].data);
const switches = $derived(results[1].data);
const networkObjects = $derived(results[2].data);
const switches = resource(
() => 'network-switches',
async (key, prevKey, { signal }) => {
const res = await getSwitches();
updateCache(key, res);
return res;
},
{ initialValue: data.switches }
);
const networkObjects = resource(
() => 'network-objects',
async (key, prevKey, { signal }) => {
const res = await getNetworkObjects();
updateCache(key, res);
return res;
},
{ initialValue: data.objects }
);
let query: string = $state('');
let useablePorts = $derived.by(() => {
let available: string[] = [];
if (interfaces) {
for (const iface of interfaces) {
if (networkInterfaces.current) {
for (const iface of networkInterfaces.current) {
available.push(iface.name);
}
}
@@ -153,11 +141,19 @@
}
});
function reloadData() {
results.forEach((result) => {
result.refetch();
});
}
let reload = $state(false);
watch(
() => reload,
(current) => {
if (current) {
networkInterfaces.refetch();
switches.refetch();
networkObjects.refetch();
reload = false;
}
}
);
async function confirmAction() {
if (confirmModals.active === 'newSwitch' || confirmModals.active === 'editSwitch') {
@@ -199,7 +195,7 @@
!activeModal.dhcp &&
activeModal.defaultRoute
) {
const existingSwitch = switches?.standard?.find(
const existingSwitch = switches.current?.standard?.find(
(sw) =>
sw.defaultRoute && !(confirmModals.active === 'editSwitch' && sw.id === activeRow?.id)
);
@@ -234,7 +230,7 @@
activeModal.defaultRoute
);
reloadData();
reload = true;
if (isAPIResponse(created) && created.status === 'success') {
toast.success(`Switch ${confirmModals.newSwitch.name} created`, {
@@ -267,7 +263,7 @@
activeModal.defaultRoute
);
reloadData();
reload = true;
if (isAPIResponse(edited) && edited.status === 'success') {
toast.success(`Switch ${confirmModals.editSwitch.name} updated`, {
@@ -284,7 +280,7 @@
}
}
let tableData = $derived(generateTableData(switches));
let tableData = $derived(generateTableData(switches.current));
let activeRows: Row[] | null = $state(null);
let activeRow: Row | null = $derived(activeRows ? (activeRows[0] as Row) : ({} as Row));
@@ -548,7 +544,7 @@
bind:open={comboBoxes.ipv4.open}
label={'IPv4 Network'}
bind:value={comboBoxes.ipv4.value}
data={generateNetworkOptions(networkObjects, 'IPv4')}
data={generateNetworkOptions(networkObjects.current, 'IPv4')}
classes="flex-1 space-y-1"
placeholder="Select IPv4 Network"
width="w-3/4"
@@ -560,7 +556,7 @@
bind:open={comboBoxes.ipv4Gw.open}
label={'IPv4 Gateway'}
bind:value={comboBoxes.ipv4Gw.value}
data={generateIPOptions(networkObjects, 'IPv4')}
data={generateIPOptions(networkObjects.current, 'IPv4')}
classes="flex-1 space-y-1"
placeholder="Select IPv4 Gateway"
width="w-3/4"
@@ -574,7 +570,7 @@
bind:open={comboBoxes.ipv6.open}
label={'IPv6 Network'}
bind:value={comboBoxes.ipv6.value}
data={generateNetworkOptions(networkObjects, 'IPv6')}
data={generateNetworkOptions(networkObjects.current, 'IPv6')}
classes="flex-1 space-y-1"
placeholder="Select IPv6 Network"
width="w-3/4"
@@ -589,7 +585,7 @@
bind:open={comboBoxes.ipv6Gw.open}
label={'IPv6 Gateway'}
bind:value={comboBoxes.ipv6Gw.value}
data={generateIPOptions(networkObjects, 'IPv6')}
data={generateIPOptions(networkObjects.current, 'IPv6')}
classes="flex-1 space-y-1"
placeholder="Select IPv6 Gateway"
width="w-3/4"
@@ -682,7 +678,7 @@
actions={{
onConfirm: async () => {
const result = await deleteSwitch(confirmModals.deleteSwitch.id);
reloadData();
reload = true;
if (isAPIResponse(result) && result.status === 'success') {
toast.success(`Switch ${confirmModals.deleteSwitch.name} deleted`, {
position: 'bottom-center'
+4 -24
View File
@@ -18,9 +18,6 @@
import { resource } from 'runed';
import { storage } from '$lib';
import { untrack } from 'svelte';
import { fade } from 'svelte/transition';
import { Carta, MarkdownEditor } from 'carta-md';
import 'carta-md/default.css';
interface Data {
notes: Note[];
@@ -176,11 +173,8 @@
let tableData = $derived(
generateTableData(columns, notes.current && Array.isArray(notes.current) ? notes.current : [])
);
let activeRow: Row[] | null = $state(null);
let query: string = $state('');
const carta = new Carta();
</script>
{#snippet button(type: string)}
@@ -254,7 +248,7 @@
{/if}
{/snippet}
<div class="flex h-full w-full flex-col" transition:fade|global={{ duration: 300 }}>
<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 />
@@ -336,15 +330,14 @@
<ScrollArea orientation="vertical" class="h-full">
{#if modalState.isEditMode}
<div class="h-1/2">
<!-- <CustomValueInput
<div>
<CustomValueInput
label={'Content'}
placeholder="This is a note"
bind:value={modalState.content}
classes="flex-1 space-y-1 "
type="textarea"
/> -->
<MarkdownEditor bind:value={modalState.content} {carta} />
/>
</div>
{:else}
<div class="mt-2">
@@ -424,16 +417,3 @@
}}
></AlertDialog>
</div>
<style>
:global(.carta-font-code) {
font-family: 'Fira Code', monospace;
font-size: 1.1rem;
line-height: 1.1rem;
letter-spacing: normal;
}
.carta-editor {
height: 10% !important;
}
</style>
@@ -13,7 +13,7 @@
import { handleAPIError, updateCache } from '$lib/utils/http';
import { convertDbTime } from '$lib/utils/time';
import { createQueries } from '@tanstack/svelte-query';
import { resource, watch } from 'runed';
import { toast } from 'svelte-sonner';
import type { CellComponent } from 'tabulator-tables';
@@ -23,39 +23,29 @@
}
let { data }: { data: Data } = $props();
const results = createQueries(() => ({
queries: [
{
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[]);
const users = resource(
() => 'users',
async (key, prevKey, { signal }) => {
const res = await listUsers();
updateCache(key, res);
return res;
},
{ initialValue: data.users }
);
let groups = resource(
() => 'groups',
async (key, prevKey, { signal }) => {
const res = await listGroups();
updateCache(key, res);
return res;
},
{ initialValue: data.groups }
);
let usersOptions = $derived.by(() => {
return users.map((user) => ({
return users.current.map((user) => ({
label: user.username,
value: user.username
}));
@@ -86,13 +76,25 @@
};
let properties = $state(options);
let reload = $state(false);
watch(
() => reload,
(current) => {
if (current) {
groups.refetch();
users.refetch();
reload = false;
}
}
);
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())) {
} else if (groups.current.some((g) => g.name === properties.create.name.trim())) {
error = 'Group name already exists';
}
@@ -108,6 +110,8 @@
properties.create.users.value
);
reload = true;
if (response.error) {
handleAPIError(response);
toast.error('Failed to create group', {
@@ -138,6 +142,8 @@
activeRow ? activeRow.name : ''
);
reload = true;
if (response.status === 'error') {
handleAPIError(response);
toast.error('Failed to add users to group', {
@@ -198,7 +204,7 @@
};
}
let tableData = $derived(generateTableData(users, groups));
let tableData = $derived(generateTableData(users.current, groups.current));
let query: string = $state('');
let activeRows: Row[] | null = $state(null);
let activeRow: Row | null = $derived(activeRows ? (activeRows[0] as Row) : ({} as Row));
@@ -277,7 +283,7 @@
{#if properties.create.open}
<Dialog.Root bind:open={properties.create.open}>
<Dialog.Content
class="sm:max-w-[425px]"
class="sm:max-w-106.25"
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeydown={(e) => e.preventDefault()}
>
@@ -350,7 +356,7 @@
{#if properties.addUsers.open}
<Dialog.Root bind:open={properties.addUsers.open}>
<Dialog.Content
class="sm:max-w-[425px]"
class="sm:max-w-106.25"
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeydown={(e) => e.preventDefault()}
>
@@ -419,6 +425,7 @@
actions={{
onConfirm: async () => {
const result = await deleteGroup(properties.delete.id);
reload = true;
if (result.status === 'error') {
handleAPIError(result);
toast.error('Failed to delete group', {
@@ -9,7 +9,7 @@
import type { Column, Row } from '$lib/types/components/tree-table';
import { handleAPIError, updateCache } from '$lib/utils/http';
import { convertDbTime, getLastUsage } from '$lib/utils/time';
import { createQuery } from '@tanstack/svelte-query';
import { resource, watch } from 'runed';
import { toast } from 'svelte-sonner';
import type { CellComponent } from 'tabulator-tables';
@@ -60,29 +60,31 @@
return { rows, columns };
}
const results = createQuery(() => ({
queryKey: ['users'],
queryFn: async () => {
return (await listUsers()) as User[];
const users = resource(
() => 'users',
async (key, prevKey, { signal }) => {
const results = await listUsers();
updateCache('users', results);
return results;
},
keepPreviousData: true,
initialData: data.users,
onSuccess: (data: User[]) => {
updateCache('users', data);
{
initialValue: data.users
}
}));
);
let reload = $state(false);
$effect(() => {
if (reload) {
results.refetch();
reload = false;
watch(
() => reload,
(value) => {
if (value) {
users.refetch();
reload = false;
}
}
});
);
let users: User[] = $derived(results.data as User[]);
let tableData = $derived(generateTableData(users));
let tableData = $derived(generateTableData(users.current));
let query: string = $state('');
let activeRows: Row[] | null = $state(null);
let activeRow: Row | null = $derived(activeRows ? (activeRows[0] as Row) : ({} as Row));
@@ -103,7 +105,7 @@
}}
size="sm"
variant="outline"
class="h-6.5 !pointer-events-auto"
class="h-6.5 pointer-events-auto!"
disabled={!activeRow || activeRow.name === 'admin'}
title={activeRow && activeRow.name === 'admin' ? 'Cannot delete admin user' : ''}
>
@@ -122,7 +124,7 @@
}}
size="sm"
variant="outline"
class="h-6.5 !pointer-events-auto"
class="h-6.5 pointer-events-auto!"
disabled={!activeRow || activeRow.name === 'admin'}
title={activeRow && activeRow.name === 'admin' ? 'Cannot edit admin user' : ''}
>
@@ -160,15 +162,15 @@
</div>
{#if modals.create.open}
<CreateOrEdit bind:open={modals.create.open} {users} bind:reload />
<CreateOrEdit bind:open={modals.create.open} users={users.current} bind:reload />
{/if}
{#if modals.edit.open}
<CreateOrEdit
bind:open={modals.edit.open}
{users}
users={users.current}
edit={true}
user={activeRow ? (users.find((u) => u.id === activeRow.id) as User) : undefined}
user={activeRow ? (users.current.find((u) => u.id === activeRow.id) as User) : undefined}
bind:reload
/>
{/if}
@@ -8,7 +8,7 @@
import { type PCIDevice, type PPTDevice } from '$lib/types/system/pci';
import { updateCache } from '$lib/utils/http';
import { generateTableData } from '$lib/utils/system/pci';
import { createQueries } from '@tanstack/svelte-query';
import { resource, watch } from 'runed';
import { toast } from 'svelte-sonner';
interface Data {
@@ -17,47 +17,44 @@
}
let { data }: { data: Data } = $props();
const results = createQueries(() => ({
queries: [
{
queryKey: ['pci-devices'],
queryFn: async () => {
return await getPCIDevices();
},
keepPreviousData: true,
initialData: data.pciDevices,
onSuccess: (data: PCIDevice[]) => {
updateCache('pci-devices', data);
}
},
{
queryKey: ['ppt-devices'],
queryFn: async () => {
return await getPPTDevices();
},
keepPreviousData: true,
initialData: data.pptDevices,
onSuccess: (data: PPTDevice[]) => {
updateCache('ppt-devices', data);
}
}
]
}));
let pptDevices = resource(
() => 'ppt-devices',
async () => {
const result = await getPPTDevices();
updateCache('ppt-devices', result);
return result;
},
{
initialValue: data.pptDevices
}
);
let pciDevices = resource(
() => 'pci-devices',
async () => {
const result = await getPCIDevices();
updateCache('pci-devices', result);
return result;
},
{
initialValue: data.pciDevices
}
);
let reload = $state(false);
$effect(() => {
if (reload) {
results.forEach((result) => {
result.refetch();
});
reload = false;
watch(
() => reload,
(value) => {
if (value) {
pciDevices.refetch();
pptDevices.refetch();
reload = false;
}
}
});
);
let pciDevices: PCIDevice[] = $derived(results[0].data as PCIDevice[]);
let pptDevices: PPTDevice[] = $derived(results[1].data as PPTDevice[]);
let tableData = $derived(generateTableData(pciDevices, pptDevices));
let tableData = $derived(generateTableData(pciDevices.current, pptDevices.current));
let tableName: string = 'device-passthrough-tt';
let query: string = $state('');
let activeRow: Row[] | null = $state(null);
@@ -181,7 +178,12 @@
});
} else {
let message = '';
if (result.error?.endsWith('in_use_by_vm')) {
if (
typeof result.error === 'string'
? result.error.endsWith('in_use_by_vm')
: Array.isArray(result.error) &&
result.error.some((e) => typeof e === 'string' && e.endsWith('in_use_by_vm'))
) {
message = 'Device is in use by a VM, failed to remove';
} else {
message = 'Failed to remove device from passthrough';
@@ -9,8 +9,7 @@
import type { SambaConfig } from '$lib/types/samba/config';
import { handleAPIError, updateCache } from '$lib/utils/http';
import { generateNanoId } from '$lib/utils/string';
import { createQueries } from '@tanstack/svelte-query';
import { untrack } from 'svelte';
import { resource, watch } from 'runed';
import { toast } from 'svelte-sonner';
import type { CellComponent } from 'tabulator-tables';
@@ -21,52 +20,46 @@
let { data }: { data: Data } = $props();
const results = createQueries(() => ({
queries: [
{
queryKey: ['samba-config'],
queryFn: async () => {
return await getSambaConfig();
},
keepPreviousData: true,
initialData: data.sambaConfig,
onSuccess: (data: SambaConfig) => {
updateCache('samba-config', data);
}
},
{
queryKey: ['network-interfaces'],
queryFn: async () => {
return await getInterfaces();
},
keepPreviousData: true,
initialData: data.interfaces,
onSuccess: (data: Iface[]) => {
updateCache('network-interfaces', data);
}
}
]
}));
let sambaConfig = resource(
() => 'samba-config',
async () => {
const result = await getSambaConfig();
updateCache('samba-config', result);
return result;
},
{
initialValue: data.sambaConfig
}
);
let networkInterfaces = resource(
() => 'network-interfaces',
async () => {
const result = await getInterfaces();
updateCache('network-interfaces', result);
return result;
},
{
initialValue: data.interfaces
}
);
let reload = $state(false);
$effect(() => {
if (reload) {
results.forEach((result) => {
result.refetch();
});
untrack(() => {
watch(
() => reload,
(value) => {
if (value) {
sambaConfig.refetch();
networkInterfaces.refetch();
reload = false;
});
}
}
});
);
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) {
for (const iface of networkInterfaces.current) {
if (iface.groups && iface.groups.length > 0) {
if (!iface.groups.includes('tap')) {
filtered.push(iface);
@@ -81,23 +74,23 @@
let options = {
unixCharset: {
value: (() => $state.snapshot(sambaConfig.unixCharset))(),
value: (() => $state.snapshot(sambaConfig.current.unixCharset))(),
open: false
},
workgroup: {
value: (() => $state.snapshot(sambaConfig.workgroup))(),
value: (() => $state.snapshot(sambaConfig.current.workgroup))(),
open: false
},
serverString: {
value: (() => $state.snapshot(sambaConfig.serverString))(),
value: (() => $state.snapshot(sambaConfig.current.serverString))(),
open: false
},
interfaces: {
value: (() => $state.snapshot(sambaConfig.interfaces))(),
value: (() => $state.snapshot(sambaConfig.current.interfaces))(),
open: false
},
bindInterfaces: {
value: (() => ($state.snapshot(sambaConfig.bindInterfacesOnly) ? 'Yes' : 'No'))(),
value: (() => ($state.snapshot(sambaConfig.current.bindInterfacesOnly) ? 'Yes' : 'No'))(),
open: false
}
};
@@ -139,29 +132,29 @@
],
rows: [
{
id: generateNanoId(`${sambaConfig.unixCharset}`),
id: generateNanoId(`${sambaConfig.current.unixCharset}`),
property: 'Unix Charset',
value: sambaConfig.unixCharset
value: sambaConfig.current.unixCharset
},
{
id: generateNanoId(`${sambaConfig.workgroup}`),
id: generateNanoId(`${sambaConfig.current.workgroup}`),
property: 'Workgroup',
value: sambaConfig.workgroup
value: sambaConfig.current.workgroup
},
{
id: generateNanoId(`${sambaConfig.serverString}`),
id: generateNanoId(`${sambaConfig.current.serverString}`),
property: 'Server String',
value: sambaConfig.serverString
value: sambaConfig.current.serverString
},
{
id: generateNanoId(`${sambaConfig.interfaces}`),
id: generateNanoId(`${sambaConfig.current.interfaces}`),
property: 'Interfaces',
value: sambaConfig.interfaces
value: sambaConfig.current.interfaces
},
{
id: generateNanoId(`${sambaConfig.bindInterfacesOnly}`),
id: generateNanoId(`${sambaConfig.current.bindInterfacesOnly}`),
property: 'Bind Interfaces Only',
value: sambaConfig.bindInterfacesOnly ? 'Yes' : 'No'
value: sambaConfig.current.bindInterfacesOnly ? 'Yes' : 'No'
}
]
});
@@ -10,11 +10,10 @@
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 { GZFSDatasetTypeSchema, type Dataset } from '$lib/types/zfs/dataset';
import { handleAPIError, updateCache } from '$lib/utils/http';
import { convertDbTime } from '$lib/utils/time';
import { createQueries } from '@tanstack/svelte-query';
import { untrack } from 'svelte';
import { resource, watch } from 'runed';
import { toast } from 'svelte-sonner';
import type { CellComponent } from 'tabulator-tables';
@@ -26,61 +25,56 @@
let { data }: { data: Data } = $props();
const results = createQueries(() => ({
queries: [
{
queryKey: ['zfs-datasets'],
queryFn: async () => {
return await getDatasets();
},
keepPreviousData: false,
initialData: data.datasets,
onSuccess: (data: Dataset[]) => {
updateCache('zfs-datasets', data);
}
},
{
queryKey: ['samba-shares'],
queryFn: async () => {
return await getSambaShares();
},
keepPreviousData: false,
initialData: data.shares,
onSuccess: (data: SambaShare[]) => {
updateCache('samba-shares', data);
}
},
{
queryKey: ['groups'],
queryFn: async () => {
return (await listGroups()) as Group[];
},
keepPreviousData: true,
initialData: data.groups,
onSuccess: (data: Group[]) => {
updateCache('groups', data);
}
}
]
}));
let datasets = resource(
() => 'zfs-filesystems',
async () => {
const result = await getDatasets(GZFSDatasetTypeSchema.enum.FILESYSTEM);
updateCache('zfs-filesystems', result);
return result;
},
{
initialValue: data.datasets
}
);
let shares = resource(
() => 'samba-shares',
async () => {
const result = await getSambaShares();
updateCache('samba-shares', result);
return result;
},
{
initialValue: data.shares
}
);
let groups = resource(
() => 'groups',
async () => {
const result = await listGroups();
updateCache('groups', result);
return result;
},
{
initialValue: data.groups
}
);
let reload = $state(false);
$effect(() => {
if (reload) {
results.forEach((result) => {
result.refetch();
});
untrack(() => {
watch(
() => reload,
(value) => {
if (value) {
datasets.refetch();
shares.refetch();
groups.refetch();
reload = false;
});
}
}
});
);
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));
@@ -174,7 +168,7 @@
};
}
let tableData = $derived(generateTableData(shares, datasets, groups));
let tableData = $derived(generateTableData(shares.current, datasets.current, groups.current));
</script>
{#snippet button(type: string)}
@@ -184,7 +178,7 @@
onclick={() => {
properties.edit.open = true;
properties.edit.share =
shares.find((share) => share.id === Number(activeRow?.id)) || null;
shares.current.find((share) => share.id === Number(activeRow?.id)) || null;
}}
size="sm"
variant="outline"
@@ -249,15 +243,21 @@
</div>
{#if properties.create.open}
<Share bind:open={properties.create.open} {shares} {datasets} {groups} bind:reload />
<Share
bind:open={properties.create.open}
shares={shares.current}
datasets={datasets.current}
groups={groups.current}
bind:reload
/>
{/if}
{#if properties.edit.open}
<Share
bind:open={properties.edit.open}
{shares}
{datasets}
{groups}
shares={shares.current}
datasets={datasets.current}
groups={groups.current}
share={properties.edit.share}
edit={properties.edit.open}
bind:reload
@@ -1,15 +1,18 @@
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 { GZFSDatasetTypeSchema } from '$lib/types/zfs/dataset';
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('zfs-datasets', async () => await getDatasets(), cacheDuration),
cachedFetch(
'zfs-filesystems',
async () => await getDatasets(GZFSDatasetTypeSchema.enum.FILESYSTEM),
cacheDuration
),
cachedFetch('samba-shares', async () => await getSambaShares(), cacheDuration),
cachedFetch('groups', async () => await listGroups(), cacheDuration)
]);
@@ -1,412 +0,0 @@
<script lang="ts">
import { getDatasets } from '$lib/api/zfs/datasets';
import { getPools, getPoolStats } from '$lib/api/zfs/pool';
import AreaChart from '$lib/components/custom/Charts/Area.svelte';
import BarChart from '$lib/components/custom/Charts/Bar.svelte';
import LineGraph from '$lib/components/custom/Charts/LineGraph.svelte';
import PieChart from '$lib/components/custom/Charts/Pie.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import * as Card from '$lib/components/ui/card';
import CustomComboBox from '$lib/components/ui/custom-input/combobox.svelte';
import type { Dataset } from '$lib/types/zfs/dataset';
import type { PoolStatPointsResponse, Zpool } from '$lib/types/zfs/pool';
import { updateCache } from '$lib/utils/http';
import {
getDatasetCompressionHist,
getPoolStatsCombined,
getPoolUsagePieData,
type StatType
} from '$lib/utils/zfs/pool';
import { createQueries } from '@tanstack/svelte-query';
import type { Chart } from 'chart.js';
import { onMount } from 'svelte';
interface Data {
pools: Zpool[];
datasets: Dataset[];
poolStats: PoolStatPointsResponse;
}
type CardType = 'pools' | 'datasets' | 'file_systems' | 'volumes' | 'snapshots';
let { data }: { data: Data } = $props();
let poolStatsRef: Chart | null = $state(null);
let datasetChartRef: Chart | null = $state(null);
let poolStatsInterval = $state('1');
const results = createQueries(() => ({
queries: [
{
queryKey: ['poolList'],
queryFn: async () => {
return await getPools();
},
refetchInterval: 1000,
keepPreviousData: false,
initialData: data.pools,
onSuccess: (data: Zpool[]) => {
updateCache('pools', data);
}
},
{
queryKey: ['datasetList'],
queryFn: async () => {
return await getDatasets();
},
refetchInterval: 1000,
keepPreviousData: false,
initialData: data.datasets,
onSuccess: (data: Dataset[]) => {
updateCache('datasets', data);
}
},
{
queryKey: ['pool-stats', poolStatsInterval],
queryFn: async () => {
return await getPoolStats(Number(poolStatsInterval), 128);
},
refetchInterval: 1000,
keepPreviousData: false,
initialData: Array.isArray(data.poolStats) ? data.poolStats[0] : data.poolStats,
onSuccess: (data: PoolStatPointsResponse) => {
updateCache('pool-stats', data);
}
}
]
}));
let pools: Zpool[] = $derived(results[0].data as Zpool[]);
let datasets: Dataset[] = $derived(results[1].data as Dataset[]);
let poolStats: PoolStatPointsResponse = $derived(results[2].data as PoolStatPointsResponse);
let filesystems: Dataset[] = $derived.by(() => {
return datasets.filter((dataset) => dataset.type === 'filesystem');
});
let volumes: Dataset[] = $derived.by(() => {
return datasets.filter((dataset) => dataset.type === 'volume');
});
let snapshots: Dataset[] = $derived.by(() => {
return datasets.filter((dataset) => dataset.type === 'snapshot');
});
const counts = $derived({
pools: pools.length,
datasets: datasets.length,
file_systems: filesystems.length,
volumes: volumes.length,
snapshots: snapshots.length
});
function getCardDetails(type: string) {
switch (type) {
case 'pools':
return {
title: 'Pools',
icon: 'bi--hdd-stack-fill'
};
case 'datasets':
return {
title: 'Datasets',
icon: 'material-symbols--dataset'
};
case 'file_systems':
return {
title: 'Filesystems',
icon: 'eos-icons--file-system'
};
case 'volumes':
return {
title: 'Volumes',
icon: 'carbon--volume-block-storage'
};
case 'snapshots':
return {
title: 'Snapshots',
icon: 'carbon--ibm-cloud-vpc-block-storage-snapshots'
};
default:
return {
title: '',
icon: ''
};
}
}
let comboBoxes = $state({
poolUsage: {
open: false,
value: '',
data: [] as { value: string; label: string }[]
},
datasetCompression: {
open: false,
value: '',
data: [] as { value: string; label: string }[]
},
poolStats: {
interval: {
open: false,
value: '1',
data: [] as { value: string; label: string }[]
},
statType: {
open: false,
value: 'allocated',
data: [
{ value: 'allocated', label: 'Allocated' },
{ value: 'free', label: 'Free' },
{ value: 'size', label: 'Size' },
{ value: 'dedupRatio', label: 'Dedup Ratio' }
]
}
}
});
onMount(() => {
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: {
data: getPoolUsagePieData(pools, comboBoxes.poolUsage.value)
}
};
});
let histograms = $derived.by(() => {
return {
compression: {
data: getDatasetCompressionHist(comboBoxes.datasetCompression.value, datasets)
}
};
});
let { poolStatSeries } = $derived.by(() => {
const statType = comboBoxes.poolStats.statType.value;
const { poolStatsData, poolStatsKeys } = getPoolStatsCombined(
poolStats.poolStatPoint,
statType as StatType
);
const poolStatSeries = poolStatsKeys.map((series, index) => ({
field: series.key,
label: series.title,
color: series.color,
data: poolStatsData[index].map((item) => ({
date: item.date,
value: item[series.key]
}))
}));
return { poolStatSeries };
});
</script>
{#snippet card(type: string)}
<Card.Root class="gap-2 p-5">
<Card.Header class="p-0">
<Card.Title class="p-0">
<div class="flex items-center">
<span
class={`icon-[${getCardDetails(type).icon}] mr-2`}
style="width: 18px; height: 18px;"
></span>
<span class="font-normal">{getCardDetails(type).title}</span>
</div>
</Card.Title>
</Card.Header>
<Card.Content class="p-0 pl-1">
<p class="text-xl font-semibold">
{counts[type as CardType]}
</p>
</Card.Content>
</Card.Root>
{/snippet}
<div class="p-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-3 lg:grid-cols-5">
{#each ['pools', 'datasets', 'file_systems', 'volumes', 'snapshots'] as type}
<div>
{@render card(type)}
</div>
{/each}
</div>
{#if pools.length > 0}
<Card.Root class="mt-4 w-full flex-col">
<Card.Header>
<Card.Title class="mb-[-100px]">
<div
class="flex w-full flex-col items-start justify-between gap-2 md:flex-row md:items-center"
>
<div class="flex items-center">
<span class="icon-[mdi--data-usage] mr-2"></span>
<span class="text-sm font-bold md:text-lg xl:text-xl">Pool Stats</span>
</div>
<div class="flex items-center gap-2">
<CustomComboBox
bind:open={comboBoxes.poolStats.statType.open}
label=""
bind:value={comboBoxes.poolStats.statType.value}
data={comboBoxes.poolStats.statType.data}
classes=""
placeholder="Select a stat type"
width="w-48"
disallowEmpty={true}
/>
<CustomComboBox
bind:open={comboBoxes.poolStats.interval.open}
label=""
bind:value={poolStatsInterval}
data={comboBoxes.poolStats.interval.data}
classes=""
placeholder="Select a interval"
width="w-48"
disallowEmpty={true}
/>
<Button
onclick={() => poolStatsRef?.resetZoom()}
variant="outline"
size="sm"
class="h-9"
>
<span class="icon-[carbon--reset] h-4 w-4"></span>
Reset zoom
</Button>
</div>
</div>
</Card.Title>
</Card.Header>
<Card.Content class="mt-10 flex-1 overflow-hidden md:mt-2">
<AreaChart
bind:chart={poolStatsRef}
elements={poolStatSeries}
formatSize={comboBoxes.poolStats.statType.value !== 'dedupRatio'}
containerClass="border-none shadow-none !p-0"
icon=""
showResetButton={false}
/>
</Card.Content>
</Card.Root>
<div class="mt-4 grid h-full w-full grid-cols-12 gap-4">
<Card.Root class="col-span-12 min-h-[300px] w-full flex-col md:col-span-8">
<Card.Header>
<Card.Title class="mb-[-100px]">
<div
class="flex w-full flex-col items-start justify-between gap-2 md:flex-row md:items-center"
>
<div class="flex items-center">
<span class="icon-[mdi--data-usage] mr-2"></span>
<span class="text-sm font-bold md:text-lg xl:text-xl">Dataset Compression</span>
</div>
<div class="flex items-center gap-2">
<CustomComboBox
bind:open={comboBoxes.datasetCompression.open}
label=""
bind:value={comboBoxes.datasetCompression.value}
data={comboBoxes.datasetCompression.data}
classes=""
placeholder="Select a pool"
width="w-48"
disallowEmpty={true}
/>
<Button
onclick={() => datasetChartRef?.resetZoom()}
variant="outline"
size="sm"
class="h-9"
>
<span class="icon-[carbon--reset] h-4 w-4"></span>
Reset zoom
</Button>
</div>
</div>
</Card.Title>
</Card.Header>
<Card.Content class="mt-10 flex-1 overflow-hidden md:mt-2">
{#if histograms.compression.data.length === 0}
<div class="flex h-full items-center justify-center">
<p class="text-md">No data available</p>
</div>
{:else}
<BarChart
bind:chart={datasetChartRef}
data={histograms.compression.data}
colors={{
baseline: 'chart-3',
value: 'chart-4'
}}
formatter="size-formatter"
showResetButton={false}
/>
{/if}
</Card.Content>
</Card.Root>
<Card.Root class="col-span-12 flex w-full flex-col md:col-span-4">
<Card.Header>
<Card.Title class="mb-[-100px]">
<div class="flex w-full items-center justify-between">
<div class="flex items-center">
<span class="icon-[mdi--data-usage] mr-2"></span>
<span class="text-sm font-bold md:text-lg xl:text-xl">Pool Usage</span>
</div>
<CustomComboBox
bind:open={comboBoxes.poolUsage.open}
label=""
bind:value={comboBoxes.poolUsage.value}
data={comboBoxes.poolUsage.data}
classes=""
placeholder="Select a pool"
width="w-48"
disallowEmpty={true}
/>
</div>
</Card.Title>
</Card.Header>
<Card.Content class="flex-1 overflow-hidden">
<div class="mt-4 flex h-full items-center justify-center">
<PieChart
containerClass="h-full w-full rounded flex items-start justify-center"
data={pieCharts.poolUsage.data}
formatter={'size-formatter'}
/>
</div>
</Card.Content>
</Card.Root>
</div>
{/if}
</div>
@@ -1,19 +0,0 @@
import { getDatasets } from '$lib/api/zfs/datasets';
import { getPools, getPoolStats } from '$lib/api/zfs/pool';
import { SEVEN_DAYS } from '$lib/utils';
import { cachedFetch } from '$lib/utils/http';
export async function load() {
const cacheDuration = SEVEN_DAYS;
const [datasets, pools, poolStats] = await Promise.all([
cachedFetch('datasets', async () => await getDatasets(), cacheDuration),
cachedFetch('pools', getPools, cacheDuration),
cachedFetch('pool-stats', async () => await getPoolStats(1, 128), cacheDuration)
]);
return {
pools: pools,
datasets: datasets,
poolStats: poolStats
};
}
@@ -25,12 +25,9 @@
import humanFormat from 'human-format';
import { toast } from 'svelte-sonner';
import { storage } from '$lib';
import { resource, useInterval, Debounced, IsDocumentVisible } from 'runed';
import { untrack } from 'svelte';
import { resource, useInterval, Debounced, IsDocumentVisible, watch } from 'runed';
import type { GFSStep } from '$lib/types/common';
import SimpleSelect from '$lib/components/custom/SimpleSelect.svelte';
import LayerBrush from '$lib/components/custom/Charts/LayerBrush.svelte';
import EChart from '$lib/components/custom/Charts/EChartSample.svelte';
import LineBrush from '$lib/components/custom/Charts/LineBrush/Single.svelte';
interface Data {
@@ -50,7 +47,7 @@
updateCache(key, result);
return result;
},
{ lazy: true, initialValue: data.vm }
{ initialValue: data.vm }
);
const domain = resource(
@@ -60,7 +57,7 @@
updateCache(key, result);
return result;
},
{ lazy: true, initialValue: data.domain }
{ initialValue: data.domain }
);
const stats = resource(
@@ -71,7 +68,7 @@
updateCache(key, result);
return result;
},
{ lazy: true, initialValue: data.stats }
{ initialValue: data.stats }
);
const visible = new IsDocumentVisible();
@@ -86,15 +83,16 @@
}
});
$effect(() => {
if (storage.visible) {
untrack(() => {
watch(
() => storage.idle,
(idle) => {
if (!idle) {
vm.refetch();
domain.refetch();
stats.refetch();
});
}
}
});
);
let recentStat = $derived(
stats.current[stats.current.length - 1] || getObjectSchemaDefaults(VMStatSchema)
@@ -450,6 +448,17 @@
color="one"
containerContentHeight="h-64"
/>
<LineBrush
title="Memory Usage"
points={stats.current.map((data) => ({
date: new Date(data.createdAt).getTime(),
value: Number(data.memoryUsage)
}))}
percentage={true}
color="two"
containerContentHeight="h-64"
/>
</div>
</ScrollArea>
</div>
@@ -12,6 +12,8 @@ export async function load({ params }) {
cachedFetch(`vm-stats-${rid}`, async () => getStats(Number(rid), 'hourly'), cacheDuration)
]);
console.log('VM Summary Load:', { rid, vm, domain, stats });
return {
rid: Number(rid),
vm: vm,
+19 -20
View File
@@ -13,9 +13,9 @@
import type { Column, Row } from '$lib/types/components/tree-table';
import { handleAPIError, updateCache } from '$lib/utils/http';
import { renderWithIcon } from '$lib/utils/table';
import { createQuery } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import type { CellComponent } from 'tabulator-tables';
import { resource } from 'runed';
interface Data {
cluster: ClusterDetails;
@@ -23,34 +23,33 @@
let { data }: { data: Data } = $props();
let reload = $state(false);
const results = createQuery(() => ({
queryKey: ['cluster-info'],
queryFn: async () => {
return await getDetails();
const datacenter = resource(
() => 'cluster-info',
async (key, prevKey, { signal }) => {
const res = await getDetails();
updateCache('cluster-info', res);
return res;
},
keepPreviousData: true,
initialData: data.cluster,
refetchOnMount: 'always',
onSuccess: (data: ClusterDetails) => {
updateCache('cluster-info', data);
}
}));
{ initialValue: data.cluster }
);
$effect(() => {
if (reload) {
results.refetch();
datacenter.refetch();
reload = false;
}
});
let dataCenter = $derived(results.data);
let canReset = $derived(dataCenter?.cluster.enabled === true);
let canReset = $derived(datacenter.current.cluster.enabled === true);
let canCreate = $derived(
dataCenter?.cluster.raftBootstrap === null && dataCenter?.cluster.enabled === false
datacenter.current.cluster.raftBootstrap === null &&
datacenter.current.cluster.enabled === false
);
let canJoin = $derived(
dataCenter?.cluster.raftBootstrap !== true && dataCenter?.cluster.enabled === false
datacenter.current.cluster.raftBootstrap !== true &&
datacenter.current.cluster.enabled === false
);
let modals = $state({
@@ -117,8 +116,8 @@
}
];
if (dataCenter?.nodes) {
for (const node of dataCenter.nodes) {
if (datacenter.current.nodes) {
for (const node of datacenter.current.nodes) {
rows.push({
id: node.id,
leader: node.isLeader,
@@ -199,7 +198,7 @@
</div>
<Create bind:open={modals.create.open} bind:reload />
<JoinInformation bind:open={modals.view.open} cluster={dataCenter} />
<JoinInformation bind:open={modals.view.open} cluster={datacenter.current} />
<Join bind:open={modals.join.open} bind:reload />
<AlertDialog
+37 -32
View File
@@ -13,7 +13,8 @@
import { handleAPIError, isAPIResponse, updateCache } from '$lib/utils/http';
import { generateTableData, markdownToTailwindHTML } from '$lib/utils/info/notes';
import { convertDbTime } from '$lib/utils/time';
import { createQuery } from '@tanstack/svelte-query';
import Icon from '@iconify/svelte';
import { resource, watch } from 'runed';
import { toast } from 'svelte-sonner';
import type { CellComponent } from 'tabulator-tables';
@@ -22,19 +23,28 @@
}
let { data }: { data: Data } = $props();
const results = createQuery(() => ({
queryKey: ['cluster-notes'],
queryFn: async () => {
let notes = resource(
() => 'cluster-notes',
async () => {
return (await getNotes()) as Note[];
},
refetchInterval: 1000,
keepPreviousData: true,
initialData: data.notes,
refetchOnMount: 'always'
}));
{
initialValue: data.notes
}
);
let reload = $state(false);
watch(
() => reload,
(value) => {
if (value) {
notes.refetch();
reload = false;
}
}
);
let notes: Note[] = $derived(results.data as Note[]);
let modalState = $state({
title: '',
content: '',
@@ -81,7 +91,7 @@
// }
} else {
const response = await createNote(modalState.title, modalState.content);
results.refetch();
reload = true;
if (response.status === 'success') {
toast.success('Note created', { position: 'bottom-center' });
handleNote(undefined, false, true);
@@ -97,7 +107,7 @@
}
function viewNote(id: number | string | undefined) {
const note = notes.find((note) => note.id === id);
const note = notes.current.find((note) => note.id === id);
if (note) {
modalState.title = note.title;
modalState.content = note.content;
@@ -107,7 +117,7 @@
}
function handleDelete(id: number | string | undefined) {
const note = notes.find((note) => note.id === id);
const note = notes.current.find((note) => note.id === id);
if (note) {
modalState.title = note.title;
modalState.content = note.content;
@@ -117,7 +127,7 @@
}
function handleBulkDelete(ids: number[]) {
const notesToDelete = notes.filter((note) => ids.includes(note.id));
const notesToDelete = notes.current.filter((note) => ids.includes(note.id));
if (notesToDelete.length > 0) {
modalState.title = `${notesToDelete.length} notes`;
modalState.isBulkDeleteOpen = true;
@@ -153,7 +163,7 @@
}
]);
let tableData = $derived(generateTableData(columns, notes));
let tableData = $derived(generateTableData(columns, notes.current));
let activeRow: Row[] | null = $state(null);
let query: string = $state('');
</script>
@@ -168,8 +178,7 @@
class="h-6.5"
>
<div class="flex items-center">
<span class="icon-[mdi--eye] mr-1 h-4 w-4"></span>
<Icon icon="mdi:eye" class="mr-1 h-4 w-4" />
<span>View</span>
</div>
</Button>
@@ -183,7 +192,7 @@
class="h-6.5"
>
<div class="flex items-center">
<span class="icon-[mdi--delete] mr-1 h-4 w-4"></span>
<Icon icon="mdi:delete" class="mr-1 h-4 w-4" />
<span>Delete</span>
</div>
</Button>
@@ -192,7 +201,7 @@
{#if type === 'edit-note'}
<Button
onclick={() => {
const note = notes.find((note) => activeRow && note.id === activeRow[0]?.id);
const note = notes.current.find((note) => activeRow && note.id === activeRow[0]?.id);
handleNote(note, true);
}}
size="sm"
@@ -200,7 +209,7 @@
class="h-6.5"
>
<div class="flex items-center">
<span class="icon-[mdi--note-edit] mr-1 h-4 w-4"></span>
<Icon icon="mdi:note-edit" class="mr-1 h-4 w-4" />
<span>Edit</span>
</div>
</Button>
@@ -219,7 +228,7 @@
class="h-6.5"
>
<div class="flex items-center">
<span class="icon-[material-symbols--delete-sweep] mr-1 h-4 w-4"></span>
<Icon icon="material-symbols:delete-sweep" class="mr-1 h-4 w-4" />
<span>Bulk Delete</span>
</div>
</Button>
@@ -233,7 +242,7 @@
<Button onclick={() => handleNote()} size="sm" class="h-6 ">
<div class="flex items-center">
<span class="icon-[gg--add] mr-1 h-4 w-4"></span>
<Icon icon="gg:add" class="mr-1 h-4 w-4" />
<span>New</span>
</div>
</Button>
@@ -249,8 +258,7 @@
<Dialog.Header class="">
<Dialog.Title class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class={`icon-[${modalState.isEditMode ? 'mdi:note-edit' : 'mdi:note'}] h-5 w-5`}
></span>
<Icon icon={modalState.isEditMode ? 'mdi:note-edit' : 'mdi:note'} class="h-5 w-5" />
<span>
{#if modalState.isEditMode}
{#if selectedId}
@@ -276,8 +284,7 @@
modalState.content = '';
}}
>
<span class="icon-[radix-icons--reset] pointer-events-none h-4 w-4"></span>
<Icon icon="radix-icons:reset" class="pointer-events-none h-4 w-4" />
<span class="sr-only">{'Reset'}</span>
</Button>
<Button
@@ -291,8 +298,7 @@
modalState.content = '';
}}
>
<span class="icon-[material-symbols--close-rounded] pointer-events-none h-4 w-4"
></span>
<Icon icon="material-symbols:close-rounded" class="pointer-events-none h-4 w-4" />
<span class="sr-only">{'Close'}</span>
</Button>
</div>
@@ -354,8 +360,7 @@
onConfirm: async () => {
const id = activeRow ? activeRow[0]?.id : null;
const result = await deleteNote(id as number);
results.refetch();
reload = true;
if (isAPIResponse(result) && result.status === 'success') {
toast.success('Note deleted', { position: 'bottom-center' });
handleNote(undefined, false, true);
@@ -381,7 +386,7 @@
? activeRow.map((row) => (typeof row.id === 'number' ? row.id : parseInt(row.id)))
: [];
const result = await deleteNote(ids[0]);
results.refetch();
reload = true;
if (isAPIResponse(result) && result.status === 'success') {
toast.success('Notes deleted', { position: 'bottom-center' });
handleNote(undefined, false, true);
@@ -1,184 +0,0 @@
<script lang="ts">
import { deleteS3Storage, getStorages } from '$lib/api/cluster/storage';
import Create from '$lib/components/custom/Cluster/Storage/Create.svelte';
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/index.js';
import type { ClusterStorages } from '$lib/types/cluster/storage';
import type { Column, Row } from '$lib/types/components/tree-table';
import { handleAPIError, updateCache } from '$lib/utils/http';
import { createQuery } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
interface Data {
storages: ClusterStorages;
}
let { data }: { data: Data } = $props();
const results = createQuery(() => ({
queryKey: ['cluster-storages'],
queryFn: async () => {
return await getStorages();
},
keepPreviousData: true,
initialData: data.storages,
refetchOnMount: 'always',
onSuccess: (data: ClusterStorages) => {
updateCache('cluster-storages', data);
}
}));
let reload = $state(false);
$effect(() => {
if (reload) {
results.refetch();
activeRows = null;
reload = false;
}
});
let storages = $derived(results.data as ClusterStorages);
let table = $derived.by(() => {
const rows = [];
const columns: Column[] = [
{
field: 'id',
title: 'ID',
visible: false
},
{
field: 'type',
title: 'Type'
},
{
field: 'name',
title: 'Name'
},
{
field: 'details',
title: 'Details',
formatter: 'html'
}
];
for (const s3Storage of storages.s3) {
rows.push({
id: s3Storage.id,
type: 'S3',
name: s3Storage.name,
details: `Bucket: ${s3Storage.bucket}<br/>Region: ${s3Storage.region}`
});
}
for (const dirStorage of storages.directories) {
rows.push({
id: dirStorage.id,
type: 'Directory',
name: dirStorage.name,
details: `Path: ${dirStorage.path}`
});
}
return {
columns,
rows
};
});
let activeRows: Row[] | null = $state(null);
let activeRow: Row | null = $derived(activeRows ? (activeRows[0] as Row) : ({} as Row));
let query = $state('');
let modals = $state({
create: {
open: false
},
delete: {
open: false,
type: '' as '' | 's3'
}
});
</script>
{#snippet button(type: string)}
{#if activeRows && activeRows?.length !== 0}
{#if type === 'delete'}
<Button
onclick={() => {
modals.delete.open = true;
if (activeRow?.type === 'S3') {
modals.delete.type = 's3';
}
}}
size="sm"
variant="outline"
class="h-6.5"
>
<div class="flex items-center">
<span class="icon-[mdi--delete] mr-1 h-4 w-4"></span>
<span>Delete</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={() => (modals.create.open = true)} size="sm" class="h-6 ">
<div class="flex items-center">
<span class="icon-[gg--add] mr-1 h-4 w-4"></span>
<span>New</span>
</div>
</Button>
{@render button('delete')}
</div>
<TreeTable
name="cluster-storages-tt"
data={table}
{query}
bind:parentActiveRow={activeRows}
multipleSelect={false}
/>
</div>
{#if modals.create.open}
<Create bind:open={modals.create.open} bind:reload {storages} />
{/if}
<AlertDialog
open={modals.delete.open}
customTitle={`This will delete ${activeRow?.name}`}
actions={{
onConfirm: async () => {
let deleteFunc = modals.delete.type === 's3' ? deleteS3Storage : deleteS3Storage;
const response = await deleteFunc(Number(activeRow?.id));
reload = true;
if (response.error) {
handleAPIError(response);
toast.error(`Failed to delete ${activeRow?.name}`, {
position: 'bottom-center'
});
return;
}
toast.success(`Deleted ${activeRow?.name}`, {
position: 'bottom-center'
});
modals.delete.open = false;
},
onCancel: () => {
modals.delete.open = false;
}
}}
></AlertDialog>
@@ -1,16 +0,0 @@
import { getNotes } from '$lib/api/cluster/notes';
import { getStorages } from '$lib/api/cluster/storage';
import type { Note } from '$lib/types/info/notes';
import { SEVEN_DAYS } from '$lib/utils';
import { cachedFetch } from '$lib/utils/http';
export async function load() {
const cacheDuration = SEVEN_DAYS;
const [storages] = await Promise.all([
cachedFetch('cluster-storages', async () => getStorages(), cacheDuration)
]);
return {
storages: storages
};
}
+92 -90
View File
@@ -2,21 +2,21 @@
import { getDetails, getNodes } from '$lib/api/cluster/cluster';
import { getCPUInfo } from '$lib/api/info/cpu';
import { getRAMInfo } from '$lib/api/info/ram';
import { getPoolsDiskUsage, getPoolsDiskUsageFull } from '$lib/api/zfs/pool';
import { getPoolsDiskUsageFull } from '$lib/api/zfs/pool';
import Arc from '$lib/components/custom/Charts/Arc.svelte';
import { Badge } from '$lib/components/ui/badge/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import type { ClusterDetails, ClusterNode } from '$lib/types/cluster/cluster';
import type { CPUInfo, CPUInfoHistorical } from '$lib/types/info/cpu';
import type { RAMInfo, RAMInfoHistorical } from '$lib/types/info/ram';
import type { CPUInfo } from '$lib/types/info/cpu';
import type { RAMInfo } from '$lib/types/info/ram';
import type { PoolsDiskUsage } from '$lib/types/zfs/pool';
import { getQuorumStatus } from '$lib/utils/cluster';
import { updateCache } from '$lib/utils/http';
import { capitalizeFirstLetter } from '$lib/utils/string';
import { dateToAgo } from '$lib/utils/time';
import { createQueries } from '@tanstack/svelte-query';
import humanFormat from 'human-format';
import { resource } from 'runed';
interface Data {
nodes: ClusterNode[];
@@ -27,78 +27,73 @@
}
let { data }: { data: Data } = $props();
const results = createQueries(() => ({
queries: [
{
queryKey: ['cluster-nodes'],
queryFn: async () => {
return (await getNodes()) as ClusterNode[];
},
refetchInterval: 1000,
keepPreviousData: true,
initialData: data.nodes,
refetchOnMount: 'always',
onSuccess: (data: ClusterNode[]) => {
updateCache('cluster-nodes', data);
}
},
{
queryKey: ['cluster-details'],
queryFn: async () => {
return (await getDetails()) as ClusterDetails;
},
refetchInterval: 1000,
keepPreviousData: true,
initialData: data.details,
refetchOnMount: 'always',
onSuccess: (data: ClusterDetails) => {
updateCache('cluster-details', data);
}
},
{
queryKey: ['cpu-info'],
queryFn: getCPUInfo,
keepPreviousData: true,
initialData: data.cpu,
refetchOnMount: 'always',
onSuccess: (data: CPUInfo | CPUInfoHistorical) => {
updateCache('cpu-info', data as CPUInfo);
}
},
{
queryKey: ['ram-info'],
queryFn: getRAMInfo,
keepPreviousData: true,
initialData: data.ram,
onSuccess: (data: RAMInfo | RAMInfoHistorical) => {
updateCache('ram-info', data);
},
refetchOnMount: true,
refetchOnWindowFocus: true
},
{
queryKey: ['total-disk-usage'],
queryFn: getPoolsDiskUsageFull,
keepPreviousData: true,
initialData: data.disk,
onSuccess: (data: PoolsDiskUsage) => {
updateCache('total-disk-usage', data);
},
refetchOnMount: true,
refetchOnWindowFocus: true
}
]
}));
let nodes = $derived(results[0].data ?? []);
let clusterDetails = $derived(results[1].data);
let cpuInfo = $derived(results[2].data as CPUInfo);
let ramInfo = $derived(results[3].data as RAMInfo);
let diskInfo = $derived(results[4].data as PoolsDiskUsage);
let clustered = $derived(clusterDetails?.cluster.enabled || false);
let nodes = resource(
() => 'cluster-nodes',
async (key, prevKey, { signal }) => {
const result = await getNodes();
updateCache('cluster-nodes', result);
return result;
},
{
initialValue: data.nodes
}
);
let clusterDetails = resource(
() => 'cluster-details',
async (key, prevKey, { signal }) => {
const result = await getDetails();
updateCache('cluster-details', result);
return result;
},
{
initialValue: data.details
}
);
let cpuInfo = resource(
() => 'cpu-info',
async (key, prevKey, { signal }) => {
const result = await getCPUInfo('current');
updateCache('cpu-info', result);
return result;
},
{
initialValue: data.cpu
}
);
let ramInfo = resource(
() => 'ram-info',
async (key, prevKey, { signal }) => {
const result = await getRAMInfo('current');
updateCache('ram-info', result);
return result;
},
{
initialValue: data.ram
}
);
let diskInfo = resource(
() => 'total-disk-usage',
async (key, prevKey, { signal }) => {
const result = await getPoolsDiskUsageFull();
updateCache('total-disk-usage', result);
return result;
},
{
initialValue: data.disk
}
);
let clustered = $derived.by(() => {
return clusterDetails?.current.cluster.enabled ?? false;
});
let total = $derived.by(() => {
if (nodes.length === 0) {
if (nodes.current.length === 0) {
return {
cpu: { total: 0, usage: 0 },
ram: { total: 0, usage: 0 },
@@ -106,17 +101,20 @@
};
}
const totalCPUs = nodes.reduce((acc, node) => acc + node.cpu, 0);
const used = nodes.reduce((acc, node) => acc + (node.cpu * node.cpuUsage) / 100, 0);
const totalCPUs = nodes.current.reduce((acc, node) => acc + node.cpu, 0);
const used = nodes.current.reduce((acc, node) => acc + (node.cpu * node.cpuUsage) / 100, 0);
const totalMemory = nodes.reduce((acc, node) => acc + node.memory, 0);
const usedMemory = nodes.reduce(
const totalMemory = nodes.current.reduce((acc, node) => acc + node.memory, 0);
const usedMemory = nodes.current.reduce(
(acc, node) => acc + ((node.memory ?? 0) * (node.memoryUsage ?? 0)) / 100,
0
);
const totalDisk = nodes.reduce((acc, node) => acc + node.disk, 0);
const usedDisk = nodes.reduce((acc, node) => acc + (node.disk * node.diskUsage) / 100, 0);
const totalDisk = nodes.current.reduce((acc, node) => acc + node.disk, 0);
const usedDisk = nodes.current.reduce(
(acc, node) => acc + (node.disk * node.diskUsage) / 100,
0
);
return {
cpu: {
@@ -134,9 +132,9 @@
};
});
let quorumStatus = $derived(getQuorumStatus(clusterDetails as ClusterDetails, nodes));
let quorumStatus = $derived(getQuorumStatus(clusterDetails.current, nodes.current));
let statusCounts = $derived.by(() => {
return nodes.reduce(
return nodes.current.reduce(
(acc, node) => {
acc[node.status] = (acc[node.status] || 0) + 1;
return acc;
@@ -227,21 +225,25 @@
<Arc value={total.disk.usage} subtitle={humanFormat(total.disk.total)} title="Disk" />
</div>
{:else}
<div class="flex flex-1 justify-center">
<Arc value={cpuInfo?.usage} title="CPU" subtitle="{cpuInfo?.physicalCores} vCPUs" />
</div>
<div class="flex flex-1 justify-center">
<Arc
value={ramInfo?.usedPercent}
title="RAM"
subtitle={humanFormat(ramInfo?.total || 0)}
value={cpuInfo.current.usage}
title="CPU"
subtitle="{cpuInfo.current.physicalCores} vCPUs"
/>
</div>
<div class="flex flex-1 justify-center">
<Arc
value={diskInfo?.usage || 0}
value={ramInfo.current.usedPercent}
title="RAM"
subtitle={humanFormat(ramInfo.current.total || 0)}
/>
</div>
<div class="flex flex-1 justify-center">
<Arc
value={diskInfo.current.usage || 0}
title="Disk"
subtitle={humanFormat(diskInfo?.total || 0)}
subtitle={humanFormat(diskInfo.current.total || 0)}
/>
</div>
{/if}
@@ -274,7 +276,7 @@
</Table.Row>
</Table.Header>
<Table.Body>
{#each nodes as node (node.id)}
{#each nodes.current as node (node.id)}
<Table.Row>
<Table.Cell>
<Badge variant="outline" class="text-muted-foreground px-1.5">