428 lines
12 KiB
Go
428 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"sort"
|
|
"strconv"
|
|
|
|
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
|
|
acl "github.com/OliveTin/OliveTin/internal/acl"
|
|
config "github.com/OliveTin/OliveTin/internal/config"
|
|
entities "github.com/OliveTin/OliveTin/internal/entities"
|
|
"github.com/OliveTin/OliveTin/internal/executor"
|
|
"github.com/OliveTin/OliveTin/internal/tpl"
|
|
log "github.com/sirupsen/logrus"
|
|
"slices"
|
|
)
|
|
|
|
func renderDashboard(rr *DashboardRenderRequest, dashboardTitle string) *apiv1.Dashboard {
|
|
if dashboardTitle == "default" {
|
|
return buildDefaultDashboard(rr)
|
|
}
|
|
|
|
return findAndRenderDashboard(rr, dashboardTitle)
|
|
}
|
|
|
|
func getEntityFromRequest(rr *DashboardRenderRequest) *entities.Entity {
|
|
if rr.EntityType == "" || rr.EntityKey == "" {
|
|
return nil
|
|
}
|
|
|
|
if !acl.IsAllowedViewEntityType(rr.cfg, rr.AuthenticatedUser, entityFileForType(rr.cfg, rr.EntityType)) {
|
|
return nil
|
|
}
|
|
|
|
entityInstances := entities.GetEntityInstances(rr.EntityType)
|
|
if entity, ok := entityInstances[rr.EntityKey]; ok {
|
|
return entity
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func findAndRenderDashboard(rr *DashboardRenderRequest, dashboardTitle string) *apiv1.Dashboard {
|
|
if dashboard := findDashboardByTitle(rr, dashboardTitle); dashboard != nil {
|
|
return renderDashboardIfValid(dashboard, rr)
|
|
}
|
|
|
|
return renderDirectoryDashboard(rr, dashboardTitle)
|
|
}
|
|
|
|
func findDashboardByTitle(rr *DashboardRenderRequest, dashboardTitle string) *config.DashboardComponent {
|
|
for _, dashboard := range rr.cfg.Dashboards {
|
|
if dashboard.Title == dashboardTitle {
|
|
return dashboard
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func renderDashboardIfValid(dashboard *config.DashboardComponent, rr *DashboardRenderRequest) *apiv1.Dashboard {
|
|
if !acl.IsAllowedViewDashboard(rr.cfg, rr.AuthenticatedUser, dashboard) {
|
|
return nil
|
|
}
|
|
|
|
if len(dashboard.Contents) == 0 {
|
|
logEmptyDashboard(dashboard.Title, rr.AuthenticatedUser.Username)
|
|
return nil
|
|
}
|
|
return buildDashboardFromConfig(dashboard, rr)
|
|
}
|
|
|
|
func renderDirectoryDashboard(rr *DashboardRenderRequest, dashboardTitle string) *apiv1.Dashboard {
|
|
directoryComponent := findDirectoryComponent(rr, dashboardTitle)
|
|
if directoryComponent == nil {
|
|
return nil
|
|
}
|
|
|
|
entity := getEntityFromRequest(rr)
|
|
return buildDashboardFromConfigWithEntity(directoryComponent, rr, entity)
|
|
}
|
|
|
|
func findDirectoryComponent(rr *DashboardRenderRequest, title string) *config.DashboardComponent {
|
|
for _, dashboard := range rr.cfg.Dashboards {
|
|
if component := findDirectoryInRootIfAllowed(rr, dashboard, title); component != nil {
|
|
return component
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func findDirectoryInRootIfAllowed(rr *DashboardRenderRequest, root *config.DashboardComponent, title string) *config.DashboardComponent {
|
|
if !acl.IsAllowedViewDashboard(rr.cfg, rr.AuthenticatedUser, root) {
|
|
return nil
|
|
}
|
|
|
|
return searchDirectoryInComponent(root, title)
|
|
}
|
|
|
|
func searchDirectoryInComponent(component *config.DashboardComponent, title string) *config.DashboardComponent {
|
|
if isMatchingDirectory(component, title) {
|
|
return component
|
|
}
|
|
|
|
return searchDirectoryInSubcomponents(component.Contents, title)
|
|
}
|
|
|
|
func isMatchingDirectory(component *config.DashboardComponent, title string) bool {
|
|
return component.Title == title && len(component.Contents) > 0 && component.Type != "fieldset"
|
|
}
|
|
|
|
func searchDirectoryInSubcomponents(contents []*config.DashboardComponent, title string) *config.DashboardComponent {
|
|
for _, subitem := range contents {
|
|
if found := searchDirectoryInComponent(subitem, title); found != nil {
|
|
return found
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func logEmptyDashboard(dashboardTitle, username string) {
|
|
log.WithFields(log.Fields{
|
|
"dashboard": dashboardTitle,
|
|
"username": username,
|
|
}).Debugf("Dashboard has no readable contents, so it will not be visible in the web ui")
|
|
}
|
|
|
|
func buildDashboardFromConfig(dashboard *config.DashboardComponent, rr *DashboardRenderRequest) *apiv1.Dashboard {
|
|
return buildDashboardFromConfigWithEntity(dashboard, rr, nil)
|
|
}
|
|
|
|
func buildDashboardFromConfigWithEntity(dashboard *config.DashboardComponent, rr *DashboardRenderRequest, entity *entities.Entity) *apiv1.Dashboard {
|
|
contents, root := getDashboardComponentContentsWithEntity(dashboard, rr, entity)
|
|
return &apiv1.Dashboard{
|
|
Title: dashboard.Title,
|
|
Contents: orderTopLevelDashboardComponents(removeNulls(contents), root),
|
|
}
|
|
}
|
|
|
|
func buildDefaultDashboard(rr *DashboardRenderRequest) *apiv1.Dashboard {
|
|
db := &apiv1.Dashboard{
|
|
Title: "Actions",
|
|
Contents: make([]*apiv1.DashboardComponent, 0),
|
|
}
|
|
|
|
fieldset := &apiv1.DashboardComponent{
|
|
Type: "fieldset",
|
|
Title: "Actions",
|
|
Contents: make([]*apiv1.DashboardComponent, 0),
|
|
}
|
|
|
|
for _, binding := range rr.ex.MapActionBindings {
|
|
if !defaultBindingEligibleForDashboard(rr, binding) {
|
|
continue
|
|
}
|
|
|
|
if comp := defaultDashboardComponentFromBinding(binding, rr); comp != nil {
|
|
fieldset.Contents = append(fieldset.Contents, comp)
|
|
}
|
|
}
|
|
|
|
if len(fieldset.Contents) > 0 {
|
|
fieldset.Contents = sortDashboardComponents(fieldset.Contents)
|
|
db.Contents = append(db.Contents, fieldset)
|
|
}
|
|
|
|
return db
|
|
}
|
|
|
|
func defaultBindingEligibleForDashboard(rr *DashboardRenderRequest, binding *executor.ActionBinding) bool {
|
|
return defaultBindingWellFormed(binding) &&
|
|
!binding.IsOnConfiguredDashboard() &&
|
|
acl.IsAllowedView(rr.cfg, rr.AuthenticatedUser, binding.Action) &&
|
|
defaultBindingEntityTypeAllowed(rr, binding)
|
|
}
|
|
|
|
func defaultBindingWellFormed(binding *executor.ActionBinding) bool {
|
|
return binding != nil && binding.Action != nil && !binding.Action.Hidden
|
|
}
|
|
|
|
func defaultBindingEntityTypeAllowed(rr *DashboardRenderRequest, binding *executor.ActionBinding) bool {
|
|
if binding.Entity == nil || binding.Action.Entity == "" {
|
|
return true
|
|
}
|
|
|
|
return acl.IsAllowedViewEntityType(rr.cfg, rr.AuthenticatedUser, entityFileForType(rr.cfg, binding.Action.Entity))
|
|
}
|
|
|
|
func defaultDashboardComponentFromBinding(binding *executor.ActionBinding, rr *DashboardRenderRequest) *apiv1.DashboardComponent {
|
|
action := buildAction(binding, rr)
|
|
if action == nil {
|
|
return nil
|
|
}
|
|
|
|
comp := &apiv1.DashboardComponent{
|
|
Type: "link",
|
|
Title: action.Title,
|
|
Icon: action.Icon,
|
|
Action: action,
|
|
}
|
|
if binding.Entity != nil {
|
|
comp.EntityKey = binding.Entity.UniqueKey
|
|
}
|
|
|
|
return comp
|
|
}
|
|
|
|
func entityKeyLess(a, b string) bool {
|
|
ai, errA := strconv.ParseInt(a, 10, 64)
|
|
bi, errB := strconv.ParseInt(b, 10, 64)
|
|
if errA == nil && errB == nil {
|
|
return ai < bi
|
|
}
|
|
return a < b
|
|
}
|
|
|
|
//gocyclo:ignore
|
|
func sortDashboardComponents(components []*apiv1.DashboardComponent) []*apiv1.DashboardComponent {
|
|
sort.Slice(components, func(i, j int) bool {
|
|
if components[i].Action == nil || components[j].Action == nil {
|
|
if components[i].EntityKey != "" && components[j].EntityKey != "" &&
|
|
components[i].EntityKey != components[j].EntityKey {
|
|
return entityKeyLess(components[i].EntityKey, components[j].EntityKey)
|
|
}
|
|
|
|
return components[i].Title < components[j].Title
|
|
}
|
|
|
|
if components[i].Action.Order != components[j].Action.Order {
|
|
return components[i].Action.Order < components[j].Action.Order
|
|
}
|
|
if components[i].EntityKey != components[j].EntityKey {
|
|
return entityKeyLess(components[i].EntityKey, components[j].EntityKey)
|
|
}
|
|
return components[i].Action.Title < components[j].Action.Title
|
|
})
|
|
|
|
return components
|
|
}
|
|
|
|
func removeNulls(components []*apiv1.DashboardComponent) []*apiv1.DashboardComponent {
|
|
ret := make([]*apiv1.DashboardComponent, 0)
|
|
|
|
for _, component := range components {
|
|
if component == nil {
|
|
continue
|
|
}
|
|
|
|
ret = append(ret, component)
|
|
}
|
|
|
|
return ret
|
|
}
|
|
|
|
func isNonEntityFieldset(component *apiv1.DashboardComponent) bool {
|
|
return component != nil && component.Type == "fieldset" && component.EntityType == ""
|
|
}
|
|
|
|
func isRegularFieldset(component *apiv1.DashboardComponent, root *apiv1.DashboardComponent) bool {
|
|
if !isNonEntityFieldset(component) {
|
|
return false
|
|
}
|
|
return root == nil || component != root
|
|
}
|
|
|
|
func partitionTopLevelComponents(components []*apiv1.DashboardComponent, root *apiv1.DashboardComponent) (regular, sortables []*apiv1.DashboardComponent, isRegular []bool) {
|
|
regular = make([]*apiv1.DashboardComponent, 0)
|
|
sortables = make([]*apiv1.DashboardComponent, 0)
|
|
isRegular = make([]bool, len(components))
|
|
for i, c := range components {
|
|
anchor := isRegularFieldset(c, root)
|
|
isRegular[i] = anchor
|
|
if anchor {
|
|
regular = append(regular, c)
|
|
} else {
|
|
sortables = append(sortables, c)
|
|
}
|
|
}
|
|
return regular, sortables, isRegular
|
|
}
|
|
|
|
func mergeOrderedTopLevelComponents(regular, sortables []*apiv1.DashboardComponent, isRegular []bool) []*apiv1.DashboardComponent {
|
|
out := make([]*apiv1.DashboardComponent, 0, len(isRegular))
|
|
regIdx, sortIdx := 0, 0
|
|
for _, anchor := range isRegular {
|
|
if anchor {
|
|
out = append(out, regular[regIdx])
|
|
regIdx++
|
|
} else {
|
|
out = append(out, sortables[sortIdx])
|
|
sortIdx++
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func orderTopLevelDashboardComponents(components []*apiv1.DashboardComponent, root *apiv1.DashboardComponent) []*apiv1.DashboardComponent {
|
|
if len(components) == 0 {
|
|
return components
|
|
}
|
|
regular, sortables, isRegular := partitionTopLevelComponents(components, root)
|
|
sortDashboardComponents(sortables)
|
|
return mergeOrderedTopLevelComponents(regular, sortables, isRegular)
|
|
}
|
|
|
|
func getDashboardComponentContentsWithEntity(dashboard *config.DashboardComponent, rr *DashboardRenderRequest, entity *entities.Entity) ([]*apiv1.DashboardComponent, *apiv1.DashboardComponent) {
|
|
ret := make([]*apiv1.DashboardComponent, 0)
|
|
rootFieldset := createRootFieldset()
|
|
|
|
for _, subitem := range dashboard.Contents {
|
|
processDashboardSubitemWithEntity(subitem, rr, &ret, rootFieldset, entity)
|
|
}
|
|
|
|
if len(rootFieldset.Contents) > 0 {
|
|
ret = append(ret, rootFieldset)
|
|
return ret, rootFieldset
|
|
}
|
|
return ret, nil
|
|
}
|
|
|
|
func createRootFieldset() *apiv1.DashboardComponent {
|
|
return &apiv1.DashboardComponent{
|
|
Type: "fieldset",
|
|
Title: "Actions",
|
|
Contents: make([]*apiv1.DashboardComponent, 0),
|
|
}
|
|
}
|
|
|
|
func appendComponentIfNotNil(components *[]*apiv1.DashboardComponent, comp *apiv1.DashboardComponent) {
|
|
if comp != nil {
|
|
*components = append(*components, comp)
|
|
}
|
|
}
|
|
|
|
func getDashboardComponentOrNil(subitem *config.DashboardComponent, rr *DashboardRenderRequest, entity *entities.Entity) *apiv1.DashboardComponent {
|
|
if len(subitem.Contents) == 0 && rr.findActionForEntity(subitem.Title, entity) == nil {
|
|
if !isAllowedType(subitem.Type) {
|
|
return nil
|
|
}
|
|
}
|
|
return buildDashboardComponentSimpleWithEntity(subitem, rr, entity)
|
|
}
|
|
|
|
func processDashboardSubitemWithEntity(subitem *config.DashboardComponent, rr *DashboardRenderRequest, ret *[]*apiv1.DashboardComponent, rootFieldset *apiv1.DashboardComponent, entity *entities.Entity) {
|
|
if subitem.Type != "fieldset" {
|
|
appendComponentIfNotNil(&rootFieldset.Contents, getDashboardComponentOrNil(subitem, rr, entity))
|
|
return
|
|
}
|
|
|
|
if subitem.Entity != "" {
|
|
*ret = append(*ret, buildEntityFieldsets(subitem.Entity, subitem, rr)...)
|
|
} else {
|
|
appendComponentIfNotNil(ret, getDashboardComponentOrNil(subitem, rr, entity))
|
|
}
|
|
}
|
|
|
|
func buildDashboardComponentSimpleWithEntity(subitem *config.DashboardComponent, rr *DashboardRenderRequest, entity *entities.Entity) *apiv1.DashboardComponent {
|
|
var contents []*apiv1.DashboardComponent
|
|
|
|
if len(subitem.Contents) > 0 {
|
|
contents, _ = getDashboardComponentContentsWithEntity(subitem, rr, entity)
|
|
}
|
|
|
|
action := rr.findActionForEntity(subitem.Title, entity)
|
|
componentType := getDashboardComponentType(subitem, action)
|
|
|
|
title := subitem.Title
|
|
if entity != nil {
|
|
title = tpl.ParseTemplateOfActionBeforeExec(subitem.Title, entity)
|
|
}
|
|
|
|
newitem := &apiv1.DashboardComponent{
|
|
Title: title,
|
|
Type: componentType,
|
|
Contents: contents,
|
|
Icon: getDashboardComponentIcon(subitem, rr.cfg),
|
|
CssClass: subitem.CssClass,
|
|
Action: action,
|
|
}
|
|
|
|
return newitem
|
|
}
|
|
|
|
func getDashboardComponentIcon(item *config.DashboardComponent, cfg *config.Config) string {
|
|
if item.Icon == "" {
|
|
return cfg.DefaultIconForDirectories
|
|
}
|
|
|
|
return item.Icon
|
|
}
|
|
|
|
func getDashboardComponentType(item *config.DashboardComponent, action *apiv1.Action) string {
|
|
if hasContents(item) {
|
|
return getTypeForComponentWithContents(item)
|
|
}
|
|
|
|
if isAllowedType(item.Type) {
|
|
return item.Type
|
|
}
|
|
|
|
return getDefaultType(action)
|
|
}
|
|
|
|
func hasContents(item *config.DashboardComponent) bool {
|
|
return len(item.Contents) > 0
|
|
}
|
|
|
|
func getTypeForComponentWithContents(item *config.DashboardComponent) string {
|
|
if item.Type != "fieldset" {
|
|
return "directory"
|
|
}
|
|
return "fieldset"
|
|
}
|
|
|
|
func isAllowedType(itemType string) bool {
|
|
allowedTypes := []string{
|
|
"stdout-most-recent-execution",
|
|
"display",
|
|
}
|
|
return slices.Contains(allowedTypes, itemType)
|
|
}
|
|
|
|
func getDefaultType(action *apiv1.Action) string {
|
|
if action == nil {
|
|
return "display"
|
|
}
|
|
return "link"
|
|
}
|