Merge branch 'next' into dependabot/go_modules/service/next/github.com/bufbuild/buf-1.61.0

This commit is contained in:
James Read 2025-12-01 22:18:25 +00:00 committed by GitHub
commit 75a696d8c0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 656 additions and 145 deletions

View File

@ -151,6 +151,11 @@ export declare type Entity = Message<"olivetin.api.v1.Entity"> & {
* @generated from field: repeated string directories = 4; * @generated from field: repeated string directories = 4;
*/ */
directories: string[]; directories: string[];
/**
* @generated from field: map<string, string> fields = 5;
*/
fields: { [key: string]: string };
}; };
/** /**

File diff suppressed because one or more lines are too long

View File

@ -21,10 +21,14 @@
<h3>Used on Dashboards:</h3> <h3>Used on Dashboards:</h3>
<ul> <ul>
<li v-for="dash in def.usedOnDashboards"> <li v-for="dash in filteredDashboards(def.usedOnDashboards)" :key="dash">
<router-link :to="{ name: 'Dashboard', params: { title: dash } }"> <template v-if="isEntityDirectory(dash)">
{{ dash }} {{ getDashboardTitle(dash) }} <span class="entity-directory-label">[Entity Directory]</span>
</template>
<router-link v-else-if="!dash.includes('entity:')" :to="{ name: 'Dashboard', params: { title: getDashboardTitle(dash) } }">
{{ getDashboardTitle(dash) }}
</router-link> </router-link>
<span v-else>{{ dash }}</span>
</li> </li>
</ul> </ul>
</div> </div>
@ -44,6 +48,21 @@
entityDefinitions.value = ret.entityDefinitions entityDefinitions.value = ret.entityDefinitions
} }
function filteredDashboards(dashboards) {
return dashboards.filter(d => d && !d.includes('{{'))
}
function isEntityDirectory(dashboardTitle) {
return dashboardTitle.endsWith(' [Entity Directory]')
}
function getDashboardTitle(dashboardTitle) {
if (isEntityDirectory(dashboardTitle)) {
return dashboardTitle.slice(0, -' [Entity Directory]'.length)
}
return dashboardTitle
}
onMounted(() => { onMounted(() => {
fetchEntities() fetchEntities()
}) })

View File

@ -19,15 +19,21 @@
</dd> </dd>
<dt v-if="entityDetails.title">Title</dt> <dt v-if="entityDetails.title">Title</dt>
<dd v-if="entityDetails.title">{{ entityDetails.title }}</dd> <dd v-if="entityDetails.title">{{ entityDetails.title }}</dd>
<template v-if="entityDetails.fields">
<template v-for="(value, key) in entityDetails.fields" :key="key">
<dt>{{ key }}</dt>
<dd>{{ value }}</dd>
</template>
</template>
</dl> </dl>
<p v-if="!entityDetails.title">No details available for this entity.</p> <p v-if="!entityDetails.title && (!entityDetails.fields || Object.keys(entityDetails.fields).length === 0)">No details available for this entity.</p>
<hr /> <hr />
<h3>Dashboard Entity Directories</h3> <h3>Dashboard Entity Directories</h3>
<div v-if="entityDetails.directories && entityDetails.directories.length > 0" class="directories-section"> <div v-if="filteredDirectories.length > 0" class="directories-section">
<ul class="directory-list"> <ul class="directory-list">
<li v-for="directory in entityDetails.directories" :key="directory"> <li v-for="(directory, idx) in filteredDirectories" :key="idx">
<router-link <router-link
:to="{ :to="{
name: 'Dashboard', name: 'Dashboard',
@ -50,7 +56,7 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { HugeiconsIcon } from '@hugeicons/vue' import { HugeiconsIcon } from '@hugeicons/vue'
import { ArrowLeftIcon } from '@hugeicons/core-free-icons' import { ArrowLeftIcon } from '@hugeicons/core-free-icons'
@ -64,6 +70,13 @@
entityKey: String entityKey: String
}) })
const filteredDirectories = computed(() => {
if (!entityDetails.value?.directories) {
return []
}
return entityDetails.value.directories.filter(d => d)
})
function goBack() { function goBack() {
router.push({ name: 'Entities' }) router.push({ name: 'Entities' })
} }

View File

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

View File

@ -0,0 +1,21 @@
#
# Integration Test Config: inline dashboard actions
#
listenAddressSingleHTTPFrontend: 0.0.0.0:1337
logLevel: "DEBUG"
checkForUpdates: false
# No top-level actions – actions will be defined inline on dashboard components.
actions: []
dashboards:
- title: Inline Dashboard
contents:
- title: Inline Dashboard Action
inlineAction:
shell: date
icon: clock

View File

@ -0,0 +1,38 @@
import { describe, it, before, after } from 'mocha'
import { assert } from 'chai'
import {
getRootAndWait,
getActionButtons,
takeScreenshotOnFailure,
} from '../../lib/elements.js'
describe('config: inlineActions', function () {
before(async function () {
await runner.start('inlineActions')
})
after(async () => {
await runner.stop()
})
afterEach(function () {
takeScreenshotOnFailure(this.currentTest, webdriver);
});
it('Inline dashboard actions are rendered as clickable buttons', async function () {
await getRootAndWait()
const buttons = await getActionButtons()
assert.isArray(buttons, 'Action buttons should be an array')
assert.isAtLeast(buttons.length, 1, 'There should be at least one action button')
const texts = await Promise.all(buttons.map(b => b.getText()))
const combinedText = texts.join(' ')
assert.include(
combinedText,
'Inline Dashboard Action',
'Inline dashboard action should be rendered as a button'
)
})
})

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: Second Dashboard
contents:
- type: action
inlineAction:
title: Third Action
shell: echo "Second Dashboard, Third Action!"
- type: action
inlineAction:
title: Fourth Action
shell: echo "Second Dashboard, Fourth 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('Fourth Action')
})
it('Second Dashboard shows Third and Fourth 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('Fourth 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

@ -37,6 +37,7 @@ message Entity {
string unique_key = 2; string unique_key = 2;
string type = 3; string type = 3;
repeated string directories = 4; repeated string directories = 4;
map<string, string> fields = 5;
} }
message GetDashboardResponse { message GetDashboardResponse {

View File

@ -271,6 +271,7 @@ type Entity struct {
UniqueKey string `protobuf:"bytes,2,opt,name=unique_key,json=uniqueKey,proto3" json:"unique_key,omitempty"` UniqueKey string `protobuf:"bytes,2,opt,name=unique_key,json=uniqueKey,proto3" json:"unique_key,omitempty"`
Type string `protobuf:"bytes,3,opt,name=type,proto3" json:"type,omitempty"` Type string `protobuf:"bytes,3,opt,name=type,proto3" json:"type,omitempty"`
Directories []string `protobuf:"bytes,4,rep,name=directories,proto3" json:"directories,omitempty"` Directories []string `protobuf:"bytes,4,rep,name=directories,proto3" json:"directories,omitempty"`
Fields map[string]string `protobuf:"bytes,5,rep,name=fields,proto3" json:"fields,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -333,6 +334,13 @@ func (x *Entity) GetDirectories() []string {
return nil return nil
} }
func (x *Entity) GetFields() map[string]string {
if x != nil {
return x.Fields
}
return nil
}
type GetDashboardResponse struct { type GetDashboardResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"` Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"`
@ -3839,13 +3847,17 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"B\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"B\n" +
"\x14ActionArgumentChoice\x12\x14\n" + "\x14ActionArgumentChoice\x12\x14\n" +
"\x05value\x18\x01 \x01(\tR\x05value\x12\x14\n" + "\x05value\x18\x01 \x01(\tR\x05value\x12\x14\n" +
"\x05title\x18\x02 \x01(\tR\x05title\"s\n" + "\x05title\x18\x02 \x01(\tR\x05title\"\xeb\x01\n" +
"\x06Entity\x12\x14\n" + "\x06Entity\x12\x14\n" +
"\x05title\x18\x01 \x01(\tR\x05title\x12\x1d\n" + "\x05title\x18\x01 \x01(\tR\x05title\x12\x1d\n" +
"\n" + "\n" +
"unique_key\x18\x02 \x01(\tR\tuniqueKey\x12\x12\n" + "unique_key\x18\x02 \x01(\tR\tuniqueKey\x12\x12\n" +
"\x04type\x18\x03 \x01(\tR\x04type\x12 \n" + "\x04type\x18\x03 \x01(\tR\x04type\x12 \n" +
"\vdirectories\x18\x04 \x03(\tR\vdirectories\"f\n" + "\vdirectories\x18\x04 \x03(\tR\vdirectories\x12;\n" +
"\x06fields\x18\x05 \x03(\v2#.olivetin.api.v1.Entity.FieldsEntryR\x06fields\x1a9\n" +
"\vFieldsEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"f\n" +
"\x14GetDashboardResponse\x12\x14\n" + "\x14GetDashboardResponse\x12\x14\n" +
"\x05title\x18\x01 \x01(\tR\x05title\x128\n" + "\x05title\x18\x01 \x01(\tR\x05title\x128\n" +
"\tdashboard\x18\x04 \x01(\v2\x1a.olivetin.api.v1.DashboardR\tdashboard\"`\n" + "\tdashboard\x18\x04 \x01(\v2\x1a.olivetin.api.v1.DashboardR\tdashboard\"`\n" +
@ -4113,7 +4125,7 @@ func file_olivetin_api_v1_olivetin_proto_rawDescGZIP() []byte {
return file_olivetin_api_v1_olivetin_proto_rawDescData return file_olivetin_api_v1_olivetin_proto_rawDescData
} }
var file_olivetin_api_v1_olivetin_proto_msgTypes = make([]protoimpl.MessageInfo, 71) var file_olivetin_api_v1_olivetin_proto_msgTypes = make([]protoimpl.MessageInfo, 72)
var file_olivetin_api_v1_olivetin_proto_goTypes = []any{ var file_olivetin_api_v1_olivetin_proto_goTypes = []any{
(*Action)(nil), // 0: olivetin.api.v1.Action (*Action)(nil), // 0: olivetin.api.v1.Action
(*ActionArgument)(nil), // 1: olivetin.api.v1.ActionArgument (*ActionArgument)(nil), // 1: olivetin.api.v1.ActionArgument
@ -4184,95 +4196,97 @@ var file_olivetin_api_v1_olivetin_proto_goTypes = []any{
(*GetEntityRequest)(nil), // 66: olivetin.api.v1.GetEntityRequest (*GetEntityRequest)(nil), // 66: olivetin.api.v1.GetEntityRequest
(*RestartActionRequest)(nil), // 67: olivetin.api.v1.RestartActionRequest (*RestartActionRequest)(nil), // 67: olivetin.api.v1.RestartActionRequest
nil, // 68: olivetin.api.v1.ActionArgument.SuggestionsEntry nil, // 68: olivetin.api.v1.ActionArgument.SuggestionsEntry
nil, // 69: olivetin.api.v1.DumpVarsResponse.ContentsEntry nil, // 69: olivetin.api.v1.Entity.FieldsEntry
nil, // 70: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry nil, // 70: olivetin.api.v1.DumpVarsResponse.ContentsEntry
nil, // 71: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry
} }
var file_olivetin_api_v1_olivetin_proto_depIdxs = []int32{ var file_olivetin_api_v1_olivetin_proto_depIdxs = []int32{
1, // 0: olivetin.api.v1.Action.arguments:type_name -> olivetin.api.v1.ActionArgument 1, // 0: olivetin.api.v1.Action.arguments:type_name -> olivetin.api.v1.ActionArgument
2, // 1: olivetin.api.v1.ActionArgument.choices:type_name -> olivetin.api.v1.ActionArgumentChoice 2, // 1: olivetin.api.v1.ActionArgument.choices:type_name -> olivetin.api.v1.ActionArgumentChoice
68, // 2: olivetin.api.v1.ActionArgument.suggestions:type_name -> olivetin.api.v1.ActionArgument.SuggestionsEntry 68, // 2: olivetin.api.v1.ActionArgument.suggestions:type_name -> olivetin.api.v1.ActionArgument.SuggestionsEntry
7, // 3: olivetin.api.v1.GetDashboardResponse.dashboard:type_name -> olivetin.api.v1.Dashboard 69, // 3: olivetin.api.v1.Entity.fields:type_name -> olivetin.api.v1.Entity.FieldsEntry
8, // 4: olivetin.api.v1.Dashboard.contents:type_name -> olivetin.api.v1.DashboardComponent 7, // 4: olivetin.api.v1.GetDashboardResponse.dashboard:type_name -> olivetin.api.v1.Dashboard
8, // 5: olivetin.api.v1.DashboardComponent.contents:type_name -> olivetin.api.v1.DashboardComponent 8, // 5: olivetin.api.v1.Dashboard.contents:type_name -> olivetin.api.v1.DashboardComponent
0, // 6: olivetin.api.v1.DashboardComponent.action:type_name -> olivetin.api.v1.Action 8, // 6: olivetin.api.v1.DashboardComponent.contents:type_name -> olivetin.api.v1.DashboardComponent
10, // 7: olivetin.api.v1.StartActionRequest.arguments:type_name -> olivetin.api.v1.StartActionArgument 0, // 7: olivetin.api.v1.DashboardComponent.action:type_name -> olivetin.api.v1.Action
10, // 8: olivetin.api.v1.StartActionAndWaitRequest.arguments:type_name -> olivetin.api.v1.StartActionArgument 10, // 8: olivetin.api.v1.StartActionRequest.arguments:type_name -> olivetin.api.v1.StartActionArgument
19, // 9: olivetin.api.v1.StartActionAndWaitResponse.log_entry:type_name -> olivetin.api.v1.LogEntry 10, // 9: olivetin.api.v1.StartActionAndWaitRequest.arguments:type_name -> olivetin.api.v1.StartActionArgument
19, // 10: olivetin.api.v1.StartActionByGetAndWaitResponse.log_entry:type_name -> olivetin.api.v1.LogEntry 19, // 10: olivetin.api.v1.StartActionAndWaitResponse.log_entry:type_name -> olivetin.api.v1.LogEntry
19, // 11: olivetin.api.v1.GetLogsResponse.logs:type_name -> olivetin.api.v1.LogEntry 19, // 11: olivetin.api.v1.StartActionByGetAndWaitResponse.log_entry:type_name -> olivetin.api.v1.LogEntry
19, // 12: olivetin.api.v1.GetActionLogsResponse.logs:type_name -> olivetin.api.v1.LogEntry 19, // 12: olivetin.api.v1.GetLogsResponse.logs:type_name -> olivetin.api.v1.LogEntry
19, // 13: olivetin.api.v1.ExecutionStatusResponse.log_entry:type_name -> olivetin.api.v1.LogEntry 19, // 13: olivetin.api.v1.GetActionLogsResponse.logs:type_name -> olivetin.api.v1.LogEntry
69, // 14: olivetin.api.v1.DumpVarsResponse.contents:type_name -> olivetin.api.v1.DumpVarsResponse.ContentsEntry 19, // 14: olivetin.api.v1.ExecutionStatusResponse.log_entry:type_name -> olivetin.api.v1.LogEntry
70, // 15: olivetin.api.v1.DumpPublicIdActionMapResponse.contents:type_name -> olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry 70, // 15: olivetin.api.v1.DumpVarsResponse.contents:type_name -> olivetin.api.v1.DumpVarsResponse.ContentsEntry
43, // 16: olivetin.api.v1.EventStreamResponse.entity_changed:type_name -> olivetin.api.v1.EventEntityChanged 71, // 16: olivetin.api.v1.DumpPublicIdActionMapResponse.contents:type_name -> olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry
44, // 17: olivetin.api.v1.EventStreamResponse.config_changed:type_name -> olivetin.api.v1.EventConfigChanged 43, // 17: olivetin.api.v1.EventStreamResponse.entity_changed:type_name -> olivetin.api.v1.EventEntityChanged
45, // 18: olivetin.api.v1.EventStreamResponse.execution_finished:type_name -> olivetin.api.v1.EventExecutionFinished 44, // 18: olivetin.api.v1.EventStreamResponse.config_changed:type_name -> olivetin.api.v1.EventConfigChanged
46, // 19: olivetin.api.v1.EventStreamResponse.execution_started:type_name -> olivetin.api.v1.EventExecutionStarted 45, // 19: olivetin.api.v1.EventStreamResponse.execution_finished:type_name -> olivetin.api.v1.EventExecutionFinished
42, // 20: olivetin.api.v1.EventStreamResponse.output_chunk:type_name -> olivetin.api.v1.EventOutputChunk 46, // 20: olivetin.api.v1.EventStreamResponse.execution_started:type_name -> olivetin.api.v1.EventExecutionStarted
19, // 21: olivetin.api.v1.EventExecutionFinished.log_entry:type_name -> olivetin.api.v1.LogEntry 42, // 21: olivetin.api.v1.EventStreamResponse.output_chunk:type_name -> olivetin.api.v1.EventOutputChunk
19, // 22: olivetin.api.v1.EventExecutionStarted.log_entry:type_name -> olivetin.api.v1.LogEntry 19, // 22: olivetin.api.v1.EventExecutionFinished.log_entry:type_name -> olivetin.api.v1.LogEntry
60, // 23: olivetin.api.v1.InitResponse.oAuth2Providers:type_name -> olivetin.api.v1.OAuth2Provider 19, // 23: olivetin.api.v1.EventExecutionStarted.log_entry:type_name -> olivetin.api.v1.LogEntry
59, // 24: olivetin.api.v1.InitResponse.additionalLinks:type_name -> olivetin.api.v1.AdditionalLink 60, // 24: olivetin.api.v1.InitResponse.oAuth2Providers:type_name -> olivetin.api.v1.OAuth2Provider
5, // 25: olivetin.api.v1.InitResponse.effective_policy:type_name -> olivetin.api.v1.EffectivePolicy 59, // 25: olivetin.api.v1.InitResponse.additionalLinks:type_name -> olivetin.api.v1.AdditionalLink
0, // 26: olivetin.api.v1.GetActionBindingResponse.action:type_name -> olivetin.api.v1.Action 5, // 26: olivetin.api.v1.InitResponse.effective_policy:type_name -> olivetin.api.v1.EffectivePolicy
65, // 27: olivetin.api.v1.GetEntitiesResponse.entity_definitions:type_name -> olivetin.api.v1.EntityDefinition 0, // 27: olivetin.api.v1.GetActionBindingResponse.action:type_name -> olivetin.api.v1.Action
3, // 28: olivetin.api.v1.EntityDefinition.instances:type_name -> olivetin.api.v1.Entity 65, // 28: olivetin.api.v1.GetEntitiesResponse.entity_definitions:type_name -> olivetin.api.v1.EntityDefinition
35, // 29: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry.value:type_name -> olivetin.api.v1.ActionEntityPair 3, // 29: olivetin.api.v1.EntityDefinition.instances:type_name -> olivetin.api.v1.Entity
6, // 30: olivetin.api.v1.OliveTinApiService.GetDashboard:input_type -> olivetin.api.v1.GetDashboardRequest 35, // 30: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry.value:type_name -> olivetin.api.v1.ActionEntityPair
9, // 31: olivetin.api.v1.OliveTinApiService.StartAction:input_type -> olivetin.api.v1.StartActionRequest 6, // 31: olivetin.api.v1.OliveTinApiService.GetDashboard:input_type -> olivetin.api.v1.GetDashboardRequest
12, // 32: olivetin.api.v1.OliveTinApiService.StartActionAndWait:input_type -> olivetin.api.v1.StartActionAndWaitRequest 9, // 32: olivetin.api.v1.OliveTinApiService.StartAction:input_type -> olivetin.api.v1.StartActionRequest
14, // 33: olivetin.api.v1.OliveTinApiService.StartActionByGet:input_type -> olivetin.api.v1.StartActionByGetRequest 12, // 33: olivetin.api.v1.OliveTinApiService.StartActionAndWait:input_type -> olivetin.api.v1.StartActionAndWaitRequest
16, // 34: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:input_type -> olivetin.api.v1.StartActionByGetAndWaitRequest 14, // 34: olivetin.api.v1.OliveTinApiService.StartActionByGet:input_type -> olivetin.api.v1.StartActionByGetRequest
67, // 35: olivetin.api.v1.OliveTinApiService.RestartAction:input_type -> olivetin.api.v1.RestartActionRequest 16, // 35: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:input_type -> olivetin.api.v1.StartActionByGetAndWaitRequest
47, // 36: olivetin.api.v1.OliveTinApiService.KillAction:input_type -> olivetin.api.v1.KillActionRequest 67, // 36: olivetin.api.v1.OliveTinApiService.RestartAction:input_type -> olivetin.api.v1.RestartActionRequest
27, // 37: olivetin.api.v1.OliveTinApiService.ExecutionStatus:input_type -> olivetin.api.v1.ExecutionStatusRequest 47, // 37: olivetin.api.v1.OliveTinApiService.KillAction:input_type -> olivetin.api.v1.KillActionRequest
18, // 38: olivetin.api.v1.OliveTinApiService.GetLogs:input_type -> olivetin.api.v1.GetLogsRequest 27, // 38: olivetin.api.v1.OliveTinApiService.ExecutionStatus:input_type -> olivetin.api.v1.ExecutionStatusRequest
21, // 39: olivetin.api.v1.OliveTinApiService.GetActionLogs:input_type -> olivetin.api.v1.GetActionLogsRequest 18, // 39: olivetin.api.v1.OliveTinApiService.GetLogs:input_type -> olivetin.api.v1.GetLogsRequest
23, // 40: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:input_type -> olivetin.api.v1.ValidateArgumentTypeRequest 21, // 40: olivetin.api.v1.OliveTinApiService.GetActionLogs:input_type -> olivetin.api.v1.GetActionLogsRequest
29, // 41: olivetin.api.v1.OliveTinApiService.WhoAmI:input_type -> olivetin.api.v1.WhoAmIRequest 23, // 41: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:input_type -> olivetin.api.v1.ValidateArgumentTypeRequest
31, // 42: olivetin.api.v1.OliveTinApiService.SosReport:input_type -> olivetin.api.v1.SosReportRequest 29, // 42: olivetin.api.v1.OliveTinApiService.WhoAmI:input_type -> olivetin.api.v1.WhoAmIRequest
33, // 43: olivetin.api.v1.OliveTinApiService.DumpVars:input_type -> olivetin.api.v1.DumpVarsRequest 31, // 43: olivetin.api.v1.OliveTinApiService.SosReport:input_type -> olivetin.api.v1.SosReportRequest
36, // 44: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:input_type -> olivetin.api.v1.DumpPublicIdActionMapRequest 33, // 44: olivetin.api.v1.OliveTinApiService.DumpVars:input_type -> olivetin.api.v1.DumpVarsRequest
38, // 45: olivetin.api.v1.OliveTinApiService.GetReadyz:input_type -> olivetin.api.v1.GetReadyzRequest 36, // 45: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:input_type -> olivetin.api.v1.DumpPublicIdActionMapRequest
49, // 46: olivetin.api.v1.OliveTinApiService.LocalUserLogin:input_type -> olivetin.api.v1.LocalUserLoginRequest 38, // 46: olivetin.api.v1.OliveTinApiService.GetReadyz:input_type -> olivetin.api.v1.GetReadyzRequest
51, // 47: olivetin.api.v1.OliveTinApiService.PasswordHash:input_type -> olivetin.api.v1.PasswordHashRequest 49, // 47: olivetin.api.v1.OliveTinApiService.LocalUserLogin:input_type -> olivetin.api.v1.LocalUserLoginRequest
53, // 48: olivetin.api.v1.OliveTinApiService.Logout:input_type -> olivetin.api.v1.LogoutRequest 51, // 48: olivetin.api.v1.OliveTinApiService.PasswordHash:input_type -> olivetin.api.v1.PasswordHashRequest
40, // 49: olivetin.api.v1.OliveTinApiService.EventStream:input_type -> olivetin.api.v1.EventStreamRequest 53, // 49: olivetin.api.v1.OliveTinApiService.Logout:input_type -> olivetin.api.v1.LogoutRequest
55, // 50: olivetin.api.v1.OliveTinApiService.GetDiagnostics:input_type -> olivetin.api.v1.GetDiagnosticsRequest 40, // 50: olivetin.api.v1.OliveTinApiService.EventStream:input_type -> olivetin.api.v1.EventStreamRequest
57, // 51: olivetin.api.v1.OliveTinApiService.Init:input_type -> olivetin.api.v1.InitRequest 55, // 51: olivetin.api.v1.OliveTinApiService.GetDiagnostics:input_type -> olivetin.api.v1.GetDiagnosticsRequest
61, // 52: olivetin.api.v1.OliveTinApiService.GetActionBinding:input_type -> olivetin.api.v1.GetActionBindingRequest 57, // 52: olivetin.api.v1.OliveTinApiService.Init:input_type -> olivetin.api.v1.InitRequest
63, // 53: olivetin.api.v1.OliveTinApiService.GetEntities:input_type -> olivetin.api.v1.GetEntitiesRequest 61, // 53: olivetin.api.v1.OliveTinApiService.GetActionBinding:input_type -> olivetin.api.v1.GetActionBindingRequest
66, // 54: olivetin.api.v1.OliveTinApiService.GetEntity:input_type -> olivetin.api.v1.GetEntityRequest 63, // 54: olivetin.api.v1.OliveTinApiService.GetEntities:input_type -> olivetin.api.v1.GetEntitiesRequest
4, // 55: olivetin.api.v1.OliveTinApiService.GetDashboard:output_type -> olivetin.api.v1.GetDashboardResponse 66, // 55: olivetin.api.v1.OliveTinApiService.GetEntity:input_type -> olivetin.api.v1.GetEntityRequest
11, // 56: olivetin.api.v1.OliveTinApiService.StartAction:output_type -> olivetin.api.v1.StartActionResponse 4, // 56: olivetin.api.v1.OliveTinApiService.GetDashboard:output_type -> olivetin.api.v1.GetDashboardResponse
13, // 57: olivetin.api.v1.OliveTinApiService.StartActionAndWait:output_type -> olivetin.api.v1.StartActionAndWaitResponse 11, // 57: olivetin.api.v1.OliveTinApiService.StartAction:output_type -> olivetin.api.v1.StartActionResponse
15, // 58: olivetin.api.v1.OliveTinApiService.StartActionByGet:output_type -> olivetin.api.v1.StartActionByGetResponse 13, // 58: olivetin.api.v1.OliveTinApiService.StartActionAndWait:output_type -> olivetin.api.v1.StartActionAndWaitResponse
17, // 59: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:output_type -> olivetin.api.v1.StartActionByGetAndWaitResponse 15, // 59: olivetin.api.v1.OliveTinApiService.StartActionByGet:output_type -> olivetin.api.v1.StartActionByGetResponse
11, // 60: olivetin.api.v1.OliveTinApiService.RestartAction:output_type -> olivetin.api.v1.StartActionResponse 17, // 60: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:output_type -> olivetin.api.v1.StartActionByGetAndWaitResponse
48, // 61: olivetin.api.v1.OliveTinApiService.KillAction:output_type -> olivetin.api.v1.KillActionResponse 11, // 61: olivetin.api.v1.OliveTinApiService.RestartAction:output_type -> olivetin.api.v1.StartActionResponse
28, // 62: olivetin.api.v1.OliveTinApiService.ExecutionStatus:output_type -> olivetin.api.v1.ExecutionStatusResponse 48, // 62: olivetin.api.v1.OliveTinApiService.KillAction:output_type -> olivetin.api.v1.KillActionResponse
20, // 63: olivetin.api.v1.OliveTinApiService.GetLogs:output_type -> olivetin.api.v1.GetLogsResponse 28, // 63: olivetin.api.v1.OliveTinApiService.ExecutionStatus:output_type -> olivetin.api.v1.ExecutionStatusResponse
22, // 64: olivetin.api.v1.OliveTinApiService.GetActionLogs:output_type -> olivetin.api.v1.GetActionLogsResponse 20, // 64: olivetin.api.v1.OliveTinApiService.GetLogs:output_type -> olivetin.api.v1.GetLogsResponse
24, // 65: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:output_type -> olivetin.api.v1.ValidateArgumentTypeResponse 22, // 65: olivetin.api.v1.OliveTinApiService.GetActionLogs:output_type -> olivetin.api.v1.GetActionLogsResponse
30, // 66: olivetin.api.v1.OliveTinApiService.WhoAmI:output_type -> olivetin.api.v1.WhoAmIResponse 24, // 66: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:output_type -> olivetin.api.v1.ValidateArgumentTypeResponse
32, // 67: olivetin.api.v1.OliveTinApiService.SosReport:output_type -> olivetin.api.v1.SosReportResponse 30, // 67: olivetin.api.v1.OliveTinApiService.WhoAmI:output_type -> olivetin.api.v1.WhoAmIResponse
34, // 68: olivetin.api.v1.OliveTinApiService.DumpVars:output_type -> olivetin.api.v1.DumpVarsResponse 32, // 68: olivetin.api.v1.OliveTinApiService.SosReport:output_type -> olivetin.api.v1.SosReportResponse
37, // 69: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:output_type -> olivetin.api.v1.DumpPublicIdActionMapResponse 34, // 69: olivetin.api.v1.OliveTinApiService.DumpVars:output_type -> olivetin.api.v1.DumpVarsResponse
39, // 70: olivetin.api.v1.OliveTinApiService.GetReadyz:output_type -> olivetin.api.v1.GetReadyzResponse 37, // 70: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:output_type -> olivetin.api.v1.DumpPublicIdActionMapResponse
50, // 71: olivetin.api.v1.OliveTinApiService.LocalUserLogin:output_type -> olivetin.api.v1.LocalUserLoginResponse 39, // 71: olivetin.api.v1.OliveTinApiService.GetReadyz:output_type -> olivetin.api.v1.GetReadyzResponse
52, // 72: olivetin.api.v1.OliveTinApiService.PasswordHash:output_type -> olivetin.api.v1.PasswordHashResponse 50, // 72: olivetin.api.v1.OliveTinApiService.LocalUserLogin:output_type -> olivetin.api.v1.LocalUserLoginResponse
54, // 73: olivetin.api.v1.OliveTinApiService.Logout:output_type -> olivetin.api.v1.LogoutResponse 52, // 73: olivetin.api.v1.OliveTinApiService.PasswordHash:output_type -> olivetin.api.v1.PasswordHashResponse
41, // 74: olivetin.api.v1.OliveTinApiService.EventStream:output_type -> olivetin.api.v1.EventStreamResponse 54, // 74: olivetin.api.v1.OliveTinApiService.Logout:output_type -> olivetin.api.v1.LogoutResponse
56, // 75: olivetin.api.v1.OliveTinApiService.GetDiagnostics:output_type -> olivetin.api.v1.GetDiagnosticsResponse 41, // 75: olivetin.api.v1.OliveTinApiService.EventStream:output_type -> olivetin.api.v1.EventStreamResponse
58, // 76: olivetin.api.v1.OliveTinApiService.Init:output_type -> olivetin.api.v1.InitResponse 56, // 76: olivetin.api.v1.OliveTinApiService.GetDiagnostics:output_type -> olivetin.api.v1.GetDiagnosticsResponse
62, // 77: olivetin.api.v1.OliveTinApiService.GetActionBinding:output_type -> olivetin.api.v1.GetActionBindingResponse 58, // 77: olivetin.api.v1.OliveTinApiService.Init:output_type -> olivetin.api.v1.InitResponse
64, // 78: olivetin.api.v1.OliveTinApiService.GetEntities:output_type -> olivetin.api.v1.GetEntitiesResponse 62, // 78: olivetin.api.v1.OliveTinApiService.GetActionBinding:output_type -> olivetin.api.v1.GetActionBindingResponse
3, // 79: olivetin.api.v1.OliveTinApiService.GetEntity:output_type -> olivetin.api.v1.Entity 64, // 79: olivetin.api.v1.OliveTinApiService.GetEntities:output_type -> olivetin.api.v1.GetEntitiesResponse
55, // [55:80] is the sub-list for method output_type 3, // 80: olivetin.api.v1.OliveTinApiService.GetEntity:output_type -> olivetin.api.v1.Entity
30, // [30:55] is the sub-list for method input_type 56, // [56:81] is the sub-list for method output_type
30, // [30:30] is the sub-list for extension type_name 31, // [31:56] is the sub-list for method input_type
30, // [30:30] is the sub-list for extension extendee 31, // [31:31] is the sub-list for extension type_name
0, // [0:30] is the sub-list for field type_name 31, // [31:31] is the sub-list for extension extendee
0, // [0:31] is the sub-list for field type_name
} }
func init() { file_olivetin_api_v1_olivetin_proto_init() } func init() { file_olivetin_api_v1_olivetin_proto_init() }
@ -4293,7 +4307,7 @@ func file_olivetin_api_v1_olivetin_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_olivetin_api_v1_olivetin_proto_rawDesc), len(file_olivetin_api_v1_olivetin_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_olivetin_api_v1_olivetin_proto_rawDesc), len(file_olivetin_api_v1_olivetin_proto_rawDesc)),
NumEnums: 0, NumEnums: 0,
NumMessages: 71, NumMessages: 72,
NumExtensions: 0, NumExtensions: 0,
NumServices: 1, NumServices: 1,
}, },

View File

@ -977,24 +977,49 @@ func buildSortedEntityInstances(entityType string, entityInstances map[string]*e
func findDashboardsForEntity(entityTitle string, dashboards []*config.DashboardComponent) []string { func findDashboardsForEntity(entityTitle string, dashboards []*config.DashboardComponent) []string {
var foundDashboards []string var foundDashboards []string
seen := make(map[string]bool)
findEntityInComponents(entityTitle, "", dashboards, &foundDashboards) findEntityInComponents(entityTitle, "", dashboards, &foundDashboards, seen)
return foundDashboards return foundDashboards
} }
func findEntityInComponents(entityTitle string, parentTitle string, components []*config.DashboardComponent, foundDashboards *[]string) { func findEntityInComponents(entityTitle string, parentTitle string, components []*config.DashboardComponent, foundDashboards *[]string, seen map[string]bool) {
for _, component := range components { for _, component := range components {
if component.Entity == entityTitle { if component.Entity == entityTitle {
*foundDashboards = append(*foundDashboards, parentTitle) addEntityDashboard(component, parentTitle, foundDashboards, seen)
} }
if len(component.Contents) > 0 { if len(component.Contents) > 0 {
findEntityInComponents(entityTitle, component.Title, component.Contents, foundDashboards) findEntityInComponents(entityTitle, component.Title, component.Contents, foundDashboards, seen)
} }
} }
} }
func addEntityDashboard(component *config.DashboardComponent, parentTitle string, foundDashboards *[]string, seen map[string]bool) {
if component.Type == "directory" {
addEntityDirectory(component, foundDashboards, seen)
} else {
addParentDashboard(parentTitle, foundDashboards, seen)
}
}
func addEntityDirectory(component *config.DashboardComponent, foundDashboards *[]string, seen map[string]bool) {
dashboardTitle := component.Title + " [Entity Directory]"
if !seen[dashboardTitle] {
*foundDashboards = append(*foundDashboards, dashboardTitle)
seen[dashboardTitle] = true
seen[component.Title] = true
}
}
func addParentDashboard(parentTitle string, foundDashboards *[]string, seen map[string]bool) {
if parentTitle != "" && !seen[parentTitle] {
*foundDashboards = append(*foundDashboards, parentTitle)
seen[parentTitle] = true
}
}
func findDirectoriesInEntityFieldsets(entityType string, dashboards []*config.DashboardComponent) []string { func findDirectoriesInEntityFieldsets(entityType string, dashboards []*config.DashboardComponent) []string {
var directories []string var directories []string
@ -1036,26 +1061,46 @@ func (api *oliveTinAPI) GetEntity(ctx ctx.Context, req *connect.Request[apiv1.Ge
return nil, err return nil, err
} }
res := &apiv1.Entity{}
instances := entities.GetEntityInstances(req.Msg.Type) instances := entities.GetEntityInstances(req.Msg.Type)
log.Infof("msg: %+v", req.Msg)
if len(instances) == 0 { if len(instances) == 0 {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("entity type %s not found", req.Msg.Type)) return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("entity type %s not found", req.Msg.Type))
} }
if entity, ok := instances[req.Msg.UniqueKey]; !ok { entity, ok := instances[req.Msg.UniqueKey]
if !ok {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("entity with unique key %s not found in type %s", req.Msg.UniqueKey, req.Msg.Type)) return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("entity with unique key %s not found in type %s", req.Msg.UniqueKey, req.Msg.Type))
} else {
res.Title = entity.Title
res.UniqueKey = entity.UniqueKey
res.Type = req.Msg.Type
res.Directories = findDirectoriesInEntityFieldsets(req.Msg.Type, api.cfg.Dashboards)
return connect.NewResponse(res), nil
} }
res := buildEntityResponse(entity, req.Msg.Type, api.cfg.Dashboards)
return connect.NewResponse(res), nil
}
func buildEntityResponse(entity *entities.Entity, entityType string, dashboards []*config.DashboardComponent) *apiv1.Entity {
res := &apiv1.Entity{
Title: entity.Title,
UniqueKey: entity.UniqueKey,
Type: entityType,
Directories: findDirectoriesInEntityFieldsets(entityType, dashboards),
Fields: serializeEntityFields(entity.Data),
}
return res
}
func serializeEntityFields(data any) map[string]string {
if data == nil {
return nil
}
dataMap, ok := data.(map[string]any)
if !ok {
return nil
}
fields := make(map[string]string)
for k, v := range dataMap {
fields[k] = fmt.Sprintf("%v", v)
}
return fields
} }
func (api *oliveTinAPI) RestartAction(ctx ctx.Context, req *connect.Request[apiv1.RestartActionRequest]) (*connect.Response[apiv1.StartActionResponse], error) { func (api *oliveTinAPI) RestartAction(ctx ctx.Context, req *connect.Request[apiv1.RestartActionRequest]) (*connect.Response[apiv1.StartActionResponse], error) {

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 { func cloneLinkItem(subitem *config.DashboardComponent, ent *entities.Entity, clone *apiv1.DashboardComponent, rr *DashboardRenderRequest) *apiv1.DashboardComponent {
clone.Type = "link" clone.Type = "link"
clone.Title = entities.ParseTemplateWith(subitem.Title, ent) 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 return clone
} }

View File

@ -185,10 +185,6 @@ func removeNulls(components []*apiv1.DashboardComponent) []*apiv1.DashboardCompo
return ret return ret
} }
func getDashboardComponentContents(dashboard *config.DashboardComponent, rr *DashboardRenderRequest) []*apiv1.DashboardComponent {
return getDashboardComponentContentsWithEntity(dashboard, rr, nil)
}
func getDashboardComponentContentsWithEntity(dashboard *config.DashboardComponent, rr *DashboardRenderRequest, entity *entities.Entity) []*apiv1.DashboardComponent { func getDashboardComponentContentsWithEntity(dashboard *config.DashboardComponent, rr *DashboardRenderRequest, entity *entities.Entity) []*apiv1.DashboardComponent {
ret := make([]*apiv1.DashboardComponent, 0) ret := make([]*apiv1.DashboardComponent, 0)
rootFieldset := createRootFieldset() rootFieldset := createRootFieldset()
@ -208,10 +204,6 @@ func createRootFieldset() *apiv1.DashboardComponent {
} }
} }
func processDashboardSubitem(subitem *config.DashboardComponent, rr *DashboardRenderRequest, ret *[]*apiv1.DashboardComponent, rootFieldset *apiv1.DashboardComponent) {
processDashboardSubitemWithEntity(subitem, rr, ret, rootFieldset, nil)
}
func processDashboardSubitemWithEntity(subitem *config.DashboardComponent, rr *DashboardRenderRequest, ret *[]*apiv1.DashboardComponent, rootFieldset *apiv1.DashboardComponent, entity *entities.Entity) { func processDashboardSubitemWithEntity(subitem *config.DashboardComponent, rr *DashboardRenderRequest, ret *[]*apiv1.DashboardComponent, rootFieldset *apiv1.DashboardComponent, entity *entities.Entity) {
if subitem.Type != "fieldset" { if subitem.Type != "fieldset" {
rootFieldset.Contents = append(rootFieldset.Contents, buildDashboardComponentSimpleWithEntity(subitem, rr, entity)) rootFieldset.Contents = append(rootFieldset.Contents, buildDashboardComponentSimpleWithEntity(subitem, rr, entity))
@ -232,10 +224,6 @@ func appendRootFieldsetIfNeeded(ret []*apiv1.DashboardComponent, rootFieldset *a
return ret return ret
} }
func buildDashboardComponentSimple(subitem *config.DashboardComponent, rr *DashboardRenderRequest) *apiv1.DashboardComponent {
return buildDashboardComponentSimpleWithEntity(subitem, rr, nil)
}
func buildDashboardComponentSimpleWithEntity(subitem *config.DashboardComponent, rr *DashboardRenderRequest, entity *entities.Entity) *apiv1.DashboardComponent { func buildDashboardComponentSimpleWithEntity(subitem *config.DashboardComponent, rr *DashboardRenderRequest, entity *entities.Entity) *apiv1.DashboardComponent {
var contents []*apiv1.DashboardComponent var contents []*apiv1.DashboardComponent

View File

@ -207,12 +207,13 @@ type LogDebugOptions struct {
} }
type DashboardComponent struct { type DashboardComponent struct {
Title string `koanf:"title"` Title string `koanf:"title"`
Type string `koanf:"type"` Type string `koanf:"type"`
Entity string `koanf:"entity"` Entity string `koanf:"entity"`
Icon string `koanf:"icon"` Icon string `koanf:"icon"`
CssClass string `koanf:"cssClass"` CssClass string `koanf:"cssClass"`
Contents []*DashboardComponent `koanf:"contents"` InlineAction *Action `koanf:"inlineAction"`
Contents []*DashboardComponent `koanf:"contents"`
} }
func DefaultConfig() *Config { func DefaultConfig() *Config {

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 { func mergeFunc(src map[string]interface{}, dest map[string]interface{}) error {
if srcActions, ok := src["actions"]; ok { if srcActions, ok := src["actions"]; ok {
mergeActionsFromSource(srcActions, dest) mergeActionsFromSource(srcActions, dest)
} }
if srcDashboards, ok := src["dashboards"]; ok {
mergeDashboardsFromSource(srcDashboards, dest)
}
if srcEntities, ok := src["entities"]; ok {
mergeEntitiesFromSource(srcEntities, dest)
}
return nil return nil
} }

View File

@ -19,6 +19,113 @@ func (cfg *Config) Sanitize() {
for idx := range cfg.Actions { for idx := range cfg.Actions {
cfg.Actions[idx].sanitize(cfg) cfg.Actions[idx].sanitize(cfg)
} }
cfg.sanitizeDashboardsForInlineActions()
}
func (cfg *Config) sanitizeDashboardsForInlineActions() {
for _, dashboard := range cfg.Dashboards {
cfg.sanitizeDashboardComponentForInlineActions(dashboard)
}
}
func (cfg *Config) sanitizeDashboardComponentForInlineActions(component *DashboardComponent) {
visited := make(map[*DashboardComponent]bool)
cfg.sanitizeDashboardComponentForInlineActionsHelper(component, visited)
}
func (cfg *Config) sanitizeDashboardComponentForInlineActionsHelper(component *DashboardComponent, visited map[*DashboardComponent]bool) {
if component == nil {
return
}
if visited[component] {
return
}
visited[component] = true
cfg.sanitizeInlineAction(component)
cfg.sanitizeChildDashboardComponents(component, visited)
}
func (cfg *Config) sanitizeInlineAction(component *DashboardComponent) {
if component.InlineAction == nil {
return
}
sanitizeInlineActionTitles(component)
if component.Entity != "" && component.InlineAction.Entity == "" {
component.InlineAction.Entity = component.Entity
}
component.InlineAction.sanitize(cfg)
cfg.addInlineActionIfNotExists(component.InlineAction)
}
func (cfg *Config) addInlineActionIfNotExists(action *Action) {
if cfg.inlineActionExists(action) {
return
}
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 {
if cfg.inlineActionPointerExists(action) {
return true
}
if cfg.inlineActionIDExists(action) {
return true
}
return false
}
func (cfg *Config) inlineActionPointerExists(action *Action) bool {
for _, existingAction := range cfg.Actions {
if existingAction == action {
return true
}
}
return false
}
func (cfg *Config) inlineActionIDExists(action *Action) bool {
if action.ID == "" {
return false
}
for _, existingAction := range cfg.Actions {
if existingAction.ID == action.ID {
return true
}
}
return false
}
func (cfg *Config) sanitizeChildDashboardComponents(component *DashboardComponent, visited map[*DashboardComponent]bool) {
for _, child := range component.Contents {
if child.Entity == "" {
child.Entity = component.Entity
}
cfg.sanitizeDashboardComponentForInlineActionsHelper(child, visited)
}
} }
func (cfg *Config) sanitizeLogLevel() { func (cfg *Config) sanitizeLogLevel() {

View File

@ -36,3 +36,39 @@ func TestSanitizeConfig(t *testing.T) {
assert.Equal(t, "Carrots", a2.Arguments[0].Title, "Arg title is set to name") assert.Equal(t, "Carrots", a2.Arguments[0].Title, "Arg title is set to name")
assert.Equal(t, "Waffle", a2.Arguments[0].Choices[0].Title, "Choice title is set to name") assert.Equal(t, "Waffle", a2.Arguments[0].Choices[0].Title, "Choice title is set to name")
} }
func TestSanitizeConfigInlineDashboardActions(t *testing.T) {
c := DefaultConfig()
inline := &Action{
Shell: "date",
}
dashboardActionTitle := "Inline Dashboard Action"
c.Dashboards = []*DashboardComponent{
{
Title: "My Dashboard",
Contents: []*DashboardComponent{
{
Title: dashboardActionTitle,
InlineAction: inline,
},
},
},
}
c.Sanitize()
// Inline action should have been appended to the global Actions slice.
assert.GreaterOrEqual(t, len(c.Actions), 1, "At least one action should exist after sanitization")
// It should be discoverable by the dashboard component title when no explicit title was set.
found := c.findAction(dashboardActionTitle)
if assert.NotNil(t, found, "Inline dashboard action should be discoverable by title") {
assert.Equal(t, dashboardActionTitle, found.Title, "Inline action title should default from dashboard component title")
assert.Equal(t, 3, found.Timeout, "Inline action should have default timeout applied")
assert.NotEmpty(t, found.Icon, "Inline action should have default icon applied")
assert.NotEmpty(t, found.ID, "Inline action should have a generated ID")
}
}

View File

@ -120,7 +120,7 @@ func findEntityTitle(data any) string {
keys[lookupKey] = k keys[lookupKey] = k
} }
for _, key := range []string{"title", "name", "id"} { for _, key := range []string{"title", "name", "id", "hostname", "host", "label"} {
if lookupKey, exists := keys[strings.ToLower(key)]; exists { if lookupKey, exists := keys[strings.ToLower(key)]; exists {
if value, ok := mapData[lookupKey]; ok { if value, ok := mapData[lookupKey]; ok {
if valueStr, ok := value.(string); ok { if valueStr, ok := value.(string); ok {

View File

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

View File

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