dashboards.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431
  1. package api
  2. import (
  3. "sort"
  4. "strconv"
  5. apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
  6. acl "github.com/OliveTin/OliveTin/internal/acl"
  7. config "github.com/OliveTin/OliveTin/internal/config"
  8. entities "github.com/OliveTin/OliveTin/internal/entities"
  9. "github.com/OliveTin/OliveTin/internal/executor"
  10. "github.com/OliveTin/OliveTin/internal/tpl"
  11. log "github.com/sirupsen/logrus"
  12. "slices"
  13. )
  14. func renderDashboard(rr *DashboardRenderRequest, dashboardTitle string) *apiv1.Dashboard {
  15. if dashboardTitle == "default" {
  16. return buildDefaultDashboard(rr)
  17. }
  18. return findAndRenderDashboard(rr, dashboardTitle)
  19. }
  20. func getEntityFromRequest(rr *DashboardRenderRequest) *entities.Entity {
  21. if rr.EntityType == "" || rr.EntityKey == "" {
  22. return nil
  23. }
  24. if !acl.IsAllowedViewEntityType(rr.cfg, rr.AuthenticatedUser, entityFileForType(rr.cfg, rr.EntityType)) {
  25. return nil
  26. }
  27. entityInstances := entities.GetEntityInstances(rr.EntityType)
  28. if entity, ok := entityInstances[rr.EntityKey]; ok {
  29. return entity
  30. }
  31. return nil
  32. }
  33. func findAndRenderDashboard(rr *DashboardRenderRequest, dashboardTitle string) *apiv1.Dashboard {
  34. if dashboard := findDashboardByTitle(rr, dashboardTitle); dashboard != nil {
  35. return renderDashboardIfValid(dashboard, rr)
  36. }
  37. return renderDirectoryDashboard(rr, dashboardTitle)
  38. }
  39. func findDashboardByTitle(rr *DashboardRenderRequest, dashboardTitle string) *config.DashboardComponent {
  40. for _, dashboard := range rr.cfg.Dashboards {
  41. if dashboard.Title == dashboardTitle {
  42. return dashboard
  43. }
  44. }
  45. return nil
  46. }
  47. func renderDashboardIfValid(dashboard *config.DashboardComponent, rr *DashboardRenderRequest) *apiv1.Dashboard {
  48. if !acl.IsAllowedViewDashboard(rr.cfg, rr.AuthenticatedUser, dashboard) {
  49. return nil
  50. }
  51. if len(dashboard.Contents) == 0 {
  52. logEmptyDashboard(dashboard.Title, rr.AuthenticatedUser.Username)
  53. return nil
  54. }
  55. return buildDashboardFromConfig(dashboard, rr)
  56. }
  57. func renderDirectoryDashboard(rr *DashboardRenderRequest, dashboardTitle string) *apiv1.Dashboard {
  58. directoryComponent := findDirectoryComponent(rr, dashboardTitle)
  59. if directoryComponent == nil {
  60. return nil
  61. }
  62. entity := getEntityFromRequest(rr)
  63. return buildDashboardFromConfigWithEntity(directoryComponent, rr, entity)
  64. }
  65. func findDirectoryComponent(rr *DashboardRenderRequest, title string) *config.DashboardComponent {
  66. for _, dashboard := range rr.cfg.Dashboards {
  67. if component := findDirectoryInRootIfAllowed(rr, dashboard, title); component != nil {
  68. return component
  69. }
  70. }
  71. return nil
  72. }
  73. func findDirectoryInRootIfAllowed(rr *DashboardRenderRequest, root *config.DashboardComponent, title string) *config.DashboardComponent {
  74. if !acl.IsAllowedViewDashboard(rr.cfg, rr.AuthenticatedUser, root) {
  75. return nil
  76. }
  77. return searchDirectoryInComponent(root, title)
  78. }
  79. func searchDirectoryInComponent(component *config.DashboardComponent, title string) *config.DashboardComponent {
  80. if isMatchingDirectory(component, title) {
  81. return component
  82. }
  83. return searchDirectoryInSubcomponents(component.Contents, title)
  84. }
  85. func isMatchingDirectory(component *config.DashboardComponent, title string) bool {
  86. return component.Title == title && len(component.Contents) > 0 && component.Type != "fieldset"
  87. }
  88. func searchDirectoryInSubcomponents(contents []*config.DashboardComponent, title string) *config.DashboardComponent {
  89. for _, subitem := range contents {
  90. if found := searchDirectoryInComponent(subitem, title); found != nil {
  91. return found
  92. }
  93. }
  94. return nil
  95. }
  96. func logEmptyDashboard(dashboardTitle, username string) {
  97. log.WithFields(log.Fields{
  98. "dashboard": dashboardTitle,
  99. "username": username,
  100. }).Debugf("Dashboard has no readable contents, so it will not be visible in the web ui")
  101. }
  102. func buildDashboardFromConfig(dashboard *config.DashboardComponent, rr *DashboardRenderRequest) *apiv1.Dashboard {
  103. return buildDashboardFromConfigWithEntity(dashboard, rr, nil)
  104. }
  105. func buildDashboardFromConfigWithEntity(dashboard *config.DashboardComponent, rr *DashboardRenderRequest, entity *entities.Entity) *apiv1.Dashboard {
  106. contents, root := getDashboardComponentContentsWithEntity(dashboard, rr, entity)
  107. return &apiv1.Dashboard{
  108. Title: dashboard.Title,
  109. Contents: orderTopLevelDashboardComponents(removeNulls(contents), root),
  110. }
  111. }
  112. func buildDefaultDashboard(rr *DashboardRenderRequest) *apiv1.Dashboard {
  113. db := &apiv1.Dashboard{
  114. Title: "Actions",
  115. Contents: make([]*apiv1.DashboardComponent, 0),
  116. }
  117. fieldset := &apiv1.DashboardComponent{
  118. Type: "fieldset",
  119. Title: "Actions",
  120. Contents: make([]*apiv1.DashboardComponent, 0),
  121. }
  122. rr.ex.MapActionBindingsLock.RLock()
  123. for _, binding := range rr.ex.MapActionBindings {
  124. if !defaultBindingEligibleForDashboard(rr, binding) {
  125. continue
  126. }
  127. if comp := defaultDashboardComponentFromBinding(binding, rr); comp != nil {
  128. fieldset.Contents = append(fieldset.Contents, comp)
  129. }
  130. }
  131. rr.ex.MapActionBindingsLock.RUnlock()
  132. if len(fieldset.Contents) > 0 {
  133. fieldset.Contents = sortDashboardComponents(fieldset.Contents)
  134. db.Contents = append(db.Contents, fieldset)
  135. }
  136. return db
  137. }
  138. func defaultBindingEligibleForDashboard(rr *DashboardRenderRequest, binding *executor.ActionBinding) bool {
  139. return defaultBindingWellFormed(binding) &&
  140. !binding.IsOnConfiguredDashboard() &&
  141. acl.IsAllowedView(rr.cfg, rr.AuthenticatedUser, binding.Action) &&
  142. defaultBindingEntityTypeAllowed(rr, binding)
  143. }
  144. func defaultBindingWellFormed(binding *executor.ActionBinding) bool {
  145. return binding != nil && binding.Action != nil && !binding.Action.Hidden
  146. }
  147. func defaultBindingEntityTypeAllowed(rr *DashboardRenderRequest, binding *executor.ActionBinding) bool {
  148. if binding.Entity == nil || binding.Action.Entity == "" {
  149. return true
  150. }
  151. return acl.IsAllowedViewEntityType(rr.cfg, rr.AuthenticatedUser, entityFileForType(rr.cfg, binding.Action.Entity))
  152. }
  153. func defaultDashboardComponentFromBinding(binding *executor.ActionBinding, rr *DashboardRenderRequest) *apiv1.DashboardComponent {
  154. action := buildAction(binding, rr)
  155. if action == nil {
  156. return nil
  157. }
  158. comp := &apiv1.DashboardComponent{
  159. Type: "link",
  160. Title: action.Title,
  161. Icon: action.Icon,
  162. Action: action,
  163. }
  164. if binding.Entity != nil {
  165. comp.EntityKey = binding.Entity.UniqueKey
  166. }
  167. return comp
  168. }
  169. func entityKeyLess(a, b string) bool {
  170. ai, errA := strconv.ParseInt(a, 10, 64)
  171. bi, errB := strconv.ParseInt(b, 10, 64)
  172. if errA == nil && errB == nil {
  173. return ai < bi
  174. }
  175. return a < b
  176. }
  177. //gocyclo:ignore
  178. func sortDashboardComponents(components []*apiv1.DashboardComponent) []*apiv1.DashboardComponent {
  179. sort.Slice(components, func(i, j int) bool {
  180. if components[i].Action == nil || components[j].Action == nil {
  181. if components[i].EntityKey != "" && components[j].EntityKey != "" &&
  182. components[i].EntityKey != components[j].EntityKey {
  183. return entityKeyLess(components[i].EntityKey, components[j].EntityKey)
  184. }
  185. return components[i].Title < components[j].Title
  186. }
  187. if components[i].Action.Order != components[j].Action.Order {
  188. return components[i].Action.Order < components[j].Action.Order
  189. }
  190. if components[i].EntityKey != components[j].EntityKey {
  191. return entityKeyLess(components[i].EntityKey, components[j].EntityKey)
  192. }
  193. return components[i].Action.Title < components[j].Action.Title
  194. })
  195. return components
  196. }
  197. func removeNulls(components []*apiv1.DashboardComponent) []*apiv1.DashboardComponent {
  198. ret := make([]*apiv1.DashboardComponent, 0)
  199. for _, component := range components {
  200. if component == nil {
  201. continue
  202. }
  203. ret = append(ret, component)
  204. }
  205. return ret
  206. }
  207. func isNonEntityFieldset(component *apiv1.DashboardComponent) bool {
  208. return component != nil && component.Type == "fieldset" && component.EntityType == ""
  209. }
  210. func isRegularFieldset(component *apiv1.DashboardComponent, root *apiv1.DashboardComponent) bool {
  211. if !isNonEntityFieldset(component) {
  212. return false
  213. }
  214. return root == nil || component != root
  215. }
  216. func partitionTopLevelComponents(components []*apiv1.DashboardComponent, root *apiv1.DashboardComponent) (regular, sortables []*apiv1.DashboardComponent, isRegular []bool) {
  217. regular = make([]*apiv1.DashboardComponent, 0)
  218. sortables = make([]*apiv1.DashboardComponent, 0)
  219. isRegular = make([]bool, len(components))
  220. for i, c := range components {
  221. anchor := isRegularFieldset(c, root)
  222. isRegular[i] = anchor
  223. if anchor {
  224. regular = append(regular, c)
  225. } else {
  226. sortables = append(sortables, c)
  227. }
  228. }
  229. return regular, sortables, isRegular
  230. }
  231. func mergeOrderedTopLevelComponents(regular, sortables []*apiv1.DashboardComponent, isRegular []bool) []*apiv1.DashboardComponent {
  232. out := make([]*apiv1.DashboardComponent, 0, len(isRegular))
  233. regIdx, sortIdx := 0, 0
  234. for _, anchor := range isRegular {
  235. if anchor {
  236. out = append(out, regular[regIdx])
  237. regIdx++
  238. } else {
  239. out = append(out, sortables[sortIdx])
  240. sortIdx++
  241. }
  242. }
  243. return out
  244. }
  245. func orderTopLevelDashboardComponents(components []*apiv1.DashboardComponent, root *apiv1.DashboardComponent) []*apiv1.DashboardComponent {
  246. if len(components) == 0 {
  247. return components
  248. }
  249. regular, sortables, isRegular := partitionTopLevelComponents(components, root)
  250. sortDashboardComponents(sortables)
  251. return mergeOrderedTopLevelComponents(regular, sortables, isRegular)
  252. }
  253. func getDashboardComponentContentsWithEntity(dashboard *config.DashboardComponent, rr *DashboardRenderRequest, entity *entities.Entity) ([]*apiv1.DashboardComponent, *apiv1.DashboardComponent) {
  254. ret := make([]*apiv1.DashboardComponent, 0)
  255. rootFieldset := createRootFieldset()
  256. for _, subitem := range dashboard.Contents {
  257. processDashboardSubitemWithEntity(subitem, rr, &ret, rootFieldset, entity)
  258. }
  259. if len(rootFieldset.Contents) > 0 {
  260. ret = append(ret, rootFieldset)
  261. return ret, rootFieldset
  262. }
  263. return ret, nil
  264. }
  265. func createRootFieldset() *apiv1.DashboardComponent {
  266. return &apiv1.DashboardComponent{
  267. Type: "fieldset",
  268. Title: "Actions",
  269. Contents: make([]*apiv1.DashboardComponent, 0),
  270. }
  271. }
  272. func appendComponentIfNotNil(components *[]*apiv1.DashboardComponent, comp *apiv1.DashboardComponent) {
  273. if comp != nil {
  274. *components = append(*components, comp)
  275. }
  276. }
  277. func getDashboardComponentOrNil(subitem *config.DashboardComponent, rr *DashboardRenderRequest, entity *entities.Entity) *apiv1.DashboardComponent {
  278. if len(subitem.Contents) == 0 && rr.findActionForEntity(subitem.Title, entity) == nil {
  279. if !isAllowedType(subitem.Type) {
  280. return nil
  281. }
  282. }
  283. return buildDashboardComponentSimpleWithEntity(subitem, rr, entity)
  284. }
  285. func processDashboardSubitemWithEntity(subitem *config.DashboardComponent, rr *DashboardRenderRequest, ret *[]*apiv1.DashboardComponent, rootFieldset *apiv1.DashboardComponent, entity *entities.Entity) {
  286. if subitem.Type != "fieldset" {
  287. appendComponentIfNotNil(&rootFieldset.Contents, getDashboardComponentOrNil(subitem, rr, entity))
  288. return
  289. }
  290. if subitem.Entity != "" {
  291. *ret = append(*ret, buildEntityFieldsets(subitem.Entity, subitem, rr)...)
  292. } else {
  293. appendComponentIfNotNil(ret, getDashboardComponentOrNil(subitem, rr, entity))
  294. }
  295. }
  296. func buildDashboardComponentSimpleWithEntity(subitem *config.DashboardComponent, rr *DashboardRenderRequest, entity *entities.Entity) *apiv1.DashboardComponent {
  297. var contents []*apiv1.DashboardComponent
  298. if len(subitem.Contents) > 0 {
  299. contents, _ = getDashboardComponentContentsWithEntity(subitem, rr, entity)
  300. }
  301. action := rr.findActionForEntity(subitem.Title, entity)
  302. componentType := getDashboardComponentType(subitem, action)
  303. title := subitem.Title
  304. if entity != nil {
  305. title = tpl.ParseTemplateOfActionBeforeExec(subitem.Title, entity)
  306. }
  307. newitem := &apiv1.DashboardComponent{
  308. Title: title,
  309. Type: componentType,
  310. Contents: contents,
  311. Icon: getDashboardComponentIcon(subitem, rr.cfg),
  312. CssClass: subitem.CssClass,
  313. Action: action,
  314. }
  315. return newitem
  316. }
  317. func getDashboardComponentIcon(item *config.DashboardComponent, cfg *config.Config) string {
  318. if item.Icon == "" {
  319. return cfg.DefaultIconForDirectories
  320. }
  321. return item.Icon
  322. }
  323. func getDashboardComponentType(item *config.DashboardComponent, action *apiv1.Action) string {
  324. if hasContents(item) {
  325. return getTypeForComponentWithContents(item)
  326. }
  327. if isAllowedType(item.Type) {
  328. return item.Type
  329. }
  330. return getDefaultType(action)
  331. }
  332. func hasContents(item *config.DashboardComponent) bool {
  333. return len(item.Contents) > 0
  334. }
  335. func getTypeForComponentWithContents(item *config.DashboardComponent) string {
  336. if item.Type != "fieldset" {
  337. return "directory"
  338. }
  339. return "fieldset"
  340. }
  341. func isAllowedType(itemType string) bool {
  342. allowedTypes := []string{
  343. "stdout-most-recent-execution",
  344. "display",
  345. }
  346. return slices.Contains(allowedTypes, itemType)
  347. }
  348. func getDefaultType(action *apiv1.Action) string {
  349. if action == nil {
  350. return "display"
  351. }
  352. return "link"
  353. }