This commit is contained in:
James Read 2025-12-01 02:08:58 +00:00 committed by GitHub
commit d4b8743a57
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 1549 additions and 188 deletions

View File

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

View File

@ -11,7 +11,7 @@ import { createConnectTransport } from '@connectrpc/connect-web'
import { OliveTinApiService } from './resources/scripts/gen/olivetin/api/v1/olivetin_pb' 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 { createI18n } from 'vue-i18n'
import router from './resources/vue/router.js' import router from './resources/vue/router.js'
@ -91,12 +91,33 @@ function setupVue (i18nSettings) {
app.mount('#app') 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 () { async function main () {
try {
const i18nSettings = await initClient() 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() main()

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@ -1,7 +1,7 @@
<template> <template>
<span> <div :class = "statusClass + ' annotation'">
<span :class="['action-status', statusClass]">{{ statusText }}</span><span>{{ exitCodeText }}</span> <span>{{ statusText }}</span><span>{{ exitCodeText }}</span>
</span> </div>
</template> </template>
@ -35,11 +35,14 @@ const statusText = computed(() => {
const exitCodeText = computed(() => { const exitCodeText = computed(() => {
const logEntry = props.logEntry const logEntry = props.logEntry
if (!logEntry) return '' if (!logEntry) return ''
if (logEntry.exitCode === 0) {
return ''
}
if (logEntry.executionFinished) { if (logEntry.executionFinished) {
if (logEntry.blocked || logEntry.timedOut) { if (logEntry.blocked || logEntry.timedOut) {
return '' return ''
} }
return ' Exit code: ' + logEntry.exitCode return ' (Exit code: ' + logEntry.exitCode + ')'
} }
return '' return ''
}) })
@ -49,15 +52,35 @@ const statusClass = computed(() => {
if (!logEntry) return '' if (!logEntry) return ''
if (logEntry.executionFinished) { if (logEntry.executionFinished) {
if (logEntry.blocked) { if (logEntry.blocked) {
return 'action-blocked' return 'status-blocked'
} else if (logEntry.timedOut) { } else if (logEntry.timedOut) {
return 'action-timeout' return 'status-timeout'
} else if (logEntry.exitCode === 0) { } else if (logEntry.exitCode === 0) {
return 'action-success' return 'status-success'
} else { } else {
return 'action-nonzero-exit' return 'status-nonzero-exit'
} }
} }
return '' return ''
}) })
</script> </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>

View File

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

View File

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

View File

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

View File

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

View File

@ -13,7 +13,7 @@ const routes = [
meta: { title: 'Actions', icon: DashboardSquare01Icon } meta: { title: 'Actions', icon: DashboardSquare01Icon }
}, },
{ {
path: '/dashboards/:title', path: '/dashboards/:title/:entityType?/:entityKey?',
name: 'Dashboard', name: 'Dashboard',
component: () => import('./Dashboard.vue'), component: () => import('./Dashboard.vue'),
props: true, props: true,
@ -128,7 +128,8 @@ const router = createRouter({
// Navigation guard to update page title // Navigation guard to update page title
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
if (to.meta && to.meta.title) { 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() next()
}) })

View File

@ -1,44 +1,62 @@
<template> <template>
<Section title = "Get support"> <Section :title="t('diagnostics.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. <p>{{ t('diagnostics.get-support-description') }}
</p> </p>
<ul> <ul>
<li> <li>
<a href="https://docs.olivetin.app/sosreport.html" target="_blank">sosreport Documentation</a> <a href = "https://docs.olivetin.app/troubleshooting/wheretofindhelp.html" target="_blank">{{ t('diagnostics.where-to-find-help') }}</a>
</li>
<li>
<a href = "https://docs.olivetin.app/troubleshooting/wheretofindhelp.html" target="_blank">Where to find help</a>
</li> </li>
</ul> </ul>
</Section> </Section>
<Section title = "SSH"> <Section :title="t('diagnostics.ssh')">
<dl> <dl>
<dt>Found Key</dt> <dt>{{ t('diagnostics.found-key') }}</dt>
<dd>{{ diagnostics.sshFoundKey || '?' }}</dd> <dd>{{ diagnostics.sshFoundKey || '?' }}</dd>
<dt>Found Config</dt> <dt>{{ t('diagnostics.found-config') }}</dt>
<dd>{{ diagnostics.sshFoundConfig || '?' }}</dd> <dd>{{ diagnostics.sshFoundConfig || '?' }}</dd>
</dl> </dl>
</Section> </Section>
<Section title = "SOS Report"> <Section :title="t('diagnostics.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> <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"> <div role="toolbar">
<button @click="generateSosReport" :disabled="loading" class = "good">Generate SOS Report</button> <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> </div>
<textarea v-model="sosReport" readonly style="flex: 1; min-height: 200px; resize: vertical;"></textarea> <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="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="browserInfo" readonly style="flex: 1; min-height: 200px; resize: vertical; width: 100%; box-sizing: border-box;"></textarea>
</Section> </Section>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import Section from 'picocrank/vue/components/Section.vue' import Section from 'picocrank/vue/components/Section.vue'
import { useI18n } from 'vue-i18n'
const { t, locale } = useI18n()
const diagnostics = ref({}) const diagnostics = ref({})
const loading = ref(false) 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() { async function fetchDiagnostics() {
loading.value = true loading.value = true
@ -52,8 +70,8 @@ async function fetchDiagnostics() {
} catch (err) { } catch (err) {
console.error('Failed to fetch diagnostics:', err); console.error('Failed to fetch diagnostics:', err);
diagnostics.value = { diagnostics.value = {
sshFoundKey: 'Unknown', sshFoundKey: t('diagnostics.unknown'),
sshFoundConfig: 'Unknown' sshFoundConfig: t('diagnostics.unknown')
} }
} }
loading.value = false loading.value = false
@ -69,7 +87,134 @@ function formatKey(key) {
async function generateSosReport() { async function generateSosReport() {
const response = await window.client.sosReport() const response = await window.client.sosReport()
console.log("response", response) 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(() => { onMounted(() => {

View File

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

View File

@ -46,9 +46,7 @@
</span> </span>
</td> </td>
<td class="exit-code"> <td class="exit-code">
<span :class="getStatusClass(log) + ' annotation'"> <ActionStatusDisplay :logEntry="log" />
{{ getStatusText(log) }}
</span>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@ -70,6 +68,7 @@ import { ref, computed, onMounted } from 'vue'
import Pagination from 'picocrank/vue/components/Pagination.vue' import Pagination from 'picocrank/vue/components/Pagination.vue'
import Section from 'picocrank/vue/components/Section.vue' import Section from 'picocrank/vue/components/Section.vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
const logs = ref([]) const logs = ref([])
const searchText = 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) { function handlePageChange(page) {
currentPage.value = page currentPage.value = page
fetchLogs() fetchLogs()
@ -227,22 +212,6 @@ onMounted(() => {
font-size: smaller; 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 { .empty-state {
text-align: center; text-align: center;
padding: 2rem; padding: 2rem;

View File

@ -21,20 +21,6 @@ section {
padding: 0; 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 { aside .flex-row {
padding-left: 1em; padding-left: 1em;
padding-right: .5em; padding-right: .5em;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,6 +3,23 @@
"messages": { "messages": {
"de-DE": { "de-DE": {
"connected": "Verbunden", "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", "docs": "Dokumentation",
"language-dialog.browser-languages": "Browser-Sprachen", "language-dialog.browser-languages": "Browser-Sprachen",
"language-dialog.close": "Schließen", "language-dialog.close": "Schließen",
@ -32,6 +49,23 @@
}, },
"en": { "en": {
"connected": "Connected", "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", "docs": "Documentation",
"language-dialog.browser-languages": "Browser languages", "language-dialog.browser-languages": "Browser languages",
"language-dialog.close": "Close", "language-dialog.close": "Close",
@ -61,6 +95,23 @@
}, },
"es-ES": { "es-ES": {
"connected": "Conectado", "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", "docs": "Documentación",
"language-dialog.browser-languages": "Idiomas del navegador", "language-dialog.browser-languages": "Idiomas del navegador",
"language-dialog.close": "Cerrar", "language-dialog.close": "Cerrar",
@ -90,6 +141,23 @@
}, },
"it-IT": { "it-IT": {
"connected": "Connesso", "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", "docs": "Documentazione",
"language-dialog.browser-languages": "Lingue del browser", "language-dialog.browser-languages": "Lingue del browser",
"language-dialog.close": "Chiudi", "language-dialog.close": "Chiudi",
@ -119,6 +187,23 @@
}, },
"zh-Hans-CN": { "zh-Hans-CN": {
"connected": "已连接", "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": "文档", "docs": "文档",
"language-dialog.browser-languages": "浏览器语言", "language-dialog.browser-languages": "浏览器语言",
"language-dialog.close": "关闭", "language-dialog.close": "关闭",

View File

@ -21,6 +21,23 @@ translations:
logs.exit-code: Ausführungscode logs.exit-code: Ausführungscode
logs.completed: Abgeschlossen logs.completed: Abgeschlossen
logs.clear-filter: Suchfilter löschen 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 return-to-index: Zurück zur Startseite
search-filter: Filter aktuelle Seite search-filter: Filter aktuelle Seite
language-dialog.title: Sprache auswählen language-dialog.title: Sprache auswählen

View File

@ -21,6 +21,23 @@ translations:
logs.exit-code: Exit code logs.exit-code: Exit code
logs.completed: Completed logs.completed: Completed
logs.clear-filter: Clear search filter 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 return-to-index: Return to index
search-filter: Filter current page search-filter: Filter current page
language-dialog.title: Select Language language-dialog.title: Select Language

View File

@ -21,6 +21,23 @@ translations:
logs.exit-code: Código de salida logs.exit-code: Código de salida
logs.completed: Completado logs.completed: Completado
logs.clear-filter: Limpiar filtro de búsqueda 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 return-to-index: Volver a la página principal
search-filter: Filtrar página actual search-filter: Filtrar página actual
language-dialog.title: Seleccionar idioma language-dialog.title: Seleccionar idioma

View File

@ -21,6 +21,23 @@ translations:
logs.exit-code: Codice di uscita logs.exit-code: Codice di uscita
logs.completed: Completato logs.completed: Completato
logs.clear-filter: Cancella filtro di ricerca 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 return-to-index: Torna alla pagina principale
search-filter: Filtra la pagina corrente search-filter: Filtra la pagina corrente
language-dialog.title: Seleziona lingua language-dialog.title: Seleziona lingua

View File

@ -27,3 +27,20 @@ translations:
logs.exit-code: 退出代码 logs.exit-code: 退出代码
logs.completed: 完成 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 时出错

View File

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

View File

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

View File

@ -363,15 +363,25 @@ func (api *oliveTinAPI) Logout(ctx ctx.Context, req *connect.Request[apiv1.Logou
response := connect.NewResponse(&apiv1.LogoutResponse{}) response := connect.NewResponse(&apiv1.LogoutResponse{})
// Clear the authentication cookie by setting it to expire // Clear the local authentication cookie by setting it to expire
cookie := &http.Cookie{ localCookie := &http.Cookie{
Name: "olivetin-sid-local", Name: "olivetin-sid-local",
Value: "", Value: "",
MaxAge: -1, // This tells the browser to delete the cookie MaxAge: -1, // This tells the browser to delete the cookie
HttpOnly: true, HttpOnly: true,
Path: "/", 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 return response, nil
} }
@ -405,7 +415,13 @@ func (api *oliveTinAPI) GetDashboard(ctx ctx.Context, req *connect.Request[apiv1
return nil, err return nil, err
} }
dashboardRenderRequest := api.createDashboardRenderRequest(user) entityType := ""
entityKey := ""
if req.Msg != nil {
entityType = req.Msg.EntityType
entityKey = req.Msg.EntityKey
}
dashboardRenderRequest := api.createDashboardRenderRequest(user, entityType, entityKey)
if api.isDefaultDashboard(req.Msg.Title) { if api.isDefaultDashboard(req.Msg.Title) {
return api.buildDefaultDashboardResponse(dashboardRenderRequest) return api.buildDefaultDashboardResponse(dashboardRenderRequest)
@ -421,11 +437,13 @@ func (api *oliveTinAPI) checkDashboardAccess(user *authpublic.AuthenticatedUser)
return nil return nil
} }
func (api *oliveTinAPI) createDashboardRenderRequest(user *authpublic.AuthenticatedUser) *DashboardRenderRequest { func (api *oliveTinAPI) createDashboardRenderRequest(user *authpublic.AuthenticatedUser, entityType, entityKey string) *DashboardRenderRequest {
return &DashboardRenderRequest{ return &DashboardRenderRequest{
AuthenticatedUser: user, AuthenticatedUser: user,
cfg: api.cfg, cfg: api.cfg,
ex: api.executor, ex: api.executor,
EntityType: entityType,
EntityKey: entityKey,
} }
} }
@ -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 { func (api *oliveTinAPI) buildRootDashboards(user *authpublic.AuthenticatedUser, dashboards []*config.DashboardComponent) []string {
var rootDashboards []string var rootDashboards []string
dashboardRenderRequest := api.createDashboardRenderRequest(user) dashboardRenderRequest := api.createDashboardRenderRequest(user, "", "")
api.addDefaultDashboardIfNeeded(&rootDashboards, dashboardRenderRequest) api.addDefaultDashboardIfNeeded(&rootDashboards, dashboardRenderRequest)
api.addCustomDashboards(&rootDashboards, dashboards, dashboardRenderRequest) api.addCustomDashboards(&rootDashboards, dashboards, dashboardRenderRequest)
@ -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) { func (api *oliveTinAPI) GetEntity(ctx ctx.Context, req *connect.Request[apiv1.GetEntityRequest]) (*connect.Response[apiv1.Entity], error) {
user := auth.UserFromApiCall(ctx, req, api.cfg) user := auth.UserFromApiCall(ctx, req, api.cfg)
@ -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)) return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("entity with unique key %s not found in type %s", req.Msg.UniqueKey, req.Msg.Type))
} else { } else {
res.Title = entity.Title res.Title = entity.Title
res.UniqueKey = entity.UniqueKey
res.Type = req.Msg.Type
res.Directories = findDirectoriesInEntityFieldsets(req.Msg.Type, api.cfg.Dashboards)
return connect.NewResponse(res), nil return connect.NewResponse(res), nil
} }

View File

@ -13,14 +13,24 @@ type DashboardRenderRequest struct {
AuthenticatedUser *authpublic.AuthenticatedUser AuthenticatedUser *authpublic.AuthenticatedUser
cfg *config.Config cfg *config.Config
ex *executor.Executor ex *executor.Executor
EntityType string
EntityKey string
} }
func (rr *DashboardRenderRequest) findAction(title string) *apiv1.Action { func (rr *DashboardRenderRequest) findAction(title string) *apiv1.Action {
return rr.findActionForEntity(title, nil)
}
func (rr *DashboardRenderRequest) findActionForEntity(title string, entity *entities.Entity) *apiv1.Action {
rr.ex.MapActionIdToBindingLock.RLock() rr.ex.MapActionIdToBindingLock.RLock()
defer rr.ex.MapActionIdToBindingLock.RUnlock() defer rr.ex.MapActionIdToBindingLock.RUnlock()
for _, binding := range rr.ex.MapActionIdToBinding { for _, binding := range rr.ex.MapActionIdToBinding {
if binding.Action.Title == title { if binding.Action.Title != title {
continue
}
if matchesEntity(binding, entity) {
return buildAction(binding, rr) return buildAction(binding, rr)
} }
} }
@ -28,6 +38,14 @@ func (rr *DashboardRenderRequest) findAction(title string) *apiv1.Action {
return nil 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 { func buildEffectivePolicy(policy *config.ConfigurationPolicy) *apiv1.EffectivePolicy {
ret := &apiv1.EffectivePolicy{ ret := &apiv1.EffectivePolicy{
ShowDiagnostics: policy.ShowDiagnostics, ShowDiagnostics: policy.ShowDiagnostics,

View File

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

View File

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