diff --git a/internal/collect/zfs.go b/internal/collect/zfs.go index fa3a6ee..6e133fb 100644 --- a/internal/collect/zfs.go +++ b/internal/collect/zfs.go @@ -35,6 +35,8 @@ type Pool struct { Status string `json:"status,omitempty"` Action string `json:"action,omitempty"` Vdevs []Vdev `json:"vdevs"` + // DedupRatio is the pool-wide dedup ratio (1.00 = nothing deduplicated). + DedupRatio float64 `json:"dedup_ratio"` } type Dataset struct { @@ -43,11 +45,12 @@ type Dataset struct { Avail uint64 `json:"avail"` Refer uint64 `json:"refer"` Mountpoint string `json:"mountpoint"` + Dedup string `json:"dedup"` // dedup property: off, on, verify, sha256, ... } // ReadPools combines zpool list and zpool status for every pool. func ReadPools() ([]Pool, error) { - out, err := run(20*time.Second, "zpool", "list", "-Hp", "-o", "name,size,alloc,free,cap,health,frag") + out, err := run(20*time.Second, "zpool", "list", "-Hp", "-o", "name,size,alloc,free,cap,health,frag,dedup") if err != nil { return nil, err } @@ -63,7 +66,7 @@ func ReadPools() ([]Pool, error) { } func ReadDatasets() ([]Dataset, error) { - out, err := run(20*time.Second, "zfs", "list", "-Hp", "-t", "filesystem", "-o", "name,used,avail,refer,mountpoint") + out, err := run(20*time.Second, "zfs", "list", "-Hp", "-t", "filesystem", "-o", "name,used,avail,refer,mountpoint,dedup") if err != nil { return nil, err } @@ -84,6 +87,9 @@ func parseZpoolList(b []byte) []Pool { p.Free, _ = strconv.ParseUint(f[3], 10, 64) p.CapPct = atoiPct(f[4]) p.FragPct = atoiPct(f[6]) + if len(f) > 7 { + p.DedupRatio, _ = strconv.ParseFloat(strings.TrimSuffix(strings.TrimSpace(f[7]), "x"), 64) + } if p.CapPct == 0 && p.Size > 0 { p.CapPct = int(p.Alloc * 100 / p.Size) } @@ -220,6 +226,9 @@ func parseZfsList(b []byte) []Dataset { d.Used, _ = strconv.ParseUint(f[1], 10, 64) d.Avail, _ = strconv.ParseUint(f[2], 10, 64) d.Refer, _ = strconv.ParseUint(f[3], 10, 64) + if len(f) > 5 { + d.Dedup = f[5] + } out = append(out, d) } return out diff --git a/internal/collect/zfs_test.go b/internal/collect/zfs_test.go index db4af89..1b1b8ab 100644 --- a/internal/collect/zfs_test.go +++ b/internal/collect/zfs_test.go @@ -119,6 +119,15 @@ func TestParseZpoolList(t *testing.T) { if pools[1].CapPct != 90 || pools[1].FragPct != 0 { t.Errorf("pool1 = %+v", pools[1]) } + if pools[0].DedupRatio != 0 { + t.Errorf("dedup ratio without the column = %v", pools[0].DedupRatio) + } + + // with the dedup column; -p prints "1.35", without -p it is "1.35x" + pools = parseZpoolList([]byte("a\t1\t1\t0\t100\tONLINE\t0\t1.35\nb\t1\t1\t0\t100\tONLINE\t0\t2.00x\n")) + if len(pools) != 2 || pools[0].DedupRatio != 1.35 || pools[1].DedupRatio != 2 { + t.Errorf("dedup ratio = %+v", pools) + } } func TestParseZfsList(t *testing.T) { @@ -127,4 +136,8 @@ func TestParseZfsList(t *testing.T) { if len(ds) != 2 || ds[1].Name != "nas_pool_1_8_gb/appdata" || ds[1].Avail != 200 { t.Errorf("datasets = %+v", ds) } + ds = parseZfsList([]byte("tank\t1\t2\t3\t/tank\ton\n")) + if len(ds) != 1 || ds[0].Dedup != "on" || ds[0].Mountpoint != "/tank" { + t.Errorf("dedup column: %+v", ds) + } } diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 0394081..9eacca0 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -124,7 +124,9 @@ type zfsVM struct { S sched.Status Th config.Thresholds Datasets []datasetRow - Form createForm // last submitted values (re-rendered after an error) + // PoolDedup maps a pool to the dedup property of its root dataset. + PoolDedup map[string]string + Form createForm // last submitted values (re-rendered after an error) } type datasetRow struct { @@ -134,15 +136,18 @@ type datasetRow struct { } type createForm struct { - Pool, Name, Owner, Compression, Quota string + Pool, Name, Owner, Compression, Dedup, Quota string } func (s *Server) zfsView(st sched.Status, form createForm) zfsVM { - vm := zfsVM{S: st, Th: s.cfg.Get().Thresholds, Form: form} + vm := zfsVM{S: st, Th: s.cfg.Get().Thresholds, Form: form, PoolDedup: map[string]string{}} if vm.Form.Owner == "" { vm.Form.Owner = defaultOwner } for _, d := range st.Datasets { + if !strings.Contains(d.Name, "/") { + vm.PoolDedup[d.Name] = d.Dedup + } row := datasetRow{Dataset: d, DomID: strings.NewReplacer("/", "-", ".", "_", ":", "_").Replace(d.Name)} row.CanDestroy = strings.Contains(d.Name, "/") && d.Refer < zfsadm.EmptyRefer for _, o := range st.Datasets { @@ -170,6 +175,7 @@ func (s *Server) zfsCreate(w http.ResponseWriter, r *http.Request) { Name: strings.Trim(strings.TrimSpace(r.FormValue("name")), "/"), Owner: strings.TrimSpace(r.FormValue("owner")), Compression: r.FormValue("compression"), + Dedup: r.FormValue("dedup"), Quota: strings.ToUpper(strings.TrimSpace(r.FormValue("quota"))), } st := s.sch.Status() @@ -191,20 +197,20 @@ func (s *Server) zfsCreate(w http.ResponseWriter, r *http.Request) { return } } - for _, err := range []error{zfsadm.ValidateOwner(f.Owner), zfsadm.ValidateCompression(f.Compression), zfsadm.ValidateSize(f.Quota)} { + for _, err := range []error{zfsadm.ValidateOwner(f.Owner), zfsadm.ValidateCompression(f.Compression), zfsadm.ValidateDedup(f.Dedup), zfsadm.ValidateSize(f.Quota)} { if err != nil { fail(err.Error()) return } } - err := s.admin.Create(full, zfsadm.CreateOpts{Compression: f.Compression, Quota: f.Quota, Owner: f.Owner}) + err := s.admin.Create(full, zfsadm.CreateOpts{Compression: f.Compression, Dedup: f.Dedup, Quota: f.Quota, Owner: f.Owner}) s.sch.RefreshStorage() if err != nil { log.Printf("zfs create %s: %v", full, err) fail(err.Error()) return } - log.Printf("dataset %s created (owner %s, compression %q, quota %q)", full, f.Owner, f.Compression, f.Quota) + log.Printf("dataset %s created (owner %s, compression %q, dedup %q, quota %q)", full, f.Owner, f.Compression, f.Dedup, f.Quota) http.Redirect(w, r, "/zfs?msg="+url.QueryEscape("Dataset "+full+" created")+"#"+url.PathEscape(f.Pool), http.StatusSeeOther) } @@ -243,6 +249,37 @@ func (s *Server) zfsDestroy(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/zfs?msg="+url.QueryEscape("Dataset "+name+" destroyed"), http.StatusSeeOther) } +// zfsDedup turns dedup on/off on a pool's root dataset (inherited by children). +func (s *Server) zfsDedup(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + pool := strings.TrimSpace(r.FormValue("pool")) + value := r.FormValue("dedup") + st := s.sch.Status() + fail := func(msg string) { + s.render(w, r, "zfs", pageData{Title: "ZFS", Data: s.zfsView(st, createForm{}), Alerts: len(st.Alerts), Error: "Dedup not changed: " + msg}) + } + if !hasPool(st, pool) { + fail("unknown pool " + pool) + return + } + if value != "on" && value != "off" { + fail("dedup must be on or off") + return + } + err := s.admin.SetDedup(pool, value) + s.sch.RefreshStorage() + if err != nil { + log.Printf("zfs set dedup=%s %s: %v", value, pool, err) + fail(err.Error()) + return + } + log.Printf("dedup=%s set on pool %s", value, pool) + http.Redirect(w, r, "/zfs?msg="+url.QueryEscape("Dedup "+value+" for "+pool)+"#"+url.PathEscape(pool), http.StatusSeeOther) +} + func hasPool(st sched.Status, name string) bool { for _, p := range st.Pools { if p.Name == name { diff --git a/internal/web/server.go b/internal/web/server.go index 347a186..2671e69 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -73,6 +73,7 @@ func New(cfg *config.Store, st *store.Store, ring *history.Ring, sch *sched.Sche s.mux.HandleFunc("GET /zfs", s.zfs) s.mux.HandleFunc("POST /zfs/create", s.zfsCreate) s.mux.HandleFunc("POST /zfs/destroy", s.zfsDestroy) + s.mux.HandleFunc("POST /zfs/dedup", s.zfsDedup) s.mux.HandleFunc("GET /alerts", s.alerts) s.mux.HandleFunc("POST /alerts/{id}/{action}", s.alertAction) s.mux.HandleFunc("GET /shares", s.sharesPage) diff --git a/internal/web/templates/zfs.html b/internal/web/templates/zfs.html index 57ace10..f75c3eb 100644 --- a/internal/web/templates/zfs.html +++ b/internal/web/templates/zfs.html @@ -13,6 +13,7 @@
scan: {{.Scan}}
{{end}} @@ -31,6 +32,21 @@ {{end}} + {{$dedup := index $.Data.PoolDedup .Name}} +Sets dedup on the pool root dataset; child datasets inherit it unless they override it. Only newly written data is deduplicated, and turning it off keeps the existing dedup table. The dedup table needs a lot of RAM (up to ~5 GB per 1 TB of unique data) — enable it only for data with many duplicate blocks.
+