James Read 7 месяцев назад
Родитель
Сommit
d4b8743a57
36 измененных файлов с 1548 добавлено и 187 удалено
  1. 22 0
      config.yaml
  2. 25 4
      frontend/main.js
  3. 48 27
      frontend/package-lock.json
  4. 3 3
      frontend/package.json
  5. 25 0
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts
  6. 0 0
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js
  7. 3 1
      frontend/resources/vue/App.vue
  8. 121 7
      frontend/resources/vue/Dashboard.vue
  9. 31 8
      frontend/resources/vue/components/ActionStatusDisplay.vue
  10. 7 10
      frontend/resources/vue/components/DashboardComponent.vue
  11. 60 0
      frontend/resources/vue/components/DashboardComponentDirectory.vue
  12. 37 0
      frontend/resources/vue/components/DashboardComponentDisplay.vue
  13. 149 0
      frontend/resources/vue/components/DashboardComponentMostRecentExecution.vue
  14. 3 2
      frontend/resources/vue/router.js
  15. 162 17
      frontend/resources/vue/views/DiagnosticsView.vue
  16. 127 4
      frontend/resources/vue/views/EntityDetailsView.vue
  17. 2 33
      frontend/resources/vue/views/LogsListView.vue
  18. 0 14
      frontend/style.css
  19. 3 3
      integration-tests/package-lock.json
  20. 16 0
      integration-tests/tests/pageTitle/config.yaml
  21. 51 0
      integration-tests/tests/pageTitle/pageTitle.mjs
  22. 1 1
      integration-tests/tests/sleep/sleep.js
  23. 21 0
      integration-tests/tests/stdoutMostRecentExecution/config.yaml
  24. 141 0
      integration-tests/tests/stdoutMostRecentExecution/stdoutMostRecentExecution.mjs
  25. 85 0
      lang/combined_output.json
  26. 17 0
      lang/de-DE.yaml
  27. 17 0
      lang/en.yaml
  28. 17 0
      lang/es-ES.yaml
  29. 17 0
      lang/it-IT.yaml
  30. 18 1
      lang/zh-Hans-CN.yaml
  31. 5 0
      proto/olivetin/api/v1/olivetin.proto
  32. 55 6
      service/gen/olivetin/api/v1/olivetin.pb.go
  33. 61 6
      service/internal/api/api.go
  34. 19 1
      service/internal/api/apiActions.go
  35. 43 15
      service/internal/api/dashboard_entities.go
  36. 136 24
      service/internal/api/dashboards.go

+ 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 - 4
frontend/main.js

@@ -11,7 +11,7 @@ import { createConnectTransport } from '@connectrpc/connect-web'
 
 import { OliveTinApiService } from './resources/scripts/gen/olivetin/api/v1/olivetin_pb'
 
-import { createApp } from 'vue'
+import { createApp, h } from 'vue'
 import { createI18n } from 'vue-i18n'
 
 import router from './resources/vue/router.js'
@@ -91,12 +91,33 @@ function setupVue (i18nSettings) {
   app.mount('#app')
 }
 
+function setupErrorDisplay (errorMessage) {
+  const ErrorApp = {
+    render() {
+      return h('section', { class: 'bad', style: 'padding: 2em; text-align: center; margin: 2em auto;' }, [
+        h('h2', 'OliveTin Init Failed'),
+        h('p', errorMessage),
+        h('p', 'Please check your browser console for more details.')
+      ])
+    }
+  }
+
+  const app = createApp(ErrorApp)
+  app.mount('#app')
+}
+
 async function main () {
-  const i18nSettings = await initClient()
+  try {
+    const i18nSettings = await initClient()
 
-  initWebsocket()
+    initWebsocket()
 
-  setupVue(i18nSettings)
+    setupVue(i18nSettings)
+  } catch (err) {
+    const errorMessage = err.message || 'Failed to initialize. Please check your configuration and try again.'
+    console.error('Init failed:', err)
+    setupErrorDisplay(errorMessage)
+  }
 }
 
 main()

+ 48 - 27
frontend/package-lock.json

@@ -17,16 +17,16 @@
 				"@xterm/addon-fit": "^0.10.0",
 				"@xterm/xterm": "^5.5.0",
 				"iconify-icon": "^3.0.2",
-				"picocrank": "^1.8.9",
+				"picocrank": "^1.9.0",
 				"standard": "^17.1.2",
 				"unplugin-vue-components": "^30.0.0",
 				"vite": "^7.2.4",
-				"vue-i18n": "^11.2.1",
+				"vue-i18n": "^11.2.2",
 				"vue-router": "^4.6.3"
 			},
 			"devDependencies": {
 				"process": "^0.11.10",
-				"stylelint": "^16.26.0",
+				"stylelint": "^16.26.1",
 				"stylelint-config-standard": "^39.0.1"
 			}
 		},
@@ -281,6 +281,26 @@
 				"@csstools/css-tokenizer": "^3.0.4"
 			}
 		},
+		"node_modules/@csstools/css-syntax-patches-for-csstree": {
+			"version": "1.0.20",
+			"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.20.tgz",
+			"integrity": "sha512-8BHsjXfSciZxjmHQOuVdW2b8WLUPts9a+mfL13/PzEviufUEW2xnvQuOlKs9dRBHgRqJ53SF/DUoK9+MZk72oQ==",
+			"dev": true,
+			"funding": [
+				{
+					"type": "github",
+					"url": "https://github.com/sponsors/csstools"
+				},
+				{
+					"type": "opencollective",
+					"url": "https://opencollective.com/csstools"
+				}
+			],
+			"license": "MIT-0",
+			"engines": {
+				"node": ">=18"
+			}
+		},
 		"node_modules/@csstools/css-tokenizer": {
 			"version": "3.0.4",
 			"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
@@ -490,13 +510,13 @@
 			"license": "MIT"
 		},
 		"node_modules/@intlify/core-base": {
-			"version": "11.2.1",
-			"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.2.1.tgz",
-			"integrity": "sha512-2V1A4yaN9ElAnQ6ih3HHEc+jZ+sHV6BlQHjCsnIVlOotL5NCUgJElIxgUFiJs6zV4puoAq3hHuQIfWNp+J+8yQ==",
+			"version": "11.2.2",
+			"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.2.2.tgz",
+			"integrity": "sha512-0mCTBOLKIqFUP3BzwuFW23hYEl9g/wby6uY//AC5hTgQfTsM2srCYF2/hYGp+a5DZ/HIFIgKkLJMzXTt30r0JQ==",
 			"license": "MIT",
 			"dependencies": {
-				"@intlify/message-compiler": "11.2.1",
-				"@intlify/shared": "11.2.1"
+				"@intlify/message-compiler": "11.2.2",
+				"@intlify/shared": "11.2.2"
 			},
 			"engines": {
 				"node": ">= 16"
@@ -506,12 +526,12 @@
 			}
 		},
 		"node_modules/@intlify/message-compiler": {
-			"version": "11.2.1",
-			"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.2.1.tgz",
-			"integrity": "sha512-J2454D3Agg3Kvgaj14gxTleJU8/H06Sisz7C2BwiHF0/i5Soyfb5ySpwn8GCL6yscDbOGj6xM+lUe6gO6BFQyg==",
+			"version": "11.2.2",
+			"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.2.2.tgz",
+			"integrity": "sha512-XS2p8Ff5JxWsKhgfld4/MRQzZRQ85drMMPhb7Co6Be4ZOgqJX1DzcZt0IFgGTycgqL8rkYNwgnD443Q+TapOoA==",
 			"license": "MIT",
 			"dependencies": {
-				"@intlify/shared": "11.2.1",
+				"@intlify/shared": "11.2.2",
 				"source-map-js": "^1.0.2"
 			},
 			"engines": {
@@ -522,9 +542,9 @@
 			}
 		},
 		"node_modules/@intlify/shared": {
-			"version": "11.2.1",
-			"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.2.1.tgz",
-			"integrity": "sha512-O67LZM4dbfr70WCsZLW+g+pIXdgQ66laLVd/FicW7iYgP/RuH0X1FDGSh+Hr9Gou/8TeldUE6KmTGdLwX2ufIA==",
+			"version": "11.2.2",
+			"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.2.2.tgz",
+			"integrity": "sha512-OtCmyFpSXxNu/oET/aN6HtPCbZ01btXVd0f3w00YsHOb13Kverk1jzA2k47pAekM55qbUw421fvPF1yxZ+gicw==",
 			"license": "MIT",
 			"engines": {
 				"node": ">= 16"
@@ -4031,9 +4051,9 @@
 			"license": "ISC"
 		},
 		"node_modules/picocrank": {
-			"version": "1.8.9",
-			"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.8.9.tgz",
-			"integrity": "sha512-5NcLEYy4BSPhZm0tY8l/DLxaQoaOuHaf43S0MUgsKyLEiUIn9WnZGsDXHTlGnqNk6VK4+HnHs2rZnt3i5gj7FQ==",
+			"version": "1.9.0",
+			"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.9.0.tgz",
+			"integrity": "sha512-51Ej4F0tgUp3JxywmiMW/IR6orTjs0aXsWMAbMHqYQNmFxmsR3SgQY7J9dhTCq27BMP43KGtGdx9dcItz23JkQ==",
 			"license": "ISC",
 			"dependencies": {
 				"@hugeicons/core-free-icons": "^2.0.0",
@@ -4990,9 +5010,9 @@
 			}
 		},
 		"node_modules/stylelint": {
-			"version": "16.26.0",
-			"resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.26.0.tgz",
-			"integrity": "sha512-Y/3AVBefrkqqapVYH3LBF5TSDZ1kw+0XpdKN2KchfuhMK6lQ85S4XOG4lIZLcrcS4PWBmvcY6eS2kCQFz0jukQ==",
+			"version": "16.26.1",
+			"resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.26.1.tgz",
+			"integrity": "sha512-v20V59/crfc8sVTAtge0mdafI3AdnzQ2KsWe6v523L4OA1bJO02S7MO2oyXDCS6iWb9ckIPnqAFVItqSBQr7jw==",
 			"dev": true,
 			"funding": [
 				{
@@ -5007,6 +5027,7 @@
 			"license": "MIT",
 			"dependencies": {
 				"@csstools/css-parser-algorithms": "^3.0.5",
+				"@csstools/css-syntax-patches-for-csstree": "^1.0.19",
 				"@csstools/css-tokenizer": "^3.0.4",
 				"@csstools/media-query-list-parser": "^4.0.3",
 				"@csstools/selector-specificity": "^5.0.0",
@@ -5019,7 +5040,7 @@
 				"debug": "^4.4.3",
 				"fast-glob": "^3.3.3",
 				"fastest-levenshtein": "^1.0.16",
-				"file-entry-cache": "^11.1.0",
+				"file-entry-cache": "^11.1.1",
 				"global-modules": "^2.0.0",
 				"globby": "^11.1.0",
 				"globjoin": "^0.1.4",
@@ -5669,13 +5690,13 @@
 			}
 		},
 		"node_modules/vue-i18n": {
-			"version": "11.2.1",
-			"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.2.1.tgz",
-			"integrity": "sha512-cc3Wx4eJZac9WMS8mxhfYiCipm9PBQ2Dz15piWYm7DwNcCehaKRgpolEdiqrjjT27T3Wijz3xJ7NeIc8ofIWAA==",
+			"version": "11.2.2",
+			"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.2.2.tgz",
+			"integrity": "sha512-ULIKZyRluUPRCZmihVgUvpq8hJTtOqnbGZuv4Lz+byEKZq4mU0g92og414l6f/4ju+L5mORsiUuEPYrAuX2NJg==",
 			"license": "MIT",
 			"dependencies": {
-				"@intlify/core-base": "11.2.1",
-				"@intlify/shared": "11.2.1",
+				"@intlify/core-base": "11.2.2",
+				"@intlify/shared": "11.2.2",
 				"@vue/devtools-api": "^6.5.0"
 			},
 			"engines": {

+ 3 - 3
frontend/package.json

@@ -6,7 +6,7 @@
 	"source": "index.html",
 	"devDependencies": {
 		"process": "^0.11.10",
-		"stylelint": "^16.26.0",
+		"stylelint": "^16.26.1",
 		"stylelint-config-standard": "^39.0.1"
 	},
 	"scripts": {
@@ -30,11 +30,11 @@
 		"@xterm/addon-fit": "^0.10.0",
 		"@xterm/xterm": "^5.5.0",
 		"iconify-icon": "^3.0.2",
-		"picocrank": "^1.8.9",
+		"picocrank": "^1.9.0",
 		"standard": "^17.1.2",
 		"unplugin-vue-components": "^30.0.0",
 		"vite": "^7.2.4",
-		"vue-i18n": "^11.2.1",
+		"vue-i18n": "^11.2.2",
 		"vue-router": "^4.6.3"
 	}
 }

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

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js


+ 3 - 1
frontend/resources/vue/App.vue

@@ -1,5 +1,5 @@
 <template>
-    <Header title="OliveTin" :logoUrl="logoUrl" @toggleSidebar="toggleSidebar" :sidebarEnabled="showNavigation">
+    <Header :title="pageTitle" :logoUrl="logoUrl" @toggleSidebar="toggleSidebar" :sidebarEnabled="showNavigation">
         <template #toolbar>
             <div id="banner" v-if="bannerMessage" :style="bannerCss">
                 <p>{{ bannerMessage }}</p>
@@ -95,6 +95,7 @@ const username = ref('notset');
 const isLoggedIn = ref(false);
 const serverConnection = ref(true);
 const currentVersion = ref('?');
+const pageTitle = ref('OliveTin');
 const bannerMessage = ref('');
 const bannerCss = ref('');
 const hasLoaded = ref(false);
@@ -168,6 +169,7 @@ function updateHeaderFromInit() {
     username.value = window.initResponse.authenticatedUser
     isLoggedIn.value = window.initResponse.authenticatedUser !== '' && window.initResponse.authenticatedUser !== 'guest'
     currentVersion.value = window.initResponse.currentVersion
+    pageTitle.value = window.initResponse.pageTitle || 'OliveTin'
     bannerMessage.value = window.initResponse.bannerMessage || ''
     bannerCss.value = window.initResponse.bannerCss || ''
     showFooter.value = window.initResponse.showFooter

+ 121 - 7
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,16 +115,24 @@ 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')
         }
 
         dashboard.value = ret.dashboard 
-        document.title = ret.dashboard.title + ' - OliveTin'
+        const pageTitle = window.initResponse?.pageTitle || 'OliveTin'
+        document.title = ret.dashboard.title + ' - ' + pageTitle
         
         // Clear any previous init error since we successfully loaded
         initError.value = null
@@ -84,7 +149,8 @@ async function getDashboard() {
         // On error, provide a safe fallback state
         console.error('Failed to load dashboard', e)
         dashboard.value = { title: title || 'Default', contents: [] }
-        document.title = 'Error - OliveTin'
+        const pageTitle = window.initResponse?.pageTitle || 'OliveTin'
+        document.title = 'Error - ' + pageTitle
         
         // Stop the loading timer on error
         if (loadingTimer) {
@@ -164,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);
@@ -181,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>

+ 31 - 8
frontend/resources/vue/components/ActionStatusDisplay.vue

@@ -1,7 +1,7 @@
 <template>
-    <span>
-        <span :class="['action-status', statusClass]">{{ statusText }}</span><span>{{ exitCodeText }}</span>
-    </span>
+    <div :class = "statusClass + ' annotation'">
+        <span>{{ statusText }}</span><span>{{ exitCodeText }}</span>
+    </div>
 
 </template>
 
@@ -35,11 +35,14 @@ const statusText = computed(() => {
 const exitCodeText = computed(() => {
     const logEntry = props.logEntry
     if (!logEntry) return ''
+    if (logEntry.exitCode === 0) {
+        return ''
+    }
     if (logEntry.executionFinished) {
         if (logEntry.blocked || logEntry.timedOut) {
             return ''
         }
-        return ' Exit code: ' + logEntry.exitCode
+        return ' (Exit code: ' + logEntry.exitCode + ')'
     }
     return ''
 })
@@ -49,15 +52,35 @@ const statusClass = computed(() => {
     if (!logEntry) return ''
     if (logEntry.executionFinished) {
         if (logEntry.blocked) {
-            return 'action-blocked'
+            return 'status-blocked'
         } else if (logEntry.timedOut) {
-            return 'action-timeout'
+            return 'status-timeout'
         } else if (logEntry.exitCode === 0) {
-            return 'action-success'
+            return 'status-success'
         } else {
-            return 'action-nonzero-exit'
+            return 'status-nonzero-exit'
         }
     }
     return ''
 })
 </script>
+
+<style scoped>
+.status-success {
+  color: var(--karma-good-fg);
+}
+
+.status-nonzero-exit {
+  color: var(--karma-bad-fg);
+}
+
+.status-timeout {
+  color: var(--karma-warning-fg);
+}
+
+.status-blocked {
+  color: #ca79ff;
+}
+
+
+</style>

+ 7 - 10
frontend/resources/vue/components/DashboardComponent.vue

@@ -1,17 +1,11 @@
 <template>
     <ActionButton v-if="component.type == 'link'" :actionData="component.action" :key="component.title" />
 
-    <div v-else-if="component.type == 'directory'">
-        <router-link :to="{ name: 'Dashboard', params: { title: component.title } }" class="dashboard-link">
-            <button>
-                {{ component.title }}
-            </button>
-        </router-link>
-    </div>
+    <DashboardComponentDirectory v-else-if="component.type == 'directory'" :component="component" />
 
-    <div v-else-if="component.type == 'display'" class="display">
-        <div v-html="component.title" />
-    </div>
+    <DashboardComponentDisplay v-else-if="component.type == 'display'" :component="component" />
+
+    <DashboardComponentMostRecentExecution v-else-if="component.type == 'stdout-most-recent-execution'" :component="component" />
 
     <template v-else-if="component.type == 'fieldset'">
         <template v-for="subcomponent in component.contents" :key="subcomponent.title">
@@ -28,6 +22,9 @@
 
 <script setup>
 import ActionButton from '../ActionButton.vue'
+import DashboardComponentMostRecentExecution from './DashboardComponentMostRecentExecution.vue'
+import DashboardComponentDirectory from './DashboardComponentDirectory.vue'
+import DashboardComponentDisplay from './DashboardComponentDisplay.vue'
 
 const props = defineProps({
     component: {

+ 60 - 0
frontend/resources/vue/components/DashboardComponentDirectory.vue

@@ -0,0 +1,60 @@
+<template>
+    <button @click="navigateToDirectory">
+        {{ component.title }}
+    </button>
+</template>
+
+<script setup>
+import { useRouter } from 'vue-router'
+
+const router = useRouter()
+
+const props = defineProps({
+    component: {
+        type: Object,
+        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>
+.folder-container {
+    display: grid;
+}
+
+button {
+    box-shadow: 0 0 .6em #aaa;
+    background-color: #fff;
+    border-radius: .7em;
+}
+
+button:hover {
+    background-color: #f5f5f5;
+    border-color: #999;
+}
+
+@media (prefers-color-scheme: dark) {
+    button {
+        box-shadow: 0 0 .6em #000;
+        background-color: #111;
+        border-color: #000;
+    }
+
+    button:hover {
+        background-color: #222;
+        border-color: #000;
+    }
+}
+
+</style>

+ 37 - 0
frontend/resources/vue/components/DashboardComponentDisplay.vue

@@ -0,0 +1,37 @@
+<template>
+    <div class="display">
+        <div v-html="component.title" />
+    </div>
+</template>
+
+<script setup>
+const props = defineProps({
+    component: {
+        type: Object,
+        required: true
+    }
+})
+</script>
+
+<style scoped>
+.display {
+	padding: 1em;
+	border-radius: .7em;
+	box-shadow: 0 0 .6em #aaa;
+	text-align: center;
+	font-size: small;
+	display: flex;
+	flex-direction: column;
+	flex-grow: 1;
+	justify-content: center;
+	align-items: center;
+}
+
+@media (prefers-color-scheme: dark) {
+    .display {
+        border-color: #000;
+        box-shadow: 0 0 .6em #000;
+    }
+}
+
+</style>

+ 149 - 0
frontend/resources/vue/components/DashboardComponentMostRecentExecution.vue

@@ -0,0 +1,149 @@
+<template>
+  <div class="mre-container">   
+    <router-link 
+        v-if="executionTrackingId" 
+        :to="`/logs/${executionTrackingId}`" 
+        class="mre-link"
+    >
+        <pre class="mre-output">{{ output }}</pre>
+    </router-link>
+    <pre v-else class="mre-output fg-important">{{ output }}</pre>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, onBeforeUnmount } from 'vue'
+
+const props = defineProps({
+  component: {
+    type: Object,
+    required: true
+  }
+})
+
+const output = ref('Waiting...')
+const executionTrackingId = ref(null)
+let eventListener = null
+
+async function fetchMostRecentExecution() {
+  if (!props.component.title) {
+    output.value = 'Error: No action ID specified'
+    executionTrackingId.value = null
+    return
+  }
+
+  if (!window.client) {
+    output.value = 'Error: Client not initialized'
+    executionTrackingId.value = null
+    return
+  }
+
+  try {
+    const executionStatusArgs = {
+      actionId: props.component.title
+    }
+
+    const result = await window.client.executionStatus(executionStatusArgs)
+    
+    if (result.logEntry) {
+      if (result.logEntry.output !== undefined) {
+        output.value = result.logEntry.output
+      } else {
+        output.value = 'No output available'
+      }
+      if (result.logEntry.executionTrackingId) {
+        executionTrackingId.value = result.logEntry.executionTrackingId
+      }
+    } else {
+      output.value = 'No output available'
+      executionTrackingId.value = null
+    }
+  } catch (err) {
+    if (err.code === 'NotFound' || err.status === 404) {
+      output.value = 'No execution found'
+      executionTrackingId.value = null
+    } else {
+      output.value = 'Error: ' + (err.message || 'Failed to fetch execution')
+      console.error('Failed to fetch most recent execution:', err)
+      executionTrackingId.value = null
+    }
+  }
+}
+
+function handleExecutionFinished(event) {
+  // The dashboard component "title" field is used for lots of things
+  // and in this context for MreOutput it's just to refer to an actionId.
+  //
+  // So this is not a typo.
+  const logEntry = event.payload.logEntry
+  if (logEntry && logEntry.actionId === props.component.title) {
+    if (logEntry.output !== undefined) {
+      output.value = logEntry.output
+    }
+    if (logEntry.executionTrackingId) {
+      executionTrackingId.value = logEntry.executionTrackingId
+    }
+  }
+}
+
+onMounted(() => {
+  fetchMostRecentExecution()
+  
+  eventListener = (event) => handleExecutionFinished(event)
+  window.addEventListener('EventExecutionFinished', eventListener)
+})
+
+onBeforeUnmount(() => {
+  if (eventListener) {
+    window.removeEventListener('EventExecutionFinished', eventListener)
+  }
+})
+</script>
+
+<style scoped>
+.mre-container {
+  display: grid;
+  grid-column: span 2;
+}
+
+.mre-link {
+  text-decoration: none;
+  color: inherit;
+  display: grid;
+  cursor: pointer;
+  grid-column: span 2;
+}
+
+.mre-link:hover .mre-output {
+  border-color: #999;
+}
+
+.mre-output {
+  box-shadow: 0 0 .6em #aaa;
+  border: 1px dashed #ccc;
+  border-radius: .7em;
+  padding: 1em;
+  margin: 0;
+  min-height: 0;
+  white-space: pre-wrap;
+  word-wrap: break-word;
+  font-family: monospace;
+  font-size: 0.9em;
+  overflow-x: auto;
+  overflow-y: auto;
+  transition: border-color 0.2s ease;
+  max-height: 20em;
+}
+
+@media (prefers-color-scheme: dark) {
+  .mre-output {
+    border: 1px dashed #444;
+    box-shadow: 0 0 .6em #444;
+  }
+  
+  .mre-link:hover .mre-output {
+    border-color: #666;
+  }
+}
+</style>
+

+ 3 - 2
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,
@@ -128,7 +128,8 @@ const router = createRouter({
 // Navigation guard to update page title
 router.beforeEach((to, from, next) => {
   if (to.meta && to.meta.title) {
-    document.title = to.meta.title + " - OliveTin"
+    const pageTitle = window.initResponse?.pageTitle || 'OliveTin'
+    document.title = to.meta.title + " - " + pageTitle
   }
   next()
 })

+ 162 - 17
frontend/resources/vue/views/DiagnosticsView.vue

@@ -1,44 +1,62 @@
 <template>
-  <Section title = "Get support">
-    <p>If you are having problems with OliveTin and want to raise a support request, it would be very helpful to include a sosreport from this page.
+  <Section :title="t('diagnostics.get-support')">
+    <p>{{ t('diagnostics.get-support-description') }}
     </p>
     <ul>
       <li>
-        <a href="https://docs.olivetin.app/sosreport.html" target="_blank">sosreport Documentation</a>
-      </li>
-      <li>
-        <a href = "https://docs.olivetin.app/troubleshooting/wheretofindhelp.html" target="_blank">Where to find help</a>
+        <a href = "https://docs.olivetin.app/troubleshooting/wheretofindhelp.html" target="_blank">{{ t('diagnostics.where-to-find-help') }}</a>
       </li>
     </ul>
   </Section>
 
-  <Section title = "SSH">
+  <Section :title="t('diagnostics.ssh')">
     <dl>
-      <dt>Found Key</dt>
+      <dt>{{ t('diagnostics.found-key') }}</dt>
       <dd>{{ diagnostics.sshFoundKey || '?' }}</dd>
-      <dt>Found Config</dt>
+      <dt>{{ t('diagnostics.found-config') }}</dt>
       <dd>{{ diagnostics.sshFoundConfig || '?' }}</dd>
     </dl>
   </Section>
 
-  <Section title = "SOS Report">
-    <p>This section allows you to generate a detailed report of your configuration and environment. It is a good idea to include this when raising a support request.</p>
+  <Section :title="t('diagnostics.sos-report')">
+    <p>{{ t('diagnostics.sos-report-description') }}</p>
+    <p>
+      <a href="https://docs.olivetin.app/troubleshooting/sosreport.html" target="_blank">{{ t('diagnostics.sos-report-docs') }}</a>
+    </p>
+
+    <div role="toolbar">
+      <button @click="generateSosReport" :disabled="loading" class = "good">{{ t('diagnostics.generate-sos-report') }}</button>
+      <button @click="copySosReport" :disabled="!sosReport || loading" :class="sosReportCopied ? 'good' : ''">{{ sosReportCopied ? t('diagnostics.copied') : t('diagnostics.copy-to-clipboard') }}</button>
+    </div>
+
+    <textarea v-model="sosReport" readonly style="flex: 1; min-height: 200px; resize: vertical; width: 100%; box-sizing: border-box;"></textarea>
+  </Section>
+
+  <Section :title="t('diagnostics.browser-info')">
+    <p>{{ t('diagnostics.browser-info-description') }}</p>
 
     <div role="toolbar">
-      <button @click="generateSosReport" :disabled="loading" class = "good">Generate SOS Report</button>
+      <button @click="generateBrowserInfo" :disabled="loading" class = "good">{{ t('diagnostics.generate-browser-info') }}</button>
+      <button @click="copyBrowserInfo" :disabled="!browserInfo || loading" :class="browserInfoCopied ? 'good' : ''">{{ browserInfoCopied ? t('diagnostics.copied') : t('diagnostics.copy-to-clipboard') }}</button>
     </div>
 
-    <textarea v-model="sosReport" readonly style="flex: 1; min-height: 200px; resize: vertical;"></textarea>
+    <textarea v-model="browserInfo" readonly style="flex: 1; min-height: 200px; resize: vertical; width: 100%; box-sizing: border-box;"></textarea>
   </Section>
 </template>
 
 <script setup>
 import { ref, onMounted } from 'vue'
 import Section from 'picocrank/vue/components/Section.vue'
+import { useI18n } from 'vue-i18n'
+
+const { t, locale } = useI18n()
 
 const diagnostics = ref({})
 const loading = ref(false)
-const sosReport = ref('Waiting to start...')
+const sosReport = ref('')
+const browserInfo = ref('')
+const sosReportCopied = ref(false)
+const browserInfoCopied = ref(false)
 
 async function fetchDiagnostics() {
   loading.value = true
@@ -52,8 +70,8 @@ async function fetchDiagnostics() {
   } catch (err) {
     console.error('Failed to fetch diagnostics:', err);
     diagnostics.value = {
-      sshFoundKey: 'Unknown',
-      sshFoundConfig: 'Unknown'
+      sshFoundKey: t('diagnostics.unknown'),
+      sshFoundConfig: t('diagnostics.unknown')
     }
   }
   loading.value = false
@@ -69,7 +87,134 @@ function formatKey(key) {
 async function generateSosReport() {
   const response = await window.client.sosReport()
   console.log("response", response)
-  sosReport.value = response.alert
+  sosReport.value = `\`\`\`\n${response.alert}\n\`\`\`\n`
+}
+
+async function copySosReport() {
+  try {
+    await navigator.clipboard.writeText(sosReport.value)
+    sosReportCopied.value = true
+    setTimeout(() => {
+      sosReportCopied.value = false
+    }, 2000)
+  } catch (err) {
+    console.error('Failed to copy SOS report to clipboard:', err)
+  }
+}
+
+async function generateBrowserInfo() {
+  loading.value = true
+  try {
+    let userAgentData = 'N/A'
+    if (navigator.userAgentData) {
+      try {
+        const uaData = await navigator.userAgentData.getHighEntropyValues([
+          'platform',
+          'platformVersion',
+          'architecture',
+          'model',
+          'uaFullVersion',
+          'bitness',
+          'fullVersionList'
+        ])
+        userAgentData = JSON.stringify(uaData, null, 2)
+      } catch (err) {
+        userAgentData = `${t('diagnostics.useragent-data-error')}: ${err.message}`
+      }
+    }
+
+    const info = {
+      userAgent: navigator.userAgent,
+      platform: navigator.platform,
+      language: navigator.language,
+      languages: navigator.languages?.join(', ') || 'N/A',
+      cookieEnabled: navigator.cookieEnabled,
+      onLine: navigator.onLine,
+      screenWidth: screen.width,
+      screenHeight: screen.height,
+      screenColorDepth: screen.colorDepth,
+      screenPixelDepth: screen.pixelDepth,
+      viewportWidth: window.innerWidth,
+      viewportHeight: window.innerHeight,
+      devicePixelRatio: window.devicePixelRatio || 'N/A',
+      timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+      timezoneOffset: new Date().getTimezoneOffset(),
+      localStorageEnabled: (() => {
+        try {
+          localStorage.setItem('test', 'test')
+          localStorage.removeItem('test')
+          return true
+        } catch {
+          return false
+        }
+      })(),
+      sessionStorageEnabled: (() => {
+        try {
+          sessionStorage.setItem('test', 'test')
+          sessionStorage.removeItem('test')
+          return true
+        } catch {
+          return false
+        }
+      })(),
+      hardwareConcurrency: navigator.hardwareConcurrency || 'N/A',
+      maxTouchPoints: navigator.maxTouchPoints || 'N/A',
+      userAgentData: userAgentData
+    }
+
+    const olivetinVersion = window.initResponse?.currentVersion || t('diagnostics.unknown')
+    const currentLanguage = locale.value || t('diagnostics.unknown')
+
+    let output = '';
+    output += `\`\`\`\n`
+    output += '### BROWSER INFO START (copy all text to BROWSER INFO END)\n'
+    output += `# OliveTin Information\n`
+    output += `olivetinVersion: ${olivetinVersion}\n`
+    output += `currentLanguage: ${currentLanguage}\n`
+    output += `\n# Browser Information\n`
+    output += `userAgent: ${info.userAgent}\n`
+    output += `platform: ${info.platform}\n`
+    output += `language: ${info.language}\n`
+    output += `languages: ${info.languages}\n`
+    output += `\n# User Agent Data\n`
+    output += `userAgentData:\n${info.userAgentData}\n`
+    output += `\n# Display Information\n`
+    output += `screenWidth: ${info.screenWidth}\n`
+    output += `screenHeight: ${info.screenHeight}\n`
+    output += `screenColorDepth: ${info.screenColorDepth}\n`
+    output += `screenPixelDepth: ${info.screenPixelDepth}\n`
+    output += `viewportWidth: ${info.viewportWidth}\n`
+    output += `viewportHeight: ${info.viewportHeight}\n`
+    output += `devicePixelRatio: ${info.devicePixelRatio}\n`
+    output += `\n# Feature Support\n`
+    output += `cookieEnabled: ${info.cookieEnabled}\n`
+    output += `localStorageEnabled: ${info.localStorageEnabled}\n`
+    output += `sessionStorageEnabled: ${info.sessionStorageEnabled}\n`
+    output += `onLine: ${info.onLine}\n`
+    output += `hardwareConcurrency: ${info.hardwareConcurrency}\n`
+    output += `maxTouchPoints: ${info.maxTouchPoints}\n`
+    output += `\n# Location & Time\n`
+    output += `timezone: ${info.timezone}\n`
+    output += `timezoneOffset: ${info.timezoneOffset}\n`
+    output += `\n### BROWSER INFO END (copy all text from BROWSER INFO START)`
+    output += `\n\`\`\`\n`
+
+    browserInfo.value = output
+  } finally {
+    loading.value = false
+  }
+}
+
+async function copyBrowserInfo() {
+  try {
+    await navigator.clipboard.writeText(browserInfo.value)
+    browserInfoCopied.value = true
+    setTimeout(() => {
+      browserInfoCopied.value = false
+    }, 2000)
+  } catch (err) {
+    console.error('Failed to copy browser info to clipboard:', err)
+  }
 }
 
 onMounted(() => {

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

+ 2 - 33
frontend/resources/vue/views/LogsListView.vue

@@ -46,9 +46,7 @@
                 </span>
               </td>
               <td class="exit-code">
-                <span :class="getStatusClass(log) + ' annotation'">
-                  {{ getStatusText(log) }}
-                </span>
+                <ActionStatusDisplay :logEntry="log" />
               </td>
             </tr>
           </tbody>
@@ -70,6 +68,7 @@ import { ref, computed, onMounted } from 'vue'
 import Pagination from 'picocrank/vue/components/Pagination.vue'
 import Section from 'picocrank/vue/components/Section.vue'
 import { useI18n } from 'vue-i18n'
+import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
 
 const logs = ref([])
 const searchText = ref('')
@@ -134,20 +133,6 @@ function formatTimestamp(timestamp) {
   }
 }
 
-function getStatusClass(log) {
-  if (log.timedOut) return 'status-timeout'
-  if (log.blocked) return 'status-blocked'
-  if (log.exitCode !== 0) return 'status-error'
-  return 'status-success'
-}
-
-function getStatusText(log) {
-  if (log.timedOut) return t('logs.timed-out')
-  if (log.blocked) return t('logs.blocked')
-  if (log.exitCode !== 0) return `${t('logs.exit-code')} ${log.exitCode}`
-  return t('logs.completed')
-}
-
 function handlePageChange(page) {
   currentPage.value = page
   fetchLogs()
@@ -227,22 +212,6 @@ onMounted(() => {
   font-size: smaller;
 }
 
-.status-success {
-  color: var(--karma-good-fg);
-}
-
-.status-error {
-  color: var(--karma-bad-fg);
-}
-
-.status-timeout {
-  color: var(--karma-warning-fg);
-}
-
-.status-blocked {
-  color: var(--karma-neutral-fg);
-}
-
 .empty-state {
   text-align: center;
   padding: 2rem;

+ 0 - 14
frontend/style.css

@@ -21,20 +21,6 @@ section {
 	padding: 0;
 }
 
-.display {
-	border: 1px solid #666;
-	padding: 1em;
-	border-radius: .7em;
-	box-shadow: 0 0 .6em #aaa;
-	text-align: center;
-	font-size: small;
-	display: flex;
-	flex-direction: column;
-	flex-grow: 1;
-	justify-content: center;
-	align-items: center;
-}
-
 aside .flex-row {
 	padding-left: 1em;
 	padding-right: .5em;

+ 3 - 3
integration-tests/package-lock.json

@@ -1158,9 +1158,9 @@
       }
     },
     "node_modules/glob": {
-      "version": "10.4.5",
-      "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
-      "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
+      "version": "10.5.0",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
+      "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
       "dev": true,
       "license": "ISC",
       "dependencies": {

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

@@ -0,0 +1,16 @@
+#
+# Integration Test Config: pageTitle
+#
+
+listenAddressSingleHTTPFrontend: 0.0.0.0:1337
+
+logLevel: "DEBUG"
+checkForUpdates: false
+
+pageTitle: "Custom Test Title"
+
+actions:
+- title: Ping example.com
+  shell: ping example.com -c 1
+  icon: ping
+

+ 51 - 0
integration-tests/tests/pageTitle/pageTitle.mjs

@@ -0,0 +1,51 @@
+import { describe, it, before, after } from 'mocha'
+import { expect } from 'chai'
+import { By } from 'selenium-webdriver'
+import {
+  getRootAndWait,
+  takeScreenshotOnFailure,
+} from '../../lib/elements.js'
+
+describe('config: pageTitle', function () {
+  before(async function () {
+    await runner.start('pageTitle')
+  })
+
+  after(async () => {
+    await runner.stop()
+  })
+
+  afterEach(function () {
+    takeScreenshotOnFailure(this.currentTest, webdriver);
+  });
+
+  it('Init API returns custom pageTitle from config', async function () {
+    await getRootAndWait()
+
+    // Check that the Init API response (available via window.initResponse) contains pageTitle
+    // This is how the frontend accesses it, so it's the most reliable way to test
+    const initResponse = await webdriver.executeScript('return window.initResponse')
+    
+    expect(initResponse).to.not.be.null
+    expect(initResponse).to.have.own.property('pageTitle')
+    expect(initResponse.pageTitle).to.equal('Custom Test Title')
+  })
+
+  it('Header displays custom pageTitle from init response', async function () {
+    await getRootAndWait()
+
+    // Check that the pageTitle from init response is used in the header
+    // First verify the init response has the correct pageTitle
+    const pageTitle = await webdriver.executeScript('return window.initResponse?.pageTitle')
+    expect(pageTitle).to.equal('Custom Test Title')
+
+    // The Header component from picocrank should render the title prop
+    // Check for the title in the header element
+    const header = await webdriver.findElement(By.tagName('header'))
+    const headerText = await header.getText()
+    
+    // The header should contain the custom page title
+    expect(headerText).to.include('Custom Test Title')
+  })
+})
+

+ 1 - 1
integration-tests/tests/sleep/sleep.js

@@ -44,6 +44,6 @@ describe('config: sleep', function () {
 
     await killButton.click()
 
-    await requireExecutionDialogStatus(webdriver, "Completed Exit code: -1")
+    await requireExecutionDialogStatus(webdriver, "Completed (Exit code: -1)")
   })
 })

+ 21 - 0
integration-tests/tests/stdoutMostRecentExecution/config.yaml

@@ -0,0 +1,21 @@
+logLevel: debug
+
+actions:
+  - title: Check status
+    id: status_command
+    shell: |
+      date
+
+    icon: poop
+
+dashboards:
+  - title: Test Dashboard
+    contents:
+      - title: Status Section
+        type: fieldset
+        contents:
+          - type: stdout-most-recent-execution
+            title: status_command
+
+          - title: Check status
+

+ 141 - 0
integration-tests/tests/stdoutMostRecentExecution/stdoutMostRecentExecution.mjs

@@ -0,0 +1,141 @@
+import { describe, it, before, after } from 'mocha'
+import { expect } from 'chai'
+import { By, Condition } from 'selenium-webdriver'
+import {
+  getRootAndWait,
+  getActionButtons,
+  takeScreenshotOnFailure,
+} from '../../lib/elements.js'
+
+describe('config: stdout-most-recent-execution', function () {
+  before(async function () {
+    await runner.start('stdoutMostRecentExecution')
+  })
+
+  after(async () => {
+    await runner.stop()
+  })
+
+  afterEach(function () {
+    takeScreenshotOnFailure(this.currentTest, webdriver)
+  })
+
+  it('stdout-most-recent-execution component is rendered', async function () {
+    await getRootAndWait()
+
+    const title = await webdriver.getTitle()
+    expect(title).to.be.equal('Test Dashboard - OliveTin')
+
+    // Wait for the mre-output element to appear
+    await webdriver.wait(
+      new Condition('wait for mre-output element', async () => {
+        const elements = await webdriver.findElements(By.css('.mre-output'))
+        return elements.length > 0
+      }),
+      10000
+    )
+
+    const mreElements = await webdriver.findElements(By.css('.mre-output'))
+    expect(mreElements).to.have.length(1, 'Expected one stdout-most-recent-execution component')
+  })
+
+  it('stdout-most-recent-execution displays initial state', async function () {
+    await getRootAndWait()
+
+    await webdriver.wait(
+      new Condition('wait for mre-output element', async () => {
+        const elements = await webdriver.findElements(By.css('.mre-output'))
+        return elements.length > 0
+      }),
+      10000
+    )
+
+    const mreElement = await webdriver.findElement(By.css('.mre-output'))
+    const text = await mreElement.getText()
+
+    // Should show either "Waiting...", "No execution found", or actual output
+    expect(text).to.be.a('string')
+    expect(text.length).to.be.greaterThan(0)
+  })
+
+  it('stdout-most-recent-execution updates after action execution', async function () {
+    this.timeout(30000) // Increase timeout for this test
+
+    await getRootAndWait()
+
+    // Wait for the mre-output element
+    await webdriver.wait(
+      new Condition('wait for mre-output element', async () => {
+        const elements = await webdriver.findElements(By.css('.mre-output'))
+        return elements.length > 0
+      }),
+      10000
+    )
+
+    const mreElement = await webdriver.findElement(By.css('.mre-output'))
+    const initialText = await mreElement.getText()
+
+    // Find the "Check status" action button (button text is the action title, not ID)
+    await webdriver.wait(
+      new Condition('wait for Check status button', async () => {
+        const buttons = await webdriver.findElements(By.css('.action-button button'))
+        for (const btn of buttons) {
+          const text = await btn.getText()
+          if (text.includes('Check status')) {
+            return true
+          }
+        }
+        return false
+      }),
+      10000
+    )
+
+    const buttons = await webdriver.findElements(By.css('.action-button button'))
+    let statusButton = null
+    for (const btn of buttons) {
+      const text = await btn.getText()
+      if (text.includes('Check status')) {
+        statusButton = btn
+        break
+      }
+    }
+    expect(statusButton).to.not.be.null
+
+    // Click the button to execute the action
+    await statusButton.click()
+
+    // Wait a moment for the action to start
+    await webdriver.sleep(500)
+
+    // Wait for the output to update (the component listens to EventExecutionFinished events)
+    // We'll wait for the output to change from the initial state
+    await webdriver.wait(
+      new Condition('wait for output to update after execution', async () => {
+        try {
+          const mreElement = await webdriver.findElement(By.css('.mre-output'))
+          const newText = await mreElement.getText()
+          // Output should change from initial state and contain actual output
+          // (not "Waiting...", "No execution found", or the same as initialText)
+          const hasChanged = newText !== initialText
+          const hasValidOutput = newText && 
+                                 !newText.includes('Waiting...') && 
+                                 !newText.includes('No execution found') && 
+                                 !newText.includes('Error:') &&
+                                 newText.trim().length > 0
+          return hasChanged && hasValidOutput
+        } catch (e) {
+          return false
+        }
+      }),
+      20000
+    )
+
+    const updatedMreElement = await webdriver.findElement(By.css('.mre-output'))
+    const updatedText = await updatedMreElement.getText()
+
+    // The date command should produce output, so verify it's not empty and not an error state
+    expect(updatedText).to.not.include('Waiting...')
+    expect(updatedText).to.not.include('No execution found')
+    expect(updatedText.trim().length).to.be.greaterThan(0)
+  })
+})

+ 85 - 0
lang/combined_output.json

@@ -3,6 +3,23 @@
     "messages": {
         "de-DE": {
             "connected": "Verbunden",
+            "diagnostics.browser-info": "Browser-Informationen",
+            "diagnostics.browser-info-description": "Dieser Abschnitt ermöglicht es Ihnen, einen detaillierten Bericht über Ihre Browser-Informationen zu erstellen. Dies kann bei der Fehlerbehebung von browser-spezifischen Problemen hilfreich sein.",
+            "diagnostics.copied": "Kopiert!",
+            "diagnostics.copy-to-clipboard": "In Zwischenablage kopieren",
+            "diagnostics.found-config": "Konfiguration gefunden",
+            "diagnostics.found-key": "Schlüssel gefunden",
+            "diagnostics.generate-browser-info": "Browser-Informationen erstellen",
+            "diagnostics.generate-sos-report": "SOS-Bericht erstellen",
+            "diagnostics.get-support": "Unterstützung erhalten",
+            "diagnostics.get-support-description": "Wenn Sie Probleme mit OliveTin haben und eine Support-Anfrage stellen möchten, wäre es sehr hilfreich, einen sosreport von dieser Seite einzufügen.",
+            "diagnostics.sos-report": "SOS-Bericht",
+            "diagnostics.sos-report-description": "Dieser Abschnitt ermöglicht es Ihnen, einen detaillierten Bericht über Ihre Konfiguration und Umgebung zu erstellen. Es ist eine gute Idee, dies bei einer Support-Anfrage einzufügen.",
+            "diagnostics.sos-report-docs": "sosreport Dokumentation",
+            "diagnostics.ssh": "SSH",
+            "diagnostics.unknown": "Unbekannt",
+            "diagnostics.useragent-data-error": "Fehler beim Abrufen von userAgentData",
+            "diagnostics.where-to-find-help": "Wo Sie Hilfe finden",
             "docs": "Dokumentation",
             "language-dialog.browser-languages": "Browser-Sprachen",
             "language-dialog.close": "Schließen",
@@ -32,6 +49,23 @@
         },
         "en": {
             "connected": "Connected",
+            "diagnostics.browser-info": "Browser Info",
+            "diagnostics.browser-info-description": "This section allows you to generate a detailed report of your browser information. This can be helpful when troubleshooting browser-specific issues.",
+            "diagnostics.copied": "Copied!",
+            "diagnostics.copy-to-clipboard": "Copy to Clipboard",
+            "diagnostics.found-config": "Found Config",
+            "diagnostics.found-key": "Found Key",
+            "diagnostics.generate-browser-info": "Generate Browser Info",
+            "diagnostics.generate-sos-report": "Generate SOS Report",
+            "diagnostics.get-support": "Get support",
+            "diagnostics.get-support-description": "If you are having problems with OliveTin and want to raise a support request, it would be very helpful to include a sosreport from this page.",
+            "diagnostics.sos-report": "SOS Report",
+            "diagnostics.sos-report-description": "This section allows you to generate a detailed report of your configuration and environment. It is a good idea to include this when raising a support request.",
+            "diagnostics.sos-report-docs": "sosreport Documentation",
+            "diagnostics.ssh": "SSH",
+            "diagnostics.unknown": "Unknown",
+            "diagnostics.useragent-data-error": "Error retrieving userAgentData",
+            "diagnostics.where-to-find-help": "Where to find help",
             "docs": "Documentation",
             "language-dialog.browser-languages": "Browser languages",
             "language-dialog.close": "Close",
@@ -61,6 +95,23 @@
         },
         "es-ES": {
             "connected": "Conectado",
+            "diagnostics.browser-info": "Información del navegador",
+            "diagnostics.browser-info-description": "Esta sección le permite generar un informe detallado de su información del navegador. Esto puede ser útil al solucionar problemas específicos del navegador.",
+            "diagnostics.copied": "¡Copiado!",
+            "diagnostics.copy-to-clipboard": "Copiar al portapapeles",
+            "diagnostics.found-config": "Configuración encontrada",
+            "diagnostics.found-key": "Clave encontrada",
+            "diagnostics.generate-browser-info": "Generar información del navegador",
+            "diagnostics.generate-sos-report": "Generar informe SOS",
+            "diagnostics.get-support": "Obtener soporte",
+            "diagnostics.get-support-description": "Si tiene problemas con OliveTin y desea presentar una solicitud de soporte, sería muy útil incluir un sosreport de esta página.",
+            "diagnostics.sos-report": "Informe SOS",
+            "diagnostics.sos-report-description": "Esta sección le permite generar un informe detallado de su configuración y entorno. Es una buena idea incluir esto al presentar una solicitud de soporte.",
+            "diagnostics.sos-report-docs": "Documentación de sosreport",
+            "diagnostics.ssh": "SSH",
+            "diagnostics.unknown": "Desconocido",
+            "diagnostics.useragent-data-error": "Error al recuperar userAgentData",
+            "diagnostics.where-to-find-help": "Dónde encontrar ayuda",
             "docs": "Documentación",
             "language-dialog.browser-languages": "Idiomas del navegador",
             "language-dialog.close": "Cerrar",
@@ -90,6 +141,23 @@
         },
         "it-IT": {
             "connected": "Connesso",
+            "diagnostics.browser-info": "Informazioni del browser",
+            "diagnostics.browser-info-description": "Questa sezione ti consente di generare un rapporto dettagliato delle informazioni del tuo browser. Questo può essere utile durante la risoluzione dei problemi specifici del browser.",
+            "diagnostics.copied": "Copiato!",
+            "diagnostics.copy-to-clipboard": "Copia negli appunti",
+            "diagnostics.found-config": "Configurazione trovata",
+            "diagnostics.found-key": "Chiave trovata",
+            "diagnostics.generate-browser-info": "Genera informazioni del browser",
+            "diagnostics.generate-sos-report": "Genera rapporto SOS",
+            "diagnostics.get-support": "Ottenere supporto",
+            "diagnostics.get-support-description": "Se hai problemi con OliveTin e vuoi presentare una richiesta di supporto, sarebbe molto utile includere un sosreport da questa pagina.",
+            "diagnostics.sos-report": "Rapporto SOS",
+            "diagnostics.sos-report-description": "Questa sezione ti consente di generare un rapporto dettagliato della tua configurazione e ambiente. È una buona idea includere questo quando si presenta una richiesta di supporto.",
+            "diagnostics.sos-report-docs": "Documentazione sosreport",
+            "diagnostics.ssh": "SSH",
+            "diagnostics.unknown": "Sconosciuto",
+            "diagnostics.useragent-data-error": "Errore nel recupero di userAgentData",
+            "diagnostics.where-to-find-help": "Dove trovare aiuto",
             "docs": "Documentazione",
             "language-dialog.browser-languages": "Lingue del browser",
             "language-dialog.close": "Chiudi",
@@ -119,6 +187,23 @@
         },
         "zh-Hans-CN": {
             "connected": "已连接",
+            "diagnostics.browser-info": "浏览器信息",
+            "diagnostics.browser-info-description": "此部分允许您生成浏览器信息的详细报告。这在排查浏览器特定问题时很有帮助。",
+            "diagnostics.copied": "已复制!",
+            "diagnostics.copy-to-clipboard": "复制到剪贴板",
+            "diagnostics.found-config": "找到配置",
+            "diagnostics.found-key": "找到密钥",
+            "diagnostics.generate-browser-info": "生成浏览器信息",
+            "diagnostics.generate-sos-report": "生成 SOS 报告",
+            "diagnostics.get-support": "获取支持",
+            "diagnostics.get-support-description": "如果您在使用 OliveTin 时遇到问题并希望提交支持请求,从本页面包含 sosreport 将非常有帮助。",
+            "diagnostics.sos-report": "SOS 报告",
+            "diagnostics.sos-report-description": "此部分允许您生成配置和环境的详细报告。在提交支持请求时包含此信息是个好主意。",
+            "diagnostics.sos-report-docs": "sosreport 文档",
+            "diagnostics.ssh": "SSH",
+            "diagnostics.unknown": "未知",
+            "diagnostics.useragent-data-error": "检索 userAgentData 时出错",
+            "diagnostics.where-to-find-help": "在哪里找到帮助",
             "docs": "文档",
             "language-dialog.browser-languages": "浏览器语言",
             "language-dialog.close": "关闭",

+ 17 - 0
lang/de-DE.yaml

@@ -21,6 +21,23 @@ translations:
   logs.exit-code: Ausführungscode
   logs.completed: Abgeschlossen
   logs.clear-filter: Suchfilter löschen
+  diagnostics.get-support: Unterstützung erhalten
+  diagnostics.get-support-description: Wenn Sie Probleme mit OliveTin haben und eine Support-Anfrage stellen möchten, wäre es sehr hilfreich, einen sosreport von dieser Seite einzufügen.
+  diagnostics.where-to-find-help: Wo Sie Hilfe finden
+  diagnostics.ssh: SSH
+  diagnostics.found-key: Schlüssel gefunden
+  diagnostics.found-config: Konfiguration gefunden
+  diagnostics.sos-report: SOS-Bericht
+  diagnostics.sos-report-description: Dieser Abschnitt ermöglicht es Ihnen, einen detaillierten Bericht über Ihre Konfiguration und Umgebung zu erstellen. Es ist eine gute Idee, dies bei einer Support-Anfrage einzufügen.
+  diagnostics.sos-report-docs: sosreport Dokumentation
+  diagnostics.generate-sos-report: SOS-Bericht erstellen
+  diagnostics.browser-info: Browser-Informationen
+  diagnostics.browser-info-description: Dieser Abschnitt ermöglicht es Ihnen, einen detaillierten Bericht über Ihre Browser-Informationen zu erstellen. Dies kann bei der Fehlerbehebung von browser-spezifischen Problemen hilfreich sein.
+  diagnostics.generate-browser-info: Browser-Informationen erstellen
+  diagnostics.copy-to-clipboard: In Zwischenablage kopieren
+  diagnostics.copied: Kopiert!
+  diagnostics.unknown: Unbekannt
+  diagnostics.useragent-data-error: Fehler beim Abrufen von userAgentData
   return-to-index: Zurück zur Startseite
   search-filter: Filter aktuelle Seite
   language-dialog.title: Sprache auswählen

+ 17 - 0
lang/en.yaml

@@ -21,6 +21,23 @@ translations:
   logs.exit-code: Exit code
   logs.completed: Completed
   logs.clear-filter: Clear search filter
+  diagnostics.get-support: Get support
+  diagnostics.get-support-description: If you are having problems with OliveTin and want to raise a support request, it would be very helpful to include a sosreport from this page.
+  diagnostics.where-to-find-help: Where to find help
+  diagnostics.ssh: SSH
+  diagnostics.found-key: Found Key
+  diagnostics.found-config: Found Config
+  diagnostics.sos-report: SOS Report
+  diagnostics.sos-report-description: This section allows you to generate a detailed report of your configuration and environment. It is a good idea to include this when raising a support request.
+  diagnostics.sos-report-docs: sosreport Documentation
+  diagnostics.generate-sos-report: Generate SOS Report
+  diagnostics.browser-info: Browser Info
+  diagnostics.browser-info-description: This section allows you to generate a detailed report of your browser information. This can be helpful when troubleshooting browser-specific issues.
+  diagnostics.generate-browser-info: Generate Browser Info
+  diagnostics.copy-to-clipboard: Copy to Clipboard
+  diagnostics.copied: Copied!
+  diagnostics.unknown: Unknown
+  diagnostics.useragent-data-error: Error retrieving userAgentData
   return-to-index: Return to index
   search-filter: Filter current page
   language-dialog.title: Select Language

+ 17 - 0
lang/es-ES.yaml

@@ -21,6 +21,23 @@ translations:
   logs.exit-code: Código de salida
   logs.completed: Completado
   logs.clear-filter: Limpiar filtro de búsqueda
+  diagnostics.get-support: Obtener soporte
+  diagnostics.get-support-description: Si tiene problemas con OliveTin y desea presentar una solicitud de soporte, sería muy útil incluir un sosreport de esta página.
+  diagnostics.where-to-find-help: Dónde encontrar ayuda
+  diagnostics.ssh: SSH
+  diagnostics.found-key: Clave encontrada
+  diagnostics.found-config: Configuración encontrada
+  diagnostics.sos-report: Informe SOS
+  diagnostics.sos-report-description: Esta sección le permite generar un informe detallado de su configuración y entorno. Es una buena idea incluir esto al presentar una solicitud de soporte.
+  diagnostics.sos-report-docs: Documentación de sosreport
+  diagnostics.generate-sos-report: Generar informe SOS
+  diagnostics.browser-info: Información del navegador
+  diagnostics.browser-info-description: Esta sección le permite generar un informe detallado de su información del navegador. Esto puede ser útil al solucionar problemas específicos del navegador.
+  diagnostics.generate-browser-info: Generar información del navegador
+  diagnostics.copy-to-clipboard: Copiar al portapapeles
+  diagnostics.copied: ¡Copiado!
+  diagnostics.unknown: Desconocido
+  diagnostics.useragent-data-error: Error al recuperar userAgentData
   return-to-index: Volver a la página principal
   search-filter: Filtrar página actual
   language-dialog.title: Seleccionar idioma

+ 17 - 0
lang/it-IT.yaml

@@ -21,6 +21,23 @@ translations:
   logs.exit-code: Codice di uscita
   logs.completed: Completato
   logs.clear-filter: Cancella filtro di ricerca
+  diagnostics.get-support: Ottenere supporto
+  diagnostics.get-support-description: Se hai problemi con OliveTin e vuoi presentare una richiesta di supporto, sarebbe molto utile includere un sosreport da questa pagina.
+  diagnostics.where-to-find-help: Dove trovare aiuto
+  diagnostics.ssh: SSH
+  diagnostics.found-key: Chiave trovata
+  diagnostics.found-config: Configurazione trovata
+  diagnostics.sos-report: Rapporto SOS
+  diagnostics.sos-report-description: Questa sezione ti consente di generare un rapporto dettagliato della tua configurazione e ambiente. È una buona idea includere questo quando si presenta una richiesta di supporto.
+  diagnostics.sos-report-docs: Documentazione sosreport
+  diagnostics.generate-sos-report: Genera rapporto SOS
+  diagnostics.browser-info: Informazioni del browser
+  diagnostics.browser-info-description: Questa sezione ti consente di generare un rapporto dettagliato delle informazioni del tuo browser. Questo può essere utile durante la risoluzione dei problemi specifici del browser.
+  diagnostics.generate-browser-info: Genera informazioni del browser
+  diagnostics.copy-to-clipboard: Copia negli appunti
+  diagnostics.copied: Copiato!
+  diagnostics.unknown: Sconosciuto
+  diagnostics.useragent-data-error: Errore nel recupero di userAgentData
   return-to-index: Torna alla pagina principale
   search-filter: Filtra la pagina corrente
   language-dialog.title: Seleziona lingua

+ 18 - 1
lang/zh-Hans-CN.yaml

@@ -26,4 +26,21 @@ translations:
   logs.blocked: 阻塞
   logs.exit-code: 退出代码
   logs.completed: 完成
-  logs.clear-filter: 清除搜索筛选器
+  logs.clear-filter: 清除搜索筛选器
+  diagnostics.get-support: 获取支持
+  diagnostics.get-support-description: 如果您在使用 OliveTin 时遇到问题并希望提交支持请求,从本页面包含 sosreport 将非常有帮助。
+  diagnostics.where-to-find-help: 在哪里找到帮助
+  diagnostics.ssh: SSH
+  diagnostics.found-key: 找到密钥
+  diagnostics.found-config: 找到配置
+  diagnostics.sos-report: SOS 报告
+  diagnostics.sos-report-description: 此部分允许您生成配置和环境的详细报告。在提交支持请求时包含此信息是个好主意。
+  diagnostics.sos-report-docs: sosreport 文档
+  diagnostics.generate-sos-report: 生成 SOS 报告
+  diagnostics.browser-info: 浏览器信息
+  diagnostics.browser-info-description: 此部分允许您生成浏览器信息的详细报告。这在排查浏览器特定问题时很有帮助。
+  diagnostics.generate-browser-info: 生成浏览器信息
+  diagnostics.copy-to-clipboard: 复制到剪贴板
+  diagnostics.copied: 已复制!
+  diagnostics.unknown: 未知
+  diagnostics.useragent-data-error: 检索 userAgentData 时出错

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

+ 61 - 6
service/internal/api/api.go

@@ -363,15 +363,25 @@ func (api *oliveTinAPI) Logout(ctx ctx.Context, req *connect.Request[apiv1.Logou
 
 	response := connect.NewResponse(&apiv1.LogoutResponse{})
 
-	// Clear the authentication cookie by setting it to expire
-	cookie := &http.Cookie{
+	// Clear the local authentication cookie by setting it to expire
+	localCookie := &http.Cookie{
 		Name:     "olivetin-sid-local",
 		Value:    "",
 		MaxAge:   -1, // This tells the browser to delete the cookie
 		HttpOnly: true,
 		Path:     "/",
 	}
-	response.Header().Set("Set-Cookie", cookie.String())
+	response.Header().Set("Set-Cookie", localCookie.String())
+
+	// Clear the OAuth2 authentication cookie by setting it to expire
+	oauth2Cookie := &http.Cookie{
+		Name:     "olivetin-sid-oauth",
+		Value:    "",
+		MaxAge:   -1, // This tells the browser to delete the cookie
+		HttpOnly: true,
+		Path:     "/",
+	}
+	response.Header().Add("Set-Cookie", oauth2Cookie.String())
 
 	return response, nil
 }
@@ -405,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)
@@ -421,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,
 	}
 }
 
@@ -825,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)
@@ -977,6 +995,40 @@ 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 {
+		collectDirectoriesFromComponent(component, directories)
+	}
+
+	if len(component.Contents) > 0 {
+		searchSubcomponentsForDirectories(entityType, component.Contents, directories)
+	}
+}
+
+func collectDirectoriesFromComponent(component *config.DashboardComponent, directories *[]string) {
+	for _, subitem := range component.Contents {
+		if subitem.Type == "directory" {
+			*directories = append(*directories, subitem.Title)
+		}
+	}
+}
+
+func searchSubcomponentsForDirectories(entityType string, contents []*config.DashboardComponent, directories *[]string) {
+	for _, subitem := range 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)
 
@@ -998,6 +1050,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
 	}

+ 19 - 1
service/internal/api/apiActions.go

@@ -13,14 +13,24 @@ 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 {
+		if binding.Action.Title != title {
+			continue
+		}
+
+		if matchesEntity(binding, entity) {
 			return buildAction(binding, rr)
 		}
 	}
@@ -28,6 +38,14 @@ func (rr *DashboardRenderRequest) findAction(title string) *apiv1.Action {
 	return nil
 }
 
+func matchesEntity(binding *executor.ActionBinding, entity *entities.Entity) bool {
+	if entity == nil {
+		return binding.Entity == nil
+	}
+
+	return binding.Entity != nil && binding.Entity.UniqueKey == entity.UniqueKey
+}
+
 func buildEffectivePolicy(policy *config.ConfigurationPolicy) *apiv1.EffectivePolicy {
 	ret := &apiv1.EffectivePolicy{
 		ShowDiagnostics: policy.ShowDiagnostics,

+ 43 - 15
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,18 +66,44 @@ 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)
-	} else {
-		clone.Title = entities.ParseTemplateWith(subitem.Title, ent)
-		clone.Type = subitem.Type
+	if isLinkType(subitem.Type) {
+		return cloneLinkItem(subitem, ent, clone, rr)
+	}
+
+	return cloneNonLinkItem(subitem, ent, entityType, clone, rr)
+}
+
+func isLinkType(itemType string) bool {
+	return itemType == "" || itemType == "link"
+}
+
+func cloneLinkItem(subitem *config.DashboardComponent, ent *entities.Entity, clone *apiv1.DashboardComponent, rr *DashboardRenderRequest) *apiv1.DashboardComponent {
+	clone.Type = "link"
+	clone.Title = entities.ParseTemplateWith(subitem.Title, ent)
+	clone.Action = rr.findActionForEntity(subitem.Title, ent)
+	return clone
+}
+
+func cloneNonLinkItem(subitem *config.DashboardComponent, ent *entities.Entity, entityType string, clone *apiv1.DashboardComponent, rr *DashboardRenderRequest) *apiv1.DashboardComponent {
+	clone.Title = entities.ParseTemplateWith(subitem.Title, ent)
+	clone.Type = subitem.Type
+
+	if isDirectoryWithEntity(clone.Type, ent, entityType) {
+		clone.EntityType = entityType
+		clone.EntityKey = ent.UniqueKey
+	}
+
+	if len(subitem.Contents) > 0 {
+		clone.Contents = buildEntityFieldsetContents(subitem.Contents, ent, entityType, rr)
 	}
 
 	return clone
 }
+
+func isDirectoryWithEntity(itemType string, ent *entities.Entity, entityType string) bool {
+	return itemType == "directory" && ent != nil && entityType != ""
+}

+ 136 - 24
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,18 +18,80 @@ 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 {
+	if dashboard := findDashboardByTitle(rr, dashboardTitle); dashboard != nil {
+		return renderDashboardIfValid(dashboard, rr)
+	}
+
+	return renderDirectoryDashboard(rr, dashboardTitle)
+}
+
+func findDashboardByTitle(rr *DashboardRenderRequest, dashboardTitle string) *config.DashboardComponent {
 	for _, dashboard := range rr.cfg.Dashboards {
-		if dashboard.Title != dashboardTitle {
-			continue
+		if dashboard.Title == dashboardTitle {
+			return dashboard
 		}
+	}
+	return nil
+}
+
+func renderDashboardIfValid(dashboard *config.DashboardComponent, rr *DashboardRenderRequest) *apiv1.Dashboard {
+	if len(dashboard.Contents) == 0 {
+		logEmptyDashboard(dashboard.Title, rr.AuthenticatedUser.Username)
+		return nil
+	}
+	return buildDashboardFromConfig(dashboard, rr)
+}
+
+func renderDirectoryDashboard(rr *DashboardRenderRequest, dashboardTitle string) *apiv1.Dashboard {
+	directoryComponent := findDirectoryComponent(rr, dashboardTitle)
+	if directoryComponent == nil {
+		return nil
+	}
 
-		if len(dashboard.Contents) == 0 {
-			logEmptyDashboard(dashboard.Title, rr.AuthenticatedUser.Username)
-			return nil
+	entity := getEntityFromRequest(rr)
+	return buildDashboardFromConfigWithEntity(directoryComponent, rr, entity)
+}
+
+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 isMatchingDirectory(component, title) {
+		return component
+	}
+
+	return searchDirectoryInSubcomponents(component.Contents, title)
+}
+
+func isMatchingDirectory(component *config.DashboardComponent, title string) bool {
+	return component.Title == title && len(component.Contents) > 0 && component.Type != "fieldset"
+}
 
-		return buildDashboardFromConfig(dashboard, rr)
+func searchDirectoryInSubcomponents(contents []*config.DashboardComponent, title string) *config.DashboardComponent {
+	for _, subitem := range contents {
+		if found := searchDirectoryInComponent(subitem, title); found != nil {
+			return found
+		}
 	}
 
 	return nil
@@ -42,9 +105,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 +186,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 +209,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 +233,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,21 +271,40 @@ 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 {
+	if hasContents(item) {
+		return getTypeForComponentWithContents(item)
+	}
+
+	if isAllowedType(item.Type) {
+		return item.Type
+	}
+
+	return getDefaultType(action)
+}
+
+func hasContents(item *config.DashboardComponent) bool {
+	return len(item.Contents) > 0
+}
+
+func getTypeForComponentWithContents(item *config.DashboardComponent) string {
+	if item.Type != "fieldset" {
+		return "directory"
+	}
+	return "fieldset"
+}
+
+func isAllowedType(itemType string) bool {
 	allowedTypes := []string{
 		"stdout-most-recent-execution",
 		"display",
 	}
+	return slices.Contains(allowedTypes, itemType)
+}
 
-	if len(item.Contents) > 0 {
-		if item.Type != "fieldset" {
-			return "directory"
-		}
-
-		return "fieldset"
-	} else if slices.Contains(allowedTypes, item.Type) {
-		return item.Type
+func getDefaultType(action *apiv1.Action) string {
+	if action == nil {
+		return "display"
 	}
-
 	return "link"
 }

Некоторые файлы не были показаны из-за большого количества измененных файлов