diff --git a/Makefile b/Makefile index 114c0ea..3cc3d69 100644 --- a/Makefile +++ b/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 diff --git a/OliveTin.proto b/OliveTin.proto index dfa82d7..89ed6d7 100644 --- a/OliveTin.proto +++ b/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 contents = 2; +} + +message ActionEntityPair { + string action_title = 1; + string entity_prefix = 2; +} + +message DumpPublicIdActionMapRequest {} +message DumpPublicIdActionMapResponse { + string alert = 1; + map 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" diff --git a/cmd/OliveTin/main.go b/cmd/OliveTin/main.go index b021f9e..f2af0fe 100644 --- a/cmd/OliveTin/main.go +++ b/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) diff --git a/config.yaml b/config.yaml index 03c2eaa..bdbcf11 100644 --- a/config.yaml +++ b/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: "🇦" - 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' - - title: Server2 Power Off - shell: echo "Power Off Server 2" + # 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 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: '🇦' + 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: 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 + # 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 + + # 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: {{ server.name }} + IP Address: {{ server.ip }} + + # 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: - - title: Server 1 - contents: - - link: Server1 Power Off - - - title: Server 2 - contents: - - link: Server2 Wake On LAN - - link: Server2 Power Off - - - title: Server Utilities - type: fieldset - contents: - - link: Ping All Servers + - type: display + title: | + {{ container.Names }}

{{ container.State }} + - title: 'Start {{ container.Names }}' + - title: 'Stop {{ container.Names }}' diff --git a/integration-tests/runner.mjs b/integration-tests/runner.mjs index 6902ff1..71d8fef 100644 --- a/integration-tests/runner.mjs +++ b/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' diff --git a/integration-tests/test/general.mjs b/integration-tests/test/general.mjs index 8ccef8d..3b127c8 100644 --- a/integration-tests/test/general.mjs +++ b/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) }) diff --git a/integration-tests/test/hiddenFooter.mjs b/integration-tests/test/hiddenFooter.mjs index 23c45ed..112e59b 100644 --- a/integration-tests/test/hiddenFooter.mjs +++ b/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 }) diff --git a/integration-tests/test/hiddenNav.mjs b/integration-tests/test/hiddenNav.mjs index 4b1340b..8191e64 100644 --- a/integration-tests/test/hiddenNav.mjs +++ b/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 () { diff --git a/integration-tests/test/multipleDropdowns.js b/integration-tests/test/multipleDropdowns.js index 6d40d3d..eed8037 100644 --- a/integration-tests/test/multipleDropdowns.js +++ b/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() diff --git a/internal/acl/acl.go b/internal/acl/acl.go index 0008193..20676f7 100644 --- a/internal/acl/acl.go +++ b/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) } } diff --git a/internal/config/config.go b/internal/config/config.go index bde362c..b538643 100644 --- a/internal/config/config.go +++ b/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 } diff --git a/internal/config/config_helpers.go b/internal/config/config_helpers.go index 8cba6b5..39f8f52 100644 --- a/internal/config/config_helpers.go +++ b/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 } } diff --git a/internal/config/config_helpers_test.go b/internal/config/config_helpers_test.go index 3c67219..9109458 100644 --- a/internal/config/config_helpers_test.go +++ b/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", } diff --git a/internal/config/sanitize.go b/internal/config/sanitize.go index b010962..528428f 100644 --- a/internal/config/sanitize.go +++ b/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": diff --git a/internal/config/sanitize_test.go b/internal/config/sanitize_test.go index 241c60b..ee442eb 100644 --- a/internal/config/sanitize_test.go +++ b/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{ { diff --git a/internal/entityfiles/entityfiles.go b/internal/entityfiles/entityfiles.go new file mode 100644 index 0000000..36596c8 --- /dev/null +++ b/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 + } + } +} diff --git a/internal/executor/arguments.go b/internal/executor/arguments.go index a82a30d..1a7cab1 100644 --- a/internal/executor/arguments.go +++ b/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") diff --git a/internal/executor/arguments_test.go b/internal/executor/arguments_test.go index 7e2f8a6..ae14ab2 100644 --- a/internal/executor/arguments_test.go +++ b/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") diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 779ae61..baf2fc1 100644 --- a/internal/executor/executor.go +++ b/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: "💩", } - 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 diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go index cc41e7c..7e4dc42 100644 --- a/internal/executor/executor_test.go +++ b/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) diff --git a/internal/grpcapi/grpcApi.go b/internal/grpcapi/grpcApi.go index b9b568b..9add424 100644 --- a/internal/grpcapi/grpcApi.go +++ b/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) } - log.Infof("\n" + installationinfo.GetSosReport()) + 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, + } + } + + 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 } diff --git a/internal/grpcapi/grpcApiActions.go b/internal/grpcapi/grpcApiActions.go index 6b073e1..f6709c1 100644 --- a/internal/grpcapi/grpcApiActions.go +++ b/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 diff --git a/internal/grpcapi/grpcApiDashboard.go b/internal/grpcapi/grpcApiDashboard.go index dd20e58..f657a99 100644 --- a/internal/grpcapi/grpcApiDashboard.go +++ b/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" +} diff --git a/internal/grpcapi/grpcApiDashboardEntities.go b/internal/grpcapi/grpcApiDashboardEntities.go new file mode 100644 index 0000000..ffa728e --- /dev/null +++ b/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 +} diff --git a/internal/grpcapi/grpcApi_test.go b/internal/grpcapi/grpcApi_test.go index 646db2b..e85801a 100644 --- a/internal/grpcapi/grpcApi_test.go +++ b/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") diff --git a/internal/installationinfo/init.go b/internal/installationinfo/init.go new file mode 100644 index 0000000..42fca73 --- /dev/null +++ b/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) +} diff --git a/internal/oncron/cron.go b/internal/oncron/cron.go index 8ba77da..236853f 100644 --- a/internal/oncron/cron.go +++ b/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", }, diff --git a/internal/onfileindir/fileindir.go b/internal/onfileindir/fileindir.go index b3eb836..482994c 100644 --- a/internal/onfileindir/fileindir.go +++ b/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, }, diff --git a/internal/onstartup/startup.go b/internal/onstartup/startup.go index c08f791..24ab541 100644 --- a/internal/onstartup/startup.go +++ b/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"}, diff --git a/internal/stringvariables/entities.go b/internal/stringvariables/entities.go new file mode 100644 index 0000000..a9849df --- /dev/null +++ b/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 +} diff --git a/internal/stringvariables/map.go b/internal/stringvariables/map.go new file mode 100644 index 0000000..8bdd555 --- /dev/null +++ b/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 + } +} diff --git a/internal/websocket/websocket.go b/internal/websocket/websocket.go index 79e59af..ebaa65e 100644 --- a/internal/websocket/websocket.go +++ b/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) diff --git a/var/entities/containers.json b/var/entities/containers.json new file mode 100644 index 0000000..e9417a5 --- /dev/null +++ b/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"} diff --git a/var/entities/containers.yaml b/var/entities/containers.yaml deleted file mode 100644 index 3e2b3aa..0000000 --- a/var/entities/containers.yaml +++ /dev/null @@ -1,6 +0,0 @@ -sonarr: - state: started -plex: - state: started -sabnzbd: - state: stopped diff --git a/var/entities/servers.yaml b/var/entities/servers.yaml new file mode 100644 index 0000000..96da80e --- /dev/null +++ b/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 diff --git a/webui.dev/index.html b/webui.dev/index.html index e877f54..8946905 100644 --- a/webui.dev/index.html +++ b/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) { diff --git a/webui.dev/js/ActionButton.js b/webui.dev/js/ActionButton.js index eaa70e8..8071a45 100644 --- a/webui.dev/js/ActionButton.js +++ b/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 = '💩' @@ -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' diff --git a/webui.dev/js/marshaller.js b/webui.dev/js/marshaller.js index f97c190..dd75f35 100644 --- a/webui.dev/js/marshaller.js +++ b/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 = '📁 ' + item.title diff --git a/webui.dev/style.css b/webui.dev/style.css index fa08327..163509c 100644 --- a/webui.dev/style.css +++ b/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;