feature: The mega dashboards & entities commit.
This commit is contained in:
parent
fddf83f27d
commit
381bf59fbd
3
Makefile
3
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
|
||||
|
|
|
|||
|
|
@ -39,24 +39,23 @@ message GetDashboardComponentsResponse {
|
|||
string title = 1;
|
||||
repeated Action actions = 2;
|
||||
repeated Entity entities = 3;
|
||||
repeated DashboardItem dashboards = 4;
|
||||
repeated DashboardComponent dashboards = 4;
|
||||
}
|
||||
|
||||
message GetDashboardComponentsRequest {}
|
||||
|
||||
message DashboardItem {
|
||||
message DashboardComponent {
|
||||
string title = 1;
|
||||
string type = 2;
|
||||
repeated DashboardItem contents = 3;
|
||||
string link = 4;
|
||||
repeated DashboardComponent contents = 3;
|
||||
}
|
||||
|
||||
message StartActionRequest {
|
||||
string action_name = 1;
|
||||
string action_id = 1;
|
||||
|
||||
repeated StartActionArgument arguments = 2;
|
||||
|
||||
string uuid = 3;
|
||||
string unique_tracking_id = 3;
|
||||
}
|
||||
|
||||
message StartActionArgument {
|
||||
|
|
@ -65,30 +64,30 @@ message StartActionArgument {
|
|||
}
|
||||
|
||||
message StartActionResponse {
|
||||
string execution_uuid = 2;
|
||||
string execution_tracking_id = 2;
|
||||
}
|
||||
|
||||
message StartActionAndWaitRequest {
|
||||
string action_name = 1;
|
||||
string action_id = 1;
|
||||
}
|
||||
|
||||
message StartActionAndWaitResponse {
|
||||
LogEntry log_entry = 1;
|
||||
}
|
||||
|
||||
message StartActionByAliasRequest {
|
||||
string action_alias = 1;
|
||||
message StartActionByGetRequest {
|
||||
string action_id = 1;
|
||||
}
|
||||
|
||||
message StartActionByAliasResponse {
|
||||
string execution_uuid = 2;
|
||||
message StartActionByGetResponse {
|
||||
string execution_tracking_id = 2;
|
||||
}
|
||||
|
||||
message StartActionByAliasAndWaitRequest {
|
||||
string action_alias = 1;
|
||||
message StartActionByGetAndWaitRequest {
|
||||
string action_id = 1;
|
||||
}
|
||||
|
||||
message StartActionByAliasAndWaitResponse {
|
||||
message StartActionByGetAndWaitResponse {
|
||||
LogEntry log_entry = 1;
|
||||
}
|
||||
|
||||
|
|
@ -105,9 +104,9 @@ message LogEntry {
|
|||
string user_class = 8;
|
||||
string action_icon = 9;
|
||||
repeated string tags = 10;
|
||||
string execution_uuid = 11;
|
||||
string execution_tracking_id = 11;
|
||||
string datetime_finished = 12;
|
||||
string uuid = 13;
|
||||
string action_id = 13;
|
||||
bool execution_started = 14;
|
||||
bool execution_finished = 15;
|
||||
bool blocked = 16;
|
||||
|
|
@ -128,7 +127,7 @@ message ValidateArgumentTypeResponse {
|
|||
}
|
||||
|
||||
message WatchExecutionRequest {
|
||||
string execution_uuid = 1;
|
||||
string execution_tracking_id = 1;
|
||||
}
|
||||
|
||||
message WatchExecutionUpdate {
|
||||
|
|
@ -136,7 +135,7 @@ message WatchExecutionUpdate {
|
|||
}
|
||||
|
||||
message ExecutionStatusRequest {
|
||||
string execution_uuid = 1;
|
||||
string execution_tracking_id = 1;
|
||||
}
|
||||
|
||||
message ExecutionStatusResponse {
|
||||
|
|
@ -155,6 +154,24 @@ message SosReportResponse {
|
|||
string alert = 1;
|
||||
}
|
||||
|
||||
message DumpVarsRequest {}
|
||||
|
||||
message DumpVarsResponse {
|
||||
string alert = 1;
|
||||
map<string, string> contents = 2;
|
||||
}
|
||||
|
||||
message ActionEntityPair {
|
||||
string action_title = 1;
|
||||
string entity_prefix = 2;
|
||||
}
|
||||
|
||||
message DumpPublicIdActionMapRequest {}
|
||||
message DumpPublicIdActionMapResponse {
|
||||
string alert = 1;
|
||||
map<string, ActionEntityPair> contents = 2;
|
||||
}
|
||||
|
||||
message GetReadyzRequest {}
|
||||
|
||||
message GetReadyzResponse {
|
||||
|
|
@ -182,15 +199,15 @@ service OliveTinApiService {
|
|||
};
|
||||
}
|
||||
|
||||
rpc StartActionByAlias(StartActionByAliasRequest) returns (StartActionByAliasResponse) {
|
||||
rpc StartActionByGet(StartActionByGetRequest) returns (StartActionByGetResponse) {
|
||||
option (google.api.http) = {
|
||||
get: "/api/StartActionByAlias/{action_alias}"
|
||||
get: "/api/StartActionByGet/{action_id}"
|
||||
};
|
||||
}
|
||||
|
||||
rpc StartActionByAliasAndWait(StartActionByAliasAndWaitRequest) returns (StartActionByAliasAndWaitResponse) {
|
||||
rpc StartActionByGetAndWait(StartActionByGetAndWaitRequest) returns (StartActionByGetAndWaitResponse) {
|
||||
option (google.api.http) = {
|
||||
get: "/api/StartActionByAliasAndWait/{action_alias}"
|
||||
get: "/api/StartActionByGetAndWait/{action_id}"
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -226,6 +243,18 @@ service OliveTinApiService {
|
|||
};
|
||||
}
|
||||
|
||||
rpc DumpVars(DumpVarsRequest) returns (DumpVarsResponse) {
|
||||
option (google.api.http) = {
|
||||
get: "/api/DumpVars"
|
||||
};
|
||||
}
|
||||
|
||||
rpc DumpPublicIdActionMap(DumpPublicIdActionMapRequest) returns (DumpPublicIdActionMapResponse) {
|
||||
option (google.api.http) = {
|
||||
get: "/api/DumpPublicIdActionMap"
|
||||
};
|
||||
}
|
||||
|
||||
rpc GetReadyz(GetReadyzRequest) returns (GetReadyzResponse) {
|
||||
option (google.api.http) = {
|
||||
get: "/api/readyz"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
230
config.yaml
230
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 <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: '🇦'
|
||||
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: <strong>{{ server.name }}</strong>
|
||||
IP Address: <strong>{{ server.ip }}</strong>
|
||||
|
||||
# These are the actions (defined above) that we want on the dashboard.
|
||||
- title: '{{ server.name }} Wake on Lan'
|
||||
- title: '{{ server.name }} Power Off'
|
||||
|
||||
# This is the second dashboard.
|
||||
- title: My Containers
|
||||
contents:
|
||||
- title: Container {{ container.Names }}
|
||||
entity: container
|
||||
type: fieldset
|
||||
contents:
|
||||
- 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 }} <br /><br /><strong>{{ container.State }}</strong>
|
||||
- title: 'Start {{ container.Names }}'
|
||||
- title: 'Stop {{ container.Names }}'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
func TestSanitizeConfig(t *testing.T) {
|
||||
c := DefaultConfig()
|
||||
|
||||
a := Action{
|
||||
a := &Action{
|
||||
Title: "Mr Waffles",
|
||||
Arguments: []ActionArgument{
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
sonarr:
|
||||
state: started
|
||||
plex:
|
||||
state: started
|
||||
sabnzbd:
|
||||
state: stopped
|
||||
|
|
@ -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
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -187,11 +187,11 @@ function marshalDashboardStructureToHtml (json) {
|
|||
}
|
||||
|
||||
function marshalLink (item, fieldset) {
|
||||
let btn = window.actionButtons[item.link]
|
||||
let btn = window.actionButtons[item.title]
|
||||
|
||||
if (typeof btn === 'undefined') {
|
||||
btn = document.createElement('button')
|
||||
btn.innerText = 'Action not found: ' + item.link
|
||||
btn.innerText = 'Action not found: ' + item.title
|
||||
btn.classList.add('error')
|
||||
}
|
||||
|
||||
|
|
@ -208,6 +208,9 @@ function marshalContainerContents (json, section, fieldset, parentDashboard) {
|
|||
marshalDirectoryButton(item, fieldset)
|
||||
marshalDirectory(item, section)
|
||||
break
|
||||
case 'display':
|
||||
marshalDisplay(item, fieldset)
|
||||
break
|
||||
case 'link':
|
||||
marshalLink(item, fieldset)
|
||||
break
|
||||
|
|
@ -335,6 +338,13 @@ function createDirectoryBreadcrumb (title, link) {
|
|||
return a
|
||||
}
|
||||
|
||||
function marshalDisplay (item, fieldset) {
|
||||
const display = document.createElement('div')
|
||||
display.innerHTML = item.title
|
||||
|
||||
fieldset.appendChild(display)
|
||||
}
|
||||
|
||||
function marshalDirectoryButton (item, fieldset) {
|
||||
const directoryButton = document.createElement('button')
|
||||
directoryButton.innerHTML = '<span class = "icon">📁</span> ' + item.title
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue