feat: Dashboard configs can now self-contain their entities, and actions all in one neat file

This commit is contained in:
jamesread 2025-12-01 16:15:08 +00:00
parent e58677e12c
commit 32fea4ec30
11 changed files with 258 additions and 17 deletions

View File

@ -3,14 +3,10 @@ import fs from 'fs'
import { expect } from 'chai'
import { Condition } from 'selenium-webdriver'
export async function getActionButtons (dashboardTitle = null) {
// New Vue UI renders action buttons using ActionButton.vue structure
// Each button lives under a container with class .action-button
if (dashboardTitle == null) {
return await webdriver.findElements(By.css('.action-button button'))
} else {
return await webdriver.findElements(By.css('section[title="' + dashboardTitle + '"] .action-button button'))
}
export async function getActionButtons () {
// Currently, only the active dashboard's contents are rendered,
// so we don't need to scope the selector by dashboard title.
return await webdriver.findElements(By.css('.action-button button'))
}
export async function getExecutionDialogOutput() {

View File

@ -0,0 +1,7 @@
include: dashboards.d
actions:
- title: Base Action
shell: echo "base"
icon: ping

View File

@ -0,0 +1,12 @@
dashboards:
- title: First Dashboard
contents:
- type: action
inlineAction:
title: First Action
shell: echo "First Dashboard, First Action!"
- type: action
inlineAction:
title: Second Action
shell: echo "First Dashboard, Second Action!"

View File

@ -0,0 +1,30 @@
entities:
- file: entities/person.yaml
name: person
dashboards:
- title: People
contents:
- type: action
inlineAction:
title: Third Action
shell: echo "First Dashboard, Third Action!"
- type: action
inlineAction:
title: Forth Action
shell: echo "First Dashboard, Forth Action!"
- title: "Person: {{ person.name }}"
type: fieldset
entity: person
contents:
- type: display
title: " {{ person.name }} is a person"
cssStyle:
background-color: red;
- title: "Greet {{ person.name }}"
inlineAction:
shell: echo "Hello, {{ person.name }}!"
icon: ping

View File

@ -0,0 +1,4 @@
- name: Alice
- name: Bob

View File

@ -0,0 +1,108 @@
import { describe, it, before, after } from 'mocha'
import { expect, assert } from 'chai'
import { By } from 'selenium-webdriver'
import {
getRootAndWait,
getActionButtons,
getNavigationLinks,
openSidebar,
takeScreenshotOnFailure,
} from '../../lib/elements.js'
describe('config: multi-dashboard-includes', function () {
this.timeout(30000)
before(async function () {
await runner.start('multi-dashboard-includes')
})
after(async () => {
await runner.stop()
})
afterEach(function () {
takeScreenshotOnFailure(this.currentTest, webdriver);
});
async function clickNavigationLinkByTitle (title) {
await openSidebar()
const navigationLinks = await getNavigationLinks()
assert.isAbove(navigationLinks.length, 0, 'Expected at least one navigation link')
const matching = []
for (const li of navigationLinks) {
const liTitle = await li.getAttribute('title')
if (liTitle === title) {
matching.push(li)
}
}
assert.strictEqual(matching.length, 1, `Expected exactly one navigation link with title "${title}"`)
await matching[0].click()
}
async function getActionTitlesOnDashboard (dashboardTitle = null) {
const buttons = await getActionButtons(dashboardTitle)
const titles = []
for (const button of buttons) {
titles.push(await button.getAttribute('title'))
}
return titles
}
it('Should expose both dashboards from included files in navigation', async function () {
await getRootAndWait()
await openSidebar()
const navigationLinks = await getNavigationLinks()
assert.isAbove(navigationLinks.length, 0, 'Expected navigation to have at least one link')
const titles = []
for (const li of navigationLinks) {
titles.push(await li.getAttribute('title'))
}
expect(titles).to.include('First Dashboard')
expect(titles).to.include('Second Dashboard')
})
it('First Dashboard shows First and Second inline actions from include', async function () {
await getRootAndWait()
// Navigate to "First Dashboard"
await clickNavigationLinkByTitle('First Dashboard')
// Buttons on this dashboard only
const titles = await getActionTitlesOnDashboard('First Dashboard')
expect(titles).to.include('First Action')
expect(titles).to.include('Second Action')
// Ensure actions from the second dashboard are not rendered here
expect(titles).to.not.include('Third Action')
expect(titles).to.not.include('Forth Action')
})
it('Second Dashboard shows Third and Forth inline actions from include', async function () {
await getRootAndWait()
// Navigate to "Second Dashboard"
await clickNavigationLinkByTitle('Second Dashboard')
const titles = await getActionTitlesOnDashboard('Second Dashboard')
expect(titles).to.include('Third Action')
expect(titles).to.include('Forth Action')
// Ensure actions from the first dashboard are not rendered here
expect(titles).to.not.include('First Action')
expect(titles).to.not.include('Second Action')
})
})

View File

@ -84,7 +84,16 @@ func isLinkType(itemType string) bool {
func cloneLinkItem(subitem *config.DashboardComponent, ent *entities.Entity, clone *apiv1.DashboardComponent, rr *DashboardRenderRequest) *apiv1.DashboardComponent {
clone.Type = "link"
clone.Title = entities.ParseTemplateWith(subitem.Title, ent)
clone.Action = rr.findActionForEntity(subitem.Title, ent)
// Prefer an entity-specific action when available, but fall back to a
// non-entity-scoped action with the same title. This allows inline actions
// defined inside entity dashboards to work without requiring an explicit
// entity binding.
action := rr.findActionForEntity(subitem.Title, ent)
if action == nil {
action = rr.findAction(subitem.Title)
}
clone.Action = action
return clone
}

View File

@ -192,10 +192,59 @@ func mergeActionsFromSource(srcActions interface{}, dest map[string]interface{})
}
}
// mergeDashboardsWhenBothExist merges dashboards when both src and dest have dashboards.
func mergeDashboardsWhenBothExist(srcDashboards interface{}, destDashboards interface{}, dest map[string]interface{}) {
srcSlice, ok1 := srcDashboards.([]interface{})
destSlice, ok2 := destDashboards.([]interface{})
if ok1 && ok2 {
dest["dashboards"] = append(destSlice, srcSlice...)
} else {
dest["dashboards"] = srcDashboards
}
}
// mergeDashboardsFromSource merges dashboards from source into destination.
func mergeDashboardsFromSource(srcDashboards interface{}, dest map[string]interface{}) {
if destDashboards, ok := dest["dashboards"]; ok {
mergeDashboardsWhenBothExist(srcDashboards, destDashboards, dest)
} else {
dest["dashboards"] = srcDashboards
}
}
// mergeEntitiesWhenBothExist merges entities when both src and dest have entities.
func mergeEntitiesWhenBothExist(srcEntities interface{}, destEntities interface{}, dest map[string]interface{}) {
srcSlice, ok1 := srcEntities.([]interface{})
destSlice, ok2 := destEntities.([]interface{})
if ok1 && ok2 {
dest["entities"] = append(destSlice, srcSlice...)
} else {
dest["entities"] = srcEntities
}
}
// mergeEntitiesFromSource merges entities from source into destination.
func mergeEntitiesFromSource(srcEntities interface{}, dest map[string]interface{}) {
if destEntities, ok := dest["entities"]; ok {
mergeEntitiesWhenBothExist(srcEntities, destEntities, dest)
} else {
dest["entities"] = srcEntities
}
}
func mergeFunc(src map[string]interface{}, dest map[string]interface{}) error {
if srcActions, ok := src["actions"]; ok {
mergeActionsFromSource(srcActions, dest)
}
if srcDashboards, ok := src["dashboards"]; ok {
mergeDashboardsFromSource(srcDashboards, dest)
}
if srcEntities, ok := src["entities"]; ok {
mergeEntitiesFromSource(srcEntities, dest)
}
return nil
}

View File

@ -42,17 +42,33 @@ func (cfg *Config) sanitizeInlineAction(component *DashboardComponent) {
return
}
if component.InlineAction.Title == "" {
component.InlineAction.Title = component.Title
sanitizeInlineActionTitles(component)
if component.Entity != "" && component.InlineAction.Entity == "" {
component.InlineAction.Entity = component.Entity
}
component.InlineAction.sanitize(cfg)
if cfg.inlineActionExists(component.InlineAction) {
cfg.addInlineActionIfNotExists(component.InlineAction)
}
func (cfg *Config) addInlineActionIfNotExists(action *Action) {
if cfg.inlineActionExists(action) {
return
}
cfg.Actions = append(cfg.Actions, component.InlineAction)
cfg.Actions = append(cfg.Actions, action)
}
func sanitizeInlineActionTitles(component *DashboardComponent) {
if component.InlineAction.Title == "" {
component.InlineAction.Title = component.Title
}
if component.Title == "" {
component.Title = component.InlineAction.Title
}
}
func (cfg *Config) inlineActionExists(action *Action) bool {
@ -93,6 +109,10 @@ func (cfg *Config) inlineActionIDExists(action *Action) bool {
func (cfg *Config) sanitizeChildDashboardComponents(component *DashboardComponent) {
for _, child := range component.Contents {
if child.Entity == "" {
child.Entity = component.Entity
}
cfg.sanitizeDashboardComponentForInlineActions(child)
}
}

View File

@ -30,7 +30,7 @@ func migrateLegacyEntityProperties(rawShellCommand string) string {
log.WithFields(log.Fields{
"old": entityName,
"new": ".CurrentEntity",
}).Warnf("Legacy entity variable name found, changing to CurrentEntity")
}).Debugf("Legacy entity variable name found, changing to CurrentEntity")
continue
}
@ -42,7 +42,7 @@ func migrateLegacyEntityProperties(rawShellCommand string) string {
log.WithFields(log.Fields{
"old": argName,
"new": ".CurrentEntity." + argName,
}).Warnf("Legacy variable name found, changing to CurrentEntity")
}).Debugf("Legacy variable name found, changing to CurrentEntity")
}
}
@ -59,7 +59,7 @@ func migrateLegacyArgumentNames(rawShellCommand string) string {
log.WithFields(log.Fields{
"old": argName,
"new": ".Arguments." + argName,
}).Warnf("Legacy variable name found, changing to Argument")
}).Debugf("Legacy variable name found, changing to Argument")
rawShellCommand = strings.ReplaceAll(rawShellCommand, argName, ".Arguments."+argName)
}

View File

@ -81,7 +81,13 @@ func findDashboardActionTitles(req *RebuildActionMapRequest) {
//gocyclo:ignore
func recurseDashboardForActionTitles(component *config.DashboardComponent, req *RebuildActionMapRequest) {
for _, sub := range component.Contents {
if sub.Type == "link" || sub.Type == "" {
if sub.InlineAction != nil {
title := sub.Title
if title == "" {
title = sub.InlineAction.Title
}
req.DashboardActionTitles = append(req.DashboardActionTitles, title)
} else if sub.Type == "link" || sub.Type == "" {
req.DashboardActionTitles = append(req.DashboardActionTitles, sub.Title)
}