Quellcode durchsuchen

feature: websocket implemenation at last!

jamesread vor 2 Jahren
Ursprung
Commit
e5a870ed94

+ 29 - 18
OliveTin.proto

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

+ 2 - 0
cmd/OliveTin/main.go

@@ -11,6 +11,7 @@ import (
 	"github.com/OliveTin/OliveTin/internal/oncron"
 	"github.com/OliveTin/OliveTin/internal/onstartup"
 	updatecheck "github.com/OliveTin/OliveTin/internal/updatecheck"
+	"github.com/OliveTin/OliveTin/internal/websocket"
 
 	"github.com/OliveTin/OliveTin/internal/httpservers"
 
@@ -110,6 +111,7 @@ func main() {
 	log.Debugf("Config: %+v", cfg)
 
 	executor := executor.DefaultExecutor()
+	executor.AddListener(websocket.ExecutionListener)
 
 	go onstartup.Execute(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/golang-jwt/jwt/v4 v4.5.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/robfig/cron/v3 v3.0.1
 	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/protobuf v1.31.0
 	gopkg.in/yaml.v3 v3.0.1
-	nhooyr.io/websocket v1.8.7
 )
 
 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/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
 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/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
 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/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
 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/go.mod h1:qdcuFWeGGS2xX5bLM/c3U9lewg7+Zu4mr+xPwZIB4ZU=
 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/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/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/uuid/v5 v5.0.0 h1:p544++a97kEL+svbcFbCQVM9KFu0Yo25UoISXGNNH9M=
 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-containerregistry v0.16.1 h1:rUEt426sR6nyrL3gt+18ibRcvYpKYdpsa5ZW7MA08dQ=
 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/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=
@@ -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/go.mod h1:Zi/ZFkEqFHTm7qkjyNJjaWH4LQA9LQhGJyF0lTYGpxw=
 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.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
 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/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/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
 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/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 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/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/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
 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/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/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/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
 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.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 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.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
 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/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/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/go.mod h1:yZbwRsSeGjusneWgA781EKej9HF8vme8okylkAeNKLk=
 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-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-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-20200202164722-d101bd2416d5/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/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
 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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 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-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=
-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/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
 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{
 		"username":  ret.Username,
 		"usergroup": ret.Usergroup,
-	}).Infof("UserFromContext")
+	}).Debugf("UserFromContext")
 
 	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) {
 	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_]+?) *?}}")
 	matches := r.FindAllStringSubmatch(rawShellCommand, -1)
@@ -51,8 +52,9 @@ func parseActionArguments(rawShellCommand string, values map[string]string, acti
 	}
 
 	log.WithFields(log.Fields{
-		"cmd": rawShellCommand,
-	}).Infof("After Parse Args")
+		"actionTitle": action.Title,
+		"cmd":         rawShellCommand,
+	}).Infof("Action parse args - After")
 
 	return rawShellCommand, nil
 }

+ 71 - 33
internal/executor/executor.go

@@ -4,7 +4,6 @@ import (
 	pb "github.com/OliveTin/OliveTin/gen/grpc"
 	acl "github.com/OliveTin/OliveTin/internal/acl"
 	config "github.com/OliveTin/OliveTin/internal/config"
-	"github.com/google/uuid"
 	log "github.com/sirupsen/logrus"
 
 	"bytes"
@@ -15,56 +14,59 @@ import (
 	"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
 // Executor. They're created from the grpcapi.
 type ExecutionRequest struct {
 	ActionName         string
 	Arguments          map[string]string
+	UUID               string
 	Tags               []string
 	action             *config.Action
 	Cfg                *config.Config
 	AuthenticatedUser  *acl.AuthenticatedUser
 	logEntry           *InternalLogEntry
 	finalParsedCommand string
-	uuid               string
+	executor           *Executor
 }
 
 // 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
 // easily serializable.
 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
 		logs, etc. Therefore, we duplicate those values here.
 	*/
 	ActionTitle string
 	ActionIcon  string
-
-	ExecutionStarted   bool
-	ExecutionCompleted bool
+	UUID        string
 }
 
 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
 // executing actions.
 func DefaultExecutor() *Executor {
@@ -72,23 +74,38 @@ func DefaultExecutor() *Executor {
 	e.Logs = make(map[string]*InternalLogEntry)
 
 	e.chainOfCommand = []executorStepFunc{
+		stepLogRequested,
 		stepFindAction,
 		stepACLCheck,
 		stepParseArgs,
 		stepLogStart,
 		stepExec,
+		stepNotifyListeners,
 		stepLogFinish,
 	}
 
 	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
 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{
 		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.
@@ -96,12 +113,16 @@ func (e *Executor) ExecRequest(req *ExecutionRequest) *pb.StartActionResponse {
 		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)
 
 	return &pb.StartActionResponse{
-		ExecutionUuid: req.uuid,
+		ExecutionUuid: req.UUID,
 	}
 }
 
@@ -152,10 +173,18 @@ func stepParseArgs(req *ExecutionRequest) bool {
 	return true
 }
 
+func stepLogRequested(req *ExecutionRequest) bool {
+	log.WithFields(log.Fields{
+		"actionTitle": req.ActionName,
+	}).Infof("Action requested")
+
+	return true
+}
+
 func stepLogStart(req *ExecutionRequest) bool {
 	log.WithFields(log.Fields{
-		"title":   req.action.Title,
-		"timeout": req.action.Timeout,
+		"actionTitle": req.action.Title,
+		"timeout":     req.action.Timeout,
 	}).Infof("Action starting")
 
 	return true
@@ -163,16 +192,24 @@ func stepLogStart(req *ExecutionRequest) bool {
 
 func stepLogFinish(req *ExecutionRequest) bool {
 	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")
 
 	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 {
 	if runtime.GOOS == "windows" {
 		return exec.CommandContext(ctx, "cmd", "/C", finalParsedCommand)
@@ -217,6 +254,7 @@ func stepExec(req *ExecutionRequest) bool {
 	}
 
 	req.logEntry.Tags = req.Tags
+	req.logEntry.DatetimeFinished = time.Now().Format("2006-01-02 15:04:05")
 
 	return true
 }

+ 37 - 3
internal/grpcapi/grpcApi.go

@@ -6,8 +6,8 @@ import (
 	log "github.com/sirupsen/logrus"
 	"google.golang.org/grpc"
 
-	"io"
 	"net"
+	"sort"
 
 	acl "github.com/OliveTin/OliveTin/internal/acl"
 	config "github.com/OliveTin/OliveTin/internal/config"
@@ -20,7 +20,7 @@ var (
 )
 
 type oliveTinAPI struct {
-	pb.UnimplementedOliveTinApiServer
+	pb.UnimplementedOliveTinApiServiceServer
 
 	executor *executor.Executor
 }
@@ -36,6 +36,7 @@ func (api *oliveTinAPI) StartAction(ctx ctx.Context, req *pb.StartActionRequest)
 
 	execReq := executor.ExecutionRequest{
 		ActionName:        req.ActionName,
+		UUID:              req.Uuid,
 		Arguments:         args,
 		AuthenticatedUser: acl.UserFromContext(ctx, cfg),
 		Cfg:               cfg,
@@ -44,6 +45,32 @@ func (api *oliveTinAPI) StartAction(ctx ctx.Context, req *pb.StartActionRequest)
 	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 {
 	log.Infof("Watch")
 
@@ -69,6 +96,7 @@ func (api *oliveTinAPI) WatchExecution(req *pb.WatchExecutionRequest, srv pb.Oli
 		return nil
 	}
 }
+*/
 
 func (api *oliveTinAPI) GetDashboardComponents(ctx ctx.Context, req *pb.GetDashboardComponentsRequest) (*pb.GetDashboardComponentsResponse, error) {
 	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
 }
 
@@ -159,7 +193,7 @@ func Start(globalConfig *config.Config, ex *executor.Executor) {
 	}
 
 	grpcServer := grpc.NewServer()
-	pb.RegisterOliveTinApiServer(grpcServer, newServer(ex))
+	pb.RegisterOliveTinApiServiceServer(grpcServer, newServer(ex))
 
 	err = grpcServer.Serve(lis)
 

+ 3 - 3
internal/grpcapi/grpcApi_test.go

@@ -26,7 +26,7 @@ func init() {
 
 	lis = bufconn.Listen(bufSize)
 	s := grpc.NewServer()
-	pb.RegisterOliveTinApiServer(s, newServer(ex))
+	pb.RegisterOliveTinApiServiceServer(s, newServer(ex))
 
 	go func() {
 		if err := s.Serve(lis); err != nil {
@@ -39,7 +39,7 @@ func bufDialer(context.Context, string) (net.Conn, error) {
 	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
 
 	ctx := context.Background()
@@ -50,7 +50,7 @@ func getNewTestServerAndClient(t *testing.T, injectedConfig *config.Config) (*gr
 		t.Fatalf("Failed to dial bufnet: %v", err)
 	}
 
-	client := pb.NewOliveTinApiClient(conn)
+	client := pb.NewOliveTinApiServiceClient(conn)
 
 	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{
 			Marshaler: &runtime.JSONPb{
 				MarshalOptions: protojson.MarshalOptions{
-					UseProtoNames:   true,
+					UseProtoNames:   false, // eg: canExec for js instead of can_exec from protobuf
 					EmitUnpopulated: true,
 				},
 			},
@@ -88,7 +88,7 @@ func startRestAPIServer(globalConfig *config.Config) error {
 	)
 	opts := []grpc.DialOption{grpc.WithInsecure()}
 
-	err := gw.RegisterOliveTinApiHandlerFromEndpoint(ctx, mux, cfg.ListenAddressGrpcActions, opts)
+	err := gw.RegisterOliveTinApiServiceHandlerFromEndpoint(ctx, mux, cfg.ListenAddressGrpcActions, opts)
 
 	if err != nil {
 		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 (
 	config "github.com/OliveTin/OliveTin/internal/config"
+	"github.com/OliveTin/OliveTin/internal/websocket"
 	log "github.com/sirupsen/logrus"
 	"net/http"
 	"net/http/httputil"
@@ -39,7 +40,7 @@ func StartSingleHTTPFrontend(cfg *config.Config) {
 
 	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
 		if strings.Contains(r.Header.Get("Connection"), "Upgrade") {
-			handleWebsocket(w, r)
+			websocket.HandleWebsocket(w, r)
 		} else {
 			log.Debugf("ui req: %q", r.URL)
 			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>
 
 		<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>
 				<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> |
-				<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>
 			</p>
 		</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>
-			<form>
+				</form>
+			</button>
 		</template>
 
 		<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 id = "tplLogRow">
@@ -129,7 +174,10 @@
 		  	to at least display a helpful error message if we can't use OliveTin.
 			*/
 			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)
 

+ 16 - 61
webui/js/ActionButton.js

@@ -1,5 +1,5 @@
-import { marshalLogsJsonToHtml } from './marshaller.js'
 import './ArgumentForm.js'
+import './ExecutionButton.js'
 
 class ActionButton extends window.HTMLElement {
   constructFromJson (json) {
@@ -25,6 +25,7 @@ class ActionButton extends window.HTMLElement {
         })
 
         document.body.appendChild(frm)
+        frm.querySelector('dialog').showModal()
       } else {
         this.startAction()
       }
@@ -32,7 +33,8 @@ class ActionButton extends window.HTMLElement {
 
     this.updateFromJson(json)
 
-    this.updateDom()
+    this.domTitle.innerText = this.btn.title
+    this.domIcon.innerHTML = this.unicodeIcon
 
     this.setAttribute('id', 'actionButton_' + json.id)
   }
@@ -52,20 +54,27 @@ class ActionButton extends window.HTMLElement {
   }
 
   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
 
     if (actionArgs === undefined) {
       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 = {
       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, {
       method: 'POST',
       headers: {
@@ -80,45 +89,12 @@ class ActionButton extends window.HTMLElement {
       }
     }
     ).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 => {
       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 () {
     const tpl = document.getElementById('tplActionButton')
     const content = tpl.content.cloneNode(true)
@@ -134,27 +110,6 @@ class ActionButton extends window.HTMLElement {
     this.domTitle = this.btn.querySelector('.title')
     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)

+ 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)'
     }
 
-    row.querySelector('.timestamp').innerText = logEntry.datetime
+    row.querySelector('.timestamp').innerText = logEntry.datetimeStarted
     row.querySelector('.content').innerText = logEntry.actionTitle
     row.querySelector('.icon').innerHTML = logEntry.actionIcon
     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
 
   let proto = 'ws:'
@@ -18,20 +28,44 @@ export function setupWebsocket () {
 
 function websocketOnOpen (evt) {
   window.websocketAvailable = true
-  console.log('open')
 
-  const foo = '{}'
+  window.ws.send('monitor')
 
-  window.ws.send(foo)
+  window.refreshLoop()
 }
 
 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) {
   window.websocketAvailable = false
-  console.log(err)
+  window.refreshLoop()
+  console.error(err)
 }
 
 function websocketOnClose () {

+ 35 - 9
webui/main.js

@@ -1,7 +1,7 @@
 'use strict'
 
 import { marshalActionButtonsJsonToHtml, marshalLogsJsonToHtml } from './js/marshaller.js'
-import { setupWebsocket } from './js/websocket.js'
+import { checkWebsocketConnection } from './js/websocket.js'
 
 function showSection (name) {
   for (const otherName of ['Actions', 'Logs']) {
@@ -20,15 +20,42 @@ function setupSections () {
   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 () {
   window.fetch(window.restBaseUrl + 'GetDashboardComponents', {
     cors: 'cors'
   }).then(res => {
     return res.json()
   }).then(res => {
+    window.restAvailable = true
     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.querySelector('#currentVersion').innerText = 'Version: ' + settings.CurrentVersion
+  document.querySelector('#currentVersion').innerText = settings.CurrentVersion
 
   if (settings.ShowNewVersions && settings.AvailableVersion !== 'none') {
     document.querySelector('#available-version').innerText = 'New Version Available: ' + settings.AvailableVersion
@@ -76,17 +103,16 @@ function processWebuiSettingsJson (settings) {
 function main () {
   setupSections()
 
-  setupWebsocket()
-
   window.fetch('webUiSettings.json').then(res => {
     return res.json()
   }).then(res => {
     processWebuiSettingsJson(res)
 
-    fetchGetDashboardComponents()
-    fetchGetLogs()
+    window.restAvailable = true
+    window.refreshLoop = refreshLoop
+    window.refreshLoop()
 
-    window.buttonInterval = setInterval(fetchGetDashboardComponents, 3000)
+    setInterval(refreshLoop, 3000)
   }).catch(err => {
     window.showBigError('fetch-webui-settings', 'getting webui settings', err)
   })

+ 17 - 13
webui/style.css

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