Răsfoiți Sursa

fix: add support for entities in checklists

jamesread 10 ore în urmă
părinte
comite
b18518ebea

+ 25 - 0
docs/modules/ROOT/pages/args/input_checklist.adoc

@@ -53,3 +53,28 @@ arguments:
 Choice `value` fields must not contain commas, because commas are used to join multiple selections together.
 
 Each `title` is shown in the web interface. If a submitted segment matches a choice `title`, OliveTin maps it to the corresponding `value` before validation, matching the behaviour of xref:args/input_checkbox.adoc[checkbox] arguments with choices.
+
+== Using Entities
+
+Checklist options can be generated from entities, using the same pattern as xref:args/input_dropdown.adoc#args-dropdown-entities[entity-backed dropdowns]. Define one choice template and set `entity` to the entity type name:
+
+[source,yaml]
+----
+actions:
+  - title: Restart selected containers
+    shell: 'docker restart {{ containers }}'
+    arguments:
+      - name: containers
+        title: Containers to restart
+        type: checklist
+        entity: container
+        choices:
+          - value: '{{ container.Names }}'
+            title: '{{ container.Names }}'
+
+entities:
+  - file: entities/containers.json
+    name: container
+----
+
+OliveTin expands the template once per entity instance and renders each result as a checkbox. Selected values are still passed as a comma-separated string.

+ 24 - 4
integration-tests/tests/checklist/checklist.mjs

@@ -13,9 +13,9 @@ import {
   waitForExecutionComplete,
 } from '../../lib/elements.js'
 
-async function openChecklistArgumentForm() {
+async function openChecklistArgumentForm(actionTitle = 'Test checklist argument') {
   await getRootAndWait()
-  const btn = await getActionButton(webdriver, 'Test checklist argument')
+  const btn = await getActionButton(webdriver, actionTitle)
   await btn.click()
 
   await waitForArgumentFormPage()
@@ -52,9 +52,9 @@ async function pollTerminal(matcher, timeoutMs = DEFAULT_UI_WAIT_MS) {
   )
 }
 
-async function waitForTerminalOutput(expectedValue) {
+async function waitForTerminalOutput(expectedValue, label = 'Selected segments') {
   await pollTerminal(
-    (output) => output.includes(`Selected segments: ${expectedValue}`),
+    (output) => output.includes(`${label}: ${expectedValue}`),
     DEFAULT_UI_WAIT_MS
   )
 }
@@ -145,4 +145,24 @@ describe('config: checklist', function () {
     await waitForExecutionComplete()
     await waitForTerminalOutput('kitchen,bedroom,hallway')
   })
+
+  it('Checklist entity argument renders choices from entities', async function () {
+    await openChecklistArgumentForm('Test checklist entity argument')
+
+    const checkboxes = await webdriver.findElements(
+      By.css('.choice-checklist-item input[type="checkbox"]')
+    )
+    expect(checkboxes).to.have.length(2)
+
+    const labels = await webdriver.findElements(By.css('.choice-checklist-item span'))
+    expect(await labels[0].getText()).to.equal('attic')
+    expect(await labels[1].getText()).to.equal('basement')
+
+    await checkboxes[0].click()
+
+    await submitChecklistForm()
+    await waitForLogsPage()
+    await waitForExecutionComplete()
+    await waitForTerminalOutput('attic', 'Selected rooms')
+  })
 })

+ 16 - 0
integration-tests/tests/checklist/config.yaml

@@ -5,6 +5,10 @@ logLevel: "DEBUG"
 checkForUpdates: false
 defaultPopupOnStart: execution-dialog
 
+entities:
+  - file: entities/rooms.yaml
+    name: room
+
 actions:
   - title: Test checklist argument
     shell: "echo 'Selected segments: {{ segments }}'"
@@ -22,3 +26,15 @@ actions:
           - title: Hallway
             value: hallway
         default: kitchen,bedroom
+
+  - title: Test checklist entity argument
+    shell: "echo 'Selected rooms: {{ rooms }}'"
+    icon: ping
+    arguments:
+      - name: rooms
+        title: Rooms to include
+        type: checklist
+        entity: room
+        choices:
+          - title: '{{ room.hostname }}'
+            value: '{{ room.hostname }}'

+ 2 - 0
integration-tests/tests/checklist/entities/rooms.yaml

@@ -0,0 +1,2 @@
+- hostname: attic
+- hostname: basement

+ 20 - 0
service/internal/api/api_test.go

@@ -919,3 +919,23 @@ func TestBuildActionIncludesGroups(t *testing.T) {
 	assert.Equal(t, "missing", actionResult.Groups[1].Name)
 	assert.Equal(t, int32(0), actionResult.Groups[1].MaxConcurrent)
 }
+
+func TestBuildChoicesExpandsChecklistEntityChoices(t *testing.T) {
+	entities.AddEntity("room", "0", map[string]any{"hostname": "attic"})
+	entities.AddEntity("room", "1", map[string]any{"hostname": "basement"})
+
+	arg := config.ActionArgument{
+		Type:   "checklist",
+		Entity: "room",
+		Choices: []config.ActionArgumentChoice{
+			{Title: "{{ room.hostname }}", Value: "{{ room.hostname }}"},
+		},
+	}
+
+	choices := buildChoices(arg)
+	require.Len(t, choices, 2)
+	assert.Equal(t, "attic", choices[0].Value)
+	assert.Equal(t, "attic", choices[0].Title)
+	assert.Equal(t, "basement", choices[1].Value)
+	assert.Equal(t, "basement", choices[1].Title)
+}

+ 16 - 0
service/internal/config/sanitize.go

@@ -498,6 +498,11 @@ func (arg *ActionArgument) sanitizeChecklist() {
 		return
 	}
 
+	arg.warnMissingChecklistChoices()
+	arg.warnInvalidChecklistEntityTemplate()
+}
+
+func (arg *ActionArgument) warnMissingChecklistChoices() {
 	if len(arg.Choices) == 0 {
 		log.WithFields(log.Fields{
 			"arg": arg.Name,
@@ -505,6 +510,17 @@ func (arg *ActionArgument) sanitizeChecklist() {
 	}
 }
 
+func (arg *ActionArgument) warnInvalidChecklistEntityTemplate() {
+	if arg.Entity == "" || len(arg.Choices) == 1 {
+		return
+	}
+
+	log.WithFields(log.Fields{
+		"arg":    arg.Name,
+		"entity": arg.Entity,
+	}).Warn("Checklist argument with entity should define exactly one choice template")
+}
+
 func (arg *ActionArgument) sanitizeNoType() {
 	if len(arg.Choices) == 0 && arg.Type == "" {
 		log.WithFields(log.Fields{

+ 37 - 0
service/internal/executor/arguments.go

@@ -505,6 +505,43 @@ func mangleChecklistSegment(arg *config.ActionArgument, segment string, actionTi
 }
 
 func mangleChoiceSegment(arg *config.ActionArgument, value string, actionTitle string) string {
+	if mapped, ok := mangleChoiceSegmentEntity(arg, value, actionTitle); ok {
+		return mapped
+	}
+
+	return mangleChoiceSegmentStatic(arg, value, actionTitle)
+}
+
+func mangleChoiceSegmentEntity(arg *config.ActionArgument, value string, actionTitle string) (string, bool) {
+	if arg.Entity == "" || len(arg.Choices) == 0 {
+		return value, false
+	}
+
+	return mangleEntityTemplateChoiceSegment(arg.Choices[0], arg.Entity, arg.Name, value, actionTitle)
+}
+
+func mangleEntityTemplateChoiceSegment(templateChoice config.ActionArgumentChoice, entityName string, argName string, value string, actionTitle string) (string, bool) {
+	for _, ent := range entities.GetEntityInstancesOrdered(entityName) {
+		expandedTitle := tpl.ParseTemplateOfActionBeforeExec(templateChoice.Title, ent)
+		if value != expandedTitle {
+			continue
+		}
+
+		expandedValue := tpl.ParseTemplateOfActionBeforeExec(templateChoice.Value, ent)
+		log.WithFields(log.Fields{
+			"arg":         argName,
+			"oldValue":    value,
+			"newValue":    expandedValue,
+			"actionTitle": actionTitle,
+		}).Infof("Mangled entity choice segment")
+
+		return expandedValue, true
+	}
+
+	return value, false
+}
+
+func mangleChoiceSegmentStatic(arg *config.ActionArgument, value string, actionTitle string) string {
 	for _, choice := range arg.Choices {
 		if value == choice.Title {
 			log.WithFields(log.Fields{

+ 49 - 0
service/internal/executor/arguments_test.go

@@ -202,6 +202,55 @@ func TestMangleArgumentValueChecklist(t *testing.T) {
 	assert.Equal(t, "documents,photos", out)
 }
 
+func checklistEntityTestArg() config.ActionArgument {
+	return config.ActionArgument{
+		Name:   "rooms",
+		Type:   "checklist",
+		Entity: "room",
+		Choices: []config.ActionArgumentChoice{
+			{Title: "{{ room.hostname }}", Value: "{{ room.hostname }}"},
+		},
+	}
+}
+
+func TestValidateArgumentChecklistEntitySelections(t *testing.T) {
+	log.SetLevel(log.PanicLevel)
+
+	entities.AddEntity("room", "0", map[string]any{"hostname": "attic"})
+	entities.AddEntity("room", "1", map[string]any{"hostname": "basement"})
+
+	arg := checklistEntityTestArg()
+	action := config.Action{Title: "Test checklist entity"}
+
+	err := ValidateArgument(&arg, "attic", &action)
+	assert.Nil(t, err)
+
+	err = ValidateArgument(&arg, "attic,basement", &action)
+	assert.Nil(t, err)
+
+	err = ValidateArgument(&arg, "attic,unknown", &action)
+	assert.NotNil(t, err)
+}
+
+func TestMangleArgumentValueChecklistEntityTitles(t *testing.T) {
+	log.SetLevel(log.PanicLevel)
+
+	entities.AddEntity("room", "0", map[string]any{"hostname": "attic"})
+	entities.AddEntity("room", "1", map[string]any{"hostname": "basement"})
+
+	arg := config.ActionArgument{
+		Name:   "rooms",
+		Type:   "checklist",
+		Entity: "room",
+		Choices: []config.ActionArgumentChoice{
+			{Title: "{{ room.hostname }} room", Value: "{{ room.hostname }}"},
+		},
+	}
+
+	out := MangleArgumentValue(&arg, "attic room,basement room", "Test checklist entity titles")
+	assert.Equal(t, "attic,basement", out)
+}
+
 func TestParseActionArgumentsChecklistEmptySelection(t *testing.T) {
 	req := newExecRequest()
 	req.Binding.Action = &config.Action{