Navigation: Remove `ApplyAdminIA` logic (#89113)

make admin IA more normal
pull/89126/head
Ashley Harrison 1 year ago committed by GitHub
parent 5bb10d84e0
commit 822644714a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      pkg/api/index.go
  2. 11
      pkg/services/licensing/oss.go
  3. 135
      pkg/services/navtree/models.go
  4. 180
      pkg/services/navtree/navtreeimpl/admin.go
  5. 4
      pkg/services/navtree/navtreeimpl/applinks.go
  6. 2
      pkg/services/navtree/navtreeimpl/navtree.go

@ -158,7 +158,7 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV
hs.HooksService.RunIndexDataHooks(&data, c) hs.HooksService.RunIndexDataHooks(&data, c)
data.NavTree.ApplyAdminIA() data.NavTree.ApplyCostManagementIA()
data.NavTree.ApplyHelpVersion(data.Settings.BuildInfo.VersionString) // RunIndexDataHooks can modify the version string data.NavTree.ApplyHelpVersion(data.Settings.BuildInfo.VersionString) // RunIndexDataHooks can modify the version string
data.NavTree.Sort() data.NavTree.Sort()

@ -59,12 +59,13 @@ func ProvideService(cfg *setting.Cfg, hooksService *hooks.HooksService) *OSSLice
return return
} }
if adminNode := indexData.NavTree.FindById(navtree.NavIDCfg); adminNode != nil { if adminNode := indexData.NavTree.FindById(navtree.NavIDCfgGeneral); adminNode != nil {
adminNode.Children = append(adminNode.Children, &navtree.NavLink{ adminNode.Children = append(adminNode.Children, &navtree.NavLink{
Text: "Stats and license", Text: "Stats and license",
Id: "upgrading", Id: "upgrading",
Url: l.LicenseURL(req.IsGrafanaAdmin), Url: l.LicenseURL(req.IsGrafanaAdmin),
Icon: "unlock", Icon: "unlock",
SortWeight: -1,
}) })
} }
}) })

@ -136,113 +136,50 @@ func (root *NavTreeRoot) ApplyHelpVersion(version string) {
} }
} }
func (root *NavTreeRoot) ApplyAdminIA() { func (root *NavTreeRoot) ApplyCostManagementIA() {
orgAdminNode := root.FindById(NavIDCfg) orgAdminNode := root.FindById(NavIDCfg)
var costManagementApp *NavLink
var adaptiveMetricsApp *NavLink
var attributionsApp *NavLink
var logVolumeExplorerApp *NavLink
if orgAdminNode != nil { if orgAdminNode != nil {
adminNodeLinks := []*NavLink{} adminNodeLinks := []*NavLink{}
for _, element := range orgAdminNode.Children {
generalNodeLinks := []*NavLink{} switch navId := element.Id; navId {
generalNodeLinks = AppendIfNotNil(generalNodeLinks, root.FindById("upgrading")) // TODO does this even exist case "plugin-page-grafana-costmanagementui-app":
generalNodeLinks = AppendIfNotNil(generalNodeLinks, root.FindById("licensing")) costManagementApp = element
generalNodeLinks = AppendIfNotNil(generalNodeLinks, root.FindById("org-settings")) case "plugin-page-grafana-adaptive-metrics-app":
generalNodeLinks = AppendIfNotNil(generalNodeLinks, root.FindById("server-settings")) adaptiveMetricsApp = element
generalNodeLinks = AppendIfNotNil(generalNodeLinks, root.FindById("global-orgs")) case "plugin-page-grafana-attributions-app":
generalNodeLinks = AppendIfNotNil(generalNodeLinks, root.FindById("feature-toggles")) attributionsApp = element
generalNodeLinks = AppendIfNotNil(generalNodeLinks, root.FindById("storage")) case "plugin-page-grafana-logvolumeexplorer-app":
generalNodeLinks = AppendIfNotNil(generalNodeLinks, root.FindById("migrate-to-cloud")) logVolumeExplorerApp = element
generalNodeLinks = AppendIfNotNil(generalNodeLinks, root.FindById("banner-settings")) default:
adminNodeLinks = append(adminNodeLinks, element)
generalNode := &NavLink{ }
Text: "General",
SubTitle: "Manage default preferences and settings across Grafana",
Id: NavIDCfgGeneral,
Url: "/admin/general",
Icon: "shield",
Children: generalNodeLinks,
}
pluginsNodeLinks := []*NavLink{}
pluginsNodeLinks = AppendIfNotNil(pluginsNodeLinks, root.FindById("plugins"))
pluginsNodeLinks = AppendIfNotNil(pluginsNodeLinks, root.FindById("datasources"))
pluginsNodeLinks = AppendIfNotNil(pluginsNodeLinks, root.FindById("recordedQueries"))
pluginsNodeLinks = AppendIfNotNil(pluginsNodeLinks, root.FindById("correlations"))
pluginsNodeLinks = AppendIfNotNil(pluginsNodeLinks, root.FindById("plugin-page-grafana-cloud-link-app"))
pluginsNode := &NavLink{
Text: "Plugins and data",
SubTitle: "Install plugins and define the relationships between data",
Id: NavIDCfgPlugins,
Url: "/admin/plugins",
Icon: "shield",
Children: pluginsNodeLinks,
}
accessNodeLinks := []*NavLink{}
accessNodeLinks = AppendIfNotNil(accessNodeLinks, root.FindById("global-users"))
accessNodeLinks = AppendIfNotNil(accessNodeLinks, root.FindById("teams"))
accessNodeLinks = AppendIfNotNil(accessNodeLinks, root.FindById("standalone-plugin-page-/a/grafana-auth-app"))
accessNodeLinks = AppendIfNotNil(accessNodeLinks, root.FindById("serviceaccounts"))
accessNodeLinks = AppendIfNotNil(accessNodeLinks, root.FindById("apikeys"))
usersNode := &NavLink{
Text: "Users and access",
SubTitle: "Configure access for individual users, teams, and service accounts",
Id: NavIDCfgAccess,
Url: "/admin/access",
Icon: "shield",
Children: accessNodeLinks,
}
if len(generalNode.Children) > 0 {
adminNodeLinks = append(adminNodeLinks, generalNode)
}
if len(pluginsNode.Children) > 0 {
adminNodeLinks = append(adminNodeLinks, pluginsNode)
}
if len(usersNode.Children) > 0 {
adminNodeLinks = append(adminNodeLinks, usersNode)
}
authenticationNode := root.FindById("authentication")
if authenticationNode != nil {
authenticationNode.IsSection = true
adminNodeLinks = append(adminNodeLinks, authenticationNode)
}
costManagementNode := root.FindById("plugin-page-grafana-costmanagementui-app")
if costManagementNode != nil {
adminNodeLinks = append(adminNodeLinks, costManagementNode)
}
costManagementMetricsNode := root.FindByURL("/a/grafana-costmanagementui-app/metrics")
adaptiveMetricsNode := root.FindById("plugin-page-grafana-adaptive-metrics-app")
if costManagementMetricsNode != nil && adaptiveMetricsNode != nil {
costManagementMetricsNode.Children = append(costManagementMetricsNode.Children, adaptiveMetricsNode)
}
attributionsNode := root.FindById("plugin-page-grafana-attributions-app")
if costManagementMetricsNode != nil && attributionsNode != nil {
costManagementMetricsNode.Children = append(costManagementMetricsNode.Children, attributionsNode)
} }
costManagementLogsNode := root.FindByURL("/a/grafana-costmanagementui-app/logs") if costManagementApp != nil {
logVolumeExplorerNode := root.FindById("plugin-page-grafana-logvolumeexplorer-app") costManagementMetricsNode := FindByURL(costManagementApp.Children, "/a/grafana-costmanagementui-app/metrics")
if costManagementMetricsNode != nil {
if costManagementLogsNode != nil && logVolumeExplorerNode != nil { if adaptiveMetricsApp != nil {
costManagementLogsNode.Children = append(costManagementLogsNode.Children, logVolumeExplorerNode) costManagementMetricsNode.Children = append(costManagementMetricsNode.Children, adaptiveMetricsApp)
} }
if attributionsApp != nil {
costManagementMetricsNode.Children = append(costManagementMetricsNode.Children, attributionsApp)
}
}
if len(adminNodeLinks) > 0 { costManagementLogsNode := FindByURL(costManagementApp.Children, "/a/grafana-costmanagementui-app/logs")
orgAdminNode.Children = adminNodeLinks if costManagementLogsNode != nil {
} else { if logVolumeExplorerApp != nil {
root.RemoveSection(orgAdminNode) costManagementLogsNode.Children = append(costManagementLogsNode.Children, logVolumeExplorerApp)
}
}
adminNodeLinks = append(adminNodeLinks, costManagementApp)
} }
orgAdminNode.Children = adminNodeLinks
} }
} }

@ -21,10 +21,71 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink
orgsAccessEvaluator := ac.EvalPermission(ac.ActionOrgsRead) orgsAccessEvaluator := ac.EvalPermission(ac.ActionOrgsRead)
authConfigUIAvailable := s.license.FeatureEnabled(social.SAMLProviderName) || s.cfg.LDAPAuthEnabled authConfigUIAvailable := s.license.FeatureEnabled(social.SAMLProviderName) || s.cfg.LDAPAuthEnabled
generalNodeLinks := []*navtree.NavLink{}
if hasAccess(ac.OrgPreferencesAccessEvaluator) {
generalNodeLinks = append(generalNodeLinks, &navtree.NavLink{
Text: "Default preferences",
Id: "org-settings",
SubTitle: "Manage preferences across an organization",
Icon: "sliders-v-alt",
Url: s.cfg.AppSubURL + "/org",
})
}
if hasAccess(ac.EvalPermission(ac.ActionSettingsRead, ac.ScopeSettingsAll)) {
generalNodeLinks = append(generalNodeLinks, &navtree.NavLink{
Text: "Settings", SubTitle: "View the settings defined in your Grafana config", Id: "server-settings", Url: s.cfg.AppSubURL + "/admin/settings", Icon: "sliders-v-alt",
})
}
if hasGlobalAccess(orgsAccessEvaluator) {
generalNodeLinks = append(generalNodeLinks, &navtree.NavLink{
Text: "Organizations", SubTitle: "Isolated instances of Grafana running on the same server", Id: "global-orgs", Url: s.cfg.AppSubURL + "/admin/orgs", Icon: "building",
})
}
if s.features.IsEnabled(ctx, featuremgmt.FlagFeatureToggleAdminPage) && hasAccess(ac.EvalPermission(ac.ActionFeatureManagementRead)) {
generalNodeLinks = append(generalNodeLinks, &navtree.NavLink{
Text: "Feature Toggles",
SubTitle: "View and edit feature toggles",
Id: "feature-toggles",
Url: s.cfg.AppSubURL + "/admin/featuretoggles",
Icon: "toggle-on",
})
}
if hasAccess(ac.EvalPermission(ac.ActionSettingsRead, ac.ScopeSettingsAll)) && s.features.IsEnabled(ctx, featuremgmt.FlagStorage) {
generalNodeLinks = append(generalNodeLinks, &navtree.NavLink{
Text: "Storage",
Id: "storage",
SubTitle: "Manage file storage",
Icon: "cube",
Url: s.cfg.AppSubURL + "/admin/storage",
})
}
if s.features.IsEnabled(ctx, featuremgmt.FlagOnPremToCloudMigrations) && c.SignedInUser.HasRole(org.RoleAdmin) {
generalNodeLinks = append(generalNodeLinks, &navtree.NavLink{
Text: "Migrate to Grafana Cloud",
Id: "migrate-to-cloud",
SubTitle: "Copy configuration from your self-managed installation to a cloud stack",
Url: s.cfg.AppSubURL + "/admin/migrate-to-cloud",
})
}
generalNode := &navtree.NavLink{
Text: "General",
SubTitle: "Manage default preferences and settings across Grafana",
Id: navtree.NavIDCfgGeneral,
Url: "/admin/general",
Icon: "shield",
Children: generalNodeLinks,
}
if len(generalNode.Children) > 0 {
configNodes = append(configNodes, generalNode)
}
pluginsNodeLinks := []*navtree.NavLink{}
// FIXME: If plugin admin is disabled or externally managed, server admins still need to access the page, this is why // FIXME: If plugin admin is disabled or externally managed, server admins still need to access the page, this is why
// while we don't have a permissions for listing plugins the legacy check has to stay as a default // while we don't have a permissions for listing plugins the legacy check has to stay as a default
if pluginaccesscontrol.ReqCanAdminPlugins(s.cfg)(c) || hasAccess(pluginaccesscontrol.AdminAccessEvaluator) { if pluginaccesscontrol.ReqCanAdminPlugins(s.cfg)(c) || hasAccess(pluginaccesscontrol.AdminAccessEvaluator) {
configNodes = append(configNodes, &navtree.NavLink{ pluginsNodeLinks = append(pluginsNodeLinks, &navtree.NavLink{
Text: "Plugins", Text: "Plugins",
Id: "plugins", Id: "plugins",
SubTitle: "Extend the Grafana experience with plugins", SubTitle: "Extend the Grafana experience with plugins",
@ -32,15 +93,37 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink
Url: s.cfg.AppSubURL + "/plugins", Url: s.cfg.AppSubURL + "/plugins",
}) })
} }
if s.features.IsEnabled(ctx, featuremgmt.FlagCorrelations) && hasAccess(correlations.ConfigurationPageAccess) {
pluginsNodeLinks = append(pluginsNodeLinks, &navtree.NavLink{
Text: "Correlations",
Icon: "gf-glue",
SubTitle: "Add and configure correlations",
Id: "correlations",
Url: s.cfg.AppSubURL + "/datasources/correlations",
})
}
pluginsNode := &navtree.NavLink{
Text: "Plugins and data",
SubTitle: "Install plugins and define the relationships between data",
Id: navtree.NavIDCfgPlugins,
Url: "/admin/plugins",
Icon: "shield",
Children: pluginsNodeLinks,
}
if len(pluginsNode.Children) > 0 {
configNodes = append(configNodes, pluginsNode)
}
accessNodeLinks := []*navtree.NavLink{}
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionOrgUsersRead), ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll))) { if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionOrgUsersRead), ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll))) {
configNodes = append(configNodes, &navtree.NavLink{ accessNodeLinks = append(accessNodeLinks, &navtree.NavLink{
Text: "Users", SubTitle: "Manage users in Grafana", Id: "global-users", Url: s.cfg.AppSubURL + "/admin/users", Icon: "user", Text: "Users", SubTitle: "Manage users in Grafana", Id: "global-users", Url: s.cfg.AppSubURL + "/admin/users", Icon: "user",
}) })
} }
if hasAccess(ac.TeamsAccessEvaluator) { if hasAccess(ac.TeamsAccessEvaluator) {
configNodes = append(configNodes, &navtree.NavLink{ accessNodeLinks = append(accessNodeLinks, &navtree.NavLink{
Text: "Teams", Text: "Teams",
Id: "teams", Id: "teams",
SubTitle: "Groups of users that have common dashboard and permission needs", SubTitle: "Groups of users that have common dashboard and permission needs",
@ -48,9 +131,8 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink
Url: s.cfg.AppSubURL + "/org/teams", Url: s.cfg.AppSubURL + "/org/teams",
}) })
} }
if enableServiceAccount(s, c) { if enableServiceAccount(s, c) {
configNodes = append(configNodes, &navtree.NavLink{ accessNodeLinks = append(accessNodeLinks, &navtree.NavLink{
Text: "Service accounts", Text: "Service accounts",
Id: "serviceaccounts", Id: "serviceaccounts",
SubTitle: "Use service accounts to run automated workloads in Grafana", SubTitle: "Use service accounts to run automated workloads in Grafana",
@ -58,13 +140,12 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink
Url: s.cfg.AppSubURL + "/org/serviceaccounts", Url: s.cfg.AppSubURL + "/org/serviceaccounts",
}) })
} }
disabled, err := s.apiKeyService.IsDisabled(ctx, c.SignedInUser.GetOrgID()) disabled, err := s.apiKeyService.IsDisabled(ctx, c.SignedInUser.GetOrgID())
if err != nil { if err != nil {
return nil, err return nil, err
} }
if hasAccess(ac.ApiKeyAccessEvaluator) && !disabled { if hasAccess(ac.ApiKeyAccessEvaluator) && !disabled {
configNodes = append(configNodes, &navtree.NavLink{ accessNodeLinks = append(accessNodeLinks, &navtree.NavLink{
Text: "API keys", Text: "API keys",
Id: "apikeys", Id: "apikeys",
SubTitle: "Manage and create API keys that are used to interact with Grafana HTTP APIs", SubTitle: "Manage and create API keys that are used to interact with Grafana HTTP APIs",
@ -73,80 +154,31 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink
}) })
} }
if hasAccess(ac.OrgPreferencesAccessEvaluator) { usersNode := &navtree.NavLink{
configNodes = append(configNodes, &navtree.NavLink{ Text: "Users and access",
Text: "Default preferences", SubTitle: "Configure access for individual users, teams, and service accounts",
Id: "org-settings", Id: navtree.NavIDCfgAccess,
SubTitle: "Manage preferences across an organization", Url: "/admin/access",
Icon: "sliders-v-alt", Icon: "shield",
Url: s.cfg.AppSubURL + "/org", Children: accessNodeLinks,
})
}
if authConfigUIAvailable && hasAccess(ssoutils.EvalAuthenticationSettings(s.cfg)) ||
(hasAccess(ssoutils.OauthSettingsEvaluator(s.cfg)) && s.features.IsEnabled(ctx, featuremgmt.FlagSsoSettingsApi)) {
configNodes = append(configNodes, &navtree.NavLink{
Text: "Authentication",
Id: "authentication",
SubTitle: "Manage your auth settings and configure single sign-on",
Icon: "signin",
Url: s.cfg.AppSubURL + "/admin/authentication",
})
}
if hasAccess(ac.EvalPermission(ac.ActionSettingsRead, ac.ScopeSettingsAll)) {
configNodes = append(configNodes, &navtree.NavLink{
Text: "Settings", SubTitle: "View the settings defined in your Grafana config", Id: "server-settings", Url: s.cfg.AppSubURL + "/admin/settings", Icon: "sliders-v-alt",
})
} }
if hasGlobalAccess(orgsAccessEvaluator) { if len(usersNode.Children) > 0 {
configNodes = append(configNodes, &navtree.NavLink{ configNodes = append(configNodes, usersNode)
Text: "Organizations", SubTitle: "Isolated instances of Grafana running on the same server", Id: "global-orgs", Url: s.cfg.AppSubURL + "/admin/orgs", Icon: "building",
})
}
if s.features.IsEnabled(ctx, featuremgmt.FlagFeatureToggleAdminPage) && hasAccess(ac.EvalPermission(ac.ActionFeatureManagementRead)) {
configNodes = append(configNodes, &navtree.NavLink{
Text: "Feature Toggles",
SubTitle: "View and edit feature toggles",
Id: "feature-toggles",
Url: s.cfg.AppSubURL + "/admin/featuretoggles",
Icon: "toggle-on",
})
} }
if s.features.IsEnabled(ctx, featuremgmt.FlagCorrelations) && hasAccess(correlations.ConfigurationPageAccess) { if authConfigUIAvailable && hasAccess(ssoutils.EvalAuthenticationSettings(s.cfg)) ||
(hasAccess(ssoutils.OauthSettingsEvaluator(s.cfg)) && s.features.IsEnabled(ctx, featuremgmt.FlagSsoSettingsApi)) {
configNodes = append(configNodes, &navtree.NavLink{ configNodes = append(configNodes, &navtree.NavLink{
Text: "Correlations", Text: "Authentication",
Icon: "gf-glue", Id: "authentication",
SubTitle: "Add and configure correlations", SubTitle: "Manage your auth settings and configure single sign-on",
Id: "correlations", Icon: "signin",
Url: s.cfg.AppSubURL + "/datasources/correlations", IsSection: true,
Url: s.cfg.AppSubURL + "/admin/authentication",
}) })
} }
if hasAccess(ac.EvalPermission(ac.ActionSettingsRead, ac.ScopeSettingsAll)) && s.features.IsEnabled(ctx, featuremgmt.FlagStorage) {
storage := &navtree.NavLink{
Text: "Storage",
Id: "storage",
SubTitle: "Manage file storage",
Icon: "cube",
Url: s.cfg.AppSubURL + "/admin/storage",
}
configNodes = append(configNodes, storage)
}
if s.features.IsEnabled(ctx, featuremgmt.FlagOnPremToCloudMigrations) && c.SignedInUser.HasRole(org.RoleAdmin) {
migrateToCloud := &navtree.NavLink{
Text: "Migrate to Grafana Cloud",
Id: "migrate-to-cloud",
SubTitle: "Copy configuration from your self-managed installation to a cloud stack",
Url: s.cfg.AppSubURL + "/admin/migrate-to-cloud",
}
configNodes = append(configNodes, migrateToCloud)
}
configNode := &navtree.NavLink{ configNode := &navtree.NavLink{
Id: navtree.NavIDCfg, Id: navtree.NavIDCfg,
Text: "Administration", Text: "Administration",

@ -295,7 +295,7 @@ func (s *ServiceImpl) readNavigationSettings() {
"grafana-incident-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 2, Text: "Incidents"}, "grafana-incident-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 2, Text: "Incidents"},
"grafana-ml-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 3, Text: "Machine Learning"}, "grafana-ml-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 3, Text: "Machine Learning"},
"grafana-slo-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 4}, "grafana-slo-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 4},
"grafana-cloud-link-app": {SectionID: navtree.NavIDCfg}, "grafana-cloud-link-app": {SectionID: navtree.NavIDCfgPlugins, SortWeight: 3},
"grafana-costmanagementui-app": {SectionID: navtree.NavIDCfg, Text: "Cost management"}, "grafana-costmanagementui-app": {SectionID: navtree.NavIDCfg, Text: "Cost management"},
"grafana-adaptive-metrics-app": {SectionID: navtree.NavIDCfg, Text: "Adaptive Metrics"}, "grafana-adaptive-metrics-app": {SectionID: navtree.NavIDCfg, Text: "Adaptive Metrics"},
"grafana-attributions-app": {SectionID: navtree.NavIDCfg, Text: "Attributions"}, "grafana-attributions-app": {SectionID: navtree.NavIDCfg, Text: "Attributions"},
@ -307,7 +307,7 @@ func (s *ServiceImpl) readNavigationSettings() {
} }
s.navigationAppPathConfig = map[string]NavigationAppConfig{ s.navigationAppPathConfig = map[string]NavigationAppConfig{
"/a/grafana-auth-app": {SectionID: navtree.NavIDCfg, SortWeight: 7}, "/a/grafana-auth-app": {SectionID: navtree.NavIDCfgAccess, SortWeight: 2},
} }
appSections := s.cfg.Raw.Section("navigation.app_sections") appSections := s.cfg.Raw.Section("navigation.app_sections")

@ -151,7 +151,7 @@ func (s *ServiceImpl) GetNavTree(c *contextmodel.ReqContext, prefs *pref.Prefere
orgAdminNode, err := s.getAdminNode(c) orgAdminNode, err := s.getAdminNode(c)
if orgAdminNode != nil { if orgAdminNode != nil && len(orgAdminNode.Children) > 0 {
treeRoot.AddSection(orgAdminNode) treeRoot.AddSection(orgAdminNode)
} else if err != nil { } else if err != nil {
return nil, err return nil, err

Loading…
Cancel
Save