Explorar el Código

feature: websocket implemenation at last!

jamesread hace 2 años
padre
commit
e5a870ed94

+ 29 - 18
OliveTin.proto

@@ -8,16 +8,16 @@ message Action {
 	string id = 1;
 	string id = 1;
 	string title = 2;
 	string title = 2;
 	string icon = 3;
 	string icon = 3;
-	bool canExec = 4;
+	bool can_exec = 4;
 	repeated ActionArgument arguments = 5;
 	repeated ActionArgument arguments = 5;
-	bool popupOnStart = 6;
+	bool popup_on_start = 6;
 }
 }
 
 
 message ActionArgument {
 message ActionArgument {
 	string name = 1;
 	string name = 1;
 	string title = 2;
 	string title = 2;
 	string type = 3;
 	string type = 3;
-	string defaultValue = 4;
+	string default_value = 4;
 
 
 	repeated ActionArgumentChoice choices = 5;
 	repeated ActionArgumentChoice choices = 5;
 
 
@@ -44,9 +44,11 @@ message GetDashboardComponentsResponse {
 message GetDashboardComponentsRequest {}
 message GetDashboardComponentsRequest {}
 
 
 message StartActionRequest {
 message StartActionRequest {
-	string actionName = 1;
+	string action_name = 1;
 
 
 	repeated StartActionArgument arguments = 2;
 	repeated StartActionArgument arguments = 2;
+
+	string uuid = 3;
 }
 }
 
 
 message StartActionArgument {
 message StartActionArgument {
@@ -55,24 +57,25 @@ message StartActionArgument {
 }
 }
 
 
 message StartActionResponse {
 message StartActionResponse {
-	string executionUuid = 2;
+	string execution_uuid = 2;
 }
 }
 
 
 message GetLogsRequest{};
 message GetLogsRequest{};
 
 
 message LogEntry {
 message LogEntry {
-	string datetimeStarted = 1;
-	string actionTitle = 2;
+	string datetime_started = 1;
+	string action_title = 2;
 	string stdout = 3;
 	string stdout = 3;
 	string stderr = 4;
 	string stderr = 4;
-	bool timedOut = 5;
-	int32 exitCode = 6;
+	bool timed_out = 5;
+	int32 exit_code = 6;
 	string user = 7;
 	string user = 7;
-	string userClass = 8;
-	string actionIcon = 9;
+	string user_class = 8;
+	string action_icon = 9;
 	repeated string tags = 10;
 	repeated string tags = 10;
-	string executionUuid = 11;
-	string datetimeFinished = 12;
+	string execution_uuid = 11;
+	string datetime_finished = 12;
+	string uuid = 13;
 }
 }
 
 
 message GetLogsResponse {
 message GetLogsResponse {
@@ -90,17 +93,25 @@ message ValidateArgumentTypeResponse {
 }
 }
 
 
 message WatchExecutionRequest {
 message WatchExecutionRequest {
-	string executionUuid = 1;
+	string execution_uuid = 1;
 }
 }
 
 
 message WatchExecutionUpdate {
 message WatchExecutionUpdate {
 	string update = 1;
 	string update = 1;
 }
 }
 
 
+message ExecutionStatusRequest {
+	string execution_uuid = 1;
+}
+
+message ExecutionStatusResponse {
+	LogEntry log_entry = 1;
+}
+
 message WhoAmIRequest {}
 message WhoAmIRequest {}
 
 
 message WhoAmIResponse {
 message WhoAmIResponse {
-	string authenticatedUser = 1;
+	string authenticated_user = 1;
 }
 }
 
 
 message SosReportRequest {}
 message SosReportRequest {}
@@ -109,7 +120,7 @@ message SosReportResponse {
 	string alert = 1;
 	string alert = 1;
 }
 }
 
 
-service OliveTinApi {
+service OliveTinApiService {
 	rpc GetDashboardComponents(GetDashboardComponentsRequest) returns (GetDashboardComponentsResponse) {
 	rpc GetDashboardComponents(GetDashboardComponentsRequest) returns (GetDashboardComponentsResponse) {
 		option (google.api.http) = {
 		option (google.api.http) = {
 			get: "/api/GetDashboardComponents"
 			get: "/api/GetDashboardComponents"
@@ -123,9 +134,9 @@ service OliveTinApi {
 		};
 		};
 	}
 	}
 
 
-	rpc WatchExecution(WatchExecutionRequest) returns (stream WatchExecutionUpdate) {
+	rpc ExecutionStatus(ExecutionStatusRequest) returns (ExecutionStatusResponse) {
 		option (google.api.http) = {
 		option (google.api.http) = {
-			post: "/api/WatchExecution"
+			post: "/api/ExecutionStatus"
 			body: "*"
 			body: "*"
 		};
 		};
 	}
 	}

+ 2 - 0
cmd/OliveTin/main.go

@@ -11,6 +11,7 @@ import (
 	"github.com/OliveTin/OliveTin/internal/oncron"
 	"github.com/OliveTin/OliveTin/internal/oncron"
 	"github.com/OliveTin/OliveTin/internal/onstartup"
 	"github.com/OliveTin/OliveTin/internal/onstartup"
 	updatecheck "github.com/OliveTin/OliveTin/internal/updatecheck"
 	updatecheck "github.com/OliveTin/OliveTin/internal/updatecheck"
+	"github.com/OliveTin/OliveTin/internal/websocket"
 
 
 	"github.com/OliveTin/OliveTin/internal/httpservers"
 	"github.com/OliveTin/OliveTin/internal/httpservers"
 
 
@@ -110,6 +111,7 @@ func main() {
 	log.Debugf("Config: %+v", cfg)
 	log.Debugf("Config: %+v", cfg)
 
 
 	executor := executor.DefaultExecutor()
 	executor := executor.DefaultExecutor()
+	executor.AddListener(websocket.ExecutionListener)
 
 
 	go onstartup.Execute(cfg, executor)
 	go onstartup.Execute(cfg, executor)
 	go oncron.Schedule(cfg, executor)
 	go oncron.Schedule(cfg, executor)

+ 1 - 1
go.mod

@@ -9,6 +9,7 @@ require (
 	github.com/go-critic/go-critic v0.8.2
 	github.com/go-critic/go-critic v0.8.2
 	github.com/golang-jwt/jwt/v4 v4.5.0
 	github.com/golang-jwt/jwt/v4 v4.5.0
 	github.com/google/uuid v1.3.0
 	github.com/google/uuid v1.3.0
+	github.com/gorilla/websocket v1.4.1
 	github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.2
 	github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.2
 	github.com/robfig/cron/v3 v3.0.1
 	github.com/robfig/cron/v3 v3.0.1
 	github.com/sirupsen/logrus v1.9.3
 	github.com/sirupsen/logrus v1.9.3
@@ -20,7 +21,6 @@ require (
 	google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.3.0
 	google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.3.0
 	google.golang.org/protobuf v1.31.0
 	google.golang.org/protobuf v1.31.0
 	gopkg.in/yaml.v3 v3.0.1
 	gopkg.in/yaml.v3 v3.0.1
-	nhooyr.io/websocket v1.8.7
 )
 )
 
 
 require (
 require (

+ 0 - 39
go.sum

@@ -94,10 +94,6 @@ github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4
 github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
 github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
 github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
 github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
 github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA=
 github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA=
-github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
-github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
-github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14=
-github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
 github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
 github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
 github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
 github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
 github.com/go-critic/go-critic v0.8.2 h1:mekhZ9jw5NBEj3I8o/EywXw5zBfGAJuMo4VVVjtxF80=
 github.com/go-critic/go-critic v0.8.2 h1:mekhZ9jw5NBEj3I8o/EywXw5zBfGAJuMo4VVVjtxF80=
@@ -110,13 +106,6 @@ github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
 github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
 github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
-github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
-github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
-github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
-github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no=
-github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
-github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY=
-github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
 github.com/go-toolsmith/astcast v1.1.0 h1:+JN9xZV1A+Re+95pgnMgDboWNVnIMMQXwfBwLRPgSC8=
 github.com/go-toolsmith/astcast v1.1.0 h1:+JN9xZV1A+Re+95pgnMgDboWNVnIMMQXwfBwLRPgSC8=
 github.com/go-toolsmith/astcast v1.1.0/go.mod h1:qdcuFWeGGS2xX5bLM/c3U9lewg7+Zu4mr+xPwZIB4ZU=
 github.com/go-toolsmith/astcast v1.1.0/go.mod h1:qdcuFWeGGS2xX5bLM/c3U9lewg7+Zu4mr+xPwZIB4ZU=
 github.com/go-toolsmith/astcopy v1.1.0 h1:YGwBN0WM+ekI/6SS6+52zLDEf8Yvp3n2seZITCUBt5s=
 github.com/go-toolsmith/astcopy v1.1.0 h1:YGwBN0WM+ekI/6SS6+52zLDEf8Yvp3n2seZITCUBt5s=
@@ -135,12 +124,6 @@ github.com/go-toolsmith/strparse v1.1.0 h1:GAioeZUK9TGxnLS+qfdqNbA4z0SSm5zVNtCQi
 github.com/go-toolsmith/strparse v1.1.0/go.mod h1:7ksGy58fsaQkGQlY8WVoBFNyEPMGuJin1rfoPS4lBSQ=
 github.com/go-toolsmith/strparse v1.1.0/go.mod h1:7ksGy58fsaQkGQlY8WVoBFNyEPMGuJin1rfoPS4lBSQ=
 github.com/go-toolsmith/typep v1.1.0 h1:fIRYDyF+JywLfqzyhdiHzRop/GQDxxNhLGQ6gFUNHus=
 github.com/go-toolsmith/typep v1.1.0 h1:fIRYDyF+JywLfqzyhdiHzRop/GQDxxNhLGQ6gFUNHus=
 github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig=
 github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig=
-github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0=
-github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
-github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
-github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
-github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
-github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
 github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
 github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
 github.com/gofrs/uuid/v5 v5.0.0 h1:p544++a97kEL+svbcFbCQVM9KFu0Yo25UoISXGNNH9M=
 github.com/gofrs/uuid/v5 v5.0.0 h1:p544++a97kEL+svbcFbCQVM9KFu0Yo25UoISXGNNH9M=
 github.com/gofrs/uuid/v5 v5.0.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
 github.com/gofrs/uuid/v5 v5.0.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
@@ -194,7 +177,6 @@ github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
 github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
 github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
 github.com/google/go-containerregistry v0.16.1 h1:rUEt426sR6nyrL3gt+18ibRcvYpKYdpsa5ZW7MA08dQ=
 github.com/google/go-containerregistry v0.16.1 h1:rUEt426sR6nyrL3gt+18ibRcvYpKYdpsa5ZW7MA08dQ=
 github.com/google/go-containerregistry v0.16.1/go.mod h1:u0qB2l7mvtWVR5kNcbFIhFY1hLbf8eeGapA+vbFDCtQ=
 github.com/google/go-containerregistry v0.16.1/go.mod h1:u0qB2l7mvtWVR5kNcbFIhFY1hLbf8eeGapA+vbFDCtQ=
-github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
 github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
 github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
 github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
 github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
 github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
@@ -234,13 +216,10 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf
 github.com/jdxcode/netrc v0.0.0-20221124155335-4616370d1a84 h1:2uT3aivO7NVpUPGcQX7RbHijHMyWix/yCnIrCWc+5co=
 github.com/jdxcode/netrc v0.0.0-20221124155335-4616370d1a84 h1:2uT3aivO7NVpUPGcQX7RbHijHMyWix/yCnIrCWc+5co=
 github.com/jdxcode/netrc v0.0.0-20221124155335-4616370d1a84/go.mod h1:Zi/ZFkEqFHTm7qkjyNJjaWH4LQA9LQhGJyF0lTYGpxw=
 github.com/jdxcode/netrc v0.0.0-20221124155335-4616370d1a84/go.mod h1:Zi/ZFkEqFHTm7qkjyNJjaWH4LQA9LQhGJyF0lTYGpxw=
 github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c=
 github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c=
-github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
-github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
 github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
 github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
 github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
 github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
 github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
 github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
-github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
 github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
 github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
 github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
 github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
 github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
 github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
@@ -251,22 +230,14 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
-github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
-github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
 github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
 github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
 github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
 github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
-github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
-github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
 github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
 github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
 github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
 github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
 github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
 github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
 github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
 github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
 github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
 github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
 github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
 github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
-github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
-github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
-github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
-github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
 github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
 github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
 github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
 github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
 github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
 github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
@@ -320,7 +291,6 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
-github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
 github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -333,10 +303,6 @@ github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8
 github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
 github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
 github.com/tetratelabs/wazero v1.4.0 h1:9/MirYvmkJ/zSUOygKY/ia3t+e+RqIZXKbylIby1WYk=
 github.com/tetratelabs/wazero v1.4.0 h1:9/MirYvmkJ/zSUOygKY/ia3t+e+RqIZXKbylIby1WYk=
 github.com/tetratelabs/wazero v1.4.0/go.mod h1:0U0G41+ochRKoPKCJlh0jMg1CHkyfK8kDqiirMmKY8A=
 github.com/tetratelabs/wazero v1.4.0/go.mod h1:0U0G41+ochRKoPKCJlh0jMg1CHkyfK8kDqiirMmKY8A=
-github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
-github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
-github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
-github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
 github.com/vbatts/tar-split v0.11.5 h1:3bHCTIheBm1qFTcgh9oPu+nNBtX+XJIupG/vacinCts=
 github.com/vbatts/tar-split v0.11.5 h1:3bHCTIheBm1qFTcgh9oPu+nNBtX+XJIupG/vacinCts=
 github.com/vbatts/tar-split v0.11.5/go.mod h1:yZbwRsSeGjusneWgA781EKej9HF8vme8okylkAeNKLk=
 github.com/vbatts/tar-split v0.11.5/go.mod h1:yZbwRsSeGjusneWgA781EKej9HF8vme8okylkAeNKLk=
 github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -482,7 +448,6 @@ golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -691,8 +656,6 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
 gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
 gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
 gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
 gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
-gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -704,8 +667,6 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
 honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
 honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
 honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
 honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
-nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g=
-nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0=
 rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
 rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
 rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
 rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
 rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
 rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=

+ 1 - 1
internal/acl/acl.go

@@ -90,7 +90,7 @@ func UserFromContext(ctx context.Context, cfg *config.Config) *AuthenticatedUser
 	log.WithFields(log.Fields{
 	log.WithFields(log.Fields{
 		"username":  ret.Username,
 		"username":  ret.Username,
 		"usergroup": ret.Usergroup,
 		"usergroup": ret.Usergroup,
-	}).Infof("UserFromContext")
+	}).Debugf("UserFromContext")
 
 
 	return ret
 	return ret
 }
 }

+ 6 - 4
internal/executor/arguments.go

@@ -22,8 +22,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) (string, error) {
 	log.WithFields(log.Fields{
 	log.WithFields(log.Fields{
-		"cmd": rawShellCommand,
-	}).Infof("Before Parse Args")
+		"actionTitle": action.Title,
+		"cmd":         rawShellCommand,
+	}).Infof("Action parse args - Before")
 
 
 	r := regexp.MustCompile("{{ *?([a-zA-Z0-9_]+?) *?}}")
 	r := regexp.MustCompile("{{ *?([a-zA-Z0-9_]+?) *?}}")
 	matches := r.FindAllStringSubmatch(rawShellCommand, -1)
 	matches := r.FindAllStringSubmatch(rawShellCommand, -1)
@@ -51,8 +52,9 @@ func parseActionArguments(rawShellCommand string, values map[string]string, acti
 	}
 	}
 
 
 	log.WithFields(log.Fields{
 	log.WithFields(log.Fields{
-		"cmd": rawShellCommand,
-	}).Infof("After Parse Args")
+		"actionTitle": action.Title,
+		"cmd":         rawShellCommand,
+	}).Infof("Action parse args - After")
 
 
 	return rawShellCommand, nil
 	return rawShellCommand, nil
 }
 }

+ 71 - 33
internal/executor/executor.go

@@ -4,7 +4,6 @@ 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"
-	"github.com/google/uuid"
 	log "github.com/sirupsen/logrus"
 	log "github.com/sirupsen/logrus"
 
 
 	"bytes"
 	"bytes"
@@ -15,56 +14,59 @@ import (
 	"time"
 	"time"
 )
 )
 
 
+// Executor represents a helper class for executing commands. It's main method
+// is ExecRequest
+type Executor struct {
+	Logs map[string]*InternalLogEntry
+
+	listeners []listener
+
+	chainOfCommand []executorStepFunc
+}
+
 // 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
 	ActionName         string
 	Arguments          map[string]string
 	Arguments          map[string]string
+	UUID               string
 	Tags               []string
 	Tags               []string
 	action             *config.Action
 	action             *config.Action
 	Cfg                *config.Config
 	Cfg                *config.Config
 	AuthenticatedUser  *acl.AuthenticatedUser
 	AuthenticatedUser  *acl.AuthenticatedUser
 	logEntry           *InternalLogEntry
 	logEntry           *InternalLogEntry
 	finalParsedCommand string
 	finalParsedCommand string
-	uuid               string
+	executor           *Executor
 }
 }
 
 
 // InternalLogEntry objects are created by an Executor, and represent the final
 // InternalLogEntry objects are created by an Executor, and represent the final
 // state of execution (even if the command is not executed). It's designed to be
 // state of execution (even if the command is not executed). It's designed to be
 // easily serializable.
 // easily serializable.
 type InternalLogEntry struct {
 type InternalLogEntry struct {
-	DatetimeStarted  string
-	DatetimeFinished string
-	Stdout           string
-	Stderr           string
-	StdoutBuffer     io.ReadCloser
-	StderrBuffer     io.ReadCloser
-	TimedOut         bool
-	ExitCode         int32
-	Tags             []string
+	DatetimeStarted    string
+	DatetimeFinished   string
+	Stdout             string
+	Stderr             string
+	StdoutBuffer       io.ReadCloser
+	StderrBuffer       io.ReadCloser
+	TimedOut           bool
+	ExitCode           int32
+	Tags               []string
+	ExecutionStarted   bool
+	ExecutionCompleted bool
 
 
 	/*
 	/*
-		The following two properties are obviously on Action normally, but it's useful
+		The following 3 properties are obviously on Action normally, but it's useful
 		that logs are lightweight (so we don't need to have an action associated to
 		that logs are lightweight (so we don't need to have an action associated to
 		logs, etc. Therefore, we duplicate those values here.
 		logs, etc. Therefore, we duplicate those values here.
 	*/
 	*/
 	ActionTitle string
 	ActionTitle string
 	ActionIcon  string
 	ActionIcon  string
-
-	ExecutionStarted   bool
-	ExecutionCompleted bool
+	UUID        string
 }
 }
 
 
 type executorStepFunc func(*ExecutionRequest) bool
 type executorStepFunc func(*ExecutionRequest) bool
 
 
-// Executor represents a helper class for executing commands. It's main method
-// is ExecRequest
-type Executor struct {
-	Logs map[string]*InternalLogEntry
-
-	chainOfCommand []executorStepFunc
-}
-
 // DefaultExecutor returns an Executor, with a sensible "chain of command" for
 // DefaultExecutor returns an Executor, with a sensible "chain of command" for
 // executing actions.
 // executing actions.
 func DefaultExecutor() *Executor {
 func DefaultExecutor() *Executor {
@@ -72,23 +74,38 @@ func DefaultExecutor() *Executor {
 	e.Logs = make(map[string]*InternalLogEntry)
 	e.Logs = make(map[string]*InternalLogEntry)
 
 
 	e.chainOfCommand = []executorStepFunc{
 	e.chainOfCommand = []executorStepFunc{
+		stepLogRequested,
 		stepFindAction,
 		stepFindAction,
 		stepACLCheck,
 		stepACLCheck,
 		stepParseArgs,
 		stepParseArgs,
 		stepLogStart,
 		stepLogStart,
 		stepExec,
 		stepExec,
+		stepNotifyListeners,
 		stepLogFinish,
 		stepLogFinish,
 	}
 	}
 
 
 	return &e
 	return &e
 }
 }
 
 
+type listener interface {
+	OnExecutionStarted(actionName string)
+	OnExecutionFinished(logEntry *InternalLogEntry)
+}
+
+func (e *Executor) AddListener(m listener) {
+	e.listeners = append(e.listeners, m)
+}
+
 // ExecRequest processes an ExecutionRequest
 // ExecRequest processes an ExecutionRequest
 func (e *Executor) ExecRequest(req *ExecutionRequest) *pb.StartActionResponse {
 func (e *Executor) ExecRequest(req *ExecutionRequest) *pb.StartActionResponse {
-	req.uuid = uuid.New().String()
+	// req.UUID is now set by the client, so that they can track the request
+	// from start to finish. This means that a malicious client could send
+	// duplicate UUIDs (or just random strings), but this is the only way.
+	req.executor = e
 	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,
 		ActionTitle:        req.ActionName,
+		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.
@@ -96,12 +113,16 @@ func (e *Executor) ExecRequest(req *ExecutionRequest) *pb.StartActionResponse {
 		ExecutionCompleted: false,
 		ExecutionCompleted: false,
 	}
 	}
 
 
-	e.Logs[req.uuid] = req.logEntry
+	e.Logs[req.UUID] = req.logEntry
+
+	for _, listener := range e.listeners {
+		listener.OnExecutionStarted(req.ActionName)
+	}
 
 
 	go e.execChain(req)
 	go e.execChain(req)
 
 
 	return &pb.StartActionResponse{
 	return &pb.StartActionResponse{
-		ExecutionUuid: req.uuid,
+		ExecutionUuid: req.UUID,
 	}
 	}
 }
 }
 
 
@@ -152,10 +173,18 @@ func stepParseArgs(req *ExecutionRequest) bool {
 	return true
 	return true
 }
 }
 
 
+func stepLogRequested(req *ExecutionRequest) bool {
+	log.WithFields(log.Fields{
+		"actionTitle": req.ActionName,
+	}).Infof("Action requested")
+
+	return true
+}
+
 func stepLogStart(req *ExecutionRequest) bool {
 func stepLogStart(req *ExecutionRequest) bool {
 	log.WithFields(log.Fields{
 	log.WithFields(log.Fields{
-		"title":   req.action.Title,
-		"timeout": req.action.Timeout,
+		"actionTitle": req.action.Title,
+		"timeout":     req.action.Timeout,
 	}).Infof("Action starting")
 	}).Infof("Action starting")
 
 
 	return true
 	return true
@@ -163,16 +192,24 @@ func stepLogStart(req *ExecutionRequest) bool {
 
 
 func stepLogFinish(req *ExecutionRequest) bool {
 func stepLogFinish(req *ExecutionRequest) bool {
 	log.WithFields(log.Fields{
 	log.WithFields(log.Fields{
-		"title":    req.action.Title,
-		"stdout":   req.logEntry.Stdout,
-		"stderr":   req.logEntry.Stderr,
-		"timedOut": req.logEntry.TimedOut,
-		"exit":     req.logEntry.ExitCode,
+		"actionTitle": req.action.Title,
+		"stdout":      req.logEntry.Stdout,
+		"stderr":      req.logEntry.Stderr,
+		"timedOut":    req.logEntry.TimedOut,
+		"exit":        req.logEntry.ExitCode,
 	}).Infof("Action finished")
 	}).Infof("Action finished")
 
 
 	return true
 	return true
 }
 }
 
 
+func stepNotifyListeners(req *ExecutionRequest) bool {
+	for _, listener := range req.executor.listeners {
+		listener.OnExecutionFinished(req.logEntry)
+	}
+
+	return true
+}
+
 func wrapCommandInShell(ctx context.Context, finalParsedCommand string) *exec.Cmd {
 func wrapCommandInShell(ctx context.Context, finalParsedCommand string) *exec.Cmd {
 	if runtime.GOOS == "windows" {
 	if runtime.GOOS == "windows" {
 		return exec.CommandContext(ctx, "cmd", "/C", finalParsedCommand)
 		return exec.CommandContext(ctx, "cmd", "/C", finalParsedCommand)
@@ -217,6 +254,7 @@ func stepExec(req *ExecutionRequest) bool {
 	}
 	}
 
 
 	req.logEntry.Tags = req.Tags
 	req.logEntry.Tags = req.Tags
+	req.logEntry.DatetimeFinished = time.Now().Format("2006-01-02 15:04:05")
 
 
 	return true
 	return true
 }
 }

+ 37 - 3
internal/grpcapi/grpcApi.go

@@ -6,8 +6,8 @@ import (
 	log "github.com/sirupsen/logrus"
 	log "github.com/sirupsen/logrus"
 	"google.golang.org/grpc"
 	"google.golang.org/grpc"
 
 
-	"io"
 	"net"
 	"net"
+	"sort"
 
 
 	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"
@@ -20,7 +20,7 @@ var (
 )
 )
 
 
 type oliveTinAPI struct {
 type oliveTinAPI struct {
-	pb.UnimplementedOliveTinApiServer
+	pb.UnimplementedOliveTinApiServiceServer
 
 
 	executor *executor.Executor
 	executor *executor.Executor
 }
 }
@@ -36,6 +36,7 @@ func (api *oliveTinAPI) StartAction(ctx ctx.Context, req *pb.StartActionRequest)
 
 
 	execReq := executor.ExecutionRequest{
 	execReq := executor.ExecutionRequest{
 		ActionName:        req.ActionName,
 		ActionName:        req.ActionName,
+		UUID:              req.Uuid,
 		Arguments:         args,
 		Arguments:         args,
 		AuthenticatedUser: acl.UserFromContext(ctx, cfg),
 		AuthenticatedUser: acl.UserFromContext(ctx, cfg),
 		Cfg:               cfg,
 		Cfg:               cfg,
@@ -44,6 +45,32 @@ func (api *oliveTinAPI) StartAction(ctx ctx.Context, req *pb.StartActionRequest)
 	return api.executor.ExecRequest(&execReq), nil
 	return api.executor.ExecRequest(&execReq), nil
 }
 }
 
 
+func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *pb.ExecutionStatusRequest) (*pb.ExecutionStatusResponse, error) {
+	res := &pb.ExecutionStatusResponse{}
+
+	logEntry, ok := api.executor.Logs[req.ExecutionUuid]
+
+	if !ok {
+		return res, nil
+	}
+
+	res.LogEntry = &pb.LogEntry{
+		ActionTitle:      logEntry.ActionTitle,
+		ActionIcon:       logEntry.ActionIcon,
+		DatetimeStarted:  logEntry.DatetimeStarted,
+		DatetimeFinished: logEntry.DatetimeFinished,
+		Stdout:           logEntry.Stdout,
+		Stderr:           logEntry.Stderr,
+		TimedOut:         logEntry.TimedOut,
+		ExitCode:         logEntry.ExitCode,
+		Tags:             logEntry.Tags,
+		ExecutionUuid:    logEntry.UUID,
+	}
+
+	return res, nil
+}
+
+/**
 func (api *oliveTinAPI) WatchExecution(req *pb.WatchExecutionRequest, srv pb.OliveTinApi_WatchExecutionServer) error {
 func (api *oliveTinAPI) WatchExecution(req *pb.WatchExecutionRequest, srv pb.OliveTinApi_WatchExecutionServer) error {
 	log.Infof("Watch")
 	log.Infof("Watch")
 
 
@@ -69,6 +96,7 @@ func (api *oliveTinAPI) WatchExecution(req *pb.WatchExecutionRequest, srv pb.Oli
 		return nil
 		return nil
 	}
 	}
 }
 }
+*/
 
 
 func (api *oliveTinAPI) GetDashboardComponents(ctx ctx.Context, req *pb.GetDashboardComponentsRequest) (*pb.GetDashboardComponentsResponse, error) {
 func (api *oliveTinAPI) GetDashboardComponents(ctx ctx.Context, req *pb.GetDashboardComponentsRequest) (*pb.GetDashboardComponentsResponse, error) {
 	user := acl.UserFromContext(ctx, cfg)
 	user := acl.UserFromContext(ctx, cfg)
@@ -104,6 +132,12 @@ func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *pb.GetLogsRequest) (*pb.Ge
 		})
 		})
 	}
 	}
 
 
+	sorter := func(i, j int) bool {
+		return ret.Logs[i].DatetimeStarted < ret.Logs[j].DatetimeStarted
+	}
+
+	sort.Slice(ret.Logs, sorter);
+
 	return ret, nil
 	return ret, nil
 }
 }
 
 
@@ -159,7 +193,7 @@ func Start(globalConfig *config.Config, ex *executor.Executor) {
 	}
 	}
 
 
 	grpcServer := grpc.NewServer()
 	grpcServer := grpc.NewServer()
-	pb.RegisterOliveTinApiServer(grpcServer, newServer(ex))
+	pb.RegisterOliveTinApiServiceServer(grpcServer, newServer(ex))
 
 
 	err = grpcServer.Serve(lis)
 	err = grpcServer.Serve(lis)
 
 

+ 3 - 3
internal/grpcapi/grpcApi_test.go

@@ -26,7 +26,7 @@ func init() {
 
 
 	lis = bufconn.Listen(bufSize)
 	lis = bufconn.Listen(bufSize)
 	s := grpc.NewServer()
 	s := grpc.NewServer()
-	pb.RegisterOliveTinApiServer(s, newServer(ex))
+	pb.RegisterOliveTinApiServiceServer(s, newServer(ex))
 
 
 	go func() {
 	go func() {
 		if err := s.Serve(lis); err != nil {
 		if err := s.Serve(lis); err != nil {
@@ -39,7 +39,7 @@ func bufDialer(context.Context, string) (net.Conn, error) {
 	return lis.Dial()
 	return lis.Dial()
 }
 }
 
 
-func getNewTestServerAndClient(t *testing.T, injectedConfig *config.Config) (*grpc.ClientConn, pb.OliveTinApiClient) {
+func getNewTestServerAndClient(t *testing.T, injectedConfig *config.Config) (*grpc.ClientConn, pb.OliveTinApiServiceClient) {
 	cfg = injectedConfig
 	cfg = injectedConfig
 
 
 	ctx := context.Background()
 	ctx := context.Background()
@@ -50,7 +50,7 @@ func getNewTestServerAndClient(t *testing.T, injectedConfig *config.Config) (*gr
 		t.Fatalf("Failed to dial bufnet: %v", err)
 		t.Fatalf("Failed to dial bufnet: %v", err)
 	}
 	}
 
 
-	client := pb.NewOliveTinApiClient(conn)
+	client := pb.NewOliveTinApiServiceClient(conn)
 
 
 	return conn, client
 	return conn, client
 }
 }

+ 2 - 2
internal/httpservers/restapi.go

@@ -80,7 +80,7 @@ func startRestAPIServer(globalConfig *config.Config) error {
 		runtime.WithMarshalerOption(runtime.MIMEWildcard, &runtime.HTTPBodyMarshaler{
 		runtime.WithMarshalerOption(runtime.MIMEWildcard, &runtime.HTTPBodyMarshaler{
 			Marshaler: &runtime.JSONPb{
 			Marshaler: &runtime.JSONPb{
 				MarshalOptions: protojson.MarshalOptions{
 				MarshalOptions: protojson.MarshalOptions{
-					UseProtoNames:   true,
+					UseProtoNames:   false, // eg: canExec for js instead of can_exec from protobuf
 					EmitUnpopulated: true,
 					EmitUnpopulated: true,
 				},
 				},
 			},
 			},
@@ -88,7 +88,7 @@ func startRestAPIServer(globalConfig *config.Config) error {
 	)
 	)
 	opts := []grpc.DialOption{grpc.WithInsecure()}
 	opts := []grpc.DialOption{grpc.WithInsecure()}
 
 
-	err := gw.RegisterOliveTinApiHandlerFromEndpoint(ctx, mux, cfg.ListenAddressGrpcActions, opts)
+	err := gw.RegisterOliveTinApiServiceHandlerFromEndpoint(ctx, mux, cfg.ListenAddressGrpcActions, opts)
 
 
 	if err != nil {
 	if err != nil {
 		log.Errorf("Could not register REST API Handler %v", err)
 		log.Errorf("Could not register REST API Handler %v", err)

+ 2 - 1
internal/httpservers/singleFrontend.go

@@ -10,6 +10,7 @@ away, and several other issues.
 
 
 import (
 import (
 	config "github.com/OliveTin/OliveTin/internal/config"
 	config "github.com/OliveTin/OliveTin/internal/config"
+	"github.com/OliveTin/OliveTin/internal/websocket"
 	log "github.com/sirupsen/logrus"
 	log "github.com/sirupsen/logrus"
 	"net/http"
 	"net/http"
 	"net/http/httputil"
 	"net/http/httputil"
@@ -39,7 +40,7 @@ func StartSingleHTTPFrontend(cfg *config.Config) {
 
 
 	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
 	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
 		if strings.Contains(r.Header.Get("Connection"), "Upgrade") {
 		if strings.Contains(r.Header.Get("Connection"), "Upgrade") {
-			handleWebsocket(w, r)
+			websocket.HandleWebsocket(w, r)
 		} else {
 		} else {
 			log.Debugf("ui req: %q", r.URL)
 			log.Debugf("ui req: %q", r.URL)
 			webuiProxy.ServeHTTP(w, r)
 			webuiProxy.ServeHTTP(w, r)

+ 0 - 39
internal/httpservers/websocket.go

@@ -1,39 +0,0 @@
-package httpservers
-
-import (
-	"context"
-	log "github.com/sirupsen/logrus"
-	"net/http"
-	"nhooyr.io/websocket"
-	"nhooyr.io/websocket/wsjson"
-	"time"
-)
-
-func handleWebsocket(w http.ResponseWriter, r *http.Request) bool {
-	c, err := websocket.Accept(w, r, nil)
-
-	if err != nil {
-		log.Warnf("Websocket issue: %v", err)
-		return false
-	}
-
-	defer c.Close(websocket.StatusInternalError, "Goodbye")
-
-	ctx, cancel := context.WithTimeout(r.Context(), time.Second*10)
-
-	defer cancel()
-
-	var v interface{}
-
-	err = wsjson.Read(ctx, c, v)
-
-	log.Printf("recv: %v", v)
-
-	if err != nil {
-		log.Warnf("Websocket issue: %v", err)
-		return false
-	}
-
-	c.Close(websocket.StatusNormalClosure, "")
-	return true
-}

+ 119 - 0
internal/websocket/websocket.go

@@ -0,0 +1,119 @@
+package websocket
+
+import (
+	pb "github.com/OliveTin/OliveTin/gen/grpc"
+	"github.com/OliveTin/OliveTin/internal/executor"
+	ws "github.com/gorilla/websocket"
+	log "github.com/sirupsen/logrus"
+	"google.golang.org/protobuf/encoding/protojson"
+	"net/http"
+)
+
+var upgrader = ws.Upgrader{}
+
+type WebsocketClient struct {
+	conn *ws.Conn
+}
+
+var clients []*WebsocketClient
+
+var marshalOptions = protojson.MarshalOptions{
+	UseProtoNames:   false, // eg: canExec for js instead of can_exec from protobuf
+	EmitUnpopulated: true,
+}
+
+var ExecutionListener WebsocketExecutionListener
+
+type WebsocketExecutionListener struct{}
+
+func (WebsocketExecutionListener) OnExecutionStarted(title string) {
+	/*
+		broadcast(ExecutionStarted{
+			Type: "ExecutionStarted",
+			Action: title,
+		});
+	*/
+}
+
+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,
+		ExitCode:         logEntry.ExitCode,
+		Tags:             logEntry.Tags,
+		Uuid:             logEntry.UUID,
+	}
+
+	broadcast("ExecutionFinished", le)
+}
+
+func broadcast(messageType string, pbmsg *pb.LogEntry) {
+	payload, err := marshalOptions.Marshal(pbmsg)
+
+	// <EVIL>
+	// So, the websocket wants to encode messages using the same protomarshaller
+	// as the REST API - this gives consistency instead of using encoding/json
+	// and allows us to set specific marshalOptions.
+	//
+	// However, the protomarshaller will marshal the type, but the JavaScript at
+	// the other end has no idea what type this object is - as we're just sending
+	// it as JSON over the websocket.
+	//
+	// Therefore, we wrap the nicely marsheled bytes in a hacky JSON string
+	// literal and encode that string just with a byte array cast.
+	hackyMessageEnvelope := "{\"type\": \"" + messageType + "\", \"payload\": "
+
+	hackyMessage := []byte{}
+	hackyMessage = append(hackyMessage, []byte(hackyMessageEnvelope)...)
+	hackyMessage = append(hackyMessage, payload...)
+	hackyMessage = append(hackyMessage, []byte("}")...)
+	// </EVIL>
+
+	if err != nil {
+		log.Errorf("websocket marshal error: %v", err)
+		return
+	}
+
+	for _, client := range clients {
+		client.conn.WriteMessage(1, hackyMessage)
+	}
+}
+
+func (c *WebsocketClient) messageLoop() {
+	for {
+		mt, message, err := c.conn.ReadMessage()
+
+		if err != nil {
+			log.Printf("err: %v", err)
+			break
+		}
+
+		log.Infof("websocket recv: %s %d", message, mt)
+	}
+}
+
+func HandleWebsocket(w http.ResponseWriter, r *http.Request) bool {
+	c, err := upgrader.Upgrade(w, r, nil)
+
+	if err != nil {
+		log.Warnf("Websocket issue: %v", err)
+		return false
+	}
+
+	//	defer c.Close()
+
+	wsclient := &WebsocketClient{
+		conn: c,
+	}
+
+	clients = append(clients, wsclient)
+
+	go wsclient.messageLoop()
+
+	return true
+}

+ 68 - 20
webui/index.html

@@ -62,38 +62,83 @@
 		</main>
 		</main>
 
 
 		<footer title = "footer">
 		<footer title = "footer">
-			<p><img title = "application icon" src = "OliveTinLogo.png" height = "1em" class = "logo" /> OliveTin</p>
+			<p><img title = "application icon" src = "OliveTinLogo.png" alt = "OliveTin logo" height = "1em" class = "logo" /> OliveTin</p>
 			<p>
 			<p>
 				<a href = "https://docs.olivetin.app" target = "_new">Documentation</a> |
 				<a href = "https://docs.olivetin.app" target = "_new">Documentation</a> |
 				<a href = "https://github.com/OliveTin/OliveTin/issues/new/choose" target = "_new">Raise an issue on GitHub</a> |
 				<a href = "https://github.com/OliveTin/OliveTin/issues/new/choose" target = "_new">Raise an issue on GitHub</a> |
-				<span id = "currentVersion">Version: ?</p>
+				<span>Version: <span id = "currentVersion">?</span></span> |
+				<span>Server connection: <span id = "serverConnection">?</span></span>
+			</p>
+			<p>
 				<a id = "available-version" href = "http://olivetin.app" target = "_blank" hidden>?</a>
 				<a id = "available-version" href = "http://olivetin.app" target = "_blank" hidden>?</a>
 			</p>
 			</p>
 		</footer>
 		</footer>
 
 
-		<template id = "tplArgumentForm">
-			<form class = "action-arguments">
-				<div class = "wrapper">
-					<div>
-						<span class = "icon" role = "icon"></span>
-						<h2>Argument form</h2>
-					</div>
+		<dialog id = "executionResults">
+			<div class = "action-header">
+				<span class = "icon" role = "icon"></span>
 
 
-					<div class = "arguments"></div>
+				<h2>Log:
+					<span class = "title">?</span>
+				</h2>
+			</div>
+			<p>
+				<strong>Started: </strong><span class = "datetimeStarted">unknown</span>
+				<strong>Finished: </strong><span class = "datetimeFinished">unknown</span>
+			</p>
+			<p>
+				<strong>Exit Code: </strong><span class = "exitCode">unknown</span>
+			</p>
 
 
-					<div class = "buttons">
-						<input name = "start" type = "submit" value = "Start">
-						<button name = "cancel">Cancel</button>
+			<details>
+				<summary>stdout</summary>
+				<pre class = "stdout">
+					?
+				</pre>
+			</details>
+
+			<details>
+				<summary>stderr</summary>
+				<pre class = "stderr">
+					?
+				</pre>
+			</details>
+
+			<form method = "dialog">
+				<button name = "cancel">Close</button>
+			</form>
+		</dialog>
+
+		<template id = "tplArgumentForm">
+			<dialog>
+				<form class = "action-arguments">
+					<div class = "wrapper">
+						<div class = "action-header">
+							<span class = "icon" role = "icon"></span>
+							<h2>Argument form</h2>
+						</div>
+
+						<div class = "arguments"></div>
+
+						<div class = "buttons">
+							<input name = "start" type = "submit" value = "Start">
+							<button name = "cancel">Cancel</button>
+						</div>
 					</div>
 					</div>
-				</div>
-			<form>
+				</form>
+			</button>
 		</template>
 		</template>
 
 
 		<template id = "tplActionButton">
 		<template id = "tplActionButton">
-			<button>
-				<span role = "icon" title = "button icon" class = "icon">&#x1f4a9;</span>
-				<p role = "title" class = "title">Untitled Button</p>
-			</button>
+			<div>
+				<button>
+					<span role = "icon" title = "button icon" class = "icon">&#x1f4a9;</span>
+					<span role = "title" class = "title">Untitled Button</span>
+				</button>
+
+				<div class = "executions">
+				</div>
+			</div>
 		</template>
 		</template>
 
 
 		<template id = "tplLogRow">
 		<template id = "tplLogRow">
@@ -129,7 +174,10 @@
 		  	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) {
 			function showBigError (type, friendlyType, message) {
-			  clearInterval(window.buttonInterval)
+			  for (const oldError of document.querySelectorAll('div.error').values()) {
+				window.old = oldError;
+				oldError.remove();
+			  }
 
 
 			  console.error('Error ' + type + ': ', message)
 			  console.error('Error ' + type + ': ', message)
 
 

+ 16 - 61
webui/js/ActionButton.js

@@ -1,5 +1,5 @@
-import { marshalLogsJsonToHtml } from './marshaller.js'
 import './ArgumentForm.js'
 import './ArgumentForm.js'
+import './ExecutionButton.js'
 
 
 class ActionButton extends window.HTMLElement {
 class ActionButton extends window.HTMLElement {
   constructFromJson (json) {
   constructFromJson (json) {
@@ -25,6 +25,7 @@ class ActionButton extends window.HTMLElement {
         })
         })
 
 
         document.body.appendChild(frm)
         document.body.appendChild(frm)
+        frm.querySelector('dialog').showModal()
       } else {
       } else {
         this.startAction()
         this.startAction()
       }
       }
@@ -32,7 +33,8 @@ class ActionButton extends window.HTMLElement {
 
 
     this.updateFromJson(json)
     this.updateFromJson(json)
 
 
-    this.updateDom()
+    this.domTitle.innerText = this.btn.title
+    this.domIcon.innerHTML = this.unicodeIcon
 
 
     this.setAttribute('id', 'actionButton_' + json.id)
     this.setAttribute('id', 'actionButton_' + json.id)
   }
   }
@@ -52,20 +54,27 @@ class ActionButton extends window.HTMLElement {
   }
   }
 
 
   startAction (actionArgs) {
   startAction (actionArgs) {
-    this.btn.disabled = true
-    this.isWaiting = true
-    this.updateDom()
+    //    this.btn.disabled = true
+    //    this.isWaiting = true
+    //    this.updateDom()
     this.btn.classList = [] // Removes old animation classes
     this.btn.classList = [] // Removes old animation classes
 
 
     if (actionArgs === undefined) {
     if (actionArgs === undefined) {
       actionArgs = []
       actionArgs = []
     }
     }
 
 
+    // UUIDs are create client side, so that we can setup a "execution-button"
+    // to track the execution before we send the request to the server.
     const startActionArgs = {
     const startActionArgs = {
       actionName: this.btn.title,
       actionName: this.btn.title,
-      arguments: actionArgs
+      arguments: actionArgs,
+      uuid: window.crypto.randomUUID()
     }
     }
 
 
+    const btnExecution = document.createElement('execution-button')
+    btnExecution.constructFromJson(startActionArgs.uuid)
+    this.querySelector('div.executions').appendChild(btnExecution)
+
     window.fetch(this.actionCallUrl, {
     window.fetch(this.actionCallUrl, {
       method: 'POST',
       method: 'POST',
       headers: {
       headers: {
@@ -80,45 +89,12 @@ class ActionButton extends window.HTMLElement {
       }
       }
     }
     }
     ).then((json) => {
     ).then((json) => {
-      marshalLogsJsonToHtml({ logs: [json.logEntry] })
-
-      if (json.logEntry.timedOut) {
-        this.onActionResult('action-timeout', 'Timed out')
-      } else if (json.logEntry.exitCode === -1337) {
-        this.onActionError('Error')
-      } else if (json.logEntry.exitCode !== 0) {
-        this.onActionResult('action-nonzero-exit', 'Exit code ' + json.logEntry.exitCode)
-      } else {
-        this.onActionResult('action-success', 'Success!')
-      }
+      // The button used to wait for the action to finish, but now it is fire & forget
     }).catch(err => {
     }).catch(err => {
       this.onActionError(err)
       this.onActionError(err)
     })
     })
   }
   }
 
 
-  onActionResult (cssClass, temporaryStatusMessage) {
-    this.btn.disabled = false
-    this.temporaryStatusMessage = '[ ' + temporaryStatusMessage + ' ]'
-    this.updateDom()
-    this.btn.classList.add(cssClass)
-
-    setTimeout(() => {
-      this.btn.classList.remove(cssClass)
-    }, 1000)
-  }
-
-  onActionError (err) {
-    console.error('callback error', err)
-    this.btn.disabled = false
-    this.isWaiting = false
-    this.updateDom()
-    this.btn.classList.add('action-failed')
-
-    setTimeout(() => {
-      this.btn.classList.remove('action-failed')
-    }, 1000)
-  }
-
   constructDomFromTemplate () {
   constructDomFromTemplate () {
     const tpl = document.getElementById('tplActionButton')
     const tpl = document.getElementById('tplActionButton')
     const content = tpl.content.cloneNode(true)
     const content = tpl.content.cloneNode(true)
@@ -134,27 +110,6 @@ class ActionButton extends window.HTMLElement {
     this.domTitle = this.btn.querySelector('.title')
     this.domTitle = this.btn.querySelector('.title')
     this.domIcon = this.btn.querySelector('.icon')
     this.domIcon = this.btn.querySelector('.icon')
   }
   }
-
-  updateDom () {
-    if (this.temporaryStatusMessage != null) {
-      this.domTitle.innerText = this.temporaryStatusMessage
-      this.domTitle.classList.add('temporary-status-message')
-      this.isWaiting = false
-      this.disabled = false
-
-      setTimeout(() => {
-        this.temporaryStatusMessage = null
-        this.domTitle.classList.remove('temporary-status-message')
-        this.updateDom()
-      }, 2000)
-    } else if (this.isWaiting) {
-      this.domTitle.innerText = 'Waiting...'
-    } else {
-      this.domTitle.innerText = this.btn.title
-    }
-
-    this.domIcon.innerHTML = this.unicodeIcon
-  }
 }
 }
 
 
 window.customElements.define('action-button', ActionButton)
 window.customElements.define('action-button', ActionButton)

+ 106 - 0
webui/js/ExecutionButton.js

@@ -0,0 +1,106 @@
+import { ExecutionDialog } from './ExecutionDialog.js'
+
+class ExecutionButton extends window.HTMLElement {
+  constructFromJson (json) {
+    this.executionUuid = json
+
+    this.appendChild(document.createElement('button'))
+    this.isWaiting = true
+
+    this.setAttribute('id', 'execution-' + json)
+
+    this.btn = this.querySelector('button')
+    this.btn.innerText = 'Executing...'
+    this.btn.onclick = () => {
+      this.show()
+    }
+  }
+
+  show () {
+    if (window.executionDialog === undefined) {
+      window.executionDialog = new ExecutionDialog()
+    }
+
+    const executionStatusArgs = {
+      executionUuid: this.executionUuid
+    }
+
+    window.executionDialog.constructFromJson(this.executionUuid)
+    window.executionDialog.show()
+
+    window.fetch(window.restBaseUrl + 'ExecutionStatus', {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json'
+      },
+      body: JSON.stringify(executionStatusArgs)
+    }).then((res) => {
+      if (res.ok) {
+        return res.json()
+      } else {
+        throw new Error(res.statusText)
+      }
+    }
+    ).then((json) => {
+      window.executionDialog.renderResult(json)
+    }).catch(err => {
+      window.executionDialog.renderError(err)
+    })
+  }
+
+  onFinished (LogEntry) {
+    if (LogEntry.timedOut) {
+      this.onActionResult('action-timeout', 'Timed out')
+    } else if (LogEntry.exitCode === -1337) {
+      this.onActionError('Error')
+    } else if (LogEntry.exitCode !== 0) {
+      this.onActionResult('action-nonzero-exit', 'Exit code ' + LogEntry.exitCode)
+    } else {
+      this.onActionResult('action-success', 'Success!')
+    }
+  }
+
+  onActionResult (cssClass, temporaryStatusMessage) {
+    this.btn.disabled = false
+    this.temporaryStatusMessage = '[ ' + temporaryStatusMessage + ' ]'
+    this.updateDom()
+    this.btn.classList.add(cssClass)
+
+    setTimeout(() => {
+      this.btn.classList.remove(cssClass)
+    }, 1000)
+  }
+
+  onActionError (err) {
+    console.error('callback error', err)
+    this.btn.disabled = false
+    this.isWaiting = false
+    this.updateDom()
+    this.btn.classList.add('action-failed')
+
+    setTimeout(() => {
+      this.btn.classList.remove('action-failed')
+    }, 1000)
+  }
+
+  updateDom () {
+    if (this.temporaryStatusMessage != null) {
+      this.btn.innerText = this.temporaryStatusMessage
+      this.btn.classList.add('temporary-status-message')
+      this.isWaiting = false
+      this.disabled = false
+
+      setTimeout(() => {
+        this.temporaryStatusMessage = null
+        this.btn.classList.remove('temporary-status-message')
+        this.updateDom()
+      }, 2000)
+    } else if (this.isWaiting) {
+      this.btn.innerText = 'Waiting...'
+    } else {
+      this.btn.innerText = 'Finished'
+    }
+  }
+}
+
+window.customElements.define('execution-button', ExecutionButton)

+ 58 - 0
webui/js/ExecutionDialog.js

@@ -0,0 +1,58 @@
+// This ExecutionDialog is NOT a custom HTML element, but rather just picks up
+// the <dialog /> element out of index.html and just re-uses that - as only
+// one dialog can be shown at a time.
+export class ExecutionDialog {
+  constructFromJson (json) {
+    this.executionUuid = json
+
+    this.dlg = document.querySelector('dialog#executionResults')
+
+    this.domIcon = this.dlg.querySelector('.icon')
+    this.domTitle = this.dlg.querySelector('.title')
+    this.domStdout = this.dlg.querySelector('.stdout')
+    this.domStderr = this.dlg.querySelector('.stderr')
+    this.domDatetimeStarted = this.dlg.querySelector('.datetimeStarted')
+    this.domDatetimeFinished = this.dlg.querySelector('.datetimeFinished')
+    this.domExitCode = this.dlg.querySelector('.exitCode')
+  }
+
+  show () {
+    this.dlg.showModal()
+  }
+
+  renderResult (res) {
+    this.executionUuid = res.logEntry.executionUuid
+
+    if (res.logEntry.datetimeFinished === '') {
+      this.domExitCode.innerText = 'Still running...'
+      this.domDatetimeFinished.innerText = 'Still running...'
+    } else {
+      if (res.logEntry.timedOut) {
+        this.domExitCode.innerText = 'Timed out'
+      } else {
+        this.domExitCode.innerText = res.logEntry.exitCode
+      }
+
+      this.domDatetimeFinished.innerText = res.logEntry.datetimeFinished
+    }
+
+    this.domIcon.innerHTML = res.logEntry.actionIcon
+    this.domTitle.innerText = res.logEntry.actionTitle
+
+    this.domStdout.innerText = res.logEntry.stdout
+
+    if (res.logEntry.stderr === '') {
+      this.domStderr.parentElement.hidden = true
+      this.domStderr.innerText = res.logEntry.stderr
+    } else {
+      this.domStderr.parentElement.hidden = false
+      this.domStderr.innerText = res.logEntry.stderr
+    }
+
+    this.domDatetimeStarted.innerText = res.logEntry.datetimeStarted
+  }
+
+  renderError (err) {
+    this.dlg.querySelector('pre').innerText = JSON.stringify(err)
+  }
+}

+ 1 - 1
webui/js/marshaller.js

@@ -50,7 +50,7 @@ export function marshalLogsJsonToHtml (json) {
       logTableExitCode += ' (timed out)'
       logTableExitCode += ' (timed out)'
     }
     }
 
 
-    row.querySelector('.timestamp').innerText = logEntry.datetime
+    row.querySelector('.timestamp').innerText = logEntry.datetimeStarted
     row.querySelector('.content').innerText = logEntry.actionTitle
     row.querySelector('.content').innerText = logEntry.actionTitle
     row.querySelector('.icon').innerHTML = logEntry.actionIcon
     row.querySelector('.icon').innerHTML = logEntry.actionIcon
     row.querySelector('pre.stdout').innerText = logEntry.stdout
     row.querySelector('pre.stdout').innerText = logEntry.stdout

+ 40 - 6
webui/js/websocket.js

@@ -1,4 +1,14 @@
-export function setupWebsocket () {
+import { marshalLogsJsonToHtml } from './marshaller.js'
+
+window.ws = null
+
+export function checkWebsocketConnection () {
+  if (window.ws === null || window.ws.readyState === 3) {
+    reconnectWebsocket()
+  }
+}
+
+function reconnectWebsocket () {
   window.websocketAvailable = false
   window.websocketAvailable = false
 
 
   let proto = 'ws:'
   let proto = 'ws:'
@@ -18,20 +28,44 @@ export function setupWebsocket () {
 
 
 function websocketOnOpen (evt) {
 function websocketOnOpen (evt) {
   window.websocketAvailable = true
   window.websocketAvailable = true
-  console.log('open')
 
 
-  const foo = '{}'
+  window.ws.send('monitor')
 
 
-  window.ws.send(foo)
+  window.refreshLoop()
 }
 }
 
 
 function websocketOnMessage (msg) {
 function websocketOnMessage (msg) {
-  console.log(msg)
+  // FIXME check msg status is OK
+  const j = JSON.parse(msg.data)
+
+  switch (j.type) {
+    case 'ExecutionFinished':
+      updatePageAfterFinished(j.payload)
+      break
+    default:
+      window.showBigError('Unknown message type from server: ' + j.type)
+  }
+}
+
+function updatePageAfterFinished (logEntry) {
+  document.querySelector('execution-button#execution-' + logEntry.uuid).onFinished(logEntry)
+
+  marshalLogsJsonToHtml({
+    logs: [logEntry]
+  })
+
+  // If the current execution dialog is open, update that too
+  if (window.executionDialog != null && window.executionDialog.dlg.open && window.executionDialog.executionUuid === logEntry.uuid) {
+    window.executionDialog.renderResult({
+      logEntry: logEntry
+    })
+  }
 }
 }
 
 
 function websocketOnError (err) {
 function websocketOnError (err) {
   window.websocketAvailable = false
   window.websocketAvailable = false
-  console.log(err)
+  window.refreshLoop()
+  console.error(err)
 }
 }
 
 
 function websocketOnClose () {
 function websocketOnClose () {

+ 35 - 9
webui/main.js

@@ -1,7 +1,7 @@
 'use strict'
 'use strict'
 
 
 import { marshalActionButtonsJsonToHtml, marshalLogsJsonToHtml } from './js/marshaller.js'
 import { marshalActionButtonsJsonToHtml, marshalLogsJsonToHtml } from './js/marshaller.js'
-import { setupWebsocket } from './js/websocket.js'
+import { checkWebsocketConnection } from './js/websocket.js'
 
 
 function showSection (name) {
 function showSection (name) {
   for (const otherName of ['Actions', 'Logs']) {
   for (const otherName of ['Actions', 'Logs']) {
@@ -20,15 +20,42 @@ function setupSections () {
   showSection('Actions')
   showSection('Actions')
 }
 }
 
 
+function refreshLoop () {
+  if (window.websocketAvailable) {
+    document.querySelector('#serverConnection').classList.remove('error')
+    document.querySelector('#serverConnection').innerText = 'websocket'
+
+    // Websocket updates are streamed live, not updated on a loop.
+  } else if (window.restAvailable) {
+    // Fallback to rest, but try to reconnect the websocket anyway.
+
+    fetchGetDashboardComponents()
+    checkWebsocketConnection()
+
+    document.querySelector('#serverConnection').classList.remove('error')
+    document.querySelector('#serverConnection').innerText = 'rest'
+
+    fetchGetLogs()
+  } else {
+    document.querySelector('#serverConnection').innerText = 'disconnected, trying to reconnect...'
+    document.querySelector('#serverConnection').classList.add('error')
+
+    // Still try to fetch the dashboard, if successfull window.restAvailable = true
+    fetchGetDashboardComponents()
+  }
+}
+
 function fetchGetDashboardComponents () {
 function fetchGetDashboardComponents () {
   window.fetch(window.restBaseUrl + 'GetDashboardComponents', {
   window.fetch(window.restBaseUrl + 'GetDashboardComponents', {
     cors: 'cors'
     cors: 'cors'
   }).then(res => {
   }).then(res => {
     return res.json()
     return res.json()
   }).then(res => {
   }).then(res => {
+    window.restAvailable = true
     marshalActionButtonsJsonToHtml(res)
     marshalActionButtonsJsonToHtml(res)
-  }).catch(err => {
-    window.showBigError('fetch-buttons', 'getting buttons', err, 'blat')
+  }).catch(() => { // err is 1st arg
+    window.restAvailable = false
+    //    window.showBigError('fetch-buttons', 'getting buttons', err, 'blat')
   })
   })
 }
 }
 
 
@@ -56,7 +83,7 @@ function processWebuiSettingsJson (settings) {
     document.head.appendChild(themeCss)
     document.head.appendChild(themeCss)
   }
   }
 
 
-  document.querySelector('#currentVersion').innerText = 'Version: ' + settings.CurrentVersion
+  document.querySelector('#currentVersion').innerText = settings.CurrentVersion
 
 
   if (settings.ShowNewVersions && settings.AvailableVersion !== 'none') {
   if (settings.ShowNewVersions && settings.AvailableVersion !== 'none') {
     document.querySelector('#available-version').innerText = 'New Version Available: ' + settings.AvailableVersion
     document.querySelector('#available-version').innerText = 'New Version Available: ' + settings.AvailableVersion
@@ -76,17 +103,16 @@ function processWebuiSettingsJson (settings) {
 function main () {
 function main () {
   setupSections()
   setupSections()
 
 
-  setupWebsocket()
-
   window.fetch('webUiSettings.json').then(res => {
   window.fetch('webUiSettings.json').then(res => {
     return res.json()
     return res.json()
   }).then(res => {
   }).then(res => {
     processWebuiSettingsJson(res)
     processWebuiSettingsJson(res)
 
 
-    fetchGetDashboardComponents()
-    fetchGetLogs()
+    window.restAvailable = true
+    window.refreshLoop = refreshLoop
+    window.refreshLoop()
 
 
-    window.buttonInterval = setInterval(fetchGetDashboardComponents, 3000)
+    setInterval(refreshLoop, 3000)
   }).catch(err => {
   }).catch(err => {
     window.showBigError('fetch-webui-settings', 'getting webui settings', err)
     window.showBigError('fetch-webui-settings', 'getting webui settings', err)
   })
   })

+ 17 - 13
webui/style.css

@@ -7,6 +7,13 @@ body {
   margin: 0;
   margin: 0;
 }
 }
 
 
+dialog {
+  min-width: 600px;
+  text-align: left;
+  padding: 1em;
+  box-shadow: 0 0 6px 0 #444;
+}
+
 fieldset {
 fieldset {
   padding: 0;
   padding: 0;
 }
 }
@@ -120,7 +127,7 @@ span[role="icon"] {
   vertical-align: middle;
   vertical-align: middle;
 }
 }
 
 
-form span[role="icon"],
+.action-header span[role="icon"],
 tr.log-row span[role="icon"] {
 tr.log-row span[role="icon"] {
   display: inline-block;
   display: inline-block;
   padding-right: 0.2em;
   padding-right: 0.2em;
@@ -128,6 +135,9 @@ tr.log-row span[role="icon"] {
 
 
 .error {
 .error {
   background-color: salmon;
   background-color: salmon;
+}
+
+div.error {
   padding: 1em;
   padding: 1em;
 }
 }
 
 
@@ -146,6 +156,7 @@ div.entity {
 }
 }
 
 
 h2 {
 h2 {
+  margin-top: 0;
   font-size: 1em;
   font-size: 1em;
   display: inline-block;
   display: inline-block;
 }
 }
@@ -185,6 +196,11 @@ input[type="submit"]:disabled {
   cursor: not-allowed;
   cursor: not-allowed;
 }
 }
 
 
+div.executions button {
+  margin-top: .2em;
+  margin-bottom: .2em;
+}
+
 fieldset#section-switcher {
 fieldset#section-switcher {
   border: 0;
   border: 0;
   text-align: right;
   text-align: right;
@@ -282,21 +298,9 @@ details[open] {
   display: block;
   display: block;
 }
 }
 
 
-form.action-arguments {
-  position: absolute;
-  inset: 0;
-  padding: 1em;
-  box-shadow: 0 0 6px 0 #aaa;
-  background-color: #dee3e7;
-}
-
 form div.wrapper {
 form div.wrapper {
-  border-radius: 1em;
-  box-shadow: 0 0 10px 0 #444;
   background-color: white;
   background-color: white;
-  border: 1px solid #999;
   text-align: left;
   text-align: left;
-  padding: 1em;
 }
 }
 
 
 label {
 label {