mirror of
https://github.com/AlchemillaHQ/Sylve.git
synced 2026-06-14 00:46:34 +03:00
Merge branch 'AlchemillaHQ:master' into readme_edits
This commit is contained in:
@@ -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']
|
||||
@@ -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
|
||||
|
||||
We’re 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>
|
||||
   
|
||||
<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 |
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -10,5 +10,5 @@ package assets
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed web-files/**
|
||||
//go:embed all:web-files
|
||||
var SvelteKitFiles embed.FS
|
||||
|
||||
@@ -99,6 +99,7 @@ func SetupDatabase(cfg *internal.SylveConfig, isTest bool) *gorm.DB {
|
||||
|
||||
&clusterModels.Cluster{},
|
||||
&clusterModels.ClusterNode{},
|
||||
&clusterModels.ClusterS3Config{},
|
||||
&clusterModels.ClusterOption{},
|
||||
&clusterModels.ClusterNote{},
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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]{
|
||||
|
||||
@@ -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]{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 we’ll wait for the next pong
|
||||
pingPeriod = pongWait / 2 // how often we’ll 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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 isn’t 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -87,6 +87,8 @@ func (s *Service) CheckPackageDependencies() error {
|
||||
"smartmontools",
|
||||
"tmux",
|
||||
"samba419",
|
||||
"jansson",
|
||||
"swtpm",
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
package zfs
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Generated
+34
-132
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+1963
-2326
File diff suppressed because it is too large
Load Diff
+2158
-2154
File diff suppressed because it is too large
Load Diff
+2289
-2660
File diff suppressed because it is too large
Load Diff
+1880
-2177
File diff suppressed because it is too large
Load Diff
+2376
-2673
File diff suppressed because it is too large
Load Diff
@@ -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>;
|
||||
@@ -3,6 +3,7 @@ import { z } from 'zod/v4';
|
||||
export interface CreateData {
|
||||
name: string;
|
||||
id: number;
|
||||
node: string;
|
||||
description: string;
|
||||
storage: {
|
||||
dataset: string;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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[] } {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user