From eece638c6094c939fdd4350d0230124a3c2b3746 Mon Sep 17 00:00:00 2001 From: Georg Mangold <67909897+georgmangold@users.noreply.github.com> Date: Fri, 27 Jun 2025 15:58:00 +0200 Subject: [PATCH 1/3] revert: "Removed heal backend (#3188)" This reverts commit 343ff575e615f0cf393845e91676f1c9fa4d9aa1. --- api/admin_heal.go | 375 +++++++++++++++++++++++++++++++ api/admin_heal_test.go | 270 ++++++++++++++++++++++ api/ws_handle.go | 30 +++ web-app/tests/policies/heal.json | 16 ++ web-app/tests/scripts/common.sh | 1 + 5 files changed, 692 insertions(+) create mode 100644 api/admin_heal.go create mode 100644 api/admin_heal_test.go create mode 100644 web-app/tests/policies/heal.json diff --git a/api/admin_heal.go b/api/admin_heal.go new file mode 100644 index 0000000000..2c574aeacc --- /dev/null +++ b/api/admin_heal.go @@ -0,0 +1,375 @@ +// This file is part of MinIO Console Server +// Copyright (c) 2021 MinIO, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "github.com/minio/madmin-go/v3" + "github.com/minio/websocket" +) + +// An alias of string to represent the health color code of an object +type col string + +const ( + colGrey col = "Grey" + colRed col = "Red" + colYellow col = "Yellow" + colGreen col = "Green" +) + +var ( + hColOrder = []col{colRed, colYellow, colGreen} + hColTable = map[int][]int{ + 1: {0, -1, 1}, + 2: {0, 1, 2}, + 3: {1, 2, 3}, + 4: {1, 2, 4}, + 5: {1, 3, 5}, + 6: {2, 4, 6}, + 7: {2, 4, 7}, + 8: {2, 5, 8}, + } +) + +type healItemStatus struct { + Status string `json:"status"` + Error string `json:"errors,omitempty"` + Type string `json:"type"` + Name string `json:"name"` + Before struct { + Color string `json:"color"` + Offline int `json:"offline"` + Online int `json:"online"` + Missing int `json:"missing"` + Corrupted int `json:"corrupted"` + Drives []madmin.HealDriveInfo `json:"drives"` + } `json:"before"` + After struct { + Color string `json:"color"` + Offline int `json:"offline"` + Online int `json:"online"` + Missing int `json:"missing"` + Corrupted int `json:"corrupted"` + Drives []madmin.HealDriveInfo `json:"drives"` + } `json:"after"` + Size int64 `json:"size"` +} + +type healStatus struct { + // Total time since heal start in seconds + HealDuration float64 `json:"healDuration"` + + // Accumulated statistics of heal result records + BytesScanned int64 `json:"bytesScanned"` + + // Counter for objects, and another counter for all kinds of + // items + ObjectsScanned int64 `json:"objectsScanned"` + ItemsScanned int64 `json:"itemsScanned"` + + // Counters for healed objects and all kinds of healed items + ObjectsHealed int64 `json:"objectsHealed"` + ItemsHealed int64 `json:"itemsHealed"` + + ItemsHealthStatus []healItemStatus `json:"itemsHealthStatus"` + // Map of health color code to number of objects with that + // health color code. + HealthBeforeCols map[col]int64 `json:"healthBeforeCols"` + HealthAfterCols map[col]int64 `json:"healthAfterCols"` +} + +type healOptions struct { + BucketName string + Prefix string + ForceStart bool + ForceStop bool + madmin.HealOpts +} + +// startHeal starts healing of the servers based on heal options +func startHeal(ctx context.Context, conn WSConn, client MinioAdmin, hOpts *healOptions) error { + // Initialize heal + healStart, _, err := client.heal(ctx, hOpts.BucketName, hOpts.Prefix, hOpts.HealOpts, "", hOpts.ForceStart, hOpts.ForceStop) + if err != nil { + LogError("error initializing healing: %v", err) + return err + } + if hOpts.ForceStop { + return nil + } + clientToken := healStart.ClientToken + hs := healStatus{ + HealthBeforeCols: make(map[col]int64), + HealthAfterCols: make(map[col]int64), + } + for { + select { + case <-ctx.Done(): + return nil + default: + _, res, err := client.heal(ctx, hOpts.BucketName, hOpts.Prefix, hOpts.HealOpts, clientToken, hOpts.ForceStart, hOpts.ForceStop) + if err != nil { + LogError("error on heal: %v", err) + return err + } + + hs.writeStatus(&res, conn) + + if res.Summary == "finished" { + return nil + } + + if res.Summary == "stopped" { + return fmt.Errorf("heal had an errors - %s", res.FailureDetail) + } + + time.Sleep(time.Second) + } + } +} + +func (h *healStatus) writeStatus(s *madmin.HealTaskStatus, conn WSConn) error { + // Update state + h.updateDuration(s) + for _, item := range s.Items { + err := h.updateStats(item) + if err != nil { + LogError("error on updateStats: %v", err) + return err + } + } + + // Serialize message to be sent + infoBytes, err := json.Marshal(h) + if err != nil { + LogError("error on json.Marshal: %v", err) + return err + } + // Send Message through websocket connection + err = conn.writeMessage(websocket.TextMessage, infoBytes) + if err != nil { + LogError("error writeMessage: %v", err) + return err + } + return nil +} + +func (h *healStatus) updateDuration(s *madmin.HealTaskStatus) { + h.HealDuration = time.Now().UTC().Sub(s.StartTime).Round(time.Second).Seconds() +} + +func (h *healStatus) updateStats(i madmin.HealResultItem) error { + // update general status + if i.Type == madmin.HealItemObject { + // Objects whose size could not be found have -1 size + // returned. + if i.ObjectSize >= 0 { + h.BytesScanned += i.ObjectSize + } + h.ObjectsScanned++ + } + h.ItemsScanned++ + + beforeUp, afterUp := i.GetOnlineCounts() + if afterUp > beforeUp { + if i.Type == madmin.HealItemObject { + h.ObjectsHealed++ + } + h.ItemsHealed++ + } + // update per item status + itemStatus := healItemStatus{} + // get color health status + var beforeColor, afterColor col + var err error + switch i.Type { + case madmin.HealItemMetadata, madmin.HealItemBucket: + beforeColor, afterColor, err = getReplicatedFileHCCChange(i) + default: + if i.Type == madmin.HealItemObject { + itemStatus.Size = i.ObjectSize + } + beforeColor, afterColor, err = getObjectHCCChange(i) + } + if err != nil { + return err + } + itemStatus.Status = "success" + itemStatus.Before.Color = strings.ToLower(string(beforeColor)) + itemStatus.After.Color = strings.ToLower(string(afterColor)) + itemStatus.Type, itemStatus.Name = getHRITypeAndName(i) + itemStatus.Before.Online, itemStatus.After.Online = beforeUp, afterUp + itemStatus.Before.Missing, itemStatus.After.Missing = i.GetMissingCounts() + itemStatus.Before.Corrupted, itemStatus.After.Corrupted = i.GetCorruptedCounts() + itemStatus.Before.Offline, itemStatus.After.Offline = i.GetOfflineCounts() + itemStatus.Before.Drives = i.Before.Drives + itemStatus.After.Drives = i.After.Drives + h.ItemsHealthStatus = append(h.ItemsHealthStatus, itemStatus) + h.HealthBeforeCols[beforeColor]++ + h.HealthAfterCols[afterColor]++ + return nil +} + +// getObjectHCCChange - returns before and after color change for +// objects +func getObjectHCCChange(h madmin.HealResultItem) (b, a col, err error) { + parityShards := h.ParityBlocks + dataShards := h.DataBlocks + + onlineBefore, onlineAfter := h.GetOnlineCounts() + surplusShardsBeforeHeal := onlineBefore - dataShards + surplusShardsAfterHeal := onlineAfter - dataShards + b, err = getHColCode(surplusShardsBeforeHeal, parityShards) + if err != nil { + return + } + a, err = getHColCode(surplusShardsAfterHeal, parityShards) + return +} + +// getReplicatedFileHCCChange - fetches health color code for metadata +// files that are replicated. +func getReplicatedFileHCCChange(h madmin.HealResultItem) (b, a col, err error) { + getColCode := func(numAvail int) (c col, err error) { + // calculate color code for replicated object similar + // to erasure coded objects + quorum := h.DiskCount/h.SetCount/2 + 1 + surplus := numAvail/h.SetCount - quorum + parity := h.DiskCount/h.SetCount - quorum + c, err = getHColCode(surplus, parity) + return + } + + onlineBefore, onlineAfter := h.GetOnlineCounts() + b, err = getColCode(onlineBefore) + if err != nil { + return + } + a, err = getColCode(onlineAfter) + return +} + +func getHColCode(surplusShards, parityShards int) (c col, err error) { + if parityShards < 1 || parityShards > 8 || surplusShards > parityShards { + return c, fmt.Errorf("invalid parity shard count/surplus shard count given") + } + if surplusShards < 0 { + return colGrey, err + } + colRow := hColTable[parityShards] + for index, val := range colRow { + if val != -1 && surplusShards <= val { + return hColOrder[index], err + } + } + return c, fmt.Errorf("cannot get a heal color code") +} + +func getHRITypeAndName(i madmin.HealResultItem) (typ, name string) { + name = fmt.Sprintf("%s/%s", i.Bucket, i.Object) + switch i.Type { + case madmin.HealItemMetadata: + typ = "system" + name = i.Detail + case madmin.HealItemBucketMetadata: + typ = "system" + name = "bucket-metadata:" + name + case madmin.HealItemBucket: + typ = "bucket" + case madmin.HealItemObject: + typ = "object" + default: + typ = fmt.Sprintf("!! Unknown heal result record %#v !!", i) + name = typ + } + return +} + +// getHealOptionsFromReq return options from request for healing process +// path come as : `/heal///bucket1` +// and query params come on request form +func getHealOptionsFromReq(req *http.Request) (*healOptions, error) { + hOptions := healOptions{} + re := regexp.MustCompile(`(/heal/)(.*?)(\?.*?$|$)`) + matches := re.FindAllSubmatch([]byte(req.URL.Path), -1) + // matches comes as e.g. + // [["...", "/heal/", "bucket1"]] + // [["/heal/" "/heal/" ""]] + + if len(matches) == 0 || len(matches[0]) < 3 { + return nil, fmt.Errorf("invalid url: %s", req.URL.Path) + } + hOptions.BucketName = strings.TrimSpace(string(matches[0][2])) + hOptions.Prefix = req.FormValue("prefix") + hOptions.HealOpts.ScanMode = transformScanStr(req.FormValue("scan")) + + if req.FormValue("force-start") != "" { + boolVal, err := strconv.ParseBool(req.FormValue("force-start")) + if err != nil { + return nil, err + } + hOptions.ForceStart = boolVal + } + if req.FormValue("force-stop") != "" { + boolVal, err := strconv.ParseBool(req.FormValue("force-stop")) + if err != nil { + return nil, err + } + hOptions.ForceStop = boolVal + } + // heal recursively + if req.FormValue("recursive") != "" { + boolVal, err := strconv.ParseBool(req.FormValue("recursive")) + if err != nil { + return nil, err + } + hOptions.HealOpts.Recursive = boolVal + } + // remove dangling objects in heal sequence + if req.FormValue("remove") != "" { + boolVal, err := strconv.ParseBool(req.FormValue("remove")) + if err != nil { + return nil, err + } + hOptions.HealOpts.Remove = boolVal + } + // only inspect data + if req.FormValue("dry-run") != "" { + boolVal, err := strconv.ParseBool(req.FormValue("dry-run")) + if err != nil { + return nil, err + } + hOptions.HealOpts.DryRun = boolVal + } + return &hOptions, nil +} + +func transformScanStr(scanStr string) madmin.HealScanMode { + if scanStr == "deep" { + return madmin.HealDeepScan + } + return madmin.HealNormalScan +} diff --git a/api/admin_heal_test.go b/api/admin_heal_test.go new file mode 100644 index 0000000000..0ca5476b36 --- /dev/null +++ b/api/admin_heal_test.go @@ -0,0 +1,270 @@ +// This file is part of MinIO Console Server +// Copyright (c) 2021 MinIO, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/url" + "testing" + "time" + + "github.com/minio/madmin-go/v3" + "github.com/stretchr/testify/assert" +) + +func TestHeal(t *testing.T) { + assert := assert.New(t) + + client := AdminClientMock{} + mockWSConn := mockConn{} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + function := "startHeal()" + mockResultItem1 := madmin.HealResultItem{ + Type: madmin.HealItemObject, + SetCount: 1, + DiskCount: 4, + ParityBlocks: 2, + DataBlocks: 2, + Before: struct { + Drives []madmin.HealDriveInfo `json:"drives"` + }{ + Drives: []madmin.HealDriveInfo{ + { + State: madmin.DriveStateOk, + }, + { + State: madmin.DriveStateOk, + }, + { + State: madmin.DriveStateOk, + }, + { + State: madmin.DriveStateMissing, + }, + }, + }, + After: struct { + Drives []madmin.HealDriveInfo `json:"drives"` + }{ + Drives: []madmin.HealDriveInfo{ + { + State: madmin.DriveStateOk, + }, + { + State: madmin.DriveStateOk, + }, + { + State: madmin.DriveStateOk, + }, + { + State: madmin.DriveStateOk, + }, + }, + }, + } + mockResultItem2 := madmin.HealResultItem{ + Type: madmin.HealItemBucket, + SetCount: 1, + DiskCount: 4, + ParityBlocks: 2, + DataBlocks: 2, + Before: struct { + Drives []madmin.HealDriveInfo `json:"drives"` + }{ + Drives: []madmin.HealDriveInfo{ + { + State: madmin.DriveStateOk, + }, + { + State: madmin.DriveStateOk, + }, + { + State: madmin.DriveStateOk, + }, + { + State: madmin.DriveStateMissing, + }, + }, + }, + After: struct { + Drives []madmin.HealDriveInfo `json:"drives"` + }{ + Drives: []madmin.HealDriveInfo{ + { + State: madmin.DriveStateOk, + }, + { + State: madmin.DriveStateOk, + }, + { + State: madmin.DriveStateOk, + }, + { + State: madmin.DriveStateOk, + }, + }, + }, + } + mockHealTaskStatus := madmin.HealTaskStatus{ + StartTime: time.Now().UTC().Truncate(time.Second * 2), // mock 2 sec duration + Items: []madmin.HealResultItem{ + mockResultItem1, + mockResultItem2, + }, + Summary: "finished", + } + + testStreamSize := 1 + testReceiver := make(chan healStatus, testStreamSize) + isClosed := false // testReceiver is closed? + + testOptions := &healOptions{ + BucketName: "testbucket", + Prefix: "", + ForceStart: false, + ForceStop: false, + } + // Test-1: startHeal send simple stream of data, no errors + minioHealMock = func(ctx context.Context, bucket, prefix string, healOpts madmin.HealOpts, clientToken string, + forceStart, forceStop bool, + ) (healStart madmin.HealStartSuccess, healTaskStatus madmin.HealTaskStatus, err error) { + return healStart, mockHealTaskStatus, nil + } + writesCount := 1 + // mock connection WriteMessage() no error + connWriteMessageMock = func(messageType int, data []byte) error { + // emulate that receiver gets the message written + var t healStatus + _ = json.Unmarshal(data, &t) + testReceiver <- t + if writesCount == testStreamSize { + // for testing we need to close the receiver channel + if !isClosed { + close(testReceiver) + isClosed = true + } + return nil + } + writesCount++ + return nil + } + if err := startHeal(ctx, mockWSConn, client, testOptions); err != nil { + t.Errorf("Failed on %s:, error occurred: %s", function, err.Error()) + } + // check that the TestReceiver got the same number of data from Console. + for i := range testReceiver { + assert.Equal(int64(1), i.ObjectsScanned) + assert.Equal(int64(1), i.ObjectsHealed) + assert.Equal(int64(2), i.ItemsScanned) + assert.Equal(int64(2), i.ItemsHealed) + assert.Equal(int64(0), i.HealthBeforeCols[colGreen]) + assert.Equal(int64(1), i.HealthBeforeCols[colYellow]) + assert.Equal(int64(1), i.HealthBeforeCols[colRed]) + assert.Equal(int64(0), i.HealthBeforeCols[colGrey]) + assert.Equal(int64(2), i.HealthAfterCols[colGreen]) + assert.Equal(int64(0), i.HealthAfterCols[colYellow]) + assert.Equal(int64(0), i.HealthAfterCols[colRed]) + assert.Equal(int64(0), i.HealthAfterCols[colGrey]) + } + + // Test-2: startHeal error on init + minioHealMock = func(ctx context.Context, bucket, prefix string, healOpts madmin.HealOpts, clientToken string, + forceStart, forceStop bool, + ) (healStart madmin.HealStartSuccess, healTaskStatus madmin.HealTaskStatus, err error) { + return healStart, mockHealTaskStatus, errors.New("error") + } + + if err := startHeal(ctx, mockWSConn, client, testOptions); assert.Error(err) { + assert.Equal("error", err.Error()) + } + + // Test-3: getHealOptionsFromReq return heal options from request + u, _ := url.Parse("http://localhost/api/v1/heal/bucket1?prefix=file/&recursive=true&force-start=true&force-stop=true&remove=true&dry-run=true&scan=deep") + req := &http.Request{ + URL: u, + } + opts, err := getHealOptionsFromReq(req) + if assert.NoError(err) { + expectedOptions := healOptions{ + BucketName: "bucket1", + ForceStart: true, + ForceStop: true, + Prefix: "file/", + HealOpts: madmin.HealOpts{ + Recursive: true, + DryRun: true, + ScanMode: madmin.HealDeepScan, + }, + } + assert.Equal(expectedOptions.BucketName, opts.BucketName) + assert.Equal(expectedOptions.Prefix, opts.Prefix) + assert.Equal(expectedOptions.Recursive, opts.Recursive) + assert.Equal(expectedOptions.ForceStart, opts.ForceStart) + assert.Equal(expectedOptions.DryRun, opts.DryRun) + assert.Equal(expectedOptions.ScanMode, opts.ScanMode) + } + + // Test-4: getHealOptionsFromReq return error if boolean value not valid + u, _ = url.Parse("http://localhost/api/v1/heal/bucket1?prefix=file/&recursive=nonbool&force-start=true&force-stop=true&remove=true&dry-run=true&scan=deep") + req = &http.Request{ + URL: u, + } + _, err = getHealOptionsFromReq(req) + if assert.Error(err) { + assert.Equal("strconv.ParseBool: parsing \"nonbool\": invalid syntax", err.Error()) + } + // Test-5: getHealOptionsFromReq return error if boolean value not valid + u, _ = url.Parse("http://localhost/api/v1/heal/bucket1?prefix=file/&recursive=true&force-start=true&force-stop=true&remove=nonbool&dry-run=true&scan=deep") + req = &http.Request{ + URL: u, + } + _, err = getHealOptionsFromReq(req) + if assert.Error(err) { + assert.Equal("strconv.ParseBool: parsing \"nonbool\": invalid syntax", err.Error()) + } + // Test-6: getHealOptionsFromReq return error if boolean value not valid + u, _ = url.Parse("http://localhost/api/v1/heal/bucket1?prefix=file/&recursive=true&force-start=nonbool&force-stop=true&remove=true&dry-run=true&scan=deep") + req = &http.Request{ + URL: u, + } + _, err = getHealOptionsFromReq(req) + if assert.Error(err) { + assert.Equal("strconv.ParseBool: parsing \"nonbool\": invalid syntax", err.Error()) + } + // Test-7: getHealOptionsFromReq return error if boolean value not valid + u, _ = url.Parse("http://localhost/api/v1/heal/bucket1?prefix=file/&recursive=true&force-start=true&force-stop=nonbool&remove=true&dry-run=true&scan=deep") + req = &http.Request{ + URL: u, + } + _, err = getHealOptionsFromReq(req) + if assert.Error(err) { + assert.Equal("strconv.ParseBool: parsing \"nonbool\": invalid syntax", err.Error()) + } + // Test-8: getHealOptionsFromReq return error if boolean value not valid + u, _ = url.Parse("http://localhost/api/v1/heal/bucket1?prefix=file/&recursive=true&force-start=true&force-stop=true&remove=true&dry-run=nonbool&scan=deep") + req = &http.Request{ + URL: u, + } + _, err = getHealOptionsFromReq(req) + if assert.Error(err) { + assert.Equal("strconv.ParseBool: parsing \"nonbool\": invalid syntax", err.Error()) + } +} diff --git a/api/ws_handle.go b/api/ws_handle.go index d66c2b113a..65feec3004 100644 --- a/api/ws_handle.go +++ b/api/ws_handle.go @@ -63,6 +63,7 @@ type wsAdminClient struct { // ConsoleWebsocket interface of a Websocket Client type ConsoleWebsocket interface { watch(options watchOptions) + heal(opts healOptions) } type wsS3Client struct { @@ -254,6 +255,20 @@ func serveWS(w http.ResponseWriter, req *http.Request) { return } go wsAdminClient.healthInfo(ctx, deadline) + case strings.HasPrefix(wsPath, `/heal`): + hOptions, err := getHealOptionsFromReq(req) + if err != nil { + ErrorWithContext(ctx, fmt.Errorf("error getting heal options: %v", err)) + closeWsConn(conn) + return + } + wsAdminClient, err := newWebSocketAdminClient(conn, session) + if err != nil { + ErrorWithContext(ctx, err) + closeWsConn(conn) + return + } + go wsAdminClient.heal(ctx, hOptions) case strings.HasPrefix(wsPath, `/watch`): wOptions, err := getWatchOptionsFromReq(req) if err != nil { @@ -487,6 +502,21 @@ func (wsc *wsS3Client) watch(ctx context.Context, params *watchOptions) { sendWsCloseMessage(wsc.conn, err) } +func (wsc *wsAdminClient) heal(ctx context.Context, opts *healOptions) { + defer func() { + LogInfo("heal stopped") + // close connection after return + wsc.conn.close() + }() + LogInfo("heal started") + + ctx = wsReadClientCtx(ctx, wsc.conn) + + err := startHeal(ctx, wsc.conn, wsc.client, opts) + + sendWsCloseMessage(wsc.conn, err) +} + func (wsc *wsAdminClient) healthInfo(ctx context.Context, deadline *time.Duration) { defer func() { LogInfo("health info stopped") diff --git a/web-app/tests/policies/heal.json b/web-app/tests/policies/heal.json new file mode 100644 index 0000000000..2848bc8f9f --- /dev/null +++ b/web-app/tests/policies/heal.json @@ -0,0 +1,16 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Action": ["admin:Heal"], + "Effect": "Allow", + "Sid": "" + }, + { + "Action": ["s3:ListBucket"], + "Effect": "Allow", + "Resource": ["arn:aws:s3:::*"], + "Sid": "" + } + ] +} diff --git a/web-app/tests/scripts/common.sh b/web-app/tests/scripts/common.sh index e251df8679..d897ccc1fb 100755 --- a/web-app/tests/scripts/common.sh +++ b/web-app/tests/scripts/common.sh @@ -30,6 +30,7 @@ create_policies() { mc admin policy create minio dashboard-$TIMESTAMP web-app/tests/policies/dashboard.json mc admin policy create minio diagnostics-$TIMESTAMP web-app/tests/policies/diagnostics.json mc admin policy create minio groups-$TIMESTAMP web-app/tests/policies/groups.json + mc admin policy create minio heal-$TIMESTAMP web-app/tests/policies/heal.json mc admin policy create minio iampolicies-$TIMESTAMP web-app/tests/policies/iamPolicies.json mc admin policy create minio logs-$TIMESTAMP web-app/tests/policies/logs.json mc admin policy create minio notificationendpoints-$TIMESTAMP web-app/tests/policies/notificationEndpoints.json From e8c4ba1d6e9c956c6f349912d6bf8b263c694010 Mon Sep 17 00:00:00 2001 From: Georg Mangold <67909897+georgmangold@users.noreply.github.com> Date: Fri, 27 Jun 2025 16:04:11 +0200 Subject: [PATCH 2/3] revert: "Deprecated Heal (Drives) functionality (#3186)" This reverts commit 08c922dca650402e2df7d030a246c94ef24e2348. --- .../src/common/SecureComponent/permissions.ts | 2 + web-app/src/screens/Console/Console.tsx | 5 + web-app/src/screens/Console/Heal/Heal.tsx | 381 ++++++++++++++++++ web-app/src/screens/Console/Heal/types.ts | 65 +++ web-app/src/screens/Console/valid-routes.tsx | 7 + web-app/tests/permissions-6/heal.ts | 78 ++++ .../deleteWithPrefixes.ts | 0 .../errorsVisibleOB.ts | 0 .../filePreview.ts | 0 .../resourceTesting.ts | 0 .../sameBucketPath.ts | 0 .../{permissions-7 => permissions-8}/users.ts | 0 .../rewind.ts | 0 13 files changed, 538 insertions(+) create mode 100644 web-app/src/screens/Console/Heal/Heal.tsx create mode 100644 web-app/src/screens/Console/Heal/types.ts create mode 100644 web-app/tests/permissions-6/heal.ts rename web-app/tests/{permissions-6 => permissions-7}/deleteWithPrefixes.ts (100%) rename web-app/tests/{permissions-6 => permissions-7}/errorsVisibleOB.ts (100%) rename web-app/tests/{permissions-6 => permissions-7}/filePreview.ts (100%) rename web-app/tests/{permissions-6 => permissions-7}/resourceTesting.ts (100%) rename web-app/tests/{permissions-6 => permissions-7}/sameBucketPath.ts (100%) rename web-app/tests/{permissions-7 => permissions-8}/users.ts (100%) rename web-app/tests/{permissions-8 => permissions-9}/rewind.ts (100%) diff --git a/web-app/src/common/SecureComponent/permissions.ts b/web-app/src/common/SecureComponent/permissions.ts index 087477a020..a2b619aaf5 100644 --- a/web-app/src/common/SecureComponent/permissions.ts +++ b/web-app/src/common/SecureComponent/permissions.ts @@ -175,6 +175,7 @@ export const IAM_PAGES = { TOOLS_AUDITLOGS: "/tools/audit-logs", TOOLS_TRACE: "/tools/trace", DASHBOARD: "/tools/metrics", + TOOLS_HEAL: "/tools/heal", TOOLS_WATCH: "/tools/watch", /* KMS */ @@ -405,6 +406,7 @@ export const IAM_PAGES_PERMISSIONS = { IAM_SCOPES.S3_LISTEN_BUCKET_NOTIFICATIONS, // display watch notifications ], [IAM_PAGES.TOOLS_TRACE]: [IAM_SCOPES.ADMIN_SERVER_TRACE], + [IAM_PAGES.TOOLS_HEAL]: [IAM_SCOPES.ADMIN_HEAL], [IAM_PAGES.TOOLS_DIAGNOSTICS]: [ IAM_SCOPES.ADMIN_HEALTH_INFO, IAM_SCOPES.ADMIN_SERVER_INFO, diff --git a/web-app/src/screens/Console/Console.tsx b/web-app/src/screens/Console/Console.tsx index c4f9f2648d..d01229e5d5 100644 --- a/web-app/src/screens/Console/Console.tsx +++ b/web-app/src/screens/Console/Console.tsx @@ -50,6 +50,7 @@ import LoadingComponent from "../../common/LoadingComponent"; import ComponentsScreen from "./Common/ComponentsScreen"; const Trace = React.lazy(() => import("./Trace/Trace")); +const Heal = React.lazy(() => import("./Heal/Heal")); const Watch = React.lazy(() => import("./Watch/Watch")); const HealthInfo = React.lazy(() => import("./HealthInfo/HealthInfo")); @@ -336,6 +337,10 @@ const Console = () => { component: IDPOpenIDConfigurationDetails, path: IAM_PAGES.IDP_OPENID_CONFIGURATIONS_VIEW, }, + { + component: Heal, + path: IAM_PAGES.TOOLS_HEAL, + }, { component: Trace, path: IAM_PAGES.TOOLS_TRACE, diff --git a/web-app/src/screens/Console/Heal/Heal.tsx b/web-app/src/screens/Console/Heal/Heal.tsx new file mode 100644 index 0000000000..48eab714c7 --- /dev/null +++ b/web-app/src/screens/Console/Heal/Heal.tsx @@ -0,0 +1,381 @@ +// This file is part of MinIO Console Server +// Copyright (c) 2021 MinIO, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import React, { Fragment, useEffect, useState } from "react"; +import { useSelector } from "react-redux"; +import { IMessageEvent, w3cwebsocket as W3CWebSocket } from "websocket"; +import { + Box, + Button, + Checkbox, + Grid, + HealIcon, + InputBox, + InputLabel, + PageLayout, + Select, +} from "mds"; +import { + Bar, + BarChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import { api } from "api"; +import { Bucket } from "api/consoleApi"; +import { errorToHandler } from "api/errors"; +import { wsProtocol } from "../../../utils/wsUtils"; +import { colorH, HealStatus } from "./types"; +import { niceBytes } from "../../../common/utils"; +import { modalStyleUtils } from "../Common/FormComponents/common/styleLibrary"; +import { + CONSOLE_UI_RESOURCE, + IAM_SCOPES, +} from "../../../common/SecureComponent/permissions"; +import { selDistSet, setHelpName } from "../../../systemSlice"; +import { SecureComponent } from "../../../common/SecureComponent"; +import { useAppDispatch } from "../../../store"; +import DistributedOnly from "../Common/DistributedOnly/DistributedOnly"; +import PageHeaderWrapper from "../Common/PageHeaderWrapper/PageHeaderWrapper"; +import HelpMenu from "../HelpMenu"; + +const Heal = () => { + const distributedSetup = useSelector(selDistSet); + + const [start, setStart] = useState(false); + const [bucketName, setBucketName] = useState(""); + const [bucketList, setBucketList] = useState([]); + const [prefix, setPrefix] = useState(""); + const [recursive, setRecursive] = useState(false); + const [forceStart, setForceStart] = useState(false); + const [forceStop, setForceStop] = useState(false); + // healStatus states + const [hStatus, setHStatus] = useState({ + beforeHeal: [0, 0, 0, 0], + afterHeal: [0, 0, 0, 0], + objectsHealed: 0, + objectsScanned: 0, + healDuration: 0, + sizeScanned: "", + }); + + const fetchBucketList = () => { + api.buckets + .listBuckets() + .then((res) => { + let buckets: Bucket[] = []; + if (res.data.buckets) { + buckets = res.data.buckets; + } + setBucketList(buckets); + }) + .catch((err) => { + console.error(errorToHandler(err.error)); + }); + }; + + useEffect(() => { + fetchBucketList(); + }, []); + + // forceStart and forceStop need to be mutually exclusive + useEffect(() => { + if (forceStart) { + setForceStop(false); + } + }, [forceStart]); + + useEffect(() => { + if (forceStop) { + setForceStart(false); + } + }, [forceStop]); + + const colorHealthArr = (color: colorH) => { + return [color.Green, color.Yellow, color.Red, color.Grey]; + }; + + useEffect(() => { + // begin watch if bucketName in bucketList and start pressed + if (start) { + // values stored here to update chart + const cB: colorH = { Green: 0, Yellow: 0, Red: 0, Grey: 0 }; + const cA: colorH = { Green: 0, Yellow: 0, Red: 0, Grey: 0 }; + + const url = new URL(window.location.toString()); + const isDev = process.env.NODE_ENV === "development"; + const port = isDev ? "9090" : url.port; + + // check if we are using base path, if not this always is `/` + const baseLocation = new URL(document.baseURI); + const baseUrl = baseLocation.pathname; + + const wsProt = wsProtocol(url.protocol); + const c = new W3CWebSocket( + `${wsProt}://${url.hostname}:${port}${baseUrl}ws/heal/${bucketName}?prefix=${prefix}&recursive=${recursive}&force-start=${forceStart}&force-stop=${forceStop}`, + ); + + if (c !== null) { + c.onopen = () => { + console.log("WebSocket Client Connected"); + c.send("ok"); + }; + c.onmessage = (message: IMessageEvent) => { + let m: HealStatus = JSON.parse(message.data.toString()); + // Store percentage per health color + for (const [key, value] of Object.entries(m.healthAfterCols)) { + cA[key] = (value * 100) / m.itemsScanned; + } + for (const [key, value] of Object.entries(m.healthBeforeCols)) { + cB[key] = (value * 100) / m.itemsScanned; + } + setHStatus({ + beforeHeal: colorHealthArr(cB), + afterHeal: colorHealthArr(cA), + objectsHealed: m.objectsHealed, + objectsScanned: m.objectsScanned, + healDuration: m.healDuration, + sizeScanned: niceBytes(m.bytesScanned.toString()), + }); + }; + c.onclose = () => { + setStart(false); + console.log("connection closed by server"); + }; + return () => { + // close websocket on useEffect cleanup + c.close(1000); + console.log("closing websockets"); + }; + } + } + }, [start, bucketName, forceStart, forceStop, prefix, recursive]); + + let data = [ + { + name: "Green", + ah: hStatus.afterHeal[0], + bh: hStatus.beforeHeal[0], + amt: 100, + }, + { + name: "Yellow", + ah: hStatus.afterHeal[1], + bh: hStatus.beforeHeal[1], + amt: 100, + }, + { + name: "Red", + ah: hStatus.afterHeal[2], + bh: hStatus.beforeHeal[2], + amt: 100, + }, + { + name: "Grey", + ah: hStatus.afterHeal[3], + bh: hStatus.beforeHeal[3], + amt: 100, + }, + ]; + const bucketNames = bucketList.map((bucketName) => ({ + label: bucketName.name, + value: bucketName.name, + })); + const dispatch = useAppDispatch(); + useEffect(() => { + dispatch(setHelpName("heal")); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + } /> + + + {!distributedSetup ? ( + } /> + ) : ( + + + + + Bucket +