Explorar el Código

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

jamesread hace 7 meses
padre
commit
56365af24b

+ 22 - 0
config.yaml

@@ -189,12 +189,24 @@ actions:
   - title: Ping hypervisor2
     shell: echo "hypervisor2 online"
 
+  - title: Ping hypervisor3
+    shell: echo "hypervisor3 online"
+
+  - title: Ping hypervisor4
+    shell: echo "hypervisor4 online"
+
   - title: "{{ server.name }} Wake on Lan"
     shell: echo "Sending Wake on LAN to {{ server.hostname }}"
+    icon: <iconify-icon icon="carbon:awake"></iconify-icon>
     entity: server
 
   - title: "{{ server.name }} Power Off"
     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
 
   - title: Ping All Servers
@@ -278,6 +290,11 @@ dashboards:
             contents:
               - title: Ping hypervisor1
               - 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
       # actions grouped together without a folder.
@@ -299,6 +316,11 @@ dashboards:
           - title: '{{ server.name }} Wake on Lan'
           - title: '{{ server.name }} Power Off'
 
+          - title: More Options
+            type: directory
+            contents:
+              - title: '{{ server.name }} Print server name'
+
   # This is the second dashboard.
   - title: My Containers
     contents:

+ 25 - 0
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts

@@ -146,6 +146,11 @@ export declare type Entity = Message<"olivetin.api.v1.Entity"> & {
    * @generated from field: string type = 3;
    */
   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;
    */
   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;
    */
   action?: Action;
+
+  /**
+   * @generated from field: string entity_type = 7;
+   */
+  entityType: string;
+
+  /**
+   * @generated from field: string entity_key = 8;
+   */
+  entityKey: string;
 };
 
 /**

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js


+ 117 - 5
frontend/resources/vue/Dashboard.vue

@@ -11,13 +11,39 @@
     </section>
     <template v-else-if="dashboard">
         <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>
             <p style = "text-align: center" class = "padding">This dashboard is empty.</p>
         </section>
 
         <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">
-                <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>
                     <template v-for="subcomponent in component.contents">
@@ -31,23 +57,54 @@
 
 <script setup>
 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 { Loading03Icon } from '@hugeicons/core-free-icons'
+import { Loading03Icon, ArrowLeftIcon } from '@hugeicons/core-free-icons'
 
 const props = defineProps({
     title: {
         type: String,
         required: false
+    },
+    entityType: {
+        type: String,
+        required: false
+    },
+    entityKey: {
+        type: String,
+        required: false
     }
 })
 
+const router = useRouter()
 const dashboard = ref(null)
 const loadingTime = ref(0)
 const initError = ref(null)
 let loadingTimer = 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() {
     let title = props.title
 
@@ -58,9 +115,16 @@ async function getDashboard() {
     }
 
     try {
-        const ret = await window.client.getDashboard({
+        const request = {
             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) {
             throw new Error('No dashboard found')
@@ -166,6 +230,17 @@ h2 {
     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 {
 	display: grid;
 	grid-template-columns: repeat(auto-fit, 180px);
@@ -183,4 +258,41 @@ fieldset {
         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>

+ 12 - 1
frontend/resources/vue/components/DashboardComponentDirectory.vue

@@ -1,5 +1,5 @@
 <template>
-    <button @click="router.push({ name: 'Dashboard', params: { title: component.title } })">
+    <button @click="navigateToDirectory">
         {{ component.title }}
     </button>
 </template>
@@ -15,6 +15,17 @@ const props = defineProps({
         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>
 
 <style scoped>

+ 1 - 1
frontend/resources/vue/router.js

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

+ 127 - 4
frontend/resources/vue/views/EntityDetailsView.vue

@@ -1,17 +1,62 @@
 <template>
 	<Section title="Entity Details">
-		<div>
-			<p v-if="!entityDetails">Loading entity details...</p>
-			<p v-else-if="!entityDetails.title">No details available for this entity.</p>
-			<p v-else>{{ entityDetails.title }}</p>
+		<template #toolbar>
+			<button @click="goBack" class="back-button">
+				<HugeiconsIcon :icon="ArrowLeftIcon" width="1.2em" height="1.2em" />
+				<span>Back</span>
+			</button>
+		</template>
+		<div v-if="!entityDetails">
+			<p>Loading entity details...</p>
 		</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>
 </template>
 
 <script setup>
 	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'
 
+	const router = useRouter()
 	const entityDetails = ref(null)
 
 	const props = defineProps({
@@ -19,6 +64,10 @@
 		entityKey: String
 	})
 
+	function goBack() {
+		router.push({ name: 'Entities' })
+	}
+
 	async function fetchEntityDetails() {
 		try {
 			const response = await window.client.getEntity({
@@ -38,3 +87,77 @@
 	})
 
 </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>

+ 5 - 0
proto/olivetin/api/v1/olivetin.proto

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

+ 55 - 6
service/gen/olivetin/api/v1/olivetin.pb.go

@@ -270,6 +270,7 @@ type Entity struct {
 	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"`
 	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
 	sizeCache     protoimpl.SizeCache
 }
@@ -325,6 +326,13 @@ func (x *Entity) GetType() string {
 	return ""
 }
 
+func (x *Entity) GetDirectories() []string {
+	if x != nil {
+		return x.Directories
+	}
+	return nil
+}
+
 type GetDashboardResponse struct {
 	state         protoimpl.MessageState `protogen:"open.v1"`
 	Title         string                 `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"`
@@ -432,6 +440,8 @@ func (x *EffectivePolicy) GetShowLogList() bool {
 type GetDashboardRequest struct {
 	state         protoimpl.MessageState `protogen:"open.v1"`
 	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
 	sizeCache     protoimpl.SizeCache
 }
@@ -473,6 +483,20 @@ func (x *GetDashboardRequest) GetTitle() string {
 	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 {
 	state         protoimpl.MessageState `protogen:"open.v1"`
 	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"`
 	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"`
+	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
 	sizeCache     protoimpl.SizeCache
 }
@@ -609,6 +635,20 @@ func (x *DashboardComponent) GetAction() *Action {
 	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 {
 	state            protoimpl.MessageState `protogen:"open.v1"`
 	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" +
 	"\x14ActionArgumentChoice\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" +
 	"\x05title\x18\x01 \x01(\tR\x05title\x12\x1d\n" +
 	"\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" +
 	"\x05title\x18\x01 \x01(\tR\x05title\x128\n" +
 	"\tdashboard\x18\x04 \x01(\v2\x1a.olivetin.api.v1.DashboardR\tdashboard\"`\n" +
 	"\x0fEffectivePolicy\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" +
-	"\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" +
 	"\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" +
 	"\x05title\x18\x01 \x01(\tR\x05title\x12\x12\n" +
 	"\x04type\x18\x02 \x01(\tR\x04type\x12?\n" +
 	"\bcontents\x18\x03 \x03(\v2#.olivetin.api.v1.DashboardComponentR\bcontents\x12\x12\n" +
 	"\x04icon\x18\x04 \x01(\tR\x04icon\x12\x1b\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" +
 	"\n" +
 	"binding_id\x18\x01 \x01(\tR\tbindingId\x12B\n" +

+ 40 - 3
service/internal/api/api.go

@@ -415,7 +415,13 @@ func (api *oliveTinAPI) GetDashboard(ctx ctx.Context, req *connect.Request[apiv1
 		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) {
 		return api.buildDefaultDashboardResponse(dashboardRenderRequest)
@@ -431,11 +437,13 @@ func (api *oliveTinAPI) checkDashboardAccess(user *authpublic.AuthenticatedUser)
 	return nil
 }
 
-func (api *oliveTinAPI) createDashboardRenderRequest(user *authpublic.AuthenticatedUser) *DashboardRenderRequest {
+func (api *oliveTinAPI) createDashboardRenderRequest(user *authpublic.AuthenticatedUser, entityType, entityKey string) *DashboardRenderRequest {
 	return &DashboardRenderRequest{
 		AuthenticatedUser: user,
 		cfg:               api.cfg,
 		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 {
 	var rootDashboards []string
-	dashboardRenderRequest := api.createDashboardRenderRequest(user)
+	dashboardRenderRequest := api.createDashboardRenderRequest(user, "", "")
 
 	api.addDefaultDashboardIfNeeded(&rootDashboards, 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) {
 	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))
 	} 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
 	}

+ 18 - 2
service/internal/api/apiActions.go

@@ -13,15 +13,31 @@ type DashboardRenderRequest struct {
 	AuthenticatedUser *authpublic.AuthenticatedUser
 	cfg               *config.Config
 	ex                *executor.Executor
+	EntityType        string
+	EntityKey         string
 }
 
 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()
 	defer rr.ex.MapActionIdToBindingLock.RUnlock()
 
 	for _, binding := range rr.ex.MapActionIdToBinding {
-		if binding.Action.Title == title {
-			return buildAction(binding, rr)
+		if binding.Action.Title != title {
+			continue
+		}
+
+		if entity == nil {
+			if binding.Entity == nil {
+				return buildAction(binding, rr)
+			}
+		} else {
+			if binding.Entity != nil && binding.Entity.UniqueKey == entity.UniqueKey {
+				return buildAction(binding, rr)
+			}
 		}
 	}
 

+ 20 - 9
service/internal/api/dashboard_entities.go

@@ -25,11 +25,13 @@ func buildEntityFieldsets(entityTitle string, tpl *config.DashboardComponent, rr
 
 func buildEntityFieldset(tpl *config.DashboardComponent, ent *entities.Entity, rr *DashboardRenderRequest) *apiv1.DashboardComponent {
 	return &apiv1.DashboardComponent{
-		Title:    entities.ParseTemplateWith(tpl.Title, ent),
-		Type:     "fieldset",
-		Contents: removeFieldsetIfHasNoLinks(buildEntityFieldsetContents(tpl.Contents, ent, rr)),
-		CssClass: entities.ParseTemplateWith(tpl.CssClass, ent),
-		Action:   rr.findAction(tpl.Title),
+		Title:     entities.ParseTemplateWith(tpl.Title, ent),
+		Type:      "fieldset",
+		Contents:  removeFieldsetIfHasNoLinks(buildEntityFieldsetContents(tpl.Contents, ent, tpl.Entity, rr)),
+		CssClass:  entities.ParseTemplateWith(tpl.CssClass, ent),
+		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)
 
 	for _, subitem := range contents {
-		c := cloneItem(subitem, ent, rr)
+		c := cloneItem(subitem, ent, entityType, rr)
 
 		log.Infof("cloneItem: %+v", c)
 
@@ -64,17 +66,26 @@ func buildEntityFieldsetContents(contents []*config.DashboardComponent, ent *ent
 	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.CssClass = entities.ParseTemplateWith(subitem.CssClass, ent)
 
 	if subitem.Type == "" || subitem.Type == "link" {
 		clone.Type = "link"
 		clone.Title = entities.ParseTemplateWith(subitem.Title, ent)
-		clone.Action = rr.findAction(subitem.Title)
+		clone.Action = rr.findActionForEntity(subitem.Title, ent)
 	} else {
 		clone.Title = entities.ParseTemplateWith(subitem.Title, ent)
 		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

+ 86 - 9
service/internal/api/dashboards.go

@@ -5,6 +5,7 @@ import (
 
 	apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
 	config "github.com/OliveTin/OliveTin/internal/config"
+	entities "github.com/OliveTin/OliveTin/internal/entities"
 	log "github.com/sirupsen/logrus"
 	"golang.org/x/exp/slices"
 )
@@ -17,6 +18,19 @@ func renderDashboard(rr *DashboardRenderRequest, dashboardTitle string) *apiv1.D
 	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 {
 	for _, dashboard := range rr.cfg.Dashboards {
 		if dashboard.Title != dashboardTitle {
@@ -31,6 +45,35 @@ func findAndRenderDashboard(rr *DashboardRenderRequest, dashboardTitle string) *
 		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
 }
 
@@ -42,9 +85,13 @@ func logEmptyDashboard(dashboardTitle, username string) {
 }
 
 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{
 		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 {
+	return getDashboardComponentContentsWithEntity(dashboard, rr, nil)
+}
+
+func getDashboardComponentContentsWithEntity(dashboard *config.DashboardComponent, rr *DashboardRenderRequest, entity *entities.Entity) []*apiv1.DashboardComponent {
 	ret := make([]*apiv1.DashboardComponent, 0)
 	rootFieldset := createRootFieldset()
 
 	for _, subitem := range dashboard.Contents {
-		processDashboardSubitem(subitem, rr, &ret, rootFieldset)
+		processDashboardSubitemWithEntity(subitem, rr, &ret, rootFieldset, entity)
 	}
 
 	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) {
+	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" {
-		rootFieldset.Contents = append(rootFieldset.Contents, buildDashboardComponentSimple(subitem, rr))
+		rootFieldset.Contents = append(rootFieldset.Contents, buildDashboardComponentSimpleWithEntity(subitem, rr, entity))
 		return
 	}
 
 	if subitem.Entity != "" {
 		*ret = append(*ret, buildEntityFieldsets(subitem.Entity, subitem, rr)...)
 	} 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 {
+	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{
-		Title:    subitem.Title,
-		Type:     getDashboardComponentType(subitem),
-		Contents: getDashboardComponentContents(subitem, rr),
+		Title:    title,
+		Type:     componentType,
+		Contents: contents,
 		Icon:     getDashboardComponentIcon(subitem, rr.cfg),
 		CssClass: subitem.CssClass,
-		Action:   rr.findAction(subitem.Title),
+		Action:   action,
 	}
 
 	return newitem
@@ -178,7 +251,7 @@ func getDashboardComponentIcon(item *config.DashboardComponent, cfg *config.Conf
 	return item.Icon
 }
 
-func getDashboardComponentType(item *config.DashboardComponent) string {
+func getDashboardComponentType(item *config.DashboardComponent, action *apiv1.Action) string {
 	allowedTypes := []string{
 		"stdout-most-recent-execution",
 		"display",
@@ -194,5 +267,9 @@ func getDashboardComponentType(item *config.DashboardComponent) string {
 		return item.Type
 	}
 
+	if action == nil {
+		return "display"
+	}
+
 	return "link"
 }

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio