feature: The mega dashboards & entities commit.

This commit is contained in:
jamesread 2024-02-22 23:16:43 +00:00
parent fddf83f27d
commit 381bf59fbd
39 changed files with 928 additions and 356 deletions

View File

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

View File

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

View File

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

View File

@ -10,23 +10,41 @@ logLevel: "INFO"
# Actions (buttons) to show up on the WebUI: # Actions (buttons) to show up on the WebUI:
actions: 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 - title: Restart media container
shell: docker restart mediacontainer shell: docker restart mediacontainer
icon: box icon: box
entity: container
# This uses `popupOnStart: execution-dialog-stdout-only` to simply show just
# the command output.
- title: Check disk space - title: Check disk space
icon: disk icon: disk
shell: df -h /media shell: df -h /media
popupOnStart: execution-dialog-stdout-only 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 - title: check dmesg logs
shell: dmesg | tail shell: dmesg | tail
icon: logs icon: logs
popupOnStart: execution-dialog 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 - title: Run backup script
shell: /opt/backupScript.sh shell: /opt/backupScript.sh
shellAfterCompleted: "apprise -t 'Notification: Backup script completed' -b 'The backup script completed with code {{ exitCode}}. The log is: \n {{ stdout }} '" shellAfterCompleted: "apprise -t 'Notification: Backup script completed' -b 'The backup script completed with code {{ exitCode}}. The log is: \n {{ stdout }} '"
@ -35,13 +53,9 @@ actions:
icon: backup icon: backup
popupOnStart: execution-dialog popupOnStart: execution-dialog
- title: date # When you want to prompt users for input, that is when you should use
shell: date # `arguments` - this presents a popup dialog and asks for argument values.
timeout: 6 #
icon: clock
popupOnStart: execution-button
# This will send 1 ping (-c 1)
# Docs: https://docs.olivetin.app/action-ping.html # Docs: https://docs.olivetin.app/action-ping.html
- title: Ping host - title: Ping host
shell: ping {{ host }} -c {{ count }} shell: ping {{ host }} -c {{ count }}
@ -60,25 +74,6 @@ actions:
default: 1 default: 1
description: How many times to do you want to ping? 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. # 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, # However, if you are running in a container you will need to do some setup,
@ -96,6 +91,10 @@ actions:
- value: traefik - value: traefik
- value: grafana - 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 - title: Delete old backups
icon: ashtonished icon: ashtonished
shell: rm -rf /opt/oldBackups/ shell: rm -rf /opt/oldBackups/
@ -103,35 +102,154 @@ actions:
- type: confirmation - type: confirmation
title: Are you sure?! title: Are you sure?!
- title: Server1 Power Off
shell: echo "Power Off Server 1"
- title: Server2 Wake On LAN # Sometimes you want to run actions on other servers - don't overcomplicate
shell: echo "Sending Wake on LAN to Server 2" # 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 # There are several built-in shortcuts for the `icon` option, but you
shell: echo "Power Off Server 2" # 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: 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 - title: Ping All Servers
shell: echo "Ping all servers" shell: "echo 'Ping all servers'"
icon: ping 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: dashboards:
# Top level items are dsahboards.
- title: My Servers - title: My Servers
contents: 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: <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 type: fieldset
contents: contents:
- title: Server 1 - type: display
contents: title: |
- link: Server1 Power Off {{ container.Names }} <br /><br /><strong>{{ container.State }}</strong>
- title: 'Start {{ container.Names }}'
- title: Server 2 - title: 'Stop {{ container.Names }}'
contents:
- link: Server2 Wake On LAN
- link: Server2 Power Off
- title: Server Utilities
type: fieldset
contents:
- link: Ping All Servers

View File

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

View File

@ -1,34 +1,36 @@
import {expect} from 'chai'; import { describe, it, before, after } from 'mocha'
import {By} from 'selenium-webdriver'; import { expect } from 'chai'
import { By } from 'selenium-webdriver'
//import * as waitOn from 'wait-on'
describe('config: general', function () { describe('config: general', function () {
before(async function () { before(async function () {
await runner.start('general') await runner.start('general')
}); })
after(async () => { after(async () => {
await runner.stop() await runner.stop()
}); })
it('Page title', async function () { it('Page title', async function () {
await webdriver.get(runner.baseUrl()) await webdriver.get(runner.baseUrl())
let title = await webdriver.getTitle(); const title = await webdriver.getTitle()
expect(title).to.be.equal("OliveTin") expect(title).to.be.equal("OliveTin")
}) })
it('Footer contains promo', async function () { 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() { it('Default buttons are rendered', async function() {
await webdriver.get(runner.baseUrl()) 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) expect(buttons).to.have.length(6)
}) })

View File

@ -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 () { describe('config: hiddenFooter', function () {
before(async function () { before(async function () {
await runner.start('hiddenFooter') await runner.start('hiddenFooter')
}); })
after(async () => { after(async () => {
await runner.stop() await runner.stop()
}); })
it('Check that footer is hidden', async () => { it('Check that footer is hidden', async () => {
await webdriver.get(runner.baseUrl()) 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 expect(await footer.isDisplayed()).to.be.false
}) })

View File

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

View File

@ -1,3 +1,4 @@
import { describe, before, after } from 'mocha'
import { expect } from 'chai' import { expect } from 'chai'
import { By, until } from 'selenium-webdriver' import { By, until } from 'selenium-webdriver'
@ -14,9 +15,16 @@ describe('config: multipleDropdowns', function () {
await webdriver.get(runner.baseUrl()) await webdriver.get(runner.baseUrl())
await webdriver.manage().setTimeouts({ implicit: 2000 }) 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() await button.click()

View File

@ -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 // IsAllowedView checks if a User is allowed to view an Action
func IsAllowedView(cfg *config.Config, user *AuthenticatedUser, action *config.Action) bool { func IsAllowedView(cfg *config.Config, user *AuthenticatedUser, action *config.Action) bool {
if action.Hidden {
return false
}
for _, acl := range getRelevantAcls(cfg, action.Acls, user) { for _, acl := range getRelevantAcls(cfg, action.Acls, user) {
if acl.Permissions.View { if acl.Permissions.View {
log.WithFields(log.Fields{ 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 !slices.Contains(user.acls, acl.Name) {
// If the user does not have this ACL, then it is not relevant // 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 { for _, acl := range cfg.AccessControlLists {
if isACLRelevantToAction(cfg, actionAcls, acl, user) { if isACLRelevantToAction(cfg, actionAcls, acl, user) {
ret = append(ret, &acl) ret = append(ret, acl)
} }
} }

View File

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

View File

@ -4,7 +4,7 @@ package config
func (cfg *Config) FindAction(actionTitle string) *Action { func (cfg *Config) FindAction(actionTitle string) *Action {
for _, action := range cfg.Actions { for _, action := range cfg.Actions {
if action.Title == actionTitle { 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 { func (cfg *Config) FindAcl(aclTitle string) *AccessControlList {
for _, acl := range cfg.AccessControlLists { for _, acl := range cfg.AccessControlLists {
if acl.Name == aclTitle { if acl.Name == aclTitle {
return &acl return acl
} }
} }

View File

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

View File

@ -1,7 +1,9 @@
package config package config
import ( import (
"github.com/google/uuid"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"strings"
) )
// Sanitize will look for common configuration issues, and fix them. For example, // 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.Timeout = 3
} }
action.ID = getActionID(action)
action.Icon = lookupHTMLIcon(action.Icon) action.Icon = lookupHTMLIcon(action.Icon)
action.PopupOnStart = sanitizePopupOnStart(action.PopupOnStart, cfg) 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 { func sanitizePopupOnStart(raw string, cfg *Config) string {
switch raw { switch raw {
case "execution-dialog": case "execution-dialog":

View File

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

View File

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

View File

@ -2,6 +2,7 @@ package executor
import ( import (
config "github.com/OliveTin/OliveTin/internal/config" config "github.com/OliveTin/OliveTin/internal/config"
sv "github.com/OliveTin/OliveTin/internal/stringvariables"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"errors" "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{ log.WithFields(log.Fields{
"actionTitle": action.Title, "actionTitle": actionTitle,
"cmd": rawShellCommand, "cmd": rawShellCommand,
}).Infof("Action parse args - Before") }).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 = strings.ReplaceAll(rawShellCommand, match[0], argValue)
} }
rawShellCommand = sv.ReplaceEntityVars(entityPrefix, rawShellCommand)
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"actionTitle": action.Title, "actionTitle": actionTitle,
"cmd": rawShellCommand, "cmd": rawShellCommand,
}).Infof("Action parse args - After") }).Infof("Action parse args - After")

View File

@ -33,7 +33,7 @@ func TestArgumentNameNumbers(t *testing.T) {
"person1name": "Fred", "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.Equal(t, "echo 'Tickling Fred'", out)
assert.Nil(t, err) assert.Nil(t, err)
@ -53,7 +53,7 @@ func TestArgumentNotProvided(t *testing.T) {
values := map[string]string{} 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, "", out)
assert.Equal(t, err.Error(), "Required arg not provided: personName") assert.Equal(t, err.Error(), "Required arg not provided: personName")

View File

@ -3,6 +3,7 @@ package executor
import ( import (
acl "github.com/OliveTin/OliveTin/internal/acl" acl "github.com/OliveTin/OliveTin/internal/acl"
config "github.com/OliveTin/OliveTin/internal/config" config "github.com/OliveTin/OliveTin/internal/config"
sv "github.com/OliveTin/OliveTin/internal/stringvariables"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"bytes" "bytes"
@ -28,13 +29,15 @@ type Executor struct {
// ExecutionRequest is a request to execute an action. It's passed to an // ExecutionRequest is a request to execute an action. It's passed to an
// Executor. They're created from the grpcapi. // Executor. They're created from the grpcapi.
type ExecutionRequest struct { type ExecutionRequest struct {
ActionName string ActionTitle string
Action *config.Action Action *config.Action
Arguments map[string]string Arguments map[string]string
UUID string TrackingID string
Tags []string Tags []string
Cfg *config.Config Cfg *config.Config
AuthenticatedUser *acl.AuthenticatedUser AuthenticatedUser *acl.AuthenticatedUser
EntityPrefix string
logEntry *InternalLogEntry logEntry *InternalLogEntry
finalParsedCommand string finalParsedCommand string
executor *Executor executor *Executor
@ -56,6 +59,7 @@ type InternalLogEntry struct {
Tags []string Tags []string
ExecutionStarted bool ExecutionStarted bool
ExecutionFinished bool ExecutionFinished bool
ExecutionTrackingID string
/* /*
The following 3 properties are obviously on Action normally, but it's useful The following 3 properties are obviously on Action normally, but it's useful
@ -64,7 +68,7 @@ type InternalLogEntry struct {
*/ */
ActionTitle string ActionTitle string
ActionIcon string ActionIcon string
UUID string ActionId string
} }
type executorStepFunc func(*ExecutionRequest) bool type executorStepFunc func(*ExecutionRequest) bool
@ -76,8 +80,7 @@ func DefaultExecutor() *Executor {
e.Logs = make(map[string]*InternalLogEntry) e.Logs = make(map[string]*InternalLogEntry)
e.chainOfCommand = []executorStepFunc{ e.chainOfCommand = []executorStepFunc{
stepLogRequested, stepRequestAction,
stepFindAction,
stepConcurrencyCheck, stepConcurrencyCheck,
stepACLCheck, stepACLCheck,
stepParseArgs, stepParseArgs,
@ -109,20 +112,18 @@ func (e *Executor) ExecRequest(req *ExecutionRequest) (*sync.WaitGroup, string)
req.logEntry = &InternalLogEntry{ req.logEntry = &InternalLogEntry{
DatetimeStarted: time.Now().Format("2006-01-02 15:04:05"), DatetimeStarted: time.Now().Format("2006-01-02 15:04:05"),
ActionTitle: req.ActionName, ExecutionTrackingID: req.TrackingID,
UUID: req.UUID,
Stdout: "", Stdout: "",
Stderr: "", Stderr: "",
ExitCode: -1337, // If an Action is not actually executed, this is the default exit code. ExitCode: -1337, // If an Action is not actually executed, this is the default exit code.
ExecutionStarted: false, ExecutionStarted: false,
ExecutionFinished: false, ExecutionFinished: false,
ActionId: "",
ActionTitle: "notfound",
ActionIcon: "&#x1f4a9;",
} }
e.Logs[req.UUID] = req.logEntry e.Logs[req.TrackingID] = req.logEntry
for _, listener := range e.listeners {
listener.OnExecutionStarted(req.ActionName)
}
wg := new(sync.WaitGroup) wg := new(sync.WaitGroup)
wg.Add(1) wg.Add(1)
@ -132,7 +133,7 @@ func (e *Executor) ExecRequest(req *ExecutionRequest) (*sync.WaitGroup, string)
defer wg.Done() defer wg.Done()
}() }()
return wg, req.UUID return wg, req.TrackingID
} }
func (e *Executor) execChain(req *ExecutionRequest) { func (e *Executor) execChain(req *ExecutionRequest) {
@ -153,7 +154,7 @@ func getConcurrentCount(req *ExecutionRequest) int {
concurrentCount := 0 concurrentCount := 0
for _, log := range req.executor.Logs { for _, log := range req.executor.Logs {
if log.ActionTitle == req.ActionName && !log.ExecutionFinished { if log.ActionId == req.Action.ID && !log.ExecutionFinished {
concurrentCount += 1 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) 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{ log.WithFields(log.Fields{
"actionTitle": req.ActionName, "actionTitle": req.logEntry.ActionTitle,
}).Warnf(msg) }).Warnf(msg)
req.logEntry.Stdout = msg req.logEntry.Stdout = msg
@ -180,29 +181,6 @@ func stepConcurrencyCheck(req *ExecutionRequest) bool {
return true 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 { func stepACLCheck(req *ExecutionRequest) bool {
return acl.IsAllowedExec(req.Cfg, req.AuthenticatedUser, req.Action) return acl.IsAllowedExec(req.Cfg, req.AuthenticatedUser, req.Action)
} }
@ -210,7 +188,7 @@ func stepACLCheck(req *ExecutionRequest) bool {
func stepParseArgs(req *ExecutionRequest) bool { func stepParseArgs(req *ExecutionRequest) bool {
var err error 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 { if err != nil {
req.logEntry.Stdout = err.Error() req.logEntry.Stdout = err.Error()
@ -223,9 +201,32 @@ func stepParseArgs(req *ExecutionRequest) bool {
return true 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{ log.WithFields(log.Fields{
"actionTitle": req.ActionName, "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.logEntry.ActionTitle,
}).Infof("Action requested") }).Infof("Action requested")
return true return true
@ -233,7 +234,7 @@ func stepLogRequested(req *ExecutionRequest) bool {
func stepLogStart(req *ExecutionRequest) bool { func stepLogStart(req *ExecutionRequest) bool {
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"actionTitle": req.Action.Title, "actionTitle": req.logEntry.ActionTitle,
"timeout": req.Action.Timeout, "timeout": req.Action.Timeout,
}).Infof("Action starting") }).Infof("Action starting")
@ -242,7 +243,7 @@ func stepLogStart(req *ExecutionRequest) bool {
func stepLogFinish(req *ExecutionRequest) bool { func stepLogFinish(req *ExecutionRequest) bool {
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"actionTitle": req.Action.Title, "actionTitle": req.logEntry.ActionTitle,
"stdout": req.logEntry.Stdout, "stdout": req.logEntry.Stdout,
"stderr": req.logEntry.Stderr, "stderr": req.logEntry.Stderr,
"timedOut": req.logEntry.TimedOut, "timedOut": req.logEntry.TimedOut,
@ -322,7 +323,7 @@ func stepExecAfter(req *ExecutionRequest) bool {
"exitCode": fmt.Sprintf("%v", req.logEntry.ExitCode), "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 := wrapCommandInShell(ctx, finalParsedCommand)
cmd.Stdout = &stdout cmd.Stdout = &stdout

View File

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

View File

@ -15,6 +15,7 @@ import (
config "github.com/OliveTin/OliveTin/internal/config" config "github.com/OliveTin/OliveTin/internal/config"
executor "github.com/OliveTin/OliveTin/internal/executor" executor "github.com/OliveTin/OliveTin/internal/executor"
installationinfo "github.com/OliveTin/OliveTin/internal/installationinfo" installationinfo "github.com/OliveTin/OliveTin/internal/installationinfo"
sv "github.com/OliveTin/OliveTin/internal/stringvariables"
) )
var ( var (
@ -35,18 +36,21 @@ func (api *oliveTinAPI) StartAction(ctx ctx.Context, req *pb.StartActionRequest)
args[arg.Name] = arg.Value args[arg.Name] = arg.Value
} }
pair, _ := publicActionIdToActionMap[req.ActionId]
execReq := executor.ExecutionRequest{ execReq := executor.ExecutionRequest{
ActionName: req.ActionName, Action: pair.Action,
UUID: req.Uuid, EntityPrefix: pair.EntityPrefix,
TrackingID: req.UniqueTrackingId,
Arguments: args, Arguments: args,
AuthenticatedUser: acl.UserFromContext(ctx, cfg), AuthenticatedUser: acl.UserFromContext(ctx, cfg),
Cfg: cfg, Cfg: cfg,
} }
_, uuid := api.executor.ExecRequest(&execReq) api.executor.ExecRequest(&execReq)
return &pb.StartActionResponse{ return &pb.StartActionResponse{
ExecutionUuid: uuid, ExecutionTrackingId: req.UniqueTrackingId,
}, nil }, nil
} }
@ -54,8 +58,8 @@ func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *pb.StartActionA
args := make(map[string]string) args := make(map[string]string)
execReq := executor.ExecutionRequest{ execReq := executor.ExecutionRequest{
ActionName: req.ActionName, Action: findActionByPublicID(req.ActionId),
UUID: uuid.NewString(), TrackingID: uuid.NewString(),
Arguments: args, Arguments: args,
AuthenticatedUser: acl.UserFromContext(ctx, cfg), AuthenticatedUser: acl.UserFromContext(ctx, cfg),
Cfg: cfg, Cfg: cfg,
@ -64,7 +68,7 @@ func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *pb.StartActionA
wg, _ := api.executor.ExecRequest(&execReq) wg, _ := api.executor.ExecRequest(&execReq)
wg.Wait() wg.Wait()
internalLogEntry, ok := api.executor.Logs[execReq.UUID] internalLogEntry, ok := api.executor.Logs[execReq.TrackingID]
if ok { if ok {
return &pb.StartActionAndWaitResponse{ 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) 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{ execReq := executor.ExecutionRequest{
ActionName: action.Title, Action: findActionByPublicID(req.ActionId),
Action: action, TrackingID: uuid.NewString(),
UUID: uuid.NewString(),
Arguments: args, Arguments: args,
AuthenticatedUser: &acl.AuthenticatedUser{ AuthenticatedUser: acl.UserFromContext(ctx, cfg),
Username: "webhook",
Usergroup: "webhook",
},
Cfg: cfg, Cfg: cfg,
} }
_, uuid := api.executor.ExecRequest(&execReq) _, uniqueTrackingId := api.executor.ExecRequest(&execReq)
return &pb.StartActionByAliasResponse{ return &pb.StartActionByGetResponse{
ExecutionUuid: uuid, ExecutionTrackingId: uniqueTrackingId,
}, nil }, 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) 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{ execReq := executor.ExecutionRequest{
ActionName: action.Title, Action: findActionByPublicID(req.ActionId),
Action: action, TrackingID: uuid.NewString(),
UUID: uuid.NewString(),
Arguments: args, Arguments: args,
AuthenticatedUser: &acl.AuthenticatedUser{ AuthenticatedUser: acl.UserFromContext(ctx, cfg),
Username: "webhook",
Usergroup: "webhook",
},
Cfg: cfg, Cfg: cfg,
} }
wg, _ := api.executor.ExecRequest(&execReq) wg, _ := api.executor.ExecRequest(&execReq)
wg.Wait() wg.Wait()
internalLogEntry, ok := api.executor.Logs[execReq.UUID] internalLogEntry, ok := api.executor.Logs[execReq.TrackingID]
if ok { if ok {
return &pb.StartActionByAliasAndWaitResponse{ return &pb.StartActionByGetAndWaitResponse{
LogEntry: internalLogEntryToPb(internalLogEntry), LogEntry: internalLogEntryToPb(internalLogEntry),
}, nil }, nil
} else { } else {
@ -147,6 +126,7 @@ func internalLogEntryToPb(logEntry *executor.InternalLogEntry) *pb.LogEntry {
return &pb.LogEntry{ return &pb.LogEntry{
ActionTitle: logEntry.ActionTitle, ActionTitle: logEntry.ActionTitle,
ActionIcon: logEntry.ActionIcon, ActionIcon: logEntry.ActionIcon,
ActionId: logEntry.ActionId,
DatetimeStarted: logEntry.DatetimeStarted, DatetimeStarted: logEntry.DatetimeStarted,
DatetimeFinished: logEntry.DatetimeFinished, DatetimeFinished: logEntry.DatetimeFinished,
Stdout: logEntry.Stdout, Stdout: logEntry.Stdout,
@ -155,7 +135,7 @@ func internalLogEntryToPb(logEntry *executor.InternalLogEntry) *pb.LogEntry {
Blocked: logEntry.Blocked, Blocked: logEntry.Blocked,
ExitCode: logEntry.ExitCode, ExitCode: logEntry.ExitCode,
Tags: logEntry.Tags, Tags: logEntry.Tags,
ExecutionUuid: logEntry.UUID, ExecutionTrackingId: logEntry.ExecutionTrackingID,
ExecutionStarted: logEntry.ExecutionStarted, ExecutionStarted: logEntry.ExecutionStarted,
ExecutionFinished: logEntry.ExecutionFinished, ExecutionFinished: logEntry.ExecutionFinished,
} }
@ -164,7 +144,7 @@ func internalLogEntryToPb(logEntry *executor.InternalLogEntry) *pb.LogEntry {
func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *pb.ExecutionStatusRequest) (*pb.ExecutionStatusResponse, error) { func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *pb.ExecutionStatusRequest) (*pb.ExecutionStatusResponse, error) {
res := &pb.ExecutionStatusResponse{} res := &pb.ExecutionStatusResponse{}
logEntry, ok := api.executor.Logs[req.ExecutionUuid] logEntry, ok := api.executor.Logs[req.ExecutionTrackingId]
if !ok { if !ok {
return res, nil 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. // 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 := internalLogEntryToPb(logEntry)
pbLogEntry.ExecutionUuid = uuid pbLogEntry.ExecutionTrackingId = trackingId
ret.Logs = append(ret.Logs, pbLogEntry) 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) { func (api *oliveTinAPI) SosReport(ctx ctx.Context, req *pb.SosReportRequest) (*pb.SosReportResponse, error) {
res := &pb.SosReportResponse{ sos := installationinfo.GetSosReport()
Alert: "Your SOS Report has been logged to OliveTin logs.",
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 return res, nil
} }

View File

@ -6,29 +6,83 @@ import (
pb "github.com/OliveTin/OliveTin/gen/grpc" pb "github.com/OliveTin/OliveTin/gen/grpc"
acl "github.com/OliveTin/OliveTin/internal/acl" acl "github.com/OliveTin/OliveTin/internal/acl"
config "github.com/OliveTin/OliveTin/internal/config" 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{} res := &pb.GetDashboardComponentsResponse{}
for _, action := range cfgActions { for _, action := range cfgActions {
if !acl.IsAllowedView(cfg, user, &action) { if !acl.IsAllowedView(cfg, user, action) {
continue continue
} }
if action.Entity != "" {
res.Actions = append(res.Actions, buildActionEntities(action.Entity, action)...)
} else {
btn := actionCfgToPb(action, user) btn := actionCfgToPb(action, user)
res.Actions = append(res.Actions, btn) res.Actions = append(res.Actions, btn)
} }
}
return res 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{ btn := pb.Action{
Id: fmt.Sprintf("%x", md5.Sum([]byte(action.Title))), Id: virtualActionId,
Title: action.Title, Title: action.Title,
Icon: action.Icon, Icon: action.Icon,
CanExec: acl.IsAllowedExec(cfg, user, &action), CanExec: acl.IsAllowedExec(cfg, user, action),
PopupOnStart: action.PopupOnStart, PopupOnStart: action.PopupOnStart,
} }
@ -63,13 +117,16 @@ func buildChoices(choices []config.ActionArgumentChoice) []*pb.ActionArgumentCho
return ret return ret
} }
func findActionByAlias(alias string) *config.Action { func createPublicID(action *config.Action, entityPrefix string) string {
for _, action := range cfg.Actions { return fmt.Sprintf("%x", md5.Sum([]byte(action.ID+"."+entityPrefix)))
if action.TitleAlias != "" {
if action.TitleAlias == alias {
return &action
}
} }
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 return nil

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -58,6 +58,7 @@ func (WebsocketExecutionListener) OnExecutionFinished(logEntry *executor.Interna
le := &pb.LogEntry{ le := &pb.LogEntry{
ActionTitle: logEntry.ActionTitle, ActionTitle: logEntry.ActionTitle,
ActionIcon: logEntry.ActionIcon, ActionIcon: logEntry.ActionIcon,
ActionId: logEntry.ActionId,
DatetimeStarted: logEntry.DatetimeStarted, DatetimeStarted: logEntry.DatetimeStarted,
DatetimeFinished: logEntry.DatetimeFinished, DatetimeFinished: logEntry.DatetimeFinished,
Stdout: logEntry.Stdout, Stdout: logEntry.Stdout,
@ -66,7 +67,7 @@ func (WebsocketExecutionListener) OnExecutionFinished(logEntry *executor.Interna
Blocked: logEntry.Blocked, Blocked: logEntry.Blocked,
ExitCode: logEntry.ExitCode, ExitCode: logEntry.ExitCode,
Tags: logEntry.Tags, Tags: logEntry.Tags,
Uuid: logEntry.UUID, ExecutionTrackingId: logEntry.ExecutionTrackingID,
ExecutionStarted: logEntry.ExecutionStarted, ExecutionStarted: logEntry.ExecutionStarted,
ExecutionFinished: logEntry.ExecutionFinished, ExecutionFinished: logEntry.ExecutionFinished,
} }

View File

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

View File

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

12
var/entities/servers.yaml Normal file
View File

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

View File

@ -189,7 +189,7 @@
This is the bootstrap code, which relies on very simple, old javascript 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. 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) clearBigErrors(type)
console.error('Error ' + type + ': ', message) console.error('Error ' + type + ': ', message)
@ -202,7 +202,7 @@
document.body.prepend(domErr) document.body.prepend(domErr)
} }
function clearBigErrors(additionalClass) { window.clearBigErrors = function (additionalClass) {
let selector = 'div.error' let selector = 'div.error'
if (additionalClass != null) { if (additionalClass != null) {

View File

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

View File

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

View File

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