From dac19102007f5aae656c164acb170a1c41a53873 Mon Sep 17 00:00:00 2001 From: Alexander Baryshnikov Date: Mon, 28 Sep 2020 21:46:37 +0800 Subject: [PATCH] add ui stub --- go.mod | 7 + go.sum | 48 +++++ server/api/adapter.go | 209 +++++++++++++++++++++ server/{internal => api}/flags.go | 2 +- server/{internal => api}/flags_linux.go | 2 +- server/auth.go | 34 ++-- server/internal/adapter.go | 232 ------------------------ server/mode_bin.go | 4 +- server/{server.go => runner/handler.go} | 46 ++--- server/server_test.go | 20 +- server/ui/mapper.go | 5 + server/ui/router.go | 72 ++++++++ server/unit.go | 46 ++++- templates/unit-info.html | 18 ++ templates/units-list.html | 17 ++ 15 files changed, 466 insertions(+), 296 deletions(-) create mode 100644 server/api/adapter.go rename server/{internal => api}/flags.go (81%) rename server/{internal => api}/flags_linux.go (92%) delete mode 100644 server/internal/adapter.go rename server/{server.go => runner/handler.go} (78%) create mode 100644 server/ui/mapper.go create mode 100644 server/ui/router.go create mode 100644 templates/unit-info.html create mode 100644 templates/units-list.html diff --git a/go.mod b/go.mod index 6d2ce46..7f57253 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,18 @@ module nano-run go 1.14 require ( + github.com/Masterminds/goutils v1.1.0 // indirect + github.com/Masterminds/semver v1.5.0 // indirect + github.com/Masterminds/sprig v2.22.0+incompatible github.com/dgraph-io/badger v1.6.1 github.com/dgrijalva/jwt-go v3.2.0+incompatible + github.com/gin-gonic/gin v1.6.3 github.com/google/uuid v1.1.2 github.com/gorilla/mux v1.8.0 + github.com/huandu/xstrings v1.3.2 // indirect + github.com/imdario/mergo v0.3.11 // indirect github.com/jessevdk/go-flags v1.4.1-0.20200711081900-c17162fe8fd7 + github.com/mitchellh/copystructure v1.0.0 // indirect github.com/stretchr/testify v1.4.0 golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a gopkg.in/yaml.v2 v2.3.0 diff --git a/go.sum b/go.sum index d356f2c..c335d1f 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,12 @@ github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9 h1:HD8gA2tkByhMAwYaFAX9w2l7vxvBQ5NMoxDrkhqhtn4= github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Masterminds/goutils v1.1.0 h1:zukEsf/1JZwCMgHiK3GZftabmxiCw4apj3a28RPBiVg= +github.com/Masterminds/goutils v1.1.0/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= +github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= +github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZCSYp4Z0m2dk6cEM60= +github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= @@ -24,24 +30,56 @@ github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUn github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +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-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/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3 h1:gyjaxf+svBWX08ZjK86iN9geUJF0H6gp2IRKX6Nf6/I= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw= +github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA= +github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/jessevdk/go-flags v1.4.1-0.20200711081900-c17162fe8fd7 h1:Ug59miTxVKVg5Oi2S5uHlKOIV5jBx4Hb2u0jIxxDaSs= github.com/jessevdk/go-flags v1.4.1-0.20200711081900-c17162fe8fd7/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +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.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ= +github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY= +github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -59,9 +97,14 @@ github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnIn github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 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 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +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 v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= +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/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= @@ -76,11 +119,16 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb h1:fgwFCsaw9buMuxNd6+DQfAuSFqbNiQZpcgJQAgJsK6k= golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtDblrpj/w7B9nxGNELpg= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/server/api/adapter.go b/server/api/adapter.go new file mode 100644 index 0000000..a0b071e --- /dev/null +++ b/server/api/adapter.go @@ -0,0 +1,209 @@ +package api + +import ( + "io" + "log" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + + "nano-run/services/meta" + "nano-run/worker" +) + +// Expose worker as HTTP handler: +// POST / - post task async, returns 303 See Other and location. +// GET /:id - get task info. +// POST /:id - retry task, redirects to /:id +// GET /:id/completed - redirect to completed attempt (or 404 if task not yet complete) +// GET /:id/attempt/:atid - get attempt result (as-is). +// GET /:id/request - replay request (as-is). +func Expose(router gin.IRouter, wrk *worker.Worker) { + //TODO: wait + router.POST("/", func(gctx *gin.Context) { + id, err := wrk.Enqueue(gctx.Request) + if err != nil { + log.Println("failed to enqueue:", err) + gctx.AbortWithError(http.StatusInternalServerError, err) + return + } + gctx.Header("X-Correlation-Id", id) + gctx.Redirect(http.StatusSeeOther, id) + }) + handler := &workerHandler{wrk: wrk} + taskRoutes := router.Group("/:id") + taskRoutes.GET("", handler.getTask) + taskRoutes.POST("", handler.retry) + taskRoutes.DELETE("", handler.completeRequest) + taskRoutes.GET("/completed", handler.getComplete) + // get attempt result as-is. + taskRoutes.GET("/attempt/:attemptId", handler.getAttempt) + // get recorded request. + taskRoutes.GET("/request", handler.getRequest) +} + +type workerHandler struct { + wrk *worker.Worker +} + +// get request meta information. +func (wh *workerHandler) getTask(gctx *gin.Context) { + requestID := gctx.Param("id") + info, err := wh.wrk.Meta().Get(requestID) + if err != nil { + log.Println("failed access request", requestID, ":", err) + gctx.AbortWithStatus(http.StatusNotFound) + return + } + gctx.Header("X-Correlation-Id", requestID) + gctx.Header("Content-Version", strconv.Itoa(len(info.Attempts))) + // modification time + setLastModify(gctx, info) + + gctx.Header("Age", strconv.FormatInt(int64(time.Since(info.CreatedAt)/time.Second), 10)) + if info.Complete { + gctx.Header("X-Status", "complete") + } else { + gctx.Header("X-Status", "processing") + } + + if len(info.Attempts) > 0 { + gctx.Header("X-Last-Attempt", info.Attempts[len(info.Attempts)-1].ID) + gctx.Header("X-Last-Attempt-At", info.Attempts[len(info.Attempts)-1].CreatedAt.Format(time.RFC850)) + } + + if info.Complete { + lastAttempt := info.Attempts[len(info.Attempts)-1] + gctx.Request.URL.Path += "/attempt/" + lastAttempt.ID + gctx.Header("Location", gctx.Request.URL.String()) + } + gctx.IndentedJSON(http.StatusOK, info) +} + +func (wh *workerHandler) retry(gctx *gin.Context) { + requestID := gctx.Param("id") + id, err := wh.wrk.Retry(gctx.Request.Context(), requestID) + if err != nil { + log.Println("failed to retry:", err) + gctx.AbortWithError(http.StatusInternalServerError, err) + return + } + gctx.Header("X-Correlation-Id", id) + gctx.Redirect(http.StatusSeeOther, id) +} + +func (wh *workerHandler) completeRequest(gctx *gin.Context) { + requestID := gctx.Param("id") + info, err := wh.wrk.Meta().Get(requestID) + if err != nil { + log.Println("failed access request", requestID, ":", err) + gctx.AbortWithStatus(http.StatusNotFound) + return + } + if !info.Complete { + err = wh.wrk.Meta().Complete(requestID) + if err != nil { + log.Println("failed to mark request as complete:", err) + gctx.AbortWithError(http.StatusInternalServerError, err) + return + } + } + gctx.AbortWithStatus(http.StatusNoContent) +} + +func (wh *workerHandler) getComplete(gctx *gin.Context) { + requestID := gctx.Param("id") + info, err := wh.wrk.Meta().Get(requestID) + if err != nil { + log.Println("failed access request", requestID, ":", err) + gctx.AbortWithStatus(http.StatusNotFound) + return + } + if !info.Complete { + gctx.AbortWithStatus(http.StatusTooEarly) + return + } + lastAttempt := info.Attempts[len(info.Attempts)-1] + gctx.Redirect(http.StatusMovedPermanently, "attempt/"+lastAttempt.ID) +} + +func (wh *workerHandler) getAttempt(gctx *gin.Context) { + requestID := gctx.Param("id") + attemptID := gctx.Param("attemptId") + info, err := wh.wrk.Meta().Get(requestID) + if err != nil { + log.Println("failed access request", requestID, ":", err) + gctx.AbortWithStatus(http.StatusNotFound) + return + } + var attempt meta.Attempt + var found bool + for _, atp := range info.Attempts { + if atp.ID == attemptID { + found = true + attempt = atp + break + } + } + if !found { + gctx.AbortWithStatus(http.StatusNotFound) + return + } + body, err := wh.wrk.Blobs().Get(attempt.ID) + if err != nil { + log.Println("failed to get body:", err) + gctx.AbortWithError(http.StatusInternalServerError, err) + return + } + defer body.Close() + gctx.Header("Last-Modified", attempt.CreatedAt.Format(time.RFC850)) + if info.Complete { + gctx.Header("X-Status", "complete") + } else { + gctx.Header("X-Status", "processing") + } + gctx.Header("X-Processed", "true") + for k, v := range attempt.Headers { + gctx.Request.Header[k] = v + } + _, _ = io.Copy(gctx.Writer, body) +} + +func (wh *workerHandler) getRequest(gctx *gin.Context) { + requestID := gctx.Param("id") + info, err := wh.wrk.Meta().Get(requestID) + if err != nil { + log.Println("failed access request", requestID, ":", err) + gctx.AbortWithStatus(http.StatusNotFound) + return + } + gctx.Header("Last-Modified", info.CreatedAt.Format(time.RFC850)) + f, err := wh.wrk.Blobs().Get(requestID) + if err != nil { + log.Println("failed to get data:", err) + gctx.AbortWithError(http.StatusInternalServerError, err) + return + } + defer f.Close() + + gctx.Header("X-Method", info.Method) + gctx.Header("X-Request-Uri", info.URI) + + for k, v := range info.Headers { + gctx.Request.Header[k] = v + } + gctx.Status(http.StatusOK) + _, _ = io.Copy(gctx.Writer, f) +} + +func setLastModify(gctx *gin.Context, info *meta.Request) { + if info.Complete { + gctx.Header("Last-Modified", info.CompleteAt.Format(time.RFC850)) + } else if len(info.Attempts) > 0 { + gctx.Header("Last-Modified", info.Attempts[len(info.Attempts)-1].CreatedAt.Format(time.RFC850)) + } else { + gctx.Header("Last-Modified", info.CreatedAt.Format(time.RFC850)) + } +} diff --git a/server/internal/flags.go b/server/api/flags.go similarity index 81% rename from server/internal/flags.go rename to server/api/flags.go index 7278639..3ea6603 100644 --- a/server/internal/flags.go +++ b/server/api/flags.go @@ -1,6 +1,6 @@ // +build !linux -package internal +package api import "os/exec" diff --git a/server/internal/flags_linux.go b/server/api/flags_linux.go similarity index 92% rename from server/internal/flags_linux.go rename to server/api/flags_linux.go index 5587dab..57dd673 100644 --- a/server/internal/flags_linux.go +++ b/server/api/flags_linux.go @@ -1,4 +1,4 @@ -package internal +package api import ( "os/exec" diff --git a/server/auth.go b/server/auth.go index 9ed6e9c..bc9f281 100644 --- a/server/auth.go +++ b/server/auth.go @@ -5,10 +5,11 @@ import ( "net/http" "github.com/dgrijalva/jwt-go" + "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" ) -func (cfg Unit) enableAuthorization() func(handler http.Handler) http.Handler { +func (cfg Unit) enableAuthorization() func(gctx *gin.Context) { var handlers []AuthHandlerFunc if cfg.Authorization.JWT.Enable { handlers = append(handlers, cfg.Authorization.JWT.Create()) @@ -22,26 +23,19 @@ func (cfg Unit) enableAuthorization() func(handler http.Handler) http.Handler { if cfg.Authorization.Basic.Enable { handlers = append(handlers, cfg.Authorization.Basic.Create()) } - if len(handlers) == 0 { - return func(handler http.Handler) http.Handler { - return handler + return func(gctx *gin.Context) { + var authorized = len(handlers) == 0 + for _, h := range handlers { + if h(gctx.Request) { + authorized = true + break + } } - } - return func(handler http.Handler) http.Handler { - return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - var authorized bool - for _, h := range handlers { - if h(request) { - authorized = true - break - } - } - if !authorized { - writer.WriteHeader(http.StatusForbidden) - return - } - handler.ServeHTTP(writer, request) - }) + if !authorized { + gctx.AbortWithStatus(http.StatusForbidden) + return + } + gctx.Next() } } diff --git a/server/internal/adapter.go b/server/internal/adapter.go deleted file mode 100644 index 37c4d67..0000000 --- a/server/internal/adapter.go +++ /dev/null @@ -1,232 +0,0 @@ -package internal - -import ( - "encoding/json" - "io" - "log" - "net/http" - "strconv" - "time" - - "github.com/gorilla/mux" - - "nano-run/services/meta" - "nano-run/worker" -) - -// Expose worker as HTTP handler: -// POST / - post task async, returns 303 See Other and location. -// GET /:id - get task info. -// POST /:id - retry task, redirects to /:id -// GET /:id/completed - redirect to completed attempt (or 404 if task not yet complete) -// GET /:id/attempt/:atid - get attempt result (as-is). -// GET /:id/request - replay request (as-is). -func Expose(router *mux.Router, wrk *worker.Worker) { - //TODO: wait - router.Path("/").Methods("POST").HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - id, err := wrk.Enqueue(request) - if err != nil { - log.Println("failed to enqueue:", err) - http.Error(writer, "failed to enqueue", http.StatusInternalServerError) - return - } - writer.Header().Set("X-Correlation-Id", id) - http.Redirect(writer, request, id, http.StatusSeeOther) - }) - router.Path("/{id}").Methods("GET").HandlerFunc(getTask(wrk)) - router.Path("/{id}").Methods("POST").HandlerFunc(retry(wrk)) - router.Path("/{id}").Methods("DELETE").HandlerFunc(completeRequest(wrk)) - router.Path("/{id}/completed").Methods("GET").HandlerFunc(getComplete(wrk)) - // get attempt result as-is. - router.Path("/{id}/attempt/{attemptId}").Methods("GET").HandlerFunc(getAttempt(wrk)) - // get recorded request. - router.Path("/{id}/request").Methods("GET").HandlerFunc(getRequest(wrk)) -} - -// get request meta information. -func getTask(wrk *worker.Worker) http.HandlerFunc { - return func(writer http.ResponseWriter, request *http.Request) { - params := mux.Vars(request) - requestID := params["id"] - info, err := wrk.Meta().Get(requestID) - if err != nil { - log.Println("failed access request", requestID, ":", err) - http.NotFound(writer, request) - return - } - data, err := json.Marshal(info) - if err != nil { - log.Println("failed to encode info:", err) - http.Error(writer, "encoding", http.StatusInternalServerError) - return - } - writer.Header().Set("X-Correlation-Id", requestID) - writer.Header().Set("Content-Type", "application/json") - writer.Header().Set("Content-Length", strconv.Itoa(len(data))) - writer.Header().Set("Content-Version", strconv.Itoa(len(info.Attempts))) - // modification time - setLastModify(writer, info) - - writer.Header().Set("Age", strconv.FormatInt(int64(time.Since(info.CreatedAt)/time.Second), 10)) - if info.Complete { - writer.Header().Set("X-Status", "complete") - } else { - writer.Header().Set("X-Status", "processing") - } - - if len(info.Attempts) > 0 { - writer.Header().Set("X-Last-Attempt", info.Attempts[len(info.Attempts)-1].ID) - writer.Header().Set("X-Last-Attempt-At", info.Attempts[len(info.Attempts)-1].CreatedAt.Format(time.RFC850)) - } - - if info.Complete { - lastAttempt := info.Attempts[len(info.Attempts)-1] - request.URL.Path += "/attempt/" + lastAttempt.ID - writer.Header().Set("Location", request.URL.String()) - } - writer.WriteHeader(http.StatusOK) - _, _ = writer.Write(data) - } -} - -func retry(wrk *worker.Worker) http.HandlerFunc { - return func(writer http.ResponseWriter, request *http.Request) { - params := mux.Vars(request) - requestID := params["id"] - id, err := wrk.Retry(request.Context(), requestID) - if err != nil { - log.Println("failed to retry:", err) - http.Error(writer, "failed to enqueue", http.StatusInternalServerError) - return - } - writer.Header().Set("X-Correlation-Id", id) - http.Redirect(writer, request, id, http.StatusSeeOther) - } -} - -func completeRequest(wrk *worker.Worker) http.HandlerFunc { - return func(writer http.ResponseWriter, request *http.Request) { - params := mux.Vars(request) - requestID := params["id"] - info, err := wrk.Meta().Get(requestID) - if err != nil { - log.Println("failed access request", requestID, ":", err) - http.NotFound(writer, request) - return - } - if !info.Complete { - err = wrk.Meta().Complete(requestID) - if err != nil { - log.Println("failed to mark request as complete:", err) - http.Error(writer, "failed to complete", http.StatusInternalServerError) - return - } - } - writer.WriteHeader(http.StatusNoContent) - } -} - -func getComplete(wrk *worker.Worker) http.HandlerFunc { - return func(writer http.ResponseWriter, request *http.Request) { - params := mux.Vars(request) - requestID := params["id"] - info, err := wrk.Meta().Get(requestID) - if err != nil { - log.Println("failed access request", requestID, ":", err) - http.NotFound(writer, request) - return - } - if !info.Complete { - http.NotFound(writer, request) - return - } - lastAttempt := info.Attempts[len(info.Attempts)-1] - http.Redirect(writer, request, "attempt/"+lastAttempt.ID, http.StatusMovedPermanently) - } -} - -func getAttempt(wrk *worker.Worker) http.HandlerFunc { - return func(writer http.ResponseWriter, request *http.Request) { - params := mux.Vars(request) - requestID := params["id"] - attemptID := params["attemptId"] - info, err := wrk.Meta().Get(requestID) - if err != nil { - log.Println("failed access request", requestID, ":", err) - http.NotFound(writer, request) - return - } - var attempt meta.Attempt - var found bool - for _, atp := range info.Attempts { - if atp.ID == attemptID { - found = true - attempt = atp - break - } - } - if !found { - http.NotFound(writer, request) - return - } - body, err := wrk.Blobs().Get(attempt.ID) - if err != nil { - log.Println("failed to get body:", err) - http.Error(writer, "get body", http.StatusInternalServerError) - return - } - defer body.Close() - writer.Header().Set("Last-Modified", attempt.CreatedAt.Format(time.RFC850)) - if info.Complete { - writer.Header().Set("X-Status", "complete") - } else { - writer.Header().Set("X-Status", "processing") - } - writer.Header().Set("X-Processed", "true") - for k, v := range attempt.Headers { - writer.Header()[k] = v - } - writer.WriteHeader(attempt.Code) - _, _ = io.Copy(writer, body) - } -} - -func getRequest(wrk *worker.Worker) http.HandlerFunc { - return func(writer http.ResponseWriter, request *http.Request) { - params := mux.Vars(request) - requestID := params["id"] - info, err := wrk.Meta().Get(requestID) - if err != nil { - log.Println("failed access request", requestID, ":", err) - http.NotFound(writer, request) - return - } - writer.Header().Set("Last-Modified", info.CreatedAt.Format(time.RFC850)) - f, err := wrk.Blobs().Get(requestID) - if err != nil { - log.Println("failed to get data:", err) - http.Error(writer, "data", http.StatusInternalServerError) - return - } - defer f.Close() - - writer.Header().Set("X-Method", info.Method) - writer.Header().Set("X-Request-Uri", info.URI) - - for k, v := range info.Headers { - writer.Header()[k] = v - } - writer.WriteHeader(http.StatusOK) - _, _ = io.Copy(writer, f) - } -} - -func setLastModify(writer http.ResponseWriter, info *meta.Request) { - if info.Complete { - writer.Header().Set("Last-Modified", info.CompleteAt.Format(time.RFC850)) - } else if len(info.Attempts) > 0 { - writer.Header().Set("Last-Modified", info.Attempts[len(info.Attempts)-1].CreatedAt.Format(time.RFC850)) - } else { - writer.Header().Set("Last-Modified", info.CreatedAt.Format(time.RFC850)) - } -} diff --git a/server/mode_bin.go b/server/mode_bin.go index 04c323f..90a1d35 100644 --- a/server/mode_bin.go +++ b/server/mode_bin.go @@ -10,7 +10,7 @@ import ( "strings" "time" - "nano-run/server/internal" + "nano-run/server/api" ) type markerResponse struct { @@ -74,7 +74,7 @@ func (bh *binHandler) ServeHTTP(writer http.ResponseWriter, request *http.Reques cmd.Stdin = request.Body cmd.Stdout = marker cmd.Env = env - internal.SetBinFlags(cmd) + api.SetBinFlags(cmd) err := cmd.Run() if codeReset, ok := writer.(interface{ Status(status int) }); ok && err != nil { diff --git a/server/server.go b/server/runner/handler.go similarity index 78% rename from server/server.go rename to server/runner/handler.go index a01f3a2..09fabc0 100644 --- a/server/server.go +++ b/server/runner/handler.go @@ -1,8 +1,7 @@ -package server +package runner import ( "context" - "io" "io/ioutil" "log" "net/http" @@ -10,12 +9,16 @@ import ( "path/filepath" "time" + "github.com/gin-gonic/gin" "gopkg.in/yaml.v2" + "nano-run/server" + "nano-run/server/ui" "nano-run/worker" ) type Config struct { + UIDirectory string `yaml:"ui_directory"` WorkingDirectory string `yaml:"working_directory"` ConfigDirectory string `yaml:"config_directory"` Bind string `yaml:"bind"` @@ -37,6 +40,7 @@ func DefaultConfig() Config { cfg.Bind = defaultBind cfg.WorkingDirectory = filepath.Join("run") cfg.ConfigDirectory = filepath.Join("conf.d") + cfg.UIDirectory = filepath.Join("ui") cfg.GracefulShutdown = defaultGracefulShutdown return cfg } @@ -76,17 +80,28 @@ func (cfg Config) SaveFile(file string) error { } func (cfg Config) Create(global context.Context) (*Server, error) { - units, err := Units(cfg.ConfigDirectory) + units, err := server.Units(cfg.ConfigDirectory) if err != nil { return nil, err } - workers, err := Workers(cfg.WorkingDirectory, units) + workers, err := server.Workers(cfg.WorkingDirectory, units) if err != nil { return nil, err } ctx, cancel := context.WithCancel(global) + + router := gin.Default() + server.Attach(router.Group("/api/"), units, workers) + ui.Attach(router.Group("/ui/"), units, cfg.UIDirectory) + router.Group("/", func(gctx *gin.Context) { + gctx.Redirect(http.StatusTemporaryRedirect, "ui") + }) + //router.Path("/").Methods("GET").HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + // http.Redirect(writer, request, "ui", http.StatusTemporaryRedirect) + //}) + srv := &Server{ - Handler: Handler(units, workers), + Handler: router, workers: workers, units: units, done: make(chan struct{}), @@ -132,30 +147,17 @@ func (cfg Config) Run(global context.Context) error { return err } -func limitRequest(maxSize int64, handler http.Handler) http.Handler { - return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - body := request.Body - defer body.Close() - if request.ContentLength > maxSize { - http.Error(writer, "too big request", http.StatusBadRequest) - return - } - - limiter := io.LimitReader(request.Body, maxSize) - request.Body = ioutil.NopCloser(limiter) - handler.ServeHTTP(writer, request) - }) -} - type Server struct { http.Handler workers []*worker.Worker - units []Unit + units []server.Unit cancel func() done chan struct{} err error } +func (srv *Server) Units() []server.Unit { return srv.units } + func (srv *Server) Close() { for _, wrk := range srv.workers { wrk.Close() @@ -169,7 +171,7 @@ func (srv *Server) Err() error { } func (srv *Server) run(ctx context.Context) { - err := Run(ctx, srv.workers) + err := server.Run(ctx, srv.workers) if err != nil { log.Println("workers stopped:", err) } diff --git a/server/server_test.go b/server/server_test.go index ffebd84..cb6e5ae 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/assert" "nano-run/server" + "nano-run/server/runner" "nano-run/services/meta" ) @@ -32,7 +33,7 @@ func TestMain(main *testing.M) { os.Exit(code) } -func testServer(t *testing.T, cfg server.Config, units map[string]server.Unit) *server.Server { +func testServer(t *testing.T, cfg runner.Config, units map[string]server.Unit) *runner.Server { sub, err := ioutil.TempDir(tmpDir, "") if !assert.NoError(t, err) { t.Fatal("failed to create temp dir", err) @@ -60,28 +61,29 @@ func testServer(t *testing.T, cfg server.Config, units map[string]server.Unit) * } func Test_create(t *testing.T) { - srv := testServer(t, server.DefaultConfig(), map[string]server.Unit{ + srv := testServer(t, runner.DefaultConfig(), map[string]server.Unit{ "hello": { Command: "echo -n hello world", }, }) defer srv.Close() - req := httptest.NewRequest(http.MethodPost, "/hello/", bytes.NewBufferString("hello world")) + req := httptest.NewRequest(http.MethodPost, "/api/hello/", bytes.NewBufferString("hello world")) res := httptest.NewRecorder() srv.ServeHTTP(res, req) assert.Equal(t, http.StatusSeeOther, res.Code) assert.NotEmpty(t, res.Header().Get("X-Correlation-Id")) - assert.Equal(t, "/hello/"+res.Header().Get("X-Correlation-Id"), res.Header().Get("Location")) + assert.Equal(t, "/api/hello/"+res.Header().Get("X-Correlation-Id"), res.Header().Get("Location")) requestID := res.Header().Get("X-Correlation-Id") infoURL := res.Header().Get("Location") + t.Log("Location:", infoURL) req = httptest.NewRequest(http.MethodGet, infoURL, nil) res = httptest.NewRecorder() srv.ServeHTTP(res, req) assert.Equal(t, http.StatusOK, res.Code) assert.Equal(t, requestID, res.Header().Get("X-Correlation-Id")) - assert.Equal(t, "application/json", res.Header().Get("Content-Type")) + assert.Contains(t, res.Header().Get("Content-Type"), "application/json") var info meta.Request err := json.Unmarshal(res.Body.Bytes(), &info) assert.NoError(t, err) @@ -96,7 +98,7 @@ func Test_create(t *testing.T) { resultLocation = res.Header().Get("Location") break } - if !assert.Equal(t, http.StatusNotFound, res.Code) { + if !assert.Equal(t, http.StatusTooEarly, res.Code) { return } time.Sleep(time.Second) @@ -110,19 +112,19 @@ func Test_create(t *testing.T) { } func Test_retryIfDataReturnedInBinMode(t *testing.T) { - srv := testServer(t, server.DefaultConfig(), map[string]server.Unit{ + srv := testServer(t, runner.DefaultConfig(), map[string]server.Unit{ "hello": { Command: "echo hello world; exit 1", }, }) defer srv.Close() - req := httptest.NewRequest(http.MethodPost, "/hello/", bytes.NewBufferString("hello world")) + req := httptest.NewRequest(http.MethodPost, "/api/hello/", bytes.NewBufferString("hello world")) res := httptest.NewRecorder() srv.ServeHTTP(res, req) assert.Equal(t, http.StatusSeeOther, res.Code) assert.NotEmpty(t, res.Header().Get("X-Correlation-Id")) - assert.Equal(t, "/hello/"+res.Header().Get("X-Correlation-Id"), res.Header().Get("Location")) + assert.Equal(t, "/api/hello/"+res.Header().Get("X-Correlation-Id"), res.Header().Get("Location")) location := res.Header().Get("Location") // wait for first result diff --git a/server/ui/mapper.go b/server/ui/mapper.go new file mode 100644 index 0000000..15ee436 --- /dev/null +++ b/server/ui/mapper.go @@ -0,0 +1,5 @@ +package ui + +func withParams() { + +} diff --git a/server/ui/router.go b/server/ui/router.go new file mode 100644 index 0000000..66acf3e --- /dev/null +++ b/server/ui/router.go @@ -0,0 +1,72 @@ +package ui + +import ( + "html/template" + "net/http" + "path/filepath" + + "github.com/Masterminds/sprig" + "github.com/gin-gonic/gin" + + "nano-run/server" +) + +func Expose(units []server.Unit, uiDir string) http.Handler { + router := gin.New() + Attach(router, units, uiDir) + return router +} + +func Attach(router gin.IRouter, units []server.Unit, uiDir string) { + ui := &uiRouter{ + dir: uiDir, + units: units, + } + router.GET("/units", ui.listUnits) + router.GET("/unit/:name", ui.unitInfo) +} + +type uiRouter struct { + dir string + units []server.Unit +} + +func (ui *uiRouter) unitInfo(gctx *gin.Context) { + name := gctx.Param("name") + var unit *server.Unit + for _, u := range ui.units { + if u.Name() == name { + unit = &u + break + } + } + if unit == nil { + gctx.AbortWithStatus(http.StatusNotFound) + return + } + var reply struct { + Unit *server.Unit + } + reply.Unit = unit + gctx.HTML(http.StatusOK, "unit-info.html", reply) +} + +func (ui *uiRouter) listUnits(gctx *gin.Context) { + var reply struct { + Units []server.Unit + } + reply.Units = ui.units + gctx.HTML(http.StatusOK, "units-list.html", reply) +} + +func (ui *uiRouter) getTemplate(name string) *template.Template { + t, err := template.New("").Funcs(sprig.HtmlFuncMap()).ParseFiles(filepath.Join(ui.dir, name)) + if err == nil { + return t + } + t, err = template.New("").Parse("Ooops... Page not found") + if err != nil { + panic(err) + } + return t +} diff --git a/server/unit.go b/server/unit.go index ee035c2..26dcff6 100644 --- a/server/unit.go +++ b/server/unit.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "io/ioutil" "log" "net/http" @@ -17,10 +18,10 @@ import ( "sync" "time" - "github.com/gorilla/mux" + "github.com/gin-gonic/gin" "gopkg.in/yaml.v2" - "nano-run/server/internal" + "nano-run/server/api" "nano-run/worker" ) @@ -111,6 +112,15 @@ func (cfg Unit) SaveFile(file string) error { return ioutil.WriteFile(file, data, 0600) } +func (cfg Unit) Name() string { return cfg.name } + +func (cfg Unit) Secured() bool { + return cfg.Authorization.Basic.Enable || + cfg.Authorization.HeaderToken.Enable || + cfg.Authorization.QueryToken.Enable || + cfg.Authorization.JWT.Enable +} + func Units(configsDir string) ([]Unit, error) { var configs []Unit err := filepath.Walk(configsDir, func(path string, info os.FileInfo, err error) error { @@ -164,16 +174,19 @@ func Workers(workdir string, configurations []Unit) ([]*worker.Worker, error) { } func Handler(units []Unit, workers []*worker.Worker) http.Handler { - router := mux.NewRouter() - for i, unit := range units { - prefix := "/" + unit.name + "/" - subRouter := router.PathPrefix(prefix).Subrouter() - subRouter.Use(unit.enableAuthorization()) - internal.Expose(subRouter, workers[i]) - } + router := gin.New() + Attach(router, units, workers) return router } +func Attach(router gin.IRouter, units []Unit, workers []*worker.Worker) { + for i, unit := range units { + group := router.Group("/" + unit.name + "/") + group.Use(unit.enableAuthorization()) + api.Expose(group, workers[i]) + } +} + func Run(global context.Context, workers []*worker.Worker) error { if len(workers) == 0 { <-global.Done() @@ -263,3 +276,18 @@ func makeEnvList(content map[string]string) []string { } return ans } + +func limitRequest(maxSize int64, handler http.Handler) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + body := request.Body + defer body.Close() + if request.ContentLength > maxSize { + http.Error(writer, "too big request", http.StatusBadRequest) + return + } + + limiter := io.LimitReader(request.Body, maxSize) + request.Body = ioutil.NopCloser(limiter) + handler.ServeHTTP(writer, request) + }) +} diff --git a/templates/unit-info.html b/templates/unit-info.html new file mode 100644 index 0000000..8696c4d --- /dev/null +++ b/templates/unit-info.html @@ -0,0 +1,18 @@ + + +
+ +

{{.Unit.Name}}

+ {{if .Unit.Secured}} + secured + {{end}} +
+

+ {{.Unit.Mode}}, + {{.Unit.Workers}}, + {{.Unit.Interval}}, + {{.Unit.Timeout}}, +

+
+ + \ No newline at end of file diff --git a/templates/units-list.html b/templates/units-list.html new file mode 100644 index 0000000..cee058e --- /dev/null +++ b/templates/units-list.html @@ -0,0 +1,17 @@ + + +{{range .Units}} +
+ +

{{.Name}}

+
+

+ {{.Mode}}, + {{.Workers}}, + {{.Interval}}, + {{.Timeout}}, +

+
+{{end}} + + \ No newline at end of file