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
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:

View File

@ -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;
};
/**

File diff suppressed because one or more lines are too long

View File

@ -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>

View File

@ -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>

View File

@ -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,

View File

@ -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>

View File

@ -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 {

View File

@ -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" +

View File

@ -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
}

View File

@ -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)
}
}
}

View File

@ -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

View File

@ -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"
}