Procházet zdrojové kódy

feature: The mega dashboards & entities commit.

jamesread před 2 roky
rodič
revize
381bf59fbd
39 změnil soubory, kde provedl 927 přidání a 355 odebrání
  1. 2 1
      Makefile
  2. 52 23
      OliveTin.proto
  3. 3 0
      cmd/OliveTin/main.go
  4. 173 55
      config.yaml
  5. 1 1
      integration-tests/runner.mjs
  6. 11 9
      integration-tests/test/general.mjs
  7. 6 5
      integration-tests/test/hiddenFooter.mjs
  8. 2 2
      integration-tests/test/hiddenNav.mjs
  9. 10 2
      integration-tests/test/multipleDropdowns.js
  10. 6 2
      internal/acl/acl.go
  11. 15 20
      internal/config/config.go
  12. 2 2
      internal/config/config_helpers.go
  13. 3 3
      internal/config/config_helpers_test.go
  14. 15 0
      internal/config/sanitize.go
  15. 1 1
      internal/config/sanitize_test.go
  16. 162 0
      internal/entityfiles/entityfiles.go
  17. 6 3
      internal/executor/arguments.go
  18. 2 2
      internal/executor/arguments_test.go
  19. 68 67
      internal/executor/executor.go
  20. 9 9
      internal/executor/executor_test.go
  21. 90 68
      internal/grpcapi/grpcApi.go
  22. 71 14
      internal/grpcapi/grpcApiActions.go
  23. 26 17
      internal/grpcapi/grpcApiDashboard.go
  24. 56 0
      internal/grpcapi/grpcApiDashboardEntities.go
  25. 3 2
      internal/grpcapi/grpcApi_test.go
  26. 16 0
      internal/installationinfo/init.go
  27. 4 4
      internal/oncron/cron.go
  28. 7 6
      internal/onfileindir/fileindir.go
  29. 1 1
      internal/onstartup/startup.go
  30. 29 0
      internal/stringvariables/entities.go
  31. 26 0
      internal/stringvariables/map.go
  32. 14 13
      internal/websocket/websocket.go
  33. 2 0
      var/entities/containers.json
  34. 0 6
      var/entities/containers.yaml
  35. 12 0
      var/entities/servers.yaml
  36. 2 2
      webui.dev/index.html
  37. 6 12
      webui.dev/js/ActionButton.js
  38. 12 2
      webui.dev/js/marshaller.js
  39. 1 1
      webui.dev/style.css

+ 2 - 1
Makefile

@@ -68,8 +68,9 @@ webui-codestyle:
 	cd webui.dev && ./node_modules/.bin/stylelint style.css
 
 webui-dist:
+	rm -rf webui webui.dev/dist
 	cd webui.dev && npm install
-	cd webui.dev && parcel build --dist-dir ../webui/
+	cd webui.dev && parcel build && mv dist ../webui
 
 clean:
 	rm -rf dist OliveTin OliveTin.armhf OliveTin.exe reports gen

+ 52 - 23
OliveTin.proto

@@ -39,24 +39,23 @@ message GetDashboardComponentsResponse {
 	string title = 1;
 	repeated Action actions = 2;
 	repeated Entity entities = 3;
-	repeated DashboardItem dashboards = 4;
+	repeated DashboardComponent dashboards = 4;
 }
 
 message GetDashboardComponentsRequest {}
 
-message DashboardItem {
+message DashboardComponent {
 	string title = 1;
 	string type = 2;
-	repeated DashboardItem contents = 3;
-	string link = 4;
+	repeated DashboardComponent contents = 3;
 }
 
 message StartActionRequest {
-	string action_name = 1;
+	string action_id = 1;
 
 	repeated StartActionArgument arguments = 2;
 
-	string uuid = 3;
+	string unique_tracking_id = 3;
 }
 
 message StartActionArgument {
@@ -65,30 +64,30 @@ message StartActionArgument {
 }
 
 message StartActionResponse {
-	string execution_uuid = 2;
+	string execution_tracking_id = 2;
 }
 
 message StartActionAndWaitRequest {
-	string action_name = 1;
+	string action_id = 1;
 }
 
 message StartActionAndWaitResponse {
 	LogEntry log_entry = 1;
 }
 
-message StartActionByAliasRequest {
-	string action_alias = 1;
+message StartActionByGetRequest {
+	string action_id = 1;
 }
 
-message StartActionByAliasResponse {
-	string execution_uuid = 2;
+message StartActionByGetResponse {
+	string execution_tracking_id = 2;
 }
 
-message StartActionByAliasAndWaitRequest {
-	string action_alias = 1;
+message StartActionByGetAndWaitRequest {
+	string action_id = 1;
 }
 
-message StartActionByAliasAndWaitResponse {
+message StartActionByGetAndWaitResponse {
 	LogEntry log_entry = 1;
 }
 
@@ -105,9 +104,9 @@ message LogEntry {
 	string user_class = 8;
 	string action_icon = 9;
 	repeated string tags = 10;
-	string execution_uuid = 11;
+	string execution_tracking_id = 11;
 	string datetime_finished = 12;
-	string uuid = 13;
+	string action_id = 13;
 	bool execution_started = 14;
 	bool execution_finished = 15;
 	bool blocked = 16;
@@ -128,7 +127,7 @@ message ValidateArgumentTypeResponse {
 }
 
 message WatchExecutionRequest {
-	string execution_uuid = 1;
+	string execution_tracking_id = 1;
 }
 
 message WatchExecutionUpdate {
@@ -136,7 +135,7 @@ message WatchExecutionUpdate {
 }
 
 message ExecutionStatusRequest {
-	string execution_uuid = 1;
+	string execution_tracking_id = 1;
 }
 
 message ExecutionStatusResponse {
@@ -155,6 +154,24 @@ message SosReportResponse {
 	string alert = 1;
 }
 
+message DumpVarsRequest {}
+
+message DumpVarsResponse {
+	string alert = 1;
+	map<string, string> contents = 2;
+}
+
+message ActionEntityPair {
+	string action_title = 1;
+	string entity_prefix = 2;
+}
+
+message DumpPublicIdActionMapRequest {}
+message DumpPublicIdActionMapResponse {
+	string alert = 1;
+	map<string, ActionEntityPair> contents = 2;
+}
+
 message GetReadyzRequest {}
 
 message GetReadyzResponse {
@@ -182,15 +199,15 @@ service OliveTinApiService {
 		};
 	}
 
-	rpc StartActionByAlias(StartActionByAliasRequest) returns (StartActionByAliasResponse) {
+	rpc StartActionByGet(StartActionByGetRequest) returns (StartActionByGetResponse) {
 		option (google.api.http) = {
-			get: "/api/StartActionByAlias/{action_alias}"
+			get: "/api/StartActionByGet/{action_id}"
 		};
 	}
 
-	rpc StartActionByAliasAndWait(StartActionByAliasAndWaitRequest) returns (StartActionByAliasAndWaitResponse) {
+	rpc StartActionByGetAndWait(StartActionByGetAndWaitRequest) returns (StartActionByGetAndWaitResponse) {
 		option (google.api.http) = {
-			get: "/api/StartActionByAliasAndWait/{action_alias}"
+			get: "/api/StartActionByGetAndWait/{action_id}"
 		};
 	}
 
@@ -226,6 +243,18 @@ service OliveTinApiService {
 		};
 	}
 
+	rpc DumpVars(DumpVarsRequest) returns (DumpVarsResponse) {
+		option (google.api.http) = {
+			get: "/api/DumpVars"
+		};
+	}
+
+	rpc DumpPublicIdActionMap(DumpPublicIdActionMapRequest) returns (DumpPublicIdActionMapResponse) {
+		option (google.api.http) = {
+			get: "/api/DumpPublicIdActionMap"
+		};
+	}
+
 	rpc GetReadyz(GetReadyzRequest) returns (GetReadyzResponse) {
 		option (google.api.http) = {
 			get: "/api/readyz"

+ 3 - 0
cmd/OliveTin/main.go

@@ -5,6 +5,7 @@ import (
 
 	log "github.com/sirupsen/logrus"
 
+	"github.com/OliveTin/OliveTin/internal/entityfiles"
 	"github.com/OliveTin/OliveTin/internal/executor"
 	grpcapi "github.com/OliveTin/OliveTin/internal/grpcapi"
 	"github.com/OliveTin/OliveTin/internal/installationinfo"
@@ -153,6 +154,8 @@ func main() {
 	go oncron.Schedule(cfg, executor)
 	go onfileindir.WatchFilesInDirectory(cfg, executor)
 
+	go entityfiles.SetupEntityFileWatchers(cfg)
+
 	go updatecheck.StartUpdateChecker(version, commit, cfg, configDir)
 
 	go grpcapi.Start(cfg, executor)

+ 173 - 55
config.yaml

@@ -10,23 +10,41 @@ logLevel: "INFO"
 
 # Actions (buttons) to show up on the WebUI:
 actions:
+  # This is the most simple action, it just runs the command and flashes the
+  # button to indicate status.
+  #
+  # If you are running OliveTin in a container remember to pass through the
+  # docker socket! https://docs.olivetin.app/action-container-control.html
   - title: Restart media container
     shell: docker restart mediacontainer
     icon: box
-    entity: container
 
+  # This uses `popupOnStart: execution-dialog-stdout-only` to simply show just
+  # the command output.
   - title: Check disk space
     icon: disk
     shell: df -h /media
     popupOnStart: execution-dialog-stdout-only
 
+  # This uses `popupOnStart: execution-dialog` to show a dialog with more
+  # information about the command that was run.
   - title: check dmesg logs
     shell: dmesg | tail
     icon: logs
     popupOnStart: execution-dialog
 
+  # This uses `popupOnStart: execution-button` to display a mini button that
+  # links to the logs.
+  - title: date
+    shell: date
+    timeout: 6
+    icon: clock
+    popupOnStart: execution-button
 
-    # This will run a simple script that you create.
+  # You are not limited to operating system commands, and of course you can run
+  # your own scripts. Here `maxConcurrent` stops the script running multiple
+  # times in parallel. There is also a timeout that will kill the command if it
+  # runs for too long.
   - title: Run backup script
     shell: /opt/backupScript.sh
     shellAfterCompleted: "apprise -t 'Notification: Backup script completed' -b 'The backup script completed with code {{ exitCode}}. The log is: \n {{ stdout }} '"
@@ -35,14 +53,10 @@ actions:
     icon: backup
     popupOnStart: execution-dialog
 
-  - title: date
-    shell: date
-    timeout: 6
-    icon: clock
-    popupOnStart: execution-button
-
-    # This will send 1 ping (-c 1)
-    # Docs: https://docs.olivetin.app/action-ping.html
+  # When you want to prompt users for input, that is when you should use
+  # `arguments` - this presents a popup dialog and asks for argument values.
+  #
+  # Docs: https://docs.olivetin.app/action-ping.html
   - title: Ping host
     shell: ping {{ host }} -c {{ count }}
     icon: ping
@@ -60,31 +74,12 @@ actions:
         default: 1
         description: How many times to do you want to ping?
 
-    # OliveTin can run long-running jobs like Ansible playbooks.
-    #
-    # For such jobs, you will need to install ansible-playbook on the host where
-    # you are running OliveTin, or in the container.
-    #
-    # You probably want a much longer timeout as well (so that ansible completes).
-  - title: "Run Ansible Playbook"
-    icon: "&#x1F1E6"
-    shell: ansible-playbook -i /etc/hosts /root/myRepo/myPlaybook.yaml
-    timeout: 120
-
-    # Restart httpd on host "server1"
-    # Docs: https://docs.olivetin.app/action-ping.html
-  - title: restart httpd on server1
-    titleAlias: restart_httpd
-    icon: restart
-    timeout: 1
-    shell: ssh root@server1 'service httpd restart'
-
-    # OliveTin can control containers - docker is just a command line app.
-    #
-    # However, if you are running in a container you will need to do some setup,
-    # see the docs below.
-    #
-    # Docs: https://docs.olivetin.app/action-container-control.html
+  # OliveTin can control containers - docker is just a command line app.
+  #
+  # However, if you are running in a container you will need to do some setup,
+  # see the docs below.
+  #
+  # Docs: https://docs.olivetin.app/action-container-control.html
   - title: Restart Docker Container
     icon: restart
     shell: docker restart {{ container }}
@@ -96,6 +91,10 @@ actions:
           - value: traefik
           - value: grafana
 
+  # There is a special `confirmation` argument to help against accidental clicks
+  # on "dangerous" actions.
+  #
+  # Docs: https://docs.olivetin.app/confirmation.html
   - title: Delete old backups
     icon: ashtonished
     shell: rm -rf /opt/oldBackups/
@@ -103,35 +102,154 @@ actions:
       - type: confirmation
         title: Are you sure?!
 
-  - title: Server1 Power Off
-    shell: echo "Power Off Server 1"
 
-  - title: Server2 Wake On LAN
-    shell: echo "Sending Wake on LAN to Server 2"
+  # Sometimes you want to run actions on other servers - don't overcomplicate
+  # it, just use SSH!
+  #
+  # Docs: https://docs.olivetin.app/action-ssh.html
+  # Docs: https://docs.olivetin.app/action-service.html
+  - title: restart httpd on server1
+    id: restart_httpd
+    icon: restart
+    timeout: 1
+    shell: ssh root@server1 'service httpd restart'
+
+  # There are several built-in shortcuts for the `icon` option, but you
+  # can also just specify any HTML, this includes any unicode character,
+  # or a <img = "..." /> link to a custom icon.
+  #
+  # Docs: https://docs.olivetin.app/icons.html
+  #
+  # Lots of people use OliveTin to easily execute ansible-playbooks. You
+  # probably want a much longer timeout as well (so that ansible completes).
+  #
+  # Docs: https://docs.olivetin.app/ansible-playbook.html
+  - title: "Run Ansible Playbook"
+    icon: '&#x1F1E6;'
+    shell: ansible-playbook -i /etc/hosts /root/myRepo/myPlaybook.yaml
+    timeout: 120
+
+  # The following actions are "dummy" actions, used in a Dashboard. As long as
+  # you have these referenced in a dashboard, they will not who up in the
+  # `actions` view.
+  - title: Ping hypervisor1
+    shell: echo "hypervisor1 online"
 
-  - title: Server2 Power Off
-    shell: echo "Power Off Server 2"
+  - title: Ping hypervisor2
+    shell: echo "hypervisor2 online"
+
+  - title: "{{ server.name }} Wake on Lan"
+    shell: echo "Sending Wake on LAN to {{ server.hostname }}"
+    entity: server
+
+  - title: "{{ server.name }} Power Off"
+    shell: "echo 'Power Off Server: {{ server.hostname }}'"
+    entity: server
 
   - title: Ping All Servers
-    shell: echo "Ping all servers"
+    shell: "echo 'Ping all servers'"
     icon: ping
 
+  - title: Start {{ container.Names }}
+    icon: box
+    shell: docker start {{ container.Names }}
+    entity: container
+
+  - title: Stop {{ container.Names }}
+    icon: box
+    shell: docker stop {{ container.Names }}
+    entity: container
+
+  # Lastly, you can hide actions from the web UI, this is useful for creating
+  # background helpers that execute only on startup or a cron, for updating
+  # entity files.
+
+  # - title: Update container entity file
+  #   shell: 'docker ps -a --format json > /etc/OliveTin/containers.json'
+  #   hidden: true
+  #   execOnStartup: true
+  #   execOnCron: '*/1 * * * *'
+
+# An entity is something that exists - a "thing", like a VM, or a Container
+# is an entity. OliveTin allows you to then dynamically generate actions based
+# around these entities.
+#
+# This is really useful if you want to generate wake on lan or poweroff actions
+# for `server` entities, for example.
+#
+# A very popular use case that entities were designed for was for `container`
+# entities - in a similar way you could generate `start`, `stop`, and `restart`
+# container actions.
+#
+# Entities are just loaded fome files on disk, OliveTin will also watch these
+# files for updates while OliveTin is running, and update entities.
+#
+# Entities can have properties defined in those files, and those can be used
+# in your configuration as variables. For example; `container.status`,
+# or `vm.hostname`.
+#
+# Docs: http://docs.olivetin.app/entities.html
+entities:
+  # YAML files are the default expected format, so you can use .yml or .yaml,
+  # or even .txt, as long as the file contains valid a valid yaml LIST, then it
+  # will load properly.
+  #
+  # Docs: https://docs.olivetin.app/entities.html
+  - file: /etc/OliveTin/servers.yaml
+    name: server
+
+  - file: /etc/OliveTin/containers.json
+    name: container
+
+# Dashboards are a way of taking actions from the default "actions" view, and
+# organizing them into groups - either into folders, or fieldsets.
+#
+# The only way to properly use entities, are to use them with a `fieldset` on
+# a dashboard.
 dashboards:
+  # Top level items are dsahboards.
   - title: My Servers
     contents:
-      - title: Server Power Controls
-        type: fieldset
-        contents:
-          - title: Server 1
-            contents:
-              - link: Server1 Power Off
-
-          - title: Server 2
-            contents:
-              - link: Server2 Wake On LAN
-              - link: Server2 Power Off
+      # The contents of a dashboard will try to look for an action with a
+      # matching title IF the `contents: ` property is empty.
+      - title: Ping All Servers
 
-      - title: Server Utilities
+      # If you create an item with some "contents:", OliveTin will show that as
+      # directory.
+      - title: Hypervisors
+        contents:
+          - title: Ping hypervisor1
+          - title: Ping hypervisor2
+
+      # If you specify `type: fieldset` and some `contents`, it will show your
+      # actions grouped together without a folder.
+      - type: fieldset
+        entity: server
+        title: 'Server: {{ server.hostname }}'
+        contents:
+          # By default OliveTin will look for an action with a matching title
+          # and put it on the dashboard.
+          #
+          # Fieldsets  also support `type: display`, which can display arbitary
+          # text. This is useful for displaying things like a container's state.
+          - type: display
+            title: |
+              Hostname: <strong>{{ server.name }}</strong>
+              IP Address: <strong>{{ server.ip }}</strong>
+
+          # These are the actions (defined above) that we want on the dashboard.
+          - title: '{{ server.name }} Wake on Lan'
+          - title: '{{ server.name }} Power Off'
+
+  # This is the second dashboard.
+  - title: My Containers
+    contents:
+      - title: Container {{ container.Names }}
+        entity: container
         type: fieldset
         contents:
-          - link: Ping All Servers
+          - type: display
+            title: |
+              {{ container.Names }} <br /><br /><strong>{{ container.State }}</strong>
+          - title: 'Start {{ container.Names }}'
+          - title: 'Stop {{ container.Names }}'

+ 1 - 1
integration-tests/runner.mjs

@@ -1,4 +1,4 @@
-import process from 'node:process'
+import * as process from 'node:process'
 import waitOn from 'wait-on'
 import { spawn } from 'node:child_process'
 

+ 11 - 9
integration-tests/test/general.mjs

@@ -1,34 +1,36 @@
-import {expect} from 'chai';
-import {By} from 'selenium-webdriver';
+import { describe, it, before, after } from 'mocha'
+import { expect } from 'chai'
+import { By } from 'selenium-webdriver'
+//import * as waitOn from 'wait-on'
 
 describe('config: general', function () {
   before(async function () {
     await runner.start('general')
-  });
+  })
 
   after(async () => {
     await runner.stop()
-  });
+  })
 
   it('Page title', async function () {
     await webdriver.get(runner.baseUrl())
 
-    let title = await webdriver.getTitle();
+    const title = await webdriver.getTitle()
     expect(title).to.be.equal("OliveTin")
   })
 
   it('Footer contains promo', async function () {
-    let ftr = await webdriver.findElement(By.tagName('footer')).getText()
+    const ftr = await webdriver.findElement(By.tagName('footer')).getText()
 
-    expect(ftr).to.contain("Documentation")
+    expect(ftr).to.contain('Documentation')
   })
 
   it('Default buttons are rendered', async function() {
     await webdriver.get(runner.baseUrl())
 
-//    await webdriver.manage().setTimeouts({ implicit: 2000 });
+    // await webdriver.manage().setTimeouts({ implicit: 2000 })
 
-    let buttons = await webdriver.findElement(By.id('root-group')).findElements(By.tagName('button'))
+    const buttons = await webdriver.findElement(By.id('root-group')).findElements(By.tagName('button'))
 
     expect(buttons).to.have.length(6)
   })

+ 6 - 5
integration-tests/test/hiddenFooter.mjs

@@ -1,20 +1,21 @@
-import { expect } from 'chai';
+import { describe, it, before, after } from 'mocha'
+import { expect } from 'chai'
 
-import { By } from 'selenium-webdriver';
+import { By } from 'selenium-webdriver'
 
 describe('config: hiddenFooter', function () {
   before(async function () {
     await runner.start('hiddenFooter')
-  });
+  })
 
   after(async () => {
     await runner.stop()
-  });
+  })
 
   it('Check that footer is hidden', async () => {
     await webdriver.get(runner.baseUrl())
 
-    let footer = await webdriver.findElement(By.tagName('footer'))
+    const footer = await webdriver.findElement(By.tagName('footer'))
 
     expect(await footer.isDisplayed()).to.be.false
   })

+ 2 - 2
integration-tests/test/hiddenNav.mjs

@@ -1,5 +1,5 @@
-import {expect} from 'chai';
-import {By} from 'selenium-webdriver';
+import { expect } from 'chai'
+import { By } from 'selenium-webdriver'
 
 describe('config: hiddenNav', function () {
   before(async function () {

+ 10 - 2
integration-tests/test/multipleDropdowns.js

@@ -1,3 +1,4 @@
+import { describe, before, after } from 'mocha'
 import { expect } from 'chai'
 import { By, until } from 'selenium-webdriver'
 
@@ -14,9 +15,16 @@ describe('config: multipleDropdowns', function () {
     await webdriver.get(runner.baseUrl())
     await webdriver.manage().setTimeouts({ implicit: 2000 })
 
-    const button = await webdriver.findElement(By.id('actionButton-bdc45101bbd12c1397557790d9f3e059')).findElement(By.tagName('button'))
+    const buttons = await webdriver.findElements(By.tagName('button'))
+    let button = null
 
-    expect(button).to.not.be.undefined
+    for (const b of buttons) {
+      if (await b.getAttribute('title') === 'Test multiple dropdowns') {
+        button = b
+      }
+    }
+
+    expect(button).to.not.be.null
 
     await button.click()
 

+ 6 - 2
internal/acl/acl.go

@@ -41,6 +41,10 @@ func IsAllowedExec(cfg *config.Config, user *AuthenticatedUser, action *config.A
 
 // IsAllowedView checks if a User is allowed to view an Action
 func IsAllowedView(cfg *config.Config, user *AuthenticatedUser, action *config.Action) bool {
+	if action.Hidden {
+		return false
+	}
+
 	for _, acl := range getRelevantAcls(cfg, action.Acls, user) {
 		if acl.Permissions.View {
 			log.WithFields(log.Fields{
@@ -110,7 +114,7 @@ func buildUserAcls(cfg *config.Config, user *AuthenticatedUser) {
 	}
 }
 
-func isACLRelevantToAction(cfg *config.Config, actionAcls []string, acl config.AccessControlList, user *AuthenticatedUser) bool {
+func isACLRelevantToAction(cfg *config.Config, actionAcls []string, acl *config.AccessControlList, user *AuthenticatedUser) bool {
 	if !slices.Contains(user.acls, acl.Name) {
 		// If the user does not have this ACL, then it is not relevant
 
@@ -133,7 +137,7 @@ func getRelevantAcls(cfg *config.Config, actionAcls []string, user *Authenticate
 
 	for _, acl := range cfg.AccessControlLists {
 		if isACLRelevantToAction(cfg, actionAcls, acl, user) {
-			ret = append(ret, &acl)
+			ret = append(ret, acl)
 		}
 	}
 

+ 15 - 20
internal/config/config.go

@@ -5,14 +5,14 @@ package config
 type Action struct {
 	ID                     string
 	Title                  string
-	TitleAlias             string
 	Icon                   string
 	Shell                  string
 	ShellAfterCompleted    string
 	CSS                    map[string]string `mapstructure:"omitempty"`
 	Timeout                int
 	Acls                   []string
-	Entity                 []string
+	Entity                 string
+	Hidden                 bool
 	ExecOnStartup          bool
 	ExecOnCron             []string
 	ExecOnFileCreatedInDir []string
@@ -38,16 +38,6 @@ type ActionArgumentChoice struct {
 	Title string
 }
 
-// HelperAction is an action that is used normally to generate entities, it cannot be started manually.
-type HelperAction struct {
-	Title                  string
-	Shell                  string
-	ExecOnStartup          bool
-	ExecOnCron             []string
-	ExecOnFileCreatedInDir []string
-	ExecOnFileChangedInDir []string
-}
-
 // Entity represents a "thing" that can have multiple actions associated with it.
 // for example, a media player with a start and stop action.
 type EntityFile struct {
@@ -82,9 +72,9 @@ type Config struct {
 	ListenAddressGrpcActions        string
 	ExternalRestAddress             string
 	LogLevel                        string
-	Actions                         []Action        `mapstructure:"actions"`
-	Entities                        []EntityFile    `mapstructure:"entities"`
-	Dashboards                      []DashboardItem `mapstructure:"dashboards"`
+	Actions                         []*Action             `mapstructure:"actions"`
+	Entities                        []*EntityFile         `mapstructure:"entities"`
+	Dashboards                      []*DashboardComponent `mapstructure:"dashboards"`
 	CheckForUpdates                 bool
 	PageTitle                       string
 	ShowFooter                      bool
@@ -98,19 +88,21 @@ type Config struct {
 	AuthHttpHeaderUsername          string
 	AuthHttpHeaderUserGroup         string
 	DefaultPermissions              PermissionsList
-	AccessControlLists              []AccessControlList
+	AccessControlLists              []*AccessControlList
 	WebUIDir                        string
-	HelperActions                   []HelperAction `mapstructure:"helperActions"`
 	CronSupportForSeconds           bool
 	SectionNavigationStyle          string
 	DefaultPopupOnStart             string
+	InsecureAllowDumpVars           bool
+	InsecureAllowDumpSos            bool
+	InsecureAllowDumpActionMap      bool
 }
 
-type DashboardItem struct {
+type DashboardComponent struct {
 	Title    string
 	Type     string
-	Link     string
-	Contents []DashboardItem
+	Entity   string
+	Contents []DashboardComponent
 }
 
 // DefaultConfig gets a new Config structure with sensible default values.
@@ -136,6 +128,9 @@ func DefaultConfig() *Config {
 	config.CronSupportForSeconds = false
 	config.SectionNavigationStyle = "sidebar"
 	config.DefaultPopupOnStart = "nothing"
+	config.InsecureAllowDumpVars = false
+	config.InsecureAllowDumpSos = false
+	config.InsecureAllowDumpActionMap = false
 
 	return &config
 }

+ 2 - 2
internal/config/config_helpers.go

@@ -4,7 +4,7 @@ package config
 func (cfg *Config) FindAction(actionTitle string) *Action {
 	for _, action := range cfg.Actions {
 		if action.Title == actionTitle {
-			return &action
+			return action
 		}
 	}
 
@@ -36,7 +36,7 @@ func (action *Action) findArg(name string) *ActionArgument {
 func (cfg *Config) FindAcl(aclTitle string) *AccessControlList {
 	for _, acl := range cfg.AccessControlLists {
 		if acl.Name == aclTitle {
-			return &acl
+			return acl
 		}
 	}
 

+ 3 - 3
internal/config/config_helpers_test.go

@@ -8,11 +8,11 @@ import (
 func TestFindAction(t *testing.T) {
 	c := DefaultConfig()
 
-	a1 := Action{}
+	a1 := &Action{}
 	a1.Title = "a1"
 	c.Actions = append(c.Actions, a1)
 
-	a2 := Action{
+	a2 := &Action{
 		Title: "a2",
 		Arguments: []ActionArgument{
 			{
@@ -35,7 +35,7 @@ func TestFindAction(t *testing.T) {
 func TestFindAcl(t *testing.T) {
 	c := DefaultConfig()
 
-	acl1 := AccessControlList{
+	acl1 := &AccessControlList{
 		Name: "Testing ACL",
 	}
 

+ 15 - 0
internal/config/sanitize.go

@@ -1,7 +1,9 @@
 package config
 
 import (
+	"github.com/google/uuid"
 	log "github.com/sirupsen/logrus"
+	"strings"
 )
 
 // Sanitize will look for common configuration issues, and fix them. For example,
@@ -28,6 +30,7 @@ func (action *Action) sanitize(cfg *Config) {
 		action.Timeout = 3
 	}
 
+	action.ID = getActionID(action)
 	action.Icon = lookupHTMLIcon(action.Icon)
 	action.PopupOnStart = sanitizePopupOnStart(action.PopupOnStart, cfg)
 
@@ -40,6 +43,18 @@ func (action *Action) sanitize(cfg *Config) {
 	}
 }
 
+func getActionID(action *Action) string {
+	if action.ID == "" {
+		return uuid.NewString()
+	}
+
+	if strings.Contains(action.ID, "{{") {
+		log.Fatalf("Action IDs cannot contain variables")
+	}
+
+	return action.ID
+}
+
 func sanitizePopupOnStart(raw string, cfg *Config) string {
 	switch raw {
 	case "execution-dialog":

+ 1 - 1
internal/config/sanitize_test.go

@@ -8,7 +8,7 @@ import (
 func TestSanitizeConfig(t *testing.T) {
 	c := DefaultConfig()
 
-	a := Action{
+	a := &Action{
 		Title: "Mr Waffles",
 		Arguments: []ActionArgument{
 			{

+ 162 - 0
internal/entityfiles/entityfiles.go

@@ -0,0 +1,162 @@
+package entityfiles
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	config "github.com/OliveTin/OliveTin/internal/config"
+	sv "github.com/OliveTin/OliveTin/internal/stringvariables"
+	"github.com/fsnotify/fsnotify"
+	log "github.com/sirupsen/logrus"
+	"gopkg.in/yaml.v3"
+	"io/ioutil"
+	"strings"
+)
+
+func SetupEntityFileWatchers(cfg *config.Config) {
+	for _, ef := range cfg.Entities {
+		go watch(ef.File, ef.Name)
+		loadEntityFile(ef.File, ef.Name)
+	}
+}
+
+func watch(file string, entityname string) {
+	log.WithFields(log.Fields{
+		"file": file,
+		"name": entityname,
+	}).Infof("Watching entity file")
+
+	watcher, err := fsnotify.NewWatcher()
+
+	if err != nil {
+		log.Errorf("Could not watch entity file: %v", err)
+		return
+	}
+
+	defer watcher.Close()
+
+	done := make(chan bool)
+
+	go func() {
+		for {
+			processEvent(watcher, file, entityname)
+		}
+	}()
+
+	err = watcher.Add(file)
+
+	if err != nil {
+		log.WithFields(log.Fields{
+			"file": file,
+		}).Errorf("Could not create entity watcher: %v", err)
+	}
+
+	<-done
+}
+
+func processEvent(watcher *fsnotify.Watcher, filename string, entityname string) {
+	select {
+	case event, ok := <-watcher.Events:
+		if !ok {
+			return
+		}
+
+		loadEntityFileIfWritten(&event, filename, entityname)
+
+		return
+	case err := <-watcher.Errors:
+		log.Errorf("Error in fsnotify: %v", err)
+		return
+	}
+}
+
+func loadEntityFileIfWritten(event *fsnotify.Event, filename string, entityname string) {
+	if event.Has(fsnotify.Remove) {
+		log.WithFields(log.Fields{
+			"file": filename,
+		}).Warnf("Entity file deleted! Will no longer be able to watch for changes!")
+	}
+
+	if event.Has(fsnotify.Write) {
+		loadEntityFile(filename, entityname)
+	}
+}
+
+func loadEntityFile(filename string, entityname string) {
+	if strings.HasSuffix(filename, ".json") {
+		loadEntityFileJson(filename, entityname)
+	} else {
+		loadEntityFileYaml(filename, entityname)
+	}
+}
+
+func loadEntityFileJson(filename string, entityname string) {
+	log.WithFields(log.Fields{
+		"file": filename,
+		"name": entityname,
+	}).Infof("Loading entity file with JSON format")
+
+	jfile, err := ioutil.ReadFile(filename)
+
+	if err != nil {
+		log.Errorf("ReadIn: %v", err)
+		return
+	}
+
+	data := make([]map[string]string, 0)
+
+	decoder := json.NewDecoder(bytes.NewReader(jfile))
+
+	for decoder.More() {
+		d := make(map[string]string)
+
+		err := decoder.Decode(&d)
+
+		if err != nil {
+			log.Errorf("%v", err)
+			return
+		}
+
+		data = append(data, d)
+	}
+
+	updateEvmFromFile(entityname, data)
+}
+
+func loadEntityFileYaml(filename string, entityname string) {
+	log.WithFields(log.Fields{
+		"file": filename,
+		"name": entityname,
+	}).Infof("Loading entity file with YAML format")
+
+	yfile, err := ioutil.ReadFile(filename)
+
+	if err != nil {
+		log.Errorf("ReadIn: %v", err)
+		return
+	}
+
+	data := make([]map[string]string, 1)
+
+	err = yaml.Unmarshal(yfile, &data)
+
+	if err != nil {
+		log.Errorf("Unmarshal: %v", err)
+	}
+
+	updateEvmFromFile(entityname, data)
+}
+
+func updateEvmFromFile(entityname string, data []map[string]string) {
+	count := len(data)
+
+	sv.Contents["entities."+entityname+".count"] = fmt.Sprintf("%v", count)
+
+	for i, mapp := range data {
+		prefix := "entities." + entityname + "." + fmt.Sprintf("%v", i)
+
+		for k, v := range mapp {
+			sv.Contents[prefix+"."+k] = v
+		}
+	}
+}

+ 6 - 3
internal/executor/arguments.go

@@ -2,6 +2,7 @@ package executor
 
 import (
 	config "github.com/OliveTin/OliveTin/internal/config"
+	sv "github.com/OliveTin/OliveTin/internal/stringvariables"
 	log "github.com/sirupsen/logrus"
 
 	"errors"
@@ -20,9 +21,9 @@ var (
 	}
 )
 
-func parseActionArguments(rawShellCommand string, values map[string]string, action *config.Action) (string, error) {
+func parseActionArguments(rawShellCommand string, values map[string]string, action *config.Action, actionTitle string, entityPrefix string) (string, error) {
 	log.WithFields(log.Fields{
-		"actionTitle": action.Title,
+		"actionTitle": actionTitle,
 		"cmd":         rawShellCommand,
 	}).Infof("Action parse args - Before")
 
@@ -51,8 +52,10 @@ func parseActionArguments(rawShellCommand string, values map[string]string, acti
 		rawShellCommand = strings.ReplaceAll(rawShellCommand, match[0], argValue)
 	}
 
+	rawShellCommand = sv.ReplaceEntityVars(entityPrefix, rawShellCommand)
+
 	log.WithFields(log.Fields{
-		"actionTitle": action.Title,
+		"actionTitle": actionTitle,
 		"cmd":         rawShellCommand,
 	}).Infof("Action parse args - After")
 

+ 2 - 2
internal/executor/arguments_test.go

@@ -33,7 +33,7 @@ func TestArgumentNameNumbers(t *testing.T) {
 		"person1name": "Fred",
 	}
 
-	out, err := parseActionArguments(a1.Shell, values, &a1)
+	out, err := parseActionArguments(a1.Shell, values, &a1, a1.Title, "")
 
 	assert.Equal(t, "echo 'Tickling Fred'", out)
 	assert.Nil(t, err)
@@ -53,7 +53,7 @@ func TestArgumentNotProvided(t *testing.T) {
 
 	values := map[string]string{}
 
-	out, err := parseActionArguments(a1.Shell, values, &a1)
+	out, err := parseActionArguments(a1.Shell, values, &a1, a1.Title, "")
 
 	assert.Equal(t, "", out)
 	assert.Equal(t, err.Error(), "Required arg not provided: personName")

+ 68 - 67
internal/executor/executor.go

@@ -3,6 +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"
 	log "github.com/sirupsen/logrus"
 
 	"bytes"
@@ -28,13 +29,15 @@ type Executor struct {
 // ExecutionRequest is a request to execute an action. It's passed to an
 // Executor. They're created from the grpcapi.
 type ExecutionRequest struct {
-	ActionName         string
-	Action             *config.Action
-	Arguments          map[string]string
-	UUID               string
-	Tags               []string
-	Cfg                *config.Config
-	AuthenticatedUser  *acl.AuthenticatedUser
+	ActionTitle       string
+	Action            *config.Action
+	Arguments         map[string]string
+	TrackingID        string
+	Tags              []string
+	Cfg               *config.Config
+	AuthenticatedUser *acl.AuthenticatedUser
+	EntityPrefix      string
+
 	logEntry           *InternalLogEntry
 	finalParsedCommand string
 	executor           *Executor
@@ -44,18 +47,19 @@ type ExecutionRequest struct {
 // state of execution (even if the command is not executed). It's designed to be
 // easily serializable.
 type InternalLogEntry struct {
-	DatetimeStarted   string
-	DatetimeFinished  string
-	Stdout            string
-	Stderr            string
-	StdoutBuffer      io.ReadCloser
-	StderrBuffer      io.ReadCloser
-	TimedOut          bool
-	Blocked           bool
-	ExitCode          int32
-	Tags              []string
-	ExecutionStarted  bool
-	ExecutionFinished bool
+	DatetimeStarted     string
+	DatetimeFinished    string
+	Stdout              string
+	Stderr              string
+	StdoutBuffer        io.ReadCloser
+	StderrBuffer        io.ReadCloser
+	TimedOut            bool
+	Blocked             bool
+	ExitCode            int32
+	Tags                []string
+	ExecutionStarted    bool
+	ExecutionFinished   bool
+	ExecutionTrackingID string
 
 	/*
 		The following 3 properties are obviously on Action normally, but it's useful
@@ -64,7 +68,7 @@ type InternalLogEntry struct {
 	*/
 	ActionTitle string
 	ActionIcon  string
-	UUID        string
+	ActionId    string
 }
 
 type executorStepFunc func(*ExecutionRequest) bool
@@ -76,8 +80,7 @@ func DefaultExecutor() *Executor {
 	e.Logs = make(map[string]*InternalLogEntry)
 
 	e.chainOfCommand = []executorStepFunc{
-		stepLogRequested,
-		stepFindAction,
+		stepRequestAction,
 		stepConcurrencyCheck,
 		stepACLCheck,
 		stepParseArgs,
@@ -108,21 +111,19 @@ func (e *Executor) ExecRequest(req *ExecutionRequest) (*sync.WaitGroup, string)
 	// duplicate UUIDs (or just random strings), but this is the only way.
 
 	req.logEntry = &InternalLogEntry{
-		DatetimeStarted:   time.Now().Format("2006-01-02 15:04:05"),
-		ActionTitle:       req.ActionName,
-		UUID:              req.UUID,
-		Stdout:            "",
-		Stderr:            "",
-		ExitCode:          -1337, // If an Action is not actually executed, this is the default exit code.
-		ExecutionStarted:  false,
-		ExecutionFinished: false,
+		DatetimeStarted:     time.Now().Format("2006-01-02 15:04:05"),
+		ExecutionTrackingID: req.TrackingID,
+		Stdout:              "",
+		Stderr:              "",
+		ExitCode:            -1337, // If an Action is not actually executed, this is the default exit code.
+		ExecutionStarted:    false,
+		ExecutionFinished:   false,
+		ActionId:            "",
+		ActionTitle:         "notfound",
+		ActionIcon:          "&#x1f4a9;",
 	}
 
-	e.Logs[req.UUID] = req.logEntry
-
-	for _, listener := range e.listeners {
-		listener.OnExecutionStarted(req.ActionName)
-	}
+	e.Logs[req.TrackingID] = req.logEntry
 
 	wg := new(sync.WaitGroup)
 	wg.Add(1)
@@ -132,7 +133,7 @@ func (e *Executor) ExecRequest(req *ExecutionRequest) (*sync.WaitGroup, string)
 		defer wg.Done()
 	}()
 
-	return wg, req.UUID
+	return wg, req.TrackingID
 }
 
 func (e *Executor) execChain(req *ExecutionRequest) {
@@ -153,7 +154,7 @@ func getConcurrentCount(req *ExecutionRequest) int {
 	concurrentCount := 0
 
 	for _, log := range req.executor.Logs {
-		if log.ActionTitle == req.ActionName && !log.ExecutionFinished {
+		if log.ActionId == req.Action.ID && !log.ExecutionFinished {
 			concurrentCount += 1
 		}
 	}
@@ -169,7 +170,7 @@ func stepConcurrencyCheck(req *ExecutionRequest) bool {
 		msg := fmt.Sprintf("Blocked from executing. This would mean this action is running %d times concurrently, but this action has maxExecutions set to %d.", concurrentCount, req.Action.MaxConcurrent)
 
 		log.WithFields(log.Fields{
-			"actionTitle": req.ActionName,
+			"actionTitle": req.logEntry.ActionTitle,
 		}).Warnf(msg)
 
 		req.logEntry.Stdout = msg
@@ -180,29 +181,6 @@ func stepConcurrencyCheck(req *ExecutionRequest) bool {
 	return true
 }
 
-func stepFindAction(req *ExecutionRequest) bool {
-	if req.Action != nil {
-		return true
-	}
-
-	actualAction := req.Cfg.FindAction(req.ActionName)
-
-	if actualAction == nil {
-		log.WithFields(log.Fields{
-			"actionName": req.ActionName,
-		}).Warnf("Action not found")
-
-		req.logEntry.Stderr = "Action not found"
-
-		return false
-	}
-
-	req.Action = actualAction
-	req.logEntry.ActionIcon = actualAction.Icon
-
-	return true
-}
-
 func stepACLCheck(req *ExecutionRequest) bool {
 	return acl.IsAllowedExec(req.Cfg, req.AuthenticatedUser, req.Action)
 }
@@ -210,7 +188,7 @@ func stepACLCheck(req *ExecutionRequest) bool {
 func stepParseArgs(req *ExecutionRequest) bool {
 	var err error
 
-	req.finalParsedCommand, err = parseActionArguments(req.Action.Shell, req.Arguments, req.Action)
+	req.finalParsedCommand, err = parseActionArguments(req.Action.Shell, req.Arguments, req.Action, req.logEntry.ActionTitle, req.EntityPrefix)
 
 	if err != nil {
 		req.logEntry.Stdout = err.Error()
@@ -223,9 +201,32 @@ func stepParseArgs(req *ExecutionRequest) bool {
 	return true
 }
 
-func stepLogRequested(req *ExecutionRequest) bool {
+func stepRequestAction(req *ExecutionRequest) bool {
+	// The grpc API always tries to find the action by ID, but it may
+	if req.Action == nil {
+		log.WithFields(log.Fields{
+			"actionTitle": req.ActionTitle,
+		}).Infof("Action finding")
+
+		req.Action = req.Cfg.FindAction(req.ActionTitle)
+
+		if req.Action == nil {
+			log.WithFields(log.Fields{
+				"actionName": req.ActionTitle,
+			}).Warnf("Action requested, but not found")
+
+			req.logEntry.Stderr = "Action not found: " + req.ActionTitle
+
+			return false
+		}
+	}
+
+	req.logEntry.ActionTitle = sv.ReplaceEntityVars(req.EntityPrefix, req.Action.Title)
+	req.logEntry.ActionIcon = req.Action.Icon
+	req.logEntry.ActionId = req.Action.ID
+
 	log.WithFields(log.Fields{
-		"actionTitle": req.ActionName,
+		"actionTitle": req.logEntry.ActionTitle,
 	}).Infof("Action requested")
 
 	return true
@@ -233,7 +234,7 @@ func stepLogRequested(req *ExecutionRequest) bool {
 
 func stepLogStart(req *ExecutionRequest) bool {
 	log.WithFields(log.Fields{
-		"actionTitle": req.Action.Title,
+		"actionTitle": req.logEntry.ActionTitle,
 		"timeout":     req.Action.Timeout,
 	}).Infof("Action starting")
 
@@ -242,7 +243,7 @@ func stepLogStart(req *ExecutionRequest) bool {
 
 func stepLogFinish(req *ExecutionRequest) bool {
 	log.WithFields(log.Fields{
-		"actionTitle": req.Action.Title,
+		"actionTitle": req.logEntry.ActionTitle,
 		"stdout":      req.logEntry.Stdout,
 		"stderr":      req.logEntry.Stderr,
 		"timedOut":    req.logEntry.TimedOut,
@@ -322,7 +323,7 @@ func stepExecAfter(req *ExecutionRequest) bool {
 		"exitCode": fmt.Sprintf("%v", req.logEntry.ExitCode),
 	}
 
-	finalParsedCommand, _ := parseActionArguments(req.Action.ShellAfterCompleted, args, req.Action)
+	finalParsedCommand, _ := parseActionArguments(req.Action.ShellAfterCompleted, args, req.Action, req.logEntry.ActionTitle, req.EntityPrefix)
 
 	cmd := wrapCommandInShell(ctx, finalParsedCommand)
 	cmd.Stdout = &stdout

+ 9 - 9
internal/executor/executor_test.go

@@ -13,7 +13,7 @@ func testingExecutor() (*Executor, *config.Config) {
 
 	cfg := config.DefaultConfig()
 
-	a1 := config.Action{
+	a1 := &config.Action{
 		Title: "Do some tickles",
 		Shell: "echo 'Tickling {{ person }}'",
 		Arguments: []config.ActionArgument{
@@ -34,7 +34,7 @@ func TestCreateExecutorAndExec(t *testing.T) {
 	e, cfg := testingExecutor()
 
 	req := ExecutionRequest{
-		ActionName:        "Do some tickles",
+		ActionTitle:       "Do some tickles",
 		AuthenticatedUser: &acl.AuthenticatedUser{Username: "Mr Tickle"},
 		Cfg:               cfg,
 		Arguments: map[string]string{
@@ -54,9 +54,9 @@ func TestExecNonExistant(t *testing.T) {
 	e, cfg := testingExecutor()
 
 	req := ExecutionRequest{
-		ActionName: "Waffles",
-		logEntry:   &InternalLogEntry{},
-		Cfg:        cfg,
+		ActionTitle: "Waffles",
+		logEntry:    &InternalLogEntry{},
+		Cfg:         cfg,
 	}
 
 	wg, _ := e.ExecRequest(&req)
@@ -67,7 +67,7 @@ func TestExecNonExistant(t *testing.T) {
 }
 
 func TestArgumentNameCamelCase(t *testing.T) {
-	a1 := config.Action{
+	a1 := &config.Action{
 		Title: "Do some tickles",
 		Shell: "echo 'Tickling {{ personName }}'",
 		Arguments: []config.ActionArgument{
@@ -82,14 +82,14 @@ func TestArgumentNameCamelCase(t *testing.T) {
 		"personName": "Fred",
 	}
 
-	out, err := parseActionArguments(a1.Shell, values, &a1)
+	out, err := parseActionArguments(a1.Shell, values, a1, a1.Title, "")
 
 	assert.Equal(t, "echo 'Tickling Fred'", out)
 	assert.Nil(t, err)
 }
 
 func TestArgumentNameSnakeCase(t *testing.T) {
-	a1 := config.Action{
+	a1 := &config.Action{
 		Title: "Do some tickles",
 		Shell: "echo 'Tickling {{ person_name }}'",
 		Arguments: []config.ActionArgument{
@@ -104,7 +104,7 @@ func TestArgumentNameSnakeCase(t *testing.T) {
 		"person_name": "Fred",
 	}
 
-	out, err := parseActionArguments(a1.Shell, values, &a1)
+	out, err := parseActionArguments(a1.Shell, values, a1, a1.Title, "")
 
 	assert.Equal(t, "echo 'Tickling Fred'", out)
 	assert.Nil(t, err)

+ 90 - 68
internal/grpcapi/grpcApi.go

@@ -15,6 +15,7 @@ import (
 	config "github.com/OliveTin/OliveTin/internal/config"
 	executor "github.com/OliveTin/OliveTin/internal/executor"
 	installationinfo "github.com/OliveTin/OliveTin/internal/installationinfo"
+	sv "github.com/OliveTin/OliveTin/internal/stringvariables"
 )
 
 var (
@@ -35,18 +36,21 @@ func (api *oliveTinAPI) StartAction(ctx ctx.Context, req *pb.StartActionRequest)
 		args[arg.Name] = arg.Value
 	}
 
+	pair, _ := publicActionIdToActionMap[req.ActionId]
+
 	execReq := executor.ExecutionRequest{
-		ActionName:        req.ActionName,
-		UUID:              req.Uuid,
+		Action:            pair.Action,
+		EntityPrefix:      pair.EntityPrefix,
+		TrackingID:        req.UniqueTrackingId,
 		Arguments:         args,
 		AuthenticatedUser: acl.UserFromContext(ctx, cfg),
 		Cfg:               cfg,
 	}
 
-	_, uuid := api.executor.ExecRequest(&execReq)
+	api.executor.ExecRequest(&execReq)
 
 	return &pb.StartActionResponse{
-		ExecutionUuid: uuid,
+		ExecutionTrackingId: req.UniqueTrackingId,
 	}, nil
 }
 
@@ -54,8 +58,8 @@ func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *pb.StartActionA
 	args := make(map[string]string)
 
 	execReq := executor.ExecutionRequest{
-		ActionName:        req.ActionName,
-		UUID:              uuid.NewString(),
+		Action:            findActionByPublicID(req.ActionId),
+		TrackingID:        uuid.NewString(),
 		Arguments:         args,
 		AuthenticatedUser: acl.UserFromContext(ctx, cfg),
 		Cfg:               cfg,
@@ -64,7 +68,7 @@ func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *pb.StartActionA
 	wg, _ := api.executor.ExecRequest(&execReq)
 	wg.Wait()
 
-	internalLogEntry, ok := api.executor.Logs[execReq.UUID]
+	internalLogEntry, ok := api.executor.Logs[execReq.TrackingID]
 
 	if ok {
 		return &pb.StartActionAndWaitResponse{
@@ -75,67 +79,42 @@ func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *pb.StartActionA
 	}
 }
 
-func (api *oliveTinAPI) StartActionByAlias(ctx ctx.Context, req *pb.StartActionByAliasRequest) (*pb.StartActionByAliasResponse, error) {
+func (api *oliveTinAPI) StartActionByGet(ctx ctx.Context, req *pb.StartActionByGetRequest) (*pb.StartActionByGetResponse, error) {
 	args := make(map[string]string)
 
-	action := findActionByAlias(req.ActionAlias)
-
-	if action == nil {
-		log.Warnf("ByAlias action alias not found: %v, cannot start execution.", req.ActionAlias)
-		return &pb.StartActionByAliasResponse{
-			ExecutionUuid: "",
-		}, errors.New("ByAlias action alias not found")
-	}
-
 	execReq := executor.ExecutionRequest{
-		ActionName: action.Title,
-		Action:     action,
-		UUID:       uuid.NewString(),
-		Arguments:  args,
-		AuthenticatedUser: &acl.AuthenticatedUser{
-			Username:  "webhook",
-			Usergroup: "webhook",
-		},
-		Cfg: cfg,
+		Action:            findActionByPublicID(req.ActionId),
+		TrackingID:        uuid.NewString(),
+		Arguments:         args,
+		AuthenticatedUser: acl.UserFromContext(ctx, cfg),
+		Cfg:               cfg,
 	}
 
-	_, uuid := api.executor.ExecRequest(&execReq)
+	_, uniqueTrackingId := api.executor.ExecRequest(&execReq)
 
-	return &pb.StartActionByAliasResponse{
-		ExecutionUuid: uuid,
+	return &pb.StartActionByGetResponse{
+		ExecutionTrackingId: uniqueTrackingId,
 	}, nil
 }
 
-func (api *oliveTinAPI) StartActionByAliasAndWait(ctx ctx.Context, req *pb.StartActionByAliasAndWaitRequest) (*pb.StartActionByAliasAndWaitResponse, error) {
+func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *pb.StartActionByGetAndWaitRequest) (*pb.StartActionByGetAndWaitResponse, error) {
 	args := make(map[string]string)
 
-	action := findActionByAlias(req.ActionAlias)
-
-	if action == nil {
-		log.Warnf("ByAlias action alias not found: %v, cannot start execution.", req.ActionAlias)
-
-		return &pb.StartActionByAliasAndWaitResponse{}, errors.New("ByAlias action alias not found")
-	}
-
 	execReq := executor.ExecutionRequest{
-		ActionName: action.Title,
-		Action:     action,
-		UUID:       uuid.NewString(),
-		Arguments:  args,
-		AuthenticatedUser: &acl.AuthenticatedUser{
-			Username:  "webhook",
-			Usergroup: "webhook",
-		},
-		Cfg: cfg,
+		Action:            findActionByPublicID(req.ActionId),
+		TrackingID:        uuid.NewString(),
+		Arguments:         args,
+		AuthenticatedUser: acl.UserFromContext(ctx, cfg),
+		Cfg:               cfg,
 	}
 
 	wg, _ := api.executor.ExecRequest(&execReq)
 	wg.Wait()
 
-	internalLogEntry, ok := api.executor.Logs[execReq.UUID]
+	internalLogEntry, ok := api.executor.Logs[execReq.TrackingID]
 
 	if ok {
-		return &pb.StartActionByAliasAndWaitResponse{
+		return &pb.StartActionByGetAndWaitResponse{
 			LogEntry: internalLogEntryToPb(internalLogEntry),
 		}, nil
 	} else {
@@ -145,26 +124,27 @@ func (api *oliveTinAPI) StartActionByAliasAndWait(ctx ctx.Context, req *pb.Start
 
 func internalLogEntryToPb(logEntry *executor.InternalLogEntry) *pb.LogEntry {
 	return &pb.LogEntry{
-		ActionTitle:       logEntry.ActionTitle,
-		ActionIcon:        logEntry.ActionIcon,
-		DatetimeStarted:   logEntry.DatetimeStarted,
-		DatetimeFinished:  logEntry.DatetimeFinished,
-		Stdout:            logEntry.Stdout,
-		Stderr:            logEntry.Stderr,
-		TimedOut:          logEntry.TimedOut,
-		Blocked:           logEntry.Blocked,
-		ExitCode:          logEntry.ExitCode,
-		Tags:              logEntry.Tags,
-		ExecutionUuid:     logEntry.UUID,
-		ExecutionStarted:  logEntry.ExecutionStarted,
-		ExecutionFinished: logEntry.ExecutionFinished,
+		ActionTitle:         logEntry.ActionTitle,
+		ActionIcon:          logEntry.ActionIcon,
+		ActionId:            logEntry.ActionId,
+		DatetimeStarted:     logEntry.DatetimeStarted,
+		DatetimeFinished:    logEntry.DatetimeFinished,
+		Stdout:              logEntry.Stdout,
+		Stderr:              logEntry.Stderr,
+		TimedOut:            logEntry.TimedOut,
+		Blocked:             logEntry.Blocked,
+		ExitCode:            logEntry.ExitCode,
+		Tags:                logEntry.Tags,
+		ExecutionTrackingId: logEntry.ExecutionTrackingID,
+		ExecutionStarted:    logEntry.ExecutionStarted,
+		ExecutionFinished:   logEntry.ExecutionFinished,
 	}
 }
 
 func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *pb.ExecutionStatusRequest) (*pb.ExecutionStatusResponse, error) {
 	res := &pb.ExecutionStatusResponse{}
 
-	logEntry, ok := api.executor.Logs[req.ExecutionUuid]
+	logEntry, ok := api.executor.Logs[req.ExecutionTrackingId]
 
 	if !ok {
 		return res, nil
@@ -224,9 +204,9 @@ func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *pb.GetLogsRequest) (*pb.Ge
 
 	// TODO Limit to 10 entries or something to prevent browser lag.
 
-	for uuid, logEntry := range api.executor.Logs {
+	for trackingId, logEntry := range api.executor.Logs {
 		pbLogEntry := internalLogEntryToPb(logEntry)
-		pbLogEntry.ExecutionUuid = uuid
+		pbLogEntry.ExecutionTrackingId = trackingId
 
 		ret.Logs = append(ret.Logs, pbLogEntry)
 	}
@@ -272,11 +252,53 @@ func (api *oliveTinAPI) WhoAmI(ctx ctx.Context, req *pb.WhoAmIRequest) (*pb.WhoA
 }
 
 func (api *oliveTinAPI) SosReport(ctx ctx.Context, req *pb.SosReportRequest) (*pb.SosReportResponse, error) {
-	res := &pb.SosReportResponse{
-		Alert: "Your SOS Report has been logged to OliveTin logs.",
+	sos := installationinfo.GetSosReport()
+
+	res := &pb.SosReportResponse{}
+
+	if cfg.InsecureAllowDumpSos {
+		res.Alert = sos
+	} else {
+		res.Alert = "Your SOS Report has been logged to OliveTin logs."
+		log.Info(sos)
+	}
+
+	return res, nil
+}
+
+func (api *oliveTinAPI) DumpVars(ctx ctx.Context, req *pb.DumpVarsRequest) (*pb.DumpVarsResponse, error) {
+	res := &pb.DumpVarsResponse{}
+
+	if !cfg.InsecureAllowDumpVars {
+		res.Alert = "Dumping variables is not allowed by default because it is insecure."
+
+		return res, nil
+	}
+
+	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.Contents
+
+	return res, nil
+}
+
+func (api *oliveTinAPI) DumpPublicIdActionMap(ctx ctx.Context, req *pb.DumpPublicIdActionMapRequest) (*pb.DumpPublicIdActionMapResponse, error) {
+	res := &pb.DumpPublicIdActionMapResponse{}
+	res.Contents = make(map[string]*pb.ActionEntityPair)
+
+	if !cfg.InsecureAllowDumpActionMap {
+		res.Alert = "Dumping Public IDs is disallowed."
+
+		return res, nil
+	}
+
+	for k, v := range publicActionIdToActionMap {
+		res.Contents[k] = &pb.ActionEntityPair{
+			ActionTitle:  v.Action.Title,
+			EntityPrefix: v.EntityPrefix,
+		}
 	}
 
-	log.Infof("\n" + installationinfo.GetSosReport())
+	res.Alert = "Dumping variables has been enabled in the configuration. Please set InsecureAllowDumpActionMap = false again after you don't need it anymore"
 
 	return res, nil
 }

+ 71 - 14
internal/grpcapi/grpcApiActions.go

@@ -6,29 +6,83 @@ import (
 	pb "github.com/OliveTin/OliveTin/gen/grpc"
 	acl "github.com/OliveTin/OliveTin/internal/acl"
 	config "github.com/OliveTin/OliveTin/internal/config"
+	sv "github.com/OliveTin/OliveTin/internal/stringvariables"
+	log "github.com/sirupsen/logrus"
+	"strconv"
 )
 
-func actionsCfgToPb(cfgActions []config.Action, user *acl.AuthenticatedUser) *pb.GetDashboardComponentsResponse {
+type ActionWithEntity struct {
+	Action       *config.Action
+	EntityPrefix string
+}
+
+var publicActionIdToActionMap map[string]ActionWithEntity
+
+func init() {
+	publicActionIdToActionMap = make(map[string]ActionWithEntity)
+}
+
+func actionsCfgToPb(cfgActions []*config.Action, user *acl.AuthenticatedUser) *pb.GetDashboardComponentsResponse {
 	res := &pb.GetDashboardComponentsResponse{}
 
 	for _, action := range cfgActions {
-		if !acl.IsAllowedView(cfg, user, &action) {
+		if !acl.IsAllowedView(cfg, user, action) {
 			continue
 		}
 
-		btn := actionCfgToPb(action, user)
-		res.Actions = append(res.Actions, btn)
+		if action.Entity != "" {
+			res.Actions = append(res.Actions, buildActionEntities(action.Entity, action)...)
+		} else {
+			btn := actionCfgToPb(action, user)
+			res.Actions = append(res.Actions, btn)
+		}
 	}
 
 	return res
 }
 
-func actionCfgToPb(action config.Action, user *acl.AuthenticatedUser) *pb.Action {
+func buildActionEntities(entityTitle string, tpl *config.Action) []*pb.Action {
+	ret := make([]*pb.Action, 0)
+
+	entityCount, _ := strconv.Atoi(sv.Get("entities." + entityTitle + ".count"))
+
+	for i := 0; i < entityCount; i++ {
+		ret = append(ret, buildEntityAction(tpl, entityTitle, i))
+	}
+
+	return ret
+}
+
+func buildEntityAction(tpl *config.Action, entityTitle string, entityIndex int) *pb.Action {
+	prefix := getEntityPrefix(entityTitle, entityIndex)
+
+	virtualActionId := createPublicID(tpl, prefix)
+
+	publicActionIdToActionMap[virtualActionId] = ActionWithEntity{
+		Action:       tpl,
+		EntityPrefix: prefix,
+	}
+
+	return &pb.Action{
+		Id:    virtualActionId,
+		Title: sv.ReplaceEntityVars(prefix, tpl.Title),
+		Icon:  tpl.Icon,
+	}
+}
+
+func actionCfgToPb(action *config.Action, user *acl.AuthenticatedUser) *pb.Action {
+	virtualActionId := createPublicID(action, "")
+
+	publicActionIdToActionMap[virtualActionId] = ActionWithEntity{
+		Action:       action,
+		EntityPrefix: "noent",
+	}
+
 	btn := pb.Action{
-		Id:           fmt.Sprintf("%x", md5.Sum([]byte(action.Title))),
+		Id:           virtualActionId,
 		Title:        action.Title,
 		Icon:         action.Icon,
-		CanExec:      acl.IsAllowedExec(cfg, user, &action),
+		CanExec:      acl.IsAllowedExec(cfg, user, action),
 		PopupOnStart: action.PopupOnStart,
 	}
 
@@ -63,13 +117,16 @@ func buildChoices(choices []config.ActionArgumentChoice) []*pb.ActionArgumentCho
 	return ret
 }
 
-func findActionByAlias(alias string) *config.Action {
-	for _, action := range cfg.Actions {
-		if action.TitleAlias != "" {
-			if action.TitleAlias == alias {
-				return &action
-			}
-		}
+func createPublicID(action *config.Action, entityPrefix string) string {
+	return fmt.Sprintf("%x", md5.Sum([]byte(action.ID+"."+entityPrefix)))
+}
+
+func findActionByPublicID(id string) *config.Action {
+	pair, found := publicActionIdToActionMap[id]
+
+	if found {
+		log.Infof("findPublic %v, %v", id, pair.Action.ID)
+		return pair.Action
 	}
 
 	return nil

+ 26 - 17
internal/grpcapi/grpcApiDashboard.go

@@ -5,34 +5,29 @@ import (
 	config "github.com/OliveTin/OliveTin/internal/config"
 )
 
-func dashboardCfgToPb(res *pb.GetDashboardComponentsResponse, dashboards []config.DashboardItem) {
+func dashboardCfgToPb(res *pb.GetDashboardComponentsResponse, dashboards []*config.DashboardComponent) {
 	for _, dashboard := range dashboards {
-		res.Dashboards = append(res.Dashboards, &pb.DashboardItem{
+		res.Dashboards = append(res.Dashboards, &pb.DashboardComponent{
 			Type:     "dashboard",
 			Title:    dashboard.Title,
-			Contents: getDashboardContents(&dashboard),
+			Contents: getDashboardComponentContents(dashboard),
 		})
 	}
 }
 
-func getDashboardContents(dashboard *config.DashboardItem) []*pb.DashboardItem {
-	ret := make([]*pb.DashboardItem, 0)
+func getDashboardComponentContents(dashboard *config.DashboardComponent) []*pb.DashboardComponent {
+	ret := make([]*pb.DashboardComponent, 0)
 
 	for _, subitem := range dashboard.Contents {
-		newitem := &pb.DashboardItem{
-			Title: subitem.Title,
-			Type:  subitem.Type,
+		if subitem.Type == "fieldset" && subitem.Entity != "" {
+			ret = append(ret, buildEntityFieldsets(subitem.Entity, &subitem)...)
+			continue
 		}
 
-		if len(subitem.Contents) > 0 {
-			if newitem.Type != "fieldset" {
-				newitem.Type = "directory"
-			}
-
-			newitem.Contents = getDashboardContents(&subitem)
-		} else {
-			newitem.Type = "link"
-			newitem.Link = subitem.Link
+		newitem := &pb.DashboardComponent{
+			Title:    subitem.Title,
+			Type:     getDashboardComponentType(&subitem),
+			Contents: getDashboardComponentContents(&subitem),
 		}
 
 		ret = append(ret, newitem)
@@ -40,3 +35,17 @@ func getDashboardContents(dashboard *config.DashboardItem) []*pb.DashboardItem {
 
 	return ret
 }
+
+func getDashboardComponentType(item *config.DashboardComponent) string {
+	if len(item.Contents) > 0 {
+		if item.Type != "fieldset" {
+			return "directory"
+		}
+
+		return "fieldset"
+	} else if item.Type == "display" {
+		return "display"
+	}
+
+	return "link"
+}

+ 56 - 0
internal/grpcapi/grpcApiDashboardEntities.go

@@ -0,0 +1,56 @@
+package grpcapi
+
+import (
+	pb "github.com/OliveTin/OliveTin/gen/grpc"
+	config "github.com/OliveTin/OliveTin/internal/config"
+	sv "github.com/OliveTin/OliveTin/internal/stringvariables"
+
+	"fmt"
+	"strconv"
+)
+
+func getEntityPrefix(entityTitle string, entityIndex int) string {
+	return "entities." + entityTitle + "." + fmt.Sprintf("%v", entityIndex)
+}
+
+func buildEntityFieldsets(entityTitle string, tpl *config.DashboardComponent) []*pb.DashboardComponent {
+	ret := make([]*pb.DashboardComponent, 0)
+
+	entityCount, _ := strconv.Atoi(sv.Get("entities." + entityTitle + ".count"))
+
+	for i := 0; i < entityCount; i++ {
+		ret = append(ret, buildEntityFieldset(tpl, entityTitle, i))
+	}
+
+	return ret
+}
+
+func buildEntityFieldset(tpl *config.DashboardComponent, entityTitle string, entityIndex int) *pb.DashboardComponent {
+	prefix := getEntityPrefix(entityTitle, entityIndex)
+
+	return &pb.DashboardComponent{
+		Title:    sv.ReplaceEntityVars(prefix, tpl.Title),
+		Type:     "fieldset",
+		Contents: buildEntityFieldsetContents(tpl.Contents, prefix),
+	}
+}
+
+func buildEntityFieldsetContents(contents []config.DashboardComponent, prefix string) []*pb.DashboardComponent {
+	ret := make([]*pb.DashboardComponent, 0)
+
+	for _, subitem := range contents {
+		clone := &pb.DashboardComponent{}
+
+		if subitem.Type == "" || subitem.Type == "link" {
+			clone.Type = "link"
+			clone.Title = sv.ReplaceEntityVars(prefix, subitem.Title)
+		} else {
+			clone.Title = sv.ReplaceEntityVars(prefix, subitem.Title)
+			clone.Type = subitem.Type
+		}
+
+		ret = append(ret, clone)
+	}
+
+	return ret
+}

+ 3 - 2
internal/grpcapi/grpcApi_test.go

@@ -57,8 +57,9 @@ func getNewTestServerAndClient(t *testing.T, injectedConfig *config.Config) (*gr
 
 func TestGetActionsAndStart(t *testing.T) {
 	cfg = config.DefaultConfig()
-	btn1 := config.Action{}
+	btn1 := &config.Action{}
 	btn1.Title = "blat"
+	btn1.ID = "blat"
 	btn1.Shell = "echo 'test'"
 	cfg.Actions = append(cfg.Actions, btn1)
 
@@ -76,7 +77,7 @@ func TestGetActionsAndStart(t *testing.T) {
 
 	log.Printf("Response: %+v", respGb)
 
-	respSa, err := client.StartAction(context.Background(), &pb.StartActionRequest{ActionName: "blat"})
+	respSa, err := client.StartAction(context.Background(), &pb.StartActionRequest{ActionId: "blat"})
 
 	assert.Nil(t, err, "Empty err after start action")
 	assert.NotNil(t, respSa, "Empty err after start action")

+ 16 - 0
internal/installationinfo/init.go

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

+ 4 - 4
internal/oncron/cron.go

@@ -26,7 +26,7 @@ func Schedule(cfg *config.Config, ex *executor.Executor) {
 	scheduler.Start()
 }
 
-func scheduleAction(cfg *config.Config, scheduler *cron.Cron, cronline string, ex *executor.Executor, action config.Action) {
+func scheduleAction(cfg *config.Config, scheduler *cron.Cron, cronline string, ex *executor.Executor, action *config.Action) {
 	log.WithFields(log.Fields{
 		"action":   action.Title,
 		"cronline": cronline,
@@ -34,9 +34,9 @@ func scheduleAction(cfg *config.Config, scheduler *cron.Cron, cronline string, e
 
 	_, err := scheduler.AddFunc(cronline, func() {
 		req := &executor.ExecutionRequest{
-			ActionName: action.Title,
-			Cfg:        cfg,
-			Tags:       []string{"cron"},
+			ActionTitle: action.Title,
+			Cfg:         cfg,
+			Tags:        []string{"cron"},
 			AuthenticatedUser: &acl.AuthenticatedUser{
 				Username: "cron",
 			},

+ 7 - 6
internal/onfileindir/fileindir.go

@@ -20,7 +20,7 @@ func WatchFilesInDirectory(cfg *config.Config, ex *executor.Executor) {
 	}
 }
 
-func watch(directory string, action config.Action, cfg *config.Config, ex *executor.Executor, eventType fsnotify.Op) {
+func watch(directory string, action *config.Action, cfg *config.Config, ex *executor.Executor, eventType fsnotify.Op) {
 	log.WithFields(log.Fields{
 		"dir":       directory,
 		"eventType": eventType,
@@ -52,7 +52,7 @@ func watch(directory string, action config.Action, cfg *config.Config, ex *execu
 	<-done
 }
 
-func processEvent(watcher *fsnotify.Watcher, action config.Action, cfg *config.Config, ex *executor.Executor, eventType fsnotify.Op) {
+func processEvent(watcher *fsnotify.Watcher, action *config.Action, cfg *config.Config, ex *executor.Executor, eventType fsnotify.Op) {
 	select {
 	case event, ok := <-watcher.Events:
 		if !ok {
@@ -60,18 +60,19 @@ func processEvent(watcher *fsnotify.Watcher, action config.Action, cfg *config.C
 		}
 
 		checkEvent(&event, action, cfg, ex, eventType)
+		break
 	case err := <-watcher.Errors:
 		log.Errorf("Error in fsnotify: %v", err)
 		return
 	}
 }
 
-func checkEvent(event *fsnotify.Event, action config.Action, cfg *config.Config, ex *executor.Executor, eventType fsnotify.Op) {
+func checkEvent(event *fsnotify.Event, action *config.Action, cfg *config.Config, ex *executor.Executor, eventType fsnotify.Op) {
 	if event.Has(eventType) {
 		req := &executor.ExecutionRequest{
-			ActionName: action.Title,
-			Cfg:        cfg,
-			Tags:       []string{"fileindir"},
+			ActionTitle: action.Title,
+			Cfg:         cfg,
+			Tags:        []string{"fileindir"},
 			Arguments: map[string]string{
 				"filename": event.Name,
 			},

+ 1 - 1
internal/onstartup/startup.go

@@ -19,7 +19,7 @@ func Execute(cfg *config.Config, ex *executor.Executor) {
 			}).Infof("Startup action")
 
 			req := &executor.ExecutionRequest{
-				ActionName:        action.Title,
+				ActionTitle:       action.Title,
 				Arguments:         nil,
 				Cfg:               cfg,
 				Tags:              []string{"startup"},

+ 29 - 0
internal/stringvariables/entities.go

@@ -0,0 +1,29 @@
+package stringvariables
+
+import (
+	"regexp"
+	"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
+}

+ 26 - 0
internal/stringvariables/map.go

@@ -0,0 +1,26 @@
+/**
+ * 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
+
+var Contents map[string]string
+
+func init() {
+	Contents = make(map[string]string)
+}
+
+func Get(key string) string {
+	v, ok := Contents[key]
+
+	if !ok {
+		return ""
+	} else {
+		return v
+	}
+}

+ 14 - 13
internal/websocket/websocket.go

@@ -56,19 +56,20 @@ func checkOriginPermissive(r *http.Request) bool {
 
 func (WebsocketExecutionListener) OnExecutionFinished(logEntry *executor.InternalLogEntry) {
 	le := &pb.LogEntry{
-		ActionTitle:       logEntry.ActionTitle,
-		ActionIcon:        logEntry.ActionIcon,
-		DatetimeStarted:   logEntry.DatetimeStarted,
-		DatetimeFinished:  logEntry.DatetimeFinished,
-		Stdout:            logEntry.Stdout,
-		Stderr:            logEntry.Stderr,
-		TimedOut:          logEntry.TimedOut,
-		Blocked:           logEntry.Blocked,
-		ExitCode:          logEntry.ExitCode,
-		Tags:              logEntry.Tags,
-		Uuid:              logEntry.UUID,
-		ExecutionStarted:  logEntry.ExecutionStarted,
-		ExecutionFinished: logEntry.ExecutionFinished,
+		ActionTitle:         logEntry.ActionTitle,
+		ActionIcon:          logEntry.ActionIcon,
+		ActionId:            logEntry.ActionId,
+		DatetimeStarted:     logEntry.DatetimeStarted,
+		DatetimeFinished:    logEntry.DatetimeFinished,
+		Stdout:              logEntry.Stdout,
+		Stderr:              logEntry.Stderr,
+		TimedOut:            logEntry.TimedOut,
+		Blocked:             logEntry.Blocked,
+		ExitCode:            logEntry.ExitCode,
+		Tags:                logEntry.Tags,
+		ExecutionTrackingId: logEntry.ExecutionTrackingID,
+		ExecutionStarted:    logEntry.ExecutionStarted,
+		ExecutionFinished:   logEntry.ExecutionFinished,
 	}
 
 	broadcast("ExecutionFinished", le)

+ 2 - 0
var/entities/containers.json

@@ -0,0 +1,2 @@
+{"Command":"\"/opt/entrypoint.sh\"","CreatedAt":"2024-02-08 15:27:42 +0000 GMT","ID":"4bafe6f9f956","Image":"fedora","Labels":"?","LocalVolumes":"0","Mounts":"","Names":"media-indexer","Networks":"bridge","Ports":"","RunningFor":"13 days ago","Size":"0B","State":"exited","Status":"Exited (128) 13 days ago"}
+{"Command":"\"/opt/entrypoint.sh\"","CreatedAt":"2023-12-17 20:58:03 +0000 GMT","ID":"d25f37c49c35","Image":"fedora","Labels":"?","LocalVolumes":"0","Mounts":"","Names":"game-server","Networks":"bridge","Ports":"","RunningFor":"27 days ago","Size":"0B","State":"exited","Status":"Exited (137) 27 days ago"}

+ 0 - 6
var/entities/containers.yaml

@@ -1,6 +0,0 @@
-sonarr:
-  state: started
-plex:
-  state: started
-sabnzbd:
-  state: stopped

+ 12 - 0
var/entities/servers.yaml

@@ -0,0 +1,12 @@
+- name: server1
+  state: started
+  hostname: server1.example.com
+  ip: 192.168.0.1
+- name: server2
+  state: started
+  hostname: server2.example.com
+  ip: 192.168.0.2
+- name: server3
+  state: stopped
+  hostname: server3.example.com
+  ip: 192.168.0.3

+ 2 - 2
webui.dev/index.html

@@ -189,7 +189,7 @@
 			This is the bootstrap code, which relies on very simple, old javascript
 		  	to at least display a helpful error message if we can't use OliveTin.
 			*/
-			function showBigError (type, friendlyType, message) {
+			window.showBigError = function (type, friendlyType, message) {
 			  clearBigErrors(type)
 
 			  console.error('Error ' + type + ': ', message)
@@ -202,7 +202,7 @@
 			  document.body.prepend(domErr)
 			}
 
-			function clearBigErrors(additionalClass) {
+			window.clearBigErrors = function (additionalClass) {
 			  let selector = 'div.error'
 
 		      if (additionalClass != null) {

+ 6 - 12
webui.dev/js/ActionButton.js

@@ -25,12 +25,10 @@ class ActionButton extends ExecutionFeedbackButton {
     this.constructDomFromTemplate()
 
     // Class attributes
-    this.temporaryStatusMessage = null
-    this.isWaiting = false
-    this.actionCallUrl = window.restBaseUrl + 'StartAction'
-
     this.updateFromJson(json)
 
+    this.actionId = json.id
+
     // DOM Attributes
     this.setAttribute('role', 'none')
     this.btn.title = json.title
@@ -59,15 +57,13 @@ class ActionButton extends ExecutionFeedbackButton {
     this.domTitle.innerText = this.btn.title
     this.domIcon.innerHTML = this.unicodeIcon
 
-    this.setAttribute('id', 'actionButton-' + json.id)
+    this.setAttribute('id', 'actionButton-' + this.actionId)
   }
 
   updateFromJson (json) {
     // Fields that should not be updated
     //
     // title - as the callback URL relies on it
-    // actionCallbackUrl - as it's based on the title
-    // temporaryStatusMessage - as the button might be "waiting" on execution to finish while it's being updated.
 
     if (json.icon === '') {
       this.unicodeIcon = '&#x1f4a9'
@@ -93,8 +89,6 @@ class ActionButton extends ExecutionFeedbackButton {
   }
 
   startAction (actionArgs) {
-    //    this.btn.disabled = true
-    //    this.isWaiting = true
     this.btn.classList = [] // Removes old animation classes
 
     if (actionArgs === undefined) {
@@ -104,14 +98,14 @@ class ActionButton extends ExecutionFeedbackButton {
     // 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 = {
-      actionName: this.btn.title,
+      actionId: this.actionId,
       arguments: actionArgs,
-      uuid: this.getUniqueId()
+      uniqueTrackingId: this.getUniqueId()
     }
 
     this.onActionStarted(startActionArgs.uuid)
 
-    window.fetch(this.actionCallUrl, {
+    window.fetch(window.restBaseUrl + 'StartAction', {
       method: 'POST',
       headers: {
         'Content-Type': 'application/json'

+ 12 - 2
webui.dev/js/marshaller.js

@@ -187,11 +187,11 @@ function marshalDashboardStructureToHtml (json) {
 }
 
 function marshalLink (item, fieldset) {
-  let btn = window.actionButtons[item.link]
+  let btn = window.actionButtons[item.title]
 
   if (typeof btn === 'undefined') {
     btn = document.createElement('button')
-    btn.innerText = 'Action not found: ' + item.link
+    btn.innerText = 'Action not found: ' + item.title
     btn.classList.add('error')
   }
 
@@ -208,6 +208,9 @@ function marshalContainerContents (json, section, fieldset, parentDashboard) {
         marshalDirectoryButton(item, fieldset)
         marshalDirectory(item, section)
         break
+      case 'display':
+        marshalDisplay(item, fieldset)
+        break
       case 'link':
         marshalLink(item, fieldset)
         break
@@ -335,6 +338,13 @@ function createDirectoryBreadcrumb (title, link) {
   return a
 }
 
+function marshalDisplay (item, fieldset) {
+  const display = document.createElement('div')
+  display.innerHTML = item.title
+
+  fieldset.appendChild(display)
+}
+
 function marshalDirectoryButton (item, fieldset) {
   const directoryButton = document.createElement('button')
   directoryButton.innerHTML = '<span class = "icon">&#128193;</span> ' + item.title

+ 1 - 1
webui.dev/style.css

@@ -25,7 +25,7 @@ dialog.big {
 fieldset {
   display: grid;
   grid-template-columns: repeat(auto-fit, 180px);
-  grid-template-rows: auto auto auto auto;
+  grid-auto-rows: 1fr;
   grid-gap: 1em;
   padding: 0;
   text-align: center;