feat: Mega entity improvements - entity directories, inehrit entities (#450), ordering (#762 / #703), better entity view

This commit is contained in:
jamesread 2025-12-01 01:38:04 +00:00
parent 736ad1c83b
commit 56365af24b
13 changed files with 529 additions and 41 deletions

View File

@ -189,12 +189,24 @@ actions:
- title: Ping hypervisor2 - title: Ping hypervisor2
shell: echo "hypervisor2 online" shell: echo "hypervisor2 online"
- title: Ping hypervisor3
shell: echo "hypervisor3 online"
- title: Ping hypervisor4
shell: echo "hypervisor4 online"
- title: "{{ server.name }} Wake on Lan" - title: "{{ server.name }} Wake on Lan"
shell: echo "Sending Wake on LAN to {{ server.hostname }}" shell: echo "Sending Wake on LAN to {{ server.hostname }}"
icon: <iconify-icon icon="carbon:awake"></iconify-icon>
entity: server entity: server
- title: "{{ server.name }} Power Off" - title: "{{ server.name }} Power Off"
shell: "echo 'Power Off Server: {{ server.hostname }}'" shell: "echo 'Power Off Server: {{ server.hostname }}'"
icon: <iconify-icon icon="carbon:flash-off"></iconify-icon>
entity: server
- title: "{{ server.name }} Print server name"
shell: 'echo "Server name: {{ server.name }}"'
entity: server entity: server
- title: Ping All Servers - title: Ping All Servers
@ -278,6 +290,11 @@ dashboards:
contents: contents:
- title: Ping hypervisor1 - title: Ping hypervisor1
- title: Ping hypervisor2 - title: Ping hypervisor2
- title: More hypervisors
type: directory
contents:
- title: Ping hypervisor3
- title: Ping hypervisor4
# If you specify `type: fieldset` and some `contents`, it will show your # If you specify `type: fieldset` and some `contents`, it will show your
# actions grouped together without a folder. # actions grouped together without a folder.
@ -299,6 +316,11 @@ dashboards:
- title: '{{ server.name }} Wake on Lan' - title: '{{ server.name }} Wake on Lan'
- title: '{{ server.name }} Power Off' - title: '{{ server.name }} Power Off'
- title: More Options
type: directory
contents:
- title: '{{ server.name }} Print server name'
# This is the second dashboard. # This is the second dashboard.
- title: My Containers - title: My Containers
contents: contents:

View File

@ -146,6 +146,11 @@ export declare type Entity = Message<"olivetin.api.v1.Entity"> & {
* @generated from field: string type = 3; * @generated from field: string type = 3;
*/ */
type: string; type: string;
/**
* @generated from field: repeated string directories = 4;
*/
directories: string[];
}; };
/** /**
@ -204,6 +209,16 @@ export declare type GetDashboardRequest = Message<"olivetin.api.v1.GetDashboardR
* @generated from field: string title = 1; * @generated from field: string title = 1;
*/ */
title: string; title: string;
/**
* @generated from field: string entity_type = 2;
*/
entityType: string;
/**
* @generated from field: string entity_key = 3;
*/
entityKey: string;
}; };
/** /**
@ -266,6 +281,16 @@ export declare type DashboardComponent = Message<"olivetin.api.v1.DashboardCompo
* @generated from field: olivetin.api.v1.Action action = 6; * @generated from field: olivetin.api.v1.Action action = 6;
*/ */
action?: Action; action?: Action;
/**
* @generated from field: string entity_type = 7;
*/
entityType: string;
/**
* @generated from field: string entity_key = 8;
*/
entityKey: string;
}; };
/** /**

File diff suppressed because one or more lines are too long

View File

@ -11,13 +11,39 @@
</section> </section>
<template v-else-if="dashboard"> <template v-else-if="dashboard">
<section v-if="dashboard.contents.length == 0"> <section v-if="dashboard.contents.length == 0">
<div class="back-button-container" v-if="isDirectory">
<button @click="goBack" class="back-button">
<HugeiconsIcon :icon="ArrowLeftIcon" width="1.2em" height="1.2em" />
<span>Back</span>
</button>
</div>
<h2>{{ dashboard.title }}</h2> <h2>{{ dashboard.title }}</h2>
<p style = "text-align: center" class = "padding">This dashboard is empty.</p> <p style = "text-align: center" class = "padding">This dashboard is empty.</p>
</section> </section>
<section class="transparent" v-else> <section class="transparent" v-else>
<div class="back-button-container" v-if="isDirectory">
<button @click="goBack" class="back-button">
<HugeiconsIcon :icon="ArrowLeftIcon" width="1.2em" height="1.2em" />
<span>Back</span>
</button>
</div>
<div class = "dashboard-row" v-for="component in dashboard.contents" :key="component.title"> <div class = "dashboard-row" v-for="component in dashboard.contents" :key="component.title">
<h2 v-if = "dashboard.title != 'Default'">{{ component.title }}</h2> <h2 v-if = "dashboard.title != 'Default'">
<router-link
v-if="component.entityType && component.entityKey"
:to="{
name: 'EntityDetails',
params: {
entityType: component.entityType,
entityKey: component.entityKey
}
}"
class="entity-link">
{{ component.title }}
</router-link>
<span v-else>{{ component.title }}</span>
</h2>
<fieldset> <fieldset>
<template v-for="subcomponent in component.contents"> <template v-for="subcomponent in component.contents">
@ -31,23 +57,54 @@
<script setup> <script setup>
import DashboardComponent from './components/DashboardComponent.vue' import DashboardComponent from './components/DashboardComponent.vue'
import { onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { HugeiconsIcon } from '@hugeicons/vue' import { HugeiconsIcon } from '@hugeicons/vue'
import { Loading03Icon } from '@hugeicons/core-free-icons' import { Loading03Icon, ArrowLeftIcon } from '@hugeicons/core-free-icons'
const props = defineProps({ const props = defineProps({
title: { title: {
type: String, type: String,
required: false required: false
},
entityType: {
type: String,
required: false
},
entityKey: {
type: String,
required: false
} }
}) })
const router = useRouter()
const dashboard = ref(null) const dashboard = ref(null)
const loadingTime = ref(0) const loadingTime = ref(0)
const initError = ref(null) const initError = ref(null)
let loadingTimer = null let loadingTimer = null
let checkInitInterval = null let checkInitInterval = null
const isDirectory = computed(() => {
if (!dashboard.value || !window.initResponse) {
return false
}
const rootDashboards = window.initResponse.rootDashboards || []
return !rootDashboards.includes(dashboard.value.title) && dashboard.value.title !== 'Actions'
})
function goBack() {
if (window.history.length > 1) {
router.back()
} else {
const rootDashboards = window.initResponse?.rootDashboards || []
if (rootDashboards.length > 0) {
router.push({ name: 'Dashboard', params: { title: rootDashboards[0] } })
} else {
router.push({ name: 'Actions' })
}
}
}
async function getDashboard() { async function getDashboard() {
let title = props.title let title = props.title
@ -58,9 +115,16 @@ async function getDashboard() {
} }
try { try {
const ret = await window.client.getDashboard({ const request = {
title: title, title: title,
}) }
if (props.entityType && props.entityKey) {
request.entityType = props.entityType
request.entityKey = props.entityKey
}
const ret = await window.client.getDashboard(request)
if (!ret || !ret.dashboard) { if (!ret || !ret.dashboard) {
throw new Error('No dashboard found') throw new Error('No dashboard found')
@ -166,6 +230,17 @@ h2 {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
h2 .entity-link {
color: inherit;
text-decoration: none;
transition: opacity 0.2s;
}
h2 .entity-link:hover {
opacity: 0.7;
text-decoration: underline;
}
fieldset { fieldset {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, 180px); grid-template-columns: repeat(auto-fit, 180px);
@ -183,4 +258,41 @@ fieldset {
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
.back-button-container {
display: flex;
justify-content: flex-start;
padding: 1em;
padding-bottom: 0;
}
.back-button {
display: flex;
align-items: center;
gap: 0.5em;
padding: 0.5em 1em;
background-color: var(--bg, #fff);
border: 1px solid var(--border-color, #ccc);
border-radius: 0.5em;
cursor: pointer;
font-size: 0.9em;
box-shadow: 0 0 .3em rgba(0, 0, 0, 0.1);
transition: background-color 0.2s, box-shadow 0.2s;
}
.back-button:hover {
background-color: var(--bg-hover, #f5f5f5);
box-shadow: 0 0 .5em rgba(0, 0, 0, 0.15);
}
@media (prefers-color-scheme: dark) {
.back-button {
background-color: var(--bg, #111);
border-color: var(--border-color, #333);
}
.back-button:hover {
background-color: var(--bg-hover, #222);
}
}
</style> </style>

View File

@ -1,5 +1,5 @@
<template> <template>
<button @click="router.push({ name: 'Dashboard', params: { title: component.title } })"> <button @click="navigateToDirectory">
{{ component.title }} {{ component.title }}
</button> </button>
</template> </template>
@ -15,6 +15,17 @@ const props = defineProps({
required: true required: true
} }
}) })
function navigateToDirectory() {
const params = { title: props.component.title }
if (props.component.entityType && props.component.entityKey) {
params.entityType = props.component.entityType
params.entityKey = props.component.entityKey
}
router.push({ name: 'Dashboard', params })
}
</script> </script>
<style scoped> <style scoped>

View File

@ -13,7 +13,7 @@ const routes = [
meta: { title: 'Actions', icon: DashboardSquare01Icon } meta: { title: 'Actions', icon: DashboardSquare01Icon }
}, },
{ {
path: '/dashboards/:title', path: '/dashboards/:title/:entityType?/:entityKey?',
name: 'Dashboard', name: 'Dashboard',
component: () => import('./Dashboard.vue'), component: () => import('./Dashboard.vue'),
props: true, props: true,

View File

@ -1,17 +1,62 @@
<template> <template>
<Section title="Entity Details"> <Section title="Entity Details">
<div> <template #toolbar>
<p v-if="!entityDetails">Loading entity details...</p> <button @click="goBack" class="back-button">
<p v-else-if="!entityDetails.title">No details available for this entity.</p> <HugeiconsIcon :icon="ArrowLeftIcon" width="1.2em" height="1.2em" />
<p v-else>{{ entityDetails.title }}</p> <span>Back</span>
</button>
</template>
<div v-if="!entityDetails">
<p>Loading entity details...</p>
</div> </div>
<template v-else>
<dl>
<dt>Type</dt>
<dd>
<router-link :to="{ name: 'Entities' }" class="entity-type-link">
{{ entityType }}
</router-link>
</dd>
<dt v-if="entityDetails.title">Title</dt>
<dd v-if="entityDetails.title">{{ entityDetails.title }}</dd>
</dl>
<p v-if="!entityDetails.title">No details available for this entity.</p>
<hr />
<h3>Dashboard Entity Directories</h3>
<div v-if="entityDetails.directories && entityDetails.directories.length > 0" class="directories-section">
<ul class="directory-list">
<li v-for="directory in entityDetails.directories" :key="directory">
<router-link
:to="{
name: 'Dashboard',
params: {
title: directory,
entityType: entityType,
entityKey: entityKey
}
}">
{{ directory }}
</router-link>
</li>
</ul>
</div>
<p v-else>No directories found for this entity.
<a href = "https://docs.olivetin.app/dashboards/entity-directories.html" target = "_blank">Learn more</a>
</p>
</template>
</Section> </Section>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { HugeiconsIcon } from '@hugeicons/vue'
import { ArrowLeftIcon } from '@hugeicons/core-free-icons'
import Section from 'picocrank/vue/components/Section.vue' import Section from 'picocrank/vue/components/Section.vue'
const router = useRouter()
const entityDetails = ref(null) const entityDetails = ref(null)
const props = defineProps({ const props = defineProps({
@ -19,6 +64,10 @@
entityKey: String entityKey: String
}) })
function goBack() {
router.push({ name: 'Entities' })
}
async function fetchEntityDetails() { async function fetchEntityDetails() {
try { try {
const response = await window.client.getEntity({ const response = await window.client.getEntity({
@ -38,3 +87,77 @@
}) })
</script> </script>
<style scoped>
.back-button {
display: flex;
align-items: center;
gap: 0.5em;
padding: 0.5em 1em;
background-color: var(--bg, #fff);
border: 1px solid var(--border-color, #ccc);
border-radius: 0.5em;
cursor: pointer;
font-size: 0.9em;
box-shadow: 0 0 .3em rgba(0, 0, 0, 0.1);
transition: background-color 0.2s, box-shadow 0.2s;
}
.back-button:hover {
background-color: var(--bg-hover, #f5f5f5);
box-shadow: 0 0 .5em rgba(0, 0, 0, 0.15);
}
.directories-section h3 {
margin-bottom: 0.5em;
font-size: 1.1em;
}
.directory-list a {
text-decoration: none;
padding: 0.5em;
display: inline-block;
border-radius: 0.3em;
transition: background-color 0.2s;
}
.directory-list a:hover {
background-color: var(--bg-hover, #f5f5f5);
text-decoration: underline;
}
.entity-type-link {
text-decoration: none;
transition: opacity 0.2s;
}
.entity-type-link:hover {
text-decoration: underline;
opacity: 0.8;
}
hr {
border: 0;
border-top: 1px solid var(--border-color, #ccc);
}
@media (prefers-color-scheme: dark) {
.back-button {
background-color: var(--bg, #111);
border-color: var(--border-color, #333);
}
.back-button:hover {
background-color: var(--bg-hover, #222);
}
.directories-section {
border-top-color: var(--border-color, #333);
}
.directory-list a:hover {
background-color: var(--bg-hover, #222);
}
}
</style>

View File

@ -36,6 +36,7 @@ message Entity {
string title = 1; string title = 1;
string unique_key = 2; string unique_key = 2;
string type = 3; string type = 3;
repeated string directories = 4;
} }
message GetDashboardResponse { message GetDashboardResponse {
@ -51,6 +52,8 @@ message EffectivePolicy {
message GetDashboardRequest { message GetDashboardRequest {
string title = 1; string title = 1;
string entity_type = 2;
string entity_key = 3;
} }
message Dashboard { message Dashboard {
@ -65,6 +68,8 @@ message DashboardComponent {
string icon = 4; string icon = 4;
string css_class = 5; string css_class = 5;
Action action = 6; Action action = 6;
string entity_type = 7;
string entity_key = 8;
} }
message StartActionRequest { message StartActionRequest {

View File

@ -270,6 +270,7 @@ type Entity struct {
Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"` Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"`
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"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -325,6 +326,13 @@ func (x *Entity) GetType() string {
return "" return ""
} }
func (x *Entity) GetDirectories() []string {
if x != nil {
return x.Directories
}
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"`
@ -432,6 +440,8 @@ func (x *EffectivePolicy) GetShowLogList() bool {
type GetDashboardRequest struct { type GetDashboardRequest 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"`
EntityType string `protobuf:"bytes,2,opt,name=entity_type,json=entityType,proto3" json:"entity_type,omitempty"`
EntityKey string `protobuf:"bytes,3,opt,name=entity_key,json=entityKey,proto3" json:"entity_key,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -473,6 +483,20 @@ func (x *GetDashboardRequest) GetTitle() string {
return "" return ""
} }
func (x *GetDashboardRequest) GetEntityType() string {
if x != nil {
return x.EntityType
}
return ""
}
func (x *GetDashboardRequest) GetEntityKey() string {
if x != nil {
return x.EntityKey
}
return ""
}
type Dashboard struct { type Dashboard 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"`
@ -533,6 +557,8 @@ type DashboardComponent struct {
Icon string `protobuf:"bytes,4,opt,name=icon,proto3" json:"icon,omitempty"` Icon string `protobuf:"bytes,4,opt,name=icon,proto3" json:"icon,omitempty"`
CssClass string `protobuf:"bytes,5,opt,name=css_class,json=cssClass,proto3" json:"css_class,omitempty"` CssClass string `protobuf:"bytes,5,opt,name=css_class,json=cssClass,proto3" json:"css_class,omitempty"`
Action *Action `protobuf:"bytes,6,opt,name=action,proto3" json:"action,omitempty"` Action *Action `protobuf:"bytes,6,opt,name=action,proto3" json:"action,omitempty"`
EntityType string `protobuf:"bytes,7,opt,name=entity_type,json=entityType,proto3" json:"entity_type,omitempty"`
EntityKey string `protobuf:"bytes,8,opt,name=entity_key,json=entityKey,proto3" json:"entity_key,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -609,6 +635,20 @@ func (x *DashboardComponent) GetAction() *Action {
return nil return nil
} }
func (x *DashboardComponent) GetEntityType() string {
if x != nil {
return x.EntityType
}
return ""
}
func (x *DashboardComponent) GetEntityKey() string {
if x != nil {
return x.EntityKey
}
return ""
}
type StartActionRequest struct { type StartActionRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
BindingId string `protobuf:"bytes,1,opt,name=binding_id,json=bindingId,proto3" json:"binding_id,omitempty"` BindingId string `protobuf:"bytes,1,opt,name=binding_id,json=bindingId,proto3" json:"binding_id,omitempty"`
@ -3799,30 +3839,39 @@ 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\"Q\n" + "\x05title\x18\x02 \x01(\tR\x05title\"s\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\"f\n" + "\x04type\x18\x03 \x01(\tR\x04type\x12 \n" +
"\vdirectories\x18\x04 \x03(\tR\vdirectories\"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" +
"\x0fEffectivePolicy\x12)\n" + "\x0fEffectivePolicy\x12)\n" +
"\x10show_diagnostics\x18\x01 \x01(\bR\x0fshowDiagnostics\x12\"\n" + "\x10show_diagnostics\x18\x01 \x01(\bR\x0fshowDiagnostics\x12\"\n" +
"\rshow_log_list\x18\x02 \x01(\bR\vshowLogList\"+\n" + "\rshow_log_list\x18\x02 \x01(\bR\vshowLogList\"k\n" +
"\x13GetDashboardRequest\x12\x14\n" + "\x13GetDashboardRequest\x12\x14\n" +
"\x05title\x18\x01 \x01(\tR\x05title\"b\n" + "\x05title\x18\x01 \x01(\tR\x05title\x12\x1f\n" +
"\ventity_type\x18\x02 \x01(\tR\n" +
"entityType\x12\x1d\n" +
"\n" +
"entity_key\x18\x03 \x01(\tR\tentityKey\"b\n" +
"\tDashboard\x12\x14\n" + "\tDashboard\x12\x14\n" +
"\x05title\x18\x01 \x01(\tR\x05title\x12?\n" + "\x05title\x18\x01 \x01(\tR\x05title\x12?\n" +
"\bcontents\x18\x02 \x03(\v2#.olivetin.api.v1.DashboardComponentR\bcontents\"\xe1\x01\n" + "\bcontents\x18\x02 \x03(\v2#.olivetin.api.v1.DashboardComponentR\bcontents\"\xa1\x02\n" +
"\x12DashboardComponent\x12\x14\n" + "\x12DashboardComponent\x12\x14\n" +
"\x05title\x18\x01 \x01(\tR\x05title\x12\x12\n" + "\x05title\x18\x01 \x01(\tR\x05title\x12\x12\n" +
"\x04type\x18\x02 \x01(\tR\x04type\x12?\n" + "\x04type\x18\x02 \x01(\tR\x04type\x12?\n" +
"\bcontents\x18\x03 \x03(\v2#.olivetin.api.v1.DashboardComponentR\bcontents\x12\x12\n" + "\bcontents\x18\x03 \x03(\v2#.olivetin.api.v1.DashboardComponentR\bcontents\x12\x12\n" +
"\x04icon\x18\x04 \x01(\tR\x04icon\x12\x1b\n" + "\x04icon\x18\x04 \x01(\tR\x04icon\x12\x1b\n" +
"\tcss_class\x18\x05 \x01(\tR\bcssClass\x12/\n" + "\tcss_class\x18\x05 \x01(\tR\bcssClass\x12/\n" +
"\x06action\x18\x06 \x01(\v2\x17.olivetin.api.v1.ActionR\x06action\"\xa5\x01\n" + "\x06action\x18\x06 \x01(\v2\x17.olivetin.api.v1.ActionR\x06action\x12\x1f\n" +
"\ventity_type\x18\a \x01(\tR\n" +
"entityType\x12\x1d\n" +
"\n" +
"entity_key\x18\b \x01(\tR\tentityKey\"\xa5\x01\n" +
"\x12StartActionRequest\x12\x1d\n" + "\x12StartActionRequest\x12\x1d\n" +
"\n" + "\n" +
"binding_id\x18\x01 \x01(\tR\tbindingId\x12B\n" + "binding_id\x18\x01 \x01(\tR\tbindingId\x12B\n" +

View File

@ -415,7 +415,13 @@ func (api *oliveTinAPI) GetDashboard(ctx ctx.Context, req *connect.Request[apiv1
return nil, err return nil, err
} }
dashboardRenderRequest := api.createDashboardRenderRequest(user) entityType := ""
entityKey := ""
if req.Msg != nil {
entityType = req.Msg.EntityType
entityKey = req.Msg.EntityKey
}
dashboardRenderRequest := api.createDashboardRenderRequest(user, entityType, entityKey)
if api.isDefaultDashboard(req.Msg.Title) { if api.isDefaultDashboard(req.Msg.Title) {
return api.buildDefaultDashboardResponse(dashboardRenderRequest) return api.buildDefaultDashboardResponse(dashboardRenderRequest)
@ -431,11 +437,13 @@ func (api *oliveTinAPI) checkDashboardAccess(user *authpublic.AuthenticatedUser)
return nil return nil
} }
func (api *oliveTinAPI) createDashboardRenderRequest(user *authpublic.AuthenticatedUser) *DashboardRenderRequest { func (api *oliveTinAPI) createDashboardRenderRequest(user *authpublic.AuthenticatedUser, entityType, entityKey string) *DashboardRenderRequest {
return &DashboardRenderRequest{ return &DashboardRenderRequest{
AuthenticatedUser: user, AuthenticatedUser: user,
cfg: api.cfg, cfg: api.cfg,
ex: api.executor, ex: api.executor,
EntityType: entityType,
EntityKey: entityKey,
} }
} }
@ -835,7 +843,7 @@ func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitReq
func (api *oliveTinAPI) buildRootDashboards(user *authpublic.AuthenticatedUser, dashboards []*config.DashboardComponent) []string { func (api *oliveTinAPI) buildRootDashboards(user *authpublic.AuthenticatedUser, dashboards []*config.DashboardComponent) []string {
var rootDashboards []string var rootDashboards []string
dashboardRenderRequest := api.createDashboardRenderRequest(user) dashboardRenderRequest := api.createDashboardRenderRequest(user, "", "")
api.addDefaultDashboardIfNeeded(&rootDashboards, dashboardRenderRequest) api.addDefaultDashboardIfNeeded(&rootDashboards, dashboardRenderRequest)
api.addCustomDashboards(&rootDashboards, dashboards, dashboardRenderRequest) api.addCustomDashboards(&rootDashboards, dashboards, dashboardRenderRequest)
@ -987,6 +995,32 @@ func findEntityInComponents(entityTitle string, parentTitle string, components [
} }
} }
func findDirectoriesInEntityFieldsets(entityType string, dashboards []*config.DashboardComponent) []string {
var directories []string
for _, dashboard := range dashboards {
findDirectoriesInEntityFieldsetsRecursive(entityType, dashboard, &directories)
}
return directories
}
func findDirectoriesInEntityFieldsetsRecursive(entityType string, component *config.DashboardComponent, directories *[]string) {
if component.Entity == entityType {
for _, subitem := range component.Contents {
if subitem.Type == "directory" {
*directories = append(*directories, subitem.Title)
}
}
}
if len(component.Contents) > 0 {
for _, subitem := range component.Contents {
findDirectoriesInEntityFieldsetsRecursive(entityType, subitem, directories)
}
}
}
func (api *oliveTinAPI) GetEntity(ctx ctx.Context, req *connect.Request[apiv1.GetEntityRequest]) (*connect.Response[apiv1.Entity], error) { func (api *oliveTinAPI) GetEntity(ctx ctx.Context, req *connect.Request[apiv1.GetEntityRequest]) (*connect.Response[apiv1.Entity], error) {
user := auth.UserFromApiCall(ctx, req, api.cfg) user := auth.UserFromApiCall(ctx, req, api.cfg)
@ -1008,6 +1042,9 @@ func (api *oliveTinAPI) GetEntity(ctx ctx.Context, req *connect.Request[apiv1.Ge
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 { } else {
res.Title = entity.Title 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 return connect.NewResponse(res), nil
} }

View File

@ -13,16 +13,32 @@ type DashboardRenderRequest struct {
AuthenticatedUser *authpublic.AuthenticatedUser AuthenticatedUser *authpublic.AuthenticatedUser
cfg *config.Config cfg *config.Config
ex *executor.Executor ex *executor.Executor
EntityType string
EntityKey string
} }
func (rr *DashboardRenderRequest) findAction(title string) *apiv1.Action { func (rr *DashboardRenderRequest) findAction(title string) *apiv1.Action {
return rr.findActionForEntity(title, nil)
}
func (rr *DashboardRenderRequest) findActionForEntity(title string, entity *entities.Entity) *apiv1.Action {
rr.ex.MapActionIdToBindingLock.RLock() rr.ex.MapActionIdToBindingLock.RLock()
defer rr.ex.MapActionIdToBindingLock.RUnlock() defer rr.ex.MapActionIdToBindingLock.RUnlock()
for _, binding := range rr.ex.MapActionIdToBinding { for _, binding := range rr.ex.MapActionIdToBinding {
if binding.Action.Title == title { if binding.Action.Title != title {
continue
}
if entity == nil {
if binding.Entity == nil {
return buildAction(binding, rr) return buildAction(binding, rr)
} }
} else {
if binding.Entity != nil && binding.Entity.UniqueKey == entity.UniqueKey {
return buildAction(binding, rr)
}
}
} }
return nil return nil

View File

@ -27,9 +27,11 @@ func buildEntityFieldset(tpl *config.DashboardComponent, ent *entities.Entity, r
return &apiv1.DashboardComponent{ return &apiv1.DashboardComponent{
Title: entities.ParseTemplateWith(tpl.Title, ent), Title: entities.ParseTemplateWith(tpl.Title, ent),
Type: "fieldset", Type: "fieldset",
Contents: removeFieldsetIfHasNoLinks(buildEntityFieldsetContents(tpl.Contents, ent, rr)), Contents: removeFieldsetIfHasNoLinks(buildEntityFieldsetContents(tpl.Contents, ent, tpl.Entity, rr)),
CssClass: entities.ParseTemplateWith(tpl.CssClass, ent), CssClass: entities.ParseTemplateWith(tpl.CssClass, ent),
Action: rr.findAction(tpl.Title), Action: rr.findAction(tpl.Title),
EntityType: tpl.Entity,
EntityKey: ent.UniqueKey,
} }
} }
@ -48,11 +50,11 @@ func removeFieldsetIfHasNoLinks(contents []*apiv1.DashboardComponent) []*apiv1.D
*/ */
} }
func buildEntityFieldsetContents(contents []*config.DashboardComponent, ent *entities.Entity, rr *DashboardRenderRequest) []*apiv1.DashboardComponent { func buildEntityFieldsetContents(contents []*config.DashboardComponent, ent *entities.Entity, entityType string, rr *DashboardRenderRequest) []*apiv1.DashboardComponent {
ret := make([]*apiv1.DashboardComponent, 0) ret := make([]*apiv1.DashboardComponent, 0)
for _, subitem := range contents { for _, subitem := range contents {
c := cloneItem(subitem, ent, rr) c := cloneItem(subitem, ent, entityType, rr)
log.Infof("cloneItem: %+v", c) log.Infof("cloneItem: %+v", c)
@ -64,17 +66,26 @@ func buildEntityFieldsetContents(contents []*config.DashboardComponent, ent *ent
return ret return ret
} }
func cloneItem(subitem *config.DashboardComponent, ent *entities.Entity, rr *DashboardRenderRequest) *apiv1.DashboardComponent { func cloneItem(subitem *config.DashboardComponent, ent *entities.Entity, entityType string, rr *DashboardRenderRequest) *apiv1.DashboardComponent {
clone := &apiv1.DashboardComponent{} clone := &apiv1.DashboardComponent{}
clone.CssClass = entities.ParseTemplateWith(subitem.CssClass, ent) clone.CssClass = entities.ParseTemplateWith(subitem.CssClass, ent)
if subitem.Type == "" || subitem.Type == "link" { if subitem.Type == "" || subitem.Type == "link" {
clone.Type = "link" clone.Type = "link"
clone.Title = entities.ParseTemplateWith(subitem.Title, ent) clone.Title = entities.ParseTemplateWith(subitem.Title, ent)
clone.Action = rr.findAction(subitem.Title) clone.Action = rr.findActionForEntity(subitem.Title, ent)
} else { } else {
clone.Title = entities.ParseTemplateWith(subitem.Title, ent) clone.Title = entities.ParseTemplateWith(subitem.Title, ent)
clone.Type = subitem.Type clone.Type = subitem.Type
if clone.Type == "directory" && ent != nil && entityType != "" {
clone.EntityType = entityType
clone.EntityKey = ent.UniqueKey
}
if len(subitem.Contents) > 0 {
clone.Contents = buildEntityFieldsetContents(subitem.Contents, ent, entityType, rr)
}
} }
return clone return clone

View File

@ -5,6 +5,7 @@ import (
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1" apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
config "github.com/OliveTin/OliveTin/internal/config" config "github.com/OliveTin/OliveTin/internal/config"
entities "github.com/OliveTin/OliveTin/internal/entities"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"golang.org/x/exp/slices" "golang.org/x/exp/slices"
) )
@ -17,6 +18,19 @@ func renderDashboard(rr *DashboardRenderRequest, dashboardTitle string) *apiv1.D
return findAndRenderDashboard(rr, dashboardTitle) return findAndRenderDashboard(rr, dashboardTitle)
} }
func getEntityFromRequest(rr *DashboardRenderRequest) *entities.Entity {
if rr.EntityType == "" || rr.EntityKey == "" {
return nil
}
entityInstances := entities.GetEntityInstances(rr.EntityType)
if entity, ok := entityInstances[rr.EntityKey]; ok {
return entity
}
return nil
}
func findAndRenderDashboard(rr *DashboardRenderRequest, dashboardTitle string) *apiv1.Dashboard { func findAndRenderDashboard(rr *DashboardRenderRequest, dashboardTitle string) *apiv1.Dashboard {
for _, dashboard := range rr.cfg.Dashboards { for _, dashboard := range rr.cfg.Dashboards {
if dashboard.Title != dashboardTitle { if dashboard.Title != dashboardTitle {
@ -31,6 +45,35 @@ func findAndRenderDashboard(rr *DashboardRenderRequest, dashboardTitle string) *
return buildDashboardFromConfig(dashboard, rr) return buildDashboardFromConfig(dashboard, rr)
} }
directoryComponent := findDirectoryComponent(rr, dashboardTitle)
if directoryComponent != nil {
entity := getEntityFromRequest(rr)
return buildDashboardFromConfigWithEntity(directoryComponent, rr, entity)
}
return nil
}
func findDirectoryComponent(rr *DashboardRenderRequest, title string) *config.DashboardComponent {
for _, dashboard := range rr.cfg.Dashboards {
if component := searchDirectoryInComponent(dashboard, title); component != nil {
return component
}
}
return nil
}
func searchDirectoryInComponent(component *config.DashboardComponent, title string) *config.DashboardComponent {
if component.Title == title && len(component.Contents) > 0 && component.Type != "fieldset" {
return component
}
for _, subitem := range component.Contents {
if found := searchDirectoryInComponent(subitem, title); found != nil {
return found
}
}
return nil return nil
} }
@ -42,9 +85,13 @@ func logEmptyDashboard(dashboardTitle, username string) {
} }
func buildDashboardFromConfig(dashboard *config.DashboardComponent, rr *DashboardRenderRequest) *apiv1.Dashboard { func buildDashboardFromConfig(dashboard *config.DashboardComponent, rr *DashboardRenderRequest) *apiv1.Dashboard {
return buildDashboardFromConfigWithEntity(dashboard, rr, nil)
}
func buildDashboardFromConfigWithEntity(dashboard *config.DashboardComponent, rr *DashboardRenderRequest, entity *entities.Entity) *apiv1.Dashboard {
return &apiv1.Dashboard{ return &apiv1.Dashboard{
Title: dashboard.Title, Title: dashboard.Title,
Contents: sortActions(removeNulls(getDashboardComponentContents(dashboard, rr))), Contents: sortActions(removeNulls(getDashboardComponentContentsWithEntity(dashboard, rr, entity))),
} }
} }
@ -119,11 +166,15 @@ func removeNulls(components []*apiv1.DashboardComponent) []*apiv1.DashboardCompo
} }
func getDashboardComponentContents(dashboard *config.DashboardComponent, rr *DashboardRenderRequest) []*apiv1.DashboardComponent { 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 {
ret := make([]*apiv1.DashboardComponent, 0) ret := make([]*apiv1.DashboardComponent, 0)
rootFieldset := createRootFieldset() rootFieldset := createRootFieldset()
for _, subitem := range dashboard.Contents { for _, subitem := range dashboard.Contents {
processDashboardSubitem(subitem, rr, &ret, rootFieldset) processDashboardSubitemWithEntity(subitem, rr, &ret, rootFieldset, entity)
} }
return appendRootFieldsetIfNeeded(ret, rootFieldset) return appendRootFieldsetIfNeeded(ret, rootFieldset)
@ -138,15 +189,19 @@ func createRootFieldset() *apiv1.DashboardComponent {
} }
func processDashboardSubitem(subitem *config.DashboardComponent, rr *DashboardRenderRequest, ret *[]*apiv1.DashboardComponent, rootFieldset *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) {
if subitem.Type != "fieldset" { if subitem.Type != "fieldset" {
rootFieldset.Contents = append(rootFieldset.Contents, buildDashboardComponentSimple(subitem, rr)) rootFieldset.Contents = append(rootFieldset.Contents, buildDashboardComponentSimpleWithEntity(subitem, rr, entity))
return return
} }
if subitem.Entity != "" { if subitem.Entity != "" {
*ret = append(*ret, buildEntityFieldsets(subitem.Entity, subitem, rr)...) *ret = append(*ret, buildEntityFieldsets(subitem.Entity, subitem, rr)...)
} else { } else {
*ret = append(*ret, buildDashboardComponentSimple(subitem, rr)) *ret = append(*ret, buildDashboardComponentSimpleWithEntity(subitem, rr, entity))
} }
} }
@ -158,13 +213,31 @@ func appendRootFieldsetIfNeeded(ret []*apiv1.DashboardComponent, rootFieldset *a
} }
func buildDashboardComponentSimple(subitem *config.DashboardComponent, rr *DashboardRenderRequest) *apiv1.DashboardComponent { 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 {
var contents []*apiv1.DashboardComponent
if len(subitem.Contents) > 0 {
contents = getDashboardComponentContentsWithEntity(subitem, rr, entity)
}
action := rr.findActionForEntity(subitem.Title, entity)
componentType := getDashboardComponentType(subitem, action)
title := subitem.Title
if entity != nil {
title = entities.ParseTemplateWith(subitem.Title, entity)
}
newitem := &apiv1.DashboardComponent{ newitem := &apiv1.DashboardComponent{
Title: subitem.Title, Title: title,
Type: getDashboardComponentType(subitem), Type: componentType,
Contents: getDashboardComponentContents(subitem, rr), Contents: contents,
Icon: getDashboardComponentIcon(subitem, rr.cfg), Icon: getDashboardComponentIcon(subitem, rr.cfg),
CssClass: subitem.CssClass, CssClass: subitem.CssClass,
Action: rr.findAction(subitem.Title), Action: action,
} }
return newitem return newitem
@ -178,7 +251,7 @@ func getDashboardComponentIcon(item *config.DashboardComponent, cfg *config.Conf
return item.Icon return item.Icon
} }
func getDashboardComponentType(item *config.DashboardComponent) string { func getDashboardComponentType(item *config.DashboardComponent, action *apiv1.Action) string {
allowedTypes := []string{ allowedTypes := []string{
"stdout-most-recent-execution", "stdout-most-recent-execution",
"display", "display",
@ -194,5 +267,9 @@ func getDashboardComponentType(item *config.DashboardComponent) string {
return item.Type return item.Type
} }
if action == nil {
return "display"
}
return "link" return "link"
} }