mirror of
https://github.com/grafana/grafana.git
synced 2024-11-21 16:38:03 -06:00
Admin: Add support bundles (#60536)
* Add support bundles Co-authored-by: ievaVasiljeva <ieva.vasiljeva@grafana.com> Co-authored-by: Kalle Persson <kalle.persson@grafana.com> * tweak code owners * rename and lint frontend * lint * fix backend lint * register feature flag * add feature toggle. fix small backend issues Co-authored-by: ievaVasiljeva <ieva.vasiljeva@grafana.com> Co-authored-by: Kalle Persson <kalle.persson@grafana.com>
This commit is contained in:
parent
6e2b148745
commit
2c7410c87d
4
.github/CODEOWNERS
vendored
4
.github/CODEOWNERS
vendored
@ -229,6 +229,10 @@ lerna.json @grafana/frontend-ops
|
||||
/pkg/services/loginattempt @grafana/grafana-authnz-team
|
||||
/pkg/services/authn @grafana/grafana-authnz-team
|
||||
|
||||
# Support bundles
|
||||
/public/app/features/support-bundles @grafana/grafana-authnz-team
|
||||
/pkg/infra/supportbundles @grafana/grafana-authnz-team
|
||||
|
||||
# Grafana Partnerships Team
|
||||
/pkg/infra/httpclient/httpclientprovider/sigv4_middleware.go @grafana/grafana-partnerships-team
|
||||
/pkg/infra/httpclient/httpclientprovider/sigv4_middleware_test.go @grafana/grafana-partnerships-team
|
||||
|
@ -67,6 +67,7 @@ Alpha features might be changed or removed without prior notice.
|
||||
| `dashboardComments` | Enable dashboard-wide comments |
|
||||
| `annotationComments` | Enable annotation comments |
|
||||
| `storage` | Configurable storage for dashboards, datasources, and resources |
|
||||
| `supportBundles` | Support bundles for troubleshooting |
|
||||
| `exploreMixedDatasource` | Enable mixed datasource in Explore |
|
||||
| `tracing` | Adds trace ID to error notifications |
|
||||
| `correlations` | Correlations page |
|
||||
|
@ -43,6 +43,7 @@ export interface FeatureToggles {
|
||||
migrationLocking?: boolean;
|
||||
storage?: boolean;
|
||||
k8s?: boolean;
|
||||
supportBundles?: boolean;
|
||||
dashboardsFromStorage?: boolean;
|
||||
export?: boolean;
|
||||
azureMonitorResourcePickerForMetrics?: boolean;
|
||||
|
@ -121,6 +121,9 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
}
|
||||
r.Get("/styleguide", reqSignedIn, hs.Index)
|
||||
|
||||
r.Get("/admin/support-bundles", reqGrafanaAdmin, hs.Index)
|
||||
r.Get("/admin/support-bundles/create", reqGrafanaAdmin, hs.Index)
|
||||
|
||||
r.Get("/live", reqGrafanaAdmin, hs.Index)
|
||||
r.Get("/live/pipeline", reqGrafanaAdmin, hs.Index)
|
||||
r.Get("/live/cloud", reqGrafanaAdmin, hs.Index)
|
||||
|
45
pkg/infra/supportbundles/interface.go
Normal file
45
pkg/infra/supportbundles/interface.go
Normal file
@ -0,0 +1,45 @@
|
||||
package supportbundles
|
||||
|
||||
import "context"
|
||||
|
||||
type SupportItem struct {
|
||||
Filename string
|
||||
FileBytes []byte
|
||||
}
|
||||
|
||||
type State string
|
||||
|
||||
const (
|
||||
StatePending State = "pending"
|
||||
StateComplete State = "complete"
|
||||
StateError State = "error"
|
||||
StateTimeout State = "timeout"
|
||||
)
|
||||
|
||||
func (s State) String() string {
|
||||
return string(s)
|
||||
}
|
||||
|
||||
type Bundle struct {
|
||||
UID string `json:"uid"`
|
||||
State State `json:"state"`
|
||||
FilePath string `json:"filePath"`
|
||||
Creator string `json:"creator"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type CollectorFunc func(context.Context) (*SupportItem, error)
|
||||
|
||||
type Collector struct {
|
||||
UID string `json:"uid"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Description string `json:"description"`
|
||||
IncludedByDefault bool `json:"includedByDefault"`
|
||||
Default bool `json:"default"`
|
||||
Fn CollectorFunc `json:"-"`
|
||||
}
|
||||
|
||||
type Service interface {
|
||||
RegisterSupportItemCollector(collector Collector)
|
||||
}
|
115
pkg/infra/supportbundles/supportbundlesimpl/api.go
Normal file
115
pkg/infra/supportbundles/supportbundlesimpl/api.go
Normal file
@ -0,0 +1,115 @@
|
||||
package supportbundlesimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/response"
|
||||
"github.com/grafana/grafana/pkg/api/routing"
|
||||
"github.com/grafana/grafana/pkg/infra/supportbundles"
|
||||
"github.com/grafana/grafana/pkg/middleware"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
)
|
||||
|
||||
const rootUrl = "/api/support-bundles"
|
||||
|
||||
func (s *Service) registerAPIEndpoints(routeRegister routing.RouteRegister) {
|
||||
authorize := ac.Middleware(s.accessControl)
|
||||
|
||||
routeRegister.Group(rootUrl, func(subrouter routing.RouteRegister) {
|
||||
subrouter.Get("/", authorize(middleware.ReqGrafanaAdmin,
|
||||
ac.EvalPermission(ActionRead)), routing.Wrap(s.handleList))
|
||||
subrouter.Post("/", authorize(middleware.ReqGrafanaAdmin,
|
||||
ac.EvalPermission(ActionCreate)), routing.Wrap(s.handleCreate))
|
||||
subrouter.Get("/:uid", authorize(middleware.ReqGrafanaAdmin,
|
||||
ac.EvalPermission(ActionRead)), s.handleDownload)
|
||||
subrouter.Delete("/:uid", authorize(middleware.ReqGrafanaAdmin,
|
||||
ac.EvalPermission(ActionDelete)), s.handleRemove)
|
||||
subrouter.Get("/collectors", authorize(middleware.ReqGrafanaAdmin,
|
||||
ac.EvalPermission(ActionCreate)), routing.Wrap(s.handleGetCollectors))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) handleList(ctx *models.ReqContext) response.Response {
|
||||
bundles, err := s.List(ctx.Req.Context())
|
||||
if err != nil {
|
||||
return response.Error(http.StatusInternalServerError, "failed to list bundles", err)
|
||||
}
|
||||
|
||||
data, err := json.Marshal(bundles)
|
||||
if err != nil {
|
||||
return response.Error(http.StatusInternalServerError, "failed to encode bundle", err)
|
||||
}
|
||||
|
||||
return response.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
func (s *Service) handleCreate(ctx *models.ReqContext) response.Response {
|
||||
type command struct {
|
||||
Collectors []string `json:"collectors"`
|
||||
}
|
||||
|
||||
var c command
|
||||
if err := web.Bind(ctx.Req, &c); err != nil {
|
||||
return response.Error(http.StatusBadRequest, "failed to parse request", err)
|
||||
}
|
||||
|
||||
bundle, err := s.Create(context.Background(), c.Collectors, ctx.SignedInUser)
|
||||
if err != nil {
|
||||
return response.Error(http.StatusInternalServerError, "failed to create support bundle", err)
|
||||
}
|
||||
|
||||
data, err := json.Marshal(bundle)
|
||||
if err != nil {
|
||||
return response.Error(http.StatusInternalServerError, "failed to encode bundle", err)
|
||||
}
|
||||
|
||||
return response.JSON(http.StatusCreated, data)
|
||||
}
|
||||
|
||||
func (s *Service) handleDownload(ctx *models.ReqContext) {
|
||||
uid := web.Params(ctx.Req)[":uid"]
|
||||
bundle, err := s.Get(ctx.Req.Context(), uid)
|
||||
if err != nil {
|
||||
ctx.Redirect("/admin/support-bundles")
|
||||
return
|
||||
}
|
||||
|
||||
if bundle.State != supportbundles.StateComplete {
|
||||
ctx.Redirect("/admin/support-bundles")
|
||||
return
|
||||
}
|
||||
|
||||
if bundle.FilePath == "" {
|
||||
ctx.Redirect("/admin/support-bundles")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := os.Stat(bundle.FilePath); err != nil {
|
||||
ctx.Redirect("/admin/support-bundles")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Resp.Header().Set("Content-Type", "application/tar+gzip")
|
||||
ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%d.tar.gz", bundle.CreatedAt))
|
||||
http.ServeFile(ctx.Resp, ctx.Req, bundle.FilePath)
|
||||
}
|
||||
|
||||
func (s *Service) handleRemove(ctx *models.ReqContext) response.Response {
|
||||
uid := web.Params(ctx.Req)[":uid"]
|
||||
err := s.Remove(ctx.Req.Context(), uid)
|
||||
if err != nil {
|
||||
return response.Error(http.StatusInternalServerError, "failed to remove bundle", err)
|
||||
}
|
||||
|
||||
return response.Respond(http.StatusOK, "successfully removed the support bundle")
|
||||
}
|
||||
|
||||
func (s *Service) handleGetCollectors(ctx *models.ReqContext) response.Response {
|
||||
return response.JSON(http.StatusOK, s.collectors)
|
||||
}
|
162
pkg/infra/supportbundles/supportbundlesimpl/collectors.go
Normal file
162
pkg/infra/supportbundles/supportbundlesimpl/collectors.go
Normal file
@ -0,0 +1,162 @@
|
||||
package supportbundlesimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/supportbundles"
|
||||
"github.com/grafana/grafana/pkg/infra/usagestats"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsettings"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func basicCollector(cfg *setting.Cfg) supportbundles.Collector {
|
||||
return supportbundles.Collector{
|
||||
UID: "basic",
|
||||
DisplayName: "Basic information",
|
||||
Description: "Basic information about the Grafana instance",
|
||||
IncludedByDefault: true,
|
||||
Default: true,
|
||||
Fn: func(ctx context.Context) (*supportbundles.SupportItem, error) {
|
||||
type basicInfo struct {
|
||||
Version string `json:"version"`
|
||||
Commit string `json:"commit"`
|
||||
}
|
||||
|
||||
data, err := json.Marshal(basicInfo{
|
||||
Version: cfg.BuildVersion,
|
||||
Commit: cfg.BuildCommit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &supportbundles.SupportItem{
|
||||
Filename: "basic.json",
|
||||
FileBytes: data,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func settingsCollector(settings setting.Provider) supportbundles.Collector {
|
||||
return supportbundles.Collector{
|
||||
UID: "settings",
|
||||
DisplayName: "Settings",
|
||||
Description: "Settings for grafana instance",
|
||||
IncludedByDefault: false,
|
||||
Default: true,
|
||||
Fn: func(ctx context.Context) (*supportbundles.SupportItem, error) {
|
||||
current := settings.Current()
|
||||
data, err := json.Marshal(current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &supportbundles.SupportItem{
|
||||
Filename: "settings.json",
|
||||
FileBytes: data,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func usageStatesCollector(stats usagestats.Service) supportbundles.Collector {
|
||||
return supportbundles.Collector{
|
||||
UID: "usage-stats",
|
||||
DisplayName: "Usage statistics",
|
||||
Description: "Usage statistic for grafana instance",
|
||||
IncludedByDefault: false,
|
||||
Default: true,
|
||||
Fn: func(ctx context.Context) (*supportbundles.SupportItem, error) {
|
||||
report, err := stats.GetUsageReport(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err := json.Marshal(report)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &supportbundles.SupportItem{
|
||||
Filename: "usage-stats.json",
|
||||
FileBytes: data,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func pluginInfoCollector(pluginStore plugins.Store, pluginSettings pluginsettings.Service) supportbundles.Collector {
|
||||
return supportbundles.Collector{
|
||||
UID: "plugins",
|
||||
DisplayName: "Plugin information",
|
||||
Description: "Plugin information for grafana instance",
|
||||
IncludedByDefault: false,
|
||||
Default: true,
|
||||
Fn: func(ctx context.Context) (*supportbundles.SupportItem, error) {
|
||||
type pluginInfo struct {
|
||||
data plugins.JSONData
|
||||
Class plugins.Class
|
||||
|
||||
// App fields
|
||||
IncludedInAppID string
|
||||
DefaultNavURL string
|
||||
Pinned bool
|
||||
|
||||
// Signature fields
|
||||
Signature plugins.SignatureStatus
|
||||
|
||||
// SystemJS fields
|
||||
Module string
|
||||
BaseURL string
|
||||
|
||||
PluginVersion string
|
||||
Enabled bool
|
||||
Updated time.Time
|
||||
}
|
||||
|
||||
plugins := pluginStore.Plugins(context.Background())
|
||||
|
||||
var pluginInfoList []pluginInfo
|
||||
for _, plugin := range plugins {
|
||||
// skip builtin plugins
|
||||
if plugin.BuiltIn {
|
||||
continue
|
||||
}
|
||||
|
||||
pInfo := pluginInfo{
|
||||
data: plugin.JSONData,
|
||||
Class: plugin.Class,
|
||||
IncludedInAppID: plugin.IncludedInAppID,
|
||||
DefaultNavURL: plugin.DefaultNavURL,
|
||||
Pinned: plugin.Pinned,
|
||||
Signature: plugin.Signature,
|
||||
Module: plugin.Module,
|
||||
BaseURL: plugin.BaseURL,
|
||||
}
|
||||
|
||||
// TODO need to loop through all the orgs
|
||||
// TODO ignore the error for now, not all plugins have settings
|
||||
settings, err := pluginSettings.GetPluginSettingByPluginID(context.Background(), &pluginsettings.GetByPluginIDArgs{PluginID: plugin.ID, OrgID: 1})
|
||||
if err == nil {
|
||||
pInfo.PluginVersion = settings.PluginVersion
|
||||
pInfo.Enabled = settings.Enabled
|
||||
pInfo.Updated = settings.Updated
|
||||
}
|
||||
|
||||
pluginInfoList = append(pluginInfoList, pInfo)
|
||||
}
|
||||
|
||||
data, err := json.Marshal(pluginInfoList)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &supportbundles.SupportItem{
|
||||
Filename: "plugins.json",
|
||||
FileBytes: data,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
}
|
75
pkg/infra/supportbundles/supportbundlesimpl/db_collector.go
Normal file
75
pkg/infra/supportbundles/supportbundlesimpl/db_collector.go
Normal file
@ -0,0 +1,75 @@
|
||||
package supportbundlesimpl
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/supportbundles"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
)
|
||||
|
||||
func dbCollector(sql db.DB) supportbundles.Collector {
|
||||
collectorFn := func(ctx context.Context) (*supportbundles.SupportItem, error) {
|
||||
dbType := string(sql.GetDBType())
|
||||
|
||||
// buffer writer
|
||||
bWriter := bytes.NewBuffer(nil)
|
||||
|
||||
bWriter.WriteString("# Database information\n\n")
|
||||
bWriter.WriteString("dbType: " + dbType + " \n")
|
||||
|
||||
logItems := make([]migrator.MigrationLog, 0)
|
||||
version := []string{}
|
||||
err := sql.WithDbSession(ctx, func(sess *db.Session) error {
|
||||
rawSQL := ""
|
||||
if dbType == migrator.MySQL {
|
||||
rawSQL = "SELECT @@VERSION"
|
||||
} else if dbType == migrator.Postgres {
|
||||
rawSQL = "SELECT version()"
|
||||
} else if dbType == migrator.SQLite {
|
||||
rawSQL = "SELECT sqlite_version()"
|
||||
} else {
|
||||
return fmt.Errorf("unsupported dbType: %s", dbType)
|
||||
}
|
||||
|
||||
return sess.Table("migration_log").SQL(rawSQL).Find(&version)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, v := range version {
|
||||
bWriter.WriteString("version: " + v + " \n")
|
||||
}
|
||||
|
||||
errD := sql.WithDbSession(ctx, func(sess *db.Session) error {
|
||||
return sess.Table("migration_log").Find(&logItems)
|
||||
})
|
||||
if errD != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bWriter.WriteString("\n## Migration Log\n\n")
|
||||
|
||||
for _, logItem := range logItems {
|
||||
bWriter.WriteString(fmt.Sprintf("**migrationId**: %s \nsuccess: %t \nerror: %s \ntimestamp: %s\n\n",
|
||||
logItem.MigrationID, logItem.Success, logItem.Error, logItem.Timestamp.UTC()))
|
||||
}
|
||||
|
||||
return &supportbundles.SupportItem{
|
||||
Filename: "db.md",
|
||||
FileBytes: bWriter.Bytes(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return supportbundles.Collector{
|
||||
UID: "db",
|
||||
Description: "Database information and migration log",
|
||||
DisplayName: "Database and Migration information",
|
||||
IncludedByDefault: false,
|
||||
Default: true,
|
||||
Fn: collectorFn,
|
||||
}
|
||||
}
|
49
pkg/infra/supportbundles/supportbundlesimpl/models.go
Normal file
49
pkg/infra/supportbundles/supportbundlesimpl/models.go
Normal file
@ -0,0 +1,49 @@
|
||||
package supportbundlesimpl
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
)
|
||||
|
||||
const (
|
||||
ActionRead = "support.bundles:read"
|
||||
ActionCreate = "support.bundles:create"
|
||||
ActionDelete = "support.bundles:delete"
|
||||
)
|
||||
|
||||
var (
|
||||
bundleReaderRole = accesscontrol.RoleDTO{
|
||||
Name: "fixed:support.bundles:reader",
|
||||
DisplayName: "Support bundle reader",
|
||||
Description: "List and download support bundles",
|
||||
Group: "Support bundles",
|
||||
Permissions: []accesscontrol.Permission{
|
||||
{Action: ActionRead},
|
||||
},
|
||||
}
|
||||
|
||||
bundleWriterRole = accesscontrol.RoleDTO{
|
||||
Name: "fixed:support.bundles:writer",
|
||||
DisplayName: "Support bundle writer",
|
||||
Description: "Create, delete, list and download support bundles",
|
||||
Group: "Support bundles",
|
||||
Permissions: []accesscontrol.Permission{
|
||||
{Action: ActionRead},
|
||||
{Action: ActionCreate},
|
||||
{Action: ActionDelete},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func DeclareFixedRoles(ac accesscontrol.Service) error {
|
||||
bundleReader := accesscontrol.RoleRegistration{
|
||||
Role: bundleReaderRole,
|
||||
Grants: []string{string(org.RoleAdmin)},
|
||||
}
|
||||
bundleWriter := accesscontrol.RoleRegistration{
|
||||
Role: bundleWriterRole,
|
||||
Grants: []string{string(org.RoleAdmin)},
|
||||
}
|
||||
|
||||
return ac.DeclareFixedRoles(bundleWriter, bundleReader)
|
||||
}
|
164
pkg/infra/supportbundles/supportbundlesimpl/service.go
Normal file
164
pkg/infra/supportbundles/supportbundlesimpl/service.go
Normal file
@ -0,0 +1,164 @@
|
||||
package supportbundlesimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/routing"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/kvstore"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/supportbundles"
|
||||
"github.com/grafana/grafana/pkg/infra/usagestats"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsettings"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
cfg *setting.Cfg
|
||||
store *store
|
||||
pluginStore plugins.Store
|
||||
pluginSettings pluginsettings.Service
|
||||
accessControl ac.AccessControl
|
||||
features *featuremgmt.FeatureManager
|
||||
|
||||
log log.Logger
|
||||
|
||||
collectors []supportbundles.Collector
|
||||
}
|
||||
|
||||
func ProvideService(cfg *setting.Cfg,
|
||||
sql db.DB,
|
||||
kvStore kvstore.KVStore,
|
||||
accessControl ac.AccessControl,
|
||||
accesscontrolService ac.Service,
|
||||
routeRegister routing.RouteRegister,
|
||||
userService user.Service,
|
||||
settings setting.Provider,
|
||||
pluginStore plugins.Store,
|
||||
pluginSettings pluginsettings.Service,
|
||||
features *featuremgmt.FeatureManager,
|
||||
usageStats usagestats.Service) (*Service, error) {
|
||||
s := &Service{
|
||||
cfg: cfg,
|
||||
store: newStore(kvStore),
|
||||
pluginStore: pluginStore,
|
||||
pluginSettings: pluginSettings,
|
||||
accessControl: accessControl,
|
||||
features: features,
|
||||
log: log.New("supportbundle.service"),
|
||||
}
|
||||
|
||||
if !features.IsEnabled(featuremgmt.FlagSupportBundles) {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
if !accessControl.IsDisabled() {
|
||||
if err := DeclareFixedRoles(accesscontrolService); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
s.registerAPIEndpoints(routeRegister)
|
||||
|
||||
// TODO: move to relevant services
|
||||
s.RegisterSupportItemCollector(basicCollector(cfg))
|
||||
s.RegisterSupportItemCollector(settingsCollector(settings))
|
||||
s.RegisterSupportItemCollector(usageStatesCollector(usageStats))
|
||||
s.RegisterSupportItemCollector(userCollector(userService))
|
||||
s.RegisterSupportItemCollector(dbCollector(sql))
|
||||
s.RegisterSupportItemCollector(pluginInfoCollector(pluginStore, pluginSettings))
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, collectors []string, usr *user.SignedInUser) (*supportbundles.Bundle, error) {
|
||||
bundle, err := s.store.Create(ctx, usr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go func(uid string, collectors []string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
|
||||
defer cancel()
|
||||
s.startBundleWork(ctx, collectors, uid)
|
||||
}(bundle.UID, collectors)
|
||||
|
||||
return bundle, nil
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, uid string) (*supportbundles.Bundle, error) {
|
||||
return s.store.Get(ctx, uid)
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context) ([]supportbundles.Bundle, error) {
|
||||
return s.store.List()
|
||||
}
|
||||
|
||||
func (s *Service) Remove(ctx context.Context, uid string) error {
|
||||
// Remove the data
|
||||
bundle, err := s.store.Get(ctx, uid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not retrieve support bundle with uid %s: %w", uid, err)
|
||||
}
|
||||
|
||||
// TODO handle cases when bundles aren't complete yet
|
||||
if bundle.State == supportbundles.StatePending {
|
||||
return fmt.Errorf("could not remove a support bundle with uid %s as it is still beign cteated", uid)
|
||||
}
|
||||
|
||||
if bundle.FilePath != "" {
|
||||
if err := os.RemoveAll(filepath.Dir(bundle.FilePath)); err != nil {
|
||||
return fmt.Errorf("could not remove directory for support bundle %s: %w", uid, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the KV store entry
|
||||
return s.store.Remove(ctx, uid)
|
||||
}
|
||||
|
||||
func (s *Service) RegisterSupportItemCollector(collector supportbundles.Collector) {
|
||||
// FIXME: add check for duplicate UIDs
|
||||
s.collectors = append(s.collectors, collector)
|
||||
}
|
||||
|
||||
func (s *Service) Run(ctx context.Context) error {
|
||||
if !s.features.IsEnabled(featuremgmt.FlagSupportBundles) {
|
||||
return nil
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
s.cleanup(ctx)
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.cleanup(ctx)
|
||||
case <-ctx.Done():
|
||||
break
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
func (s *Service) cleanup(ctx context.Context) {
|
||||
bundles, err := s.List(ctx)
|
||||
if err != nil {
|
||||
s.log.Error("failed to list bundles to clean up", "error", err)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
for _, b := range bundles {
|
||||
if time.Now().Unix() >= b.ExpiresAt {
|
||||
if err := s.Remove(ctx, b.UID); err != nil {
|
||||
s.log.Error("failed to cleanup bundle", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
153
pkg/infra/supportbundles/supportbundlesimpl/service_bundle.go
Normal file
153
pkg/infra/supportbundles/supportbundlesimpl/service_bundle.go
Normal file
@ -0,0 +1,153 @@
|
||||
package supportbundlesimpl
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/supportbundles"
|
||||
)
|
||||
|
||||
type bundleResult struct {
|
||||
path string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *Service) startBundleWork(ctx context.Context, collectors []string, uid string) {
|
||||
result := make(chan bundleResult)
|
||||
go func() {
|
||||
sbFilePath, err := s.bundle(ctx, collectors, uid)
|
||||
if err != nil {
|
||||
result <- bundleResult{err: err}
|
||||
}
|
||||
result <- bundleResult{
|
||||
path: sbFilePath,
|
||||
}
|
||||
close(result)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
s.log.Warn("Context cancelled while collecting support bundle")
|
||||
if err := s.store.Update(ctx, uid, supportbundles.StateTimeout, ""); err != nil {
|
||||
s.log.Error("failed to update bundle after timeout")
|
||||
}
|
||||
return
|
||||
case r := <-result:
|
||||
if r.err != nil {
|
||||
if err := s.store.Update(ctx, uid, supportbundles.StateError, ""); err != nil {
|
||||
s.log.Error("failed to update bundle after error")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := s.store.Update(ctx, uid, supportbundles.StateComplete, r.path); err != nil {
|
||||
s.log.Error("failed to update bundle after completion")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) bundle(ctx context.Context, collectors []string, uid string) (string, error) {
|
||||
lookup := make(map[string]bool, len(collectors))
|
||||
for _, c := range collectors {
|
||||
lookup[c] = true
|
||||
}
|
||||
|
||||
sbDir, err := os.MkdirTemp("", "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, collector := range s.collectors {
|
||||
if !lookup[collector.UID] && !collector.IncludedByDefault {
|
||||
continue
|
||||
}
|
||||
item, err := collector.Fn(ctx)
|
||||
if err != nil {
|
||||
s.log.Warn("Failed to collect support bundle item", "error", err)
|
||||
}
|
||||
|
||||
// write item to file
|
||||
if item != nil {
|
||||
if err := os.WriteFile(filepath.Join(sbDir, item.Filename), item.FileBytes, 0600); err != nil {
|
||||
s.log.Warn("Failed to collect support bundle item", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// create tar.gz file
|
||||
var buf bytes.Buffer
|
||||
errCompress := compress(sbDir, &buf)
|
||||
if errCompress != nil {
|
||||
return "", errCompress
|
||||
}
|
||||
|
||||
finalFilePath := filepath.Join(sbDir, fmt.Sprintf("%s.tar.gz", uid))
|
||||
|
||||
// Ignore gosec G304 as this function is only used internally.
|
||||
//nolint:gosec
|
||||
fileToWrite, err := os.OpenFile(finalFilePath, os.O_CREATE|os.O_RDWR, 0600)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := io.Copy(fileToWrite, &buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return finalFilePath, nil
|
||||
}
|
||||
|
||||
func compress(src string, buf io.Writer) error {
|
||||
// tar > gzip > buf
|
||||
zr := gzip.NewWriter(buf)
|
||||
tw := tar.NewWriter(zr)
|
||||
|
||||
// walk through every file in the folder
|
||||
err := filepath.Walk(src, func(file string, fi os.FileInfo, err error) error {
|
||||
// if not a dir, write file content
|
||||
if !fi.IsDir() {
|
||||
// generate tar header
|
||||
header, err := tar.FileInfoHeader(fi, file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
header.Name = filepath.ToSlash("/bundle/" + header.Name)
|
||||
|
||||
// write header
|
||||
if err := tw.WriteHeader(header); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ignore gosec G304 as this function is only used internally.
|
||||
//nolint:gosec
|
||||
data, err := os.Open(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(tw, data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// produce tar
|
||||
if err := tw.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
// produce gzip
|
||||
if err := zr.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
//
|
||||
return nil
|
||||
}
|
108
pkg/infra/supportbundles/supportbundlesimpl/store.go
Normal file
108
pkg/infra/supportbundles/supportbundlesimpl/store.go
Normal file
@ -0,0 +1,108 @@
|
||||
package supportbundlesimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/grafana/grafana/pkg/infra/kvstore"
|
||||
"github.com/grafana/grafana/pkg/infra/supportbundles"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
)
|
||||
|
||||
func newStore(kv kvstore.KVStore) *store {
|
||||
return &store{kv: kvstore.WithNamespace(kv, 0, "supportbundle")}
|
||||
}
|
||||
|
||||
type store struct {
|
||||
kv *kvstore.NamespacedKVStore
|
||||
}
|
||||
|
||||
func (s *store) Create(ctx context.Context, usr *user.SignedInUser) (*supportbundles.Bundle, error) {
|
||||
uid, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bundle := supportbundles.Bundle{
|
||||
UID: uid.String(),
|
||||
State: supportbundles.StatePending,
|
||||
Creator: usr.Login,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour).Unix(),
|
||||
}
|
||||
|
||||
if err := s.set(ctx, &bundle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &bundle, nil
|
||||
}
|
||||
|
||||
func (s *store) Update(ctx context.Context, uid string, state supportbundles.State, filePath string) error {
|
||||
bundle, err := s.Get(ctx, uid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bundle.State = state
|
||||
bundle.FilePath = filePath
|
||||
|
||||
return s.set(ctx, bundle)
|
||||
}
|
||||
|
||||
func (s *store) set(ctx context.Context, bundle *supportbundles.Bundle) error {
|
||||
data, err := json.Marshal(&bundle)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.kv.Set(ctx, bundle.UID, string(data))
|
||||
}
|
||||
|
||||
func (s *store) Get(ctx context.Context, uid string) (*supportbundles.Bundle, error) {
|
||||
data, ok, err := s.kv.Get(ctx, uid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
// FIXME: handle not found
|
||||
return nil, errors.New("not found")
|
||||
}
|
||||
var b supportbundles.Bundle
|
||||
if err := json.NewDecoder(strings.NewReader(data)).Decode(&b); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
func (s *store) Remove(ctx context.Context, uid string) error {
|
||||
return s.kv.Del(ctx, uid)
|
||||
}
|
||||
|
||||
func (s *store) List() ([]supportbundles.Bundle, error) {
|
||||
data, err := s.kv.GetAll(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var res []supportbundles.Bundle
|
||||
for _, items := range data {
|
||||
for _, s := range items {
|
||||
var b supportbundles.Bundle
|
||||
if err := json.NewDecoder(strings.NewReader(s)).Decode(&b); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res = append(res, b)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(res, func(i, j int) bool {
|
||||
return res[i].CreatedAt < res[j].CreatedAt
|
||||
})
|
||||
|
||||
return res, nil
|
||||
}
|
@ -0,0 +1,47 @@
|
||||
package supportbundlesimpl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/supportbundles"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
)
|
||||
|
||||
func userCollector(users user.Service) supportbundles.Collector {
|
||||
collectorFn := func(ctx context.Context) (*supportbundles.SupportItem, error) {
|
||||
query := &user.SearchUsersQuery{
|
||||
SignedInUser: &user.SignedInUser{},
|
||||
OrgID: 0,
|
||||
Query: "",
|
||||
Page: 0,
|
||||
Limit: 0,
|
||||
AuthModule: "",
|
||||
Filters: []user.Filter{},
|
||||
IsDisabled: new(bool),
|
||||
}
|
||||
res, err := users.Search(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userBytes, err := json.Marshal(res.Users)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &supportbundles.SupportItem{
|
||||
Filename: "users.json",
|
||||
FileBytes: userBytes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return supportbundles.Collector{
|
||||
UID: "users",
|
||||
Description: "User information",
|
||||
DisplayName: "A list of users of the Grafana instance",
|
||||
IncludedByDefault: false,
|
||||
Default: true,
|
||||
Fn: collectorFn,
|
||||
}
|
||||
}
|
@ -4,6 +4,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/api"
|
||||
"github.com/grafana/grafana/pkg/infra/metrics"
|
||||
"github.com/grafana/grafana/pkg/infra/remotecache"
|
||||
"github.com/grafana/grafana/pkg/infra/supportbundles/supportbundlesimpl"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
uss "github.com/grafana/grafana/pkg/infra/usagestats/service"
|
||||
"github.com/grafana/grafana/pkg/infra/usagestats/statscollector"
|
||||
@ -46,8 +47,8 @@ func ProvideBackgroundServiceRegistry(
|
||||
secretsService *secretsManager.SecretsService, remoteCache *remotecache.RemoteCache,
|
||||
thumbnailsService thumbs.Service, StorageService store.StorageService, searchService searchV2.SearchService, entityEventsService store.EntityEventsService,
|
||||
saService *samanager.ServiceAccountsService, authInfoService *authinfoservice.Implementation,
|
||||
grpcServerProvider grpcserver.Provider,
|
||||
secretMigrationProvider secretsMigrations.SecretMigrationProvider, loginAttemptService *loginattemptimpl.Service,
|
||||
grpcServerProvider grpcserver.Provider, secretMigrationProvider secretsMigrations.SecretMigrationProvider, loginAttemptService *loginattemptimpl.Service,
|
||||
bundleService *supportbundlesimpl.Service,
|
||||
// Need to make sure these are initialized, is there a better place to put them?
|
||||
_ dashboardsnapshots.Service, _ *alerting.AlertNotificationService,
|
||||
_ serviceaccounts.Service, _ *guardian.Provider,
|
||||
@ -83,6 +84,7 @@ func ProvideBackgroundServiceRegistry(
|
||||
processManager,
|
||||
secretMigrationProvider,
|
||||
loginAttemptService,
|
||||
bundleService,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -20,6 +20,8 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/metrics"
|
||||
"github.com/grafana/grafana/pkg/infra/remotecache"
|
||||
"github.com/grafana/grafana/pkg/infra/serverlock"
|
||||
"github.com/grafana/grafana/pkg/infra/supportbundles"
|
||||
"github.com/grafana/grafana/pkg/infra/supportbundles/supportbundlesimpl"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/infra/usagestats"
|
||||
uss "github.com/grafana/grafana/pkg/infra/usagestats/service"
|
||||
@ -354,6 +356,8 @@ var wireBasicSet = wire.NewSet(
|
||||
authnimpl.ProvideService,
|
||||
wire.Bind(new(authn.Service), new(*authnimpl.Service)),
|
||||
k8saccess.ProvideK8SAccess,
|
||||
supportbundlesimpl.ProvideService,
|
||||
wire.Bind(new(supportbundles.Service), new(*supportbundlesimpl.Service)),
|
||||
)
|
||||
|
||||
var wireSet = wire.NewSet(
|
||||
|
@ -153,6 +153,11 @@ var (
|
||||
State: FeatureStateAlpha,
|
||||
RequiresDevMode: true,
|
||||
},
|
||||
{
|
||||
Name: "supportBundles",
|
||||
Description: "Support bundles for troubleshooting",
|
||||
State: FeatureStateAlpha,
|
||||
},
|
||||
{
|
||||
Name: "dashboardsFromStorage",
|
||||
Description: "Load dashboards from the generic storage interface",
|
||||
|
@ -115,6 +115,10 @@ const (
|
||||
// Explore native k8s integrations
|
||||
FlagK8s = "k8s"
|
||||
|
||||
// FlagSupportBundles
|
||||
// Support bundles for troubleshooting
|
||||
FlagSupportBundles = "supportBundles"
|
||||
|
||||
// FlagDashboardsFromStorage
|
||||
// Load dashboards from the generic storage interface
|
||||
FlagDashboardsFromStorage = "dashboardsFromStorage"
|
||||
|
@ -243,6 +243,15 @@ func (s *ServiceImpl) addHelpLinks(treeRoot *navtree.NavTreeRoot, c *models.ReqC
|
||||
helpVersion = setting.ApplicationName
|
||||
}
|
||||
|
||||
supportBundleNode := &navtree.NavLink{
|
||||
Text: "Support bundles",
|
||||
Id: "support-bundles",
|
||||
Url: "/admin/support-bundles",
|
||||
Icon: "wrench",
|
||||
Section: navtree.NavSectionConfig,
|
||||
SortWeight: navtree.WeightHelp,
|
||||
}
|
||||
|
||||
treeRoot.AddSection(&navtree.NavLink{
|
||||
Text: "Help",
|
||||
SubTitle: helpVersion,
|
||||
@ -251,7 +260,7 @@ func (s *ServiceImpl) addHelpLinks(treeRoot *navtree.NavTreeRoot, c *models.ReqC
|
||||
Icon: "question-circle",
|
||||
SortWeight: navtree.WeightHelp,
|
||||
Section: navtree.NavSectionConfig,
|
||||
Children: []*navtree.NavLink{},
|
||||
Children: []*navtree.NavLink{supportBundleNode},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -95,6 +95,8 @@ export function getNavTitle(navId: string | undefined) {
|
||||
return t('nav.service-accounts.title', 'Service accounts');
|
||||
case 'admin':
|
||||
return t('nav.admin.title', 'Server admin');
|
||||
case 'support-bundles':
|
||||
return t('nav.support-bundles.title', 'Support Bundles');
|
||||
case 'global-users':
|
||||
return config.featureToggles.topnav
|
||||
? t('nav.global-users.title', 'Users')
|
||||
@ -191,6 +193,8 @@ export function getNavSubTitle(navId: string | undefined) {
|
||||
return t('nav.server-settings.subtitle', 'View the settings defined in your Grafana config');
|
||||
case 'storage':
|
||||
return t('nav.storage.subtitle', 'Manage file storage');
|
||||
case 'support-bundles':
|
||||
return t('nav.support-bundles.subtitle', 'Download support bundles');
|
||||
case 'admin':
|
||||
return config.featureToggles.topnav
|
||||
? t(
|
||||
|
@ -7,7 +7,7 @@ import { contextSrv } from 'app/core/services/context_srv';
|
||||
|
||||
import { ShowModalReactEvent } from '../../../types/events';
|
||||
import appEvents from '../../app_events';
|
||||
import { getFooterLinks } from '../Footer/Footer';
|
||||
import { FooterLink, getFooterLinks } from '../Footer/Footer';
|
||||
import { OrgSwitcher } from '../OrgSwitcher';
|
||||
import { HelpModal } from '../help/HelpModal';
|
||||
|
||||
@ -53,6 +53,7 @@ export const enrichConfigItems = (items: NavModelItem[], location: Location<unkn
|
||||
if (link.id === 'help') {
|
||||
link.children = [
|
||||
...getFooterLinks(),
|
||||
...getSupportBundleFooterLinks(),
|
||||
{
|
||||
id: 'keyboard-shortcuts',
|
||||
text: t('nav.help/keyboard-shortcuts', 'Keyboard shortcuts'),
|
||||
@ -77,6 +78,22 @@ export const enrichConfigItems = (items: NavModelItem[], location: Location<unkn
|
||||
return items;
|
||||
};
|
||||
|
||||
export let getSupportBundleFooterLinks = (cfg = config): FooterLink[] => {
|
||||
if (!cfg.featureToggles.supportBundles) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
target: '_self',
|
||||
id: 'support-bundle',
|
||||
text: t('nav.help/support-bundle', 'Support Bundles'),
|
||||
icon: 'question-circle',
|
||||
url: '/admin/support-bundles',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const enrichWithInteractionTracking = (item: NavModelItem, expandedState: boolean) => {
|
||||
const onClick = item.onClick;
|
||||
item.onClick = () => {
|
||||
|
73
public/app/features/support-bundles/SupportBundles.tsx
Normal file
73
public/app/features/support-bundles/SupportBundles.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useAsyncFn } from 'react-use';
|
||||
|
||||
import { dateTimeFormat } from '@grafana/data';
|
||||
import { getBackendSrv } from '@grafana/runtime';
|
||||
import { LinkButton } from '@grafana/ui';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
|
||||
const subTitle = (
|
||||
<span>
|
||||
Support bundles allow you to easily collect and share Grafana logs, configuration, and data with the Grafana Labs
|
||||
team.
|
||||
</span>
|
||||
);
|
||||
|
||||
type SupportBundleState = 'complete' | 'error' | 'timeout' | 'pending';
|
||||
|
||||
interface SupportBundle {
|
||||
uid: string;
|
||||
state: SupportBundleState;
|
||||
creator: string;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const getBundles = () => {
|
||||
return getBackendSrv().get<SupportBundle[]>('/api/support-bundles');
|
||||
};
|
||||
|
||||
function SupportBundles() {
|
||||
const [bundlesState, fetchBundles] = useAsyncFn(getBundles, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBundles();
|
||||
}, [fetchBundles]);
|
||||
|
||||
return (
|
||||
<Page navId="support-bundles" subTitle={subTitle}>
|
||||
<Page.Contents isLoading={bundlesState.loading}>
|
||||
<LinkButton href="admin/support-bundles/create" variant="primary">
|
||||
Create New Support Bundle
|
||||
</LinkButton>
|
||||
|
||||
<table className="filter-table form-inline">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Requested by</th>
|
||||
<th>Expires</th>
|
||||
<th style={{ width: '1%' }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bundlesState?.value?.map((b) => (
|
||||
<tr key={b.uid}>
|
||||
<th>{dateTimeFormat(b.createdAt * 1000)}</th>
|
||||
<th>{b.creator}</th>
|
||||
<th>{dateTimeFormat(b.expiresAt * 1000)}</th>
|
||||
<th>
|
||||
<LinkButton disabled={b.state !== 'complete'} target={'_self'} href={'/api/support-bundles/' + b.uid}>
|
||||
Download
|
||||
</LinkButton>
|
||||
</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default SupportBundles;
|
96
public/app/features/support-bundles/SupportBundlesCreate.tsx
Normal file
96
public/app/features/support-bundles/SupportBundlesCreate.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useAsyncFn } from 'react-use';
|
||||
|
||||
import { getBackendSrv, locationService } from '@grafana/runtime';
|
||||
import { Form, Button, Field, Checkbox } from '@grafana/ui';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
|
||||
// move to types
|
||||
export interface SupportBundleCreateRequest {
|
||||
collectors: string[];
|
||||
}
|
||||
|
||||
export interface SupportBundleCollector {
|
||||
uid: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
includedByDefault: boolean;
|
||||
default: boolean;
|
||||
}
|
||||
|
||||
export interface Props {}
|
||||
|
||||
const createSupportBundle = async (data: SupportBundleCreateRequest) => {
|
||||
const result = await getBackendSrv().post('/api/support-bundles', data);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const SupportBundlesCreate = ({}: Props): JSX.Element => {
|
||||
const onSubmit = useCallback(async (data) => {
|
||||
try {
|
||||
const selectedLabelsArray = Object.keys(data).filter((key) => data[key]);
|
||||
const response = await createSupportBundle({ collectors: selectedLabelsArray });
|
||||
console.info(response);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
locationService.push('/admin/support-bundles');
|
||||
}, []);
|
||||
|
||||
const [components, setComponents] = useState<SupportBundleCollector[]>([]);
|
||||
// populate components from the backend
|
||||
const populateComponents = async () => {
|
||||
return await getBackendSrv().get('/api/support-bundles/collectors');
|
||||
};
|
||||
|
||||
const [state, fetchComponents] = useAsyncFn(populateComponents);
|
||||
useEffect(() => {
|
||||
fetchComponents().then((res) => {
|
||||
setComponents(res);
|
||||
});
|
||||
}, [fetchComponents]);
|
||||
|
||||
// turn components into a uuid -> enabled map
|
||||
const values: Record<string, boolean> = components.reduce((acc, curr) => {
|
||||
return { ...acc, [curr.uid]: curr.default };
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<Page navId="support-bundles" pageNav={{ text: 'Create support bundle' }}>
|
||||
<Page.Contents>
|
||||
<Page.OldNavOnly>
|
||||
<h3 className="page-sub-heading">Create support bundle</h3>
|
||||
</Page.OldNavOnly>
|
||||
{state.error && <p>{state.error}</p>}
|
||||
{!!components.length && (
|
||||
<Form defaultValues={values} onSubmit={onSubmit} validateOn="onSubmit">
|
||||
{({ register, errors }) => {
|
||||
return (
|
||||
<>
|
||||
{components.map((component) => {
|
||||
return (
|
||||
<Field key={component.uid}>
|
||||
<Checkbox
|
||||
{...register(component.uid)}
|
||||
label={component.displayName}
|
||||
id={component.uid}
|
||||
description={component.description}
|
||||
defaultChecked={component.default}
|
||||
disabled={component.includedByDefault}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
})}
|
||||
<Button type="submit">Create</Button>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form>
|
||||
)}
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default SupportBundlesCreate;
|
@ -514,6 +514,7 @@ export function getAppRoutes(): RouteDescriptor[] {
|
||||
...getBrowseStorageRoutes(),
|
||||
...getDynamicDashboardRoutes(),
|
||||
...getPluginCatalogRoutes(),
|
||||
...getSupportBundleRoutes(),
|
||||
...getLiveRoutes(),
|
||||
...getAlertingRoutes(),
|
||||
...getProfileRoutes(),
|
||||
@ -552,6 +553,28 @@ export function getBrowseStorageRoutes(cfg = config): RouteDescriptor[] {
|
||||
];
|
||||
}
|
||||
|
||||
export function getSupportBundleRoutes(cfg = config): RouteDescriptor[] {
|
||||
if (!cfg.featureToggles.supportBundles) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
path: '/admin/support-bundles',
|
||||
component: SafeDynamicImport(
|
||||
() => import(/* webpackChunkName: "SupportBundles" */ 'app/features/support-bundles/SupportBundles')
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/admin/support-bundles/create',
|
||||
component: SafeDynamicImport(
|
||||
() =>
|
||||
import(/* webpackChunkName: "ServiceAccountCreatePage" */ 'app/features/support-bundles/SupportBundlesCreate')
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function getDynamicDashboardRoutes(cfg = config): RouteDescriptor[] {
|
||||
if (!cfg.featureToggles.scenes) {
|
||||
return [];
|
||||
|
@ -1194,6 +1194,12 @@ export const mockNavModel: NavIndex = {
|
||||
],
|
||||
},
|
||||
},
|
||||
'support-bundles': {
|
||||
id: 'support-bundles',
|
||||
text: 'Support bundles',
|
||||
icon: 'sliders-v-alt',
|
||||
url: '/admin/support-bundles',
|
||||
},
|
||||
'server-settings': {
|
||||
id: 'server-settings',
|
||||
text: 'Settings',
|
||||
|
Loading…
Reference in New Issue
Block a user