dashboards.go 12 KB

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