Răsfoiți Sursa

chore: Port huge amount of code to OliveTin 3k

jamesread 10 luni în urmă
părinte
comite
6b342cbedb
59 a modificat fișierele cu 3792 adăugiri și 3206 ștergeri
  1. 0 36
      frontend/index.html
  2. 4 0
      frontend/js/OutputTerminal.js
  3. 0 419
      frontend/js/marshaller.js
  4. 5 6
      frontend/js/websocket.js
  5. 6 131
      frontend/main.js
  6. 19 3
      frontend/package-lock.json
  7. 3 1
      frontend/package.json
  8. 353 40
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts
  9. 1 1
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js
  10. 124 117
      frontend/resources/vue/ActionButton.vue
  11. 72 41
      frontend/resources/vue/App.vue
  12. 0 432
      frontend/resources/vue/ArgumentForm.vue
  13. 66 14
      frontend/resources/vue/Dashboard.vue
  14. 0 421
      frontend/resources/vue/ExecutionDialog.vue
  15. 0 72
      frontend/resources/vue/LoginForm.vue
  16. 0 111
      frontend/resources/vue/LogsList.vue
  17. 58 0
      frontend/resources/vue/components/Breadcrumbs.vue
  18. 284 0
      frontend/resources/vue/components/Pagination.vue
  19. 51 59
      frontend/resources/vue/components/Sidebar.vue
  20. 0 179
      frontend/resources/vue/components/SidebarExample.vue
  21. 50 10
      frontend/resources/vue/router.js
  22. 3 0
      frontend/resources/vue/stores/buttonResults.js
  23. 337 0
      frontend/resources/vue/views/ArgumentForm.vue
  24. 0 23
      frontend/resources/vue/views/DashboardRoot.vue
  25. 59 101
      frontend/resources/vue/views/DiagnosticsView.vue
  26. 53 0
      frontend/resources/vue/views/EntitiesView.vue
  27. 43 0
      frontend/resources/vue/views/EntityDetailsView.vue
  28. 314 0
      frontend/resources/vue/views/ExecutionView.vue
  29. 6 3
      frontend/resources/vue/views/LoginView.vue
  30. 256 0
      frontend/resources/vue/views/LogsListView.vue
  31. 0 318
      frontend/resources/vue/views/LogsView.vue
  32. 6 58
      frontend/resources/vue/views/NotFoundView.vue
  33. 75 3
      frontend/style.css
  34. 135 20
      service/gen/olivetin/api/v1/apiv1connect/olivetin.connect.go
  35. 771 87
      service/gen/olivetin/api/v1/olivetin.pb.go
  36. 164 44
      service/internal/api/api.go
  37. 20 53
      service/internal/api/apiActions.go
  38. 29 26
      service/internal/api/dashboard_entities.go
  39. 43 23
      service/internal/api/dashboards.go
  40. 3 1
      service/internal/config/config.go
  41. 16 14
      service/internal/entities/entities.go
  42. 1 1
      service/internal/entities/entities_test.go
  43. 111 0
      service/internal/entities/storage.go
  44. 106 0
      service/internal/entities/templates.go
  45. 0 0
      service/internal/entities/testdata/object-per-line.json
  46. 14 13
      service/internal/executor/arguments.go
  47. 35 17
      service/internal/executor/executor.go
  48. 65 25
      service/internal/executor/executor_actions.go
  49. 7 7
      service/internal/httpservers/singleFrontend.go
  50. 5 77
      service/internal/httpservers/webuiServer.go
  51. 2 2
      service/internal/installationinfo/buildinfo.go
  52. 0 16
      service/internal/installationinfo/init.go
  53. 4 2
      service/internal/installationinfo/runtimeinfo.go
  54. 6 6
      service/internal/installationinfo/sosreport.go
  55. 0 59
      service/internal/stringvariables/entities.go
  56. 0 12
      service/internal/stringvariables/entities_test.go
  57. 0 76
      service/internal/stringvariables/map.go
  58. 0 20
      service/internal/stringvariables/map_test.go
  59. 7 6
      service/main.go

+ 0 - 36
frontend/index.html

@@ -75,42 +75,6 @@
 			</div>
 		</dialog>
 
-		<template id = "tplLoginForm">
-			<section id = "content-login" title = "Login" hidden>
-				<div class = "flex-col">
-					<form class = "box-shadow padded-content border-radius" id = "local-user-login">
-						<p class = "login-disabled">This server is not configured with either OAuth, or local users, so you cannot login.</p>
-
-						<div class = "login-oauth2" hidden>
-							<h2>OAuth Login</h2>
-						</div>
-
-						<br />
-
-						<div class = "login-local" hidden>
-							<h2>Local Login</h2>
-							<div class = "error"></div>
-							<div class = "arguments">
-								<label for = "in-username">
-									<span>Username:</span>
-								</label>
-								<input type = "text" name = "username" id = "in-username" class = "username" autocomplete = "username"/>
-								<span></span>
-
-								<label for = "in-password">
-									<span>Password:</span>
-								</label>
-								<input type = "password" name = "password" id = "in-password" class = "password" />
-								<span></span>
-
-								<button type = "submit">Login</button>
-							</div>
-						</div>
-					</form>
-				</div>
-			</section>
-		</template>
-
 		<template id = "tplArgumentForm">
 			<dialog title = "Arguments" id = "argument-popup">
 				<form class = "action-arguments padded-content">

+ 4 - 0
frontend/js/OutputTerminal.js

@@ -66,6 +66,10 @@ export class OutputTerminal {
     this.terminal.open(el)
   }
 
+  close () {
+    this.terminal.dispose()
+  }
+
   resize (cols, rows) {
     this.terminal.resize(cols, rows)
   }

+ 0 - 419
frontend/js/marshaller.js

@@ -1,117 +1,14 @@
-function checkAndTriggerActionFromQueryParam () {
-  const params = getQueryParams()
-
-  const action = params.get('action')
-  if (action && window.actionButtons) {
-    // Look for an action button with matching title
-    const actionButton = window.actionButtons[action]
-
-    if (actionButton) {
-      // Only trigger actions that have arguments
-      const jsonButton = window.actionButtonsJson[action]
-      if (jsonButton && jsonButton.arguments && jsonButton.arguments.length > 0) {
-        // Trigger the action button click
-        setTimeout(() => {
-          actionButton.btn.click()
-        }, 500) // Small delay to ensure UI is fully loaded
-        return true
-      }
-    }
-  }
-  return false
-}
-
-function createElement (tag, attributes) {
-  const el = document.createElement(tag)
-
-  if (attributes !== null) {
-    if (attributes.classNames !== undefined) {
-      el.classList.add(...attributes.classNames)
-    }
-
-    if (attributes.innerText !== undefined) {
-      el.innerText = attributes.innerText
-    }
-  }
-
-  return el
-}
-
-function createTag (val) {
-  const domTag = createElement('span', {
-    innerText: val,
-    classNames: ['tag']
-  })
-
-  return domTag
-}
-
-function createAnnotation (key, val) {
-  const domAnnotation = createElement('span', {
-    classNames: ['annotation']
-  })
-
-  domAnnotation.appendChild(createElement('span', {
-    innerText: key,
-    classNames: ['annotation-key']
-  }))
-
-  domAnnotation.appendChild(createElement('span', {
-    innerText: val,
-    classNames: ['annotation-value']
-  }))
-
-  return domAnnotation
-}
-
 /**
  * This is a weird function that just sets some globals.
  */
 export function initMarshaller () {
   window.logEntries = new Map()
 
-
   window.addEventListener('EventExecutionStarted', onExecutionStarted)
   window.addEventListener('EventExecutionFinished', onExecutionFinished)
   window.addEventListener('EventOutputChunk', onOutputChunk)
 }
 
-function setUsername (username, provider) {
-  document.getElementById('username').innerText = username
-  document.getElementById('username').setAttribute('title', provider)
-
-  if (window.settings.AuthLocalLogin || window.settings.AuthOAuth2Providers !== null) {
-    if (username === 'guest') {
-      document.getElementById('link-login').hidden = false
-      document.getElementById('link-logout').hidden = true
-    } else {
-      document.getElementById('link-login').hidden = true
-
-      if (provider === 'local' || provider === 'oauth2') {
-        document.getElementById('link-logout').hidden = false
-      }
-    }
-  }
-}
-
-export function marshalDashboardComponentsJsonToHtml (json) {
-  if (json == null) { // eg: HTTP 403
-    setUsername('guest', 'system')
-
-    if (window.settings.AuthLoginUrl !== '') {
-      window.location = window.settings.AuthLoginUrl
-    } else {
-      showSection('/login')
-    }
-  } else {
-    setUsername(json.authenticatedUser, json.authenticatedUserProvider)
-
-    marshalDashboardStructureToHtml(json)
-  }
-
-  document.body.setAttribute('initial-marshal-complete', 'true')
-}
-
 function onOutputChunk (evt) {
   const chunk = evt.payload
 
@@ -172,319 +69,3 @@ function onExecutionFinished (evt) {
     })
   }
 }
-
-function convertPathToBreadcrumb (path) {
-  const parts = path.split('/')
-
-  const result = []
-
-  for (let i = 0; i < parts.length; i++) {
-    if (parts[i] === '') {
-      continue
-    }
-
-    result.push(parts.slice(0, i + 1).join('/'))
-  }
-
-  return result
-}
-
-function showExecutionResult (pathName) {
-  const executionTrackingId = pathName.split('/')[2]
-  window.executionDialog.fetchExecutionResult(executionTrackingId)
-  window.executionDialog.show()
-}
-
-function setSectionNavigationVisible (visible) {
-  const nav = document.querySelector('nav')
-  const btn = document.getElementById('sidebar-toggler-button')
-
-  nav.removeAttribute('hidden')
-
-  if (document.body.classList.contains('has-sidebar')) {
-    if (visible) {
-      btn.setAttribute('aria-pressed', false)
-      btn.setAttribute('aria-label', 'Open sidebar navigation')
-      btn.innerHTML = '&laquo;'
-
-      nav.classList.add('shown')
-    } else {
-      btn.setAttribute('aria-pressed', true)
-      btn.setAttribute('aria-label', 'Close sidebar navigation')
-      btn.innerHTML = '&#9776;'
-
-      nav.classList.remove('shown')
-    }
-  } else {
-    btn.disabled = true
-  }
-}
-
-export function setupSectionNavigation (style) {
-  const nav = document.querySelector('nav')
-  const btn = document.getElementById('sidebar-toggler-button')
-
-  if (style === 'sidebar') {
-    nav.classList.add('sidebar')
-
-    document.body.classList.add('has-sidebar')
-
-    btn.onclick = () => {
-      if (nav.classList.contains('shown')) {
-        setSectionNavigationVisible(false)
-      } else {
-        setSectionNavigationVisible(true)
-      }
-    }
-  } else {
-    nav.classList.add('topbar')
-
-    document.body.classList.add('has-topbar')
-  }
-}
-
-function marshalSingleDashboard (dashboard, nav) {
-  const oldsection = document.querySelector('section[title="' + getSystemTitle(dashboard.title) + '"]')
-
-  if (oldsection != null) {
-    oldsection.remove()
-  }
-
-  const section = document.createElement('section')
-  section.setAttribute('system-title', getSystemTitle(dashboard.title))
-  section.title = section.getAttribute('system-title')
-
-  const def = createFieldset('default', section)
-  section.appendChild(def)
-
-  document.getElementsByTagName('main')[0].appendChild(section)
-  marshalContainerContents(dashboard, section, def, dashboard.title)
-
-  const oldLi = nav.querySelector('li[title="' + dashboard.title + '"]')
-
-  if (oldLi != null) {
-    oldLi.remove()
-  }
-
-  const systemTitleUrl = '/' + getSystemTitle(dashboard.title)
-
-  window.navbar.createLink(dashboard.title, systemTitleUrl, false)
-
-}
-
-function marshalDashboardStructureToHtml (json) {
-  return
-  const nav = document.getElementById('navigation-links')
-
-  for (const dashboard of json.dashboards) {
-    marshalSingleDashboard(dashboard, nav)
-  }
-
-  const rootGroup = document.querySelector('#root-group')
-
-  for (const btn of Object.values(window.actionButtons)) {
-    if (btn.parentElement === null) {
-      rootGroup.appendChild(btn)
-    }
-  }
-
-  const shouldHideActions = rootGroup.querySelectorAll('action-button').length === 0 && json.dashboards.length > 0
-
-  if (shouldHideActions) {
-    nav.querySelector('li[title="Actions"]').style.display = 'none'
-  }
-
-  if (window.currentPath !== '') {
-    showSection(window.currentPath)
-  } else if (window.location.pathname !== '/' && document.body.getAttribute('initial-marshal-complete') === null) {
-    showSection(window.location.pathname)
-  } else {
-    if (shouldHideActions) {
-      showSection('/' + getSystemTitle(json.dashboards[0].title))
-    } else {
-      showSection('/')
-    }
-  }
-}
-
-function marshalLink (item, fieldset) {
-  return
-  let btn = window.actionButtons[item.title]
-
-  if (typeof btn === 'undefined') {
-    btn = document.createElement('button')
-    btn.innerText = 'Action not found: ' + item.title
-    btn.classList.add('error')
-  }
-
-  if (item.cssClass !== '') {
-    btn.classList.add(item.cssClass)
-  }
-
-  fieldset.appendChild(btn)
-}
-
-function marshalMreOutput (dashboardComponent, fieldset) {
-  const pre = document.createElement('pre')
-  pre.classList.add('mre-output')
-  pre.innerHTML = 'Waiting...'
-
-  const args = {
-    actionId: dashboardComponent.title
-  }
-
-  try { 
-    const status = window.client.executionStatus(args)
-
-    updateMre(pre, status.logEntry)
-  } catch (err) {
-    pre.innerHTML = 'error'
-
-      throw new Error(res.statusText)
-  }
-
-  const updateMre = (pre, json) => {
-    pre.innerHTML = json.output
-  }
-
-  window.addEventListener('ExecutionFinished', (e) => {
-    // The dashboard component "title" field is used for lots of things
-    // and in this context for MreOutput it's just to refer an an actionId.
-    //
-    // So this is not a typo.
-    if (e.payload.actionId === dashboardComponent.title) {
-      updateMre(pre, e.payload)
-    }
-  })
-
-  fieldset.appendChild(pre)
-}
-
-function marshalContainerContents (json, section, fieldset, parentDashboard) {
-  for (const item of json.contents) {
-    switch (item.type) {
-      case 'fieldset':
-        marshalFieldset(item, section, parentDashboard)
-        break
-      case 'directory': {
-        const directoryPath = marshalDirectory(item, section)
-        marshalDirectoryButton(item, fieldset, directoryPath)
-      }
-        break
-      case 'display':
-        marshalDisplay(item, fieldset)
-        break
-      case 'stdout-most-recent-execution':
-        marshalMreOutput(item, fieldset)
-        break
-      case 'link':
-        marshalLink(item, fieldset)
-        break
-      default:
-    }
-  }
-}
-
-function createFieldset (title, parentDashboard) {
-  const legend = document.createElement('legend')
-  legend.innerText = title
-
-  const fs = document.createElement('fieldset')
-  fs.title = title
-  fs.appendChild(legend)
-
-  if (typeof parentDashboard === 'undefined') {
-    fs.setAttribute('parent-dashboard', '')
-  } else {
-    fs.setAttribute('parent-dashboard', parentDashboard)
-  }
-
-  return fs
-}
-
-function marshalFieldset (item, section, parentDashboard) {
-  const fs = createFieldset(item.title, parentDashboard)
-
-  marshalContainerContents(item, section, fs, parentDashboard)
-
-  section.appendChild(fs)
-}
-
-function createNavigationBreadcrumbDisplay (path) {
-  const a = document.createElement('a')
-  a.href = 'javascript:void(0)'
-
-  if (path.view === null) {
-    a.title = path.section
-    a.innerText = path.section
-  } else {
-    a.innerText = path.view
-    a.title = path.view
-  }
-
-  a.onclick = () => {
-    showSectionView(path.view)
-  }
-
-  return a
-}
-
-function marshalDisplay (item, fieldset) {
-  const display = document.createElement('div')
-  display.innerHTML = item.title
-  display.classList.add('display')
-
-  if (item.cssClass !== '') {
-    display.classList.add(item.cssClass)
-  }
-
-  fieldset.appendChild(display)
-}
-
-function marshalDirectoryButton (item, fieldset, path) {
-  const directoryButton = document.createElement('button')
-  directoryButton.innerHTML = '<span class = "icon">' + item.icon + '</span> ' + item.title
-  directoryButton.onclick = () => {
-    showSection(path)
-  }
-
-  fieldset.appendChild(directoryButton)
-}
-
-function marshalDirectory (item, section) {
-  const fs = createFieldset(item.title)
-  fs.style.display = 'none'
-
-  const directoryBackButton = document.createElement('button')
-  directoryBackButton.innerHTML = window.settings.DefaultIconForBack
-  directoryBackButton.title = 'Go back one directory'
-  directoryBackButton.onclick = () => {
-    showSection('/' + section.title)
-  }
-
-  fs.appendChild(directoryBackButton)
-
-  marshalContainerContents(item, section, fs)
-
-  section.appendChild(fs)
-
-  const path = '/' + section.title + '/' + getSystemTitle(item.title)
-
-  return path
-}
-
-export function refreshServerConnectionLabel () {
-  if (window.restAvailable) {
-    document.querySelector('#serverConnectionRest').classList.remove('error')
-  } else {
-    document.querySelector('#serverConnectionRest').classList.add('error')
-  }
-
-  if (window.websocketAvailable) {
-    document.querySelector('#serverConnectionWebSocket').classList.remove('error')
-    document.querySelector('#serverConnectionWebSocket').innerText = 'WebSocket'
-  } else {
-    document.querySelector('#serverConnectionWebSocket').classList.add('error')
-    document.querySelector('#serverConnectionWebSocket').innerText = 'WebSocket Error'
-  }
-}

+ 5 - 6
frontend/js/websocket.js

@@ -1,6 +1,4 @@
-import {
-  refreshServerConnectionLabel
-} from './marshaller.js'
+import { buttonResults } from '../resources/vue/stores/buttonResults.js'
 
 export function checkWebsocketConnection () {
   reconnectWebsocket()
@@ -29,8 +27,6 @@ async function reconnectWebsocket () {
 function handleEvent (msg) {
   const typeName = msg.event.value.$typeName.replace('olivetin.api.v1.', '')
 
-  console.log("Websocket event receved: ", typeName)
-
   const j = new Event(typeName)
   j.payload = msg.event.value
 
@@ -38,9 +34,12 @@ function handleEvent (msg) {
     case 'EventOutputChunk':
     case 'EventConfigChanged':
     case 'EventEntityChanged':
+      window.dispatchEvent(j)
+      break
     case 'EventExecutionFinished':
     case 'EventExecutionStarted':
-      window.dispatchEvent(j)
+      console.log('EventExecutionStarted', msg.event.value.logEntry.executionTrackingId)
+      buttonResults[msg.event.value.logEntry.executionTrackingId] = msg.event.value.logEntry
       break
     default:
       console.warn('Unhandled websocket message type from server: ', typeName)

+ 6 - 131
frontend/main.js

@@ -11,121 +11,10 @@ import App from './resources/vue/App.vue';
 
 import {
   initMarshaller,
-  setupSectionNavigation,
-  marshalDashboardComponentsJsonToHtml,
-  refreshServerConnectionLabel
 } from './js/marshaller.js'
 
 import { checkWebsocketConnection } from './js/websocket.js'
 
-function searchLogs (e) {
-  document.getElementById('searchLogsClear').disabled = false
-
-  const searchText = e.target.value.toLowerCase()
-
-  for (const row of document.querySelectorAll('tr.log-row')) {
-    const actionTitle = row.getAttribute('title').toLowerCase()
-
-    row.hidden = !actionTitle.includes(searchText)
-  }
-}
-
-function searchLogsClear () {
-  for (const row of document.querySelectorAll('tr.log-row')) {
-    row.hidden = false
-  }
-
-  document.getElementById('searchLogsClear').disabled = true
-  document.getElementById('logSearchBox').value = ''
-}
-
-
-function refreshLoop () {
-  checkWebsocketConnection()
-//  fetchGetDashboardComponents()
-//  fetchGetLogs()
-  refreshServerConnectionLabel()
-}
-
-async function fetchGetDashboardComponents () {
-  try {
-    const res = await window.client.getDashboardComponents()
-
-    marshalDashboardComponentsJsonToHtml(res)
-
-    refreshServerConnectionLabel() // in-case it changed, update the label quicker
-  } catch(err) {
-    window.showBigError('fetch-buttons', 'getting buttons', err, false)
-  }
-}
-
-function processWebuiSettingsJson (settings) {
-  setupSectionNavigation(settings.SectionNavigationStyle)
-
-  window.restBaseUrl = settings.Rest
-
-  document.querySelector('#currentVersion').innerText = settings.CurrentVersion
-
-  if (settings.ShowNewVersions && settings.AvailableVersion !== 'none') {
-    document.querySelector('#available-version').innerText = 'New Version Available: ' + settings.AvailableVersion
-    document.querySelector('#available-version').hidden = false
-  }
-
-  if (!settings.ShowNavigation) {
-    document.querySelector('header').style.display = 'none'
-  }
-
-  if (!settings.ShowFooter) {
-    document.querySelector('footer[title="footer"]').style.display = 'none'
-  }
-
-  if (settings.EnableCustomJs) {
-    const script = document.createElement('script')
-    script.src = './custom-webui/custom.js'
-    document.head.appendChild(script)
-  }
-
-  window.pageTitle = 'OliveTin'
-
-  if (settings.PageTitle) {
-    window.pageTitle = settings.PageTitle
-
-    document.title = window.pageTitle
-
-    const titleElem = document.querySelector('#page-title')
-    if (titleElem) titleElem.innerText = window.pageTitle
-  }
-
-  processAdditionalLinks(settings.AdditionalLinks)
-
-  window.settings = settings
-}
-
-function processAdditionalLinks (links) {
-  if (links === null) {
-    return
-  }
-
-  if (links.length > 0) {
-    for (const link of links) {
-      const linkA = document.createElement('a')
-      linkA.href = link.Url
-      linkA.innerText = link.Title
-
-      if (link.Target === '') {
-        linkA.target = '_blank'
-      } else {
-        linkA.target = link.Target
-      }
-
-      const linkLi = document.createElement('li')
-      linkLi.appendChild(linkA)
-
-      document.getElementById('supplemental-links').prepend(linkLi)
-    }
-  }
-}
-
 function initClient () {
   const transport = createConnectTransport({
     baseUrl: window.location.protocol + '//' + window.location.host + '/api/',
@@ -143,30 +32,16 @@ function setupVue () {
 }
 
 function main () {
-  setupVue();
-
   initClient() 
 
-  initMarshaller()
-
-  window.addEventListener('EventConfigChanged', fetchGetDashboardComponents)
-  window.addEventListener('EventEntityChanged', fetchGetDashboardComponents)
-
-  window.fetch('webUiSettings.json').then(res => {
-    return res.json()
-  }).then(res => {
-    processWebuiSettingsJson(res)
-
-    fetchGetDashboardComponents()
+  checkWebsocketConnection()
+  
+  setupVue();
 
-    window.restAvailable = true
-    window.refreshLoop = refreshLoop
-    window.refreshLoop()
+  initMarshaller()
 
-    setInterval(refreshLoop, 3000)
-  }).catch(err => {
-    window.showBigError('fetch-webui-settings', 'getting webui settings', err)
-  })
+//  window.addEventListener('EventConfigChanged', fetchGetDashboardComponents)
+//  window.addEventListener('EventEntityChanged', fetchGetDashboardComponents)
 }
 
 main() // call self

+ 19 - 3
frontend/package-lock.json

@@ -11,6 +11,8 @@
 			"dependencies": {
 				"@connectrpc/connect": "^2.0.3",
 				"@connectrpc/connect-web": "^2.0.3",
+				"@hugeicons/core-free-icons": "^1.0.16",
+				"@hugeicons/vue": "^1.0.3",
 				"@vitejs/plugin-vue": "^6.0.1",
 				"@xterm/addon-fit": "^0.10.0",
 				"@xterm/xterm": "^5.5.0",
@@ -722,6 +724,20 @@
 				"node": "^10.12.0 || >=12.0.0"
 			}
 		},
+		"node_modules/@hugeicons/core-free-icons": {
+			"version": "1.0.16",
+			"resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-1.0.16.tgz",
+			"integrity": "sha512-rm48rjUN8a58yOZ7mpk3HkMvZPmOMTkMtNdXXq9m0Af6BsRQWJZl+4zd6ssj52y+A9Zn4Yg/TptobNtNpx3GCg==",
+			"license": "MIT"
+		},
+		"node_modules/@hugeicons/vue": {
+			"version": "1.0.3",
+			"resolved": "https://registry.npmjs.org/@hugeicons/vue/-/vue-1.0.3.tgz",
+			"integrity": "sha512-DF9A277Ej4Eahu11Hkd3v6V0eZ1NHWZWs9OOByJaxGekgG8q7DAbkhltIo3bqsoxVprxaKSX3Mmn5a2dyzLsHA==",
+			"peerDependencies": {
+				"vue": "^2.6.0 || ^3.0.0"
+			}
+		},
 		"node_modules/@humanwhocodes/config-array": {
 			"version": "0.5.0",
 			"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.5.0.tgz",
@@ -2589,9 +2605,9 @@
 			}
 		},
 		"node_modules/femtocrank": {
-			"version": "1.2.2",
-			"resolved": "https://registry.npmjs.org/femtocrank/-/femtocrank-1.2.2.tgz",
-			"integrity": "sha512-tXvXllBCZ1Wt5QRHr07+cSjsRr/lBqDWow7WhVn67zg7BE8MZ1Niv8BzSbx9c7JKYaso7OIVm02Yo/9wJYzhGw==",
+			"version": "1.2.4",
+			"resolved": "https://registry.npmjs.org/femtocrank/-/femtocrank-1.2.4.tgz",
+			"integrity": "sha512-OAyowQ45LOwl7xWaOFyXlmP9MzdV9sQrTz1130vgqF2TxDkOx6y2uP8QK/+12MGMP0v7KkiTO8fE/TN6orb9fg==",
 			"license": "AGPL-3.0",
 			"dependencies": {
 				"vite": "^6.3.5"

+ 3 - 1
frontend/package.json

@@ -29,10 +29,12 @@
 	"dependencies": {
 		"@connectrpc/connect": "^2.0.3",
 		"@connectrpc/connect-web": "^2.0.3",
+		"@hugeicons/core-free-icons": "^1.0.16",
+		"@hugeicons/vue": "^1.0.3",
 		"@vitejs/plugin-vue": "^6.0.1",
 		"@xterm/addon-fit": "^0.10.0",
 		"@xterm/xterm": "^5.5.0",
-		"femtocrank": "^1.2.2",
+		"femtocrank": "^1.2.4",
 		"unplugin-vue-components": "^28.8.0",
 		"vite": "^7.0.6",
 		"vue-router": "^4.5.1"

+ 353 - 40
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts

@@ -1,4 +1,4 @@
-// @generated by protoc-gen-es v2.6.2
+// @generated by protoc-gen-es v2.6.3
 // @generated from file olivetin/api/v1/olivetin.proto (package olivetin.api.v1, syntax proto3)
 /* eslint-disable */
 
@@ -15,9 +15,9 @@ export declare const file_olivetin_api_v1_olivetin: GenFile;
  */
 export declare type Action = Message<"olivetin.api.v1.Action"> & {
   /**
-   * @generated from field: string id = 1;
+   * @generated from field: string binding_id = 1;
    */
-  id: string;
+  bindingId: string;
 
   /**
    * @generated from field: string title = 2;
@@ -133,14 +133,14 @@ export declare type Entity = Message<"olivetin.api.v1.Entity"> & {
   title: string;
 
   /**
-   * @generated from field: string icon = 2;
+   * @generated from field: string unique_key = 2;
    */
-  icon: string;
+  uniqueKey: string;
 
   /**
-   * @generated from field: repeated olivetin.api.v1.Action actions = 3;
+   * @generated from field: string type = 3;
    */
-  actions: Action[];
+  type: string;
 };
 
 /**
@@ -150,40 +150,25 @@ export declare type Entity = Message<"olivetin.api.v1.Entity"> & {
 export declare const EntitySchema: GenMessage<Entity>;
 
 /**
- * @generated from message olivetin.api.v1.GetDashboardComponentsResponse
+ * @generated from message olivetin.api.v1.GetDashboardResponse
  */
-export declare type GetDashboardComponentsResponse = Message<"olivetin.api.v1.GetDashboardComponentsResponse"> & {
+export declare type GetDashboardResponse = Message<"olivetin.api.v1.GetDashboardResponse"> & {
   /**
    * @generated from field: string title = 1;
    */
   title: string;
 
   /**
-   * @generated from field: repeated olivetin.api.v1.Dashboard dashboards = 4;
-   */
-  dashboards: Dashboard[];
-
-  /**
-   * @generated from field: string authenticated_user = 5;
-   */
-  authenticatedUser: string;
-
-  /**
-   * @generated from field: string authenticated_user_provider = 6;
-   */
-  authenticatedUserProvider: string;
-
-  /**
-   * @generated from field: olivetin.api.v1.EffectivePolicy effective_policy = 7;
+   * @generated from field: olivetin.api.v1.Dashboard dashboard = 4;
    */
-  effectivePolicy?: EffectivePolicy;
+  dashboard?: Dashboard;
 };
 
 /**
- * Describes the message olivetin.api.v1.GetDashboardComponentsResponse.
- * Use `create(GetDashboardComponentsResponseSchema)` to create a new message.
+ * Describes the message olivetin.api.v1.GetDashboardResponse.
+ * Use `create(GetDashboardResponseSchema)` to create a new message.
  */
-export declare const GetDashboardComponentsResponseSchema: GenMessage<GetDashboardComponentsResponse>;
+export declare const GetDashboardResponseSchema: GenMessage<GetDashboardResponse>;
 
 /**
  * @generated from message olivetin.api.v1.EffectivePolicy
@@ -207,16 +192,20 @@ export declare type EffectivePolicy = Message<"olivetin.api.v1.EffectivePolicy">
 export declare const EffectivePolicySchema: GenMessage<EffectivePolicy>;
 
 /**
- * @generated from message olivetin.api.v1.GetDashboardComponentsRequest
+ * @generated from message olivetin.api.v1.GetDashboardRequest
  */
-export declare type GetDashboardComponentsRequest = Message<"olivetin.api.v1.GetDashboardComponentsRequest"> & {
+export declare type GetDashboardRequest = Message<"olivetin.api.v1.GetDashboardRequest"> & {
+  /**
+   * @generated from field: string title = 1;
+   */
+  title: string;
 };
 
 /**
- * Describes the message olivetin.api.v1.GetDashboardComponentsRequest.
- * Use `create(GetDashboardComponentsRequestSchema)` to create a new message.
+ * Describes the message olivetin.api.v1.GetDashboardRequest.
+ * Use `create(GetDashboardRequestSchema)` to create a new message.
  */
-export declare const GetDashboardComponentsRequestSchema: GenMessage<GetDashboardComponentsRequest>;
+export declare const GetDashboardRequestSchema: GenMessage<GetDashboardRequest>;
 
 /**
  * @generated from message olivetin.api.v1.Dashboard
@@ -267,6 +256,11 @@ export declare type DashboardComponent = Message<"olivetin.api.v1.DashboardCompo
    * @generated from field: string css_class = 5;
    */
   cssClass: string;
+
+  /**
+   * @generated from field: olivetin.api.v1.Action action = 6;
+   */
+  action?: Action;
 };
 
 /**
@@ -280,9 +274,9 @@ export declare const DashboardComponentSchema: GenMessage<DashboardComponent>;
  */
 export declare type StartActionRequest = Message<"olivetin.api.v1.StartActionRequest"> & {
   /**
-   * @generated from field: string action_id = 1;
+   * @generated from field: string binding_id = 1;
    */
-  actionId: string;
+  bindingId: string;
 
   /**
    * @generated from field: repeated olivetin.api.v1.StartActionArgument arguments = 2;
@@ -569,6 +563,16 @@ export declare type GetLogsResponse = Message<"olivetin.api.v1.GetLogsResponse">
    * @generated from field: int64 page_size = 3;
    */
   pageSize: bigint;
+
+  /**
+   * @generated from field: int64 total_count = 4;
+   */
+  totalCount: bigint;
+
+  /**
+   * @generated from field: int64 start_offset = 5;
+   */
+  startOffset: bigint;
 };
 
 /**
@@ -1187,17 +1191,294 @@ export declare type GetDiagnosticsResponse = Message<"olivetin.api.v1.GetDiagnos
  */
 export declare const GetDiagnosticsResponseSchema: GenMessage<GetDiagnosticsResponse>;
 
+/**
+ * @generated from message olivetin.api.v1.InitRequest
+ */
+export declare type InitRequest = Message<"olivetin.api.v1.InitRequest"> & {
+};
+
+/**
+ * Describes the message olivetin.api.v1.InitRequest.
+ * Use `create(InitRequestSchema)` to create a new message.
+ */
+export declare const InitRequestSchema: GenMessage<InitRequest>;
+
+/**
+ * @generated from message olivetin.api.v1.InitResponse
+ */
+export declare type InitResponse = Message<"olivetin.api.v1.InitResponse"> & {
+  /**
+   * @generated from field: bool showFooter = 1;
+   */
+  showFooter: boolean;
+
+  /**
+   * @generated from field: bool showNavigation = 2;
+   */
+  showNavigation: boolean;
+
+  /**
+   * @generated from field: bool showNewVersions = 3;
+   */
+  showNewVersions: boolean;
+
+  /**
+   * @generated from field: string availableVersion = 4;
+   */
+  availableVersion: string;
+
+  /**
+   * @generated from field: string currentVersion = 5;
+   */
+  currentVersion: string;
+
+  /**
+   * @generated from field: string pageTitle = 6;
+   */
+  pageTitle: string;
+
+  /**
+   * @generated from field: string sectionNavigationStyle = 7;
+   */
+  sectionNavigationStyle: string;
+
+  /**
+   * @generated from field: string defaultIconForBack = 8;
+   */
+  defaultIconForBack: string;
+
+  /**
+   * @generated from field: bool enableCustomJs = 9;
+   */
+  enableCustomJs: boolean;
+
+  /**
+   * @generated from field: string authLoginUrl = 10;
+   */
+  authLoginUrl: string;
+
+  /**
+   * @generated from field: bool authLocalLogin = 11;
+   */
+  authLocalLogin: boolean;
+
+  /**
+   * @generated from field: repeated string styleMods = 12;
+   */
+  styleMods: string[];
+
+  /**
+   * @generated from field: repeated olivetin.api.v1.OAuth2Provider oAuth2Providers = 13;
+   */
+  oAuth2Providers: OAuth2Provider[];
+
+  /**
+   * @generated from field: repeated olivetin.api.v1.AdditionalLink additionalLinks = 14;
+   */
+  additionalLinks: AdditionalLink[];
+
+  /**
+   * @generated from field: repeated string rootDashboards = 15;
+   */
+  rootDashboards: string[];
+
+  /**
+   * @generated from field: string authenticated_user = 16;
+   */
+  authenticatedUser: string;
+
+  /**
+   * @generated from field: string authenticated_user_provider = 17;
+   */
+  authenticatedUserProvider: string;
+
+  /**
+   * @generated from field: olivetin.api.v1.EffectivePolicy effective_policy = 18;
+   */
+  effectivePolicy?: EffectivePolicy;
+
+  /**
+   * @generated from field: string banner_message = 19;
+   */
+  bannerMessage: string;
+
+  /**
+   * @generated from field: string banner_css = 20;
+   */
+  bannerCss: string;
+};
+
+/**
+ * Describes the message olivetin.api.v1.InitResponse.
+ * Use `create(InitResponseSchema)` to create a new message.
+ */
+export declare const InitResponseSchema: GenMessage<InitResponse>;
+
+/**
+ * @generated from message olivetin.api.v1.AdditionalLink
+ */
+export declare type AdditionalLink = Message<"olivetin.api.v1.AdditionalLink"> & {
+  /**
+   * @generated from field: string title = 1;
+   */
+  title: string;
+
+  /**
+   * @generated from field: string url = 2;
+   */
+  url: string;
+};
+
+/**
+ * Describes the message olivetin.api.v1.AdditionalLink.
+ * Use `create(AdditionalLinkSchema)` to create a new message.
+ */
+export declare const AdditionalLinkSchema: GenMessage<AdditionalLink>;
+
+/**
+ * @generated from message olivetin.api.v1.OAuth2Provider
+ */
+export declare type OAuth2Provider = Message<"olivetin.api.v1.OAuth2Provider"> & {
+  /**
+   * @generated from field: string title = 1;
+   */
+  title: string;
+
+  /**
+   * @generated from field: string url = 2;
+   */
+  url: string;
+
+  /**
+   * @generated from field: string icon = 3;
+   */
+  icon: string;
+};
+
+/**
+ * Describes the message olivetin.api.v1.OAuth2Provider.
+ * Use `create(OAuth2ProviderSchema)` to create a new message.
+ */
+export declare const OAuth2ProviderSchema: GenMessage<OAuth2Provider>;
+
+/**
+ * @generated from message olivetin.api.v1.GetActionBindingRequest
+ */
+export declare type GetActionBindingRequest = Message<"olivetin.api.v1.GetActionBindingRequest"> & {
+  /**
+   * @generated from field: string binding_id = 1;
+   */
+  bindingId: string;
+};
+
+/**
+ * Describes the message olivetin.api.v1.GetActionBindingRequest.
+ * Use `create(GetActionBindingRequestSchema)` to create a new message.
+ */
+export declare const GetActionBindingRequestSchema: GenMessage<GetActionBindingRequest>;
+
+/**
+ * @generated from message olivetin.api.v1.GetActionBindingResponse
+ */
+export declare type GetActionBindingResponse = Message<"olivetin.api.v1.GetActionBindingResponse"> & {
+  /**
+   * @generated from field: olivetin.api.v1.Action action = 1;
+   */
+  action?: Action;
+};
+
+/**
+ * Describes the message olivetin.api.v1.GetActionBindingResponse.
+ * Use `create(GetActionBindingResponseSchema)` to create a new message.
+ */
+export declare const GetActionBindingResponseSchema: GenMessage<GetActionBindingResponse>;
+
+/**
+ * @generated from message olivetin.api.v1.GetEntitiesRequest
+ */
+export declare type GetEntitiesRequest = Message<"olivetin.api.v1.GetEntitiesRequest"> & {
+};
+
+/**
+ * Describes the message olivetin.api.v1.GetEntitiesRequest.
+ * Use `create(GetEntitiesRequestSchema)` to create a new message.
+ */
+export declare const GetEntitiesRequestSchema: GenMessage<GetEntitiesRequest>;
+
+/**
+ * @generated from message olivetin.api.v1.GetEntitiesResponse
+ */
+export declare type GetEntitiesResponse = Message<"olivetin.api.v1.GetEntitiesResponse"> & {
+  /**
+   * @generated from field: repeated olivetin.api.v1.EntityDefinition entity_definitions = 1;
+   */
+  entityDefinitions: EntityDefinition[];
+};
+
+/**
+ * Describes the message olivetin.api.v1.GetEntitiesResponse.
+ * Use `create(GetEntitiesResponseSchema)` to create a new message.
+ */
+export declare const GetEntitiesResponseSchema: GenMessage<GetEntitiesResponse>;
+
+/**
+ * @generated from message olivetin.api.v1.EntityDefinition
+ */
+export declare type EntityDefinition = Message<"olivetin.api.v1.EntityDefinition"> & {
+  /**
+   * @generated from field: string title = 1;
+   */
+  title: string;
+
+  /**
+   * @generated from field: repeated olivetin.api.v1.Entity instances = 2;
+   */
+  instances: Entity[];
+
+  /**
+   * @generated from field: repeated string used_on_dashboards = 3;
+   */
+  usedOnDashboards: string[];
+};
+
+/**
+ * Describes the message olivetin.api.v1.EntityDefinition.
+ * Use `create(EntityDefinitionSchema)` to create a new message.
+ */
+export declare const EntityDefinitionSchema: GenMessage<EntityDefinition>;
+
+/**
+ * @generated from message olivetin.api.v1.GetEntityRequest
+ */
+export declare type GetEntityRequest = Message<"olivetin.api.v1.GetEntityRequest"> & {
+  /**
+   * @generated from field: string unique_key = 1;
+   */
+  uniqueKey: string;
+
+  /**
+   * @generated from field: string type = 2;
+   */
+  type: string;
+};
+
+/**
+ * Describes the message olivetin.api.v1.GetEntityRequest.
+ * Use `create(GetEntityRequestSchema)` to create a new message.
+ */
+export declare const GetEntityRequestSchema: GenMessage<GetEntityRequest>;
+
 /**
  * @generated from service olivetin.api.v1.OliveTinApiService
  */
 export declare const OliveTinApiService: GenService<{
   /**
-   * @generated from rpc olivetin.api.v1.OliveTinApiService.GetDashboardComponents
+   * @generated from rpc olivetin.api.v1.OliveTinApiService.GetDashboard
    */
-  getDashboardComponents: {
+  getDashboard: {
     methodKind: "unary";
-    input: typeof GetDashboardComponentsRequestSchema;
-    output: typeof GetDashboardComponentsResponseSchema;
+    input: typeof GetDashboardRequestSchema;
+    output: typeof GetDashboardResponseSchema;
   },
   /**
    * @generated from rpc olivetin.api.v1.OliveTinApiService.StartAction
@@ -1343,5 +1624,37 @@ export declare const OliveTinApiService: GenService<{
     input: typeof GetDiagnosticsRequestSchema;
     output: typeof GetDiagnosticsResponseSchema;
   },
+  /**
+   * @generated from rpc olivetin.api.v1.OliveTinApiService.Init
+   */
+  init: {
+    methodKind: "unary";
+    input: typeof InitRequestSchema;
+    output: typeof InitResponseSchema;
+  },
+  /**
+   * @generated from rpc olivetin.api.v1.OliveTinApiService.GetActionBinding
+   */
+  getActionBinding: {
+    methodKind: "unary";
+    input: typeof GetActionBindingRequestSchema;
+    output: typeof GetActionBindingResponseSchema;
+  },
+  /**
+   * @generated from rpc olivetin.api.v1.OliveTinApiService.GetEntities
+   */
+  getEntities: {
+    methodKind: "unary";
+    input: typeof GetEntitiesRequestSchema;
+    output: typeof GetEntitiesResponseSchema;
+  },
+  /**
+   * @generated from rpc olivetin.api.v1.OliveTinApiService.GetEntity
+   */
+  getEntity: {
+    methodKind: "unary";
+    input: typeof GetEntityRequestSchema;
+    output: typeof EntitySchema;
+  },
 }>;
 

Fișier diff suprimat deoarece este prea mare
+ 1 - 1
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js


+ 124 - 117
frontend/resources/vue/ActionButton.vue

@@ -1,27 +1,43 @@
 <template>
-  <div :id="`actionButton-${actionId}`" role="none" class="action-button">
-    <button :id="`actionButtonInner-${actionId}`" :title="title" :disabled="!canExec || isDisabled"
-      :class="buttonClasses" @click="handleClick">
-      <span class="icon" v-html="unicodeIcon"></span>
-      <span class="title" aria-live="polite">{{ displayTitle }}</span>
-    </button>
-
-    <ArgumentForm v-if="showArgumentForm" :action-data="actionData" @submit="handleArgumentSubmit"
-      @cancel="handleArgumentCancel" @close="handleArgumentClose" />
-  </div>
+	<div :id="`actionButton-${actionId}`" role="none" class="action-button">
+		<button :id="`actionButtonInner-${actionId}`" :title="title" :disabled="!canExec || isDisabled"
+													  :class="buttonClasses" @click="handleClick">
+
+			<div class="navigate-on-start-container">
+				<div v-if="navigateOnStart == 'pop'" class="navigate-on-start" title="Opens a popup dialog on start">
+					<HugeiconsIcon :icon="ComputerTerminal01Icon" />
+				</div>
+				<div v-if="navigateOnStart == 'arg'" class="navigate-on-start" title="Opens an argument form on start">
+					<HugeiconsIcon :icon="TypeCursorIcon" />
+				</div>
+				<div v-if="navigateOnStart == ''" class="navigate-on-start" title="Run in the background">
+					<HugeiconsIcon :icon="WorkoutRunIcon" />
+				</div>
+			</div>
+
+			<span class="icon" v-html="unicodeIcon"></span>
+			<span class="title" aria-live="polite">{{ displayTitle }}
+			</span>
+		</button>
+	</div>
 </template>
 
 <script setup>
-import ArgumentForm from './ArgumentForm.vue'
+import ArgumentForm from './views/ArgumentForm.vue'
+import { buttonResults } from './stores/buttonResults'
+import { useRouter } from 'vue-router'
+import { HugeiconsIcon } from '@hugeicons/vue'
+import { WorkoutRunIcon, TypeCursorIcon, ComputerTerminal01Icon } from '@hugeicons/core-free-icons'
 
-import { ref, computed, watch, onMounted, inject } from 'vue'
+import { ref, watch, onMounted, inject } from 'vue'
 
-const executionDialog = inject('executionDialog');
+const router = useRouter()
+const navigateOnStart = ref('')
 
 const props = defineProps({
   actionData: {
-    type: Object,
-    required: true
+	type: Object,
+	required: true
   }
 })
 
@@ -46,16 +62,15 @@ const updateIterationTimestamp = ref(0)
 
 function getUnicodeIcon(icon) {
   if (icon === '') {
-    return '&#x1f4a9;'
+	return '&#x1f4a9;'
   } else {
-    return unescape(icon)
+	return unescape(icon)
   }
 }
 
 function constructFromJson(json) {
   updateIterationTimestamp.value = 0
 
-  // Class attributes
   updateFromJson(json)
 
   actionId.value = json.id
@@ -63,6 +78,12 @@ function constructFromJson(json) {
   canExec.value = json.canExec
   popupOnStart.value = json.popupOnStart
 
+  if (popupOnStart.value.includes('execution-dialog')) {
+	navigateOnStart.value = 'pop'
+  } else if (props.actionData.arguments.length > 0) {
+	navigateOnStart.value = 'arg'
+  }
+
   isDisabled.value = !json.canExec
   displayTitle.value = title.value
   unicodeIcon.value = getUnicodeIcon(json.icon)
@@ -77,29 +98,17 @@ function updateFromJson(json) {
 
 async function handleClick() {
   if (props.actionData.arguments && props.actionData.arguments.length > 0) {
-    updateUrlWithAction()
-    showArgumentForm.value = true
+	router.push(`/actionBinding/${props.actionData.bindingId}/argumentForm`)
   } else {
-    await startAction()
+	await startAction()
   }
 }
 
-function updateUrlWithAction() {
-  // Get the current URL and create a new URL object
-  const url = new URL(window.location.href)
-
-  // Set the action parameter
-  url.searchParams.set('action', title.value)
-
-  // Update the URL without reloading the page
-  window.history.replaceState({}, '', url.toString())
-}
-
 function getUniqueId() {
   if (window.isSecureContext) {
-    return window.crypto.randomUUID()
+	return window.crypto.randomUUID()
   } else {
-    return Date.now().toString()
+	return Date.now().toString()
   }
 }
 
@@ -107,70 +116,59 @@ async function startAction(actionArgs) {
   buttonClasses.value = [] // Removes old animation classes
 
   if (actionArgs === undefined) {
-    actionArgs = []
+	actionArgs = []
   }
 
   // UUIDs are create client side, so that we can setup a "execution-button"
   // to track the execution before we send the request to the server.
   const startActionArgs = {
-    actionId: actionId.value,
-    arguments: actionArgs,
-    uniqueTrackingId: getUniqueId()
+	bindingId: props.actionData.bindingId,
+	arguments: actionArgs,
+	uniqueTrackingId: getUniqueId()
   }
 
-  onActionStarted(startActionArgs.uniqueTrackingId)
+  console.log('Watching buttonResults for', startActionArgs.uniqueTrackingId)
+
+  watch(
+	() => buttonResults[startActionArgs.uniqueTrackingId],
+	(newResult, oldResult) => {
+	  onLogEntryChanged(newResult)
+	}
+  )
 
   try {
-    await window.client.startAction(startActionArgs)
+	await window.client.startAction(startActionArgs)
   } catch (err) {
-    console.error('Failed to start action:', err)
+	console.error('Failed to start action:', err)
   }
 }
 
-function onActionStarted(execTrackingId) {
-  console.log('onActionStarted', execTrackingId)
-  console.log('executionDialog', executionDialog)
+function onLogEntryChanged(logEntry) {
+  if (logEntry.executionFinished) {
+	onExecutionFinished(logEntry)
+  } else {
+	onExecutionStarted(logEntry)
+  }
+}
 
+function onExecutionStarted(logEntry) {
   if (popupOnStart.value && popupOnStart.value.includes('execution-dialog')) {
-    if (executionDialog.value) {
-      executionDialog.value.reset();
-
-      if (popupOnStart.value === 'execution-dialog-stdout-only') {
-        executionDialog.value.hideEverythingApartFromOutput();
-      }
-    }
-
-    executionDialog.value.executionTrackingId = execTrackingId;
-    executionDialog.value.show()
+	router.push(`/logs/${logEntry.executionTrackingId}`)
   }
 
   isDisabled.value = true
 }
 
-function handleArgumentSubmit(args) {
-  startAction(args)
-  showArgumentForm.value = false
-}
-
-function handleArgumentCancel() {
-  showArgumentForm.value = false
-}
-
-function handleArgumentClose() {
-  showArgumentForm.value = false
-}
-
-// ExecutionFeedbackButton methods
 function onExecutionFinished(logEntry) {
   if (logEntry.timedOut) {
-    renderExecutionResult('action-timeout', 'Timed out')
+	renderExecutionResult('action-timeout', 'Timed out')
   } else if (logEntry.blocked) {
-    renderExecutionResult('action-blocked', 'Blocked!')
+	renderExecutionResult('action-blocked', 'Blocked!')
   } else if (logEntry.exitCode !== 0) {
-    renderExecutionResult('action-nonzero-exit', 'Exit code ' + logEntry.exitCode)
+	renderExecutionResult('action-nonzero-exit', 'Exit code ' + logEntry.exitCode)
   } else {
-    const ellapsed = Math.ceil(new Date(logEntry.datetimeFinished) - new Date(logEntry.datetimeStarted)) / 1000
-    renderExecutionResult('action-success', 'Success!')
+	const ellapsed = Math.ceil(new Date(logEntry.datetimeFinished) - new Date(logEntry.datetimeStarted)) / 1000
+	renderExecutionResult('action-success', 'Success!')
   }
 }
 
@@ -181,9 +179,9 @@ function renderExecutionResult(resultCssClass, temporaryStatusMessage) {
 
 function updateDom(resultCssClass, newTitle) {
   if (resultCssClass == null) {
-    buttonClasses.value = []
+	buttonClasses.value = []
   } else {
-    buttonClasses.value = [resultCssClass]
+	buttonClasses.value = [resultCssClass]
   }
 
   displayTitle.value = newTitle
@@ -193,7 +191,7 @@ function onExecStatusChanged() {
   isDisabled.value = false
 
   setTimeout(() => {
-    updateDom(null, title.value)
+	updateDom(null, title.value)
   }, 2000)
 }
 
@@ -204,84 +202,93 @@ onMounted(() => {
 watch(
   () => props.actionData,
   (newData) => {
-    updateFromJson(newData)
+	updateFromJson(newData)
   },
   { deep: true }
 )
 
-defineExpose({
-  onExecutionFinished
-})
 </script>
 
 <style scoped>
 .action-button {
-  display: flex;
-  flex-direction: column;
-  flex-grow: 1;
+	display: flex;
+	flex-direction: column;
+	flex-grow: 1;
 }
 
 .action-button button {
-  display: flex;
-  flex-direction: column;
-  flex-grow: 1;
-  justify-content: center;
-  gap: 0.5em;
-  padding: 0.5em 1em;
-  border: 1px solid #ccc;
-  border-radius: 4px;
-  background: #fff;
-  cursor: pointer;
-  transition: all 0.2s ease;
-  box-shadow: 0 0 .6em #aaa;
-  font-size: .85em;
-  border-radius: .7em;
+	display: flex;
+	flex-direction: column;
+	flex-grow: 1;
+	justify-content: center;
+	padding: 0.5em;
+	border: 1px solid #ccc;
+	border-radius: 4px;
+	background: #fff;
+	cursor: pointer;
+	transition: all 0.2s ease;
+	box-shadow: 0 0 .6em #aaa;
+	font-size: .85em;
+	border-radius: .7em;
 }
 
 .action-button button:hover:not(:disabled) {
-  background: #f5f5f5;
-  border-color: #999;
+	background: #f5f5f5;
+	border-color: #999;
 }
 
 .action-button button:disabled {
-  opacity: 0.6;
-  cursor: not-allowed;
+	opacity: 0.6;
+	cursor: not-allowed;
 }
 
 .action-button button .icon {
-  font-size: 3em;
+	font-size: 3em;
+	flex-grow: 1;
+	align-content: center;
 }
 
 .action-button button .title {
-  font-weight: 500;
+	font-weight: 500;
+
+	padding: 0.2em;
 }
 
 /* Animation classes */
 .action-button button.action-timeout {
-  background: #fff3cd;
-  border-color: #ffeaa7;
-  color: #856404;
+	background: #fff3cd;
+	border-color: #ffeaa7;
+	color: #856404;
 }
 
 .action-button button.action-blocked {
-  background: #f8d7da;
-  border-color: #f5c6cb;
-  color: #721c24;
+	background: #f8d7da !important;
+	border-color: #f5c6cb;
+	color: #721c24;
 }
 
 .action-button button.action-nonzero-exit {
-  background: #f8d7da;
-  border-color: #f5c6cb;
-  color: #721c24;
+	background: #f8d7da !important;
+	border-color: #f5c6cb;
+	color: #721c24;
 }
 
 .action-button button.action-success {
-  background: #d4edda;
-  border-color: #c3e6cb;
-  color: #155724;
+	background: #d4edda !important;
+	border-color: #c3e6cb;
+	color: #155724;
 }
 
 .action-button-footer {
-  margin-top: 0.5em;
+	margin-top: 0.5em;
 }
-</style>
+
+.navigate-on-start-container {
+	position: relative;
+	margin-left: auto;
+	height: 0;
+	right: 0;
+	top: 0;
+}
+
+</style>

+ 72 - 41
frontend/resources/vue/App.vue

@@ -1,29 +1,29 @@
 <template>
     <header>
-        <img src="../../OliveTinLogo.png" alt="OliveTin logo" class="logo" />
-
-        <h1 id="page-title">
-            <router-link to="/">OliveTin</router-link>
-        </h1>
-
-        <button id="sidebar-toggler-button" aria-label="Open sidebar navigation" aria-pressed="false"
-            aria-haspopup="menu" @click="toggleSidebar">&#9776;</button>
-
-
-        <div class="fg1" />
-
-        <div class="userinfo">
-            <span id="link-login" hidden><router-link to="/login">Login</router-link> |</span>
-            <span id="link-logout" hidden><a href="/api/Logout">Logout</a> |</span>
-            <span id="username">&nbsp;</span>
-            <svg xmlns="http://www.w3.org/2000/svg" width="1.5em" height="1.5em" viewBox="0 0 24 24">
-                <g fill="none" fill-rule="evenodd">
-                    <path
-                        d="m12.594 23.258l-.012.002l-.071.035l-.02.004l-.014-.004l-.071-.036q-.016-.004-.024.006l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.016-.018m.264-.113l-.014.002l-.184.093l-.01.01l-.003.011l.018.43l.005.012l.008.008l.201.092q.019.005.029-.008l.004-.014l-.034-.614q-.005-.019-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.003-.011l.018-.43l-.003-.012l-.01-.01z" />
-                    <path fill="currentColor"
-                        d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10s10-4.477 10-10S17.523 2 12 2M8.5 9.5a3.5 3.5 0 1 1 7 0a3.5 3.5 0 0 1-7 0m9.758 7.484A7.99 7.99 0 0 1 12 20a7.99 7.99 0 0 1-6.258-3.016C7.363 15.821 9.575 15 12 15s4.637.821 6.258 1.984" />
-                </g>
-            </svg>
+        <div id="sidebar-button" class="flex-row" @click="toggleSidebar">
+            <img src="../../OliveTinLogo.png" alt="OliveTin logo" class="logo" />
+
+            <h1 id="page-title">OliveTin</h1>
+
+            <div class="fg1" />
+            <button id="sidebar-toggler-button" aria-label="Open sidebar navigation" aria-pressed="false" aria-haspopup="menu" class="neutral">
+                <HugeiconsIcon :icon="Menu01Icon" width = "1em" height = "1em" />
+            </button>
+        </div>
+
+        <div class="fg1">
+            <Breadcrumbs />
+        </div>
+
+		<div id="banner" v-if="bannerMessage" :style="bannerCss">
+			<p>{{ bannerMessage }}</p>
+		</div>
+
+        <div class="flex-row" style="gap: .5em;">
+            <span id="link-login" v-if="!isLoggedIn"><router-link to="/login">Login</router-link></span>
+            <span id="link-logout" v-if="isLoggedIn"><a href="/api/Logout">Logout</a></span>
+            <span id="username-text" :title="'Provider: ' + userProvider">{{ username }}</span>
+            <HugeiconsIcon :icon="UserCircle02Icon" width = "1.5em" height = "1.5em" />
         </div>
     </header>
 
@@ -32,15 +32,15 @@
 
         <div id="content">
             <main title="Main content">
-                <router-view />
+                <router-view :key="$route.fullPath" />
             </main>
 
-            <ExecutionDialog ref="executionDialog" />
-
             <footer title="footer">
-                <p><img title="application icon" src="../../OliveTinLogo.png" alt="OliveTin logo" height="1em"
+                <p>
+                    <img title="application icon" src="../../OliveTinLogo.png" alt="OliveTin logo" height="1em"
                         class="logo" />
-                    OliveTin</p>
+                    OliveTin 3000!
+                </p>
                 <p>
                     <span>
                         <a href="https://docs.olivetin.app" target="_new">Documentation</a>
@@ -51,10 +51,9 @@
                             GitHub</a>
                     </span>
 
-                    <span id="currentVersion">?</span>
+                    <span>{{ currentVersion }}</span>
 
-                    <span id="serverConnectionRest">REST</span>
-                    <span id="serverConnectionWebSocket">WebSocket</span>
+                    <span>{{ serverConnection }}</span>
                 </p>
                 <p>
                     <a id="available-version" href="http://olivetin.app" target="_blank" hidden>?</a>
@@ -65,19 +64,51 @@
 </template>
 
 <script setup>
-import { ref } from 'vue';
+import { ref, onMounted } from 'vue';
 import Sidebar from './components/Sidebar.vue';
-import ExecutionDialog from './ExecutionDialog.vue';
+import { HugeiconsIcon } from '@hugeicons/vue'
+import { Menu01Icon } from '@hugeicons/core-free-icons'
+import { UserCircle02Icon } from '@hugeicons/core-free-icons'
 
-import { provide } from 'vue';
 const sidebar = ref(null);
-const executionDialog = ref(null);
-
-provide('executionDialog', executionDialog.value);
+const username = ref('guest');
+const userProvider = ref('system');
+const isLoggedIn = ref(false);
+const serverConnection = ref('Connected');
+const currentVersion = ref('?');
+const bannerMessage = ref('');
+const bannerCss = ref('');
 
 function toggleSidebar() {
-    if (sidebar.value && typeof sidebar.value.isOpen !== 'undefined') {
-        sidebar.value.isOpen = !sidebar.value.isOpen;
+    sidebar.value.toggle()
+}
+
+async function requestInit() {
+    try {
+        const initResponse = await window.client.init({})
+
+        console.log("init response", initResponse)
+
+        username.value = initResponse.authenticatedUser
+        currentVersion.value = initResponse.currentVersion
+		bannerMessage.value = initResponse.bannerMessage || '';
+		bannerCss.value = initResponse.bannerCss || '';
+
+        for (const rootDashboard of initResponse.rootDashboards) {
+            sidebar.value.addNavigationLink({
+                id: rootDashboard,
+                title: rootDashboard,
+                path: `/dashboards/${rootDashboard}`,
+                icon: '📊'
+            })
+        }
+    } catch (error) {
+        console.error("Error initializing client", error)
     }
 }
-</script>
+
+onMounted(() => {
+    serverConnection.value = 'Connected';
+    requestInit()
+})
+</script>

+ 0 - 432
frontend/resources/vue/ArgumentForm.vue

@@ -1,432 +0,0 @@
-<template>
-  <dialog 
-    ref="dialog" 
-    title="Arguments" 
-    class="action-arguments"
-    @close="handleClose"
-  >
-    <form class="padded-content" @submit.prevent="handleSubmit">
-      <div class="wrapper">
-        <div class="action-header">
-          <span class="icon" v-html="icon"></span>
-          <h2>{{ title }}</h2>
-        </div>
-
-        <div class="arguments">
-          <div 
-            v-for="arg in arguments" 
-            :key="arg.name"
-            class="argument-group"
-          >
-            <label :for="arg.name">
-              {{ formatLabel(arg.title) }}
-            </label>
-            
-            <datalist 
-              v-if="arg.suggestions && Object.keys(arg.suggestions).length > 0"
-              :id="`${arg.name}-choices`"
-            >
-              <option 
-                v-for="(suggestion, key) in arg.suggestions" 
-                :key="key"
-                :value="key"
-              >
-                {{ suggestion }}
-              </option>
-            </datalist>
-            
-            <component 
-              :is="getInputComponent(arg)"
-              :id="arg.name"
-              :name="arg.name"
-              :value="getArgumentValue(arg)"
-              :list="arg.suggestions ? `${arg.name}-choices` : undefined"
-              :type="getInputType(arg)"
-              :rows="arg.type === 'raw_string_multiline' ? 5 : undefined"
-              :step="arg.type === 'datetime' ? 1 : undefined"
-              :pattern="getPattern(arg)"
-              :required="arg.required"
-              @input="handleInput(arg, $event)"
-              @change="handleChange(arg, $event)"
-            />
-            
-            <span 
-              v-if="arg.description"
-              class="argument-description"
-              v-html="arg.description"
-            ></span>
-          </div>
-        </div>
-
-        <div class="buttons">
-          <button 
-            name="start" 
-            type="submit"
-            :disabled="!isFormValid || (hasConfirmation && !confirmationChecked)"
-          >
-            Start
-          </button>
-          <button 
-            name="cancel" 
-            type="button"
-            @click="handleCancel"
-          >
-            Cancel
-          </button>
-        </div>
-      </div>
-    </form>
-  </dialog>
-</template>
-
-<script>
-export default {
-  name: 'ArgumentForm',
-  props: {
-    actionData: {
-      type: Object,
-      required: true
-    }
-  },
-  data() {
-    return {
-      title: '',
-      icon: '',
-      arguments: [],
-      argValues: {},
-      confirmationChecked: false,
-      hasConfirmation: false,
-      formErrors: {}
-    }
-  },
-  computed: {
-    isFormValid() {
-      return Object.keys(this.formErrors).length === 0
-    }
-  },
-  mounted() {
-    this.setup()
-  },
-  methods: {
-    setup() {
-      this.title = this.actionData.title
-      this.icon = this.actionData.icon
-      this.arguments = this.actionData.arguments || []
-      this.argValues = {}
-      this.formErrors = {}
-      this.confirmationChecked = false
-      this.hasConfirmation = false
-      
-      // Initialize values from query params or defaults
-      this.arguments.forEach(arg => {
-        const paramValue = this.getQueryParamValue(arg.name)
-        this.argValues[arg.name] = paramValue !== null ? paramValue : arg.defaultValue || ''
-        
-        if (arg.type === 'confirmation') {
-          this.hasConfirmation = true
-        }
-      })
-    },
-    
-    getQueryParamValue(paramName) {
-      const params = new URLSearchParams(window.location.search.substring(1))
-      return params.get(paramName)
-    },
-    
-    formatLabel(title) {
-      const lastChar = title.charAt(title.length - 1)
-      if (lastChar === '?' || lastChar === '.' || lastChar === ':') {
-        return title
-      }
-      return title + ':'
-    },
-    
-    getInputComponent(arg) {
-      if (arg.type === 'html') {
-        return 'div'
-      } else if (arg.type === 'raw_string_multiline') {
-        return 'textarea'
-      } else if (arg.choices && arg.choices.length > 0 && (arg.type === 'select' || arg.type === '')) {
-        return 'select'
-      } else {
-        return 'input'
-      }
-    },
-    
-    getInputType(arg) {
-      if (arg.type === 'html' || arg.type === 'raw_string_multiline' || arg.type === 'select') {
-        return undefined
-      }
-      return arg.type
-    },
-    
-    getPattern(arg) {
-      if (arg.type && arg.type.startsWith('regex:')) {
-        return arg.type.replace('regex:', '')
-      }
-      return undefined
-    },
-    
-    getArgumentValue(arg) {
-      if (arg.type === 'checkbox') {
-        return this.argValues[arg.name] === '1' || this.argValues[arg.name] === true
-      }
-      return this.argValues[arg.name] || ''
-    },
-    
-    handleInput(arg, event) {
-      const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value
-      this.argValues[arg.name] = value
-      this.updateUrlWithArg(arg.name, value)
-    },
-    
-    handleChange(arg, event) {
-      if (arg.type === 'confirmation') {
-        this.confirmationChecked = event.target.checked
-        return
-      }
-      
-      // Validate the input
-      this.validateArgument(arg, event.target.value)
-    },
-    
-    async validateArgument(arg, value) {
-      if (!arg.type || arg.type.startsWith('regex:')) {
-        return
-      }
-      
-      try {
-        const validateArgumentTypeArgs = {
-          value: value,
-          type: arg.type
-        }
-        
-        const validation = await window.validateArgumentType(validateArgumentTypeArgs)
-        
-        if (validation.valid) {
-          this.$delete(this.formErrors, arg.name)
-        } else {
-          this.$set(this.formErrors, arg.name, validation.description)
-        }
-      } catch (err) {
-        console.warn('Validation failed:', err)
-      }
-    },
-    
-    updateUrlWithArg(name, value) {
-      if (name && value !== undefined) {
-        const url = new URL(window.location.href)
-        
-        // Don't add passwords to URL
-        const arg = this.arguments.find(a => a.name === name)
-        if (arg && arg.type === 'password') {
-          return
-        }
-        
-        url.searchParams.set(name, value)
-        window.history.replaceState({}, '', url.toString())
-      }
-    },
-    
-    getArgumentValues() {
-      const ret = []
-      
-      for (const arg of this.arguments) {
-        let value = this.argValues[arg.name] || ''
-        
-        if (arg.type === 'checkbox') {
-          value = value ? '1' : '0'
-        }
-        
-        ret.push({
-          name: arg.name,
-          value: value
-        })
-      }
-      
-      return ret
-    },
-    
-    handleSubmit() {
-      // Validate all inputs
-      let isValid = true
-      
-      for (const arg of this.arguments) {
-        const value = this.argValues[arg.name]
-        if (arg.required && (!value || value === '')) {
-          this.$set(this.formErrors, arg.name, 'This field is required')
-          isValid = false
-        }
-      }
-      
-      if (!isValid) {
-        return
-      }
-      
-      const argvs = this.getArgumentValues()
-      this.$emit('submit', argvs)
-      this.close()
-    },
-    
-    handleCancel() {
-      this.clearBookmark()
-      this.$emit('cancel')
-      this.close()
-    },
-    
-    handleClose() {
-      this.$emit('close')
-    },
-    
-    clearBookmark() {
-      // Remove the action from the URL
-      window.history.replaceState({
-        path: window.location.pathname
-      }, '', window.location.pathname)
-    },
-    
-    show() {
-      this.$refs.dialog.showModal()
-    },
-    
-    close() {
-      this.$refs.dialog.close()
-    }
-  }
-}
-</script>
-
-<style scoped>
-.action-arguments {
-  border: none;
-  border-radius: 8px;
-  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
-  max-width: 500px;
-  width: 90vw;
-}
-
-.wrapper {
-  display: flex;
-  flex-direction: column;
-  gap: 1rem;
-}
-
-.action-header {
-  display: flex;
-  align-items: center;
-  gap: 0.5rem;
-  padding-bottom: 1rem;
-  border-bottom: 1px solid #eee;
-}
-
-.action-header .icon {
-  font-size: 1.5em;
-}
-
-.action-header h2 {
-  margin: 0;
-  font-size: 1.2em;
-}
-
-.arguments {
-  display: flex;
-  flex-direction: column;
-  gap: 1rem;
-}
-
-.argument-group {
-  display: flex;
-  flex-direction: column;
-  gap: 0.25rem;
-}
-
-.argument-group label {
-  font-weight: 500;
-  color: #333;
-}
-
-.argument-group input,
-.argument-group select,
-.argument-group textarea {
-  padding: 0.5rem;
-  border: 1px solid #ddd;
-  border-radius: 4px;
-  font-size: 1rem;
-  transition: border-color 0.2s ease;
-}
-
-.argument-group input:focus,
-.argument-group select:focus,
-.argument-group textarea:focus {
-  outline: none;
-  border-color: #007bff;
-  box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
-}
-
-.argument-group input:invalid,
-.argument-group select:invalid,
-.argument-group textarea:invalid {
-  border-color: #dc3545;
-}
-
-.argument-group textarea {
-  resize: vertical;
-  min-height: 100px;
-}
-
-.argument-description {
-  font-size: 0.875rem;
-  color: #666;
-  margin-top: 0.25rem;
-}
-
-.buttons {
-  display: flex;
-  gap: 0.5rem;
-  justify-content: flex-end;
-  padding-top: 1rem;
-  border-top: 1px solid #eee;
-}
-
-.buttons button {
-  padding: 0.5rem 1rem;
-  border: 1px solid #ddd;
-  border-radius: 4px;
-  background: #fff;
-  cursor: pointer;
-  font-size: 1rem;
-  transition: all 0.2s ease;
-}
-
-.buttons button:hover:not(:disabled) {
-  background: #f8f9fa;
-  border-color: #adb5bd;
-}
-
-.buttons button:disabled {
-  opacity: 0.6;
-  cursor: not-allowed;
-}
-
-.buttons button[name="start"] {
-  background: #007bff;
-  color: white;
-  border-color: #007bff;
-}
-
-.buttons button[name="start"]:hover:not(:disabled) {
-  background: #0056b3;
-  border-color: #0056b3;
-}
-
-/* Checkbox specific styling */
-.argument-group input[type="checkbox"] {
-  width: auto;
-  margin-right: 0.5rem;
-}
-
-.argument-group input[type="checkbox"] + label {
-  display: inline;
-  font-weight: normal;
-}
-</style> 

+ 66 - 14
frontend/resources/vue/Dashboard.vue

@@ -1,32 +1,84 @@
 <template>
-    <section class = "transparent">
-        <fieldset>
+    <div v-if="!dashboard" style = "text-align: center">
+        <p>Loading... {{ title }}</p>
+    </div>
+    <div v-else>
+        <section v-if="dashboard.contents.length == 0">
             <legend>{{ dashboard.title }}</legend>
+            <p>This dashboard is empty.</p>
+        </section>
 
-            <ActionButton :actionData = "action" v-for = "action in dashboard.contents" :key = "action.id" />
-        </fieldset>
-    </section>
+        <section class="transparent" v-else>
+            <div v-for="component in dashboard.contents" :key="component.title">
+                <div v-if="component.type == 'fieldset'">
+                    <fieldset>
+                        <legend v-if = "dashboard.title != 'Default'">{{ component.title }}</legend>
+
+                        <template v-for="subcomponent in component.contents">
+                            <div v-if="subcomponent.type == 'display'" class="display">
+                                <div v-html="subcomponent.title" />
+                            </div>
+
+                            <ActionButton v-else-if="subcomponent.type == 'link'" :actionData="subcomponent.action"
+                                :key="subcomponent.title" />
+
+                            <div v-else-if="subcomponent.type == 'directory'">
+                                <router-link :to="{ name: 'Dashboard', params: { title: subcomponent.title } }"
+                                    class="dashboard-link">
+                                    <button>
+                                        {{ subcomponent.title }}
+                                    </button>
+                                </router-link>
+                            </div>
+
+                            <div v-else>
+                                OTHER: {{ subcomponent.type }}
+                                {{ subcomponent }}
+                            </div>
+                        </template>
+                    </fieldset>
+                </div>
+
+                <ActionButton v-else :actionData="action" v-for="action in component.contents" :key="action.title" />
+            </div>
+        </section>
+    </div>
 </template>
 
 <script setup>
 import ActionButton from './ActionButton.vue'
+import { onMounted, ref } from 'vue'
 
-defineProps({
-    dashboard: {
-        type: Object,
+const props = defineProps({
+    title: {
+        type: String,
         required: true
     }
 })
 
+const dashboard = ref(null)
+
+async function getDashboard() {
+    console.log("getting dashboard", props.title)
+    const ret = await window.client.getDashboard({
+        title: props.title,
+    })
+
+    dashboard.value = ret.dashboard
+}
+
+onMounted(() => {
+    getDashboard()
+})
 
 </script>
 
 <style>
 fieldset {
-	display: grid;
-	grid-template-columns: repeat(auto-fit, 180px);
-	grid-auto-rows: 1fr;
-	justify-content: center;
-	place-items: stretch;
-}   
+    display: grid;
+    grid-template-columns: repeat(auto-fit, 180px);
+    grid-auto-rows: 1fr;
+    justify-content: center;
+    place-items: stretch;
+}
 </style>

+ 0 - 421
frontend/resources/vue/ExecutionDialog.vue

@@ -1,421 +0,0 @@
-<template>
-  <dialog 
-    ref="dialog" 
-    title="Execution Results" 
-    :class="{ big: isBig }"
-    @close="handleClose"
-  >
-    <div class="action-header padded-content">
-      <span class="icon" role="img" v-html="icon"></span>
-
-      <h2>
-        <span :title="titleTooltip">{{ title }}</span>
-      </h2>
-
-      <button 
-        v-show="!hideToggleButton"
-        @click="toggleSize" 
-        title="Toggle dialog size"
-      >
-        <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
-          <path fill="currentColor" d="M3 3h6v2H6.462l4.843 4.843l-1.415 1.414L5 6.367V9H3zm0 18h6v-2H6.376l4.929-4.928l-1.415-1.414L5 17.548V15H3zm12 0h6v-6h-2v2.524l-4.867-4.866l-1.414 1.414L17.647 19H15zm6-18h-6v2h2.562l-4.843 4.843l1.414 1.414L19 6.39V9h2z"/>
-        </svg>
-      </button>
-    </div>
-    
-    <div v-show="!hideBasics" class="padded-content-sides">
-      <strong>Duration: </strong><span v-html="duration"></span>
-    </div>
-    
-    <div v-show="!hideDetails && logEntry" class="padded-content-sides">
-      <p>
-        <strong>Status: </strong>
-        <ActionStatusDisplay :log-entry="logEntry" v-if="logEntry"/>
-      </p>
-    </div>
-
-    <div ref="xtermOutput" v-show="!isHtmlOutput"></div>
-    <div 
-      v-show="isHtmlOutput" 
-      class="padded-content"
-      v-html="htmlOutput"
-    ></div>
-
-    <div class="buttons padded-content">
-      <button 
-        :disabled="!canRerun" 
-        @click="rerunAction" 
-        title="Rerun"
-      >
-        Rerun
-      </button>
-      <button 
-        :disabled="!canKill" 
-        @click="killAction" 
-        title="Kill"
-      >
-        Kill
-      </button>
-
-      <form method="dialog">
-        <button name="Cancel" title="Close">Close</button>
-      </form>
-    </div>
-  </dialog>
-</template>
-
-<script setup>
-import { ref, reactive, onMounted, onBeforeUnmount, nextTick } from 'vue'
-import ActionStatusDisplay from './components/ActionStatusDisplay.vue'
-import { OutputTerminal } from '../../js/OutputTerminal.js'
-
-// Refs for DOM elements
-const xtermOutput = ref(null)
-const dialog = ref(null)
-
-// State
-const state = reactive({
-  isBig: false,
-  hideToggleButton: false,
-  hideBasics: false,
-  hideDetails: false,
-  hideDetailsOnResult: false,
-
-  // Execution data
-  executionSeconds: 0,
-  executionTrackingId: 'notset',
-  executionTicker: null,
-
-  // Display data
-  icon: '',
-  title: 'Waiting for result...',
-  titleTooltip: '',
-  duration: '',
-  htmlOutput: '',
-  isHtmlOutput: false,
-
-  // Action data
-  logEntry: null,
-  canRerun: false,
-  canKill: false,
-
-  // Terminal
-  terminal: null
-})
-
-// Expose for template
-const isBig = ref(false)
-const hideToggleButton = ref(false)
-const hideBasics = ref(false)
-const hideDetails = ref(false)
-const hideDetailsOnResult = ref(false)
-const executionSeconds = ref(0)
-const executionTrackingId = ref('notset')
-const icon = ref('')
-const title = ref('Waiting for result...')
-const titleTooltip = ref('')
-const duration = ref('')
-const htmlOutput = ref('')
-const isHtmlOutput = ref(false)
-const logEntry = ref(null)
-const canRerun = ref(false)
-const canKill = ref(false)
-
-let executionTicker = null
-let terminal = null
-
-function syncStateToRefs() {
-  isBig.value = state.isBig
-  hideToggleButton.value = state.hideToggleButton
-  hideBasics.value = state.hideBasics
-  hideDetails.value = state.hideDetails
-  hideDetailsOnResult.value = state.hideDetailsOnResult
-  executionSeconds.value = state.executionSeconds
-  executionTrackingId.value = state.executionTrackingId
-  icon.value = state.icon
-  title.value = state.title
-  titleTooltip.value = state.titleTooltip
-  duration.value = state.duration
-  htmlOutput.value = state.htmlOutput
-  isHtmlOutput.value = state.isHtmlOutput
-  logEntry.value = state.logEntry
-  canRerun.value = state.canRerun
-  canKill.value = state.canKill
-}
-
-function syncRefsToState() {
-  state.isBig = isBig.value
-  state.hideToggleButton = hideToggleButton.value
-  state.hideBasics = hideBasics.value
-  state.hideDetails = hideDetails.value
-  state.hideDetailsOnResult = hideDetailsOnResult.value
-  state.executionSeconds = executionSeconds.value
-  state.executionTrackingId = executionTrackingId.value
-  state.icon = icon.value
-  state.title = title.value
-  state.titleTooltip = titleTooltip.value
-  state.duration = duration.value
-  state.htmlOutput = htmlOutput.value
-  state.isHtmlOutput = isHtmlOutput.value
-  state.logEntry = logEntry.value
-  state.canRerun = canRerun.value
-  state.canKill = canKill.value
-}
-
-function initializeTerminal() {
-  terminal = new OutputTerminal()
-  terminal.open(xtermOutput.value)
-  terminal.resize(80, 24)
-  window.terminal = terminal
-  state.terminal = terminal
-}
-
-function toggleSize() {
-  state.isBig = !state.isBig
-  isBig.value = state.isBig
-  if (state.isBig) {
-    terminal.fit()
-  } else {
-    terminal.resize(80, 24)
-  }
-}
-
-async function reset() {
-  state.executionSeconds = 0
-  state.executionTrackingId = 'notset'
-  state.isBig = false
-  state.hideToggleButton = false
-  state.hideBasics = false
-  state.hideDetails = false
-  state.hideDetailsOnResult = false
-
-  state.icon = ''
-  state.title = 'Waiting for result...'
-  state.titleTooltip = ''
-  state.duration = ''
-  state.htmlOutput = ''
-  state.isHtmlOutput = false
-
-  state.canRerun = false
-  state.canKill = false
-  state.logEntry = null
-
-  syncStateToRefs()
-
-  if (terminal) {
-    await terminal.reset()
-    terminal.fit()
-  }
-}
-
-function show(actionButton) {
-  if (actionButton) {
-    state.icon = actionButton.domIcon.innerText
-    icon.value = state.icon
-  }
-
-  state.canKill = true
-  canKill.value = true
-
-  // Clear existing ticker
-  if (executionTicker) {
-    clearInterval(executionTicker)
-  }
-
-  state.executionSeconds = 0
-  executionSeconds.value = 0
-  executionTick()
-  executionTicker = setInterval(() => {
-    executionTick()
-  }, 1000)
-  state.executionTicker = executionTicker
-
-  // Close if already open
-  if (dialog.value && dialog.value.open) {
-    dialog.value.close()
-  }
-
-  dialog.value && dialog.value.showModal()
-}
-
-function rerunAction() {
-  if (state.logEntry && state.logEntry.actionId) {
-    const actionButton = document.getElementById('actionButton-' + state.logEntry.actionId)
-    if (actionButton && actionButton.btn) {
-      actionButton.btn.click()
-    }
-  }
-  dialog.value && dialog.value.close()
-}
-
-async function killAction() {
-  if (!state.executionTrackingId || state.executionTrackingId === 'notset') {
-    return
-  }
-
-  const killActionArgs = {
-    executionTrackingId: state.executionTrackingId
-  }
-
-  try {
-    await window.client.killAction(killActionArgs)
-  } catch (err) {
-    console.error('Failed to kill action:', err)
-  }
-}
-
-function executionTick() {
-  state.executionSeconds++
-  executionSeconds.value = state.executionSeconds
-  updateDuration(null)
-}
-
-function hideEverythingApartFromOutput() {
-  state.hideDetailsOnResult = true
-  state.hideBasics = true
-  hideDetailsOnResult.value = true
-  hideBasics.value = true
-}
-
-async function fetchExecutionResult(executionTrackingIdParam) {
-  state.executionTrackingId = executionTrackingIdParam
-  executionTrackingId.value = executionTrackingIdParam
-
-  const executionStatusArgs = {
-    executionTrackingId: state.executionTrackingId
-  }
-
-  try {
-    const logEntryResult = await window.client.executionStatus(executionStatusArgs)
-    await renderExecutionResult(logEntryResult)
-  } catch (err) {
-    renderError(err)
-    throw err
-  }
-}
-
-function updateDuration(logEntryParam) {
-  let logEntry = logEntryParam
-  if (logEntry == null) {
-    duration.value = state.executionSeconds + ' seconds'
-    state.duration = duration.value
-  } else if (!logEntry.executionStarted) {
-    duration.value = logEntry.datetimeStarted + ' (request time). Not executed.'
-    state.duration = duration.value
-  } else if (logEntry.executionStarted && !logEntry.executionFinished) {
-    duration.value = logEntry.datetimeStarted
-    state.duration = duration.value
-  } else {
-    let delta = ''
-    try {
-      delta = (new Date(logEntry.datetimeStarted) - new Date(logEntry.datetimeStarted)) / 1000
-      delta = new Intl.RelativeTimeFormat().format(delta, 'seconds').replace('in ', '').replace('ago', '')
-    } catch (e) {
-      console.warn('Failed to calculate delta', e)
-    }
-    duration.value = logEntry.datetimeStarted + ' &rarr; ' + logEntry.datetimeFinished
-    if (delta !== '') {
-      duration.value += ' (' + delta + ')'
-    }
-    state.duration = duration.value
-  }
-}
-
-async function renderExecutionResult(res) {
-  // Clear ticker
-  if (executionTicker) {
-    clearInterval(executionTicker)
-  }
-  state.executionTicker = null
-
-  if (res.type === 'execution-dialog-output-html') {
-    state.isHtmlOutput = true
-    state.htmlOutput = res.logEntry.output
-    state.hideDetailsOnResult = true
-    isHtmlOutput.value = true
-    htmlOutput.value = res.logEntry.output
-    hideDetailsOnResult.value = true
-  } else {
-    state.isHtmlOutput = false
-    state.htmlOutput = ''
-    isHtmlOutput.value = false
-    htmlOutput.value = ''
-  }
-
-  if (state.hideDetailsOnResult) {
-    state.hideDetails = true
-    hideDetails.value = true
-  }
-
-  state.executionTrackingId = res.logEntry.executionTrackingId
-  executionTrackingId.value = res.logEntry.executionTrackingId
-  state.canRerun = res.logEntry.executionFinished
-  canRerun.value = res.logEntry.executionFinished
-  state.canKill = res.logEntry.canKill
-  canKill.value = res.logEntry.canKill
-  state.logEntry = res.logEntry
-  logEntry.value = res.logEntry
-
-  state.icon = res.logEntry.actionIcon
-  icon.value = res.logEntry.actionIcon
-  state.title = res.logEntry.actionTitle
-  title.value = res.logEntry.actionTitle
-  state.titleTooltip = 'Action ID: ' + res.logEntry.actionId + '\nExecution ID: ' + res.logEntry.executionTrackingId
-  titleTooltip.value = state.titleTooltip
-
-  updateDuration(res.logEntry)
-
-  if (terminal) {
-    await terminal.reset()
-    await terminal.write(res.logEntry.output, () => {
-      terminal.fit()
-    })
-  }
-}
-
-function renderError(err) {
-  window.showBigError('execution-dlg-err', 'in the execution dialog', 'Failed to fetch execution result. ' + err, false)
-}
-
-function handleClose() {
-  // Clean up when dialog is closed
-  if (executionTicker) {
-    clearInterval(executionTicker)
-  }
-  state.executionTicker = null
-}
-
-function cleanup() {
-  if (executionTicker) {
-    clearInterval(executionTicker)
-  }
-  state.executionTicker = null
-  if (terminal) {
-    terminal.close()
-  }
-  state.terminal = null
-}
-
-onMounted(() => {
-  nextTick(() => {
-    initializeTerminal()
-  })
-})
-
-onBeforeUnmount(() => {
-  cleanup()
-})
-
-// Expose methods for parent/imperative use
-defineExpose({
-  reset,
-  show,
-  rerunAction,
-  killAction,
-  fetchExecutionResult,
-  renderExecutionResult,
-  hideEverythingApartFromOutput,
-  handleClose
-})
-
-</script>

+ 0 - 72
frontend/resources/vue/LoginForm.vue

@@ -1,72 +0,0 @@
-<script setup>
-  setup () {
-    const tpl = document.getElementById('tplLoginForm')
-    this.content = tpl.content.cloneNode(true)
-
-    this.appendChild(this.content)
-
-    this.querySelector('#local-user-login').addEventListener('submit', (e) => {
-      e.preventDefault()
-      this.localLoginRequest()
-    })
-  }
-
-  async localLoginRequest () {
-    const username = this.querySelector('input.username').value
-    const password = this.querySelector('input.password').value
-
-    document.querySelector('.error').innerHTML = ''
-
-    const args = {
-      username: username,
-      password: password
-    }
-
-    const loginResult = await window.client.localUserLogin(args)
-
-    if (loginResult.success) {
-      window.location.href = '/'
-    } else {
-      document.querySelector('.error').innerHTML = 'Login failed.'
-    }
-  }
-
-  processOAuth2Providers (providers) {
-    if (providers === null) {
-      return
-    }
-
-    if (providers.length > 0) {
-      this.querySelector('.login-oauth2').hidden = false
-      this.querySelector('.login-disabled').hidden = true
-
-      for (const provider of providers) {
-        const providerForm = document.createElement('form')
-        providerForm.method = 'GET'
-        providerForm.action = '/oauth/login'
-
-        const hiddenField = document.createElement('input')
-        hiddenField.type = 'hidden'
-        hiddenField.name = 'provider'
-        hiddenField.value = provider.Name
-
-        providerForm.appendChild(hiddenField)
-
-        const providerButton = document.createElement('button')
-        providerButton.type = 'submit'
-        providerButton.innerHTML = '<span class = "oauth2-icon">' + provider.Icon + '</span> Login with ' + provider.Title
-
-        providerForm.appendChild(providerButton)
-
-        this.querySelector('.login-oauth2').appendChild(providerForm)
-      }
-    }
-  }
-
-  processLocalLogin (enabled) {
-    if (enabled) {
-      this.querySelector('.login-local').hidden = false
-      this.querySelector('.login-disabled').hidden = true
-    }
-  }
-</script>

+ 0 - 111
frontend/resources/vue/LogsList.vue

@@ -1,111 +0,0 @@
-<template>
-    <section title="Logs" class="">
-        <div class="toolbar">
-            <label class="input-with-icons">
-                <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
-                    <path fill="currentColor"
-                        d="m19.6 21l-6.3-6.3q-.75.6-1.725.95T9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l6.3 6.3zM9.5 14q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14" />
-                </svg>
-                <input placeholder="Search for action name" id="logSearchBox" />
-                <button id="searchLogsClear" title="Clear search filter" disabled>
-                    <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
-                        <path fill="currentColor"
-                            d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z" />
-                    </svg>
-                </button>
-            </label>
-        </div>
-        <table id="logsTable" title="Logs" hidden>
-            <thead>
-                <tr title="untitled">
-                    <th>Timestamp</th>
-                    <th>Action</th>
-                    <th>Metadata</th>
-                    <th>Status</th>
-                </tr>
-            </thead>
-            <tbody>
-                <tr v-for="logEntry in logEntries" :key="logEntry.executionTrackingId"></tr>
-                    <td>{{ logEntry.datetimeStarted }}</td>
-                    <td>{{ logEntry.actionTitle }}</td>
-                    <td>{{ logEntry.actionIcon }}</td>
-                    <td>{{ logEntry.tags }}</td>
-                    <td>{{ logEntry.user }}</td>
-                </tr>
-            </tbody>
-        </table>
-
-        <p id="logsTableEmpty">There are no logs to display. <a href="/">Return to index</a></p>
-
-        <p><strong>Note:</strong> The server is configured to only send <strong id="logs-server-page-size">?</strong>
-            log entries at a time. The search box at the top of this page only searches this current page of logs.</p>
-    </section>
-</template>
-
-<script setup>
-import { onMounted } from 'vue'
-
-function setupLogSearchBox () {
-  document.getElementById('logSearchBox').oninput = searchLogs
-  document.getElementById('searchLogsClear').onclick = searchLogsClear
-}
-
-function marshalLogsJsonToHtml (json) {
-  // This function is called internally with a "fake" server response, that does
-  // not have pageSize set. So we need to check if it's set before trying to use it.
-  if (json.pageSize !== undefined) {
-    document.getElementById('logs-server-page-size').innerText = json.pageSize
-  }
-
-  if (json.logs != null && json.logs.length > 0) {
-    document.getElementById('logsTable').hidden = false
-    document.getElementById('logsTableEmpty').hidden = true
-  } else {
-    return
-  }
-
-  for (const logEntry of json.logs) {
-    let row = document.getElementById('log-' + logEntry.executionTrackingId)
-
-    if (row == null) {
-      const tpl = document.getElementById('tplLogRow')
-      row = tpl.content.querySelector('tr').cloneNode(true)
-      row.id = 'log-' + logEntry.executionTrackingId
-
-      row.querySelector('.content').onclick = () => {
-        window.executionDialog.reset()
-        window.executionDialog.show()
-        window.executionDialog.renderExecutionResult({
-          logEntry: window.logEntries.get(logEntry.executionTrackingId)
-        })
-        pushNewNavigationPath('/logs/' + logEntry.executionTrackingId)
-      }
-
-      logEntry.dom = row
-
-      window.logEntries.set(logEntry.executionTrackingId, logEntry)
-
-      document.querySelector('#logTableBody').prepend(row)
-    }
-
-    row.querySelector('.timestamp').innerText = logEntry.datetimeStarted
-    row.querySelector('.content').innerText = logEntry.actionTitle
-    row.querySelector('.icon').innerHTML = logEntry.actionIcon
-    row.setAttribute('title', logEntry.actionTitle)
-
-    row.exitCodeDisplay.update(logEntry)
-
-    row.querySelector('.tags').innerHTML = ''
-
-    for (const tag of logEntry.tags) {
-      row.querySelector('.tags').append(createTag(tag))
-    }
-
-    row.querySelector('.tags').append(createAnnotation('user', logEntry.user))
-  }
-}
-
-onMounted(() => {
-    setupLogSearchBox()
-})
-</script>

+ 58 - 0
frontend/resources/vue/components/Breadcrumbs.vue

@@ -0,0 +1,58 @@
+<template>
+    <div id = "breadcrumbs">
+        <template v-for="(link, index) in links" :key="link.name">
+            <router-link :to="link.href">{{ link.name }}</router-link>
+            <span v-if="index < links.length - 1" class="separator">
+                &raquo;
+            </span>
+        </template>
+    </div>
+</template>
+
+<style scoped>
+span {
+    color: #bbb;
+}
+
+a {
+    text-decoration: none;
+    padding: 0.4em;
+    border-radius: 0.2em;
+}
+
+a:hover {
+    text-decoration: underline;
+    background-color: #000;
+}
+
+</style>
+
+
+<script setup>
+    import { ref } from 'vue';
+    import { watch } from 'vue';
+    import { useRoute } from 'vue-router';
+
+    const route = useRoute();
+    const links = ref([]);
+
+    watch(() => route.matched, (matched) => {
+
+        links.value = [];
+        matched.forEach((record) => {
+            if (record.meta && record.meta.breadcrumb) {
+                record.meta.breadcrumb.forEach((item) => {
+                    links.value.push({
+                        name: item.name,
+                        href: item.href || record.path || '/'
+                    });
+                });
+            } else if (record.name) {
+                links.value.push({
+                    name: record.name,
+                    href: record.path || '/'
+                });
+            }
+        });
+    }, { immediate: true });
+</script>

+ 284 - 0
frontend/resources/vue/components/Pagination.vue

@@ -0,0 +1,284 @@
+<template>
+  <div class="pagination">
+    <div class="pagination-info">
+      <span class="pagination-text">
+        Showing {{ startItem + 1 }}-{{ endItem }} of {{ total }} {{ itemTitle }}
+      </span>
+    </div>
+    
+    <div class="pagination-controls">
+      <button 
+        class="pagination-btn"
+        :disabled="currentPage === 1"
+        @click="goToPage(currentPage - 1)"
+        title="Previous page"
+      >
+        <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
+          <path fill="currentColor" d="M15.41 7.41L14 6l-6 6l6 6l1.41-1.41L10.83 12z"/>
+        </svg>
+      </button>
+
+      
+      <div class="pagination-pages">
+        <!-- First page -->
+        <button 
+          v-if="showFirstPage"
+          class="pagination-btn"
+          :class="{ active: currentPage === 1 }"
+          @click="goToPage(1)"
+        >
+          1
+        </button>
+        
+        <!-- Ellipsis after first page -->
+        <span v-if="showFirstEllipsis" class="pagination-ellipsis">...</span>
+        
+        <!-- Page numbers around current page -->
+        <button 
+          v-for="page in visiblePages" 
+          :key="page"
+          class="pagination-btn"
+          :class="{ active: currentPage === page }"
+          @click="goToPage(page)"
+        >
+          {{ page }}
+        </button>
+        
+        <!-- Ellipsis before last page -->
+        <span v-if="showLastEllipsis" class="pagination-ellipsis">...</span>
+        
+        <!-- Last page -->
+        <button 
+          v-if="showLastPage"
+          class="pagination-btn"
+          :class="{ active: currentPage === totalPages }"
+          @click="goToPage(totalPages)"
+        >
+          {{ totalPages }}
+        </button>
+      </div>
+      
+      <button 
+        class="pagination-btn"
+        :disabled="currentPage === totalPages"
+        @click="goToPage(currentPage + 1)"
+        title="Next page"
+      >
+        <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
+          <path fill="currentColor" d="M8.59 16.59L10 18l6-6l-6-6L8.59 7.41L13.17 12z"/>
+        </svg>
+      </button>
+    </div>
+    
+    <div class="pagination-size" v-if="canChangePageSize">
+      <label for="page-size">Items per page:</label>
+      <select 
+        id="page-size" 
+        v-model="localPageSize" 
+        @change="handlePageSizeChange"
+        class="page-size-select"
+      >
+        <option value="10">10</option>
+        <option value="25">25</option>
+        <option value="50">50</option>
+        <option value="100">100</option>
+      </select>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, watch } from 'vue'
+
+const props = defineProps({
+  pageSize: {
+    type: Number,
+    default: 25
+  },
+  total: {
+    type: Number,
+    required: true
+  },
+  currentPage: {
+    type: Number,
+    default: 1
+  },
+  canChangePageSize: {
+    type: Boolean,
+    default: false
+  },
+  itemTitle: {
+    type: String,
+    default: 'items'
+  }
+})
+
+const emit = defineEmits(['page-change', 'page-size-change'])
+
+const localPageSize = ref(props.pageSize)
+const localCurrentPage = ref(props.currentPage)
+
+// Computed properties
+const totalPages = computed(() => Math.ceil(props.total / localPageSize.value))
+
+const startItem = computed(() => (localCurrentPage.value - 1) * localPageSize.value)
+const endItem = computed(() => Math.min(localCurrentPage.value * localPageSize.value, props.total))
+
+// Pagination logic
+const maxVisiblePages = 5
+const visiblePages = computed(() => {
+  const pages = []
+  const halfVisible = Math.floor(maxVisiblePages / 2)
+  
+  let start = Math.max(1, localCurrentPage.value - halfVisible)
+  let end = Math.min(totalPages.value, start + maxVisiblePages - 1)
+  
+  // Adjust start if we're near the end
+  if (end - start < maxVisiblePages - 1) {
+    start = Math.max(1, end - maxVisiblePages + 1)
+  }
+  
+  for (let i = start; i <= end; i++) {
+    pages.push(i)
+  }
+  
+  return pages
+})
+
+const showFirstPage = computed(() => visiblePages.value[0] > 1)
+const showLastPage = computed(() => visiblePages.value[visiblePages.value.length - 1] < totalPages.value)
+const showFirstEllipsis = computed(() => visiblePages.value[0] > 2)
+const showLastEllipsis = computed(() => visiblePages.value[visiblePages.value.length - 1] < totalPages.value - 1)
+
+// Methods
+function goToPage(page) {
+  if (page >= 1 && page <= totalPages.value && page !== localCurrentPage.value) {
+    localCurrentPage.value = page
+    emit('page-change', page)
+  }
+}
+
+function handlePageSizeChange() {
+  // Reset to first page when changing page size
+  localCurrentPage.value = 1
+  emit('page-size-change', localPageSize.value)
+  emit('page-change', 1)
+}
+
+// Watch for prop changes
+watch(() => props.currentPage, (newPage) => {
+  localCurrentPage.value = newPage
+})
+
+watch(() => props.pageSize, (newSize) => {
+  localPageSize.value = newSize
+})
+</script>
+
+<style scoped>
+.pagination {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 1rem;
+}
+
+.pagination-info {
+  flex: 1;
+}
+
+.pagination-text {
+  font-size: 0.875rem;
+  color: #6c757d;
+}
+
+.pagination-controls {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+}
+
+.pagination-pages {
+  display: flex;
+  align-items: center;
+  gap: 0.25rem;
+}
+
+.pagination-btn {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  min-width: 2.5rem;
+  height: 2.5rem;
+  padding: 0.5rem;
+  border: 1px solid #dee2e6;
+  background: #fff;
+  color: #495057;
+  text-decoration: none;
+  border-radius: 4px;
+  cursor: pointer;
+  transition: all 0.2s ease;
+  font-size: 0.875rem;
+}
+
+.pagination-btn:hover:not(:disabled) {
+  background: #e9ecef;
+  border-color: #adb5bd;
+  color: #495057;
+}
+
+.pagination-btn.active {
+  background: #c6d0d7;
+  color: #333;
+}
+
+.pagination-btn:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
+.pagination-ellipsis {
+  padding: 0.5rem;
+  color: #6c757d;
+  font-size: 0.875rem;
+}
+
+.pagination-size {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  font-size: 0.875rem;
+  color: #6c757d;
+}
+
+.page-size-select {
+  padding: 0.25rem 0.5rem;
+  border: 1px solid #dee2e6;
+  border-radius: 4px;
+  background: #fff;
+  font-size: 0.875rem;
+}
+
+.page-size-select:focus {
+  outline: none;
+  border-color: #5681af;
+  box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
+}
+
+/* Responsive design */
+@media (max-width: 768px) {
+  .pagination {
+    flex-direction: column;
+    gap: 1rem;
+    align-items: stretch;
+  }
+  
+  .pagination-controls {
+    justify-content: center;
+  }
+  
+  .pagination-size {
+    justify-content: center;
+  }
+}
+</style> 

+ 51 - 59
frontend/resources/vue/components/Sidebar.vue

@@ -1,30 +1,39 @@
 <template>
   <aside :class="{ 'shown': isOpen, 'stuck': isStuck }" class="sidebar">
-    <button
-      class="stick-toggle"
-      :aria-pressed="isStuck"
-      :title="isStuck ? 'Unstick sidebar' : 'Stick sidebar'"
-      @click="toggleStick"
-    >
-      <span v-if="isStuck">📌 Unstick</span>
-      <span v-else>📍 Stick</span>
-    </button>
+    <div class = "flex-row">
+      <h2>Navigation</h2>
+      <div class = "fg1" />
+      <button
+        class="stick-toggle"
+        :aria-pressed="isStuck"
+        :title="isStuck ? 'Unstick sidebar' : 'Stick sidebar'"
+        @click="toggleStick"
+      >
+        <span v-if="isStuck">
+          <HugeiconsIcon :icon="Pin02Icon" width = "1em" height = "1em" />
+        </span>
+        <span v-else>
+          <HugeiconsIcon :icon="PinIcon" width = "1em" height = "1em" />
+        </span>
+      </button>
+    </div>
+
     <nav class="mainnav">
       <ul class="navigation-links">
         <li v-for="link in navigationLinks" :key="link.id" :title="link.title">
           <router-link :to="link.path" :class="{ active: isActive(link.path) }">
-            <span v-if="link.icon" class="icon" v-html="link.icon"></span>
-            <span class="title">{{ link.title }}</span>
+            <HugeiconsIcon :icon="link.icon" />
+            <span>{{ link.title }}</span>
           </router-link>
         </li>
       </ul>
 
       <ul class="supplemental-links">
         <li v-for="link in supplementalLinks" :key="link.id" :title="link.title">
-          <a :href="link.url" :target="link.target || '_self'">
-            <span v-if="link.icon" class="icon" v-html="link.icon"></span>
-            <span class="title">{{ link.title }}</span>
-          </a>
+          <router-link :to="link.path" :class="{ active: isActive(link.path) }">
+            <HugeiconsIcon :icon="link.icon" />
+            <span>{{ link.title }}</span>
+          </router-link>
         </li>
       </ul>
     </nav>
@@ -34,6 +43,13 @@
 <script setup>
 import { ref, onMounted, getCurrentInstance } from 'vue'
 import { useRoute } from 'vue-router'
+import { HugeiconsIcon } from '@hugeicons/vue'
+import { DashboardSquare01Icon } from '@hugeicons/core-free-icons'
+import { LeftToRightListDashIcon } from '@hugeicons/core-free-icons'
+import { Wrench01Icon } from '@hugeicons/core-free-icons'
+import { Pin02Icon } from '@hugeicons/core-free-icons'
+import { PinIcon } from '@hugeicons/core-free-icons'
+import { CellsIcon } from '@hugeicons/core-free-icons'
 
 const isOpen = ref(false)
 const isStuck = ref(false)
@@ -42,25 +58,32 @@ const navigationLinks = ref([
     id: 'actions',
     title: 'Actions',
     path: '/',
-    icon: '⚡'
+    icon: DashboardSquare01Icon,
+  }
+])
+
+const supplementalLinks = ref([
+  {
+    id: 'entities',
+	title: 'Entities',
+	path: '/entities',
+	icon: CellsIcon,
   },
   {
     id: 'logs',
     title: 'Logs',
     path: '/logs',
-    icon: '📋'
+    icon: LeftToRightListDashIcon,
   },
   {
     id: 'diagnostics',
     title: 'Diagnostics',
     path: '/diagnostics',
-    icon: '🔧'
+    icon: Wrench01Icon,
   }
 ])
-const supplementalLinks = ref([])
 
 const route = useRoute()
-const instance = getCurrentInstance()
 
 function toggleStick() {
   isStuck.value = !isStuck.value
@@ -68,6 +91,7 @@ function toggleStick() {
 
 function toggle() {
   isOpen.value = !isOpen.value
+  isStuck.value = false
 }
 
 function open() {
@@ -76,6 +100,7 @@ function open() {
 
 function close() {
   isOpen.value = false
+  isStuck.value = false
 }
 
 function isActive(path) {
@@ -84,6 +109,8 @@ function isActive(path) {
 
 // Method to add navigation links from other components
 function addNavigationLink(link) {
+  link.icon = DashboardSquare01Icon
+
   const existingIndex = navigationLinks.value.findIndex(l => l.id === link.id)
   if (existingIndex >= 0) {
     navigationLinks.value[existingIndex] = { ...link }
@@ -168,23 +195,15 @@ defineExpose({
 </script>
 
 <style scoped>
-.mainnav {
-  padding: 1rem 0;
-}
 
-.navigation-links,
-.supplemental-links {
-  list-style: none;
-  margin: 0;
-  padding: 0;
+.active {
+  text-decoration: underline;
 }
 
-.navigation-links li,
-.supplemental-links li {
+li {
   margin: 0;
   padding: 0;
 }
-
 .navigation-links a,
 .supplemental-links a {
   display: flex;
@@ -192,7 +211,6 @@ defineExpose({
   gap: 0.75rem;
   padding: 0.75rem 1rem;
   color: #333;
-  text-decoration: none;
   transition: background-color 0.2s ease;
   border-left: 3px solid transparent;
 }
@@ -203,41 +221,15 @@ defineExpose({
   color: #007bff;
 }
 
-.navigation-links a.active {
-  background: #e3f2fd;
-  color: #007bff;
-  border-left-color: #007bff;
-}
-
-.navigation-links a.router-link-active {
-  background: #e3f2fd;
-  color: #007bff;
-  border-left-color: #007bff;
-}
-
 .icon {
   font-size: 1.2em;
   width: 1.5rem;
   text-align: center;
 }
 
-.title {
-  font-weight: 500;
-}
-
 .supplemental-links {
   border-top: 1px solid #eee;
   margin-top: 1rem;
-  padding-top: 1rem;
-}
-
-.supplemental-links a {
-  font-size: 0.9rem;
-  color: #666;
-}
-
-.supplemental-links a:hover {
-  color: #007bff;
 }
 
 /* Responsive design */
@@ -251,4 +243,4 @@ defineExpose({
     left: 0;
   }
 }
-</style> 
+</style> 

+ 0 - 179
frontend/resources/vue/components/SidebarExample.vue

@@ -1,179 +0,0 @@
-<template>
-  <div class="sidebar-example">
-    <h3>Sidebar Management Example</h3>
-    
-    <div class="controls">
-      <button @click="addCustomNavLink" class="btn btn-primary">
-        Add Custom Nav Link
-      </button>
-      
-      <button @click="addCustomSupplementalLink" class="btn btn-secondary">
-        Add Custom Supplemental Link
-      </button>
-      
-      <button @click="removeCustomLinks" class="btn btn-danger">
-        Remove Custom Links
-      </button>
-      
-      <button @click="toggleSidebar" class="btn btn-info">
-        Toggle Sidebar
-      </button>
-    </div>
-    
-    <div class="info">
-      <p><strong>Available Sidebar Methods:</strong></p>
-      <ul>
-        <li><code>window.sidebar.addNavigationLink(link)</code> - Add navigation link</li>
-        <li><code>window.sidebar.addSupplementalLink(link)</code> - Add supplemental link</li>
-        <li><code>window.sidebar.removeNavigationLink(id)</code> - Remove navigation link</li>
-        <li><code>window.sidebar.removeSupplementalLink(id)</code> - Remove supplemental link</li>
-        <li><code>window.sidebar.toggle()</code> - Toggle sidebar visibility</li>
-        <li><code>window.sidebar.open()</code> - Open sidebar</li>
-        <li><code>window.sidebar.close()</code> - Close sidebar</li>
-      </ul>
-    </div>
-  </div>
-</template>
-
-<script>
-export default {
-  name: 'SidebarExample',
-  data() {
-    return {
-      customLinkId: 1
-    }
-  },
-  methods: {
-    addCustomNavLink() {
-      if (window.sidebar) {
-        window.sidebar.addNavigationLink({
-          id: `custom-nav-${this.customLinkId}`,
-          title: `Custom Nav ${this.customLinkId}`,
-          path: `/custom-${this.customLinkId}`,
-          icon: '🔗'
-        })
-        this.customLinkId++
-      } else {
-        console.warn('Sidebar not available')
-      }
-    },
-    
-    addCustomSupplementalLink() {
-      if (window.sidebar) {
-        window.sidebar.addSupplementalLink({
-          id: `custom-supplemental-${this.customLinkId}`,
-          title: `Custom Link ${this.customLinkId}`,
-          url: `https://example.com/custom-${this.customLinkId}`,
-          target: '_blank',
-          icon: '🔗'
-        })
-        this.customLinkId++
-      } else {
-        console.warn('Sidebar not available')
-      }
-    },
-    
-    removeCustomLinks() {
-      if (window.sidebar) {
-        // Remove all custom links
-        for (let i = 1; i < this.customLinkId; i++) {
-          window.sidebar.removeNavigationLink(`custom-nav-${i}`)
-          window.sidebar.removeSupplementalLink(`custom-supplemental-${i}`)
-        }
-        this.customLinkId = 1
-      } else {
-        console.warn('Sidebar not available')
-      }
-    },
-    
-    toggleSidebar() {
-      if (window.sidebar) {
-        window.sidebar.toggle()
-      } else {
-        console.warn('Sidebar not available')
-      }
-    }
-  }
-}
-</script>
-
-<style scoped>
-.sidebar-example {
-  padding: 1rem;
-  background: #f8f9fa;
-  border-radius: 8px;
-  margin: 1rem 0;
-}
-
-.controls {
-  display: flex;
-  gap: 0.5rem;
-  margin-bottom: 1rem;
-  flex-wrap: wrap;
-}
-
-.btn {
-  padding: 0.5rem 1rem;
-  border: none;
-  border-radius: 4px;
-  cursor: pointer;
-  font-size: 0.875rem;
-  transition: all 0.2s ease;
-}
-
-.btn-primary {
-  background: #007bff;
-  color: white;
-}
-
-.btn-primary:hover {
-  background: #0056b3;
-}
-
-.btn-secondary {
-  background: #6c757d;
-  color: white;
-}
-
-.btn-secondary:hover {
-  background: #545b62;
-}
-
-.btn-danger {
-  background: #dc3545;
-  color: white;
-}
-
-.btn-danger:hover {
-  background: #c82333;
-}
-
-.btn-info {
-  background: #17a2b8;
-  color: white;
-}
-
-.btn-info:hover {
-  background: #138496;
-}
-
-.info {
-  background: white;
-  padding: 1rem;
-  border-radius: 4px;
-  border-left: 4px solid #007bff;
-}
-
-.info ul {
-  margin: 0.5rem 0;
-  padding-left: 1.5rem;
-}
-
-.info code {
-  background: #f1f3f4;
-  padding: 0.125rem 0.25rem;
-  border-radius: 3px;
-  font-family: monospace;
-  font-size: 0.875rem;
-}
-</style> 

+ 50 - 10
frontend/resources/vue/router.js

@@ -1,25 +1,65 @@
 import { createRouter, createWebHistory } from 'vue-router'
 
-// Import components
-import App from './App.vue'
-import ExecutionDialog from './ExecutionDialog.vue'
-import ActionButton from './ActionButton.vue'
-import ArgumentForm from './ArgumentForm.vue'
-
-// Define routes
 const routes = [
   {
     path: '/',
     name: 'Home',
-    component: () => import('./views/DashboardRoot.vue'),
+    component: () => import('./Dashboard.vue'),
+    props: { title: 'default' },
+    meta: { title: 'OliveTin - Dashboard' }
+  },
+  {
+    path: '/dashboards/:title',
+    name: 'Dashboard',
+    component: () => import('./Dashboard.vue'),
+    props: true,
     meta: { title: 'OliveTin - Dashboard' }
   },
+  {
+    path: '/actionBinding/:bindingId/argumentForm',
+    name: 'ActionBinding',
+    component: () => import('./views/ArgumentForm.vue'),
+    props: true,
+    meta: { title: 'OliveTin - Action Binding' }
+  },
   {
     path: '/logs',
     name: 'Logs',
-    component: () => import('./views/LogsView.vue'),
+    component: () => import('./views/LogsListView.vue'),
     meta: { title: 'OliveTin - Logs' }
   },
+  {
+    path: '/entities',
+    name: 'Entities',
+    component: () => import('./views/EntitiesView.vue'),
+    meta: { title: 'OliveTin - Entities' }
+  },
+  {
+    path: '/entity-details/:entityType/:entityKey',
+    name: 'EntityDetails',
+    component: () => import('./views/EntityDetailsView.vue'),
+    props: true,
+    meta: { 
+      title: 'OliveTin - Entity Details', 
+      breadcrumb: [
+        { name: "Entities", href: "/entities" },
+        { name: "Entity Details" }
+      ]
+    }
+  },
+  {
+    path: '/logs/:executionTrackingId',
+    name: 'Execution',
+    component: () => import('./views/ExecutionView.vue'),
+    props: true,
+    meta: { 
+      title: 'OliveTin - Execution', 
+      breadcrumb: [
+        { name: "Logs", href: "/logs" },
+        { name: "Execution" },
+      ]
+    }
+  },
   {
     path: '/diagnostics',
     name: 'Diagnostics',
@@ -73,4 +113,4 @@ router.beforeEach((to, from, next) => {
   }
 })
 
-export default router 
+export default router 

+ 3 - 0
frontend/resources/vue/stores/buttonResults.js

@@ -0,0 +1,3 @@
+import { reactive } from 'vue'
+
+export const buttonResults = reactive({})

+ 337 - 0
frontend/resources/vue/views/ArgumentForm.vue

@@ -0,0 +1,337 @@
+<template>
+  <section>
+    <div class="section-header">
+      <h2>Start action: {{ title }}</h2>
+    </div>
+    <div class="section-content">
+      <form @submit.prevent="handleSubmit">
+        <template v-if="actionArguments.length > 0">
+
+          <template v-for="arg in actionArguments" :key="arg.name" class="argument-group">
+            <label :for="arg.name">
+              {{ formatLabel(arg.title) }}
+            </label>
+
+            <datalist v-if="arg.suggestions && Object.keys(arg.suggestions).length > 0" :id="`${arg.name}-choices`">
+              <option v-for="(suggestion, key) in arg.suggestions" :key="key" :value="key">
+                {{ suggestion }}
+              </option>
+            </datalist>
+
+            <component :is="getInputComponent(arg)" :id="arg.name" :name="arg.name" :value="getArgumentValue(arg)"
+              :list="arg.suggestions ? `${arg.name}-choices` : undefined" :type="getInputType(arg)"
+              :rows="arg.type === 'raw_string_multiline' ? 5 : undefined"
+              :step="arg.type === 'datetime' ? 1 : undefined" :pattern="getPattern(arg)" :required="arg.required"
+              @input="handleInput(arg, $event)" @change="handleChange(arg, $event)" />
+
+            <span v-if="arg.description" class="argument-description" v-html="arg.description"></span>
+          </template>
+        </template>
+        <div v-else>
+          <p>No arguments required</p>
+        </div>
+
+        <div class="buttons">
+          <button name="start" type="submit" :disabled="!isFormValid || (hasConfirmation && !confirmationChecked)">
+            Start
+          </button>
+          <button name="cancel" type="button" @click="handleCancel">
+            Cancel
+          </button>
+        </div>
+      </form>
+    </div>
+  </section>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, nextTick } from 'vue'
+import { useRouter } from 'vue-router'
+
+const router = useRouter()
+const emit = defineEmits(['submit', 'cancel', 'close'])
+
+// Reactive data
+const dialog = ref(null)
+const title = ref('')
+const icon = ref('')
+//const arguments = ref([])
+const argValues = ref({})
+const confirmationChecked = ref(false)
+const hasConfirmation = ref(false)
+const formErrors = ref({})
+const actionArguments = ref([])
+
+// Computed properties
+const isFormValid = computed(() => Object.keys(formErrors.value).length === 0)
+
+const props = defineProps({
+  bindingId: {
+    type: String,
+    required: true
+  }
+})
+
+// Methods
+async function setup() {
+  const ret = await window.client.getActionBinding({
+    bindingId: props.bindingId
+  })
+
+  const action = ret.action
+  console.log('action', action)
+
+  title.value = action.title
+  icon.value = action.icon
+  actionArguments.value = action.arguments || []
+  argValues.value = {}
+  formErrors.value = {}
+  confirmationChecked.value = false
+  hasConfirmation.value = false
+
+  // Initialize values from query params or defaults
+  arguments.value.forEach(arg => {
+    const paramValue = getQueryParamValue(arg.name)
+    argValues.value[arg.name] = paramValue !== null ? paramValue : arg.defaultValue || ''
+
+    if (arg.type === 'confirmation') {
+      hasConfirmation.value = true
+    }
+  })
+}
+
+function getQueryParamValue(paramName) {
+  const params = new URLSearchParams(window.location.search.substring(1))
+  return params.get(paramName)
+}
+
+function formatLabel(title) {
+  const lastChar = title.charAt(title.length - 1)
+  if (lastChar === '?' || lastChar === '.' || lastChar === ':') {
+    return title
+  }
+  return title + ':'
+}
+
+function getInputComponent(arg) {
+  if (arg.type === 'html') {
+    return 'div'
+  } else if (arg.type === 'raw_string_multiline') {
+    return 'textarea'
+  } else if (arg.choices && arg.choices.length > 0 && (arg.type === 'select' || arg.type === '')) {
+    return 'select'
+  } else {
+    return 'input'
+  }
+}
+
+function getInputType(arg) {
+  if (arg.type === 'html' || arg.type === 'raw_string_multiline' || arg.type === 'select') {
+    return undefined
+  }
+
+  if (arg.type === 'ascii_identifier') {
+    return 'text'
+  }
+
+  return arg.type
+}
+
+function getPattern(arg) {
+  if (arg.type && arg.type.startsWith('regex:')) {
+    return arg.type.replace('regex:', '')
+  }
+  return undefined
+}
+
+function getArgumentValue(arg) {
+  if (arg.type === 'checkbox') {
+    return argValues.value[arg.name] === '1' || argValues.value[arg.name] === true
+  }
+  return argValues.value[arg.name] || ''
+}
+
+function handleInput(arg, event) {
+  const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value
+  argValues.value[arg.name] = value
+  updateUrlWithArg(arg.name, value)
+}
+
+function handleChange(arg, event) {
+  if (arg.type === 'confirmation') {
+    confirmationChecked.value = event.target.checked
+    return
+  }
+
+  // Validate the input
+  validateArgument(arg, event.target.value)
+}
+
+async function validateArgument(arg, value) {
+  if (!arg.type || arg.type.startsWith('regex:')) {
+    return
+  }
+
+  try {
+    const validateArgumentTypeArgs = {
+      value: value,
+      type: arg.type
+    }
+
+    const validation = await window.validateArgumentType(validateArgumentTypeArgs)
+
+    if (validation.valid) {
+      delete formErrors.value[arg.name]
+    } else {
+      formErrors.value[arg.name] = validation.description
+    }
+  } catch (err) {
+    console.warn('Validation failed:', err)
+  }
+}
+
+function updateUrlWithArg(name, value) {
+  if (name && value !== undefined) {
+    const url = new URL(window.location.href)
+
+    // Don't add passwords to URL
+    const arg = arguments.value.find(a => a.name === name)
+    if (arg && arg.type === 'password') {
+      return
+    }
+
+    url.searchParams.set(name, value)
+    window.history.replaceState({}, '', url.toString())
+  }
+}
+
+function getArgumentValues() {
+  const ret = []
+
+  for (const arg of arguments.value) {
+    let value = argValues.value[arg.name] || ''
+
+    if (arg.type === 'checkbox') {
+      value = value ? '1' : '0'
+    }
+
+    ret.push({
+      name: arg.name,
+      value: value
+    })
+  }
+
+  return ret
+}
+
+function handleSubmit() {
+  // Validate all inputs
+  let isValid = true
+
+  for (const arg of arguments.value) {
+    const value = argValues.value[arg.name]
+    if (arg.required && (!value || value === '')) {
+      formErrors.value[arg.name] = 'This field is required'
+      isValid = false
+    }
+  }
+
+  if (!isValid) {
+    return
+  }
+
+  const argvs = getArgumentValues()
+  emit('submit', argvs)
+  close()
+}
+
+function handleCancel() {
+  router.back()
+  clearBookmark()
+  emit('cancel')
+  close()
+}
+
+function handleClose() {
+  emit('close')
+}
+
+function clearBookmark() {
+  // Remove the action from the URL
+  window.history.replaceState({
+    path: window.location.pathname
+  }, '', window.location.pathname)
+}
+
+function show() {
+  if (dialog.value) {
+    dialog.value.showModal()
+  }
+}
+
+function close() {
+  if (dialog.value) {
+    dialog.value.close()
+  }
+}
+
+// Expose methods for parent components
+defineExpose({
+  show,
+  close
+})
+
+// Lifecycle
+onMounted(() => {
+  setup()
+})
+</script>
+
+<style scoped>
+
+form {
+  grid-template-columns: max-content auto auto;
+}
+
+.argument-group {
+  display: flex;
+  flex-direction: column;
+  gap: 0.25rem;
+}
+
+.argument-group label {
+  font-weight: 500;
+  color: #333;
+}
+
+.argument-group input:invalid,
+.argument-group select:invalid,
+.argument-group textarea:invalid {
+  border-color: #dc3545;
+}
+
+.argument-description {
+  font-size: 0.875rem;
+  color: #666;
+  margin-top: 0.25rem;
+}
+
+.buttons {
+  display: flex;
+  gap: 0.5rem;
+  justify-content: flex-end;
+  padding-top: 1rem;
+  border-top: 1px solid #eee;
+}
+
+/* Checkbox specific styling */
+.argument-group input[type="checkbox"] {
+  width: auto;
+  margin-right: 0.5rem;
+}
+
+.argument-group input[type="checkbox"]+label {
+  display: inline;
+  font-weight: normal;
+}
+</style>

+ 0 - 23
frontend/resources/vue/views/DashboardRoot.vue

@@ -1,23 +0,0 @@
-<template>
-    <div v-for="dashboard in dashboards" :key="dashboard.id">       
-        <Dashboard :dashboard="dashboard" />
-    </div>
-</template>
-
-<script setup>
-import { onMounted, ref } from 'vue'
-import Dashboard from '../Dashboard.vue'
-
-const dashboards = ref([])
-
-async function refreshActions() {
-    const ret = await window.client.getDashboardComponents();
-
-    console.log(ret.dashboards)
-    dashboards.value = ret.dashboards
-}
-
-onMounted(() => {
-    refreshActions()
-})
-</script>

+ 59 - 101
frontend/resources/vue/views/DiagnosticsView.vue

@@ -1,94 +1,51 @@
 <template>
-  <div class="diagnostics-view">
-    <div class="diagnostics-content">
-      <p class="note">
-        <strong>Note:</strong> Diagnostics are only generated on OliveTin startup - they are not updated in real-time or
-        when you refresh this page.
-        They are intended as a "quick reference" to help you.
-      </p>
-
-      <p class="note">
-        If you are having problems with OliveTin and want to raise a support request, please don't take a screenshot or
-        copy text from this page,
-        but instead it is highly recommended to include a
-        <a href="https://docs.olivetin.app/sosreport.html" target="_blank">sosreport</a>
-        which is more detailed, and makes it easier to help you.
+  <section>
+    <div class="section-header">
+      <h2>Get support</h2>
+    </div>
+    <div class="section-content">
+      <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>
+      <ul>
+        <li>
+          <a href="https://docs.olivetin.app/sosreport.html" target="_blank">sosreport Documentation</a>
+        </li>
+        <li>
+          <a href = "https://docs.olivetin.app/troubleshooting/wheretofindhelp.html" target="_blank">Where to find help</a>
+        </li>
+      </ul>
+    </div>
+  </section>
 
-      <div class="diagnostics-section">
-        <h3>SSH</h3>
-        <table class="diagnostics-table">
-          <tbody>
-            <tr>
-              <td width="10%">Found Key</td>
-              <td>{{ diagnostics.sshFoundKey || '?' }}</td>
-            </tr>
-            <tr>
-              <td>Found Config</td>
-              <td>{{ diagnostics.sshFoundConfig || '?' }}</td>
-            </tr>
-          </tbody>
-        </table>
-      </div>
-
-      <div v-if="diagnostics.system" class="diagnostics-section">
-        <h3>System</h3>
-        <table class="diagnostics-table">
-          <tbody>
-            <tr v-for="(value, key) in diagnostics.system" :key="key">
-              <td width="10%">{{ formatKey(key) }}</td>
-              <td>{{ value }}</td>
-            </tr>
-          </tbody>
-        </table>
-      </div>
+  <section>
+    <div class="section-header">
+      <h2>SSH</h2>
+    </div>
+    <div class="section-content">
+      <dl>
+        <dt>Found Key</dt>
+        <dd>{{ diagnostics.sshFoundKey || '?' }}</dd>
+        <dt>Found Config</dt>
+        <dd>{{ diagnostics.sshFoundConfig || '?' }}</dd>
+      </dl>
+    </div>
+  </section>
 
-      <div v-if="diagnostics.network" class="diagnostics-section">
-        <h3>Network</h3>
-        <table class="diagnostics-table">
-          <tbody>
-            <tr v-for="(value, key) in diagnostics.network" :key="key">
-              <td width="10%">{{ formatKey(key) }}</td>
-              <td>{{ value }}</td>
-            </tr>
-          </tbody>
-        </table>
-      </div>
+  <section>
+    <div class="section-header">
+      <h2>SOS Report</h2>
+    </div>
+    <div class="section-content">
 
-      <div v-if="diagnostics.storage" class="diagnostics-section">
-        <h3>Storage</h3>
-        <table class="diagnostics-table">
-          <tbody>
-            <tr v-for="(value, key) in diagnostics.storage" :key="key">
-              <td width="10%">{{ formatKey(key) }}</td>
-              <td>{{ value }}</td>
-            </tr>
-          </tbody>
-        </table>
-      </div>
+      <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>
 
-      <div v-if="diagnostics.services" class="diagnostics-section">
-        <h3>Services</h3>
-        <table class="diagnostics-table">
-          <tbody>
-            <tr v-for="(value, key) in diagnostics.services" :key="key">
-              <td width="10%">{{ formatKey(key) }}</td>
-              <td>{{ value }}</td>
-            </tr>
-          </tbody>
-        </table>
+      <div role="toolbar">
+        <button @click="generateSosReport" :disabled="loading" class = "good">Generate SOS Report</button>
       </div>
 
-      <div v-if="diagnostics.errors && diagnostics.errors.length > 0" class="diagnostics-section">
-        <h3>Errors</h3>
-        <div class="error-list">
-          <div v-for="(error, index) in diagnostics.errors" :key="index" class="error-item">
-            {{ error }}
-          </div>
-        </div>
-      </div>
+      <textarea v-model="sosReport" readonly style="flex: 1; min-height: 200px; resize: vertical;"></textarea>
     </div>
-  </div>
+  </section>
 </template>
 
 <script setup>
@@ -96,6 +53,7 @@ import { ref, onMounted } from 'vue'
 
 const diagnostics = ref({})
 const loading = ref(false)
+const sosReport = ref('Waiting to start...')
 
 async function fetchDiagnostics() {
   loading.value = true
@@ -103,8 +61,8 @@ async function fetchDiagnostics() {
   try {
     const response = await window.client.getDiagnostics();
     diagnostics.value = {
-      sshFoundKey: response.sshFoundKey,
-      sshFoundConfig: response.sshFoundConfig
+      sshFoundKey: response.SshFoundKey,
+      sshFoundConfig: response.SshFoundConfig
     };
   } catch (err) {
     console.error('Failed to fetch diagnostics:', err);
@@ -123,6 +81,12 @@ function formatKey(key) {
     .trim()
 }
 
+async function generateSosReport() {
+  const response = await window.client.sosReport()
+  console.log("response", response)
+  sosReport.value = response.alert
+}
+
 onMounted(() => {
   fetchDiagnostics()
 })
@@ -157,23 +121,6 @@ onMounted(() => {
   text-decoration: underline;
 }
 
-.diagnostics-section {
-  margin-bottom: 2rem;
-  background: #fff;
-  border-radius: 8px;
-  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
-  overflow: hidden;
-}
-
-.diagnostics-section h3 {
-  margin: 0;
-  padding: 1rem;
-  background: #f8f9fa;
-  border-bottom: 1px solid #dee2e6;
-  font-size: 1.1rem;
-  font-weight: 600;
-}
-
 .diagnostics-table {
   width: 100%;
   border-collapse: collapse;
@@ -212,4 +159,15 @@ onMounted(() => {
 .error-item:last-child {
   margin-bottom: 0;
 }
+
+.flex-col {
+  display: flex;
+  flex-direction: column;
+}
+
+.section-content {
+  display: flex;
+  flex-direction: column;
+  gap: 1em;
+}
 </style>

+ 53 - 0
frontend/resources/vue/views/EntitiesView.vue

@@ -0,0 +1,53 @@
+<template>
+	<section class = "with-header-and-content" v-if="entityDefinitions.length === 0">
+		<div class = "section-header">
+			<h2 class="loading-message">
+				Loading entity definitions...
+			</h2>
+		</div>
+	</section>
+	<template v-else>
+		<section v-for="def in entityDefinitions" :key="def.name" class="with-header-and-content">
+			<div class = "section-header">
+				<h2>Entity: {{ def.title }}</h2>
+			</div>
+
+			<div class = "section-content">
+				<p>{{ def.instances.length }} instances.</p>
+
+				<ul>
+					<li v-for="inst in def.instances" :key="inst.id">
+						<router-link :to="{ name: 'EntityDetails', params: { entityType: inst.type, entityKey: inst.uniqueKey } }">
+							{{ inst.title }}
+						</router-link>
+					</li>
+				</ul>
+
+				<h3>Used on Dashboards:</h3>
+				<ul>
+					<li v-for="dash in def.usedOnDashboards">
+						<router-link :to="{ name: 'Dashboard', params: { title: dash } }">
+							{{ dash }}
+						</router-link>
+					</li>
+				</ul>
+			</div>
+		</section>
+	</template>
+</template>
+
+<script setup>
+	import { ref, onMounted } from 'vue'
+
+	const entityDefinitions = ref([])
+
+	async function fetchEntities() {
+	    const ret = await window.client.getEntities()
+
+        entityDefinitions.value = ret.entityDefinitions
+	}
+
+    onMounted(() => {
+        fetchEntities()
+	})
+</script>

+ 43 - 0
frontend/resources/vue/views/EntityDetailsView.vue

@@ -0,0 +1,43 @@
+<template>
+	<section class="with-header-and-content">
+		<div class="section-header">
+			<h2>Entity Details</h2>
+		</div>
+
+		<div class="section-content">
+			<p v-if="!entityDetails">Loading entity details...</p>
+			<p v-else-if="!entityDetails.title">No details available for this entity.</p>
+			<p v-else>{{ entityDetails.title }}</p>
+		</div>
+	</section>
+</template>
+
+<script setup>
+	import { ref, onMounted, onBeforeUnmount } from 'vue'
+
+	const entityDetails = ref(null)
+
+	const props = defineProps({
+		entityType: String,
+		entityKey: String
+	})
+
+	async function fetchEntityDetails() {
+		try {
+			const response = await window.client.getEntity({
+				type: props.entityType,
+				uniqueKey: props.entityKey
+			})
+
+			entityDetails.value = response
+		} catch (err) {
+			console.error('Failed to fetch entity details:', err)
+			window.showBigError('fetch-entity-details', 'getting entity details', err, false)
+		}
+	}
+
+	onMounted(() => {
+	    fetchEntityDetails()
+	})
+
+</script>

+ 314 - 0
frontend/resources/vue/views/ExecutionView.vue

@@ -0,0 +1,314 @@
+<template>
+  <section class="with-header-and-content">
+    <div class="section-header">
+      <h2>Execution Results: {{ title }}</h2>
+
+      <button @click="toggleSize" title="Toggle dialog size">
+        <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
+          <path fill="currentColor"
+            d="M3 3h6v2H6.462l4.843 4.843l-1.415 1.414L5 6.367V9H3zm0 18h6v-2H6.376l4.929-4.928l-1.415-1.414L5 17.548V15H3zm12 0h6v-6h-2v2.524l-4.867-4.866l-1.414 1.414L17.647 19H15zm6-18h-6v2h2.562l-4.843 4.843l1.414 1.414L19 6.39V9h2z" />
+        </svg>
+      </button>
+
+    </div>
+    <div class="section-content">
+      <div v-if="logEntry">
+        <div class="action-header padded-content" style="float: right">
+          <span class="icon" role="img" v-html="icon"></span>
+        </div>
+
+        <dl>
+          <dt>Duration</dt>
+          <dd><span v-html="duration"></span></dd>
+
+          <dt>Status</dt>
+          <dd>
+            <ActionStatusDisplay :log-entry="logEntry" />
+          </dd>
+        </dl>
+      </div>
+
+      <div ref="xtermOutput"></div>
+
+      <br />
+
+      <div class="flex-row g1 buttons padded-content">
+        <button @click="goBack" title="Go back">
+          <HugeiconsIcon :icon="ArrowLeftIcon" />
+          Back
+        </button>
+
+        <div class = "fg1" />
+
+        <button :disabled="!canRerun" @click="rerunAction" title="Rerun">
+          <HugeiconsIcon :icon="WorkoutRunIcon" />
+          Rerun
+        </button>
+        <button :disabled="!canKill" @click="killAction" title="Kill">
+          <HugeiconsIcon :icon="Cancel02Icon" />
+          Kill
+        </button>
+      </div>
+    </div>
+  </section>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted, onBeforeUnmount, nextTick } from 'vue'
+import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
+import { OutputTerminal } from '../../../js/OutputTerminal.js'
+import { HugeiconsIcon } from '@hugeicons/vue'
+import { WorkoutRunIcon, Cancel02Icon, ArrowLeftIcon } from '@hugeicons/core-free-icons'
+import { useRouter } from 'vue-router'
+
+const router = useRouter()
+
+// Refs for DOM elements
+const xtermOutput = ref(null)
+const dialog = ref(null)
+
+const props = defineProps({
+  executionTrackingId: {
+    type: String,
+    required: true
+  }
+})
+
+const executionTrackingId = ref(props.executionTrackingId)
+const isBig = ref(false)
+const hideBasics = ref(false)
+const hideDetails = ref(false)
+const hideDetailsOnResult = ref(false)
+const executionSeconds = ref(0)
+const icon = ref('')
+const title = ref('Waiting for result...')
+const titleTooltip = ref('')
+const duration = ref('')
+const logEntry = ref(null)
+const canRerun = ref(false)
+const canKill = ref(false)
+
+let executionTicker = null
+let terminal = null
+
+function initializeTerminal() {
+  terminal = new OutputTerminal()
+
+  console.log('initializeTerminal', xtermOutput.value)
+
+  terminal.open(xtermOutput.value)
+  terminal.resize(80, 24)
+  window.terminal = terminal
+}
+
+function toggleSize() {
+  isBig.value = !isBig.value
+  if (isBig.value) {
+    terminal.fit()
+  } else {
+    terminal.resize(80, 24)
+  }
+}
+
+async function reset() {
+  executionSeconds.value = 0
+  executionTrackingId.value = 'notset'
+  isBig.value = false
+  hideBasics.value = false
+  hideDetails.value = false
+  hideDetailsOnResult.value = false
+
+  icon.value = ''
+  title.value = 'Waiting for result...'
+  titleTooltip.value = ''
+  duration.value = ''
+
+  canRerun.value = false
+  canKill.value = false
+  logEntry.value = null
+
+  if (terminal) {
+    await terminal.reset()
+    terminal.fit()
+  }
+}
+
+function show(actionButton) {
+  if (actionButton) {
+    icon.value = actionButton.domIcon.innerText
+  }
+
+  canKill.value = true
+
+  // Clear existing ticker
+  if (executionTicker) {
+    clearInterval(executionTicker)
+  }
+
+  executionSeconds.value = 0
+  executionTick()
+  executionTicker = setInterval(() => {
+    executionTick()
+  }, 1000)
+}
+
+function rerunAction() {
+  if (logEntry.value && logEntry.value.actionId) {
+    const actionButton = document.getElementById('actionButton-' + logEntry.value.actionId)
+    if (actionButton && actionButton.btn) {
+      actionButton.btn.click()
+    }
+  }
+}
+
+async function killAction() {
+  if (!executionTrackingId.value || executionTrackingId.value === 'notset') {
+    return
+  }
+
+  const killActionArgs = {
+    executionTrackingId: executionTrackingId.value
+  }
+
+  try {
+    await window.client.killAction(killActionArgs)
+  } catch (err) {
+    console.error('Failed to kill action:', err)
+  }
+}
+
+function executionTick() {
+  executionSeconds.value++
+  updateDuration(null)
+}
+
+function hideEverythingApartFromOutput() {
+  hideDetailsOnResult.value = true
+  hideBasics.value = true
+  hideDetailsOnResult.value = true
+  hideBasics.value = true
+}
+
+async function fetchExecutionResult(executionTrackingIdParam) {
+  console.log("fetchExecutionResult", executionTrackingIdParam)
+
+  executionTrackingId.value = executionTrackingIdParam
+
+  const executionStatusArgs = {
+    executionTrackingId: executionTrackingId.value
+  }
+
+  try {
+    const logEntryResult = await window.client.executionStatus(executionStatusArgs)
+
+    await renderExecutionResult(logEntryResult)
+  } catch (err) {
+    renderError(err)
+    throw err
+  }
+}
+
+function updateDuration(logEntryParam) {
+  logEntry.value = logEntryParam
+  if (logEntry.value == null) {
+    duration.value = executionSeconds.value + ' seconds'
+    duration.value = duration.value
+  } else if (!logEntry.value.executionStarted) {
+    duration.value = logEntry.value.datetimeStarted + ' (request time). Not executed.'
+  } else if (logEntry.value.executionStarted && !logEntry.value.executionFinished) {
+    duration.value = logEntry.value.datetimeStarted
+  } else {
+    let delta = ''
+    try {
+      delta = (new Date(logEntry.value.datetimeStarted) - new Date(logEntry.value.datetimeStarted)) / 1000
+      delta = new Intl.RelativeTimeFormat().format(delta, 'seconds').replace('in ', '').replace('ago', '')
+    } catch (e) {
+      console.warn('Failed to calculate delta', e)
+    }
+    duration.value = logEntry.value.datetimeStarted + ' &rarr; ' + logEntry.value.datetimeFinished
+    if (delta !== '') {
+      duration.value += ' (' + delta + ')'
+    }
+  }
+}
+
+async function renderExecutionResult(res) {
+  logEntry.value = res.logEntry
+
+  // Clear ticker
+  if (executionTicker) {
+    clearInterval(executionTicker)
+  }
+  executionTicker = null
+
+  if (hideDetailsOnResult.value) {
+    hideDetails.value = true
+  }
+
+  executionTrackingId.value = res.logEntry.executionTrackingId
+  canRerun.value = res.logEntry.executionFinished
+  canKill.value = res.logEntry.canKill
+
+  icon.value = res.logEntry.actionIcon
+  title.value = res.logEntry.actionTitle
+  titleTooltip.value = 'Action ID: ' + res.logEntry.actionId + '\nExecution ID: ' + res.logEntry.executionTrackingId
+
+  updateDuration(res.logEntry)
+
+  if (terminal) {
+    await terminal.reset()
+    await terminal.write(res.logEntry.output, () => {
+      terminal.fit()
+    })
+  }
+}
+
+function renderError(err) {
+  window.showBigError('execution-dlg-err', 'in the execution dialog', 'Failed to fetch execution result. ' + err, false)
+}
+
+function handleClose() {
+  if (executionTicker) {
+    clearInterval(executionTicker)
+  }
+
+  executionTicker = null
+}
+
+function cleanup() {
+  if (executionTicker) {
+    clearInterval(executionTicker)
+  }
+  executionTicker = null
+  if (terminal != null) {
+    terminal.close()
+  }
+  terminal = null
+}
+
+function goBack() {
+  router.back()
+}
+
+onMounted(() => {
+  initializeTerminal()
+  fetchExecutionResult(props.executionTrackingId)
+})
+
+onBeforeUnmount(() => {
+  cleanup()
+})
+
+// Expose methods for parent/imperative use
+defineExpose({
+  reset,
+  show,
+  rerunAction,
+  killAction,
+  fetchExecutionResult,
+  renderExecutionResult,
+  hideEverythingApartFromOutput,
+  handleClose
+})
+
+</script>

+ 6 - 3
frontend/resources/vue/views/LoginView.vue

@@ -1,9 +1,11 @@
 <template>
-  <section class = "small">
+  <section class = "small" style = "margin: auto;">
+	<div class = "section-header">
     <div class="login-container">
       <div class="login-form" style="display: grid; grid-template-columns: max-content 1fr; gap: 1em;">
         <h2>Login to OliveTin</h2>
-
+    </div>
+	<div class = "section-content">
         <div v-if="!hasOAuth && !hasLocalLogin" class="login-disabled">
           <p>This server is not configured with either OAuth, or local users, so you cannot login.</p>
         </div>
@@ -40,6 +42,7 @@
         </div>
       </div>
     </div>
+	</div>
   </section>
 </template>
 
@@ -129,4 +132,4 @@ form {
   grid-template-columns: max-content 1fr;
   gap: 1em;
 }
-</style>
+</style>

+ 256 - 0
frontend/resources/vue/views/LogsListView.vue

@@ -0,0 +1,256 @@
+<template>
+  <section>
+    <div class="section-header">
+      <h2>Logs</h2>
+
+      <div>
+        <label class="input-with-icons">
+          <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
+            <path fill="currentColor"
+              d="m19.6 21l-6.3-6.3q-.75.6-1.725.95T9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l6.3 6.3zM9.5 14q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14" />
+          </svg>
+          <input placeholder="Filter current page" v-model="searchText" />
+          <button title="Clear search filter" :disabled="!searchText" @click="clearSearch">
+            <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
+              <path fill="currentColor"
+                d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z" />
+            </svg>
+          </button>
+        </label>
+      </div>
+
+    </div>
+
+    <div class="section-content">
+      <p>This is a list of logs from actions that have been executed. You can filter the list by action title.</p>
+      <div v-show="filteredLogs.length > 0">
+        <table class="logs-table">
+          <thead>
+            <tr>
+              <th>Timestamp</th>
+              <th>Action</th>
+              <th>Metadata</th>
+              <th>Status</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-for="log in filteredLogs" :key="log.executionTrackingId" class="log-row" :title="log.actionTitle">
+              <td class="timestamp">{{ formatTimestamp(log.datetimeStarted) }}</td>
+              <td>
+                <span class="icon" v-html="log.actionIcon"></span>
+                <router-link :to="`/logs/${log.executionTrackingId}`">
+                  {{ log.actionTitle }}
+                </router-link>
+              </td>
+              <td class="tags">
+                <span class="annotation">
+                  <span class="annotation-key">User:</span>
+                  <span class="annotation-val">{{ log.user }}</span>
+                </span>
+                <span v-if="log.tags && log.tags.length > 0" class="tag-list">
+                  <span v-for="tag in log.tags" :key="tag" class="tag">{{ tag }}</span>
+                </span>
+              </td>
+              <td class="exit-code">
+                <span :class="getStatusClass(log) + ' annotation'">
+                  {{ getStatusText(log) }}
+                </span>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+
+        <Pagination :pageSize="pageSize" :total="totalCount" :currentPage="currentPage" @page-change="handlePageChange"
+          @page-size-change="handlePageSizeChange" itemTitle="execution logs" />
+      </div>
+
+      <div v-show="logs.length === 0" class="empty-state">
+        <p>There are no logs to display.</p>
+        <router-link to="/">Return to index</router-link>
+      </div>
+    </div>
+  </section>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import Pagination from '../components/Pagination.vue'
+
+const logs = ref([])
+const searchText = ref('')
+const pageSize = ref(10)
+const currentPage = ref(1)
+const loading = ref(false)
+const totalCount = ref(0)
+
+const filteredLogs = computed(() => {
+  if (!searchText.value) {
+    return logs.value
+  }
+  const searchLower = searchText.value.toLowerCase()
+  return logs.value.filter(log =>
+    log.actionTitle.toLowerCase().includes(searchLower)
+  )
+})
+
+async function fetchLogs() {
+  loading.value = true
+  try {
+    const startOffset = (currentPage.value - 1) * pageSize.value
+
+    const args = {
+      "startOffset": BigInt(startOffset),
+    }
+
+    const response = await window.client.getLogs(args)
+
+    logs.value = response.logs
+    pageSize.value = Number(response.pageSize) || 0
+    totalCount.value = Number(response.totalCount) || 0
+  } catch (err) {
+    console.error('Failed to fetch logs:', err)
+    window.showBigError('fetch-logs', 'getting logs', err, false)
+  } finally {
+    loading.value = false
+  }
+}
+
+function clearSearch() {
+  searchText.value = ''
+}
+
+function formatTimestamp(timestamp) {
+  if (!timestamp) return 'Unknown'
+  try {
+    const date = new Date(timestamp)
+    return date.toLocaleString()
+  } catch (err) {
+    return 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 'Timed out'
+  if (log.blocked) return 'Blocked'
+  if (log.exitCode !== 0) return `Exit code ${log.exitCode}`
+  return 'Completed'
+}
+
+function handlePageChange(page) {
+  currentPage.value = page
+  fetchLogs()
+}
+
+function handlePageSizeChange(newPageSize) {
+  pageSize.value = newPageSize
+  currentPage.value = 1 // Reset to first page
+}
+
+onMounted(() => {
+  fetchLogs()
+})
+</script>
+
+<style scoped>
+.logs-view {
+  padding: 1rem;
+}
+
+.input-with-icons {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  background: #fff;
+  border: 1px solid #ddd;
+  border-radius: 4px;
+  padding: 0.5rem;
+}
+
+.input-with-icons input {
+  border: none;
+  outline: none;
+  flex: 1;
+  font-size: 1rem;
+}
+
+.input-with-icons button {
+  background: none;
+  border: none;
+  cursor: pointer;
+  padding: 0.25rem;
+  border-radius: 3px;
+}
+
+.input-with-icons button:hover:not(:disabled) {
+  background: #f5f5f5;
+}
+
+.input-with-icons button:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
+.timestamp {
+  font-family: monospace;
+  font-size: 0.875rem;
+  color: #666;
+}
+
+.icon {
+  margin-right: 0.5rem;
+  font-size: 1.2em;
+}
+
+.content {
+  color: #007bff;
+  text-decoration: none;
+  cursor: pointer;
+}
+
+.content:hover {
+  text-decoration: underline;
+}
+
+.status-success {
+  color: #28a745;
+  font-weight: 500;
+}
+
+.status-error {
+  color: #dc3545;
+  font-weight: 500;
+}
+
+.status-timeout {
+  color: #ffc107;
+  font-weight: 500;
+}
+
+.status-blocked {
+  color: #6c757d;
+  font-weight: 500;
+}
+
+.empty-state {
+  text-align: center;
+  padding: 2rem;
+  color: #666;
+}
+
+.empty-state a {
+  color: #007bff;
+  text-decoration: none;
+}
+
+.empty-state a:hover {
+  text-decoration: underline;
+}
+
+</style>

+ 0 - 318
frontend/resources/vue/views/LogsView.vue

@@ -1,318 +0,0 @@
-<template>
-  <div class="logs-view">
-    <div class="toolbar">
-      <label class="input-with-icons">
-        <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
-          <path fill="currentColor" d="m19.6 21l-6.3-6.3q-.75.6-1.725.95T9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l6.3 6.3zM9.5 14q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14"/>
-        </svg>
-        <input 
-          placeholder="Search for action name" 
-          v-model="searchText"
-          @input="handleSearch"
-        />
-        <button 
-          title="Clear search filter" 
-          :disabled="!searchText"
-          @click="clearSearch"
-        >
-          <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
-            <path fill="currentColor" d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z"/>
-          </svg>
-        </button>
-      </label>
-    </div>
-
-    <table v-show="filteredLogs.length > 0" class="logs-table">
-      <thead>
-        <tr>
-          <th>Timestamp</th>
-          <th>Action</th>
-          <th>Metadata</th>
-          <th>Status</th>
-        </tr>
-      </thead>
-      <tbody>
-        <tr 
-          v-for="log in filteredLogs" 
-          :key="log.executionTrackingId"
-          class="log-row"
-          :title="log.actionTitle"
-        >
-          <td class="timestamp">{{ formatTimestamp(log.datetimeStarted) }}</td>
-          <td>
-            <span class="icon" v-html="log.actionIcon"></span>
-            <a href="javascript:void(0)" class="content" @click="showLogDetails(log)">
-              {{ log.actionTitle }}
-            </a>
-          </td>
-          <td class="tags">
-            <span v-if="log.tags && log.tags.length > 0" class="tag-list">
-              <span v-for="tag in log.tags" :key="tag" class="tag">{{ tag }}</span>
-            </span>
-          </td>
-          <td class="exit-code">
-            <span :class="getStatusClass(log)">
-              {{ getStatusText(log) }}
-            </span>
-          </td>
-        </tr>
-      </tbody>
-    </table>
-
-    <div v-show="filteredLogs.length === 0" class="empty-state">
-      <p>There are no logs to display.</p>
-      <router-link to="/">Return to index</router-link>
-    </div>
-
-    <p class="note">
-      <strong>Note:</strong> The server is configured to only send 
-      <strong>{{ pageSize }}</strong> log entries at a time. 
-      The search box at the top of this page only searches this current page of logs.
-    </p>
-  </div>
-</template>
-
-<script>
-export default {
-  name: 'LogsView',
-  data() {
-    return {
-      logs: [],
-      searchText: '',
-      pageSize: '?',
-      loading: false
-    }
-  },
-  computed: {
-    filteredLogs() {
-      if (!this.searchText) {
-        return this.logs
-      }
-      
-      const searchLower = this.searchText.toLowerCase()
-      return this.logs.filter(log => 
-        log.actionTitle.toLowerCase().includes(searchLower)
-      )
-    }
-  },
-  mounted() {
-    this.fetchLogs()
-    this.fetchPageSize()
-  },
-  methods: {
-    async fetchLogs() {
-      this.loading = true
-      try {
-        const response = await window.client.getLogs()
-        this.logs = response.logEntries || []
-      } catch (err) {
-        console.error('Failed to fetch logs:', err)
-        window.showBigError('fetch-logs', 'getting logs', err, false)
-      } finally {
-        this.loading = false
-      }
-    },
-    
-    async fetchPageSize() {
-      try {
-        const response = await fetch('webUiSettings.json')
-        const settings = await response.json()
-        this.pageSize = settings.LogsPageSize || '?'
-      } catch (err) {
-        console.warn('Failed to fetch page size:', err)
-      }
-    },
-    
-    handleSearch() {
-      // Search is handled by computed property
-    },
-    
-    clearSearch() {
-      this.searchText = ''
-    },
-    
-    formatTimestamp(timestamp) {
-      if (!timestamp) return 'Unknown'
-      
-      try {
-        const date = new Date(timestamp)
-        return date.toLocaleString()
-      } catch (err) {
-        return timestamp
-      }
-    },
-    
-    getStatusClass(log) {
-      if (log.timedOut) return 'status-timeout'
-      if (log.blocked) return 'status-blocked'
-      if (log.exitCode !== 0) return 'status-error'
-      return 'status-success'
-    },
-    
-    getStatusText(log) {
-      if (log.timedOut) return 'Timed out'
-      if (log.blocked) return 'Blocked'
-      if (log.exitCode !== 0) return `Exit code ${log.exitCode}`
-      return 'Success'
-    },
-    
-    showLogDetails(log) {
-      // Emit event to parent or use global execution dialog
-      if (window.executionDialog) {
-        window.executionDialog.reset()
-        window.executionDialog.show()
-        window.executionDialog.fetchExecutionResult(log.executionTrackingId)
-      }
-    }
-  }
-}
-</script>
-
-<style scoped>
-.logs-view {
-  padding: 1rem;
-}
-
-.toolbar {
-  margin-bottom: 1rem;
-}
-
-.input-with-icons {
-  display: flex;
-  align-items: center;
-  gap: 0.5rem;
-  background: #fff;
-  border: 1px solid #ddd;
-  border-radius: 4px;
-  padding: 0.5rem;
-}
-
-.input-with-icons input {
-  border: none;
-  outline: none;
-  flex: 1;
-  font-size: 1rem;
-}
-
-.input-with-icons button {
-  background: none;
-  border: none;
-  cursor: pointer;
-  padding: 0.25rem;
-  border-radius: 3px;
-}
-
-.input-with-icons button:hover:not(:disabled) {
-  background: #f5f5f5;
-}
-
-.input-with-icons button:disabled {
-  opacity: 0.5;
-  cursor: not-allowed;
-}
-
-.logs-table {
-  width: 100%;
-  border-collapse: collapse;
-  background: #fff;
-  border-radius: 4px;
-  overflow: hidden;
-  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
-}
-
-.logs-table th {
-  background: #f8f9fa;
-  padding: 0.75rem;
-  text-align: left;
-  font-weight: 600;
-  border-bottom: 1px solid #dee2e6;
-}
-
-.logs-table td {
-  padding: 0.75rem;
-  border-bottom: 1px solid #f1f3f4;
-}
-
-.logs-table tr:hover {
-  background: #f8f9fa;
-}
-
-.timestamp {
-  font-family: monospace;
-  font-size: 0.875rem;
-  color: #666;
-}
-
-.icon {
-  margin-right: 0.5rem;
-  font-size: 1.2em;
-}
-
-.content {
-  color: #007bff;
-  text-decoration: none;
-  cursor: pointer;
-}
-
-.content:hover {
-  text-decoration: underline;
-}
-
-.tag-list {
-  display: flex;
-  gap: 0.25rem;
-  flex-wrap: wrap;
-}
-
-.tag {
-  background: #e9ecef;
-  color: #495057;
-  padding: 0.125rem 0.5rem;
-  border-radius: 12px;
-  font-size: 0.75rem;
-}
-
-.status-success {
-  color: #28a745;
-  font-weight: 500;
-}
-
-.status-error {
-  color: #dc3545;
-  font-weight: 500;
-}
-
-.status-timeout {
-  color: #ffc107;
-  font-weight: 500;
-}
-
-.status-blocked {
-  color: #6c757d;
-  font-weight: 500;
-}
-
-.empty-state {
-  text-align: center;
-  padding: 2rem;
-  color: #666;
-}
-
-.empty-state a {
-  color: #007bff;
-  text-decoration: none;
-}
-
-.empty-state a:hover {
-  text-decoration: underline;
-}
-
-.note {
-  margin-top: 1rem;
-  padding: 1rem;
-  background: #f8f9fa;
-  border-radius: 4px;
-  font-size: 0.875rem;
-  color: #666;
-}
-</style> 

+ 6 - 58
frontend/resources/vue/views/NotFoundView.vue

@@ -4,13 +4,12 @@
       <div class="not-found-content">
         <h1>404</h1>
         <h2>Page Not Found</h2>
-        <p>The page you're looking for doesn't exist.</p>
         
         <div class="actions">
-          <router-link to="/" class="btn btn-primary">
+          <button class = "button good" @click="goToHome">
             Go to Home
-          </router-link>
-          <button @click="goBack" class="btn btn-secondary">
+          </button>
+          <button class="button neutral" @click="goBack">
             Go Back
           </button>
         </div>
@@ -25,29 +24,15 @@ export default {
   methods: {
     goBack() {
       this.$router.go(-1)
+    },
+    goToHome() {
+      this.$router.push('/')
     }
   }
 }
 </script>
 
 <style scoped>
-.not-found-view {
-  min-height: 100vh;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-  padding: 1rem;
-}
-
-.not-found-container {
-  background: white;
-  border-radius: 12px;
-  box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
-  overflow: hidden;
-  max-width: 500px;
-  width: 100%;
-}
 
 .not-found-content {
   padding: 3rem 2rem;
@@ -57,7 +42,6 @@ export default {
 .not-found-content h1 {
   font-size: 6rem;
   margin: 0;
-  color: #007bff;
   font-weight: 700;
   line-height: 1;
 }
@@ -73,40 +57,4 @@ export default {
   color: #666;
   margin-bottom: 2rem;
 }
-
-.actions {
-  display: flex;
-  gap: 1rem;
-  justify-content: center;
-  flex-wrap: wrap;
-}
-
-.btn {
-  padding: 0.75rem 1.5rem;
-  border: none;
-  border-radius: 6px;
-  font-size: 1rem;
-  cursor: pointer;
-  text-decoration: none;
-  display: inline-block;
-  transition: all 0.2s ease;
-}
-
-.btn-primary {
-  background: #007bff;
-  color: white;
-}
-
-.btn-primary:hover {
-  background: #0056b3;
-}
-
-.btn-secondary {
-  background: #6c757d;
-  color: white;
-}
-
-.btn-secondary:hover {
-  background: #545b62;
-}
 </style> 

+ 75 - 3
frontend/style.css

@@ -1,8 +1,13 @@
 @import 'femtocrank/style.css';
 
-section.transparent {
-	background-color: transparent;
-	box-shadow: none;
+header {
+	position: fixed;
+	width: 100%;
+	z-index: 5;
+}
+
+aside {
+	padding-top: 4em;
 }
 
 fieldset {
@@ -13,6 +18,10 @@ fieldset {
 	place-items: stretch;
 }
 
+main {
+	padding-top: 4em;
+}
+
 action-button {
 	display: flex;
 	flex-direction: column;
@@ -39,3 +48,66 @@ dialog {
 footer span {
 	margin-right: 1em;
 }
+
+legend {
+	font-weight: bold;
+	text-align: center;
+	padding: 1em;
+	padding-top: 1.5em;
+}
+
+button.neutral {
+	background-color: transparent;
+	color: white;
+}
+
+section {
+	padding: 0;
+}
+
+.display {
+	border: 1px solid #666;
+	padding: 1em;
+	border-radius: .7em;
+	box-shadow: 0 0 .6em #aaa;
+	text-align: center;
+	font-size: small;
+	display: flex;
+	flex-direction: column;
+	flex-grow: 1;
+	justify-content: center;
+	align-items: center;
+}
+
+aside .flex-row {
+	padding-left: 1em;
+	padding-right: .5em;
+}
+
+#sidebar-toggler-button {
+	margin-right: .5em;
+}
+
+div.buttons button svg {
+	vertical-align: middle;
+}
+
+section .section-content {
+	padding-top: 0;
+}
+
+footer {
+	font-size: small;
+}
+
+th {
+	background-color: #fff;
+}
+
+aside {
+	z-index: 3; /* Make sure the sidebar is on top of the terminal */
+}
+
+section.small {
+	border-radius: .4em;
+}

+ 135 - 20
service/gen/olivetin/api/v1/apiv1connect/olivetin.connect.go

@@ -33,9 +33,9 @@ const (
 // reflection-formatted method names, remove the leading slash and convert the remaining slash to a
 // period.
 const (
-	// OliveTinApiServiceGetDashboardComponentsProcedure is the fully-qualified name of the
-	// OliveTinApiService's GetDashboardComponents RPC.
-	OliveTinApiServiceGetDashboardComponentsProcedure = "/olivetin.api.v1.OliveTinApiService/GetDashboardComponents"
+	// OliveTinApiServiceGetDashboardProcedure is the fully-qualified name of the OliveTinApiService's
+	// GetDashboard RPC.
+	OliveTinApiServiceGetDashboardProcedure = "/olivetin.api.v1.OliveTinApiService/GetDashboard"
 	// OliveTinApiServiceStartActionProcedure is the fully-qualified name of the OliveTinApiService's
 	// StartAction RPC.
 	OliveTinApiServiceStartActionProcedure = "/olivetin.api.v1.OliveTinApiService/StartAction"
@@ -90,11 +90,22 @@ const (
 	// OliveTinApiServiceGetDiagnosticsProcedure is the fully-qualified name of the OliveTinApiService's
 	// GetDiagnostics RPC.
 	OliveTinApiServiceGetDiagnosticsProcedure = "/olivetin.api.v1.OliveTinApiService/GetDiagnostics"
+	// OliveTinApiServiceInitProcedure is the fully-qualified name of the OliveTinApiService's Init RPC.
+	OliveTinApiServiceInitProcedure = "/olivetin.api.v1.OliveTinApiService/Init"
+	// OliveTinApiServiceGetActionBindingProcedure is the fully-qualified name of the
+	// OliveTinApiService's GetActionBinding RPC.
+	OliveTinApiServiceGetActionBindingProcedure = "/olivetin.api.v1.OliveTinApiService/GetActionBinding"
+	// OliveTinApiServiceGetEntitiesProcedure is the fully-qualified name of the OliveTinApiService's
+	// GetEntities RPC.
+	OliveTinApiServiceGetEntitiesProcedure = "/olivetin.api.v1.OliveTinApiService/GetEntities"
+	// OliveTinApiServiceGetEntityProcedure is the fully-qualified name of the OliveTinApiService's
+	// GetEntity RPC.
+	OliveTinApiServiceGetEntityProcedure = "/olivetin.api.v1.OliveTinApiService/GetEntity"
 )
 
 // OliveTinApiServiceClient is a client for the olivetin.api.v1.OliveTinApiService service.
 type OliveTinApiServiceClient interface {
-	GetDashboardComponents(context.Context, *connect.Request[v1.GetDashboardComponentsRequest]) (*connect.Response[v1.GetDashboardComponentsResponse], error)
+	GetDashboard(context.Context, *connect.Request[v1.GetDashboardRequest]) (*connect.Response[v1.GetDashboardResponse], error)
 	StartAction(context.Context, *connect.Request[v1.StartActionRequest]) (*connect.Response[v1.StartActionResponse], error)
 	StartActionAndWait(context.Context, *connect.Request[v1.StartActionAndWaitRequest]) (*connect.Response[v1.StartActionAndWaitResponse], error)
 	StartActionByGet(context.Context, *connect.Request[v1.StartActionByGetRequest]) (*connect.Response[v1.StartActionByGetResponse], error)
@@ -113,6 +124,10 @@ type OliveTinApiServiceClient interface {
 	Logout(context.Context, *connect.Request[v1.LogoutRequest]) (*connect.Response[v1.LogoutResponse], error)
 	EventStream(context.Context, *connect.Request[v1.EventStreamRequest]) (*connect.ServerStreamForClient[v1.EventStreamResponse], error)
 	GetDiagnostics(context.Context, *connect.Request[v1.GetDiagnosticsRequest]) (*connect.Response[v1.GetDiagnosticsResponse], error)
+	Init(context.Context, *connect.Request[v1.InitRequest]) (*connect.Response[v1.InitResponse], error)
+	GetActionBinding(context.Context, *connect.Request[v1.GetActionBindingRequest]) (*connect.Response[v1.GetActionBindingResponse], error)
+	GetEntities(context.Context, *connect.Request[v1.GetEntitiesRequest]) (*connect.Response[v1.GetEntitiesResponse], error)
+	GetEntity(context.Context, *connect.Request[v1.GetEntityRequest]) (*connect.Response[v1.Entity], error)
 }
 
 // NewOliveTinApiServiceClient constructs a client for the olivetin.api.v1.OliveTinApiService
@@ -126,10 +141,10 @@ func NewOliveTinApiServiceClient(httpClient connect.HTTPClient, baseURL string,
 	baseURL = strings.TrimRight(baseURL, "/")
 	oliveTinApiServiceMethods := v1.File_olivetin_api_v1_olivetin_proto.Services().ByName("OliveTinApiService").Methods()
 	return &oliveTinApiServiceClient{
-		getDashboardComponents: connect.NewClient[v1.GetDashboardComponentsRequest, v1.GetDashboardComponentsResponse](
+		getDashboard: connect.NewClient[v1.GetDashboardRequest, v1.GetDashboardResponse](
 			httpClient,
-			baseURL+OliveTinApiServiceGetDashboardComponentsProcedure,
-			connect.WithSchema(oliveTinApiServiceMethods.ByName("GetDashboardComponents")),
+			baseURL+OliveTinApiServiceGetDashboardProcedure,
+			connect.WithSchema(oliveTinApiServiceMethods.ByName("GetDashboard")),
 			connect.WithClientOptions(opts...),
 		),
 		startAction: connect.NewClient[v1.StartActionRequest, v1.StartActionResponse](
@@ -240,12 +255,36 @@ func NewOliveTinApiServiceClient(httpClient connect.HTTPClient, baseURL string,
 			connect.WithSchema(oliveTinApiServiceMethods.ByName("GetDiagnostics")),
 			connect.WithClientOptions(opts...),
 		),
+		init: connect.NewClient[v1.InitRequest, v1.InitResponse](
+			httpClient,
+			baseURL+OliveTinApiServiceInitProcedure,
+			connect.WithSchema(oliveTinApiServiceMethods.ByName("Init")),
+			connect.WithClientOptions(opts...),
+		),
+		getActionBinding: connect.NewClient[v1.GetActionBindingRequest, v1.GetActionBindingResponse](
+			httpClient,
+			baseURL+OliveTinApiServiceGetActionBindingProcedure,
+			connect.WithSchema(oliveTinApiServiceMethods.ByName("GetActionBinding")),
+			connect.WithClientOptions(opts...),
+		),
+		getEntities: connect.NewClient[v1.GetEntitiesRequest, v1.GetEntitiesResponse](
+			httpClient,
+			baseURL+OliveTinApiServiceGetEntitiesProcedure,
+			connect.WithSchema(oliveTinApiServiceMethods.ByName("GetEntities")),
+			connect.WithClientOptions(opts...),
+		),
+		getEntity: connect.NewClient[v1.GetEntityRequest, v1.Entity](
+			httpClient,
+			baseURL+OliveTinApiServiceGetEntityProcedure,
+			connect.WithSchema(oliveTinApiServiceMethods.ByName("GetEntity")),
+			connect.WithClientOptions(opts...),
+		),
 	}
 }
 
 // oliveTinApiServiceClient implements OliveTinApiServiceClient.
 type oliveTinApiServiceClient struct {
-	getDashboardComponents  *connect.Client[v1.GetDashboardComponentsRequest, v1.GetDashboardComponentsResponse]
+	getDashboard            *connect.Client[v1.GetDashboardRequest, v1.GetDashboardResponse]
 	startAction             *connect.Client[v1.StartActionRequest, v1.StartActionResponse]
 	startActionAndWait      *connect.Client[v1.StartActionAndWaitRequest, v1.StartActionAndWaitResponse]
 	startActionByGet        *connect.Client[v1.StartActionByGetRequest, v1.StartActionByGetResponse]
@@ -264,11 +303,15 @@ type oliveTinApiServiceClient struct {
 	logout                  *connect.Client[v1.LogoutRequest, v1.LogoutResponse]
 	eventStream             *connect.Client[v1.EventStreamRequest, v1.EventStreamResponse]
 	getDiagnostics          *connect.Client[v1.GetDiagnosticsRequest, v1.GetDiagnosticsResponse]
+	init                    *connect.Client[v1.InitRequest, v1.InitResponse]
+	getActionBinding        *connect.Client[v1.GetActionBindingRequest, v1.GetActionBindingResponse]
+	getEntities             *connect.Client[v1.GetEntitiesRequest, v1.GetEntitiesResponse]
+	getEntity               *connect.Client[v1.GetEntityRequest, v1.Entity]
 }
 
-// GetDashboardComponents calls olivetin.api.v1.OliveTinApiService.GetDashboardComponents.
-func (c *oliveTinApiServiceClient) GetDashboardComponents(ctx context.Context, req *connect.Request[v1.GetDashboardComponentsRequest]) (*connect.Response[v1.GetDashboardComponentsResponse], error) {
-	return c.getDashboardComponents.CallUnary(ctx, req)
+// GetDashboard calls olivetin.api.v1.OliveTinApiService.GetDashboard.
+func (c *oliveTinApiServiceClient) GetDashboard(ctx context.Context, req *connect.Request[v1.GetDashboardRequest]) (*connect.Response[v1.GetDashboardResponse], error) {
+	return c.getDashboard.CallUnary(ctx, req)
 }
 
 // StartAction calls olivetin.api.v1.OliveTinApiService.StartAction.
@@ -361,9 +404,29 @@ func (c *oliveTinApiServiceClient) GetDiagnostics(ctx context.Context, req *conn
 	return c.getDiagnostics.CallUnary(ctx, req)
 }
 
+// Init calls olivetin.api.v1.OliveTinApiService.Init.
+func (c *oliveTinApiServiceClient) Init(ctx context.Context, req *connect.Request[v1.InitRequest]) (*connect.Response[v1.InitResponse], error) {
+	return c.init.CallUnary(ctx, req)
+}
+
+// GetActionBinding calls olivetin.api.v1.OliveTinApiService.GetActionBinding.
+func (c *oliveTinApiServiceClient) GetActionBinding(ctx context.Context, req *connect.Request[v1.GetActionBindingRequest]) (*connect.Response[v1.GetActionBindingResponse], error) {
+	return c.getActionBinding.CallUnary(ctx, req)
+}
+
+// GetEntities calls olivetin.api.v1.OliveTinApiService.GetEntities.
+func (c *oliveTinApiServiceClient) GetEntities(ctx context.Context, req *connect.Request[v1.GetEntitiesRequest]) (*connect.Response[v1.GetEntitiesResponse], error) {
+	return c.getEntities.CallUnary(ctx, req)
+}
+
+// GetEntity calls olivetin.api.v1.OliveTinApiService.GetEntity.
+func (c *oliveTinApiServiceClient) GetEntity(ctx context.Context, req *connect.Request[v1.GetEntityRequest]) (*connect.Response[v1.Entity], error) {
+	return c.getEntity.CallUnary(ctx, req)
+}
+
 // OliveTinApiServiceHandler is an implementation of the olivetin.api.v1.OliveTinApiService service.
 type OliveTinApiServiceHandler interface {
-	GetDashboardComponents(context.Context, *connect.Request[v1.GetDashboardComponentsRequest]) (*connect.Response[v1.GetDashboardComponentsResponse], error)
+	GetDashboard(context.Context, *connect.Request[v1.GetDashboardRequest]) (*connect.Response[v1.GetDashboardResponse], error)
 	StartAction(context.Context, *connect.Request[v1.StartActionRequest]) (*connect.Response[v1.StartActionResponse], error)
 	StartActionAndWait(context.Context, *connect.Request[v1.StartActionAndWaitRequest]) (*connect.Response[v1.StartActionAndWaitResponse], error)
 	StartActionByGet(context.Context, *connect.Request[v1.StartActionByGetRequest]) (*connect.Response[v1.StartActionByGetResponse], error)
@@ -382,6 +445,10 @@ type OliveTinApiServiceHandler interface {
 	Logout(context.Context, *connect.Request[v1.LogoutRequest]) (*connect.Response[v1.LogoutResponse], error)
 	EventStream(context.Context, *connect.Request[v1.EventStreamRequest], *connect.ServerStream[v1.EventStreamResponse]) error
 	GetDiagnostics(context.Context, *connect.Request[v1.GetDiagnosticsRequest]) (*connect.Response[v1.GetDiagnosticsResponse], error)
+	Init(context.Context, *connect.Request[v1.InitRequest]) (*connect.Response[v1.InitResponse], error)
+	GetActionBinding(context.Context, *connect.Request[v1.GetActionBindingRequest]) (*connect.Response[v1.GetActionBindingResponse], error)
+	GetEntities(context.Context, *connect.Request[v1.GetEntitiesRequest]) (*connect.Response[v1.GetEntitiesResponse], error)
+	GetEntity(context.Context, *connect.Request[v1.GetEntityRequest]) (*connect.Response[v1.Entity], error)
 }
 
 // NewOliveTinApiServiceHandler builds an HTTP handler from the service implementation. It returns
@@ -391,10 +458,10 @@ type OliveTinApiServiceHandler interface {
 // and JSON codecs. They also support gzip compression.
 func NewOliveTinApiServiceHandler(svc OliveTinApiServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) {
 	oliveTinApiServiceMethods := v1.File_olivetin_api_v1_olivetin_proto.Services().ByName("OliveTinApiService").Methods()
-	oliveTinApiServiceGetDashboardComponentsHandler := connect.NewUnaryHandler(
-		OliveTinApiServiceGetDashboardComponentsProcedure,
-		svc.GetDashboardComponents,
-		connect.WithSchema(oliveTinApiServiceMethods.ByName("GetDashboardComponents")),
+	oliveTinApiServiceGetDashboardHandler := connect.NewUnaryHandler(
+		OliveTinApiServiceGetDashboardProcedure,
+		svc.GetDashboard,
+		connect.WithSchema(oliveTinApiServiceMethods.ByName("GetDashboard")),
 		connect.WithHandlerOptions(opts...),
 	)
 	oliveTinApiServiceStartActionHandler := connect.NewUnaryHandler(
@@ -505,10 +572,34 @@ func NewOliveTinApiServiceHandler(svc OliveTinApiServiceHandler, opts ...connect
 		connect.WithSchema(oliveTinApiServiceMethods.ByName("GetDiagnostics")),
 		connect.WithHandlerOptions(opts...),
 	)
+	oliveTinApiServiceInitHandler := connect.NewUnaryHandler(
+		OliveTinApiServiceInitProcedure,
+		svc.Init,
+		connect.WithSchema(oliveTinApiServiceMethods.ByName("Init")),
+		connect.WithHandlerOptions(opts...),
+	)
+	oliveTinApiServiceGetActionBindingHandler := connect.NewUnaryHandler(
+		OliveTinApiServiceGetActionBindingProcedure,
+		svc.GetActionBinding,
+		connect.WithSchema(oliveTinApiServiceMethods.ByName("GetActionBinding")),
+		connect.WithHandlerOptions(opts...),
+	)
+	oliveTinApiServiceGetEntitiesHandler := connect.NewUnaryHandler(
+		OliveTinApiServiceGetEntitiesProcedure,
+		svc.GetEntities,
+		connect.WithSchema(oliveTinApiServiceMethods.ByName("GetEntities")),
+		connect.WithHandlerOptions(opts...),
+	)
+	oliveTinApiServiceGetEntityHandler := connect.NewUnaryHandler(
+		OliveTinApiServiceGetEntityProcedure,
+		svc.GetEntity,
+		connect.WithSchema(oliveTinApiServiceMethods.ByName("GetEntity")),
+		connect.WithHandlerOptions(opts...),
+	)
 	return "/olivetin.api.v1.OliveTinApiService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		switch r.URL.Path {
-		case OliveTinApiServiceGetDashboardComponentsProcedure:
-			oliveTinApiServiceGetDashboardComponentsHandler.ServeHTTP(w, r)
+		case OliveTinApiServiceGetDashboardProcedure:
+			oliveTinApiServiceGetDashboardHandler.ServeHTTP(w, r)
 		case OliveTinApiServiceStartActionProcedure:
 			oliveTinApiServiceStartActionHandler.ServeHTTP(w, r)
 		case OliveTinApiServiceStartActionAndWaitProcedure:
@@ -545,6 +636,14 @@ func NewOliveTinApiServiceHandler(svc OliveTinApiServiceHandler, opts ...connect
 			oliveTinApiServiceEventStreamHandler.ServeHTTP(w, r)
 		case OliveTinApiServiceGetDiagnosticsProcedure:
 			oliveTinApiServiceGetDiagnosticsHandler.ServeHTTP(w, r)
+		case OliveTinApiServiceInitProcedure:
+			oliveTinApiServiceInitHandler.ServeHTTP(w, r)
+		case OliveTinApiServiceGetActionBindingProcedure:
+			oliveTinApiServiceGetActionBindingHandler.ServeHTTP(w, r)
+		case OliveTinApiServiceGetEntitiesProcedure:
+			oliveTinApiServiceGetEntitiesHandler.ServeHTTP(w, r)
+		case OliveTinApiServiceGetEntityProcedure:
+			oliveTinApiServiceGetEntityHandler.ServeHTTP(w, r)
 		default:
 			http.NotFound(w, r)
 		}
@@ -554,8 +653,8 @@ func NewOliveTinApiServiceHandler(svc OliveTinApiServiceHandler, opts ...connect
 // UnimplementedOliveTinApiServiceHandler returns CodeUnimplemented from all methods.
 type UnimplementedOliveTinApiServiceHandler struct{}
 
-func (UnimplementedOliveTinApiServiceHandler) GetDashboardComponents(context.Context, *connect.Request[v1.GetDashboardComponentsRequest]) (*connect.Response[v1.GetDashboardComponentsResponse], error) {
-	return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.GetDashboardComponents is not implemented"))
+func (UnimplementedOliveTinApiServiceHandler) GetDashboard(context.Context, *connect.Request[v1.GetDashboardRequest]) (*connect.Response[v1.GetDashboardResponse], error) {
+	return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.GetDashboard is not implemented"))
 }
 
 func (UnimplementedOliveTinApiServiceHandler) StartAction(context.Context, *connect.Request[v1.StartActionRequest]) (*connect.Response[v1.StartActionResponse], error) {
@@ -629,3 +728,19 @@ func (UnimplementedOliveTinApiServiceHandler) EventStream(context.Context, *conn
 func (UnimplementedOliveTinApiServiceHandler) GetDiagnostics(context.Context, *connect.Request[v1.GetDiagnosticsRequest]) (*connect.Response[v1.GetDiagnosticsResponse], error) {
 	return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.GetDiagnostics is not implemented"))
 }
+
+func (UnimplementedOliveTinApiServiceHandler) Init(context.Context, *connect.Request[v1.InitRequest]) (*connect.Response[v1.InitResponse], error) {
+	return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.Init is not implemented"))
+}
+
+func (UnimplementedOliveTinApiServiceHandler) GetActionBinding(context.Context, *connect.Request[v1.GetActionBindingRequest]) (*connect.Response[v1.GetActionBindingResponse], error) {
+	return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.GetActionBinding is not implemented"))
+}
+
+func (UnimplementedOliveTinApiServiceHandler) GetEntities(context.Context, *connect.Request[v1.GetEntitiesRequest]) (*connect.Response[v1.GetEntitiesResponse], error) {
+	return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.GetEntities is not implemented"))
+}
+
+func (UnimplementedOliveTinApiServiceHandler) GetEntity(context.Context, *connect.Request[v1.GetEntityRequest]) (*connect.Response[v1.Entity], error) {
+	return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.GetEntity is not implemented"))
+}

Fișier diff suprimat deoarece este prea mare
+ 771 - 87
service/gen/olivetin/api/v1/olivetin.pb.go


+ 164 - 44
service/internal/api/api.go

@@ -2,6 +2,7 @@ package api
 
 import (
 	ctx "context"
+	"encoding/json"
 
 	"connectrpc.com/connect"
 
@@ -15,9 +16,9 @@ import (
 
 	acl "github.com/OliveTin/OliveTin/internal/acl"
 	config "github.com/OliveTin/OliveTin/internal/config"
+	entities "github.com/OliveTin/OliveTin/internal/entities"
 	executor "github.com/OliveTin/OliveTin/internal/executor"
 	installationinfo "github.com/OliveTin/OliveTin/internal/installationinfo"
-	sv "github.com/OliveTin/OliveTin/internal/stringvariables"
 )
 
 type oliveTinAPI struct {
@@ -87,19 +88,17 @@ func (api *oliveTinAPI) StartAction(ctx ctx.Context, req *connect.Request[apiv1.
 		args[arg.Name] = arg.Value
 	}
 
-	api.executor.MapActionIdToBindingLock.RLock()
-	pair := api.executor.MapActionIdToBinding[req.Msg.ActionId]
-	api.executor.MapActionIdToBindingLock.RUnlock()
+	pair := api.executor.FindBindingByID(req.Msg.BindingId)
 
 	if pair == nil || pair.Action == nil {
-		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.ActionId))
+		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.BindingId))
 	}
 
 	authenticatedUser := acl.UserFromContext(ctx, api.cfg)
 
 	execReq := executor.ExecutionRequest{
 		Action:            pair.Action,
-		EntityPrefix:      pair.EntityPrefix,
+		Entity:            pair.Entity,
 		TrackingID:        req.Msg.UniqueTrackingId,
 		Arguments:         args,
 		AuthenticatedUser: authenticatedUser,
@@ -159,7 +158,7 @@ func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *connect.Request
 	user := acl.UserFromContext(ctx, api.cfg)
 
 	execReq := executor.ExecutionRequest{
-		Action:            api.executor.FindActionBindingByID(req.Msg.ActionId),
+		Action:            api.executor.FindActionByBindingID(req.Msg.ActionId),
 		TrackingID:        uuid.NewString(),
 		Arguments:         args,
 		AuthenticatedUser: user,
@@ -184,7 +183,7 @@ func (api *oliveTinAPI) StartActionByGet(ctx ctx.Context, req *connect.Request[a
 	args := make(map[string]string)
 
 	execReq := executor.ExecutionRequest{
-		Action:            api.executor.FindActionBindingByID(req.Msg.ActionId),
+		Action:            api.executor.FindActionByBindingID(req.Msg.ActionId),
 		TrackingID:        uuid.NewString(),
 		Arguments:         args,
 		AuthenticatedUser: acl.UserFromContext(ctx, api.cfg),
@@ -204,7 +203,7 @@ func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *connect.Re
 	user := acl.UserFromContext(ctx, api.cfg)
 
 	execReq := executor.ExecutionRequest{
-		Action:            api.executor.FindActionBindingByID(req.Msg.ActionId),
+		Action:            api.executor.FindActionByBindingID(req.Msg.ActionId),
 		TrackingID:        uuid.NewString(),
 		Arguments:         args,
 		AuthenticatedUser: user,
@@ -299,34 +298,6 @@ func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *connect.Request[ap
 	return connect.NewResponse(res), nil
 }
 
-/**
-func (api *oliveTinAPI) WatchExecution(req *apiv1.WatchExecutionRequest, srv apiv1.OliveTinApi_WatchExecutionServer) error {
-	log.Infof("Watch")
-
-	if logEntry, ok := api.executor.Logs[req.ExecutionUuid]; !ok {
-		log.Errorf("Execution not found: %v", req.ExecutionUuid)
-
-		return nil
-	} else {
-		if logEntry.ExecutionStarted {
-			for !logEntry.ExecutionCompleted {
-				tmp := make([]byte, 256)
-
-				red, err := io.ReadAtLeast(logEntry.StdoutBuffer, tmp, 1)
-
-				log.Infof("%v %v", red, err)
-
-				srv.Send(&apiv1.WatchExecutionUpdate{
-					Update: string(tmp),
-				})
-			}
-		}
-
-		return nil
-	}
-}
-*/
-
 func (api *oliveTinAPI) Logout(ctx ctx.Context, req *connect.Request[apiv1.LogoutRequest]) (*connect.Response[apiv1.LogoutResponse], error) {
 	//user := acl.UserFromContext(ctx, cfg)
 
@@ -336,14 +307,26 @@ func (api *oliveTinAPI) Logout(ctx ctx.Context, req *connect.Request[apiv1.Logou
 	return nil, nil
 }
 
-func (api *oliveTinAPI) GetDashboardComponents(ctx ctx.Context, req *connect.Request[apiv1.GetDashboardComponentsRequest]) (*connect.Response[apiv1.GetDashboardComponentsResponse], error) {
+func (api *oliveTinAPI) GetActionBinding(ctx ctx.Context, req *connect.Request[apiv1.GetActionBindingRequest]) (*connect.Response[apiv1.GetActionBindingResponse], error) {
+	binding := api.executor.FindBindingByID(req.Msg.BindingId)
+
+	return connect.NewResponse(&apiv1.GetActionBindingResponse{
+		Action: buildAction(req.Msg.BindingId, binding, &DashboardRenderRequest{
+			cfg:               api.cfg,
+			AuthenticatedUser: acl.UserFromContext(ctx, api.cfg),
+			ex:                api.executor,
+		}),
+	}), nil
+}
+
+func (api *oliveTinAPI) GetDashboard(ctx ctx.Context, req *connect.Request[apiv1.GetDashboardRequest]) (*connect.Response[apiv1.GetDashboardResponse], error) {
 	user := acl.UserFromContext(ctx, api.cfg)
 
 	if user.IsGuest() && api.cfg.AuthRequireGuestsToLogin {
 		return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("guests are not allowed to access the dashboard"))
 	}
 
-	res := buildDashboardResponse(api.executor, api.cfg, user)
+	res := buildDashboardResponse(api.executor, api.cfg, user, req.Msg.Title)
 
 	/*
 		if len(res.Actions) == 0 {
@@ -367,7 +350,7 @@ func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *connect.Request[apiv1.GetL
 
 	ret := &apiv1.GetLogsResponse{}
 
-	logEntries, countRemaining := api.executor.GetLogTrackingIds(req.Msg.StartOffset, api.cfg.LogHistoryPageSize)
+	logEntries, pagingResult := api.executor.GetLogTrackingIds(req.Msg.StartOffset, api.cfg.LogHistoryPageSize)
 
 	for _, logEntry := range logEntries {
 		action := api.cfg.FindAction(logEntry.ActionTitle)
@@ -379,8 +362,10 @@ func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *connect.Request[apiv1.GetL
 		}
 	}
 
-	ret.CountRemaining = countRemaining
-	ret.PageSize = api.cfg.LogHistoryPageSize
+	ret.CountRemaining = pagingResult.CountRemaining
+	ret.PageSize = pagingResult.PageSize
+	ret.TotalCount = pagingResult.TotalCount
+	ret.StartOffset = pagingResult.StartOffset
 
 	return connect.NewResponse(ret), nil
 }
@@ -442,8 +427,10 @@ func (api *oliveTinAPI) DumpVars(ctx ctx.Context, req *connect.Request[apiv1.Dum
 		return connect.NewResponse(res), nil
 	}
 
+	jsonstring, _ := json.MarshalIndent(entities.GetAll(), "", "  ")
+	fmt.Printf("%s", &jsonstring)
+
 	res.Alert = "Dumping variables has been enabled in the configuration. Please set InsecureAllowDumpVars = false again after you don't need it anymore"
-	res.Contents = sv.GetAll()
 
 	return connect.NewResponse(res), nil
 }
@@ -463,7 +450,7 @@ func (api *oliveTinAPI) DumpPublicIdActionMap(ctx ctx.Context, req *connect.Requ
 	for k, v := range api.executor.MapActionIdToBinding {
 		res.Contents[k] = &apiv1.ActionEntityPair{
 			ActionTitle:  v.Action.Title,
-			EntityPrefix: v.EntityPrefix,
+			EntityPrefix: "?",
 		}
 	}
 
@@ -562,6 +549,72 @@ func (api *oliveTinAPI) GetDiagnostics(ctx ctx.Context, req *connect.Request[api
 	return connect.NewResponse(res), nil
 }
 
+func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitRequest]) (*connect.Response[apiv1.InitResponse], error) {
+	user := acl.UserFromContext(ctx, api.cfg)
+
+	res := &apiv1.InitResponse{
+		ShowFooter:                api.cfg.ShowFooter,
+		ShowNavigation:            api.cfg.ShowNavigation,
+		ShowNewVersions:           api.cfg.ShowNewVersions,
+		AvailableVersion:          installationinfo.Runtime.AvailableVersion,
+		CurrentVersion:            installationinfo.Build.Version,
+		PageTitle:                 api.cfg.PageTitle,
+		SectionNavigationStyle:    api.cfg.SectionNavigationStyle,
+		DefaultIconForBack:        api.cfg.DefaultIconForBack,
+		EnableCustomJs:            api.cfg.EnableCustomJs,
+		AuthLoginUrl:              api.cfg.AuthLoginUrl,
+		AuthLocalLogin:            api.cfg.AuthLocalUsers.Enabled,
+		OAuth2Providers:           buildPublicOAuth2ProvidersList(api.cfg),
+		AdditionalLinks:           buildAdditionalLinks(api.cfg.AdditionalNavigationLinks),
+		StyleMods:                 api.cfg.StyleMods,
+		RootDashboards:            buildRootDashboards(api.cfg.Dashboards),
+		AuthenticatedUser:         user.Username,
+		AuthenticatedUserProvider: user.Provider,
+		EffectivePolicy:           buildEffectivePolicy(user.EffectivePolicy),
+		BannerMessage:             api.cfg.BannerMessage,
+		BannerCss:                 api.cfg.BannerCSS,
+	}
+
+	return connect.NewResponse(res), nil
+}
+
+func buildRootDashboards(dashboards []*config.DashboardComponent) []string {
+	var rootDashboards []string
+
+	for _, dashboard := range dashboards {
+		rootDashboards = append(rootDashboards, dashboard.Title)
+	}
+
+	return rootDashboards
+}
+
+func buildPublicOAuth2ProvidersList(cfg *config.Config) []*apiv1.OAuth2Provider {
+	var publicProviders []*apiv1.OAuth2Provider
+
+	for _, provider := range cfg.AuthOAuth2Providers {
+		publicProviders = append(publicProviders, &apiv1.OAuth2Provider{
+			Title: provider.Title,
+			Url:   provider.AuthUrl,
+			Icon:  provider.Icon,
+		})
+	}
+
+	return publicProviders
+}
+
+func buildAdditionalLinks(links []*config.NavigationLink) []*apiv1.AdditionalLink {
+	var additionalLinks []*apiv1.AdditionalLink
+
+	for _, link := range links {
+		additionalLinks = append(additionalLinks, &apiv1.AdditionalLink{
+			Title: link.Title,
+			Url:   link.Url,
+		})
+	}
+
+	return additionalLinks
+}
+
 func (api *oliveTinAPI) OnOutputChunk(content []byte, executionTrackingId string) {
 	for _, client := range api.connectedClients {
 		select {
@@ -579,6 +632,73 @@ func (api *oliveTinAPI) OnOutputChunk(content []byte, executionTrackingId string
 	}
 }
 
+func (api *oliveTinAPI) GetEntities(ctx ctx.Context, req *connect.Request[apiv1.GetEntitiesRequest]) (*connect.Response[apiv1.GetEntitiesResponse], error) {
+	res := &apiv1.GetEntitiesResponse{
+		EntityDefinitions: make([]*apiv1.EntityDefinition, 0),
+	}
+
+	for name, entityInstances := range entities.GetEntities() {
+		def := &apiv1.EntityDefinition{
+			Title:        name,
+			UsedOnDashboards: findDashboardsForEntity(name, api.cfg.Dashboards),
+		}
+
+		for _, e := range entityInstances {
+			entity := &apiv1.Entity{
+				Title: e.Title,
+				UniqueKey: e.UniqueKey,
+				Type: name,
+			}
+
+			def.Instances = append(def.Instances, entity)
+		}
+
+		res.EntityDefinitions = append(res.EntityDefinitions, def)
+	}
+
+	return connect.NewResponse(res), nil
+}
+
+func findDashboardsForEntity(entityTitle string, dashboards []*config.DashboardComponent) []string {
+	var foundDashboards []string
+
+	findEntityInComponents(entityTitle, "", dashboards, &foundDashboards)
+
+	return foundDashboards
+}
+
+func findEntityInComponents(entityTitle string, parentTitle string, components []*config.DashboardComponent, foundDashboards *[]string) {
+	for _, component := range components {
+		if component.Entity == entityTitle {
+			*foundDashboards = append(*foundDashboards, parentTitle)
+		}
+
+		if len(component.Contents) > 0 {
+			findEntityInComponents(entityTitle, component.Title, component.Contents, foundDashboards)
+		}
+	}
+}
+
+func (api *oliveTinAPI) GetEntity(ctx ctx.Context, req *connect.Request[apiv1.GetEntityRequest]) (*connect.Response[apiv1.Entity], error) {
+	res := &apiv1.Entity{}
+
+	instances := entities.GetEntityInstances(req.Msg.Type)
+
+	log.Infof("msg: %+v", req.Msg)
+
+	if instances == nil || len(instances) == 0 { 
+		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("entity type %s not found", req.Msg.Type))
+	}
+
+	if entity, ok := instances[req.Msg.UniqueKey]; !ok {
+		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("entity with unique key %s not found in type %s", req.Msg.UniqueKey, req.Msg.Type))
+	} else {
+		res.Title = entity.Title
+	
+		return connect.NewResponse(res), nil
+	}
+}
+
 func newServer(ex *executor.Executor) *oliveTinAPI {
 	server := oliveTinAPI{}
 	server.cfg = ex.Cfg

+ 20 - 53
service/internal/api/apiActions.go

@@ -4,71 +4,40 @@ import (
 	apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
 	acl "github.com/OliveTin/OliveTin/internal/acl"
 	config "github.com/OliveTin/OliveTin/internal/config"
+	entities "github.com/OliveTin/OliveTin/internal/entities"
 	executor "github.com/OliveTin/OliveTin/internal/executor"
-	sv "github.com/OliveTin/OliveTin/internal/stringvariables"
-	log "github.com/sirupsen/logrus"
 )
 
 type DashboardRenderRequest struct {
-	AuthenticatedUser   *acl.AuthenticatedUser
-	AllowedActionTitles []string `json:"allows_action_titles"`
-	cfg                 *config.Config
-	ex                  *executor.Executor
-
-	usedActions map[string]bool
+	AuthenticatedUser *acl.AuthenticatedUser
+	cfg               *config.Config
+	ex                *executor.Executor
 }
 
 func (rr *DashboardRenderRequest) findAction(title string) *apiv1.Action {
-	for _, action := range rr.cfg.Actions {
-		log.Infof("Checking action %s against %s", title, action.Title)
-		if action.Title == title {
-			return buildAction(action.ID, nil, rr)
+	for id, binding := range rr.ex.MapActionIdToBinding {
+		if binding.Action.Title == title {
+			return buildAction(id, binding, rr)
 		}
 	}
 
 	return nil
 }
 
-func buildDashboardResponse(ex *executor.Executor, cfg *config.Config, user *acl.AuthenticatedUser) *apiv1.GetDashboardComponentsResponse {
-	res := &apiv1.GetDashboardComponentsResponse{
-		AuthenticatedUser:         user.Username,
-		AuthenticatedUserProvider: user.Provider,
-	}
-
-	/*
-		sort.Slice(res.Actions, func(i, j int) bool {
-			if res.Actions[i].Order == res.Actions[j].Order {
-				return res.Actions[i].Title < res.Actions[j].Title
-			} else {
-				return res.Actions[i].Order < res.Actions[j].Order
-			}
-		})
-	*/
+func buildDashboardResponse(ex *executor.Executor, cfg *config.Config, user *acl.AuthenticatedUser, dashboardTitle string) *apiv1.GetDashboardResponse {
+	res := &apiv1.GetDashboardResponse{}
 
 	rr := &DashboardRenderRequest{
 		AuthenticatedUser: user,
-		//		AllowedActionTitles: getActionTitles(res.Actions),
-		cfg:         cfg,
-		ex:          ex,
-		usedActions: make(map[string]bool),
+		cfg:               cfg,
+		ex:                ex,
 	}
 
-	res.EffectivePolicy = buildEffectivePolicy(user.EffectivePolicy)
-	res.Dashboards = dashboardCfgToPb(rr)
+	res.Dashboard = dashboardCfgToPb(rr, dashboardTitle)
 
 	return res
 }
 
-func getActionTitles(actions []*apiv1.Action) []string {
-	titles := make([]string, 0, len(actions))
-
-	for _, action := range actions {
-		titles = append(titles, action.Title)
-	}
-
-	return titles
-}
-
 func buildEffectivePolicy(policy *config.ConfigurationPolicy) *apiv1.EffectivePolicy {
 	ret := &apiv1.EffectivePolicy{
 		ShowDiagnostics: policy.ShowDiagnostics,
@@ -78,13 +47,13 @@ func buildEffectivePolicy(policy *config.ConfigurationPolicy) *apiv1.EffectivePo
 	return ret
 }
 
-func buildAction(actionId string, actionBinding *executor.ActionBinding, rr *DashboardRenderRequest) *apiv1.Action {
+func buildAction(bindingId string, actionBinding *executor.ActionBinding, rr *DashboardRenderRequest) *apiv1.Action {
 	action := actionBinding.Action
 
 	btn := apiv1.Action{
-		Id:           actionId,
-		Title:        sv.ReplaceEntityVars(actionBinding.EntityPrefix, action.Title),
-		Icon:         sv.ReplaceEntityVars(actionBinding.EntityPrefix, action.Icon),
+		BindingId:    bindingId,
+		Title:        entities.ParseTemplateWith(action.Title, actionBinding.Entity),
+		Icon:         entities.ParseTemplateWith(action.Icon, actionBinding.Entity),
 		CanExec:      acl.IsAllowedExec(rr.cfg, rr.AuthenticatedUser, action),
 		PopupOnStart: action.PopupOnStart,
 		Order:        int32(actionBinding.ConfigOrder),
@@ -118,14 +87,12 @@ func buildChoices(arg config.ActionArgument) []*apiv1.ActionArgumentChoice {
 func buildChoicesEntity(firstChoice config.ActionArgumentChoice, entityTitle string) []*apiv1.ActionArgumentChoice {
 	ret := []*apiv1.ActionArgumentChoice{}
 
-	entityCount := sv.GetEntityCount(entityTitle)
-
-	for i := 0; i < entityCount; i++ {
-		prefix := sv.GetEntityPrefix(entityTitle, i)
+	entList := entities.GetEntityInstances(entityTitle)
 
+	for _, ent := range entList {
 		ret = append(ret, &apiv1.ActionArgumentChoice{
-			Value: sv.ReplaceEntityVars(prefix, firstChoice.Value),
-			Title: sv.ReplaceEntityVars(prefix, firstChoice.Title),
+			Value: entities.ParseTemplateWith(firstChoice.Value, ent),
+			Title: entities.ParseTemplateWith(firstChoice.Title, ent),
 		})
 	}
 

+ 29 - 26
service/internal/api/dashboard_entities.go

@@ -3,17 +3,17 @@ package api
 import (
 	apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
 	config "github.com/OliveTin/OliveTin/internal/config"
-	sv "github.com/OliveTin/OliveTin/internal/stringvariables"
-	"golang.org/x/exp/slices"
+	entities "github.com/OliveTin/OliveTin/internal/entities"
+	log "github.com/sirupsen/logrus"
 )
 
 func buildEntityFieldsets(entityTitle string, tpl *config.DashboardComponent, rr *DashboardRenderRequest) []*apiv1.DashboardComponent {
 	ret := make([]*apiv1.DashboardComponent, 0)
 
-	entityCount := sv.GetEntityCount(entityTitle)
+	entities := entities.GetEntityInstances(entityTitle)
 
-	for i := range entityCount {
-		fs := buildEntityFieldset(tpl, entityTitle, i, rr)
+	for _, ent := range entities {
+		fs := buildEntityFieldset(tpl, ent, rr)
 
 		if len(fs.Contents) > 0 {
 			ret = append(ret, fs)
@@ -23,32 +23,38 @@ func buildEntityFieldsets(entityTitle string, tpl *config.DashboardComponent, rr
 	return ret
 }
 
-func buildEntityFieldset(tpl *config.DashboardComponent, entityTitle string, entityIndex int, rr *DashboardRenderRequest) *apiv1.DashboardComponent {
-	prefix := sv.GetEntityPrefix(entityTitle, entityIndex)
-
+func buildEntityFieldset(tpl *config.DashboardComponent, ent *entities.Entity, rr *DashboardRenderRequest) *apiv1.DashboardComponent {
 	return &apiv1.DashboardComponent{
-		Title:    sv.ReplaceEntityVars(prefix, tpl.Title),
+		Title:    entities.ParseTemplateWith(tpl.Title, ent),
 		Type:     "fieldset",
-		Contents: removeFieldsetIfHasNoLinks(buildEntityFieldsetContents(tpl.Contents, prefix, rr)),
-		CssClass: sv.ReplaceEntityVars(prefix, tpl.CssClass),
+		Contents: removeFieldsetIfHasNoLinks(buildEntityFieldsetContents(tpl.Contents, ent, rr)),
+		CssClass: entities.ParseTemplateWith(tpl.CssClass, ent),
+		Action:   rr.findAction(tpl.Title),
 	}
 }
 
 func removeFieldsetIfHasNoLinks(contents []*apiv1.DashboardComponent) []*apiv1.DashboardComponent {
-	for _, subitem := range contents {
-		if subitem.Type == "link" {
-			return contents
+	return contents
+	/*
+		for _, subitem := range contents {
+			if subitem.Type == "link" {
+				return contents
+			}
 		}
-	}
 
-	return nil
+		log.Infof("removeFieldsetIfHasNoLinks: %+v", contents)
+
+		return nil
+	*/
 }
 
-func buildEntityFieldsetContents(contents []config.DashboardComponent, prefix string, rr *DashboardRenderRequest) []*apiv1.DashboardComponent {
+func buildEntityFieldsetContents(contents []*config.DashboardComponent, ent *entities.Entity, rr *DashboardRenderRequest) []*apiv1.DashboardComponent {
 	ret := make([]*apiv1.DashboardComponent, 0)
 
 	for _, subitem := range contents {
-		c := cloneItem(&subitem, prefix, rr)
+		c := cloneItem(subitem, ent, rr)
+
+		log.Infof("cloneItem: %+v", c)
 
 		if c != nil {
 			ret = append(ret, c)
@@ -58,19 +64,16 @@ func buildEntityFieldsetContents(contents []config.DashboardComponent, prefix st
 	return ret
 }
 
-func cloneItem(subitem *config.DashboardComponent, prefix string, rr *DashboardRenderRequest) *apiv1.DashboardComponent {
+func cloneItem(subitem *config.DashboardComponent, ent *entities.Entity, rr *DashboardRenderRequest) *apiv1.DashboardComponent {
 	clone := &apiv1.DashboardComponent{}
-	clone.CssClass = sv.ReplaceEntityVars(prefix, subitem.CssClass)
+	clone.CssClass = entities.ParseTemplateWith(subitem.CssClass, ent)
 
 	if subitem.Type == "" || subitem.Type == "link" {
 		clone.Type = "link"
-		clone.Title = sv.ReplaceEntityVars(prefix, subitem.Title)
-
-		if !slices.Contains(rr.AllowedActionTitles, clone.Title) {
-			return nil
-		}
+		clone.Title = entities.ParseTemplateWith(subitem.Title, ent)
+		clone.Action = rr.findAction(subitem.Title)
 	} else {
-		clone.Title = sv.ReplaceEntityVars(prefix, subitem.Title)
+		clone.Title = entities.ParseTemplateWith(subitem.Title, ent)
 		clone.Type = subitem.Type
 	}
 

+ 43 - 23
service/internal/api/dashboards.go

@@ -1,19 +1,26 @@
 package api
 
 import (
+	"sort"
+
 	apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
 	config "github.com/OliveTin/OliveTin/internal/config"
 	"golang.org/x/exp/slices"
 )
 
-func dashboardCfgToPb(rr *DashboardRenderRequest) []*apiv1.Dashboard {
-	ret := make([]*apiv1.Dashboard, 0)
+func dashboardCfgToPb(rr *DashboardRenderRequest, dashboardTitle string) *apiv1.Dashboard {
+	if dashboardTitle == "default" {
+		return buildDefaultDashboard(rr)
+	}
 
 	for _, dashboard := range rr.cfg.Dashboards {
-		pbdb := &apiv1.Dashboard{
+		if dashboard.Title != dashboardTitle {
+			continue
+		}
+
+		return &apiv1.Dashboard{
 			Title:    dashboard.Title,
-			Contents: removeNulls(getDashboardComponentContents(dashboard, rr)),
-			//			Contents: removeNulls(getDashboardComponentContents(dashboard, rr)),
+			Contents: sortActions(removeNulls(getDashboardComponentContents(dashboard, rr))),
 		}
 
 		/*
@@ -25,13 +32,9 @@ func dashboardCfgToPb(rr *DashboardRenderRequest) []*apiv1.Dashboard {
 				continue
 			}
 		*/
-
-		ret = append(ret, pbdb)
 	}
 
-	ret = append(ret, buildDefaultDashboard(rr))
-
-	return ret
+	return nil
 }
 
 func buildDefaultDashboard(rr *DashboardRenderRequest) *apiv1.Dashboard {
@@ -48,28 +51,50 @@ func buildDefaultDashboard(rr *DashboardRenderRequest) *apiv1.Dashboard {
 			continue
 		}
 
-		if rr.usedActions[id] {
+		if binding.IsOnDashboard {
 			continue
 		}
 
-		rr.usedActions[id] = true
 		actions = append(actions, buildAction(id, binding, rr))
 	}
 
 	for _, action := range actions {
 		fieldset.Contents = append(fieldset.Contents, &apiv1.DashboardComponent{
-			Type:  "link",
-			Title: action.Title,
-			Icon:  action.Icon,
+			Type:   "link",
+			Title:  action.Title,
+			Icon:   action.Icon,
+			Action: action,
 		})
 	}
 
+	fieldset.Contents = sortActions(fieldset.Contents)
+
 	return &apiv1.Dashboard{
 		Title:    "Default",
 		Contents: []*apiv1.DashboardComponent{fieldset},
 	}
 }
 
+func sortActions(components []*apiv1.DashboardComponent) []*apiv1.DashboardComponent {
+	sort.Slice(components, func(i, j int) bool {
+		if components[i].Action == nil {
+			return false
+		}
+
+		if components[j].Action == nil {
+			return true
+		}
+
+		if components[i].Action.Order == components[j].Action.Order {
+			return components[i].Action.Title < components[j].Action.Title
+		} else {
+			return components[i].Action.Order < components[j].Action.Order
+		}
+	})
+
+	return components
+}
+
 func removeNulls(components []*apiv1.DashboardComponent) []*apiv1.DashboardComponent {
 	ret := make([]*apiv1.DashboardComponent, 0)
 
@@ -89,9 +114,9 @@ func getDashboardComponentContents(dashboard *config.DashboardComponent, rr *Das
 
 	for _, subitem := range dashboard.Contents {
 		if subitem.Type == "fieldset" && subitem.Entity != "" {
-			ret = append(ret, buildEntityFieldsets(subitem.Entity, &subitem, rr)...)
+			ret = append(ret, buildEntityFieldsets(subitem.Entity, subitem, rr)...)
 		} else {
-			ret = append(ret, buildDashboardComponentSimple(&subitem, rr))
+			ret = append(ret, buildDashboardComponentSimple(subitem, rr))
 		}
 	}
 
@@ -99,18 +124,13 @@ func getDashboardComponentContents(dashboard *config.DashboardComponent, rr *Das
 }
 
 func buildDashboardComponentSimple(subitem *config.DashboardComponent, rr *DashboardRenderRequest) *apiv1.DashboardComponent {
-	if subitem.Type == "" || subitem.Type == "link" {
-		if !slices.Contains(rr.AllowedActionTitles, subitem.Title) {
-			return nil
-		}
-	}
-
 	newitem := &apiv1.DashboardComponent{
 		Title:    subitem.Title,
 		Type:     getDashboardComponentType(subitem),
 		Contents: getDashboardComponentContents(subitem, rr),
 		Icon:     getDashboardComponentIcon(subitem, rr.cfg),
 		CssClass: subitem.CssClass,
+		Action:   rr.findAction(subitem.Title),
 	}
 
 	return newitem

+ 3 - 1
service/internal/config/config.go

@@ -151,6 +151,8 @@ type Config struct {
 	AdditionalNavigationLinks       []*NavigationLink
 	ServiceHostMode                 string
 	StyleMods                       []string
+	BannerMessage				    string
+	BannerCSS                       string
 
 	usedConfigDir string
 }
@@ -209,7 +211,7 @@ type DashboardComponent struct {
 	Entity   string
 	Icon     string
 	CssClass string
-	Contents []DashboardComponent
+	Contents []*DashboardComponent
 }
 
 func DefaultConfig() *Config {

+ 16 - 14
service/internal/entityfiles/entityfiles.go → service/internal/entities/entities.go

@@ -1,18 +1,17 @@
-package entityfiles
+package entities
 
 import (
 	"bytes"
 	"encoding/json"
 	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+
 	config "github.com/OliveTin/OliveTin/internal/config"
 	"github.com/OliveTin/OliveTin/internal/filehelper"
-	sv "github.com/OliveTin/OliveTin/internal/stringvariables"
 	log "github.com/sirupsen/logrus"
 	"gopkg.in/yaml.v3"
-	"math"
-	"os"
-	"path/filepath"
-	"strings"
 )
 
 var (
@@ -20,6 +19,12 @@ var (
 	listeners           []func()
 )
 
+type Entity struct {
+	Data       any
+	UniqueKey  string
+	Title      string
+}
+
 func AddListener(l func()) {
 	listeners = append(listeners, l)
 }
@@ -121,16 +126,10 @@ func loadEntityFileYaml(filename string, entityname string) {
 func updateSvFromFile(entityname string, data []map[string]any) {
 	log.Debugf("updateSvFromFile: %+v", data)
 
-	count := len(data)
-
-	sv.RemoveKeysThatStartWith("entities." + entityname)
-
-	sv.SetEntityCount(entityname, count)
+	ClearEntities(entityname)
 
 	for i, mapp := range data {
-		prefix := "entities." + entityname + "." + fmt.Sprintf("%v", i)
-
-		serializeValueToSv(prefix, mapp)
+		AddEntity(entityname, fmt.Sprintf("%d", i), mapp)
 	}
 
 	for _, l := range listeners {
@@ -138,6 +137,7 @@ func updateSvFromFile(entityname string, data []map[string]any) {
 	}
 }
 
+/*
 //gocyclo:ignore
 func serializeValueToSv(prefix string, value any) {
 	if m, ok := value.(map[string]any); ok { // if value is a map we need to flatten it
@@ -173,3 +173,5 @@ func serializeSliceToSv(prefix string, s []any) {
 		serializeValueToSv(prefix+"."+fmt.Sprintf("%v", i), v)
 	}
 }
+*/
+

+ 1 - 1
service/internal/entityfiles/entityfiles_test.go → service/internal/entities/entities_test.go

@@ -1,4 +1,4 @@
-package entityfiles
+package entities
 
 import (
 	sv "github.com/OliveTin/OliveTin/internal/stringvariables"

+ 111 - 0
service/internal/entities/storage.go

@@ -0,0 +1,111 @@
+package entities
+
+/**
+ * The ephemeralvariablemap is used "only" for variable substitution in config
+ * titles, shell arguments, etc, in the foorm of {{ key }}, like Jinja2.
+ *
+ * OliveTin itself really only ever "writes" to this map, mostly by loading
+ * EntityFiles, and the only form of "reading" is for the variable substitution
+ * in configs.
+ */
+
+import (
+	"strings"
+	"sync"
+
+	"github.com/OliveTin/OliveTin/internal/installationinfo"
+)
+
+type entityInstancesByKey map[string]*Entity
+
+type entitiesByClass map[string]entityInstancesByKey
+
+type variableBase struct {
+	OliveTin installationInfo
+	Entities entitiesByClass
+
+	CurrentEntity interface{}
+	Arguments     map[string]string
+}
+
+type installationInfo struct {
+	Build   *installationinfo.BuildInfo
+	Runtime *installationinfo.RuntimeInfo
+}
+
+var (
+	contents *variableBase
+	rwmutex  = sync.RWMutex{}
+)
+
+func init() {
+	rwmutex.Lock()
+
+	contents = &variableBase{
+		OliveTin: installationInfo{
+			Build:   installationinfo.Build,
+			Runtime: installationinfo.Runtime,
+		},
+		Entities: make(entitiesByClass, 0),
+	}
+
+	rwmutex.Unlock()
+}
+
+func GetAll() *variableBase {
+	rwmutex.RLock()
+	defer rwmutex.RUnlock()
+
+	return contents
+}
+
+func GetEntities() entitiesByClass {
+	return contents.Entities
+}
+
+func GetEntityInstances(entityName string) entityInstancesByKey {
+	if entities, ok := contents.Entities[entityName]; ok {
+		return entities
+	}
+
+	return nil
+}
+
+func AddEntity(entityName string, entityKey string, data any) {
+	rwmutex.Lock()
+
+	if _, ok := contents.Entities[entityName]; !ok {
+		contents.Entities[entityName] = make(entityInstancesByKey, 0)
+	}
+
+	contents.Entities[entityName][entityKey] = &Entity {
+		Data: data,
+		UniqueKey: entityKey,
+		Title:      findEntityTitle(data),
+	}
+
+	rwmutex.Unlock()
+}
+
+func findEntityTitle(data any) string {
+    if mapData, ok := data.(map[string]any); ok {
+		keys := make(map[string]string)
+
+		for k := range mapData {
+			lookupKey := strings.ToLower(k)
+			keys[lookupKey] = k
+		}
+
+		for _, key := range []string{"title", "name", "id"} {
+			if lookupKey, exists := keys[strings.ToLower(key)]; exists {
+				if value, ok := mapData[lookupKey]; ok {
+					if valueStr, ok := value.(string); ok {
+						return valueStr
+					}
+				}
+			}
+		}
+	}
+
+	return "Untitled Entity"
+}

+ 106 - 0
service/internal/entities/templates.go

@@ -0,0 +1,106 @@
+package entities
+
+import (
+	"fmt"
+	"regexp"
+	"strings"
+	"text/template"
+
+	log "github.com/sirupsen/logrus"
+)
+
+var tpl = template.New("tpl")
+
+var legacyEntityRegex = regexp.MustCompile(`{{ ([a-zA-Z0-9_]+)\.*?([a-zA-Z0-9_\.]+) }}`)
+
+func migrateLegacyArgumentNames(rawShellCommand string) string {
+	foundArgumentNames := legacyEntityRegex.FindAllStringSubmatch(rawShellCommand, -1)
+
+	for _, match := range foundArgumentNames {
+		entityName := match[1]
+		argName := match[2]
+
+		if strings.Contains(argName, ".") {
+			replacement := ".CurrentEntity"
+
+			rawShellCommand = strings.Replace(rawShellCommand, entityName, replacement, -1)
+
+			log.WithFields(log.Fields{
+				"old": entityName,
+				"new": replacement,
+			}).Warnf("Legacy entity variable name found, changing to CurrentEntity")
+			continue
+		}
+
+		if !strings.HasPrefix(argName, ".Arguments.") {
+			log.WithFields(log.Fields{
+				"old": argName,
+				"new": ".Arguments." + argName,
+			}).Warnf("Legacy variable name found, changing to Argument")
+
+			rawShellCommand = strings.Replace(rawShellCommand, argName, ".Arguments."+argName, -1)
+		}
+	}
+
+	return rawShellCommand
+}
+
+func ParseTemplateWithArgs(source string, ent *Entity, args map[string]string) string {
+	source = migrateLegacyArgumentNames(source)
+
+	ret := ""
+
+	t, err := tpl.Parse(source)
+
+	if err != nil {
+		log.WithFields(log.Fields{
+			"source": source,
+			"err":    err,
+		}).Error("Error parsing template")
+		return fmt.Sprintf("tpl parse error: %v", err.Error())
+	}
+
+	var entdata any
+
+	if ent != nil {
+		entdata = ent.Data
+	} 
+
+	templateVariables := &variableBase{
+		OliveTin:      contents.OliveTin,
+		Arguments:     args,
+		CurrentEntity: entdata,
+	}
+
+	var sb strings.Builder
+	err = t.Execute(&sb, &templateVariables)
+
+	if err != nil {
+		log.WithFields(log.Fields{
+			"source": source,
+			"err":    err,
+			"currentEntity": ent,
+		}).Errorf("Error executing template")
+		ret = fmt.Sprintf("tpl exec error: %v", err.Error())
+	} else {
+		ret = sb.String()
+	}
+
+	return ret
+}
+
+func ParseTemplateWith(source string, ent *Entity) string {
+	return ParseTemplateWithArgs(source, ent, nil)
+}
+
+func ParseTemplateBoolWith(source string, ent *Entity) bool {
+	source = strings.TrimSpace(source)
+
+	tplBool := ParseTemplateWith(source, ent)
+
+	return tplBool == "true"
+}
+
+func ClearEntities(entityType string) {
+	delete(contents.Entities, entityType)
+}

+ 0 - 0
service/internal/entityfiles/testdata/object-per-line.json → service/internal/entities/testdata/object-per-line.json


+ 14 - 13
service/internal/executor/arguments.go

@@ -2,7 +2,7 @@ package executor
 
 import (
 	config "github.com/OliveTin/OliveTin/internal/config"
-	sv "github.com/OliveTin/OliveTin/internal/stringvariables"
+	"github.com/OliveTin/OliveTin/internal/entities"
 	log "github.com/sirupsen/logrus"
 
 	"fmt"
@@ -16,16 +16,16 @@ import (
 var (
 	typecheckRegex = map[string]string{
 		"very_dangerous_raw_string": "",
-		"int":                       "^[\\d]+$",
-		"unicode_identifier":        "^[\\w\\/\\\\.\\_ \\d]+$",
-		"ascii":                     "^[a-zA-Z0-9]+$",
-		"ascii_identifier":          "^[a-zA-Z0-9\\-\\.\\_]+$",
-		"ascii_sentence":            "^[a-zA-Z0-9 \\,\\.]+$",
+		"int":                       `^\d+$`,
+		"unicode_identifier":        `^[\w\-\.\_\d]+$`,
+		"ascii":                     `^[a-zA-Z0-9]+$`,
+		"ascii_identifier":          `^[a-zA-Z0-9\-\._]+$`,
+		"ascii_sentence":            `^[a-zA-Z0-9\-\._, ]+$`,
 	}
 )
 
-func parseCommandForReplacements(shellCommand string, values map[string]string) (string, error) {
-	r := regexp.MustCompile("{{ *?([a-zA-Z0-9_]+?) *?}}")
+func parseCommandForReplacements(shellCommand string, values map[string]string, entity any) (string, error) {
+	r := regexp.MustCompile(`{{ *?\.Arguments\.([a-zA-Z0-9_]+?) *?}}`)
 	foundArgumentNames := r.FindAllStringSubmatch(shellCommand, -1)
 
 	for _, match := range foundArgumentNames {
@@ -42,12 +42,14 @@ func parseCommandForReplacements(shellCommand string, values map[string]string)
 	return shellCommand, nil
 }
 
-func parseActionArguments(values map[string]string, action *config.Action, entityPrefix string) (string, error) {
+func parseActionArguments(rawShellCommand string, values map[string]string, action *config.Action, entity *entities.Entity) (string, error) {
 	log.WithFields(log.Fields{
 		"actionTitle": action.Title,
 		"cmd":         action.Shell,
 	}).Infof("Action parse args - Before")
 
+	rawShellCommand, err := parseCommandForReplacements(rawShellCommand, values, entity)
+
 	for _, arg := range action.Arguments {
 		argName := arg.Name
 		argValue := values[argName]
@@ -64,8 +66,7 @@ func parseActionArguments(values map[string]string, action *config.Action, entit
 		}).Debugf("Arg assigned")
 	}
 
-	parsedShellCommand, err := parseCommandForReplacements(action.Shell, values)
-	parsedShellCommand = sv.ReplaceEntityVars(entityPrefix, parsedShellCommand)
+	parsedShellCommand := entities.ParseTemplateWith(rawShellCommand, entity)
 	redactedShellCommand := redactShellCommand(parsedShellCommand, action.Arguments, values)
 
 	if err != nil {
@@ -173,8 +174,8 @@ func typecheckChoice(value string, arg *config.ActionArgument) error {
 func typecheckChoiceEntity(value string, arg *config.ActionArgument) error {
 	templateChoice := arg.Choices[0].Value
 
-	for _, ent := range sv.GetEntities(arg.Entity) {
-		choice := sv.ReplaceEntityVars(ent, templateChoice)
+	for _, ent := range entities.GetEntityInstances(arg.Entity) {
+		choice := entities.ParseTemplateWith(templateChoice, ent)
 
 		if value == choice {
 			return nil

+ 35 - 17
service/internal/executor/executor.go

@@ -3,7 +3,7 @@ package executor
 import (
 	acl "github.com/OliveTin/OliveTin/internal/acl"
 	config "github.com/OliveTin/OliveTin/internal/config"
-	sv "github.com/OliveTin/OliveTin/internal/stringvariables"
+	"github.com/OliveTin/OliveTin/internal/entities"
 	"github.com/google/uuid"
 	log "github.com/sirupsen/logrus"
 
@@ -23,7 +23,7 @@ import (
 
 const (
 	DefaultExitCodeNotExecuted = -1337
-	MaxTriggerDepth = 10 
+	MaxTriggerDepth            = 10
 )
 
 var (
@@ -34,9 +34,10 @@ var (
 )
 
 type ActionBinding struct {
-	Action       *config.Action
-	EntityPrefix string
-	ConfigOrder  int
+	Action        *config.Action
+	Entity        *entities.Entity
+	ConfigOrder   int
+	IsOnDashboard bool
 }
 
 // Executor represents a helper class for executing commands. It's main method
@@ -68,7 +69,7 @@ type ExecutionRequest struct {
 	Tags              []string
 	Cfg               *config.Config
 	AuthenticatedUser *acl.AuthenticatedUser
-	EntityPrefix      string
+	Entity            *entities.Entity
 	TriggerDepth      int
 
 	logEntry           *InternalLogEntry
@@ -170,10 +171,25 @@ func getPagingStartIndex(startOffset int64, totalLogCount int64) int64 {
 	return startIndex - 1
 }
 
-func (e *Executor) GetLogTrackingIds(startOffset int64, pageCount int64) ([]*InternalLogEntry, int64) {
+type PagingResult struct {
+	CountRemaining int64
+	PageSize       int64
+	TotalCount     int64
+	StartOffset    int64
+}
+
+func (e *Executor) GetLogTrackingIds(startOffset int64, pageCount int64) ([]*InternalLogEntry, *PagingResult) {
+	pagingResult := &PagingResult{
+		CountRemaining: 0,
+		PageSize:       pageCount,
+		TotalCount:     0,
+		StartOffset:    startOffset,
+	}
+
 	e.logmutex.RLock()
 
 	totalLogCount := int64(len(e.logsTrackingIdsByDate))
+	pagingResult.TotalCount = totalLogCount
 
 	startIndex := getPagingStartIndex(startOffset, totalLogCount)
 
@@ -199,9 +215,9 @@ func (e *Executor) GetLogTrackingIds(startOffset int64, pageCount int64) ([]*Int
 
 	e.logmutex.RUnlock()
 
-	remainingLogs := endIndex
+	pagingResult.CountRemaining = endIndex
 
-	return trackingIds, remainingLogs
+	return trackingIds, pagingResult
 }
 
 func (e *Executor) GetLog(trackingID string) (*InternalLogEntry, bool) {
@@ -250,14 +266,13 @@ func (e *Executor) ExecRequest(req *ExecutionRequest) (*sync.WaitGroup, string)
 		DatetimeStarted:     time.Now(),
 		ExecutionTrackingID: req.TrackingID,
 		Output:              "",
-		ExitCode:            DefaultExitCodeNotExecuted, 
+		ExitCode:            DefaultExitCodeNotExecuted,
 		ExecutionStarted:    false,
 		ExecutionFinished:   false,
 		ActionId:            "",
 		ActionTitle:         "notfound",
 		ActionIcon:          "&#x1f4a9;",
 		Username:            req.AuthenticatedUser.Username,
-		EntityPrefix:        req.EntityPrefix,
 	}
 
 	_, isDuplicate := e.GetLog(req.TrackingID)
@@ -351,9 +366,12 @@ func getExecutionsCount(rate config.RateSpec, req *ExecutionRequest) int {
 	then := time.Now().Add(-duration)
 
 	for _, logEntry := range req.executor.GetLogsByActionId(req.Action.ID) {
-		if logEntry.EntityPrefix != req.EntityPrefix {
-			continue
-		}
+		// FIXME
+		/*
+			if logEntry.EntityPrefix != req.EntityPrefix {
+				continue
+			}
+		*/
 
 		if logEntry.DatetimeStarted.After(then) && !logEntry.Blocked {
 
@@ -412,7 +430,7 @@ func stepParseArgs(req *ExecutionRequest) bool {
 
 	mangleInvalidArgumentValues(req)
 
-	req.finalParsedCommand, err = parseActionArguments(req.Arguments, req.Action, req.EntityPrefix)
+	req.finalParsedCommand, err = parseActionArguments(req.Action.Shell, req.Arguments, req.Action, req.Entity)
 
 	if err != nil {
 		req.logEntry.Output = err.Error()
@@ -448,7 +466,7 @@ func stepRequestAction(req *ExecutionRequest) bool {
 	metricActionsRequested.Inc()
 
 	req.logEntry.ActionConfigTitle = req.Action.Title
-	req.logEntry.ActionTitle = sv.ReplaceEntityVars(req.EntityPrefix, req.Action.Title)
+	req.logEntry.ActionTitle = entities.ParseTemplateWith(req.Action.Title, req.Entity)
 	req.logEntry.ActionIcon = req.Action.Icon
 	req.logEntry.ActionId = req.Action.ID
 	req.logEntry.Tags = req.Tags
@@ -613,7 +631,7 @@ func stepExecAfter(req *ExecutionRequest) bool {
 		"ot_username":            req.AuthenticatedUser.Username,
 	}
 
-	finalParsedCommand, err := parseCommandForReplacements(req.Action.ShellAfterCompleted, args)
+	finalParsedCommand, err := parseCommandForReplacements(req.Action.ShellAfterCompleted, args, req.Entity)
 
 	if err != nil {
 		msg := "Could not prepare shellAfterCompleted command: " + err.Error() + "\n"

+ 65 - 25
service/internal/executor/executor_actions.go

@@ -3,23 +3,38 @@ package executor
 import (
 	"crypto/sha256"
 	"fmt"
+	"slices"
+
 	config "github.com/OliveTin/OliveTin/internal/config"
-	sv "github.com/OliveTin/OliveTin/internal/stringvariables"
+	"github.com/OliveTin/OliveTin/internal/entities"
 	log "github.com/sirupsen/logrus"
-	"strconv"
 )
 
-func (e *Executor) FindActionBindingByID(id string) *config.Action {
+func (e *Executor) FindActionByBindingID(id string) *config.Action {
+	binding := e.FindBindingByID(id)
+
+	if binding == nil {
+		return nil
+	}
+
+	return binding.Action
+}
+
+func (e *Executor) FindBindingByID(id string) *ActionBinding {
 	e.MapActionIdToBindingLock.RLock()
 	pair, found := e.MapActionIdToBinding[id]
 	e.MapActionIdToBindingLock.RUnlock()
 
-	if found {
-		log.Infof("findActionBinding %v, %v", id, pair.Action.ID)
-		return pair.Action
+	if !found {
+		return nil
 	}
 
-	return nil
+	return pair
+}
+
+type RebuildActionMapRequest struct {
+	Cfg                   *config.Config
+	DashboardActionTitles []string
 }
 
 func (e *Executor) RebuildActionMap() {
@@ -27,11 +42,20 @@ func (e *Executor) RebuildActionMap() {
 
 	clear(e.MapActionIdToBinding)
 
+	req := &RebuildActionMapRequest{
+		Cfg:                   e.Cfg,
+		DashboardActionTitles: make([]string, 0),
+	}
+
+	findDashboardActionTitles(req)
+
+	log.Infof("dashboardActionTitles: %v", req.DashboardActionTitles)
+
 	for configOrder, action := range e.Cfg.Actions {
 		if action.Entity != "" {
-			registerActionsFromEntities(e, configOrder, action.Entity, action)
+			registerActionsFromEntities(e, configOrder, action.Entity, action, req)
 		} else {
-			registerAction(e, configOrder, action)
+			registerAction(e, configOrder, action, req)
 		}
 	}
 
@@ -42,33 +66,49 @@ func (e *Executor) RebuildActionMap() {
 	}
 }
 
-func registerAction(e *Executor, configOrder int, action *config.Action) {
-	actionId := hashActionToID(action, "")
+func findDashboardActionTitles(req *RebuildActionMapRequest) {
+	for _, dashboard := range req.Cfg.Dashboards {
+		recurseDashboardForActionTitles(dashboard, req)
+	}
+}
 
-	e.MapActionIdToBinding[actionId] = &ActionBinding{
-		Action:       action,
-		EntityPrefix: "noent",
-		ConfigOrder:  configOrder,
+func recurseDashboardForActionTitles(component *config.DashboardComponent, req *RebuildActionMapRequest) {
+	for _, sub := range component.Contents {
+		if sub.Type == "link" || sub.Type == "" {
+			req.DashboardActionTitles = append(req.DashboardActionTitles, sub.Title)
+		}
+
+		if len(sub.Contents) > 0 {
+			recurseDashboardForActionTitles(sub, req)
+		}
 	}
 }
 
-func registerActionsFromEntities(e *Executor, configOrder int, entityTitle string, tpl *config.Action) {
-	entityCount, _ := strconv.Atoi(sv.Get("entities." + entityTitle + ".count"))
+func registerAction(e *Executor, configOrder int, action *config.Action, req *RebuildActionMapRequest) {
+	actionId := hashActionToID(action, "")
 
-	for i := 0; i < entityCount; i++ {
-		registerActionFromEntity(e, configOrder, tpl, entityTitle, i)
+	e.MapActionIdToBinding[actionId] = &ActionBinding{
+		Action:        action,
+		Entity:        nil,
+		ConfigOrder:   configOrder,
+		IsOnDashboard: slices.Contains(req.DashboardActionTitles, action.Title),
 	}
 }
 
-func registerActionFromEntity(e *Executor, configOrder int, tpl *config.Action, entityTitle string, entityIndex int) {
-	prefix := sv.GetEntityPrefix(entityTitle, entityIndex)
+func registerActionsFromEntities(e *Executor, configOrder int, entityTitle string, tpl *config.Action, req *RebuildActionMapRequest) {
+	for _, ent := range entities.GetEntityInstances(entityTitle) {
+		registerActionFromEntity(e, configOrder, tpl, ent, req)
+	}
+}
 
-	virtualActionId := hashActionToID(tpl, prefix)
+func registerActionFromEntity(e *Executor, configOrder int, tpl *config.Action, ent *entities.Entity, req *RebuildActionMapRequest) {
+	virtualActionId := hashActionToID(tpl, "ent")
 
 	e.MapActionIdToBinding[virtualActionId] = &ActionBinding{
-		Action:       tpl,
-		EntityPrefix: prefix,
-		ConfigOrder:  configOrder,
+		Action:        tpl,
+		Entity:        ent,
+		ConfigOrder:   configOrder,
+		IsOnDashboard: slices.Contains(req.DashboardActionTitles, tpl.Title),
 	}
 }
 

+ 7 - 7
service/internal/httpservers/singleFrontend.go

@@ -9,14 +9,15 @@ away, and several other issues.
 */
 
 import (
-	config "github.com/OliveTin/OliveTin/internal/config"
-	"github.com/OliveTin/OliveTin/internal/api"
-	"github.com/OliveTin/OliveTin/internal/executor"
-	log "github.com/sirupsen/logrus"
 	"net/http"
 	"net/http/httputil"
 	"net/url"
 	"path"
+
+	"github.com/OliveTin/OliveTin/internal/api"
+	config "github.com/OliveTin/OliveTin/internal/config"
+	"github.com/OliveTin/OliveTin/internal/executor"
+	log "github.com/sirupsen/logrus"
 )
 
 func logDebugRequest(cfg *config.Config, source string, r *http.Request) {
@@ -54,7 +55,7 @@ func StartSingleHTTPFrontend(cfg *config.Config, ex *executor.Executor) {
 		apiHandler.ServeHTTP(w, r)
 	}))
 
-	oauth2handler := NewOAuth2Handler(cfg) 
+	oauth2handler := NewOAuth2Handler(cfg)
 
 	mux.HandleFunc("/oauth/login", oauth2handler.handleOAuthLogin)
 	mux.HandleFunc("/oauth/callback", oauth2handler.handleOAuthCallback)
@@ -63,8 +64,7 @@ func StartSingleHTTPFrontend(cfg *config.Config, ex *executor.Executor) {
 
 	webuiServer := NewWebUIServer(cfg)
 
-	mux.HandleFunc("/webUiSettings.json", webuiServer.generateWebUISettings)
-	mux.HandleFunc("/theme.css", webuiServer.generateThemeCss)	
+	mux.HandleFunc("/theme.css", webuiServer.generateThemeCss)
 	mux.Handle("/custom-webui/", webuiServer.handleCustomWebui())
 	mux.HandleFunc("/", webuiServer.handleWebui)
 

+ 5 - 77
service/internal/httpservers/webuiServer.go

@@ -1,18 +1,17 @@
 package httpservers
 
 import (
-	"encoding/json"
+
 	//	cors "github.com/OliveTin/OliveTin/internal/cors"
-	log "github.com/sirupsen/logrus"
 	"net/http"
 	"os"
 	"path"
 
+	log "github.com/sirupsen/logrus"
+
 	"github.com/jamesread/golure/pkg/dirs"
 
 	config "github.com/OliveTin/OliveTin/internal/config"
-	installationinfo "github.com/OliveTin/OliveTin/internal/installationinfo"
-	sv "github.com/OliveTin/OliveTin/internal/stringvariables"
 )
 
 type webUIServer struct {
@@ -26,24 +25,6 @@ var (
 	customThemeCssRead = false
 )
 
-type webUISettings struct {
-	Rest                   string
-	ShowFooter             bool
-	ShowNavigation         bool
-	ShowNewVersions        bool
-	AvailableVersion       string
-	CurrentVersion         string
-	PageTitle              string
-	SectionNavigationStyle string
-	DefaultIconForBack     string
-	EnableCustomJs         bool
-	AuthLoginUrl           string
-	AuthLocalLogin         bool
-	StyleMods              []string
-	AuthOAuth2Providers    []publicOAuth2Provider
-	AdditionalLinks        []*config.NavigationLink
-}
-
 func NewWebUIServer(cfg *config.Config) *webUIServer {
 	s := &webUIServer{
 		cfg: cfg,
@@ -66,11 +47,10 @@ func (s *webUIServer) handleWebui(w http.ResponseWriter, r *http.Request) {
 	} else {
 		log.Infof("Serving webui from %s for %s", s.webuiDir, r.URL.Path)
 		http.ServeFile(w, r, path.Join(s.webuiDir, r.URL.Path))
-//		http.StripPrefix(dirName, http.FileServer(http.Dir(s.webuiDir))).ServeHTTP(w, r)
+		//		http.StripPrefix(dirName, http.FileServer(http.Dir(s.webuiDir))).ServeHTTP(w, r)
 	}
 }
 
-
 func (s *webUIServer) findWebuiDir() string {
 	directoriesToSearch := []string{
 		s.cfg.WebUIDir,
@@ -108,9 +88,6 @@ func (s *webUIServer) setupCustomWebuiDir() {
 
 	if err != nil {
 		log.Warnf("Could not create themes directory: %v", err)
-		sv.Set("internal.themesdir", err.Error())
-	} else {
-		sv.Set("internal.themesdir", dir)
 	}
 }
 
@@ -132,55 +109,6 @@ func (s *webUIServer) generateThemeCss(w http.ResponseWriter, r *http.Request) {
 	w.Write(customThemeCss)
 }
 
-type publicOAuth2Provider struct {
-	Name  string
-	Title string
-	Icon  string
-}
-
-func buildPublicOAuth2ProvidersList(cfg *config.Config) []publicOAuth2Provider {
-	var publicProviders []publicOAuth2Provider
-
-	for _, provider := range cfg.AuthOAuth2Providers {
-		publicProviders = append(publicProviders, publicOAuth2Provider{
-			Name:  provider.Name,
-			Title: provider.Title,
-			Icon:  provider.Icon,
-		})
-	}
-
-	return publicProviders
-}
-
-func (s *webUIServer) generateWebUISettings(w http.ResponseWriter, r *http.Request) {
-	log.Infof("Generating webui settings for %s", r.RemoteAddr)
-
-	jsonRet, _ := json.Marshal(webUISettings{
-		Rest:                   s.cfg.ExternalRestAddress + "/api/",
-		ShowFooter:             s.cfg.ShowFooter,
-		ShowNavigation:         s.cfg.ShowNavigation,
-		ShowNewVersions:        s.cfg.ShowNewVersions,
-		AvailableVersion:       installationinfo.Runtime.AvailableVersion,
-		CurrentVersion:         installationinfo.Build.Version,
-		PageTitle:              s.cfg.PageTitle,
-		SectionNavigationStyle: s.cfg.SectionNavigationStyle,
-		DefaultIconForBack:     s.cfg.DefaultIconForBack,
-		EnableCustomJs:         s.cfg.EnableCustomJs,
-		AuthLoginUrl:           s.cfg.AuthLoginUrl,
-		AuthLocalLogin:         s.cfg.AuthLocalUsers.Enabled,
-		AuthOAuth2Providers:    buildPublicOAuth2ProvidersList(s.cfg),
-		AdditionalLinks:        s.cfg.AdditionalNavigationLinks,
-		StyleMods:              s.cfg.StyleMods,
-	})
-
-	w.Header().Add("Content-Type", "application/json")
-	_, err := w.Write([]byte(jsonRet))
-
-	if err != nil {
-		log.Warnf("Could not write webui settings: %v", err)
-	}
-}
-
-func (s *webUIServer) handleCustomWebui() (http.Handler) {
+func (s *webUIServer) handleCustomWebui() http.Handler {
 	return http.StripPrefix("/custom-webui/", http.FileServer(http.Dir(s.findCustomWebuiDir())))
 }

+ 2 - 2
service/internal/installationinfo/buildinfo.go

@@ -1,9 +1,9 @@
 package installationinfo
 
-type buildInfo struct {
+type BuildInfo struct {
 	Commit  string
 	Version string
 	Date    string
 }
 
-var Build = &buildInfo{}
+var Build = &BuildInfo{}

+ 0 - 16
service/internal/installationinfo/init.go

@@ -1,16 +0,0 @@
-package installationinfo
-
-import (
-	"fmt"
-	sv "github.com/OliveTin/OliveTin/internal/stringvariables"
-)
-
-func init() {
-	sv.Set("OliveTin.build.commit", Build.Commit)
-	sv.Set("OliveTin.build.version", Build.Version)
-	sv.Set("OliveTin.build.date", Build.Date)
-	sv.Set("OliveTin.runtime.os", Runtime.OS)
-	sv.Set("OliveTin.runtime.os.pretty", Runtime.OSReleasePrettyName)
-	sv.Set("OliveTin.runtime.arch", Runtime.Arch)
-	sv.Set("OliveTin.runtime.incontainer", fmt.Sprintf("%v", Runtime.InContainer))
-}

+ 4 - 2
service/internal/installationinfo/runtimeinfo.go

@@ -10,7 +10,7 @@ import (
 	"strings"
 )
 
-type runtimeInfo struct {
+type RuntimeInfo struct {
 	OS                   string
 	OSReleasePrettyName  string
 	Arch                 string
@@ -21,9 +21,11 @@ type runtimeInfo struct {
 	SshFoundKey          string
 	SshFoundConfig       string
 	AvailableVersion     string
+	WebuiDirectory       string
+	ThemesDirectory      string
 }
 
-var Runtime = &runtimeInfo{
+var Runtime = &RuntimeInfo{
 	OS:                  runtime.GOOS,
 	Arch:                runtime.GOARCH,
 	InContainer:         isInContainer(),

+ 6 - 6
service/internal/installationinfo/sosreport.go

@@ -2,13 +2,15 @@ package installationinfo
 
 import (
 	"fmt"
+	"time"
+
 	config "github.com/OliveTin/OliveTin/internal/config"
-	sv "github.com/OliveTin/OliveTin/internal/stringvariables"
 	"gopkg.in/yaml.v3"
-	"time"
 )
 
-var Config *config.Config
+var (
+	Config *config.Config
+)
 
 type sosReportConfig struct {
 	CountOfActions                  int
@@ -22,7 +24,6 @@ type sosReportConfig struct {
 	TimeNow                         string
 	ConfigDirectory                 string
 	WebuiDirectory                  string
-	ThemesDirectory                 string
 }
 
 func configToSosreport(cfg *config.Config) *sosReportConfig {
@@ -37,8 +38,7 @@ func configToSosreport(cfg *config.Config) *sosReportConfig {
 		Timezone:                        time.Now().Location().String(),
 		TimeNow:                         time.Now().String(),
 		ConfigDirectory:                 cfg.GetDir(),
-		WebuiDirectory:                  sv.Get("internal.webuidir"),
-		ThemesDirectory:                 sv.Get("internal.themesdir"),
+		WebuiDirectory:                  cfg.WebUIDir,
 	}
 }
 

+ 0 - 59
service/internal/stringvariables/entities.go

@@ -1,59 +0,0 @@
-package stringvariables
-
-import (
-	"fmt"
-	"regexp"
-	"strconv"
-	"strings"
-	// log "github.com/sirupsen/logrus"
-)
-
-var r *regexp.Regexp
-
-func init() {
-	r = regexp.MustCompile(`{{ *?([a-zA-Z0-9_]+)\.([a-zA-Z0-9_\.]+) *?}}`)
-}
-
-func ReplaceEntityVars(prefix string, source string) string {
-	matches := r.FindAllStringSubmatch(source, -1)
-
-	for _, matches := range matches {
-		if len(matches) == 3 {
-			property := matches[2]
-
-			val := Get(prefix + "." + property)
-
-			source = strings.Replace(source, matches[0], val, 1)
-		}
-	}
-
-	return source
-}
-
-func GetEntities(entityTitle string) []string {
-	var ret []string
-
-	count := GetEntityCount(entityTitle)
-
-	for i := 0; i < count; i++ {
-		prefix := GetEntityPrefix(entityTitle, i)
-
-		ret = append(ret, prefix)
-	}
-
-	return ret
-}
-
-func GetEntityPrefix(entityTitle string, entityIndex int) string {
-	return "entities." + entityTitle + "." + fmt.Sprintf("%v", entityIndex)
-}
-
-func GetEntityCount(entityTitle string) int {
-	count, _ := strconv.Atoi(Get("entities." + entityTitle + ".count"))
-
-	return count
-}
-
-func SetEntityCount(entityTitle string, count int) {
-	Set("entities."+entityTitle+".count", fmt.Sprintf("%v", count))
-}

+ 0 - 12
service/internal/stringvariables/entities_test.go

@@ -1,12 +0,0 @@
-package stringvariables
-
-import (
-	"github.com/stretchr/testify/assert"
-	"testing"
-)
-
-func TestEntityCount(t *testing.T) {
-	SetEntityCount("waffles", 3)
-
-	assert.Equal(t, 3, GetEntityCount("waffles"))
-}

+ 0 - 76
service/internal/stringvariables/map.go

@@ -1,76 +0,0 @@
-/**
- * The ephemeralvariablemap is used "only" for variable substitution in config
- * titles, shell arguments, etc, in the foorm of {{ key }}, like Jinja2.
- *
- * OliveTin itself really only ever "writes" to this map, mostly by loading
- * EntityFiles, and the only form of "reading" is for the variable substitution
- * in configs.
- */
-
-package stringvariables
-
-import (
-	"github.com/prometheus/client_golang/prometheus"
-	"github.com/prometheus/client_golang/prometheus/promauto"
-	"strings"
-	"sync"
-)
-
-var (
-	contents map[string]string
-
-	metricSvCount = promauto.NewGauge(prometheus.GaugeOpts{
-		Name: "olivetin_sv_count",
-		Help: "The number entries in the sv map",
-	})
-
-	rwmutex = sync.RWMutex{}
-)
-
-func init() {
-	rwmutex.Lock()
-
-	contents = make(map[string]string)
-
-	rwmutex.Unlock()
-}
-
-func Get(key string) string {
-	rwmutex.RLock()
-
-	v, ok := contents[key]
-
-	rwmutex.RUnlock()
-
-	if !ok {
-		return ""
-	} else {
-		return v
-	}
-}
-
-func GetAll() map[string]string {
-	return contents
-}
-
-func Set(key string, value string) {
-	rwmutex.Lock()
-
-	contents[key] = value
-
-	metricSvCount.Set(float64(len(contents)))
-
-	rwmutex.Unlock()
-}
-
-func RemoveKeysThatStartWith(search string) {
-	rwmutex.Lock()
-
-	for k := range contents {
-		if strings.HasPrefix(k, search) {
-			delete(contents, k)
-		}
-	}
-
-	rwmutex.Unlock()
-}

+ 0 - 20
service/internal/stringvariables/map_test.go

@@ -1,20 +0,0 @@
-package stringvariables
-
-import (
-	"github.com/stretchr/testify/assert"
-	"testing"
-)
-
-func TestGetAndSet(t *testing.T) {
-	Set("foo", "bar")
-	Set("salutation", "hello")
-
-	assert.Equal(t, "bar", Get("foo"))
-	assert.Equal(t, "", Get("not exist"))
-}
-
-func TestGetall(t *testing.T) {
-	ret := GetAll()
-
-	assert.NotEmpty(t, ret)
-}

+ 7 - 6
service/main.go

@@ -5,7 +5,7 @@ import (
 
 	log "github.com/sirupsen/logrus"
 
-	"github.com/OliveTin/OliveTin/internal/entityfiles"
+	"github.com/OliveTin/OliveTin/internal/entities"
 	"github.com/OliveTin/OliveTin/internal/executor"
 	"github.com/OliveTin/OliveTin/internal/httpservers"
 	"github.com/OliveTin/OliveTin/internal/installationinfo"
@@ -17,11 +17,12 @@ import (
 	updatecheck "github.com/OliveTin/OliveTin/internal/updatecheck"
 	"github.com/OliveTin/OliveTin/internal/websocket"
 
+	"os"
+	"strconv"
+
 	config "github.com/OliveTin/OliveTin/internal/config"
 	"github.com/fsnotify/fsnotify"
 	"github.com/spf13/viper"
-	"os"
-	"strconv"
 )
 
 var (
@@ -172,9 +173,9 @@ func main() {
 	go onfileindir.WatchFilesInDirectory(cfg, executor)
 	go oncalendarfile.Schedule(cfg, executor)
 
-	entityfiles.AddListener(websocket.OnEntityChanged)
-	entityfiles.AddListener(executor.RebuildActionMap)
-	go entityfiles.SetupEntityFileWatchers(cfg)
+	entities.AddListener(websocket.OnEntityChanged)
+	entities.AddListener(executor.RebuildActionMap)
+	go entities.SetupEntityFileWatchers(cfg)
 
 	go updatecheck.StartUpdateChecker(cfg)
 

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff