Merge branch 'AlchemillaHQ:master' into readme_edits

This commit is contained in:
Michael Bear
2025-09-17 20:49:09 -04:00
committed by GitHub
103 changed files with 15113 additions and 15241 deletions
+15
View File
@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: AlchemillaHQ
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+26 -2
View File
@@ -7,10 +7,33 @@
> [!WARNING]
> This project is still in development so expect breaking changes!
https://github.com/user-attachments/assets/bf727ef4-4316-4084-a61f-cb8ec978e43d
https://gist.github.com/user-attachments/assets/7a9d002c-f647-4872-8b55-6b0cb1ce563b
Sylve aims to be a lightweight, open-source virtualization platform for FreeBSD, leveraging [Bhyve](https://wiki.freebsd.org/bhyve) for VMs and [Jails](https://wiki.freebsd.org/Jails) for containerization, with deep [ZFS](https://docs.freebsd.org/en/books/handbook/zfs/) integration. It seeks to provide a streamlined, Proxmox-like experience tailored for FreeBSD environments. Its backend is written in Go and the frontend is written in Svelte (with Kit).
## Sponsors
Were proud to be supported by:
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./docs/sponsors/FreeBSD-White.png">
<img src="./docs/sponsors/FreeBSD-Red.png" alt="FreeBSD Foundation" width="200"/>
</picture>
&emsp;&emsp;&emsp;
<a href="https://alchemilla.io">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./docs/sponsors/Alchemilla-White.png">
<img src="./docs/sponsors/Alchemilla-Dark.png" alt="Alchemilla" width="150"/>
</picture>
</a>
</p>
- [FreeBSD Foundation](https://freebsdfoundation.org)
- [Alchemilla](https://alchemilla.io)
You can also support the project by [sponsoring us on GitHub](https://github.com/sponsors/AlchemillaHQ).
# Development Requirements
These only apply to the development version of Sylve, the production version will be a single binary.
@@ -35,6 +58,7 @@ Running Sylve is pretty easy, but `sylve` depends on some packages that you can
| 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 |
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:
@@ -53,7 +77,7 @@ samba_server_enable="YES"
Enabling `rctl` is required. Do this by adding the following line to `/boot/loader.conf`:
``` sh
```sh
kern.racct.enable=1
```
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+21 -3
View File
@@ -4,6 +4,10 @@ go 1.24.0
require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2
github.com/aws/aws-sdk-go-v2 v1.38.3
github.com/aws/aws-sdk-go-v2/config v1.31.6
github.com/aws/aws-sdk-go-v2/credentials v1.18.10
github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3
github.com/beevik/etree v1.5.1
github.com/cavaliergopher/grab/v3 v3.0.1
github.com/cenkalti/rain/v2 v2.2.1
@@ -17,6 +21,9 @@ require (
github.com/google/gopacket v1.1.19
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/go-hclog v1.6.2
github.com/hashicorp/raft v1.7.3
github.com/hashicorp/raft-boltdb v0.0.0-20250701115049-6cdf087e85ed
github.com/klauspost/cpuid/v2 v2.2.10
github.com/mackerelio/go-osstat v0.2.5
github.com/msteinert/pam v1.2.0
@@ -34,6 +41,20 @@ require (
github.com/BurntSushi/toml v1.4.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/armon/go-metrics v0.4.1 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.29.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.38.2 // indirect
github.com/aws/smithy-go v1.23.0 // indirect
github.com/boltdb/bolt v1.3.1 // indirect
github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
@@ -55,15 +76,12 @@ require (
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect
github.com/google/btree v1.1.3 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-hclog v1.6.2 // indirect
github.com/hashicorp/go-immutable-radix v1.0.0 // indirect
github.com/hashicorp/go-metrics v0.5.4 // indirect
github.com/hashicorp/go-msgpack v0.5.5 // indirect
github.com/hashicorp/go-msgpack/v2 v2.1.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/golang-lru v0.5.0 // indirect
github.com/hashicorp/raft v1.7.3 // indirect
github.com/hashicorp/raft-boltdb v0.0.0-20250701115049-6cdf087e85ed // indirect
github.com/jackpal/bencode-go v1.0.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
+38
View File
@@ -15,6 +15,42 @@ github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJ
github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aws/aws-sdk-go-v2 v1.38.3 h1:B6cV4oxnMs45fql4yRH+/Po/YU+597zgWqvDpYMturk=
github.com/aws/aws-sdk-go-v2 v1.38.3/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 h1:i8p8P4diljCr60PpJp6qZXNlgX4m2yQFpYk+9ZT+J4E=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1/go.mod h1:ddqbooRZYNoJ2dsTwOty16rM+/Aqmk/GOXrK8cg7V00=
github.com/aws/aws-sdk-go-v2/config v1.31.6 h1:a1t8fXY4GT4xjyJExz4knbuoxSCacB5hT/WgtfPyLjo=
github.com/aws/aws-sdk-go-v2/config v1.31.6/go.mod h1:5ByscNi7R+ztvOGzeUaIu49vkMk2soq5NaH5PYe33MQ=
github.com/aws/aws-sdk-go-v2/credentials v1.18.10 h1:xdJnXCouCx8Y0NncgoptztUocIYLKeQxrCgN6x9sdhg=
github.com/aws/aws-sdk-go-v2/credentials v1.18.10/go.mod h1:7tQk08ntj914F/5i9jC4+2HQTAuJirq7m1vZVIhEkWs=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6 h1:wbjnrrMnKew78/juW7I2BtKQwa1qlf6EjQgS69uYY14=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6/go.mod h1:AtiqqNrDioJXuUgz3+3T0mBWN7Hro2n9wll2zRUc0ww=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 h1:uF68eJA6+S9iVr9WgX1NaRGyQ/6MdIyc4JNUo6TN1FA=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6/go.mod h1:qlPeVZCGPiobx8wb1ft0GHT5l+dc6ldnwInDFaMvC7Y=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 h1:pa1DEC6JoI0zduhZePp3zmhWvk/xxm4NB8Hy/Tlsgos=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6/go.mod h1:gxEjPebnhWGJoaDdtDkA0JX46VRg1wcTHYe63OfX5pE=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6 h1:R0tNFJqfjHL3900cqhXuwQ+1K4G0xc9Yf8EDbFXCKEw=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6/go.mod h1:y/7sDdu+aJvPtGXr4xYosdpq9a6T9Z0jkXfugmti0rI=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6 h1:hncKj/4gR+TPauZgTAsxOxNcvBayhUlYZ6LO/BYiQ30=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6/go.mod h1:OiIh45tp6HdJDDJGnja0mw8ihQGz3VGrUflLqSL0SmM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6 h1:LHS1YAIJXJ4K9zS+1d/xa9JAA9sL2QyXIQCQFQW/X08=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6/go.mod h1:c9PCiTEuh0wQID5/KqA32J+HAgZxN9tOGXKCiYJjTZI=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6 h1:nEXUSAwyUfLTgnc9cxlDWy637qsq4UWwp3sNAfl0Z3Y=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6/go.mod h1:HGzIULx4Ge3Do2V0FaiYKcyKzOqwrhUZgCI77NisswQ=
github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3 h1:ETkfWcXP2KNPLecaDa++5bsQhCRa5M5sLUJa5DWYIIg=
github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3/go.mod h1:+/3ZTqoYb3Ur7DObD00tarKMLMuKg8iqz5CHEanqTnw=
github.com/aws/aws-sdk-go-v2/service/sso v1.29.1 h1:8OLZnVJPvjnrxEwHFg9hVUof/P4sibH+Ea4KKuqAGSg=
github.com/aws/aws-sdk-go-v2/service/sso v1.29.1/go.mod h1:27M3BpVi0C02UiQh1w9nsBEit6pLhlaH3NHna6WUbDE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2 h1:gKWSTnqudpo8dAxqBqZnDoDWCiEh/40FziUjr/mo6uA=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2/go.mod h1:x7+rkNmRoEN1U13A6JE2fXne9EWyJy54o3n6d4mGaXQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.38.2 h1:YZPjhyaGzhDQEvsffDEcpycq49nl7fiGcfJTIo8BszI=
github.com/aws/aws-sdk-go-v2/service/sts v1.38.2/go.mod h1:2dIN8qhQfv37BdUYGgEC8Q3tteM3zFxTI1MLO2O3J3c=
github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE=
github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/beevik/etree v1.5.1 h1:TC3zyxYp+81wAmbsi8SWUpZCurbxa6S8RITYRSkNRwo=
github.com/beevik/etree v1.5.1/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
@@ -162,6 +198,7 @@ github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
github.com/hashicorp/go-uuid v1.0.0 h1:RS8zrF7PhGwyNPOtxSClXXj9HA8feRnJzgnI1RJCSnM=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
@@ -261,6 +298,7 @@ github.com/otiai10/copy v1.14.1 h1:5/7E6qsUMBaH5AnQ0sSLzzTg1oTECmcCmT6lvF45Na8=
github.com/otiai10/copy v1.14.1/go.mod h1:oQwrEDDOci3IM8dJF0d8+jnbfPDllW6vUjNc3DoZm9I=
github.com/otiai10/mint v1.6.3 h1:87qsV/aw1F5as1eH1zS/yqHY85ANKVMgkDrf9rcxbQs=
github.com/otiai10/mint v1.6.3/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM=
github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY=
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
+1 -1
View File
@@ -10,5 +10,5 @@ package assets
import "embed"
//go:embed web-files/**
//go:embed all:web-files
var SvelteKitFiles embed.FS
+1
View File
@@ -99,6 +99,7 @@ func SetupDatabase(cfg *internal.SylveConfig, isTest bool) *gorm.DB {
&clusterModels.Cluster{},
&clusterModels.ClusterNode{},
&clusterModels.ClusterS3Config{},
&clusterModels.ClusterOption{},
&clusterModels.ClusterNote{},
)
+39 -2
View File
@@ -67,8 +67,9 @@ 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"`
Notes []ClusterNote `json:"notes"`
Options []ClusterOption `json:"options"`
S3Configs []ClusterS3Config `json:"s3Configs"`
// We can add more tables here as needed
}
@@ -102,6 +103,7 @@ 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},
// We can add more tables here as needed
}
@@ -165,6 +167,41 @@ 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("options", func(db *gorm.DB, action string, raw json.RawMessage) error {
var opt ClusterOption
if err := json.Unmarshal(raw, &opt); err != nil {
+36
View File
@@ -0,0 +1,36 @@
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"`
}
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
})
}
+1 -1
View File
@@ -54,7 +54,7 @@ type StandardSwitch struct {
type NetworkPort struct {
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" gorm:"unique;not null"`
Name string `json:"name" gorm:"not null"`
SwitchID int `json:"switchId" gorm:"not null"`
Switch StandardSwitch `gorm:"foreignKey:SwitchID"`
}
+1 -3
View File
@@ -21,14 +21,12 @@ func GetHistorical[T any](db *gorm.DB, limit int) ([]T, error) {
tableName := schema.NamingStrategy{}.TableName(reflect.TypeOf((*T)(nil)).Elem().Name())
err := db.Table(tableName).
Order("id DESC").
Order("strftime('%Y-%m-%d %H:%M', created_at) ASC").
Limit(limit).
Find(&records).Error
if err != nil {
return nil, err
}
return records, nil
}
+205
View File
@@ -0,0 +1,205 @@
package clusterHandlers
import (
"strconv"
"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"
"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 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,
})
}
}
// @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,
})
}
}
+3 -1
View File
@@ -122,7 +122,9 @@ func EnsureCorrectHost(db *gorm.DB) gin.HandlerFunc {
}
}
reqHost, err := utils.GetCurrentHostnameFromHeader(c.Request.Header)
requestCopy := c.Request.Clone(c.Request.Context())
reqHost, err := utils.GetCurrentHostnameFromHeader(c.Request.Header, requestCopy)
if err != nil {
c.Next()
return
+1
View File
@@ -39,6 +39,7 @@ func BasicInfo(infoService *info.Service) gin.HandlerFunc {
Error: err.Error(),
Data: nil,
})
return
}
c.JSON(http.StatusOK, internal.APIResponse[infoServiceInterfaces.BasicInfo]{
+2
View File
@@ -39,6 +39,7 @@ func RAMInfo(infoService *info.Service) gin.HandlerFunc {
Error: err.Error(),
Data: nil,
})
return
}
c.JSON(http.StatusOK, internal.APIResponse[infoServiceInterfaces.RAMInfo]{
@@ -107,6 +108,7 @@ func SwapInfo(infoService *info.Service) gin.HandlerFunc {
Error: err.Error(),
Data: nil,
})
return
}
c.JSON(http.StatusOK, internal.APIResponse[infoServiceInterfaces.SwapInfo]{
-30
View File
@@ -18,36 +18,6 @@ import (
"github.com/gin-gonic/gin"
)
/*
// @Summary List all Jails
// @Description Retrieve a list of all jails
// @Tags Jail
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} internal.APIResponse[[]jailModels.Jail] "Success"
// @Failure 500 {object} internal.APIResponse[any] "Internal Server Error"
// @Router /jail [get]
func ListJails(jailService *jail.Service) gin.HandlerFunc {
return func(c *gin.Context) {
jails, err := jailService.GetJails()
if err != nil {
c.JSON(500, internal.APIResponse[any]{Error: "failed_to_list_jails: " + err.Error()})
return
}
c.JSON(200, internal.APIResponse[[]jailModels.Jail]{
Status: "success",
Message: "jail_listed",
Data: jails,
Error: "",
})
}
}
// something like that for action /jail/action/{ctid}/{action}
*/
// @Summary Perform Jail Action
// @Description Perform an action (start/stop) on a specific jail
// @Tags Jail
+47
View File
@@ -10,6 +10,7 @@ package middleware
import (
"bytes"
"encoding/hex"
"encoding/json"
"io"
"net/http"
@@ -36,6 +37,52 @@ func EnsureAuthenticated(authService *authService.Service) gin.HandlerFunc {
return
}
if strings.HasPrefix(path, "/api/vnc/") {
if authHex := c.Query("auth"); authHex != "" {
var wssAuth struct {
Hash string `json:"hash"`
Hostname string `json:"hostname"`
Token string `json:"token"`
}
if data, err := hex.DecodeString(authHex); err == nil && json.Unmarshal(data, &wssAuth) == nil {
// 1) Try cluster JWT in wssAuth.Token
if wssAuth.Token != "" {
if claims, err := authService.VerifyClusterJWT(wssAuth.Token); err == nil {
c.Set("Token", wssAuth.Token)
c.Set("AuthScope", "wss-cluster")
c.Set("UserID", claims.UserID)
c.Set("Username", claims.Username)
c.Set("AuthType", claims.AuthType)
c.Next()
return
}
}
// 2) Fallback: use hash -> local JWT
if wssAuth.Hash != "" {
if tok, err := authService.GetTokenBySHA256(wssAuth.Hash); err == nil {
if claims, err := authService.ValidateToken(tok); err == nil {
c.Set("Token", tok)
c.Set("AuthScope", "wss")
c.Set("UserID", claims.UserID)
c.Set("Username", claims.Username)
c.Set("AuthType", claims.AuthType)
c.Next()
return
}
}
}
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"status": "error", "error": "invalid_vnc_auth"})
return
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"status": "error", "error": "missing_vnc_auth"})
return
}
if (path == "/api/cluster/accept-join" || strings.HasPrefix(path, "/api/health/basic")) &&
c.Request.Method == http.MethodPost {
raw, err := io.ReadAll(c.Request.Body)
+8
View File
@@ -274,6 +274,7 @@ func RegisterRoutes(r *gin.Engine,
}
jail := api.Group("/jail")
jail.Use(EnsureCorrectHost(db))
jail.Use(middleware.EnsureAuthenticated(authService))
jail.Use(middleware.RequestLoggerMiddleware(db, authService))
{
@@ -360,6 +361,13 @@ 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))
}
vnc := api.Group("/vnc")
vnc.Use(EnsureCorrectHost(db))
vnc.Use(middleware.EnsureAuthenticated(authService))
+55 -28
View File
@@ -9,27 +9,31 @@
package vncHandler
import (
"fmt"
"io"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/alchemillahq/sylve/internal/logger"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 32 * 1024,
WriteBufferSize: 32 * 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
ReadBufferSize: 8 * 1024,
WriteBufferSize: 8 * 1024,
EnableCompression: false,
CheckOrigin: func(r *http.Request) bool { return true },
}
const (
writeWait = 10 * time.Second
pongWait = 10 * time.Minute // how long well wait for the next pong
pingPeriod = pongWait / 2 // how often well send pings (must be < pongWait)
)
func VNCProxyHandler(c *gin.Context) {
port := c.Param("port")
if port == "" {
@@ -37,33 +41,58 @@ func VNCProxyHandler(c *gin.Context) {
return
}
vncAddress := fmt.Sprintf("localhost:%s", port)
rawConn, err := net.Dial("tcp", vncAddress)
rawConn, err := net.Dial("tcp", "localhost:"+port)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to connect to VNC on port %s", port)})
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to connect to VNC"})
return
}
defer rawConn.Close()
// Disable Nagle's algorithm for lower latency
if tcpConn, ok := rawConn.(*net.TCPConn); ok {
tcpConn.SetNoDelay(true)
if tcp, ok := rawConn.(*net.TCPConn); ok {
_ = tcp.SetNoDelay(true)
_ = tcp.SetKeepAlive(true)
_ = tcp.SetKeepAlivePeriod(30 * time.Second)
}
wsConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "WebSocket upgrade failed"})
return
}
defer wsConn.Close()
// Keepalive: expect a pong within pongWait; extend on each pong.
wsConn.SetReadDeadline(time.Now().Add(pongWait))
wsConn.SetPongHandler(func(string) error {
wsConn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
done := make(chan struct{})
var once sync.Once
closeDone := func() { once.Do(func() { close(done) }) }
const bufSize = 32 * 1024
buffer := make([]byte, bufSize)
// Ping loop (WS → client)
pingTicker := time.NewTicker(pingPeriod)
defer pingTicker.Stop()
go func() {
defer closeDone()
for {
select {
case <-done:
return
case <-pingTicker.C:
// Write a ping; if it fails, terminate.
_ = wsConn.SetWriteDeadline(time.Now().Add(writeWait))
if err := wsConn.WriteControl(websocket.PingMessage, nil, time.Now().Add(writeWait)); err != nil {
return
}
}
}
}()
buf := make([]byte, 32*1024)
// WS → VNC
go func() {
defer closeDone()
for {
@@ -71,39 +100,37 @@ func VNCProxyHandler(c *gin.Context) {
if err != nil {
return
}
if msgType != websocket.BinaryMessage {
io.Copy(io.Discard, reader)
_, _ = io.Copy(io.Discard, reader)
continue
}
if _, err := io.Copy(rawConn, reader); err != nil {
return
}
}
}()
// VNC → WS
go func() {
defer closeDone()
for {
n, err := rawConn.Read(buffer)
n, err := rawConn.Read(buf)
if err != nil {
if err != io.EOF {
if !strings.Contains(err.Error(), "use of closed network connection") {
logger.L.Debug().Err(err).Msg("Error reading from VNC connection")
}
if err != io.EOF && !strings.Contains(err.Error(), "use of closed network connection") {
logger.L.Debug().Err(err).Msg("VNC read error")
}
return
}
writer, err := wsConn.NextWriter(websocket.BinaryMessage)
_ = wsConn.SetWriteDeadline(time.Now().Add(writeWait))
w, err := wsConn.NextWriter(websocket.BinaryMessage)
if err != nil {
return
}
if _, err := writer.Write(buffer[:n]); err != nil {
writer.Close()
if _, err := w.Write(buf[:n]); err != nil {
_ = w.Close()
return
}
if err := writer.Close(); err != nil {
if err := w.Close(); err != nil {
return
}
}
@@ -0,0 +1,7 @@
package clusterServiceInterfaces
import clusterModels "github.com/alchemillahq/sylve/internal/db/models/cluster"
type Storages struct {
S3 []clusterModels.ClusterS3Config `json:"s3"`
}
@@ -0,0 +1,9 @@
// SPDX-License-Identifier: BSD-2-Clause
//
// Copyright (c) 2025 The FreeBSD Foundation.
//
// This software was developed by Hayzam Sherif <hayzam@alchemilla.io>
// of Alchemilla Ventures Pvt. Ltd. <hello@alchemilla.io>,
// under sponsorship from the FreeBSD Foundation.
package zfsServiceInterfaces
@@ -18,6 +18,7 @@ type Dataset struct {
GUID string `json:"guid"`
Used uint64 `json:"used"`
Avail uint64 `json:"avail"`
Recordsize uint64 `json:"recordsize"`
Mountpoint string `json:"mountpoint"`
Compression string `json:"compression"`
Type string `json:"type"`
+33
View File
@@ -164,6 +164,39 @@ 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)
}
}
}
if err := s.Raft.Barrier(10 * time.Second).Error(); err != nil {
return fmt.Errorf("barrier_after_backfill: %w", err)
}
+3 -5
View File
@@ -323,12 +323,10 @@ func (s *Service) PopulateClusterNodes() error {
for uuid := range exByUUID {
ids = append(ids, uuid)
}
if err := tx.Model(&clusterModels.ClusterNode{}).
if err := tx.
Where("node_uuid IN ?", ids).
Updates(map[string]any{
"status": "offline",
"updated_at": gorm.Expr("CURRENT_TIMESTAMP"),
}).Error; err != nil {
Delete(&clusterModels.ClusterNode{}).Error; err != nil {
return err
}
}
+150
View File
@@ -0,0 +1,150 @@
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"
)
/*
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"`
}
*/
func (s *Service) ListStorages() (clusterServiceInterfaces.Storages, error) {
var s3 []clusterModels.ClusterS3Config
err := s.DB.Order("id ASC").Find(&s3).Error
return clusterServiceInterfaces.Storages{S3: s3}, err
}
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
}
+15 -1
View File
@@ -12,6 +12,7 @@ import (
"github.com/alchemillahq/sylve/internal/db"
infoModels "github.com/alchemillahq/sylve/internal/db/models/info"
infoServiceInterfaces "github.com/alchemillahq/sylve/internal/interfaces/services/info"
"github.com/alchemillahq/sylve/pkg/system/swapctl"
ram "github.com/shirou/gopsutil/mem"
)
@@ -30,10 +31,23 @@ func (s *Service) GetRAMInfo() (infoServiceInterfaces.RAMInfo, error) {
}
func (s *Service) GetSwapInfo() (infoServiceInterfaces.SwapInfo, error) {
swapDevices, err := swapctl.GetSwapDevices()
if len(swapDevices) == 0 {
return infoServiceInterfaces.SwapInfo{
Total: 0,
Free: 0,
UsedPercent: 0,
}, nil
}
swapInfo, err := ram.SwapMemory()
if err != nil {
return infoServiceInterfaces.SwapInfo{}, err
return infoServiceInterfaces.SwapInfo{
Total: 0,
Free: 0,
UsedPercent: 0,
}, err
}
return infoServiceInterfaces.SwapInfo{
+1 -6
View File
@@ -94,12 +94,7 @@ func (s *Service) GetJailMountPoint(ctid uint) (string, error) {
}
for _, ds := range datasets {
guid, err := ds.GetProperty("guid")
if err != nil {
return "", fmt.Errorf("failed_to_get_dataset_guid: %w", err)
}
if guid == jail.Dataset {
if ds.GUID == jail.Dataset {
dataset = ds
break
}
+4 -18
View File
@@ -120,13 +120,9 @@ func (s *Service) ValidateCreate(data jailServiceInterfaces.CreateJailRequest) e
var dataset *zfs.Dataset
for _, d := range datasets {
guid, err := d.GetProperty("guid")
if err != nil {
return fmt.Errorf("failed_to_get_dataset_properties: %w", err)
}
if guid == data.Dataset {
if d.GUID == data.Dataset {
dataset = d
break
}
}
@@ -601,12 +597,7 @@ func (s *Service) CreateJail(data jailServiceInterfaces.CreateJailRequest) error
}
for _, d := range datasets {
guid, err := d.GetProperty("guid")
if err != nil {
return fmt.Errorf("failed_to_get_dataset_properties: %w", err)
}
if guid == data.Dataset {
if d.GUID == data.Dataset {
dataset = d
break
}
@@ -689,12 +680,7 @@ func (s *Service) DeleteJail(ctId uint, deleteMacs bool) error {
}
for _, d := range datasets {
guid, err := d.GetProperty("guid")
if err != nil {
return fmt.Errorf("failed_to_get_dataset_properties: %w", err)
}
if guid == jail.Dataset {
if d.GUID == jail.Dataset {
dataset = d
break
}
+65 -82
View File
@@ -249,8 +249,7 @@ func (s *Service) GetNetworkCleanedConfig(ctId uint) (string, error) {
func (s *Service) SyncNetwork(ctId uint, jail jailModels.Jail, save bool) error {
if save {
err := s.DB.Save(&jail).Error
if err != nil {
if err := s.DB.Save(&jail).Error; err != nil {
return err
}
}
@@ -262,32 +261,30 @@ func (s *Service) SyncNetwork(ctId uint, jail jailModels.Jail, save bool) error
var newCfg string
/* Moving from VNET to Inherited */
// Moving from VNET to Inherited
if jail.InheritIPv4 || jail.InheritIPv6 {
if jail.Networks != nil && len(jail.Networks) > 0 {
for _, network := range jail.Networks {
err = s.DeleteNetwork(ctId, network.ID)
if err != nil {
if err := s.DeleteNetwork(ctId, network.ID); err != nil {
return err
}
}
}
toAppend := ""
var toAppend strings.Builder
if jail.InheritIPv4 {
toAppend += fmt.Sprintf("\tip4=inherit;\n")
toAppend.WriteString("\tip4=inherit;\n")
}
if jail.InheritIPv6 {
toAppend += fmt.Sprintf("\tip6=inherit;\n")
toAppend.WriteString("\tip6=inherit;\n")
}
newCfg, err = s.AppendToConfig(ctId, cfg, toAppend)
newCfg, err = s.AppendToConfig(ctId, cfg, toAppend.String())
if err != nil {
return err
}
} else {
// VNET mode
if jail.Networks != nil && len(jail.Networks) > 0 {
ctidHash := utils.HashIntToNLetters(int(ctId), 5)
@@ -301,7 +298,7 @@ func (s *Service) SyncNetwork(ctId uint, jail jailModels.Jail, save bool) error
// vnet declaration once
b.WriteString("\tvnet;\n")
// Add one vnet.interface line per NIC
// vnet.interface per NIC
for _, n := range jail.Networks {
if n.SwitchID == 0 {
continue
@@ -309,18 +306,20 @@ func (s *Service) SyncNetwork(ctId uint, jail jailModels.Jail, save bool) error
b.WriteString(fmt.Sprintf("\tvnet.interface += \"%s_%db\";\n", ctidHash, n.SwitchID))
}
// Guard: only set default routes once
// Defaults only once
setV4Default := false
setV6Default := false
// Track if *any* NIC configured IPv6 (SLAAC or static)
sawAnyV6 := false
for _, n := range jail.Networks {
if n.SwitchID == 0 {
continue
}
networkId := n.SwitchID
// MAC + Bridge membership
// --- MAC + Bridge membership ---
if n.MacID != nil && *n.MacID > 0 {
mac, err := s.NetworkService.GetObjectEntryByID(*n.MacID)
if err != nil {
@@ -344,75 +343,70 @@ func (s *Service) SyncNetwork(ctId uint, jail jailModels.Jail, save bool) error
))
}
// Addressing
switch {
case n.DHCP && n.SLAAC:
// --- IPv4 (independent of IPv6) ---
if n.DHCP {
b.WriteString(fmt.Sprintf("\texec.start += \"dhclient %s_%db\";\n", ctidHash, networkId))
b.WriteString(fmt.Sprintf("\texec.start += \"sysrc ifconfig_%s_%db=\\\"DHCP\\\"\";\n", ctidHash, networkId))
b.WriteString(fmt.Sprintf("\texec.start += \"sysrc ifconfig_%s_%db_ipv6=\\\"inet6 accept_rtadv\\\"\";\n", ctidHash, networkId))
} else if n.IPv4ID != nil && *n.IPv4ID > 0 && n.IPv4GwID != nil && *n.IPv4GwID > 0 {
ipv4, err := s.NetworkService.GetObjectEntryByID(*n.IPv4ID)
if err != nil {
return fmt.Errorf("failed to get ipv4 address: %w", err)
}
ipv4Gw, err := s.NetworkService.GetObjectEntryByID(*n.IPv4GwID)
if err != nil {
return fmt.Errorf("failed to get ipv4 gateway: %w", err)
}
ip, mask, err := utils.SplitIPv4AndMask(ipv4)
if err != nil {
return fmt.Errorf("failed to split ipv4 address and mask: %w", err)
}
case n.DHCP:
b.WriteString(fmt.Sprintf("\texec.start += \"dhclient %s_%db\";\n", ctidHash, networkId))
b.WriteString(fmt.Sprintf("\texec.start += \"sysrc ifconfig_%s_%db=\\\"DHCP\\\"\";\n", ctidHash, networkId))
b.WriteString(fmt.Sprintf("\texec.start += \"ifconfig %s_%db inet %s netmask %s\";\n", ctidHash, networkId, ip, mask))
if !setV4Default {
b.WriteString(fmt.Sprintf("\texec.start += \"route add default %s\";\n", ipv4Gw))
setV4Default = true
}
b.WriteString(fmt.Sprintf("\texec.start += \"sysrc ifconfig_%s_%db=\\\"inet %s netmask %s\\\"\";\n", ctidHash, networkId, ip, mask))
}
case n.SLAAC:
// --- IPv6 (independent of IPv4) ---
if n.SLAAC {
b.WriteString(fmt.Sprintf("\texec.start += \"ifconfig %s_%db inet6 accept_rtadv up\";\n", ctidHash, networkId))
b.WriteString(fmt.Sprintf("\texec.start += \"sysrc ifconfig_%s_%db_ipv6=\\\"inet6 accept_rtadv\\\"\";\n", ctidHash, networkId))
default:
// Static IPv4
if n.IPv4ID != nil && *n.IPv4ID > 0 && n.IPv4GwID != nil && *n.IPv4GwID > 0 {
ipv4, err := s.NetworkService.GetObjectEntryByID(*n.IPv4ID)
if err != nil {
return fmt.Errorf("failed to get ipv4 address: %w", err)
}
ipv4Gw, err := s.NetworkService.GetObjectEntryByID(*n.IPv4GwID)
if err != nil {
return fmt.Errorf("failed to get ipv4 gateway: %w", err)
}
ip, mask, err := utils.SplitIPv4AndMask(ipv4)
if err != nil {
return fmt.Errorf("failed to split ipv4 address and mask: %w", err)
}
b.WriteString(fmt.Sprintf("\texec.start += \"ifconfig %s_%db inet %s netmask %s\";\n", ctidHash, networkId, ip, mask))
if !setV4Default {
b.WriteString(fmt.Sprintf("\texec.start += \"route add default %s\";\n", ipv4Gw))
setV4Default = true
}
b.WriteString(fmt.Sprintf("\texec.start += \"sysrc ifconfig_%s_%db=\\\"inet %s netmask %s\\\"\";\n", ctidHash, networkId, ip, mask))
sawAnyV6 = true
} else if n.IPv6ID != nil && *n.IPv6ID > 0 && n.IPv6GwID != nil && *n.IPv6GwID > 0 {
ipv6, err := s.NetworkService.GetObjectEntryByID(*n.IPv6ID)
if err != nil {
return fmt.Errorf("failed to get ipv6 address: %w", err)
}
ipv6Gw, err := s.NetworkService.GetObjectEntryByID(*n.IPv6GwID)
if err != nil {
return fmt.Errorf("failed to get ipv6 gateway: %w", err)
}
// Static IPv6
if n.IPv6ID != nil && *n.IPv6ID > 0 && n.IPv6GwID != nil && *n.IPv6GwID > 0 {
ipv6, err := s.NetworkService.GetObjectEntryByID(*n.IPv6ID)
if err != nil {
return fmt.Errorf("failed to get ipv6 address: %w", err)
}
ipv6Gw, err := s.NetworkService.GetObjectEntryByID(*n.IPv6GwID)
if err != nil {
return fmt.Errorf("failed to get ipv6 gateway: %w", err)
}
b.WriteString(fmt.Sprintf("\texec.start += \"ifconfig %s_%db inet6 %s\";\n", ctidHash, networkId, ipv6))
if !setV6Default {
b.WriteString(fmt.Sprintf("\texec.start += \"sysrc ipv6_defaultrouter=\\\"%s\\\"\";\n", ipv6Gw))
setV6Default = true
}
b.WriteString(fmt.Sprintf("\texec.start += \"sysrc ifconfig_%s_%db_ipv6=\\\"inet6 %s\\\"\";\n", ctidHash, networkId, ipv6))
} else {
// ip6=disable; ? in the config?
b.WriteString(fmt.Sprintf("\tip6=disable;\n"))
b.WriteString(fmt.Sprintf("\texec.start += \"ifconfig %s_%db inet6 %s\";\n", ctidHash, networkId, ipv6))
if !setV6Default {
b.WriteString(fmt.Sprintf("\texec.start += \"sysrc ipv6_defaultrouter=\\\"%s\\\"\";\n", ipv6Gw))
setV6Default = true
}
b.WriteString(fmt.Sprintf("\texec.start += \"sysrc ifconfig_%s_%db_ipv6=\\\"inet6 %s\\\"\";\n", ctidHash, networkId, ipv6))
sawAnyV6 = true
}
}
// If no NIC configured IPv6 at all, disable IPv6 at the jail level.
if !sawAnyV6 {
b.WriteString("\tip6=disable;\n")
}
newCfg, err = s.AppendToConfig(ctId, cfg, b.String())
if err != nil {
return err
}
} else {
// No networks configured: disable both stacks explicitly
toAppend := "\tip4=disable;\n\tip6=disable;\n"
var err error
newCfg, err = s.AppendToConfig(ctId, cfg, toAppend)
if err != nil {
return err
@@ -420,42 +414,31 @@ func (s *Service) SyncNetwork(ctId uint, jail jailModels.Jail, save bool) error
}
}
err = s.SaveJailConfig(ctId, newCfg)
if err != nil {
if err := s.SaveJailConfig(ctId, newCfg); err != nil {
return err
}
// If inherited, scrub rc.conf of per-if ifconfig/ipv6* lines
mountPoint, err := s.GetJailMountPoint(ctId)
if err != nil {
return err
}
rcConfPath := filepath.Join(mountPoint, "etc", "rc.conf")
var exists bool
if _, err := os.Stat(rcConfPath); err == nil {
exists = true
}
if exists {
if _, statErr := os.Stat(rcConfPath); statErr == nil {
if jail.InheritIPv4 || jail.InheritIPv6 {
rcConf, err := os.ReadFile(rcConfPath)
if err != nil {
return err
}
lines := strings.Split(string(rcConf), "\n")
for i := 0; i < len(lines); i++ {
if strings.HasPrefix(lines[i], "ifconfig") ||
strings.HasPrefix(lines[i], "ipv6") {
if strings.HasPrefix(lines[i], "ifconfig") || strings.HasPrefix(lines[i], "ipv6") {
lines = append(lines[:i], lines[i+1:]...)
i--
}
}
err = os.WriteFile(rcConfPath, []byte(strings.Join(lines, "\n")), 0644)
if err != nil {
if err := os.WriteFile(rcConfPath, []byte(strings.Join(lines, "\n")), 0644); err != nil {
return err
}
}
+87 -97
View File
@@ -23,7 +23,7 @@ import (
)
func (s *Service) CreateDiskImage(vmId int, guid string, size int64, name string) error {
dataset, err := zfs.Datasets("")
dataset, err := zfs.Filesystems("")
if err != nil {
return fmt.Errorf("failed_to_get_datasets: %w", err)
}
@@ -31,13 +31,7 @@ func (s *Service) CreateDiskImage(vmId int, guid string, size int64, name string
var targetDataset *zfs.Dataset
for _, d := range dataset {
guidProp, err := d.GetProperty("guid")
if err != nil {
return fmt.Errorf("failed_to_get_dataset_properties: %w", err)
}
if guidProp == guid {
if d.GUID == guid {
targetDataset = d
break
}
@@ -60,7 +54,7 @@ func (s *Service) CreateDiskImage(vmId int, guid string, size int64, name string
return fmt.Errorf("mountpoint_property_is_empty_for_dataset: %s", guid)
}
vmPath := filepath.Join(mountpoint, "sylve-vm-images", strconv.Itoa(vmId))
vmPath := filepath.Join(mountpoint)
if _, err := os.Stat(vmPath); os.IsNotExist(err) {
if err := os.MkdirAll(vmPath, 0755); err != nil {
return fmt.Errorf("failed_to_create_vm_images_directory: %w", err)
@@ -91,8 +85,7 @@ func (s *Service) CreateDiskImage(vmId int, guid string, size int64, name string
func (s *Service) StorageDetach(vmId int, storageId int) error {
var storage vmModels.Storage
err := s.DB.Find(&storage, "id = ?", storageId).Error
if err != nil {
if err := s.DB.Find(&storage, "id = ?", storageId).Error; err != nil {
return fmt.Errorf("failed_to_find_storage: %w", err)
}
@@ -102,11 +95,9 @@ func (s *Service) StorageDetach(vmId int, storageId int) error {
}
state, _, err := s.Conn.DomainGetState(domain, 0)
if err != nil {
return fmt.Errorf("failed_to_get_domain_state: %w", err)
}
if state != 5 {
return fmt.Errorf("domain_state_not_shutoff: %d", vmId)
}
@@ -118,7 +109,7 @@ func (s *Service) StorageDetach(vmId int, storageId int) error {
doc := etree.NewDocument()
if err := doc.ReadFromString(xml); err != nil {
return fmt.Errorf("failed to parse XML: %w", err)
return fmt.Errorf("failed_to_parse_xml: %w", err)
}
bhyveCommandline := doc.FindElement("//commandline")
@@ -130,90 +121,77 @@ func (s *Service) StorageDetach(vmId int, storageId int) error {
bhyveCommandline = root.CreateElement("bhyve:commandline")
}
filePath := ""
// Best-effort dataset resolve (no error if not found)
var dataset *zfs.Dataset
haveDataset := false
if storage.Type == "zvol" || storage.Type == "raw" {
if dsets, derr := zfs.Datasets(""); derr == nil {
for _, d := range dsets {
if d.GUID == storage.Dataset {
dataset = d
haveDataset = true
break
}
}
}
}
filePath := ""
if storage.Type == "iso" {
filePath, err = s.FindISOByUUID(storage.Dataset, false)
if err != nil {
return fmt.Errorf("failed_to_find_iso_by_uuid: %w", err)
// If ISO isnt found, we can still proceed; no need to fail
if p, ferr := s.FindISOByUUID(storage.Dataset, false); ferr == nil {
filePath = p
}
}
for _, arg := range bhyveCommandline.ChildElements() {
valueAttr := arg.SelectAttr("value")
if valueAttr != nil {
value := valueAttr.Value
if value != "" {
/* Takes care of CD removals */
if strings.Contains(value, "ahci-cd") &&
strings.Contains(value, filePath) &&
storage.Type == "iso" {
bhyveCommandline.RemoveChild(arg)
}
valAttr := arg.SelectAttr("value")
if valAttr == nil {
continue
}
val := valAttr.Value
if val == "" {
continue
}
var dataset *zfs.Dataset
// ISO removal (best-effort if we know the path)
if storage.Type == "iso" && filePath != "" &&
strings.Contains(val, "ahci-cd") && strings.Contains(val, filePath) {
bhyveCommandline.RemoveChild(arg)
continue
}
if storage.Type == "zvol" || storage.Type == "raw" {
datasets, err := zfs.Datasets("")
if err != nil {
return fmt.Errorf("failed_to_get_datasets: %w", err)
}
// ZVOL removal: only remove when we can precisely match the resolved device path.
if storage.Type == "zvol" && haveDataset && dataset.Type == "volume" {
if strings.Contains(val, "/dev/zvol/"+dataset.Name) {
bhyveCommandline.RemoveChild(arg)
continue
}
}
for _, d := range datasets {
guid, err := d.GetProperty("guid")
if err != nil {
return fmt.Errorf("failed_to_get_dataset_property: %w", err)
}
if guid == storage.Dataset {
dataset = d
break
}
}
if dataset == nil {
return fmt.Errorf("dataset_not_found: %s", storage.Dataset)
}
}
/* Takes care of ZVOL removals */
if storage.Type == "zvol" {
if dataset.Type != "volume" {
return fmt.Errorf("invalid_dataset_type: %s", dataset.Type)
}
if strings.Contains(value, fmt.Sprintf("/dev/zvol/%s", dataset.Name)) {
bhyveCommandline.RemoveChild(arg)
}
}
/* Takes care of RAW Disk removals */
if storage.Type == "raw" {
if strings.Contains(value, dataset.Name) &&
strings.Contains(value, storage.Name) {
bhyveCommandline.RemoveChild(arg)
}
imagePath := filepath.Join(dataset.Mountpoint, "sylve-vm-images", strconv.Itoa(vmId), fmt.Sprintf("%s.img", storage.Name))
if _, err := os.Stat(imagePath); !os.IsNotExist(err) {
if err := os.Remove(imagePath); err != nil {
return fmt.Errorf("failed_to_remove_disk_image: %w", err)
}
}
}
// RAW removal: require dataset to compute the image path reliably.
if storage.Type == "raw" && haveDataset {
if strings.Contains(val, dataset.Name) && strings.HasSuffix(val, fmt.Sprintf("%s.img", storage.Name)) {
bhyveCommandline.RemoveChild(arg)
// Let's not remove the image file itself, since we're "detaching" and not "deleting".
// imagePath := filepath.Join(dataset.Mountpoint,
// strconv.Itoa(vmId), fmt.Sprintf("%s.img", storage.Name))
// if _, statErr := os.Stat(imagePath); statErr == nil {
// _ = os.Remove(imagePath) // ignore error; detach should still succeed
// }
continue
}
}
}
out, err := doc.WriteToString()
if err != nil {
return fmt.Errorf("failed to serialize XML: %w", err)
return fmt.Errorf("failed_to_serialize_xml: %w", err)
}
if err := s.Conn.DomainUndefineFlags(domain, 0); err != nil {
return fmt.Errorf("failed_to_undefine_domain: %w", err)
}
if _, err := s.Conn.DomainDefineXML(out); err != nil {
return fmt.Errorf("failed_to_define_domain_with_modified_xml: %w", err)
}
@@ -325,19 +303,14 @@ func (s *Service) StorageAttach(vmId int, sType string, dataset string, emulatio
argValue := fmt.Sprintf("-s %d:0,ahci-cd,%s", index, filePath)
bhyveCommandline.CreateElement("bhyve:arg").CreateAttr("value", argValue)
} else if sType == "zvol" {
datasets, err := zfs.Datasets("")
datasets, err := zfs.Volumes("")
if err != nil {
return fmt.Errorf("failed_to_get_datasets: %w", err)
}
var targetDataset *zfs.Dataset
for _, d := range datasets {
guid, err := d.GetProperty("guid")
if err != nil {
return fmt.Errorf("failed_to_get_dataset_property: %w", err)
}
if guid == dataset {
if d.GUID == dataset {
targetDataset = d
break
}
@@ -401,19 +374,18 @@ func (s *Service) StorageAttach(vmId int, sType string, dataset string, emulatio
return fmt.Errorf("name_required_for_raw_storage")
}
datasets, err := zfs.Datasets("")
if !utils.IsValidDiskName(name) {
return fmt.Errorf("invalid_characters_in_disk_name: %s", name)
}
datasets, err := zfs.Filesystems("")
if err != nil {
return fmt.Errorf("failed_to_get_datasets: %w", err)
}
var targetDataset *zfs.Dataset
for _, d := range datasets {
guid, err := d.GetProperty("guid")
if err != nil {
return fmt.Errorf("failed_to_get_dataset_property: %w", err)
}
if guid == dataset {
if d.GUID == dataset {
targetDataset = d
break
}
@@ -423,6 +395,21 @@ func (s *Service) StorageAttach(vmId int, sType string, dataset string, emulatio
return fmt.Errorf("dataset_not_found: %s", dataset)
}
imagePath := filepath.Join(targetDataset.Mountpoint, fmt.Sprintf("%s.img", name))
if _, err := os.Stat(imagePath); err != nil {
if os.IsNotExist(err) {
if err := s.CreateDiskImage(vmId, dataset, size, name); err != nil {
return fmt.Errorf("failed_to_create_disk_image: %w", err)
}
}
} else {
info, err := os.Stat(imagePath)
if err != nil {
return fmt.Errorf("failed_to_stat_existing_image: %w", err)
}
size = info.Size()
}
newStorage := vmModels.Storage{
Type: sType,
Dataset: dataset,
@@ -436,16 +423,19 @@ func (s *Service) StorageAttach(vmId int, sType string, dataset string, emulatio
return fmt.Errorf("failed_to_create_storage: %w", err)
}
if err := s.CreateDiskImage(vmId, dataset, size, name); err != nil {
return fmt.Errorf("failed_to_create_disk_image: %w", err)
}
index, err := findLowestIndex(xml)
if err != nil {
return fmt.Errorf("failed_to_find_lowest_index: %w", err)
}
argValue := fmt.Sprintf("-s %d:0,%s,%s/%s.img", index, emulation, filepath.Join(targetDataset.Mountpoint, "sylve-vm-images", strconv.Itoa(vmId)), name)
argValue := fmt.Sprintf(
"-s %d:0,%s,%s/%s.img",
index,
emulation,
filepath.Join(targetDataset.Mountpoint, strconv.Itoa(vmId)),
name,
)
bhyveCommandline.CreateElement("bhyve:arg").CreateAttr("value", argValue)
}
+2 -7
View File
@@ -86,12 +86,7 @@ func (s *Service) CreateVmXML(vm vmModels.VM, vmPath string) (string, error) {
if storage.Dataset != "" && storage.Type != "iso" {
for _, d := range datasets {
guid, err := d.GetProperty("guid")
if err != nil {
return "", fmt.Errorf("failed_to_get_dataset_properties: %w", err)
}
if guid == storage.Dataset {
if d.GUID == storage.Dataset {
dataset = d
break
}
@@ -148,7 +143,7 @@ func (s *Service) CreateVmXML(vm vmModels.VM, vmPath string) (string, error) {
sIndex++
} else if storage.Type == "raw" {
imagePath := filepath.Join(dataset.Mountpoint, "sylve-vm-images", strconv.Itoa(vm.VmID), fmt.Sprintf("%d.img", vm.VmID))
imagePath := filepath.Join(dataset.Mountpoint, fmt.Sprintf("%d.img", vm.VmID))
if _, err := os.Stat(imagePath); os.IsNotExist(err) {
return "", fmt.Errorf("image_file_not_found: %s", imagePath)
+47 -15
View File
@@ -10,6 +10,7 @@ package libvirt
import (
"fmt"
"path/filepath"
"strings"
"github.com/alchemillahq/sylve/internal/db/models"
@@ -117,8 +118,12 @@ func validateCreate(data libvirtServiceInterfaces.CreateVMRequest, db *gorm.DB)
return fmt.Errorf("failed_to_check_storage_dataset_usage: %w", err)
}
if count > 0 && data.StorageType == "zvol" {
return fmt.Errorf("storage_dataset_zvol_already_in_use")
if count > 0 {
if data.StorageType == "zvol" {
return fmt.Errorf("storage_dataset_zvol_already_in_use")
} else if data.StorageType == "raw" {
return fmt.Errorf("storage_dataset_filesystem_already_in_use")
}
}
datasets, err := zfs.Datasets("")
@@ -134,13 +139,9 @@ func validateCreate(data libvirtServiceInterfaces.CreateVMRequest, db *gorm.DB)
var dataset *zfs.Dataset
for _, d := range datasets {
guid, err := d.GetProperty("guid")
if err != nil {
return fmt.Errorf("failed_to_get_dataset_properties: %w", err)
}
if guid == data.StorageDataset {
if d.GUID == data.StorageDataset {
dataset = d
break
}
}
@@ -536,7 +537,44 @@ func (s *Service) RemoveVM(id uint, cleanUpMacs bool) error {
return fmt.Errorf("failed_to_find_vm: %w", err)
}
err := s.RemoveLvVm(int(vm.VmID))
filesystems, err := zfs.Filesystems("")
if err != nil {
return fmt.Errorf("failed_to_get_filesystems: %w", err)
}
for _, storage := range vm.Storages {
if storage.Type == "raw" {
var dataset *zfs.Dataset
for _, fs := range filesystems {
if fs.GUID == storage.Dataset {
dataset = fs
break
}
}
if dataset == nil {
return fmt.Errorf("dataset_not_found")
}
if dataset.Mountpoint == "" {
return fmt.Errorf("raw_storage_dataset_must_have_mountpoint")
}
datasetPath := filepath.Join(dataset.Mountpoint)
err := utils.RemoveDirContents(datasetPath)
if err != nil {
return fmt.Errorf("failed_to_remove_raw_storage_files: %w", err)
}
}
if err := s.DB.Delete(&storage).Error; err != nil {
return fmt.Errorf("failed_to_delete_storage: %w", err)
}
}
err = s.RemoveLvVm(int(vm.VmID))
if err != nil {
return fmt.Errorf("failed_to_remove_lv_vm: %w", err)
}
@@ -553,12 +591,6 @@ func (s *Service) RemoveVM(id uint, cleanUpMacs bool) error {
}
}
for _, storage := range vm.Storages {
if err := s.DB.Delete(&storage).Error; err != nil {
return fmt.Errorf("failed_to_delete_storage: %w", err)
}
}
for _, stat := range vm.Stats {
if err := s.DB.Delete(&stat).Error; err != nil {
return fmt.Errorf("failed_to_delete_vm_stat: %w", err)
+43 -22
View File
@@ -36,6 +36,28 @@ func (s *Service) GetStandardSwitches() ([]networkModels.StandardSwitch, error)
return switches, nil
}
func (s *Service) conflictingPortsForVLAN(ports []string, vlan int, excludeSwitchID *int) ([]networkModels.NetworkPort, error) {
var eps []networkModels.NetworkPort
q := s.DB.Preload("Switch").Where("name IN ?", ports)
if excludeSwitchID != nil {
q = q.Where("switch_id <> ?", *excludeSwitchID)
}
if err := q.Find(&eps).Error; err != nil {
return nil, fmt.Errorf("db_error_checking_ports: %w", err)
}
conflicts := make([]networkModels.NetworkPort, 0, len(eps))
for _, ep := range eps {
other := ep.Switch.VLAN
if vlan == 0 || other == 0 || vlan == other {
conflicts = append(conflicts, ep)
}
}
return conflicts, nil
}
func (s *Service) NewStandardSwitch(
name string,
mtu int,
@@ -51,15 +73,6 @@ func (s *Service) NewStandardSwitch(
slaac bool,
defaultRoute bool,
) error {
var existingPorts []networkModels.NetworkPort
if err := s.DB.Where("name IN ?", ports).Find(&existingPorts).Error; err != nil {
return fmt.Errorf("db_error_checking_ports: %v", err)
}
if len(existingPorts) > 0 {
return fmt.Errorf("port_overlap")
}
if !utils.IsValidMTU(mtu) && mtu != 0 {
return fmt.Errorf("invalid_mtu")
}
@@ -68,6 +81,16 @@ func (s *Service) NewStandardSwitch(
return fmt.Errorf("invalid_vlan")
}
if conflicts, err := s.conflictingPortsForVLAN(ports, vlan, nil); err != nil {
return err
} else if len(conflicts) > 0 {
var msgs []string
for _, c := range conflicts {
msgs = append(msgs, fmt.Sprintf("%s (used by switch %q vlan=%d)", c.Name, c.Switch.Name, c.Switch.VLAN))
}
return fmt.Errorf("port_overlap: %s", strings.Join(msgs, ", "))
}
if network4Id != 0 {
var o4 networkModels.Object
if err := s.DB.Preload("Entries").First(&o4, network4Id).Error; err != nil {
@@ -305,26 +328,24 @@ func (s *Service) EditStandardSwitch(
slaac bool,
defaultRoute bool,
) error {
var conflictingPorts []networkModels.NetworkPort
if err := s.DB.
Where("name IN ?", ports).
Where("switch_id <> ?", id).
Find(&conflictingPorts).Error; err != nil {
return fmt.Errorf("db_error_checking_ports: %v", err)
}
if len(conflictingPorts) > 0 {
return fmt.Errorf("port_overlap")
}
if !utils.IsValidMTU(mtu) {
return fmt.Errorf("invalid_mtu")
}
if !utils.IsValidVLAN(vlan) {
if !utils.IsValidVLAN(vlan) && vlan != 0 {
return fmt.Errorf("invalid_vlan")
}
if conflicts, err := s.conflictingPortsForVLAN(ports, vlan, &id); err != nil {
return err
} else if len(conflicts) > 0 {
var msgs []string
for _, c := range conflicts {
msgs = append(msgs, fmt.Sprintf("%s (used by switch %q vlan=%d)", c.Name, c.Switch.Name, c.Switch.VLAN))
}
return fmt.Errorf("port_overlap: %s", strings.Join(msgs, ", "))
}
if network4Id != 0 {
var o4 networkModels.Object
if err := s.DB.Preload("Entries").First(&o4, network4Id).Error; err != nil {
+2 -7
View File
@@ -132,7 +132,7 @@ func (s *Service) ShareConfig() (string, error) {
return "", fmt.Errorf("failed to retrieve Samba shares: %w", err)
}
datasets, err := zfs.Datasets("")
datasets, err := zfs.Filesystems("")
if err != nil {
return "", fmt.Errorf("failed to fetch datasets: %v", err)
@@ -143,12 +143,7 @@ func (s *Service) ShareConfig() (string, error) {
var dataset *zfs.Dataset
for _, ds := range datasets {
dProps, err := ds.GetAllProperties()
if err != nil {
return "", fmt.Errorf("failed to get properties for dataset %s: %v", share.Dataset, err)
}
if dProps["guid"] == share.Dataset {
if ds.GUID == share.Dataset {
dataset = ds
break
}
+12 -13
View File
@@ -42,7 +42,7 @@ func (s *Service) CreateShare(
return fmt.Errorf("cannot_create_read_only_share_with_read_only_groups")
}
datasets, err := zfs.Datasets("")
datasets, err := zfs.Filesystems("")
if err != nil {
return fmt.Errorf("failed_to_fetch_datasets: %v", err)
}
@@ -50,12 +50,7 @@ func (s *Service) CreateShare(
var fDataset *zfs.Dataset
for _, ds := range datasets {
properties, err := ds.GetAllProperties()
if err != nil {
return fmt.Errorf("failed_to_get_properties_for_dataset: %v", err)
}
if properties["guid"] == dataset {
if ds.GUID == dataset {
fDataset = ds
break
}
@@ -162,32 +157,34 @@ func (s *Service) UpdateShare(
return fmt.Errorf("cannot_create_read_only_share_with_read_only_groups")
}
datasets, err := zfs.Datasets("")
datasets, err := zfs.Filesystems("")
if err != nil {
return fmt.Errorf("failed_to_fetch_datasets: %v", err)
}
var fDataset *zfs.Dataset
for _, ds := range datasets {
properties, err := ds.GetAllProperties()
if err != nil {
return fmt.Errorf("failed_to_get_properties_for_dataset: %v", err)
}
if properties["guid"] == dataset {
if ds.GUID == dataset {
fDataset = ds
break
}
}
if fDataset == nil {
return fmt.Errorf("dataset_not_found")
}
if fDataset.Mountpoint == "" {
return fmt.Errorf("dataset_not_mounted")
}
allGroups := utils.JoinStringSlices(readOnlyGroups, writeableGroups)
if len(allGroups) == 0 && !guestOk {
return fmt.Errorf("no_groups_selected_and_guests_not_allowed")
}
for _, group := range allGroups {
if err := s.DB.Where("name = ?", group).First(&models.Group{}).Error; err != nil {
return fmt.Errorf("group_not_found: %s", group)
@@ -195,6 +192,7 @@ func (s *Service) UpdateShare(
}
var roGroups, wrGroups []models.Group
for _, gname := range readOnlyGroups {
var g models.Group
if err := s.DB.Where("name = ?", gname).First(&g).Error; err != nil {
@@ -202,6 +200,7 @@ func (s *Service) UpdateShare(
}
roGroups = append(roGroups, g)
}
for _, gname := range writeableGroups {
var g models.Group
if err := s.DB.Where("name = ?", gname).First(&g).Error; err != nil {
+2
View File
@@ -87,6 +87,8 @@ func (s *Service) CheckPackageDependencies() error {
"smartmontools",
"tmux",
"samba419",
"jansson",
"swtpm",
}
var wg sync.WaitGroup
+1 -1
View File
@@ -327,7 +327,7 @@ func (s *Service) DeleteDownload(id int) error {
if download.Type == "http" {
if strings.HasSuffix(download.Name, ".txz") {
extractsPath := filepath.Join(config.GetDownloadsPath("extracted"), download.UUID)
_, err := utils.RunCommand("sudo", "chflags", "-R", "noschg", extractsPath)
_, err := utils.RunCommand("chflags", "-R", "noschg", extractsPath)
if err != nil {
logger.L.Error().Msgf("Failed to change flags for extracts folder: %v", err)
+1
View File
@@ -0,0 +1 @@
package zfs
+2 -6
View File
@@ -43,6 +43,7 @@ func (s *Service) GetDatasets(t string) ([]*zfsServiceInterfaces.Dataset, error)
GUID: dataset.GUID,
Used: dataset.Used,
Avail: dataset.Avail,
Recordsize: dataset.Recordsize,
Mountpoint: dataset.Mountpoint,
Compression: dataset.Compression,
Type: dataset.Type,
@@ -73,12 +74,7 @@ func (s *Service) GetDatasetByGUID(guid string) (*zfs.Dataset, error) {
}
for _, dataset := range datasets {
gguid, err := dataset.GetProperty("guid")
if err != nil {
return nil, err
}
if gguid == guid {
if dataset.GUID == guid {
return dataset, nil
}
}
+23 -33
View File
@@ -67,12 +67,7 @@ func (s *Service) EditFilesystem(guid string, props map[string]string) error {
}
for _, dataset := range datasets {
property, err := dataset.GetProperty("guid")
if err != nil {
return err
}
if property == guid {
if dataset.GUID == guid {
return zfs.EditFilesystem(dataset.Name, props)
}
}
@@ -93,47 +88,42 @@ func (s *Service) DeleteFilesystem(guid string) error {
return fmt.Errorf("dataset_in_use_by_vm")
}
datasets, err := zfs.Datasets("")
filesystems, err := zfs.Filesystems("")
if err != nil {
return err
}
for _, dataset := range datasets {
properties, err := dataset.GetAllProperties()
for _, filesystem := range filesystems {
fguid, err := filesystem.GetProperty("guid")
if err != nil {
return err
}
var keylocation string
found := false
for k, v := range properties {
if v == guid {
found = true
}
if k == "keylocation" {
keylocation = v
}
if fguid != guid {
continue
}
if found {
if err := dataset.Destroy(zfs.DestroyRecursive); err != nil {
return err
}
keylocation, err := filesystem.GetProperty("keylocation")
if err != nil {
return err
}
if keylocation != "" && keylocation != "none" {
keylocation = keylocation[7:]
if _, err := os.Stat(keylocation); err == nil {
if err := os.Remove(keylocation); err != nil {
return err
}
} else {
return fmt.Errorf("keylocation_file_not_found: %s", keylocation)
if err := filesystem.Destroy(zfs.DestroyRecursive); err != nil {
return err
}
if keylocation != "" && keylocation != "none" {
keylocation = keylocation[7:]
if _, err := os.Stat(keylocation); err == nil {
if err := os.Remove(keylocation); err != nil {
return err
}
} else {
return fmt.Errorf("keylocation_file_not_found: %s", keylocation)
}
return nil
}
return nil
}
return fmt.Errorf("filesystem with guid %s not found", guid)
+8 -10
View File
@@ -79,25 +79,23 @@ func (s *Service) DeleteVolume(guid string) error {
return fmt.Errorf("dataset_in_use_by_vm")
}
datasets, err := zfs.Datasets("")
volumes, err := zfs.Volumes("")
if err != nil {
return err
}
for _, dataset := range datasets {
properties, err := dataset.GetAllProperties()
for _, volume := range volumes {
g, err := volume.GetProperty("guid")
if err != nil {
return err
}
for _, v := range properties {
if v == guid {
err := dataset.Destroy(zfs.DestroyDefault)
if err != nil {
return err
}
return nil
if g == guid {
err := volume.Destroy(zfs.DestroyRecursive)
if err != nil {
return err
}
return nil
}
}
+1 -7
View File
@@ -115,13 +115,7 @@ func (s *Service) DeletePool(guid string) error {
if len(datasets) > 0 {
for _, ds := range datasets {
guid, err := ds.GetProperty("guid")
if err != nil {
return fmt.Errorf("failed_to_get_guid_for_dataset %s: %v", ds.Name, err)
}
inUse := s.IsDatasetInUse(guid, true)
inUse := s.IsDatasetInUse(ds.GUID, true)
if inUse {
return fmt.Errorf("dataset %s is in use and cannot be deleted", ds.Name)
+170
View File
@@ -0,0 +1,170 @@
package s3
import (
"context"
"errors"
"fmt"
"io"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/google/uuid"
)
func buildClient(ctx context.Context, endpoint, region, accessKey, secretKey string) (*awss3.Client, error) {
cfg, err := awsconfig.LoadDefaultConfig(
ctx,
awsconfig.WithRegion(region),
awsconfig.WithCredentialsProvider(
credentials.NewStaticCredentialsProvider(accessKey, secretKey, ""),
),
)
if err != nil {
return nil, fmt.Errorf("load_config_failed: %w", err)
}
ep := strings.TrimRight(endpoint, "/")
return awss3.NewFromConfig(cfg, func(o *awss3.Options) {
o.Region = region
o.UsePathStyle = true
o.BaseEndpoint = aws.String(ep)
}), nil
}
func ValidateConfig(endpoint, region, bucket, accessKey, secretKey string) error {
if endpoint == "" {
return fmt.Errorf("endpoint_is_required")
}
if region == "" {
return fmt.Errorf("region_is_required")
}
if bucket == "" {
return fmt.Errorf("bucket_is_required")
}
if accessKey == "" {
return fmt.Errorf("accessKey_is_required")
}
if secretKey == "" {
return fmt.Errorf("secretKey_is_required")
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
s3, err := buildClient(ctx, endpoint, region, accessKey, secretKey)
if err != nil {
return err
}
var missing []string
markMissing := func(perm string, e error) {
var nfe *types.NotFound
if errors.As(e, &nfe) {
missing = append(missing, fmt.Sprintf("%s (bucket_not_found)", perm))
return
}
missing = append(missing, fmt.Sprintf("%s (%v)", perm, e))
}
if _, err := s3.HeadBucket(ctx, &awss3.HeadBucketInput{Bucket: &bucket}); err != nil {
markMissing("bucket:access", err)
}
if _, err := s3.ListObjectsV2(ctx, &awss3.ListObjectsV2Input{
Bucket: &bucket,
MaxKeys: aws.Int32(1),
}); err != nil {
markMissing("s3:ListBucket", err)
}
key := "sylve-permcheck-" + uuid.NewString() + ".txt"
body := strings.NewReader("permission check")
if _, err := s3.PutObject(ctx, &awss3.PutObjectInput{
Bucket: &bucket,
Key: &key,
Body: body,
}); err != nil {
markMissing("s3:PutObject", err)
} else {
got, err := s3.GetObject(ctx, &awss3.GetObjectInput{
Bucket: &bucket,
Key: &key,
})
if err != nil {
markMissing("s3:GetObject", err)
} else {
_, _ = io.Copy(io.Discard, got.Body)
_ = got.Body.Close()
}
if _, err := s3.DeleteObject(ctx, &awss3.DeleteObjectInput{
Bucket: &bucket,
Key: &key,
}); err != nil {
markMissing("s3:DeleteObject", err)
}
}
if len(missing) > 0 {
return fmt.Errorf("s3_config_validation_failed: %s", strings.Join(missing, ", "))
}
return nil
}
type countingReader struct {
r io.Reader
n int64
}
func (c *countingReader) Read(p []byte) (int, error) {
n, err := c.r.Read(p)
c.n += int64(n)
return n, err
}
func Put(endpoint, region, bucket, accessKey, secretKey, key string, body io.Reader) (etag string, size int64, err error) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
s3, err := buildClient(ctx, endpoint, region, accessKey, secretKey)
if err != nil {
return "", 0, err
}
cr := &countingReader{r: body}
out, err := s3.PutObject(ctx, &awss3.PutObjectInput{
Bucket: &bucket,
Key: &key,
Body: cr,
})
if err != nil {
return "", 0, fmt.Errorf("put_failed: %w", err)
}
return aws.ToString(out.ETag), cr.n, nil
}
func Delete(endpoint, region, bucket, accessKey, secretKey, key string) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
s3, err := buildClient(ctx, endpoint, region, accessKey, secretKey)
if err != nil {
return err
}
if _, err := s3.DeleteObject(ctx, &awss3.DeleteObjectInput{
Bucket: &bucket,
Key: &key,
}); err != nil {
return fmt.Errorf("delete_failed: %w", err)
}
return nil
}
+54
View File
@@ -0,0 +1,54 @@
package swapctl
import (
"fmt"
"strconv"
"strings"
"github.com/alchemillahq/sylve/pkg/utils"
)
type SwapDevice struct {
Device string
Blocks1024 int64
Used int64
}
func GetSwapDevices() ([]SwapDevice, error) {
output, err := utils.RunCommand("swapctl", "-l")
if err != nil {
return nil, err
}
lines := strings.Split(strings.TrimSpace(output), "\n")
if len(lines) <= 1 {
return []SwapDevice{}, nil
}
var devices []SwapDevice
for _, line := range lines[1:] {
line = strings.TrimSpace(line)
if line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
blocks, err1 := strconv.ParseInt(fields[1], 10, 64)
used, err2 := strconv.ParseInt(fields[2], 10, 64)
if err1 != nil || err2 != nil {
return nil, fmt.Errorf("failed to parse swapctl output line: %q", line)
}
devices = append(devices, SwapDevice{
Device: fields[0],
Blocks1024: blocks,
Used: used,
})
}
return devices, nil
}
+71 -1
View File
@@ -13,6 +13,7 @@ import (
"compress/gzip"
"context"
"crypto/tls"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -75,14 +76,83 @@ func GetClusterTokenFromHeader(r http.Header) (string, error) {
return RemoveSpaces(v[7:]), nil
}
if v := r.Get("Sec-WebSocket-Protocol"); v != "" {
text := RemoveSpaces(v)
data, err := hex.DecodeString(text)
if err != nil {
return "", fmt.Errorf("failed to decode hex: %w", err)
}
var obj struct {
Hostname string `json:"hostname"`
Token string `json:"token"`
}
if err := json.Unmarshal(data, &obj); err != nil {
return "", fmt.Errorf("failed to unmarshal json: %w", err)
}
if obj.Token == "" {
return "", errors.New("no_token_provided")
}
return obj.Token, nil
}
return "", errors.New("no cluster token provided")
}
func GetCurrentHostnameFromHeader(r http.Header) (string, error) {
func GetCurrentHostnameFromHeader(r http.Header, rC *http.Request) (string, error) {
if v := r.Get("X-Current-Hostname"); v != "" {
return RemoveSpaces(v), nil
}
if v := r.Get("Sec-WebSocket-Protocol"); v != "" {
text := RemoveSpaces(v)
data, err := hex.DecodeString(text)
if err != nil {
return "", fmt.Errorf("failed to decode hex: %w", err)
}
var obj struct {
Hostname string `json:"hostname"`
Token string `json:"token"`
}
if err := json.Unmarshal(data, &obj); err != nil {
return "", fmt.Errorf("failed to unmarshal json: %w", err)
}
if obj.Hostname == "" {
return "", errors.New("no_current_hostname_provided")
}
return obj.Hostname, nil
}
if v := rC.URL.Query().Get("auth"); v != "" {
text := RemoveSpaces(v)
data, err := hex.DecodeString(text)
if err != nil {
return "", fmt.Errorf("failed to decode hex: %w", err)
}
var obj struct {
Hostname string `json:"hostname"`
Token string `json:"token"`
}
if err := json.Unmarshal(data, &obj); err != nil {
return "", fmt.Errorf("failed to unmarshal json: %w", err)
}
if obj.Hostname == "" {
return "", errors.New("no_current_hostname_provided")
}
return obj.Hostname, nil
}
return "", errors.New("no_current_hostname_provided")
}
+5
View File
@@ -654,3 +654,8 @@ func MustJSON(v any) []byte {
b, _ := json.Marshal(v)
return b
}
func IsValidDiskName(name string) bool {
regex := regexp.MustCompile(`^[a-zA-Z0-9-_]+$`)
return regex.MatchString(name)
}
+1
View File
@@ -15,6 +15,7 @@ type Dataset struct {
Origin string `json:"origin"`
Used uint64 `json:"used"`
Avail uint64 `json:"avail"`
Recordsize uint64 `json:"recordsize"`
Mountpoint string `json:"mountpoint"`
Compression string `json:"compression"`
Type string `json:"type"`
+1 -1
View File
@@ -31,7 +31,7 @@ const (
)
var (
dsPropList = []string{"name", "origin", "used", "avail", "mountpoint", "compression", "type", "volsize", "quota", "referenced", "written", "logicalused", "usedbydataset", "guid", "mounted", "checksum", "aclmode", "aclinherit", "primarycache", "volmode"}
dsPropList = []string{"name", "origin", "used", "avail", "recordsize", "mountpoint", "compression", "type", "volsize", "quota", "referenced", "written", "logicalused", "usedbydataset", "guid", "mounted", "checksum", "aclmode", "aclinherit", "primarycache", "volmode"}
zpoolPropList = []string{"name", "health", "allocated", "size", "free", "readonly", "dedupratio", "fragmentation", "freeing", "leaked", "guid"}
zpoolPropListOptions = strings.Join(zpoolPropList, ",")
zpoolArgs = []string{"get", "-Hp", zpoolPropListOptions}
+4
View File
@@ -85,6 +85,10 @@ func (d *Dataset) parseProps(out [][]string) error {
setString(&d.Compression, d.Props["compress"])
setString(&d.Type, d.Props["type"])
if err = setUint(&d.Recordsize, d.Props["recsize"]); err != nil {
return fmt.Errorf("failed to parse recordsize: %w", err)
}
if err = setUint(&d.Volsize, d.Props["volsize"]); err != nil {
return fmt.Errorf("failed to parse volsize: %w", err)
}
+34 -132
View File
@@ -29,7 +29,6 @@
"human-format": "^1.2.1",
"is-cidr": "^5.1.1",
"is-ip": "^5.0.1",
"jsonwebtoken": "^9.0.2",
"lucide-svelte": "^0.516.0",
"magnet-uri": "^7.0.7",
"svelte-filepond": "^0.2.2",
@@ -328,13 +327,13 @@
}
},
"node_modules/@eslint/plugin-kit": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz",
"integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==",
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz",
"integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^0.15.1",
"@eslint/core": "^0.15.2",
"levn": "^0.4.1"
},
"engines": {
@@ -342,9 +341,9 @@
}
},
"node_modules/@eslint/plugin-kit/node_modules/@eslint/core": {
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz",
"integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==",
"version": "0.15.2",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz",
"integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -1331,6 +1330,13 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
"dev": true,
"license": "MIT"
},
"node_modules/@svelte-put/shortcut": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@svelte-put/shortcut/-/shortcut-4.1.0.tgz",
@@ -1350,9 +1356,9 @@
}
},
"node_modules/@sveltejs/adapter-static": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.8.tgz",
"integrity": "sha512-YaDrquRpZwfcXbnlDsSrBQNCChVOT9MGuSg+dMAyfsAa1SmiAhrA5jUYUiIMC59G92kIbY/AaQOWcBdq+lh+zg==",
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.9.tgz",
"integrity": "sha512-aytHXcMi7lb9ljsWUzXYQ0p5X1z9oWud2olu/EpmH7aCu4m84h7QLvb5Wp+CFirKcwoNnYvYWhyP/L8Vh1ztdw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@@ -1360,17 +1366,18 @@
}
},
"node_modules/@sveltejs/kit": {
"version": "2.25.1",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.25.1.tgz",
"integrity": "sha512-8H+fxDEp7Xq6tLFdrGdS5fLu6ONDQQ9DgyjboXpChubuFdfH9QoFX09ypssBpyNkJNZFt9eW3yLmXIc9CesPCA==",
"version": "2.37.0",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.37.0.tgz",
"integrity": "sha512-xgKtpjQ6Ry4mdShd01ht5AODUsW7+K1iValPDq7QX8zI1hWOKREH9GjG8SRCN5tC4K7UXmMhuQam7gbLByVcnw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.5",
"@types/cookie": "^0.6.0",
"acorn": "^8.14.1",
"cookie": "^0.6.0",
"devalue": "^5.1.0",
"devalue": "^5.3.2",
"esm-env": "^1.2.2",
"kleur": "^4.1.5",
"magic-string": "^0.30.5",
@@ -1386,9 +1393,15 @@
"node": ">=18.13"
},
"peerDependencies": {
"@opentelemetry/api": "^1.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0",
"svelte": "^4.0.0 || ^5.0.0-next.0",
"vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
}
}
},
"node_modules/@sveltejs/vite-plugin-svelte": {
@@ -2459,12 +2472,6 @@
"node": ">=8"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -3123,9 +3130,9 @@
}
},
"node_modules/devalue": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.1.1.tgz",
"integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==",
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.3.2.tgz",
"integrity": "sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw==",
"dev": true,
"license": "MIT"
},
@@ -3143,15 +3150,6 @@
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/enhanced-resolve": {
"version": "5.18.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
@@ -4057,49 +4055,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
"license": "MIT",
"dependencies": {
"jws": "^3.2.2",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jwa": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
"license": "MIT",
"dependencies": {
"jwa": "^1.4.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -4477,40 +4432,11 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.merge": {
@@ -4520,12 +4446,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/lucide-svelte": {
"version": "0.516.0",
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.516.0.tgz",
@@ -4808,6 +4728,7 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@@ -5444,26 +5365,6 @@
"node": ">=6"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -5498,6 +5399,7 @@
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
-1
View File
@@ -76,7 +76,6 @@
"human-format": "^1.2.1",
"is-cidr": "^5.1.1",
"is-ip": "^5.0.1",
"jsonwebtoken": "^9.0.2",
"lucide-svelte": "^0.516.0",
"magnet-uri": "^7.0.7",
"svelte-filepond": "^0.2.2",
+5 -4
View File
@@ -18,7 +18,6 @@ import { handleAPIError } from '$lib/utils/http';
import { sha256 } from '$lib/utils/string';
import adze from 'adze';
import axios, { AxiosError } from 'axios';
// import jwt from 'jsonwebtoken';
import { toast } from 'svelte-sonner';
import { get } from 'svelte/store';
@@ -28,14 +27,14 @@ export async function login(
authType: string,
remember: boolean,
language: string
) {
): Promise<boolean> {
try {
if (username === '' || password === '') {
toast.error('Credentials are required', {
position: 'bottom-center'
});
return;
return false;
}
if (authType === '') {
@@ -43,7 +42,7 @@ export async function login(
position: 'bottom-center'
});
return;
return false;
}
const response = await axios.post('/api/auth/login', {
@@ -92,6 +91,8 @@ export async function login(
}
return false;
}
return false;
}
export function getToken(): string | null {
+29
View File
@@ -0,0 +1,29 @@
import { ClusterStoragesSchema, type ClusterStorages } from '$lib/types/cluster/storage';
import { APIResponseSchema, type APIResponse } from '$lib/types/common';
import { apiRequest } from '$lib/utils/http';
export async function getStorages(): Promise<ClusterStorages> {
return await apiRequest('/cluster/storage', ClusterStoragesSchema, 'GET');
}
export async function createS3Storage(
name: string,
endpoint: string,
region: string,
bucket: string,
accessKey: string,
secretKey: string
): Promise<APIResponse> {
return await apiRequest('/cluster/storage/s3', APIResponseSchema, 'POST', {
name,
endpoint,
region,
bucket,
accessKey,
secretKey
});
}
export async function deleteS3Storage(id: number): Promise<APIResponse> {
return await apiRequest(`/cluster/storage/s3/${id}`, APIResponseSchema, 'DELETE');
}
+1 -1
View File
@@ -44,7 +44,7 @@ api.interceptors.request.use(
}
if (get(currentHostname)) {
if (config.url === '/vm' && config.method === 'post') {
if ((config.url === '/vm' || config.url === '/jail') && config.method === 'post') {
let data;
try {
+1
View File
@@ -18,6 +18,7 @@ import { z } from 'zod/v4';
export async function newJail(data: CreateData): Promise<APIResponse> {
return await apiRequest('/jail', APIResponseSchema, 'POST', {
name: data.name,
node: data.node,
ctId: parseInt(data.id.toString(), 10),
description: data.description,
dataset: data.storage.dataset,
@@ -1,5 +1,4 @@
<script lang="ts">
// import { createCluster, joinCluster } from '$lib/api/datacenter/cluster';
import { joinCluster } from '$lib/api/cluster/cluster';
import { Button } from '$lib/components/ui/button/index.js';
import CustomValueInput from '$lib/components/ui/custom-input/value.svelte';
@@ -0,0 +1,170 @@
<script lang="ts">
import { createS3Storage } from '$lib/api/cluster/storage';
import { Button } from '$lib/components/ui/button/index.js';
import CustomComboBox from '$lib/components/ui/custom-input/combobox.svelte';
import CustomValueInput from '$lib/components/ui/custom-input/value.svelte';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import type { ClusterStorages } from '$lib/types/cluster/storage';
import { handleAPIError } from '$lib/utils/http';
import Icon from '@iconify/svelte';
import { toast } from 'svelte-sonner';
interface Props {
open: boolean;
reload: boolean;
storages: ClusterStorages;
}
let { open = $bindable(), reload = $bindable(), storages }: Props = $props();
let options = {
s3: {
name: '',
endpoint: '',
region: '',
bucket: '',
accessKey: '',
secretKey: ''
}
};
let properties = $state(options);
let loading = $state(false);
let type = $state({
combobox: {
open: false,
value: '' as '' | 's3'
}
});
async function create() {
if (type.combobox.value === 's3') {
const data = properties.s3;
if (
!data.name ||
!data.endpoint ||
!data.region ||
!data.bucket ||
!data.accessKey ||
!data.secretKey
) {
toast.error('Missing required fields', {
position: 'bottom-center'
});
return;
}
loading = true;
const response = await createS3Storage(
data.name,
data.endpoint,
data.region,
data.bucket,
data.accessKey,
data.secretKey
);
loading = false;
reload = true;
if (response.error) {
handleAPIError(response);
toast.error('Failed to create S3 storage', {
position: 'bottom-center'
});
return;
}
toast.success('S3 storage created', {
position: 'bottom-center'
});
open = false;
}
}
</script>
{#snippet s3Input(name: string, label: string)}
<CustomValueInput
bind:value={properties.s3[name as keyof typeof properties.s3]}
placeholder={label}
classes="flex-1 space-y-1.5"
/>
{/snippet}
<Dialog.Root bind:open>
<Dialog.Content>
<Dialog.Header class="p-0">
<Dialog.Title class="flex justify-between gap-1 text-left">
<div class="flex items-center gap-2">
<Icon icon="mdi:storage" class="h-6 w-6" />
<span>Create Storage</span>
</div>
<div class="flex items-center gap-0.5">
<Button
size="sm"
variant="link"
class="h-4"
title={'Reset'}
onclick={() => {
properties = options;
}}
>
<Icon icon="radix-icons:reset" class="pointer-events-none h-4 w-4" />
<span class="sr-only">Reset</span>
</Button>
<Button
size="sm"
variant="link"
class="h-4"
title={'Close'}
onclick={() => {
open = false;
properties = options;
}}
>
<Icon icon="material-symbols:close-rounded" class="pointer-events-none h-4 w-4" />
<span class="sr-only">Close</span>
</Button>
</div>
</Dialog.Title>
</Dialog.Header>
<CustomComboBox
bind:open={type.combobox.open}
label="Type"
bind:value={type.combobox.value}
data={[{ value: 's3', label: 'S3' }]}
classes="flex-1 space-y-1"
placeholder="Select Type"
triggerWidth="w-full"
width="w-full lg:w-[75%]"
></CustomComboBox>
{#if type.combobox.value === 's3'}
<div class="mt-0 grid grid-cols-2 gap-4">
{@render s3Input('name', 'Name')}
{@render s3Input('endpoint', 'Endpoint')}
{@render s3Input('region', 'Region')}
{@render s3Input('bucket', 'Bucket')}
{@render s3Input('accessKey', 'Access Key')}
{@render s3Input('secretKey', 'Secret Key')}
</div>
{/if}
<Dialog.Footer class="flex justify-end">
<div class="flex w-full items-center justify-end gap-2">
<Button onclick={create} type="submit" size="sm" disabled={loading}>
{#if loading}
<Icon icon="mdi:loading" class="h-4 w-4 animate-spin" />
{:else}
Create
{/if}
</Button>
</div>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -1,17 +1,63 @@
<script lang="ts">
import CustomComboBox from '$lib/components/ui/custom-input/combobox.svelte';
import CustomValueInput from '$lib/components/ui/custom-input/value.svelte';
import { currentHostname } from '$lib/stores/auth';
import type { ClusterNode } from '$lib/types/cluster/cluster';
interface Props {
name: string;
id: number;
description: string;
refetch: boolean;
nodes: ClusterNode[];
node: string;
}
let { name = $bindable(), id = $bindable(), description = $bindable() }: Props = $props();
let {
name = $bindable(),
id = $bindable(),
description = $bindable(),
refetch = $bindable(),
nodes,
node = $bindable()
}: Props = $props();
let host = $state({
combobox: {
open: false
}
});
let hosts = $derived.by(() => {
return nodes.map((n) => ({
label: n.hostname,
value: n.hostname
}));
});
$effect(() => {
if (node) {
currentHostname.set(node);
refetch = true;
}
});
</script>
<div class="flex flex-col gap-4 p-4">
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
{#if hosts.length > 0}
<CustomComboBox
bind:open={host.combobox.open}
label="Node"
bind:value={node}
data={hosts}
classes="flex-1 space-y-1"
placeholder="Select Node"
triggerWidth="w-full "
width="w-full lg:w-[75%]"
></CustomComboBox>
{/if}
<CustomValueInput
label="Jail Name"
placeholder="Postgres"
@@ -1,4 +1,5 @@
<script lang="ts">
import { getNodes } from '$lib/api/cluster/cluster';
import { getJails, newJail } from '$lib/api/jail/jail';
import { getNetworkObjects } from '$lib/api/network/object';
import { getSwitches } from '$lib/api/network/switch';
@@ -9,6 +10,7 @@
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';
@@ -19,7 +21,7 @@
import { isValidCreateData } from '$lib/utils/jail/jail';
import { getNextId } from '$lib/utils/vm/vm';
import Icon from '@iconify/svelte';
import { useQueries } from '@sveltestack/svelte-query';
import { useQueries, useQueryClient } from '@sveltestack/svelte-query';
import { toast } from 'svelte-sonner';
import Basic from './Basic.svelte';
import Hardware from './Hardware.svelte';
@@ -38,76 +40,97 @@
{ value: 'hardware', label: 'Hardware & Advanced' }
];
let queryClient = useQueryClient();
const results = useQueries([
{
queryKey: ['datasetList-svm'],
queryKey: 'zfs-datasets',
queryFn: async () => {
return await getDatasets();
},
refetchInterval: 1000,
keepPreviousData: true,
initialData: [],
refetchOnMount: 'always'
},
{
queryKey: ['downloads-svm'],
queryKey: 'downloads',
queryFn: async () => {
return await getDownloads();
},
refetchInterval: 1000,
keepPreviousData: true,
initialData: [],
refetchOnMount: 'always'
},
{
queryKey: ['networkSwitches-svm'],
queryKey: 'network-switches',
queryFn: async () => {
return await getSwitches();
},
refetchInterval: 1000,
keepPreviousData: true,
initialData: {} as SwitchList,
refetchOnMount: 'always'
},
{
queryKey: ['vms-svm'],
queryKey: 'vm-list',
queryFn: async () => {
return await getVMs();
},
refetchInterval: 1000,
keepPreviousData: true,
initialData: [],
refetchOnMount: 'always'
},
{
queryKey: ['network-objects-svm'],
queryKey: 'network-objects',
queryFn: async () => {
return await getNetworkObjects();
},
refetchInterval: 1000,
keepPreviousData: true,
initialData: [],
refetchOnMount: 'always'
},
{
queryKey: ['jails-svm'],
queryKey: 'jail-list',
queryFn: async () => {
return await getJails();
},
refetchInterval: 1000,
keepPreviousData: true,
initialData: [],
refetchOnMount: 'always'
},
{
queryKey: 'cluster-nodes',
queryFn: async () => {
return await getNodes();
},
keepPreviousData: true,
initialData: [],
refetchOnMount: 'always'
}
]);
let refetch = $state(false);
$effect(() => {
if (refetch) {
queryClient.refetchQueries('zfs-datasets');
queryClient.refetchQueries('downloads');
queryClient.refetchQueries('network-switches');
queryClient.refetchQueries('vm-list');
queryClient.refetchQueries('network-objects');
queryClient.refetchQueries('jail-list');
queryClient.refetchQueries('cluster-nodes');
refetch = false;
}
});
let datasets: Dataset[] = $derived($results[0].data as Dataset[]);
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 filesystems: Dataset[] = $derived(
@@ -117,6 +140,7 @@
let options = {
name: '',
id: 0,
node: '',
description: '',
storage: {
dataset: '',
@@ -248,6 +272,9 @@
bind:name={modal.name}
bind:id={modal.id}
bind:description={modal.description}
bind:refetch
bind:node={modal.node}
{nodes}
/>
{:else if value === 'storage'}
<Storage
+2 -6
View File
@@ -19,16 +19,16 @@
language: string,
remember: boolean
) => void;
loading: boolean;
}
let { onLogin }: Props = $props();
let { onLogin, loading = $bindable() }: Props = $props();
let username = $state('');
let password = $state('');
let authType = $state('sylve');
let language = $state('en');
let remember = $state(false);
let loading = $state(false);
$effect(() => {
if (page.url.search.includes('loggedOut')) {
@@ -39,9 +39,6 @@
async function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault();
if (loading) return;
loading = true;
try {
onLogin(username, password, authType, language, remember);
} catch (error) {
@@ -148,7 +145,6 @@
</div>
<Button
onclick={() => {
loading = true;
onLogin(username, password, authType, language, remember);
}}
size="sm"
@@ -1,7 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { iconCache } from '$lib/utils/icons';
import Icon from '@iconify/svelte';
import { slide } from 'svelte/transition';
import SidebarElement from './TreeView.svelte';
@@ -70,7 +69,7 @@
});
</script>
<li class={`w-full`}>
<li class="w-full">
<a
class={`my-0.5 flex w-full items-center justify-between px-1.5 py-0.5 ${isActive ? sidebarActive : 'hover:bg-muted dark:hover:bg-muted rounded-md'}${lastActiveUrl === item.label ? '!text-primary' : ' '}`}
href={item.href}
@@ -108,7 +107,7 @@
{#if isOpen && item.children}
<ul class="pl-5" transition:slide={{ duration: 200, easing: (t) => t }} style="overflow: hidden;">
{#each item.children as child}
{#each item.children as child (child.label)}
<SidebarElement item={child} {onToggle} />
{/each}
</ul>
@@ -189,8 +189,6 @@
let downloads = $derived($results[7].data as Download[]);
let nodes = $derived($results[11].data as ClusterNode[]);
$inspect(nodes);
const tabs = [
{ value: 'basic', label: 'Basic' },
{ value: 'storage', label: 'Storage' },
@@ -1,17 +1,14 @@
<script lang="ts">
import { getFiles } from '$lib/api/system/file-explorer';
import { storageAttach } from '$lib/api/vm/storage';
import SimpleSelect from '$lib/components/custom/SimpleSelect.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import CustomValueInput from '$lib/components/ui/custom-input/value.svelte';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import type { CPUInfo } from '$lib/types/info/cpu';
import type { RAMInfo } from '$lib/types/info/ram';
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 { getCache, handleAPIError } from '$lib/utils/http';
import { handleAPIError } from '$lib/utils/http';
import { getISOs } from '$lib/utils/utilities/downloader';
import Icon from '@iconify/svelte';
import humanFormat from 'human-format';
@@ -51,6 +48,30 @@
});
});
let existingImage = $state(false);
$effect(() => {
if (properties.name && properties.type === 'raw' && properties.dataset) {
const dataset = datasets.find(
(d) => d.guid === properties.dataset || d.name === properties.dataset
);
const mountPoint = dataset?.mountpoint || '';
if (mountPoint) {
getFiles(mountPoint).then((files) => {
for (const file of files) {
if (file.id === `${mountPoint}/${properties.name}.img`) {
existingImage = true;
properties.size = humanFormat(file.size || 0);
return;
}
}
existingImage = false;
});
}
}
});
async function attach() {
if (!properties.type || !properties.dataset) {
toast.error('Please select a type and dataset', {
@@ -288,7 +309,12 @@
placeholder="8 GB"
bind:value={properties.size}
classes="flex-1 space-y-1"
disabled={existingImage}
/>
{#if existingImage}
<span class="-mt-3 text-xs text-yellow-500">Existing image will be used</span>
{/if}
</div>
{/if}
@@ -55,7 +55,8 @@
encryptionKey: '',
quota: '',
aclinherit: 'passthrough',
aclmode: 'passthrough'
aclmode: 'passthrough',
recordsize: '131072'
};
let zfsProperties = $state(createFSProps);
@@ -133,7 +134,8 @@
encryptionKey: properties.encryptionKey,
quota: properties.quota,
aclinherit: properties.aclinherit,
aclmode: properties.aclmode
aclmode: properties.aclmode,
recordsize: properties.recordsize
});
reload = true;
@@ -318,6 +320,14 @@
bind:value={properties.aclmode}
onChange={(value) => (properties.aclmode = value)}
/>
<SimpleSelect
label="Recordsize"
placeholder="Select Recordsize"
options={zfsProperties.recordsize}
bind:value={properties.recordsize}
onChange={(value) => (properties.recordsize = value)}
/>
</div>
</div>
@@ -26,7 +26,8 @@
dedup: dataset.dedup || 'off',
quota: dataset.quota ? bytesToHumanReadable(dataset.quota) : '',
aclinherit: dataset.aclinherit || 'passthrough',
aclmode: dataset.aclmode || 'passthrough'
aclmode: dataset.aclmode || 'passthrough',
recordsize: dataset.recordsize ? dataset.recordsize.toString() : '131072'
};
let zfsProperties = $state(createFSProps);
@@ -49,7 +50,8 @@
dedup: properties.dedup,
quota: parseQuotaToZFSBytes(properties.quota),
aclinherit: properties.aclinherit,
aclmode: properties.aclmode
aclmode: properties.aclmode,
recordsize: properties.recordsize
});
reload = true;
@@ -180,6 +182,14 @@
bind:value={properties.aclmode}
onChange={(value) => (properties.aclmode = value)}
/>
<SimpleSelect
label="Recordsize"
placeholder="Select Recordsize"
options={zfsProperties.recordsize}
bind:value={properties.recordsize}
onChange={(value) => (properties.recordsize = value)}
/>
</div>
</div>
@@ -1,13 +1,18 @@
<script lang="ts">
import { getClusterResources, getNodes } from '$lib/api/cluster/cluster';
import { default as TreeView } from '$lib/components/custom/TreeView.svelte';
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 { useQueries, useQueryClient } from '@sveltestack/svelte-query';
import { default as TreeViewCluster } from './TreeViewCluster.svelte';
let openCategories: Record<string, boolean> = $state({});
const onToggle = (label: string) => (openCategories[label] = !openCategories[label]);
let openIds = $state(new Set<string>(['datacenter']));
const toggleOpen = (id: string) => {
if (openIds.has(id)) openIds.delete(id);
else openIds.add(id);
openIds = new Set(openIds);
};
const queryClient = useQueryClient();
const results = useQueries([
@@ -34,6 +39,7 @@
const tree = $derived([
{
id: 'datacenter',
label: 'Data Center',
icon: 'ant-design:cluster-outlined',
href: '/datacenter',
@@ -84,8 +90,8 @@
<nav aria-label="sylve-sidebar" class="menu thin-scrollbar w-full">
<ul>
<ScrollArea orientation="both" class="h-full w-full">
{#each tree as item}
<TreeView {item} {onToggle} bind:this={openCategories} />
{#each tree as item (item.id)}
<TreeViewCluster {item} {openIds} onToggleId={toggleOpen} />
{/each}
</ScrollArea>
</ul>
@@ -0,0 +1,89 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import Icon from '@iconify/svelte';
import { slide } from 'svelte/transition';
import SidebarElement from './TreeViewCluster.svelte';
interface SidebarProps {
id: string;
label: string;
icon: string;
href?: string;
state?: 'active' | 'inactive';
children?: SidebarProps[];
}
interface Props {
item: SidebarProps;
openIds: Set<string>;
onToggleId: (id: string) => void;
}
let { item, openIds, onToggleId }: Props = $props();
const toggle = (e: MouseEvent) => {
if (item.children?.length) onToggleId(item.id);
if (item.href) goto(item.href, { replaceState: false, noScroll: false });
e.preventDefault();
};
const sidebarActive = 'rounded-md bg-muted dark:bg-muted font-inter font-medium';
function isItemActive(menuItem: SidebarProps, currentUrl: string): boolean {
if (menuItem.href && currentUrl.startsWith(menuItem.href)) return true;
return menuItem.children?.some((c) => isItemActive(c, currentUrl)) ?? false;
}
let activeUrl = $derived(page.url.pathname);
let isActive = $derived(isItemActive(item, activeUrl));
let lastActiveUrl = $derived.by(() => {
const segments = activeUrl.split('/');
return segments[segments.length - 1];
});
let isOpen = $derived(openIds.has(item.id));
</script>
<li class="w-full">
<a
class={`my-0.5 flex w-full items-center justify-between px-1.5 py-0.5 ${isActive ? sidebarActive : 'hover:bg-muted dark:hover:bg-muted rounded-md'}${lastActiveUrl === item.label ? '!text-primary' : ' '}`}
href={item.href}
onclick={toggle}
>
<div class="flex items-center space-x-1 text-sm">
{#if item.icon === 'material-symbols:monitor-outline' || item.icon === 'hugeicons:prison'}
<div class="flex items-center space-x-1 text-sm">
<div class="relative">
<Icon icon={item.icon} width="18" />
{#if item.state && item.state === 'active'}
<div
class="absolute -bottom-1 -right-1 flex h-2 w-2 items-center justify-center rounded-full bg-green-500"
>
<Icon icon="mdi:play" class="h-2 w-2 text-white" />
</div>
{/if}
</div>
</div>
{:else}
<Icon icon={item.icon} width="18" />
{/if}
<p class="font-inter cursor-pointer whitespace-nowrap">
{item.label}
</p>
</div>
{#if item.children && item.children.length > 0}
<Icon
icon={isOpen ? 'teenyicons:down-solid' : 'teenyicons:right-solid'}
class="h-3.5 w-3.5"
/>
{/if}
</a>
</li>
{#if isOpen && item.children}
<ul class="pl-5" transition:slide={{ duration: 200, easing: (t) => t }} style="overflow: hidden;">
{#each item.children as child (child.id)}
<SidebarElement item={child} {openIds} {onToggleId} />
{/each}
</ul>
{/if}
@@ -0,0 +1,17 @@
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-05T13:42:00.748Z\n"
"PO-Revision-Date: 2025-09-05T13:42:00.748Z\n"
"Last-Translator: \n"
"Language: Arabic\n"
"Language-Team: \n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n == 1 ? 0 : 1;\n"
"MIME-Version: 1.0\n"
#: src/lib/components/skeleton/LeftPanelClustered.svelte
msgid "Data Center"
msgstr ""
+2302 -2603
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2158 -2154
View File
File diff suppressed because it is too large Load Diff
+2289 -2660
View File
File diff suppressed because it is too large Load Diff
+1880 -2177
View File
File diff suppressed because it is too large Load Diff
+2376 -2673
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
import { z } from 'zod/v4';
export const ClusterS3ConfigSchema = z.object({
id: z.number(),
name: z.string().min(2).max(100),
endpoint: z.string(),
region: z.string(),
bucket: z.string(),
accessKey: z.string(),
secretKey: z.string()
});
export const ClusterStoragesSchema = z.object({
s3: z.array(ClusterS3ConfigSchema).default([])
});
export type ClusterS3Config = z.infer<typeof ClusterS3ConfigSchema>;
export type ClusterStorages = z.infer<typeof ClusterStoragesSchema>;
+1
View File
@@ -3,6 +3,7 @@ import { z } from 'zod/v4';
export interface CreateData {
name: string;
id: number;
node: string;
description: string;
storage: {
dataset: string;
+1
View File
@@ -7,6 +7,7 @@ export const DatasetSchema = z.object({
guid: z.string(),
used: z.number(),
avail: z.number(),
recordsize: z.number(),
mountpoint: z.string(),
compression: z.string(),
type: z.string(),
+6 -4
View File
@@ -10,8 +10,10 @@
export function generateComboboxOptions(values: string[], additional?: string[]) {
const combined = [...values, ...(additional ?? [])];
return combined.map((option) => ({
label: option,
value: option
}));
return combined
.map((option) => ({
label: option,
value: option
}))
.filter((option, index, self) => self.findIndex((o) => o.value === option.value) === index);
}
+6
View File
@@ -264,3 +264,9 @@ export function fromBase64(input: string): string {
const decoded = atob(input);
return new TextDecoder().decode(Uint8Array.from(decoded.split('').map((c) => c.charCodeAt(0))));
}
export function toHex(input: string): string {
return Array.from(new TextEncoder().encode(input))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
+2 -2
View File
@@ -86,9 +86,9 @@ export function generateTableData(
name = (found as Dataset).name;
if (storage.type === 'raw') {
if (storage.name?.endsWith('.img')) {
name += `/sylve-vm-images/${storage.name}`;
name += `/${storage.name}`;
} else {
name += `/sylve-vm-images/${storage.name}.img`;
name += `/${storage.name}.img`;
}
size = storage.size || 0;
} else if (storage.type === 'zvol') {
+19 -1
View File
@@ -169,7 +169,25 @@ export const createFSProps = {
label: 'Restricted',
value: 'restricted'
}
]
],
recordsize: [
{
label: '8K - Postgres',
value: '8192'
},
{
label: '16K - MySQL',
value: '16384'
},
{
label: '128K - default',
value: '131072'
},
{
label: '1M - Large Files',
value: '1048576'
}
]
};
export function generateTableData(grouped: GroupedByPool[]): { rows: Row[]; columns: Column[] } {
+1 -1
View File
@@ -216,7 +216,7 @@ export function generateTableData(grouped: GroupedByPool[]): { rows: Row[]; colu
const value = cell.getValue();
if (value.includes('/')) {
const [, volume] = value.split('/');
const volume = value.substring(value.indexOf('/') + 1);
return renderWithIcon('carbon:volume-block-storage', volume);
}
+18 -10
View File
@@ -19,6 +19,7 @@
import { loadLocale } from 'wuchale/run-client';
import type { Locales } from '$lib/types/common';
import { sleep } from '$lib/utils';
import '../app.css';
$effect.pre(() => {
@@ -28,7 +29,10 @@
const queryClient = new QueryClient();
let { children } = $props();
let isLoggedIn = $state(false);
let isLoading = $state(true);
let loading = $state({
throbber: true,
login: false
});
$effect(() => {
if (isLoggedIn && $hostname) {
@@ -65,14 +69,11 @@
}
await preloadIcons();
isLoading = false;
await sleep(1000);
loading.throbber = false;
await tick();
});
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function handleLogin(
username: string,
password: string,
@@ -81,12 +82,15 @@
remember: boolean
) {
let isError = false;
loading.login = true;
await sleep(500);
try {
loadLocale(language as Locales);
if (await login(username, password, type, remember, language)) {
isLoading = true;
isLoggedIn = true;
loading.login = false;
const path = window.location.pathname;
if (path === '/') {
@@ -95,16 +99,20 @@
} else {
isError = true;
isLoggedIn = false;
loading.login = false;
}
} catch (error) {
isError = true;
isLoggedIn = false;
loading.login = false;
} finally {
if (!isError) {
await sleep(2500);
isLoading = false;
}
}
loading.login = false;
loading.throbber = false;
return;
}
</script>
@@ -117,7 +125,7 @@
<Toaster />
<ModeWatcher />
{#if isLoading}
{#if loading.throbber}
<Throbber />
{:else if isLoggedIn && $hostname}
<QueryClientProvider client={queryClient}>
@@ -126,5 +134,5 @@
</Shell>
</QueryClientProvider>
{:else}
<Login onLogin={handleLogin} />
<Login onLogin={handleLogin} loading={loading.login} />
{/if}
+1 -1
View File
@@ -297,7 +297,7 @@
<nav aria-label="Difuse-sidebar" class="menu thin-scrollbar w-full">
<ul>
<ScrollArea orientation="both" class="h-full w-full">
{#each nodeItems as item}
{#each nodeItems as item (item.label)}
<TreeView {item} onToggle={toggleCategory} bind:this={openCategories} />
{/each}
</ScrollArea>
@@ -1,10 +1,10 @@
<script lang="ts">
import { page } from '$app/state';
import { getJails, getJailStates } from '$lib/api/jail/jail';
import { store } from '$lib/stores/auth';
import { clusterStore, currentHostname, store } from '$lib/stores/auth';
import type { Jail, JailState } from '$lib/types/jail/jail';
import { updateCache } from '$lib/utils/http';
import { sha256 } from '$lib/utils/string';
import { sha256, toBase64, toHex } from '$lib/utils/string';
import {
Xterm,
XtermAddon,
@@ -16,6 +16,7 @@
import Icon from '@iconify/svelte';
import { useQueries } from '@sveltestack/svelte-query';
import adze from 'adze';
import { get } from 'svelte/store';
interface Data {
jails: Jail[];
@@ -78,9 +79,16 @@
fit.fit();
const hash = await sha256($store, 1);
ws = new WebSocket(`/api/jail/console?ctid=${jail.ctId}&hash=${hash}`);
ws.binaryType = 'arraybuffer';
const wssAuth = {
hostname: get(currentHostname),
token: $clusterStore
};
ws = new WebSocket(`/api/jail/console?ctid=${jail.ctId}&hash=${hash}`, [
toHex(JSON.stringify(wssAuth))
]);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
adze.info(`Jail console connected for jail ${jail.ctId}`);
const dims = fit.proposeDimensions();
@@ -350,6 +350,7 @@
}
modals.delete.open = false;
activeRows = [];
}
$effect(() => {
@@ -8,8 +8,7 @@ export async function load({ params }) {
const [jails, jailStates] = await Promise.all([
cachedFetch('jail-list', async () => getJails(), cacheDuration),
cachedFetch('jail-states', async () => getJailStates(), cacheDuration),
cachedFetch(`jail-stats-${vmId}`, async () => getStats(Number(vmId), 10), cacheDuration)
cachedFetch('jail-states', async () => getJailStates(), cacheDuration)
]);
const jail = jails.find((jail) => jail.ctId === parseInt(params.node, 10));
@@ -75,31 +75,15 @@
let query: string = $state('');
let useablePorts = $derived.by(() => {
let used: string[] = [];
const available: string[] = [];
if (switches) {
if (switches.standard) {
for (const sw of switches.standard) {
if (sw.ports) {
const ports = sw.ports.map((port) => port.name);
used = [...used, ...ports];
}
}
}
}
let available: string[] = [];
if (interfaces) {
if (interfaces) {
for (const iface of interfaces) {
if (!used.includes(iface.name) && !iface.groups?.includes('bridge')) {
available.push(iface.name);
}
}
for (const iface of interfaces) {
available.push(iface.name);
}
}
return available;
return available.filter((item, index) => available.indexOf(item) === index);
});
let confirmModals = $state({
@@ -125,7 +125,8 @@
let v = '';
if (formattedValue.length > 0) {
for (const val of formattedValue) {
v += `<span class=" focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-md border px-2 py-0.5 text-xs font-medium transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3 bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 dark:border-transparent">${val}</span>`;
const index = formattedValue.indexOf(val);
v += `<span class=" focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-md border px-2 py-0.5 text-xs font-medium transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3 bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 dark:border-transparent ${index > 0 ? 'ml-1.5' : ''}">${val}</span>`;
}
}
@@ -54,6 +54,12 @@
}
]);
let pools: Zpool[] = $derived($results[0].data as Zpool[]);
let datasets: Dataset[] = $derived($results[1].data as Dataset[]);
let grouped = $derived(groupByPool(pools, datasets));
let tableData = $derived(generateTableData(grouped));
let activeRows: Row[] | null = $state(null);
let activeRow: Row | null = $derived(activeRows ? (activeRows[0] as Row) : ({} as Row));
let reload = $state(false);
$effect(() => {
@@ -67,13 +73,6 @@
}
});
let pools: Zpool[] = $derived($results[0].data as Zpool[]);
let datasets: Dataset[] = $derived($results[1].data as Dataset[]);
let grouped = $derived(groupByPool(pools, datasets));
let tableData = $derived(generateTableData(grouped));
let activeRows: Row[] | null = $state(null);
let activeRow: Row | null = $derived(activeRows ? (activeRows[0] as Row) : ({} as Row));
let activeDataset: Dataset | null = $derived.by(() => {
if (activeRow) {
for (const dataset of grouped) {
+1 -5
View File
@@ -10,8 +10,6 @@
import { Progress } from '$lib/components/ui/progress/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import { reload } from '$lib/stores/api.svelte';
import { currentHostname } from '$lib/stores/auth';
import type { BasicInfo } from '$lib/types/info/basic';
import type { CPUInfo, CPUInfoHistorical } from '$lib/types/info/cpu';
import type { HistoricalNetworkInterface } from '$lib/types/info/network';
@@ -19,12 +17,10 @@
import type { IODelay, IODelayHistorical } from '$lib/types/zfs/pool';
import { updateCache } from '$lib/utils/http';
import { bytesToHumanReadable, floatToNDecimals } from '$lib/utils/numbers';
import { formatUptime, secondsToHoursAgo } from '$lib/utils/time';
import { formatUptime } from '$lib/utils/time';
import Icon from '@iconify/svelte';
import { useQueries, useQueryClient } from '@sveltestack/svelte-query';
import type { Chart } from 'chart.js';
import { onMount } from 'svelte';
import { get } from 'svelte/store';
interface Data {
basicInfo: BasicInfo;
@@ -1,6 +1,9 @@
<script lang="ts">
import { clusterStore, currentHostname } from '$lib/stores/auth';
import type { VMDomain } from '$lib/types/vm/vm';
import { toHex } from '$lib/utils/string';
import Icon from '@iconify/svelte';
import { get } from 'svelte/store';
interface Data {
port: number;
@@ -10,9 +13,17 @@
}
let { data }: { data: Data } = $props();
let path = $derived(`/api/vnc/${encodeURIComponent(String(data.port))}?hash=${data.hash}`);
const wssAuth = $state({
hash: data.hash,
hostname: get(currentHostname) || '',
token: $clusterStore || ''
});
let revealIframe = $state(false);
let path = $derived(
`/api/vnc/${encodeURIComponent(String(data.port))}?auth=${toHex(JSON.stringify(wssAuth))}`
);
if (data.domain && data.domain.status !== 'Shutoff') {
setTimeout(() => {
@@ -1,7 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
import { getDownloads } from '$lib/api/utilities/downloader';
import { storageAttach, storageDetach } from '$lib/api/vm/storage';
import { storageDetach } from '$lib/api/vm/storage';
import { getVMDomain, getVMs } from '$lib/api/vm/vm';
import { getDatasets } from '$lib/api/zfs/datasets';
import { getPools } from '$lib/api/zfs/pool';
+5
View File
@@ -34,6 +34,11 @@
label: 'Cluster',
icon: 'carbon:assembly-cluster',
href: '/datacenter/cluster'
},
{
label: 'Storage',
icon: 'mdi:storage',
href: '/datacenter/storage'
}
];
});
@@ -0,0 +1,176 @@
<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 Icon from '@iconify/svelte';
import { useQueries, useQueryClient } from '@sveltestack/svelte-query';
import { toast } from 'svelte-sonner';
interface Data {
storages: ClusterStorages;
}
let { data }: { data: Data } = $props();
const queryClient = useQueryClient();
let results = useQueries([
{
queryKey: 'cluster-storages',
queryFn: getStorages,
keepPreviousData: true,
initialData: data.storages,
refetchOnMount: 'always',
onSuccess: (data: ClusterStorages) => {
updateCache('cluster-storages', data);
}
}
]);
let reload = $state(false);
$effect(() => {
if (reload) {
queryClient.refetchQueries('cluster-storages');
activeRows = null;
reload = false;
}
});
let storages = $derived($results[0].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: 'bucket',
title: 'Bucket'
}
];
for (const s3Storage of storages.s3) {
rows.push({
id: s3Storage.id,
type: 'S3',
name: s3Storage.name,
bucket: s3Storage.bucket
});
}
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">
<Icon icon="mdi:delete" class="mr-1 h-4 w-4" />
<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">
<Icon icon="gg:add" class="mr-1 h-4 w-4" />
<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>

Some files were not shown because too many files have changed in this diff Show More