Puberty commit
This commit is contained in:
commit
34034b5223
|
@ -0,0 +1,16 @@
|
||||||
|
install:
|
||||||
|
cd backend && dep ensure
|
||||||
|
cd backend && go install ./...
|
||||||
|
cd frontend && npm install
|
||||||
|
|
||||||
|
build:
|
||||||
|
cd frontend && npm run build
|
||||||
|
go-bindata-assetfs \
|
||||||
|
-o=backend/api/assets/bindata_assetfs.go \
|
||||||
|
-pkg=assets \
|
||||||
|
-prefix=frontend/build \
|
||||||
|
frontend/build/...
|
||||||
|
go build -tags=binassets -o backend/build/cmdui backend/main.go
|
||||||
|
|
||||||
|
cloc:
|
||||||
|
cloc --exclude-dir=vendor,build,node_modules .
|
|
@ -0,0 +1,39 @@
|
||||||
|
# Command UI
|
||||||
|
|
||||||
|
A web UI for go-api commands.
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
|
||||||
|
* npm
|
||||||
|
* go
|
||||||
|
* dep
|
||||||
|
|
||||||
|
If you are using a Mac and have homebrew installed, you can run this:
|
||||||
|
|
||||||
|
```
|
||||||
|
brew install go # If you don't have Go installed
|
||||||
|
go get -u github.com/golang/dep/cmd/dep
|
||||||
|
brew install npm
|
||||||
|
```
|
||||||
|
|
||||||
|
# Installation
|
||||||
|
|
||||||
|
```
|
||||||
|
make install
|
||||||
|
```
|
||||||
|
|
||||||
|
Import `schema.sql` into a MySQL database.
|
||||||
|
|
||||||
|
# Starting
|
||||||
|
|
||||||
|
First session:
|
||||||
|
|
||||||
|
```
|
||||||
|
cd backend && go run main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
Second session:
|
||||||
|
|
||||||
|
```
|
||||||
|
cd frontend && npm start
|
||||||
|
```
|
|
@ -0,0 +1,4 @@
|
||||||
|
vendor
|
||||||
|
build
|
||||||
|
api/assets/bindata_assetfs.go
|
||||||
|
config.toml
|
|
@ -0,0 +1,69 @@
|
||||||
|
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
|
||||||
|
|
||||||
|
|
||||||
|
[[projects]]
|
||||||
|
name = "github.com/BurntSushi/toml"
|
||||||
|
packages = ["."]
|
||||||
|
revision = "b26d9c308763d68093482582cea63d69be07a0f0"
|
||||||
|
version = "v0.3.0"
|
||||||
|
|
||||||
|
[[projects]]
|
||||||
|
name = "github.com/Sirupsen/logrus"
|
||||||
|
packages = ["."]
|
||||||
|
revision = "f006c2ac4710855cf0f916dd6b77acf6b048dc6e"
|
||||||
|
version = "v1.0.3"
|
||||||
|
|
||||||
|
[[projects]]
|
||||||
|
branch = "master"
|
||||||
|
name = "github.com/elazarl/go-bindata-assetfs"
|
||||||
|
packages = ["."]
|
||||||
|
revision = "30f82fa23fd844bd5bb1e5f216db87fd77b5eb43"
|
||||||
|
|
||||||
|
[[projects]]
|
||||||
|
name = "github.com/go-sql-driver/mysql"
|
||||||
|
packages = ["."]
|
||||||
|
revision = "a0583e0143b1624142adab07e0e97fe106d99561"
|
||||||
|
version = "v1.3"
|
||||||
|
|
||||||
|
[[projects]]
|
||||||
|
branch = "master"
|
||||||
|
name = "github.com/jmoiron/sqlx"
|
||||||
|
packages = [".","reflectx"]
|
||||||
|
revision = "d9bd385d68c068f1fabb5057e3dedcbcbb039d0f"
|
||||||
|
|
||||||
|
[[projects]]
|
||||||
|
branch = "master"
|
||||||
|
name = "github.com/juju/errors"
|
||||||
|
packages = ["."]
|
||||||
|
revision = "c7d06af17c68cd34c835053720b21f6549d9b0ee"
|
||||||
|
|
||||||
|
[[projects]]
|
||||||
|
name = "github.com/julienschmidt/httprouter"
|
||||||
|
packages = ["."]
|
||||||
|
revision = "8c199fb6259ffc1af525cc3ad52ee60ba8359669"
|
||||||
|
version = "v1.1"
|
||||||
|
|
||||||
|
[[projects]]
|
||||||
|
name = "github.com/satori/go.uuid"
|
||||||
|
packages = ["."]
|
||||||
|
revision = "879c5887cd475cd7864858769793b2ceb0d44feb"
|
||||||
|
version = "v1.1.0"
|
||||||
|
|
||||||
|
[[projects]]
|
||||||
|
branch = "master"
|
||||||
|
name = "golang.org/x/crypto"
|
||||||
|
packages = ["ssh/terminal"]
|
||||||
|
revision = "2509b142fb2b797aa7587dad548f113b2c0f20ce"
|
||||||
|
|
||||||
|
[[projects]]
|
||||||
|
branch = "master"
|
||||||
|
name = "golang.org/x/sys"
|
||||||
|
packages = ["unix","windows"]
|
||||||
|
revision = "b98136db334ff9cb24f28a68e3be3cb6608f7630"
|
||||||
|
|
||||||
|
[solve-meta]
|
||||||
|
analyzer-name = "dep"
|
||||||
|
analyzer-version = 1
|
||||||
|
inputs-digest = "6c201fcf68612a8ad33e6e620125ca3a88e8e4c34d7acc2b5ed0f452c3457a3f"
|
||||||
|
solver-name = "gps-cdcl"
|
||||||
|
solver-version = 1
|
|
@ -0,0 +1,53 @@
|
||||||
|
[[constraint]]
|
||||||
|
name = "github.com/jmoiron/sqlx"
|
||||||
|
branch = "master"
|
||||||
|
|
||||||
|
[[constraint]]
|
||||||
|
name = "github.com/go-sql-driver/mysql"
|
||||||
|
version = "1.3.0"
|
||||||
|
|
||||||
|
[[constraint]]
|
||||||
|
name = "github.com/julienschmidt/httprouter"
|
||||||
|
version = "1.1.0"
|
||||||
|
|
||||||
|
[[constraint]]
|
||||||
|
name = "github.com/elazarl/go-bindata-assetfs"
|
||||||
|
branch = "master"
|
||||||
|
|
||||||
|
[[constraint]]
|
||||||
|
name = "github.com/BurntSushi/toml"
|
||||||
|
version = "v0.3.0"
|
||||||
|
|
||||||
|
[[constraint]]
|
||||||
|
name = "github.com/juju/errors"
|
||||||
|
branch = "master"
|
||||||
|
|
||||||
|
[[constraint]]
|
||||||
|
name = "github.com/satori/go.uuid"
|
||||||
|
version = "1.1.0"
|
||||||
|
|
||||||
|
|
||||||
|
# Gopkg.toml example
|
||||||
|
#
|
||||||
|
# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md
|
||||||
|
# for detailed Gopkg.toml documentation.
|
||||||
|
#
|
||||||
|
# required = ["github.com/user/thing/cmd/thing"]
|
||||||
|
# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"]
|
||||||
|
#
|
||||||
|
# [[constraint]]
|
||||||
|
# name = "github.com/user/project"
|
||||||
|
# version = "1.0.0"
|
||||||
|
#
|
||||||
|
# [[constraint]]
|
||||||
|
# name = "github.com/user/project2"
|
||||||
|
# branch = "dev"
|
||||||
|
# source = "github.com/myfork/project2"
|
||||||
|
#
|
||||||
|
# [[override]]
|
||||||
|
# name = "github.com/x/y"
|
||||||
|
# version = "2.4.0"
|
||||||
|
|
||||||
|
[[constraint]]
|
||||||
|
name = "github.com/Sirupsen/logrus"
|
||||||
|
version = "1.0.3"
|
|
@ -0,0 +1,154 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/julienschmidt/httprouter"
|
||||||
|
|
||||||
|
"github.com/localhots/cmdui/backend/api/assets"
|
||||||
|
"github.com/localhots/cmdui/backend/api/auth"
|
||||||
|
"github.com/localhots/cmdui/backend/config"
|
||||||
|
"github.com/localhots/cmdui/backend/db"
|
||||||
|
"github.com/localhots/cmdui/backend/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Start starts a web server that runs both backend API and serves assets that
|
||||||
|
// support the UI.
|
||||||
|
func Start() error {
|
||||||
|
assHand := assets.Handler()
|
||||||
|
router.NotFound = func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assHand.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := config.Get().Server
|
||||||
|
log.Logger().Infof("Starting command UI server at %s:%d", cfg.Host, cfg.Port)
|
||||||
|
return http.ListenAndServe(fmt.Sprintf(":%d", cfg.Port), router)
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Endpoints
|
||||||
|
//
|
||||||
|
|
||||||
|
type handle func(ctx context.Context, w http.ResponseWriter, r *http.Request)
|
||||||
|
|
||||||
|
const rootPath = "/"
|
||||||
|
|
||||||
|
var router = httprouter.New()
|
||||||
|
|
||||||
|
func openEndpoint(h handle) httprouter.Handle {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||||
|
ctx := contextWithParams(r.Context(), params)
|
||||||
|
h(ctx, w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func protectedEndpoint(h handle) httprouter.Handle {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||||
|
ctx, err := auth.AuthenticateRequest(w, r)
|
||||||
|
if err != nil {
|
||||||
|
renderUnauthorized(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx = contextWithParams(ctx, params)
|
||||||
|
h(ctx, w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Rendering
|
||||||
|
//
|
||||||
|
|
||||||
|
// renderJSON is a convinience function that encodes any value as JSON and
|
||||||
|
// writes it to response with appropriate headers included.
|
||||||
|
func renderJSON(w http.ResponseWriter, v interface{}) {
|
||||||
|
body, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
renderError(w, err, http.StatusInternalServerError, "Failed to encode response into JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderError(w http.ResponseWriter, err error, status int, msg string) {
|
||||||
|
log.WithFields(log.F{
|
||||||
|
"status": status,
|
||||||
|
"error": err,
|
||||||
|
}).Warnf("Request failed: %s", msg)
|
||||||
|
http.Error(w, msg, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderUnauthorized(w http.ResponseWriter, err error) {
|
||||||
|
renderError(w, err, http.StatusUnauthorized, "Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Params and context
|
||||||
|
//
|
||||||
|
|
||||||
|
type ctxKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ctxParamsKey ctxKey = "params"
|
||||||
|
)
|
||||||
|
|
||||||
|
func contextWithParams(ctx context.Context, params httprouter.Params) context.Context {
|
||||||
|
return context.WithValue(ctx, ctxParamsKey, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
func paramsFromContext(ctx context.Context) (params httprouter.Params, ok bool) {
|
||||||
|
v := ctx.Value(ctxParamsKey)
|
||||||
|
if v == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return v.(httprouter.Params), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func param(ctx context.Context, name string) string {
|
||||||
|
if params, ok := paramsFromContext(ctx); ok {
|
||||||
|
return params.ByName(name)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestedPage(r *http.Request) db.Page {
|
||||||
|
offset := r.FormValue("offset")
|
||||||
|
limit := r.FormValue("limit")
|
||||||
|
if offset == "" && limit == "" {
|
||||||
|
return db.Page{}
|
||||||
|
}
|
||||||
|
|
||||||
|
str2uint := func(s string) uint {
|
||||||
|
u, _ := strconv.ParseUint(s, 10, 64)
|
||||||
|
return uint(u)
|
||||||
|
}
|
||||||
|
return db.Page{
|
||||||
|
Offset: str2uint(offset),
|
||||||
|
Limit: str2uint(limit),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Utils
|
||||||
|
//
|
||||||
|
|
||||||
|
// unbufferedWriter is an implementation of http.ResponseWriter that flushes the
|
||||||
|
// buffer after every write.
|
||||||
|
type unbufferedWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w unbufferedWriter) Write(p []byte) (int, error) {
|
||||||
|
n, err := w.ResponseWriter.Write(p)
|
||||||
|
if f, ok := w.ResponseWriter.(http.Flusher); ok && err == nil {
|
||||||
|
f.Flush()
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
// +build binassets
|
||||||
|
|
||||||
|
package assets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
assetfs "github.com/elazarl/go-bindata-assetfs"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Handler() http.Handler {
|
||||||
|
// Following functions would be defined in bindata_assetfs.go during the
|
||||||
|
// build process
|
||||||
|
return http.FileServer(&assetfs.AssetFS{
|
||||||
|
Asset: reactRouter,
|
||||||
|
AssetDir: AssetDir,
|
||||||
|
AssetInfo: AssetInfo,
|
||||||
|
Prefix: "/",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func reactRouter(path string) ([]byte, error) {
|
||||||
|
reactPrefixes := []string{"cmd", "jobs", "users"}
|
||||||
|
for _, prefix := range reactPrefixes {
|
||||||
|
if strings.HasPrefix(path, prefix) {
|
||||||
|
return Asset("index.html")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return Asset(path)
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
// +build !binassets
|
||||||
|
|
||||||
|
package assets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Handler() http.Handler {
|
||||||
|
return http.NotFoundHandler()
|
||||||
|
}
|
|
@ -0,0 +1,50 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/localhots/cmdui/backend/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ctxKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ctxSessionKey ctxKey = "session"
|
||||||
|
ctxUserKey ctxKey = "user"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ContextWithSession(ctx context.Context, sess db.Session) context.Context {
|
||||||
|
return context.WithValue(ctx, ctxSessionKey, sess)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SessionFromContext(ctx context.Context) (sess db.Session, ok bool) {
|
||||||
|
v := ctx.Value(ctxSessionKey)
|
||||||
|
if v == nil {
|
||||||
|
return db.Session{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return v.(db.Session), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func Session(ctx context.Context) db.Session {
|
||||||
|
sess, _ := SessionFromContext(ctx)
|
||||||
|
return sess
|
||||||
|
}
|
||||||
|
|
||||||
|
func ContextWithUser(ctx context.Context, u db.User) context.Context {
|
||||||
|
return context.WithValue(ctx, ctxUserKey, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserFromContext(ctx context.Context) (u db.User, ok bool) {
|
||||||
|
v := ctx.Value(ctxUserKey)
|
||||||
|
if v == nil {
|
||||||
|
return db.User{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return v.(db.User), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func User(ctx context.Context) db.User {
|
||||||
|
u, _ := UserFromContext(ctx)
|
||||||
|
return u
|
||||||
|
}
|
|
@ -0,0 +1,60 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/juju/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sessionCookieName = "cmdui_session_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AuthenticateRequest(w http.ResponseWriter, r *http.Request) (context.Context, error) {
|
||||||
|
cook, err := r.Cookie(sessionCookieName)
|
||||||
|
if err != nil {
|
||||||
|
return r.Context(), errors.Annotate(err, "Failed to get cookie value")
|
||||||
|
}
|
||||||
|
sess, err := FindSession(cook.Value)
|
||||||
|
if err != nil {
|
||||||
|
return r.Context(), errors.Annotate(err, "Failed to authenticate request")
|
||||||
|
}
|
||||||
|
ctx := ContextWithSession(r.Context(), sess)
|
||||||
|
if sess.ExpiresAt.Before(time.Now()) {
|
||||||
|
ClearCookie(ctx, w)
|
||||||
|
return ctx, errors.New("Session expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := sess.User()
|
||||||
|
if err != nil {
|
||||||
|
return ctx, errors.Annotatef(err, "Failed to find user %d", sess.UserID)
|
||||||
|
}
|
||||||
|
if u == nil {
|
||||||
|
return ctx, errors.UserNotFoundf("User %s was not found", sess.UserID)
|
||||||
|
}
|
||||||
|
u.Authorized = true
|
||||||
|
|
||||||
|
ctx = ContextWithUser(ctx, *u)
|
||||||
|
return ctx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func AuthorizeResponse(ctx context.Context, w http.ResponseWriter) {
|
||||||
|
if sess, ok := SessionFromContext(ctx); ok {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: sess.ID,
|
||||||
|
Path: "/",
|
||||||
|
Expires: sess.ExpiresAt,
|
||||||
|
HttpOnly: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClearCookie(ctx context.Context, w http.ResponseWriter) {
|
||||||
|
sess := Session(ctx)
|
||||||
|
sess.ExpiresAt = time.Time{}
|
||||||
|
ctx = ContextWithSession(ctx, sess)
|
||||||
|
AuthorizeResponse(ctx, w)
|
||||||
|
}
|
|
@ -0,0 +1,52 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/juju/errors"
|
||||||
|
|
||||||
|
"github.com/localhots/cmdui/backend/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
sessionCacheMux sync.Mutex
|
||||||
|
sessionCache = map[string]db.Session{}
|
||||||
|
errSessionNotFound = errors.New("Session not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
func FindSession(id string) (db.Session, error) {
|
||||||
|
if id == "" {
|
||||||
|
return db.Session{}, errSessionNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionCacheMux.Lock()
|
||||||
|
sessc, ok := sessionCache[id]
|
||||||
|
sessionCacheMux.Unlock()
|
||||||
|
if ok {
|
||||||
|
return sessc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sess, err := db.FindSession(id)
|
||||||
|
if err != nil {
|
||||||
|
return db.Session{}, errors.Annotate(err, "Session lookup failed")
|
||||||
|
}
|
||||||
|
if sess == nil {
|
||||||
|
return db.Session{}, errSessionNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionCacheMux.Lock()
|
||||||
|
sessionCache[sess.ID] = *sess
|
||||||
|
sessionCacheMux.Unlock()
|
||||||
|
|
||||||
|
return *sess, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CacheSession(sess db.Session) {
|
||||||
|
if sess.ID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionCacheMux.Lock()
|
||||||
|
sessionCache[sess.ID] = sess
|
||||||
|
sessionCacheMux.Unlock()
|
||||||
|
}
|
|
@ -0,0 +1,115 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/juju/errors"
|
||||||
|
|
||||||
|
"github.com/localhots/cmdui/backend/api/auth"
|
||||||
|
"github.com/localhots/cmdui/backend/api/github"
|
||||||
|
"github.com/localhots/cmdui/backend/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
callbackURL = "/api/auth/callback"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
router.GET("/api/auth/login", openEndpoint(authLoginHandler))
|
||||||
|
router.POST("/api/auth/logout", protectedEndpoint(authLogoutHandler))
|
||||||
|
router.GET("/api/auth/session", protectedEndpoint(authSessionHandler))
|
||||||
|
router.GET(callbackURL, openEndpoint(authCallbackHandler))
|
||||||
|
}
|
||||||
|
|
||||||
|
// authSessionHandler returns currently authenticated user details.
|
||||||
|
// GET /auth/session
|
||||||
|
func authSessionHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||||
|
renderJSON(w, auth.User(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
// authLogoutHandler clears authentication cookies.
|
||||||
|
// GET /auth/session
|
||||||
|
func authLogoutHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth.ClearCookie(ctx, w)
|
||||||
|
http.Redirect(w, r, rootPath, http.StatusTemporaryRedirect)
|
||||||
|
}
|
||||||
|
|
||||||
|
// authLoginHandler redirects user to a GitHub login page.
|
||||||
|
func authLoginHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||||
|
github.RedirectToLogin(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// authCallbackHandler accepts GitHub auth callback.
|
||||||
|
func authCallbackHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||||
|
ghu, err := authWithGithubCode(ctx, r.FormValue("code"))
|
||||||
|
if err != nil {
|
||||||
|
renderError(w, err, http.StatusInternalServerError, "GitHub login failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := findOrCreateUser(ghu)
|
||||||
|
if err != nil {
|
||||||
|
renderError(w, err, http.StatusInternalServerError, "Failed to find a user using GitHub profile")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := db.NewSession(u.ID)
|
||||||
|
if err := sess.Create(); err != nil {
|
||||||
|
renderError(w, err, http.StatusInternalServerError, "Failed to create a session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx = auth.ContextWithSession(ctx, sess)
|
||||||
|
auth.AuthorizeResponse(ctx, w)
|
||||||
|
auth.CacheSession(sess)
|
||||||
|
|
||||||
|
http.Redirect(w, r, rootPath, http.StatusTemporaryRedirect)
|
||||||
|
}
|
||||||
|
|
||||||
|
func authWithGithubCode(ctx context.Context, code string) (github.User, error) {
|
||||||
|
if code == "" {
|
||||||
|
return github.User{}, errors.New("Missing authentication code")
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, err := github.ExchangeCode(ctx, code)
|
||||||
|
if err != nil {
|
||||||
|
return github.User{}, errors.Annotate(err, "Failed to exchange code for access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
ghu, err := github.AuthDetails(accessToken)
|
||||||
|
if err != nil {
|
||||||
|
return ghu, errors.Annotate(err, "Failed to fetch authenticated GitHub user details")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ghu, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findOrCreateUser(ghu github.User) (db.User, error) {
|
||||||
|
u, err := db.FindUserByGithubID(ghu.ID)
|
||||||
|
if err != nil {
|
||||||
|
return db.User{}, errors.Annotate(err, "Failed to find GitHub user")
|
||||||
|
}
|
||||||
|
if u != nil {
|
||||||
|
importGithubProfile(u, ghu)
|
||||||
|
if err := u.Update(); err != nil {
|
||||||
|
return *u, errors.Annotate(err, "Failed to update a user")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
eu := db.NewUser()
|
||||||
|
u = &eu
|
||||||
|
importGithubProfile(u, ghu)
|
||||||
|
if err := u.Create(); err != nil {
|
||||||
|
return *u, errors.Annotate(err, "Failed to create a user")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return *u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func importGithubProfile(u *db.User, ghu github.User) {
|
||||||
|
u.GithubID = ghu.ID
|
||||||
|
u.GithubLogin = ghu.Login
|
||||||
|
u.Name = ghu.Name
|
||||||
|
u.Picture = ghu.Picture
|
||||||
|
}
|
|
@ -0,0 +1,78 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/localhots/cmdui/backend/api/auth"
|
||||||
|
"github.com/localhots/cmdui/backend/commands"
|
||||||
|
"github.com/localhots/cmdui/backend/runner"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
router.GET("/api/commands", protectedEndpoint(commandsHandler))
|
||||||
|
router.POST("/api/exec", protectedEndpoint(execHandler))
|
||||||
|
}
|
||||||
|
|
||||||
|
// commandsHandler returns a list of commands that are registered on the server.
|
||||||
|
// GET /commands
|
||||||
|
func commandsHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||||
|
renderJSON(w, commands.List())
|
||||||
|
}
|
||||||
|
|
||||||
|
// execHandler accepts a form from the UI, decodes it into a command and
|
||||||
|
// attempts to execute it. Raw job ID is returned in the response.
|
||||||
|
// POST /exec
|
||||||
|
func execHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||||
|
cmd, err := commandFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
renderError(w, err, http.StatusBadRequest, "Failed to build a command from request form")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
proc, err := runner.Start(cmd, auth.User(ctx))
|
||||||
|
if err != nil {
|
||||||
|
renderError(w, err, http.StatusInternalServerError, "Failed to execute a command")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
renderJSON(w, proc.Job)
|
||||||
|
}
|
||||||
|
|
||||||
|
// commandFromRequest parses a form submitted from UI and converts it into a
|
||||||
|
// command.
|
||||||
|
func commandFromRequest(r *http.Request) (commands.Command, error) {
|
||||||
|
var cmd commands.Command
|
||||||
|
err := r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
return cmd, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range commands.List() {
|
||||||
|
if c.Name == r.PostForm.Get("command") {
|
||||||
|
cmd = c
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cmd.Name == "" {
|
||||||
|
return cmd, fmt.Errorf("Unknown command: %q", r.PostForm.Get("command"))
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, val := range r.PostForm {
|
||||||
|
if key == "args" {
|
||||||
|
cmd.Args = val[0]
|
||||||
|
} else if strings.HasPrefix(key, "flags[") {
|
||||||
|
name := key[6 : len(key)-1]
|
||||||
|
for i, f := range cmd.Flags {
|
||||||
|
if f.Name == name {
|
||||||
|
cmd.Flags[i].Value = val[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd, nil
|
||||||
|
}
|
|
@ -0,0 +1,91 @@
|
||||||
|
package github
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/juju/errors"
|
||||||
|
|
||||||
|
"github.com/localhots/cmdui/backend/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Login string `json:"login"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Picture string `json:"avatar_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
authorizeURL = "https://github.com/login/oauth/authorize"
|
||||||
|
accessTokenURL = "https://github.com/login/oauth/access_token"
|
||||||
|
userDetailsURL = "https://api.github.com/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RedirectToLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
urlStr := authorizeURL + "?" + url.Values{
|
||||||
|
"client_id": {config.Get().Github.ClientID},
|
||||||
|
"scope": {"user"},
|
||||||
|
}.Encode()
|
||||||
|
http.Redirect(w, r, urlStr, http.StatusTemporaryRedirect)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExchangeCode(ctx context.Context, code string) (accessToken string, err error) {
|
||||||
|
cfg := config.Get()
|
||||||
|
reqBody := bytes.NewBufferString(url.Values{
|
||||||
|
"client_id": {cfg.Github.ClientID},
|
||||||
|
"client_secret": {cfg.Github.ClientSecret},
|
||||||
|
"code": {code},
|
||||||
|
}.Encode())
|
||||||
|
req, err := http.NewRequest(http.MethodPost, accessTokenURL, reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Annotate(err, "Failed to create a code exchange request")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passing client request context
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Annotate(err, "Failed to perform code exchange request")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := ioutil.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Annotate(err, "Failed to read access token response")
|
||||||
|
}
|
||||||
|
|
||||||
|
uri, err := url.ParseQuery(string(respBody))
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Annotate(err, "Failed to parse access token response")
|
||||||
|
}
|
||||||
|
|
||||||
|
return uri.Get("access_token"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func AuthDetails(accessToken string) (User, error) {
|
||||||
|
var u User
|
||||||
|
reqURL := userDetailsURL + "?" + url.Values{
|
||||||
|
"access_token": {accessToken},
|
||||||
|
}.Encode()
|
||||||
|
resp, err := http.Get(reqURL)
|
||||||
|
if err != nil {
|
||||||
|
return u, errors.Annotate(err, "Failed to fetch authenticated user details")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := ioutil.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return u, errors.Annotate(err, "Failed to read authenticated user details")
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &u); err != nil {
|
||||||
|
return u, errors.Annotate(err, "Failed to parse authenticated user details")
|
||||||
|
}
|
||||||
|
|
||||||
|
return u, nil
|
||||||
|
}
|
|
@ -0,0 +1,177 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/juju/errors"
|
||||||
|
|
||||||
|
"github.com/localhots/cmdui/backend/commands"
|
||||||
|
"github.com/localhots/cmdui/backend/db"
|
||||||
|
"github.com/localhots/cmdui/backend/runner"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
router.GET("/api/jobs", protectedEndpoint(jobsIndexHandler))
|
||||||
|
router.GET("/api/jobs/:job_id", protectedEndpoint(jobShowHandler))
|
||||||
|
router.PUT("/api/jobs/:job_id", protectedEndpoint(jobActionHandler))
|
||||||
|
router.GET("/api/jobs/:job_id/log", protectedEndpoint(jobLogHandler))
|
||||||
|
router.GET("/api/commands/:cmd/jobs", protectedEndpoint(jobsIndexHandler))
|
||||||
|
router.GET("/api/users/:user_id/jobs", protectedEndpoint(jobsIndexHandler))
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobLogHandler returns job's log. If the command is still running, than the
|
||||||
|
// client would be attached to a log file and receive updates as well as few
|
||||||
|
// previous lines of it. If the command is completed, the entire log would be
|
||||||
|
// returned. If a `full` parameter is provided and the command is still running,
|
||||||
|
// the client would receive all existing log contents and future updates.
|
||||||
|
// GET /api/jobs/:job_id/log
|
||||||
|
// FIXME: What the fuck is this function
|
||||||
|
func jobLogHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := param(ctx, "job_id")
|
||||||
|
proc := runner.FindProcess(id)
|
||||||
|
if proc != nil {
|
||||||
|
var done <-chan struct{}
|
||||||
|
var err error
|
||||||
|
if r.FormValue("full") != "" {
|
||||||
|
done, err = runner.ReadFullLog(ctx, proc, unbufferedWriter{w})
|
||||||
|
} else {
|
||||||
|
done, err = runner.ReadLogUpdates(ctx, proc, unbufferedWriter{w})
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
renderError(w, err, http.StatusInternalServerError, "Failed to tail a log")
|
||||||
|
}
|
||||||
|
<-done
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
proc = &runner.Process{ID: id}
|
||||||
|
done, err := runner.ReadFullLog(ctx, proc, unbufferedWriter{w})
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobsIndexHandler returns a list of jobs for a given criteria.
|
||||||
|
// GET /api/jobs
|
||||||
|
// GET /api/commands/:cmd/jobs
|
||||||
|
// GET /api/users/:user_id/jobs
|
||||||
|
func jobsIndexHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||||
|
renderJobs := func(jobs []db.Job, err error) {
|
||||||
|
if err != nil {
|
||||||
|
renderError(w, err, http.StatusInternalServerError, "Failed to find jobs")
|
||||||
|
} else {
|
||||||
|
renderJSON(w, jobs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case param(ctx, "user_id") != "":
|
||||||
|
id := param(ctx, "user_id")
|
||||||
|
u, err := db.FindUser(id)
|
||||||
|
if err != nil {
|
||||||
|
renderError(w, err, http.StatusInternalServerError, "Failed to find a user")
|
||||||
|
}
|
||||||
|
if u == nil {
|
||||||
|
err := fmt.Errorf("User not found: %s", id)
|
||||||
|
renderError(w, err, http.StatusNotFound, "User not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
renderJobs(db.FindUserJobs(id, requestedPage(r)))
|
||||||
|
case param(ctx, "cmd") != "":
|
||||||
|
cmdName := param(ctx, "cmd")
|
||||||
|
if _, ok := commands.Map()[cmdName]; ok {
|
||||||
|
renderJobs(db.FindCommandJobs(cmdName, requestedPage(r)))
|
||||||
|
} else {
|
||||||
|
err := fmt.Errorf("Command not found: %s", cmdName)
|
||||||
|
renderError(w, err, http.StatusNotFound, "Command not found")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
renderJobs(db.FindAllJobs(requestedPage(r)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobShowHandler returns job details.
|
||||||
|
// GET /api/jobs/:job_id
|
||||||
|
func jobShowHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := param(ctx, "job_id")
|
||||||
|
job, err := db.FindJob(id)
|
||||||
|
if err != nil {
|
||||||
|
renderError(w, err, http.StatusInternalServerError, "Failed to find job")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if job != nil {
|
||||||
|
renderJSON(w, job)
|
||||||
|
} else {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobActionHandler performs certain actions on a job.
|
||||||
|
// PUT /api/jobs/:job_id
|
||||||
|
func jobActionHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
renderError(w, err, http.StatusBadRequest, "Failed to parse form")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := r.PostForm.Get("id")
|
||||||
|
if id == "" {
|
||||||
|
err := errors.New("Job ID is required")
|
||||||
|
renderError(w, err, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
proc := runner.FindProcess(id)
|
||||||
|
if proc == nil {
|
||||||
|
err := fmt.Errorf("Job %q was not found", id)
|
||||||
|
renderError(w, err, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sigName := r.PostForm.Get("signal")
|
||||||
|
if sigName != "" {
|
||||||
|
sig, err := signalFromName(sigName)
|
||||||
|
if err != nil {
|
||||||
|
renderError(w, err, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = proc.Signal(sig)
|
||||||
|
if err != nil {
|
||||||
|
renderError(w, err, http.StatusInternalServerError, "Failed to send signal to a process")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func signalFromName(name string) (syscall.Signal, error) {
|
||||||
|
switch name {
|
||||||
|
case "SIGHUP":
|
||||||
|
return syscall.SIGHUP, nil
|
||||||
|
case "SIGINT":
|
||||||
|
return syscall.SIGINT, nil
|
||||||
|
case "SIGKILL":
|
||||||
|
return syscall.SIGKILL, nil
|
||||||
|
case "SIGQUIT":
|
||||||
|
return syscall.SIGQUIT, nil
|
||||||
|
case "SIGTERM":
|
||||||
|
return syscall.SIGTERM, nil
|
||||||
|
case "SIGTTIN":
|
||||||
|
return syscall.SIGTTIN, nil
|
||||||
|
case "SIGTTOU":
|
||||||
|
return syscall.SIGTTOU, nil
|
||||||
|
case "SIGUSR1":
|
||||||
|
return syscall.SIGUSR1, nil
|
||||||
|
case "SIGUSR2":
|
||||||
|
return syscall.SIGUSR2, nil
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("Signal not supported: %s", name)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/localhots/cmdui/backend/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
router.GET("/api/users/:user_id", protectedEndpoint(userDetailsHandler))
|
||||||
|
}
|
||||||
|
|
||||||
|
// userDetailsHandler returns user details.
|
||||||
|
// GET /api/users/:user_id
|
||||||
|
func userDetailsHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := param(ctx, "user_id")
|
||||||
|
u, err := db.FindUser(id)
|
||||||
|
if err != nil {
|
||||||
|
renderError(w, err, http.StatusInternalServerError, "Failed to find a user")
|
||||||
|
}
|
||||||
|
if u == nil {
|
||||||
|
err := fmt.Errorf("User not found: %s", id)
|
||||||
|
renderError(w, err, http.StatusNotFound, "User not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
renderJSON(w, u)
|
||||||
|
}
|
|
@ -0,0 +1,78 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Command struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Args string `json:"-"`
|
||||||
|
ArgsPlaceholder string `json:"args_placeholder"`
|
||||||
|
Flags []Flag `json:"flags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Flag struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Default string `json:"default"`
|
||||||
|
Value string `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
list []Command
|
||||||
|
)
|
||||||
|
|
||||||
|
func Import(l []Command) {
|
||||||
|
list = l
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns a list of commands.
|
||||||
|
func List() []Command {
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map returns commands as a map.
|
||||||
|
func Map() map[string]Command {
|
||||||
|
m := make(map[string]Command, len(list))
|
||||||
|
for _, cmd := range list {
|
||||||
|
m[cmd.Name] = cmd
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Command) CombinedArgs() []string {
|
||||||
|
args := []string{c.Name}
|
||||||
|
args = append(args, c.ArgsSlice()...)
|
||||||
|
args = append(args, c.FlagsSlice()...)
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Command) ArgsSlice() []string {
|
||||||
|
if c.Args == "" {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
return strings.Split(c.Args, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Command) FlagsSlice() []string {
|
||||||
|
flags := []string{}
|
||||||
|
for _, f := range c.Flags {
|
||||||
|
flags = append(flags, f.encode())
|
||||||
|
}
|
||||||
|
return flags
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Command) FlagsString() string {
|
||||||
|
return strings.Join(c.FlagsSlice(), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Command) String() string {
|
||||||
|
return strings.Join(c.CombinedArgs(), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f Flag) encode() string {
|
||||||
|
return fmt.Sprintf("--%s=%s", f.Name, f.Value)
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
log_dir = "/var/log/commands"
|
||||||
|
|
||||||
|
[commands]
|
||||||
|
base_path = "/usr/local/bin/runner"
|
||||||
|
config_command = "/usr/local/bin/runner_commands --export=json"
|
||||||
|
|
||||||
|
[database]
|
||||||
|
driver = "mysql"
|
||||||
|
spec = "root:@(localhost:3306)/commands?charset=utf8mb4&parseTime=true"
|
||||||
|
|
||||||
|
[server]
|
||||||
|
host = "http://127.0.0.1"
|
||||||
|
port = 9090
|
||||||
|
|
||||||
|
[github]
|
||||||
|
client_id = "secret"
|
||||||
|
client_secret = "secret"
|
|
@ -0,0 +1,71 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/BurntSushi/toml"
|
||||||
|
"github.com/juju/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config is used to provide configuration to the server.
|
||||||
|
type Config struct {
|
||||||
|
LogDir string `toml:"log_dir"`
|
||||||
|
Commands struct {
|
||||||
|
BasePath string `toml:"base_path"`
|
||||||
|
ConfigCommand string `toml:"config_command"`
|
||||||
|
} `toml:"commands"`
|
||||||
|
Database struct {
|
||||||
|
Driver string `toml:"driver"`
|
||||||
|
Spec string `toml:"spec"`
|
||||||
|
} `toml:"database"`
|
||||||
|
Server struct {
|
||||||
|
Host string `toml:"host"`
|
||||||
|
Port uint16 `toml:"port"`
|
||||||
|
} `toml:"server"`
|
||||||
|
Github struct {
|
||||||
|
ClientID string `toml:"client_id"`
|
||||||
|
ClientSecret string `toml:"client_secret"`
|
||||||
|
} `toml:"github"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg *Config
|
||||||
|
|
||||||
|
func Get() Config {
|
||||||
|
if cfg == nil {
|
||||||
|
panic("Config is not installed")
|
||||||
|
}
|
||||||
|
return *cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func Install(c Config) error {
|
||||||
|
switch {
|
||||||
|
case c.Commands.BasePath == "":
|
||||||
|
return errors.New("Base command path is not configured")
|
||||||
|
case c.Commands.ConfigCommand == "":
|
||||||
|
return errors.New("Config command is not configured")
|
||||||
|
case c.Database.Driver == "":
|
||||||
|
return errors.New("Database driver is not configured")
|
||||||
|
case c.Database.Spec == "":
|
||||||
|
return errors.New("Database spec is not configured")
|
||||||
|
case c.Server.Host == "":
|
||||||
|
return errors.New("Server host is not configured")
|
||||||
|
case c.Server.Port == 0:
|
||||||
|
return errors.New("Server port is not configured")
|
||||||
|
case c.Github.ClientID == "":
|
||||||
|
return errors.New("GitHub client ID is not configured")
|
||||||
|
case c.Github.ClientSecret == "":
|
||||||
|
return errors.New("GitHub client secret is not configured")
|
||||||
|
case c.LogDir == "":
|
||||||
|
return errors.New("Log directory is not configured")
|
||||||
|
default:
|
||||||
|
cfg = &c
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadFile(file string) (Config, error) {
|
||||||
|
var c Config
|
||||||
|
_, err := toml.DecodeFile(file, &c)
|
||||||
|
if err != nil {
|
||||||
|
return c, errors.Annotate(err, "Failed to parse config file")
|
||||||
|
}
|
||||||
|
return c, Install(c)
|
||||||
|
}
|
|
@ -0,0 +1,80 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
uuid "github.com/satori/go.uuid"
|
||||||
|
|
||||||
|
"github.com/localhots/cmdui/backend/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
db *sqlx.DB
|
||||||
|
)
|
||||||
|
|
||||||
|
func Connect() error {
|
||||||
|
var err error
|
||||||
|
cfg := config.Get().Database
|
||||||
|
db, err = sqlx.Connect(cfg.Driver, cfg.Spec)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type Page struct {
|
||||||
|
Offset uint
|
||||||
|
Limit uint
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Page) normalize() Page {
|
||||||
|
const defaultPerPage = 50
|
||||||
|
if p.Limit == 0 {
|
||||||
|
p.Limit = defaultPerPage
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Helpers
|
||||||
|
//
|
||||||
|
|
||||||
|
func newID() string {
|
||||||
|
return uuid.NewV4().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeholders(val interface{}) string {
|
||||||
|
v := reflect.ValueOf(val)
|
||||||
|
if v.Kind() == reflect.Slice {
|
||||||
|
s := strings.Repeat("?, ", v.Len())
|
||||||
|
return s[0 : len(s)-2]
|
||||||
|
}
|
||||||
|
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
|
||||||
|
func iargs(args []string) []interface{} {
|
||||||
|
iargs := make([]interface{}, len(args))
|
||||||
|
for i, arg := range args {
|
||||||
|
iargs[i] = arg
|
||||||
|
}
|
||||||
|
return iargs
|
||||||
|
}
|
||||||
|
|
||||||
|
type stringSet map[string]struct{}
|
||||||
|
|
||||||
|
func (s stringSet) add(items ...string) {
|
||||||
|
for _, item := range items {
|
||||||
|
s[item] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s stringSet) items() []string {
|
||||||
|
l := make([]string, len(s))
|
||||||
|
i := 0
|
||||||
|
for item := range s {
|
||||||
|
l[i] = item
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
|
@ -0,0 +1,168 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/juju/errors"
|
||||||
|
|
||||||
|
"github.com/localhots/cmdui/backend/commands"
|
||||||
|
)
|
||||||
|
|
||||||
|
type JobState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
JobStateNew JobState = "new"
|
||||||
|
JobStateCreated JobState = "created"
|
||||||
|
JobStateStarted JobState = "started"
|
||||||
|
JobStateAborted JobState = "aborted"
|
||||||
|
JobStateFailed JobState = "failed"
|
||||||
|
JobStateFinished JobState = "finished"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Job struct {
|
||||||
|
ID string `json:"id" db:"id"`
|
||||||
|
Command string `json:"command" db:"command"`
|
||||||
|
Args string `json:"args" db:"args"`
|
||||||
|
Flags string `json:"flags" db:"flags"`
|
||||||
|
UserID string `json:"user_id" db:"user_id"`
|
||||||
|
User *User `json:"user" db:"-"`
|
||||||
|
State string `json:"state" db:"state"`
|
||||||
|
CreatedAt *time.Time `json:"created_at" db:"created_at"`
|
||||||
|
StartedAt *time.Time `json:"started_at" db:"started_at"`
|
||||||
|
FinishedAt *time.Time `json:"finished_at" db:"finished_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewJob(c commands.Command, u User) Job {
|
||||||
|
return Job{
|
||||||
|
ID: newID(),
|
||||||
|
Command: c.Name,
|
||||||
|
Args: c.Args,
|
||||||
|
Flags: c.FlagsString(),
|
||||||
|
UserID: u.ID,
|
||||||
|
User: &u,
|
||||||
|
State: string(JobStateNew),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func FindAllJobs(p Page) ([]Job, error) {
|
||||||
|
return findJobsWhere(jobsIndexQuery("", p))
|
||||||
|
}
|
||||||
|
|
||||||
|
func FindCommandJobs(name string, p Page) ([]Job, error) {
|
||||||
|
return findJobsWhere(jobsIndexQuery("WHERE command = ?", p), name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FindUserJobs(id string, p Page) ([]Job, error) {
|
||||||
|
return findJobsWhere(jobsIndexQuery("WHERE user_id = ?", p), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func jobsIndexQuery(where string, p Page) string {
|
||||||
|
p = p.normalize()
|
||||||
|
return fmt.Sprintf("SELECT * FROM jobs %s ORDER BY created_at DESC LIMIT %d, %d",
|
||||||
|
where, p.Offset, p.Limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findJobsWhere(query string, args ...interface{}) ([]Job, error) {
|
||||||
|
var jobs []Job
|
||||||
|
err := db.Select(&jobs, query, args...)
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
return nil, errors.Annotate(err, "Failed to load Jobs list")
|
||||||
|
}
|
||||||
|
|
||||||
|
userIDs := stringSet{}
|
||||||
|
for _, r := range jobs {
|
||||||
|
userIDs.add(r.UserID)
|
||||||
|
}
|
||||||
|
users, err := FindUsers(userIDs.items()...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Annotate(err, "Failed to find users to embed into jobs")
|
||||||
|
}
|
||||||
|
for i, r := range jobs {
|
||||||
|
if u, ok := users[r.UserID]; ok {
|
||||||
|
jobs[i].User = &u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return jobs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func FindJob(id string) (*Job, error) {
|
||||||
|
var r Job
|
||||||
|
err := db.Get(&r, "SELECT * FROM jobs WHERE id = ?", id)
|
||||||
|
if err != nil {
|
||||||
|
if err != sql.ErrNoRows {
|
||||||
|
return nil, errors.Annotate(err, "Failed to load Job details")
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.UserID != "" {
|
||||||
|
r.User, err = FindUser(r.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Annotate(err, "Failed to find a user to embed into a job")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Job) UpdateState(s JobState) error {
|
||||||
|
r.State = string(s)
|
||||||
|
ts := time.Now().UTC()
|
||||||
|
switch s {
|
||||||
|
case JobStateStarted:
|
||||||
|
r.StartedAt = &ts
|
||||||
|
case JobStateFinished, JobStateAborted, JobStateFailed:
|
||||||
|
r.FinishedAt = &ts
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.Update()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Job) Create() error {
|
||||||
|
ts := time.Now().UTC()
|
||||||
|
r.CreatedAt = &ts
|
||||||
|
r.State = string(JobStateCreated)
|
||||||
|
|
||||||
|
_, err := db.NamedExec(`
|
||||||
|
INSERT INTO jobs
|
||||||
|
SET
|
||||||
|
id = :id,
|
||||||
|
command = :command,
|
||||||
|
args = :args,
|
||||||
|
flags = :flags,
|
||||||
|
user_id = :user_id,
|
||||||
|
state = :state,
|
||||||
|
created_at = :created_at
|
||||||
|
`, r)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Annotate(err, "Failed to create a job")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Job) Update() error {
|
||||||
|
_, err := db.NamedExec(`
|
||||||
|
UPDATE jobs
|
||||||
|
SET
|
||||||
|
state = :state,
|
||||||
|
started_at = :started_at,
|
||||||
|
finished_at = :finished_at
|
||||||
|
WHERE
|
||||||
|
id = :id
|
||||||
|
`, r)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Annotate(err, "Failed to update a job")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func jobsSliceToMap(s []Job) map[string]Job {
|
||||||
|
m := make(map[string]Job, len(s))
|
||||||
|
for _, r := range s {
|
||||||
|
m[r.ID] = r
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
|
@ -0,0 +1,60 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/juju/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
ID string `db:"id"`
|
||||||
|
UserID string `db:"user_id"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
ExpiresAt time.Time `db:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSession(userID string) Session {
|
||||||
|
const ttl = 6 * 30 * 24 * time.Hour // 6 months
|
||||||
|
now := time.Now().UTC()
|
||||||
|
exp := now.Add(ttl)
|
||||||
|
|
||||||
|
return Session{
|
||||||
|
ID: newID(),
|
||||||
|
UserID: userID,
|
||||||
|
CreatedAt: now,
|
||||||
|
ExpiresAt: exp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func FindSession(id string) (*Session, error) {
|
||||||
|
var s Session
|
||||||
|
err := db.Get(&s, "SELECT * FROM sessions WHERE id = ?", id)
|
||||||
|
if err != nil {
|
||||||
|
if err != sql.ErrNoRows {
|
||||||
|
return nil, errors.Annotate(err, "Failed to load session details")
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Session) Create() error {
|
||||||
|
_, err := db.NamedExec(`
|
||||||
|
INSERT INTO sessions
|
||||||
|
SET
|
||||||
|
id = :id,
|
||||||
|
user_id = :user_id,
|
||||||
|
created_at = :created_at,
|
||||||
|
expires_at = :expires_at
|
||||||
|
`, s)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Annotate(err, "Failed to create a session")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Session) User() (*User, error) {
|
||||||
|
return FindUser(s.UserID)
|
||||||
|
}
|
|
@ -0,0 +1,110 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/juju/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID string `json:"id" db:"id"`
|
||||||
|
Name string `json:"name" db:"github_name"`
|
||||||
|
Picture string `json:"picture" db:"github_picture"`
|
||||||
|
GithubID uint `json:"-" db:"github_id"`
|
||||||
|
GithubLogin string `json:"-" db:"github_login"`
|
||||||
|
|
||||||
|
Authorized bool `json:"authorized" db:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUser() User {
|
||||||
|
return User{ID: newID()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func FindAllUsers() (map[string]User, error) {
|
||||||
|
return findUsers("SELECT * FROM users ORDER BY id ASC")
|
||||||
|
}
|
||||||
|
|
||||||
|
func FindUsers(ids ...string) (map[string]User, error) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return map[string]User{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf("SELECT * FROM users WHERE id IN (%s)", placeholders(ids))
|
||||||
|
return findUsers(query, iargs(ids)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findUsers(query string, args ...interface{}) (map[string]User, error) {
|
||||||
|
var users []User
|
||||||
|
err := db.Select(&users, query, args...)
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
return nil, errors.Annotate(err, "Failed to load users list")
|
||||||
|
}
|
||||||
|
|
||||||
|
return usersSliceToMap(users), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func FindUser(id string) (*User, error) {
|
||||||
|
return findUser("SELECT * FROM users WHERE id = ?", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FindUserByGithubID(id uint) (*User, error) {
|
||||||
|
return findUser("SELECT * FROM users WHERE github_id = ?", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FindUserByLogin(login string) (*User, error) {
|
||||||
|
return findUser("SELECT * FROM users WHERE github_login = ?", login)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findUser(query string, args ...interface{}) (*User, error) {
|
||||||
|
var u User
|
||||||
|
err := db.Get(&u, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
if err != sql.ErrNoRows {
|
||||||
|
return nil, errors.Annotate(err, "Failed to load user details")
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u User) Create() error {
|
||||||
|
_, err := db.NamedExec(`
|
||||||
|
INSERT INTO users
|
||||||
|
SET
|
||||||
|
id = :id,
|
||||||
|
github_id = :github_id,
|
||||||
|
github_login = :github_login,
|
||||||
|
github_name = :github_name,
|
||||||
|
github_picture = :github_picture
|
||||||
|
`, u)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Annotate(err, "Failed to create a user")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u User) Update() error {
|
||||||
|
_, err := db.NamedExec(`
|
||||||
|
UPDATE users
|
||||||
|
SET
|
||||||
|
github_login = :github_login,
|
||||||
|
github_name = :github_name,
|
||||||
|
github_picture = :github_picture
|
||||||
|
WHERE
|
||||||
|
github_id = :github_id
|
||||||
|
`, u)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Annotate(err, "Failed to update a user")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func usersSliceToMap(s []User) map[string]User {
|
||||||
|
m := make(map[string]User, len(s))
|
||||||
|
for _, u := range s {
|
||||||
|
m[u.ID] = u
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
package log
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/Sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// F is short for "fields".
|
||||||
|
type F map[string]interface{}
|
||||||
|
|
||||||
|
// WithFields is an entry function for logging.
|
||||||
|
func WithFields(f F) *logrus.Entry {
|
||||||
|
return logrus.WithFields(logrus.Fields(f))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logger returns a logger instance.
|
||||||
|
func Logger() *logrus.Logger {
|
||||||
|
return logrus.StandardLogger()
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
|
||||||
|
"github.com/localhots/cmdui/backend/api"
|
||||||
|
"github.com/localhots/cmdui/backend/commands"
|
||||||
|
"github.com/localhots/cmdui/backend/config"
|
||||||
|
"github.com/localhots/cmdui/backend/db"
|
||||||
|
"github.com/localhots/cmdui/backend/log"
|
||||||
|
"github.com/localhots/cmdui/backend/runner"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
confPath := flag.String("config", "config.toml", "Path to config file in TOML format")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
_, err := config.LoadFile(*confPath)
|
||||||
|
if err != nil {
|
||||||
|
log.WithFields(log.F{"error": err}).Fatal("Failed to load config file")
|
||||||
|
}
|
||||||
|
if err := db.Connect(); err != nil {
|
||||||
|
log.WithFields(log.F{"error": err}).Fatal("Failed to establish database connection")
|
||||||
|
}
|
||||||
|
if err := runner.PrepareLogsDir(); err != nil {
|
||||||
|
log.WithFields(log.F{"error": err}).Fatal("Failed to create logs directory")
|
||||||
|
}
|
||||||
|
list, err := runner.CommandsList()
|
||||||
|
if err != nil {
|
||||||
|
log.WithFields(log.F{"error": err}).Fatal("Failed to import commands")
|
||||||
|
}
|
||||||
|
commands.Import(list)
|
||||||
|
defer runner.Shutdown()
|
||||||
|
if err := api.Start(); err != nil {
|
||||||
|
log.WithFields(log.F{"error": err}).Fatal("Failed to start the server")
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,117 @@
|
||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/juju/errors"
|
||||||
|
|
||||||
|
"github.com/localhots/cmdui/backend/commands"
|
||||||
|
"github.com/localhots/cmdui/backend/config"
|
||||||
|
"github.com/localhots/cmdui/backend/db"
|
||||||
|
"github.com/localhots/cmdui/backend/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Start(cmd commands.Command, user db.User) (*Process, error) {
|
||||||
|
job := db.NewJob(cmd, user)
|
||||||
|
if err := job.Create(); err != nil {
|
||||||
|
return nil, errors.Annotate(err, "Failed to create a job")
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &Process{
|
||||||
|
ID: job.ID,
|
||||||
|
Job: &job,
|
||||||
|
}
|
||||||
|
|
||||||
|
basePath := config.Get().Commands.BasePath
|
||||||
|
p.exec = exec.Command(basePath, cmd.CombinedArgs()...)
|
||||||
|
fd, err := p.useLogfile(p.logfile())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.exec.Stdout = fd
|
||||||
|
p.exec.Stderr = fd
|
||||||
|
|
||||||
|
if err := pool.add(context.Background(), p); err != nil {
|
||||||
|
return p, errors.Annotate(err, "Failed to start a process")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadLogUpdates(ctx context.Context, p *Process, w io.Writer) (done <-chan struct{}, err error) {
|
||||||
|
cmde := exec.CommandContext(ctx, "/usr/bin/tail", "-n", "100", "-f", p.logfile())
|
||||||
|
return readLog(ctx, cmde, p, w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadFullLog(ctx context.Context, p *Process, w io.Writer) (done <-chan struct{}, err error) {
|
||||||
|
cmde := exec.CommandContext(ctx, "/bin/cat", p.logfile())
|
||||||
|
return readLog(ctx, cmde, p, w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readLog(ctx context.Context, cmde *exec.Cmd, p *Process, w io.Writer) (done <-chan struct{}, err error) {
|
||||||
|
cmde.Stdout = w
|
||||||
|
cmde.Stderr = w
|
||||||
|
|
||||||
|
tp := &Process{
|
||||||
|
ID: sysID("log-access"),
|
||||||
|
exec: cmde,
|
||||||
|
}
|
||||||
|
|
||||||
|
exited := make(chan struct{}, 2)
|
||||||
|
p.onExit(func(p *Process) {
|
||||||
|
exited <- struct{}{}
|
||||||
|
})
|
||||||
|
tp.onExit(func(p *Process) {
|
||||||
|
exited <- struct{}{}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := pool.add(ctx, tp); err != nil {
|
||||||
|
return nil, errors.Annotate(err, "Failed to start a tail process")
|
||||||
|
}
|
||||||
|
|
||||||
|
return exited, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CommandsList() ([]commands.Command, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
cmde := exec.Command("/bin/bash", "-c", config.Get().Commands.ConfigCommand)
|
||||||
|
cmde.Stdout = &buf
|
||||||
|
cmde.Stderr = &buf
|
||||||
|
|
||||||
|
p := &Process{
|
||||||
|
ID: sysID("commands-list"),
|
||||||
|
exec: cmde,
|
||||||
|
}
|
||||||
|
|
||||||
|
exited := make(chan struct{})
|
||||||
|
p.onExit(func(p *Process) {
|
||||||
|
close(exited)
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := pool.add(context.Background(), p); err != nil {
|
||||||
|
return nil, errors.Annotate(err, "Failed to import commands")
|
||||||
|
}
|
||||||
|
<-exited
|
||||||
|
|
||||||
|
body := buf.Bytes()
|
||||||
|
var list []commands.Command
|
||||||
|
if err := json.Unmarshal(body, &list); err != nil {
|
||||||
|
log.WithFields(log.F{
|
||||||
|
"error": err,
|
||||||
|
"json": string(body),
|
||||||
|
}).Error("Invalid commands JSON")
|
||||||
|
return nil, errors.Annotate(err, "Failed to decode commands JSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sysID(name string) string {
|
||||||
|
return fmt.Sprintf("%s-%d", name, time.Now().UnixNano())
|
||||||
|
}
|
|
@ -0,0 +1,155 @@
|
||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/juju/errors"
|
||||||
|
|
||||||
|
"github.com/localhots/cmdui/backend/config"
|
||||||
|
"github.com/localhots/cmdui/backend/db"
|
||||||
|
"github.com/localhots/cmdui/backend/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
var pool = &processPool{}
|
||||||
|
|
||||||
|
type processPool struct {
|
||||||
|
lock sync.RWMutex
|
||||||
|
wg sync.WaitGroup
|
||||||
|
procs map[string]*Process
|
||||||
|
}
|
||||||
|
|
||||||
|
func FindProcess(id string) *Process {
|
||||||
|
pool.lock.RLock()
|
||||||
|
defer pool.lock.RUnlock()
|
||||||
|
return pool.procs[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
func Shutdown() {
|
||||||
|
pool.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func PrepareLogsDir() error {
|
||||||
|
return os.MkdirAll(config.Get().LogDir, 0755)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pp *processPool) add(ctx context.Context, p *Process) error {
|
||||||
|
// Validate process
|
||||||
|
if err := pp.validate(p); err != nil {
|
||||||
|
return errors.Annotate(err, "Process validation failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register process
|
||||||
|
pp.lock.Lock()
|
||||||
|
if pp.procs == nil {
|
||||||
|
pp.procs = make(map[string]*Process)
|
||||||
|
}
|
||||||
|
pp.procs[p.ID] = p
|
||||||
|
pp.lock.Unlock()
|
||||||
|
|
||||||
|
// Start process
|
||||||
|
|
||||||
|
if err := p.exec.Start(); err != nil {
|
||||||
|
log.WithFields(log.F{
|
||||||
|
"id": p.ID,
|
||||||
|
"error": err,
|
||||||
|
}).Error("Failed to start a command")
|
||||||
|
return errors.Annotate(err, "Failed to start a process")
|
||||||
|
}
|
||||||
|
p.PID = p.exec.Process.Pid
|
||||||
|
log.WithFields(log.F{
|
||||||
|
"id": p.ID,
|
||||||
|
"pid": p.PID,
|
||||||
|
"command": strings.Join(p.exec.Args, " "),
|
||||||
|
}).Info("Command started")
|
||||||
|
tryUpdateState(p, db.JobStateStarted)
|
||||||
|
|
||||||
|
pp.wg.Add(1)
|
||||||
|
go pp.handleProcessExit(p)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pp *processPool) handleProcessExit(p *Process) {
|
||||||
|
defer pp.wg.Done()
|
||||||
|
err := p.exec.Wait()
|
||||||
|
|
||||||
|
pp.lock.Lock()
|
||||||
|
delete(pp.procs, p.ID)
|
||||||
|
pp.lock.Unlock()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.WithFields(log.F{
|
||||||
|
"id": p.ID,
|
||||||
|
"pid": p.PID,
|
||||||
|
"error": err,
|
||||||
|
}).Error("Command failed")
|
||||||
|
tryUpdateState(p, db.JobStateFailed)
|
||||||
|
} else {
|
||||||
|
log.WithFields(log.F{
|
||||||
|
"id": p.ID,
|
||||||
|
"pid": p.PID,
|
||||||
|
}).Info("Command finished")
|
||||||
|
tryUpdateState(p, db.JobStateFinished)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.close(); err != nil {
|
||||||
|
log.WithFields(log.F{
|
||||||
|
"id": p.ID,
|
||||||
|
"error": err,
|
||||||
|
}).Error("Failed to close a job")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, onExit := range p.exitCallbacks {
|
||||||
|
onExit(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pp *processPool) procsList() []*Process {
|
||||||
|
pp.lock.RLock()
|
||||||
|
defer pp.lock.RUnlock()
|
||||||
|
|
||||||
|
list := make([]*Process, len(pp.procs))
|
||||||
|
i := 0
|
||||||
|
for _, p := range pp.procs {
|
||||||
|
list[i] = p
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pp *processPool) validate(p *Process) error {
|
||||||
|
switch {
|
||||||
|
case p == nil:
|
||||||
|
return errors.New("Can't add an empty process")
|
||||||
|
case p.ID == "":
|
||||||
|
return errors.New("Can't add a process without an ID")
|
||||||
|
case p.exec == nil:
|
||||||
|
return errors.New("Process executable can't be empty")
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pp *processPool) close() {
|
||||||
|
pp.wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func tryUpdateState(p *Process, s db.JobState) {
|
||||||
|
log.WithFields(log.F{
|
||||||
|
"id": p.ID,
|
||||||
|
"state": s,
|
||||||
|
}).Debug("Job state changed")
|
||||||
|
if p.Job == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := p.Job.UpdateState(s); err != nil {
|
||||||
|
log.WithFields(log.F{
|
||||||
|
"id": p.ID,
|
||||||
|
"state": s,
|
||||||
|
"error": err,
|
||||||
|
}).Error("Job state changed")
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,60 @@
|
||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/juju/errors"
|
||||||
|
|
||||||
|
"github.com/localhots/cmdui/backend/commands"
|
||||||
|
"github.com/localhots/cmdui/backend/config"
|
||||||
|
"github.com/localhots/cmdui/backend/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Process struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
PID int `json:"pid"`
|
||||||
|
Job *db.Job `json:"job"`
|
||||||
|
Command commands.Command `json:"command"`
|
||||||
|
Out io.Writer `json:"-"`
|
||||||
|
|
||||||
|
exec *exec.Cmd
|
||||||
|
log *os.File
|
||||||
|
|
||||||
|
exitCallbacks []func(p *Process) `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Process) Signal(s syscall.Signal) error {
|
||||||
|
return p.exec.Process.Signal(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Process) logfile() string {
|
||||||
|
return fmt.Sprintf("%s/%s.log", config.Get().LogDir, p.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Process) useLogfile(path string) (io.Writer, error) {
|
||||||
|
fd, err := os.OpenFile(p.logfile(), os.O_CREATE|os.O_WRONLY, 0744)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Annotate(err, "Failed to create log file")
|
||||||
|
}
|
||||||
|
p.log = fd
|
||||||
|
|
||||||
|
return fd, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Process) onExit(fn func(p *Process)) {
|
||||||
|
p.exitCallbacks = append(p.exitCallbacks, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Process) close() error {
|
||||||
|
if p.log != nil {
|
||||||
|
err := p.log.Close()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Annotate(err, "Failed to close log file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
# See https://help.github.com/ignore-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"name": "cmdui",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^16.0.0",
|
||||||
|
"react-dom": "^16.0.0",
|
||||||
|
"react-router-dom": "^4.2.2",
|
||||||
|
"react-scripts": "1.0.14",
|
||||||
|
"source-map-explorer": "^1.5.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"analyze": "source-map-explorer build/static/js/main.*",
|
||||||
|
"start": "BROWSER=none react-scripts start",
|
||||||
|
"build": "react-scripts build",
|
||||||
|
"test": "react-scripts test --env=jsdom",
|
||||||
|
"eject": "react-scripts eject"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>go-api | commands</title>
|
||||||
|
<!-- <link rel="stylesheet" type="text/css" href="/styles.css"> -->
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:200,400,600" rel="stylesheet">
|
||||||
|
<meta charset="utf-8">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<!-- <script src="http://localhost:8097"></script> -->
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,21 @@
|
||||||
|
body {
|
||||||
|
font-family: "Source Sans Pro", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px 100%;
|
||||||
|
max-width: 1400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 1400px;
|
||||||
|
width: calc(100% - 300px);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #a11122;
|
||||||
|
}
|
|
@ -0,0 +1,118 @@
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { BrowserRouter as Router, Route } from 'react-router-dom'
|
||||||
|
|
||||||
|
import Loading from './pages/loading.js';
|
||||||
|
import Login from './pages/login.js';
|
||||||
|
import SelectCommand from './pages/select_command.js';
|
||||||
|
import Command from './pages/command.js';
|
||||||
|
import History from './pages/history.js';
|
||||||
|
import Job from './pages/job.js';
|
||||||
|
import Header from './blocks/header.js';
|
||||||
|
import { api, httpGET } from './http.js';
|
||||||
|
import './app.css';
|
||||||
|
|
||||||
|
export default class App extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
authorized: null,
|
||||||
|
user: null,
|
||||||
|
commands: null,
|
||||||
|
commands_query: ""
|
||||||
|
};
|
||||||
|
this.loadAuth();
|
||||||
|
this.loadCommands();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAuth() {
|
||||||
|
httpGET(api("/auth/session"),
|
||||||
|
(status, body) => {
|
||||||
|
if (status === 200) {
|
||||||
|
this.setState({
|
||||||
|
authorized: true,
|
||||||
|
user: JSON.parse(body)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.setState({authorized: false});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.log("Failed to load auth details:", error);
|
||||||
|
this.setState({authorized: false});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadCommands() {
|
||||||
|
httpGET(api("/commands"),
|
||||||
|
(status, body) => {
|
||||||
|
if (status === 200) {
|
||||||
|
let list = JSON.parse(body);
|
||||||
|
var hash = {};
|
||||||
|
for (var cmd of list) {
|
||||||
|
hash[cmd.name] = cmd;
|
||||||
|
}
|
||||||
|
this.setState({commands: hash});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
this.setState({commands: {}});
|
||||||
|
console.log("Failed to load commands:", error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.authorized === null || this.state.commands === null) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
if (this.state.authorized === false) {
|
||||||
|
return <Login />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Router>
|
||||||
|
<div className="router-container">
|
||||||
|
<Header user={this.state.user} />
|
||||||
|
<Route exact path="/" render={props => (
|
||||||
|
<SelectCommand commands={this.state.commands}
|
||||||
|
onQueryChange={this.queryHandler.bind(this)} query={this.state.commands_query} />
|
||||||
|
)}/>
|
||||||
|
{/* Command */}
|
||||||
|
<Route exact path={"/cmd/:name"} render={props => (
|
||||||
|
<Command
|
||||||
|
cmd={props.match.params.name} commands={this.state.commands}
|
||||||
|
query={this.state.commands_query} onQueryChange={this.queryHandler.bind(this)} />
|
||||||
|
)}/>
|
||||||
|
{/* Logs */}
|
||||||
|
<Route exact path="/cmd/:name/jobs/:jobID" render={props => (
|
||||||
|
<Job jobID={props.match.params.jobID}
|
||||||
|
cmd={props.match.params.name} commands={this.state.commands}
|
||||||
|
query={this.state.commands_query} onQueryChange={this.queryHandler.bind(this)} />
|
||||||
|
)}/>
|
||||||
|
{/* History */}
|
||||||
|
<Route exact path="/cmd/:name/jobs" render={props => (
|
||||||
|
<History cmd={props.match.params.name} commands={this.state.commands}
|
||||||
|
query={this.state.commands_query} onQueryChange={this.queryHandler.bind(this)} />
|
||||||
|
)}/>
|
||||||
|
<Route exact path="/users/:id/jobs" render={props => (
|
||||||
|
<History userID={props.match.params.id}
|
||||||
|
commands={this.state.commands}
|
||||||
|
query={this.state.commands_query} onQueryChange={this.queryHandler.bind(this)} />
|
||||||
|
)}/>
|
||||||
|
<Route exact path="/jobs" render={props => (
|
||||||
|
<History
|
||||||
|
commands={this.state.commands}
|
||||||
|
query={this.state.commands_query} onQueryChange={this.queryHandler.bind(this)} />
|
||||||
|
)}/>
|
||||||
|
</div>
|
||||||
|
</Router>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
queryHandler(event) {
|
||||||
|
this.setState({
|
||||||
|
commands_query: event.target.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,45 @@
|
||||||
|
header {
|
||||||
|
width: 100%;
|
||||||
|
background-color: #891826;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.header-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto auto;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
max-width: 1400px;
|
||||||
|
}
|
||||||
|
header .go-api {
|
||||||
|
grid-column: 1;
|
||||||
|
margin: 0 0 2px 20px;
|
||||||
|
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 38px;
|
||||||
|
}
|
||||||
|
header .go-api span {
|
||||||
|
font-weight: 200;
|
||||||
|
}
|
||||||
|
header .go-api span:before {
|
||||||
|
content: ' | ';
|
||||||
|
}
|
||||||
|
header .auth {
|
||||||
|
display: grid;
|
||||||
|
grid-column: 2;
|
||||||
|
grid-template-columns: auto 55px;
|
||||||
|
}
|
||||||
|
header .auth .name {
|
||||||
|
grid-column: 1;
|
||||||
|
|
||||||
|
text-align: right;
|
||||||
|
line-height: 40px;
|
||||||
|
}
|
||||||
|
header .auth img {
|
||||||
|
grid-column: 2;
|
||||||
|
margin: 5px 20px 5px 5px;
|
||||||
|
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
|
||||||
|
import './header.css';
|
||||||
|
|
||||||
|
class Header extends Component {
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<header>
|
||||||
|
<div className="header-container">
|
||||||
|
<div className="go-api">go-api<span>commands</span></div>
|
||||||
|
<div className="auth">
|
||||||
|
<div className="name">{this.props.user.name}</div>
|
||||||
|
<img src={this.props.user.picture} alt="" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Header;
|
|
@ -0,0 +1,35 @@
|
||||||
|
.job-details.short {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 20px 100px 20% auto 200px 120px;
|
||||||
|
line-height: 32px;
|
||||||
|
}
|
||||||
|
.job-details.short:nth-child(even) {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
.job-details.short.legend {
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: #aaa 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-details.short a {
|
||||||
|
color: #222;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-details.short .dot {
|
||||||
|
margin: 13px 5px 0 5px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.job-details.short .dot.finished {
|
||||||
|
background-color: #0c8; /* People can't tell green from blue, right? */
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-details.short .dot.failed {
|
||||||
|
background-color: #d03;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-details.short .id {
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
|
@ -0,0 +1,42 @@
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import Timestamp from './timestamp.js';
|
||||||
|
import './job_list.css';
|
||||||
|
|
||||||
|
export default class JobList extends Component {
|
||||||
|
render() {
|
||||||
|
if (this.props.jobs === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="history-page">
|
||||||
|
<div className={"job-details short legend"}>
|
||||||
|
<div></div>
|
||||||
|
<div>ID</div>
|
||||||
|
<div>Command</div>
|
||||||
|
<div>User</div>
|
||||||
|
<div>Started</div>
|
||||||
|
<div>Took</div>
|
||||||
|
</div>
|
||||||
|
{this.props.jobs.map(this.renderJob)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderJob(job) {
|
||||||
|
let shortID = job.id.substring(0, 8);
|
||||||
|
return (
|
||||||
|
<div key={job.id} className={"job-details short"}>
|
||||||
|
<div className={"dot "+ job.state}></div>
|
||||||
|
<div className="id"><Link to={"/cmd/"+ job.command +"/jobs/"+ job.id}>{shortID}</Link></div>
|
||||||
|
<div className="command"><Link to={"/cmd/"+ job.command +"/jobs"}>{job.command}</Link></div>
|
||||||
|
<div className="user"><Link to={"/users/"+ job.user_id +"/jobs"}>{job.user.name}</Link></div>
|
||||||
|
<div className="started"><Timestamp date={job.started_at} /></div>
|
||||||
|
<div className="took"><Timestamp from={job.started_at} until={job.finished_at} /></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
nav {
|
||||||
|
grid-column: 1;
|
||||||
|
margin: 0 20px 20px 10px;
|
||||||
|
|
||||||
|
width: 230px;
|
||||||
|
}
|
||||||
|
input.search {
|
||||||
|
width: 100%;
|
||||||
|
height: 22px;
|
||||||
|
line-height: 22px;
|
||||||
|
margin: 0 0 10px 10px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
}
|
||||||
|
nav a {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px 10px;
|
||||||
|
color: #666;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
text-decoration: none;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
nav a:hover {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
nav a.active {
|
||||||
|
background-color: #a11122;
|
||||||
|
color: #fff;
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import './nav.css';
|
||||||
|
|
||||||
|
export default class Nav extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
query: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<nav>
|
||||||
|
<input className="search" type="search" placeholder="Search for commands"
|
||||||
|
value={this.props.query} onChange={this.props.onQueryChange} results="0" />
|
||||||
|
<ul>
|
||||||
|
{Object.keys(this.props.commands).map((name) => {
|
||||||
|
let cmd = this.props.commands[name];
|
||||||
|
if (cmd.name.indexOf(this.props.query) === -1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var className = "";
|
||||||
|
if (this.props.active && cmd.name === this.props.active) {
|
||||||
|
className = "active";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={cmd.name}>
|
||||||
|
<Link to={"/cmd/" + cmd.name} className={className}>{cmd.name}</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,58 @@
|
||||||
|
.output {
|
||||||
|
overflow: auto;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
background-color: #fafafa;
|
||||||
|
border: #f4f4f4 1px solid;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
font-family: monospace;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
.output.visible {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-details.full {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto;
|
||||||
|
margin: 10px 0 10px 0;
|
||||||
|
|
||||||
|
padding: 10px;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
border: #eaeaea 1px solid;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.job-details.full .item > * {
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
.job-details.full .item .name {
|
||||||
|
grid-column: 1;
|
||||||
|
display: inline-block;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 5px 0 0;
|
||||||
|
}
|
||||||
|
.job-details.full .item .name:after {
|
||||||
|
content: ':';
|
||||||
|
}
|
||||||
|
.job-details.full .item .val {
|
||||||
|
grid-column: 2;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.job-details.full .item.command {
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.job-details.full .item.command .command {
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 10px 0 0;
|
||||||
|
}
|
||||||
|
.job-details.full .item.command .args {
|
||||||
|
margin: 0 10px 0 0;
|
||||||
|
}
|
||||||
|
.job-details.full .item.state .val.finished {
|
||||||
|
color: #3e0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.job-details.full .item.state .val.failed {
|
||||||
|
color: #e30;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
|
@ -0,0 +1,159 @@
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import Timestamp from './timestamp.js';
|
||||||
|
import { api, httpGET, httpStreamGET } from '../http.js';
|
||||||
|
import './output.css';
|
||||||
|
|
||||||
|
export default class Output extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
job: null,
|
||||||
|
xhr: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
let jobID = this.props.jobID;
|
||||||
|
if (jobID !== null) {
|
||||||
|
this.loadCommandLog(jobID);
|
||||||
|
this.loadJobDetails(jobID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.state.xhr !== null) {
|
||||||
|
this.state.xhr.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadJobDetails(id) {
|
||||||
|
if (id === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
httpGET(api("/jobs/" + id),
|
||||||
|
(status, body) => {
|
||||||
|
this.setState({job: JSON.parse(body)});
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.log("Failed to load job details:", error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadCommandLog(id) {
|
||||||
|
if (id === null || this.state.xhr !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let xhr = httpStreamGET(api("/jobs/" + id + "/log"),
|
||||||
|
(chunk) => { // Progress
|
||||||
|
let target = this.refs["output"];
|
||||||
|
target.innerHTML += chunk.replace(/\n/g, "<br/>");
|
||||||
|
this.autoScroll();
|
||||||
|
},
|
||||||
|
(status) => { // Complete
|
||||||
|
// Request cancelled
|
||||||
|
if (status === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload job details
|
||||||
|
this.setState({xhr: null});
|
||||||
|
this.loadJobDetails(id);
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
let target = this.refs["output"];
|
||||||
|
target.innerHTML = "Failed to fetch command log: "+ error;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this.setState({xhr: xhr});
|
||||||
|
}
|
||||||
|
|
||||||
|
autoScroll() {
|
||||||
|
// TODO: Figure out how to make it convinient
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
var outputClass = "output";
|
||||||
|
if (this.state.job) {
|
||||||
|
outputClass += " visible";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{this.renderJobDetails()}
|
||||||
|
<div ref="output" className={outputClass}></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderJobDetails() {
|
||||||
|
let details = this.state.job;
|
||||||
|
if (!details) {
|
||||||
|
return (<div></div>);
|
||||||
|
}
|
||||||
|
|
||||||
|
// let shortID = details.id.substring(0, 8);
|
||||||
|
var state = details.state;
|
||||||
|
state = state.charAt(0).toUpperCase() + state.substr(1);
|
||||||
|
|
||||||
|
var args;
|
||||||
|
if (details.args !== "") {
|
||||||
|
args = <span className="args">{details.args}</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="job-details full">
|
||||||
|
<div className="item command">
|
||||||
|
<span className="command"><Link to={"/cmd/"+ details.command + "/jobs"}>{details.command}</Link></span>
|
||||||
|
{args}
|
||||||
|
<span className="flags">{details.flags}</span>
|
||||||
|
</div>
|
||||||
|
<div className="item id">
|
||||||
|
<div className="name">ID</div>
|
||||||
|
<div className="val"><Link to={"/cmd/"+ details.command + "/jobs/"+ details.id}>{details.id}</Link></div>
|
||||||
|
</div>
|
||||||
|
<div className="item state">
|
||||||
|
<div className="name">Status</div>
|
||||||
|
<div className={"val "+details.state}>{state}</div>
|
||||||
|
</div>
|
||||||
|
<div className="item user">
|
||||||
|
<div className="name">User</div>
|
||||||
|
<div className="val"><Link to={"/users/"+ details.user.id +"/jobs"}>{details.user.name}</Link></div>
|
||||||
|
</div>
|
||||||
|
{this.renderStarted()}
|
||||||
|
{this.renderFinished()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderStarted() {
|
||||||
|
let details = this.state.job;
|
||||||
|
return (
|
||||||
|
<div className="item started_at">
|
||||||
|
<div className="name">Started</div>
|
||||||
|
<div className="val"><Timestamp date={details.started_at} relative={details.finished_at === null} /></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFinished() {
|
||||||
|
let details = this.state.job;
|
||||||
|
if (details.finished_at !== null) {
|
||||||
|
return [
|
||||||
|
<div className="item finished_at" key="finished_at">
|
||||||
|
<div className="name">Finished</div>
|
||||||
|
<div className="val"><Timestamp date={details.finished_at} /></div>
|
||||||
|
</div>,
|
||||||
|
<div className="item took" key="took">
|
||||||
|
<div className="name">Took</div>
|
||||||
|
<div className="val"><Timestamp from={details.started_at} until={details.finished_at} /></div>
|
||||||
|
</div>
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,93 @@
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
|
||||||
|
export default class Timestamp extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {timer: null, text: null};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
if (this.props.relative) {
|
||||||
|
this.setState({
|
||||||
|
text: this.relativeDate(),
|
||||||
|
timer: setInterval(this.setRelativeDate.bind(this), 1000)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.setState({
|
||||||
|
text: null,
|
||||||
|
timer: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (this.state.timer !== null) {
|
||||||
|
clearInterval(this.state.timer);
|
||||||
|
this.setState({timer: null, text: null});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
relativeDate(date = this.props.date) {
|
||||||
|
return this.timeSince(new Date(date)) + " ago";
|
||||||
|
}
|
||||||
|
|
||||||
|
setRelativeDate(date = this.props.date) {
|
||||||
|
this.setState({text: this.relativeDate()});
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
var text;
|
||||||
|
if (this.props.date !== undefined) {
|
||||||
|
if (this.props.relative) {
|
||||||
|
text = this.state.text;
|
||||||
|
} else {
|
||||||
|
text = this.formatDate(new Date(this.props.date));
|
||||||
|
}
|
||||||
|
} else if (this.props.from !== undefined && this.props.until !== undefined) {
|
||||||
|
text = this.timeSince(new Date(this.props.from), new Date(this.props.until));
|
||||||
|
} else {
|
||||||
|
text = "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
var title = "";
|
||||||
|
if (this.props.relative) {
|
||||||
|
title = this.formatDate(new Date(this.props.date));
|
||||||
|
} else if (this.props.until !== undefined) {
|
||||||
|
title = this.formatDate(new Date(this.props.until));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span title={title}>{text}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(date) {
|
||||||
|
let leftPad = (n) => n > 9 ? n : "0"+ n; // I wish I was a package
|
||||||
|
let monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||||
|
let y = date.getFullYear(),
|
||||||
|
m = date.getMonth(),
|
||||||
|
d = date.getDate(),
|
||||||
|
h = leftPad(date.getHours()),
|
||||||
|
i = leftPad(date.getMinutes()),
|
||||||
|
s = leftPad(date.getSeconds());
|
||||||
|
return [d, monthNames[m], y, "at", [h, i, s].join(":")].join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
timeSince(timeStamp, until = new Date()) {
|
||||||
|
let secondsPast = (until.getTime() - timeStamp.getTime())/1000;
|
||||||
|
if (secondsPast < 60) {
|
||||||
|
return parseInt(secondsPast, 10) + 's';
|
||||||
|
} else if (secondsPast < 3600) {
|
||||||
|
return parseInt(secondsPast/60, 10) + 'm';
|
||||||
|
} else if (secondsPast <= 86400) {
|
||||||
|
return parseInt(secondsPast/3600, 10) + 'h';
|
||||||
|
} else {
|
||||||
|
let day = timeStamp.getDate();
|
||||||
|
let month = timeStamp.toDateString().match(/ [a-zA-Z]*/)[0].replace(" ","");
|
||||||
|
let year = timeStamp.getFullYear() === until.getFullYear()
|
||||||
|
? ""
|
||||||
|
: " "+timeStamp.getFullYear();
|
||||||
|
return day + " " + month + year;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
.user-details {
|
||||||
|
height: 50px;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
}
|
||||||
|
.user-details img {
|
||||||
|
float: left;
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
margin: 0 10px 0 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-details .name {
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 50px;
|
||||||
|
}
|
|
@ -0,0 +1,46 @@
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
|
||||||
|
import { api, httpGET } from '../http.js';
|
||||||
|
import './user.css';
|
||||||
|
|
||||||
|
export default class User extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {user: undefined};
|
||||||
|
}
|
||||||
|
componentDidMount() {
|
||||||
|
if (this.props.id === undefined || this.props.id === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
httpGET(api("/users/"+ this.props.id),
|
||||||
|
(status, body) => {
|
||||||
|
this.setState({user: JSON.parse(body)});
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.log("Failed to load user details:", error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.props.id === undefined || this.props.id === null || this.state.user === undefined) {
|
||||||
|
return null;
|
||||||
|
} else if (this.state.user === null) {
|
||||||
|
let shortID = this.props.id.substring(0, 8);
|
||||||
|
return (
|
||||||
|
<div className="user-details">
|
||||||
|
User
|
||||||
|
<div className="user-id">{shortID}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let details = this.state.user;
|
||||||
|
return (
|
||||||
|
<div className="user-details">
|
||||||
|
<img src={details.picture} alt={details.name +" picture"} />
|
||||||
|
<div className="name">{details.name}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,93 @@
|
||||||
|
export function api(path) {
|
||||||
|
let proto = window.location.protocol,
|
||||||
|
host = window.location.host;
|
||||||
|
return proto + "//" + host + "/api" + path;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function httpGET(url, success, error) {
|
||||||
|
let xhr = new XMLHttpRequest();
|
||||||
|
xhr.addEventListener("load", (e) => {
|
||||||
|
if (xhr.status >= 400) {
|
||||||
|
error("Request failed: " + xhr.statusText);
|
||||||
|
} else {
|
||||||
|
success(xhr.status, xhr.responseText);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
xhr.addEventListener("error", (e) => {
|
||||||
|
error("Request failed");
|
||||||
|
});
|
||||||
|
xhr.addEventListener("abort", (e) => {
|
||||||
|
error("Connection closed");
|
||||||
|
});
|
||||||
|
|
||||||
|
let async = true;
|
||||||
|
xhr.open("GET", url, async);
|
||||||
|
xhr.send(null);
|
||||||
|
return xhr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function httpStreamGET(url, progress, complete, error) {
|
||||||
|
let xhr = new XMLHttpRequest();
|
||||||
|
xhr.responseType = "text";
|
||||||
|
var lastIndex = 0;
|
||||||
|
xhr.onreadystatechange = () => {
|
||||||
|
let state = xhr.readyState;
|
||||||
|
if (state === xhr.LOADING) {
|
||||||
|
let curIndex = xhr.responseText.length;
|
||||||
|
if (curIndex === lastIndex) {
|
||||||
|
// No progress was made
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = xhr.responseText.slice(lastIndex, curIndex);
|
||||||
|
lastIndex = curIndex;
|
||||||
|
progress(text);
|
||||||
|
} else if (state === xhr.DONE) {
|
||||||
|
if (xhr.status >= 400) {
|
||||||
|
error("Request failed: " + xhr.statusText);
|
||||||
|
} else {
|
||||||
|
complete(xhr.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ignoring states: UNSENT, OPENED, HEADERS_RECEIVED
|
||||||
|
};
|
||||||
|
xhr.onerror = (e) => {
|
||||||
|
error("Request failed");
|
||||||
|
};
|
||||||
|
xhr.onabort = (e) => {
|
||||||
|
error("Connection closed");
|
||||||
|
};
|
||||||
|
|
||||||
|
let async = true;
|
||||||
|
xhr.open("GET", url, async);
|
||||||
|
xhr.send(null);
|
||||||
|
return xhr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function httpPOST(url, form, success, error) {
|
||||||
|
let xhr = new XMLHttpRequest();
|
||||||
|
xhr.responseType = "text";
|
||||||
|
xhr.onreadystatechange = () => {
|
||||||
|
let state = xhr.readyState;
|
||||||
|
if (state === xhr.DONE) {
|
||||||
|
if (xhr.status >= 400) {
|
||||||
|
error("Request failed: " + xhr.statusText);
|
||||||
|
} else {
|
||||||
|
success(xhr.responseText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ignoring states: UNSENT, OPENED, HEADERS_RECEIVED, PROGRESS
|
||||||
|
};
|
||||||
|
xhr.onerror = (e) => {
|
||||||
|
error("Request failed");
|
||||||
|
};
|
||||||
|
xhr.onabort = (e) => {
|
||||||
|
error("Connection closed");
|
||||||
|
};
|
||||||
|
|
||||||
|
let async = true;
|
||||||
|
xhr.open("POST", url, async);
|
||||||
|
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
|
||||||
|
xhr.send(form);
|
||||||
|
return xhr;
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
/*
|
||||||
|
* Reset
|
||||||
|
*/
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
ul, li {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
list-style-type: none;
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
|
import registerServiceWorker from './registerServiceWorker';
|
||||||
|
import App from './app.js';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
ReactDOM.render(<App />, document.getElementById('root'));
|
||||||
|
registerServiceWorker();
|
|
@ -0,0 +1,86 @@
|
||||||
|
form {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
form > .command {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
}
|
||||||
|
.command .name {
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 36px;
|
||||||
|
}
|
||||||
|
.command .descr {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.fields li {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
.fields label {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 5px 0 0;
|
||||||
|
|
||||||
|
line-height: 18px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.fields .descr {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
color: #666;
|
||||||
|
display: inline-block;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
display: block;
|
||||||
|
width: calc(100% - 10px);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 5px;
|
||||||
|
line-height: 30px;
|
||||||
|
font-size: 16px;
|
||||||
|
border: #ddd 1px solid;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
input[type=checkbox] {
|
||||||
|
display: inline-block;
|
||||||
|
width: auto;
|
||||||
|
margin: 0 5px 0 0;
|
||||||
|
}
|
||||||
|
input[type=submit] {
|
||||||
|
width: auto;
|
||||||
|
padding: 5px 25px;
|
||||||
|
background-color: #891826;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
float: left;
|
||||||
|
margin: 0 10px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.history {
|
||||||
|
line-height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-command {
|
||||||
|
display: block;
|
||||||
|
width: calc(100% - 20px);
|
||||||
|
line-height: 60px;
|
||||||
|
background-color: #eee;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
overflow: auto;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
background-color: #f55;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
|
@ -0,0 +1,169 @@
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import Nav from '../blocks/nav.js';
|
||||||
|
import Output from '../blocks/output.js';
|
||||||
|
import { api, httpPOST } from '../http.js';
|
||||||
|
import './command.css';
|
||||||
|
|
||||||
|
export default class Command extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
form: this.defaultForm(props.commands[props.cmd]),
|
||||||
|
jobID: null,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillReceiveProps(props) {
|
||||||
|
this.setState({form: this.defaultForm(props.commands[props.cmd])});
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultForm(cmd) {
|
||||||
|
if (!cmd || Object.keys(cmd).length === 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
var form = {
|
||||||
|
command: cmd.name,
|
||||||
|
args: "",
|
||||||
|
flags: {}
|
||||||
|
}
|
||||||
|
cmd.flags.forEach((flag) => {
|
||||||
|
form.flags[flag.name] = flag.default;
|
||||||
|
});
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
let cmd = this.props.commands[this.props.cmd];
|
||||||
|
if (!cmd) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<Nav commands={this.props.commands} active={this.props.cmd}
|
||||||
|
query={this.props.query} onQueryChange={this.props.onQueryChange} />
|
||||||
|
<main>
|
||||||
|
<form method="post" action="/api/exec" onSubmit={this.submitHandler.bind(this)} ref="form">
|
||||||
|
<div className="command">
|
||||||
|
<div className="name">{cmd.name}</div>
|
||||||
|
<div className="descr">{cmd.description}</div>
|
||||||
|
<input type="hidden" name="command" defaultValue={cmd.name} />
|
||||||
|
</div>
|
||||||
|
<ul className="fields">
|
||||||
|
<li>
|
||||||
|
<div className="descr">Command arguments</div>
|
||||||
|
<input type="text" name="args" placeholder={cmd.args_placeholder}
|
||||||
|
onChange={this.changeHandler.bind(this)} />
|
||||||
|
</li>
|
||||||
|
{cmd.flags.map((flag) => { return this.input(flag); })}
|
||||||
|
</ul>
|
||||||
|
<input type="submit" value="Execute" id="submit-button"/>
|
||||||
|
<Link to={"/cmd/"+ cmd.name +"/jobs"} className="history">History</Link>
|
||||||
|
</form>
|
||||||
|
{this.renderError()}
|
||||||
|
<Output cmd={cmd} jobID={this.state.jobID} key={this.state.jobID} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderError() {
|
||||||
|
if (this.state.error === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="error">{this.state.error}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
submitHandler(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
let f = this.state.form;
|
||||||
|
var args = [];
|
||||||
|
args.push(["command", f.command]);
|
||||||
|
args.push(["args", f.args]);
|
||||||
|
for (let name of Object.keys(f.flags)) {
|
||||||
|
args.push(["flags[" + name + "]", f.flags[name]]);
|
||||||
|
}
|
||||||
|
let formQuery = args.map((pair) => {
|
||||||
|
return pair[0] + "=" + encodeURIComponent(pair[1]);
|
||||||
|
}).join("&");
|
||||||
|
|
||||||
|
httpPOST(api("/exec"), formQuery,
|
||||||
|
(response) => {
|
||||||
|
let details = JSON.parse(response);
|
||||||
|
this.setState({jobID: details.id, error: null});
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
this.setState({jobID: null, error: error});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
changeHandler(event) {
|
||||||
|
if (event.target.id.indexOf("flag-") === 0) {
|
||||||
|
var val = event.target.value;
|
||||||
|
if (event.target.type === "checkbox") {
|
||||||
|
val = JSON.stringify(event.target.checked);
|
||||||
|
}
|
||||||
|
|
||||||
|
var flags = this.state.form.flags;
|
||||||
|
let name = event.target.id.substring(5);
|
||||||
|
flags[name] = val;
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
form: {
|
||||||
|
command: this.state.form.command,
|
||||||
|
args: this.state.form.args,
|
||||||
|
flags: flags
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (event.target.name === "args") {
|
||||||
|
this.setState({
|
||||||
|
form: {
|
||||||
|
command: this.state.form.command,
|
||||||
|
args: event.target.value,
|
||||||
|
flags: this.state.form.flags
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input(flag) {
|
||||||
|
let flagName = (name) => { return "flags[" + name + "]"; }
|
||||||
|
if (flag.type === "bool") {
|
||||||
|
return (
|
||||||
|
<li key={flag.name}>
|
||||||
|
<input type="checkbox" name={flagName(flag.name)} id={"flag-"+flag.name}
|
||||||
|
defaultChecked={(flag.default === "true")} value="true"
|
||||||
|
onClick={this.changeHandler.bind(this)} />
|
||||||
|
<label htmlFor={"flag-"+flag.name}>{flag.name}</label>
|
||||||
|
<span className="descr">{flag.description}</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
var inputType = "string";
|
||||||
|
let numericTypes = [
|
||||||
|
"int", "int8", "int16", "int32", "int64",
|
||||||
|
"uint", "uint8", "uint16", "uint32", "uint64"
|
||||||
|
];
|
||||||
|
if (numericTypes.includes(flag.type)) {
|
||||||
|
inputType = "number";
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<li key={flag.name}>
|
||||||
|
<label htmlFor={"flags-"+flag.name}>{flag.name}</label>
|
||||||
|
<span className="descr">{flag.description}</span>
|
||||||
|
<input type={inputType} name={flagName(flag.name)} id={"flags-"+flag.name}
|
||||||
|
defaultValue={flag.default} onChange={this.changeHandler.bind(this)} />
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
|
@ -0,0 +1,14 @@
|
||||||
|
.command-details {
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 50px;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
}
|
||||||
|
.command-details span {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
.command-details .cmd-name {
|
||||||
|
display: inline-block;
|
||||||
|
font-weight: 400;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
|
@ -0,0 +1,61 @@
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import Nav from '../blocks/nav.js';
|
||||||
|
import JobList from '../blocks/job_list.js';
|
||||||
|
import User from '../blocks/user.js';
|
||||||
|
import { api, httpGET } from '../http.js';
|
||||||
|
import './history.css';
|
||||||
|
|
||||||
|
export default class History extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
var url;
|
||||||
|
if (this.props.cmd !== undefined) {
|
||||||
|
url = api("/commands/" + this.props.cmd + "/jobs")
|
||||||
|
} else if (this.props.userID !== undefined) {
|
||||||
|
url = api("/users/" + this.props.userID + "/jobs")
|
||||||
|
} else {
|
||||||
|
url = api("/jobs")
|
||||||
|
}
|
||||||
|
httpGET(url,
|
||||||
|
(status, body) => {
|
||||||
|
let jobs = JSON.parse(body);
|
||||||
|
this.setState({jobs: jobs});
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.log("Failed to load jobs:", error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
let details;
|
||||||
|
if (this.props.cmd !== undefined) {
|
||||||
|
details = (
|
||||||
|
<div className="command-details">
|
||||||
|
<span>Command</span>
|
||||||
|
<div className="cmd-name"><Link to={"/cmd/"+ this.props.cmd}>{this.props.cmd}</Link></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (this.props.userID !== undefined) {
|
||||||
|
details = (
|
||||||
|
<User id={this.props.userID} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<Nav commands={this.props.commands} active={this.props.cmd}
|
||||||
|
query={this.props.query} onQueryChange={this.props.onQueryChange} />
|
||||||
|
<main>
|
||||||
|
{details}
|
||||||
|
<JobList jobs={this.state.jobs} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
|
||||||
|
import Nav from '../blocks/nav.js';
|
||||||
|
import Output from '../blocks/output.js';
|
||||||
|
|
||||||
|
export default class Job extends Component {
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<Nav commands={this.props.commands} active={this.props.cmd}
|
||||||
|
query={this.props.query} onQueryChange={this.props.onQueryChange} />
|
||||||
|
<main>
|
||||||
|
<Output cmd={this.props.cmd} jobID={this.props.jobID} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
.loading-page {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 200px;
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import './loading.css';
|
||||||
|
|
||||||
|
export default class Loading extends Component {
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div className="loading-page">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
.login-page {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.login-page .button {
|
||||||
|
display: block;
|
||||||
|
margin-top: 100px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.login-page img {
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
}
|
||||||
|
.login-page div {
|
||||||
|
line-height: 30px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #222;
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import './login.css';
|
||||||
|
import githubLogo from './github_logo.png';
|
||||||
|
|
||||||
|
export default class Login extends Component {
|
||||||
|
componentDidMount() {
|
||||||
|
let page = document.getElementsByClassName("login-page")[0];
|
||||||
|
page.style.height = window.innerHeight + "px";
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<a className="button" href="/api/auth/login">
|
||||||
|
<img src={githubLogo} alt="Login with GitHub" />
|
||||||
|
<div>Login with GitHub</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
|
||||||
|
import Nav from '../blocks/nav.js';
|
||||||
|
|
||||||
|
class SelectCommand extends Component {
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<Nav commands={this.props.commands} onQueryChange={this.props.onQueryChange} query={this.props.query} />
|
||||||
|
<main>
|
||||||
|
<div className="select-command">
|
||||||
|
Please select a command
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SelectCommand;
|
|
@ -0,0 +1,108 @@
|
||||||
|
// In production, we register a service worker to serve assets from local cache.
|
||||||
|
|
||||||
|
// This lets the app load faster on subsequent visits in production, and gives
|
||||||
|
// it offline capabilities. However, it also means that developers (and users)
|
||||||
|
// will only see deployed updates on the "N+1" visit to a page, since previously
|
||||||
|
// cached resources are updated in the background.
|
||||||
|
|
||||||
|
// To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
|
||||||
|
// This link also includes instructions on opting out of this behavior.
|
||||||
|
|
||||||
|
const isLocalhost = Boolean(
|
||||||
|
window.location.hostname === 'localhost' ||
|
||||||
|
// [::1] is the IPv6 localhost address.
|
||||||
|
window.location.hostname === '[::1]' ||
|
||||||
|
// 127.0.0.1/8 is considered localhost for IPv4.
|
||||||
|
window.location.hostname.match(
|
||||||
|
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function register() {
|
||||||
|
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
|
||||||
|
// The URL constructor is available in all browsers that support SW.
|
||||||
|
const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
|
||||||
|
if (publicUrl.origin !== window.location.origin) {
|
||||||
|
// Our service worker won't work if PUBLIC_URL is on a different origin
|
||||||
|
// from what our page is served on. This might happen if a CDN is used to
|
||||||
|
// serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
|
||||||
|
|
||||||
|
if (!isLocalhost) {
|
||||||
|
// Is not local host. Just register service worker
|
||||||
|
registerValidSW(swUrl);
|
||||||
|
} else {
|
||||||
|
// This is running on localhost. Lets check if a service worker still exists or not.
|
||||||
|
checkValidServiceWorker(swUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerValidSW(swUrl) {
|
||||||
|
navigator.serviceWorker
|
||||||
|
.register(swUrl)
|
||||||
|
.then(registration => {
|
||||||
|
registration.onupdatefound = () => {
|
||||||
|
const installingWorker = registration.installing;
|
||||||
|
installingWorker.onstatechange = () => {
|
||||||
|
if (installingWorker.state === 'installed') {
|
||||||
|
if (navigator.serviceWorker.controller) {
|
||||||
|
// At this point, the old content will have been purged and
|
||||||
|
// the fresh content will have been added to the cache.
|
||||||
|
// It's the perfect time to display a "New content is
|
||||||
|
// available; please refresh." message in your web app.
|
||||||
|
console.log('New content is available; please refresh.');
|
||||||
|
} else {
|
||||||
|
// At this point, everything has been precached.
|
||||||
|
// It's the perfect time to display a
|
||||||
|
// "Content is cached for offline use." message.
|
||||||
|
console.log('Content is cached for offline use.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error during service worker registration:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkValidServiceWorker(swUrl) {
|
||||||
|
// Check if the service worker can be found. If it can't reload the page.
|
||||||
|
fetch(swUrl)
|
||||||
|
.then(response => {
|
||||||
|
// Ensure service worker exists, and that we really are getting a JS file.
|
||||||
|
if (
|
||||||
|
response.status === 404 ||
|
||||||
|
response.headers.get('content-type').indexOf('javascript') === -1
|
||||||
|
) {
|
||||||
|
// No service worker found. Probably a different app. Reload the page.
|
||||||
|
navigator.serviceWorker.ready.then(registration => {
|
||||||
|
registration.unregister().then(() => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Service worker found. Proceed as normal.
|
||||||
|
registerValidSW(swUrl);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
console.log(
|
||||||
|
'No internet connection found. App is running in offline mode.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregister() {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.ready.then(registration => {
|
||||||
|
registration.unregister();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
upstream api {
|
||||||
|
server 127.0.0.1:9090;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream react {
|
||||||
|
server 127.0.0.1:3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
server_name cmdui.local;
|
||||||
|
charset utf-8;
|
||||||
|
|
||||||
|
location ~ ^/api/ {
|
||||||
|
# Disable buffering to allow streaming logs in real-time
|
||||||
|
proxy_buffering off;
|
||||||
|
|
||||||
|
proxy_pass http://api;
|
||||||
|
}
|
||||||
|
|
||||||
|
# React websockets connection
|
||||||
|
location ~ ^/sockjs-node/ {
|
||||||
|
proxy_pass http://react;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ / {
|
||||||
|
proxy_pass http://react;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
CREATE TABLE `jobs` (
|
||||||
|
`id` char(36) NOT NULL DEFAULT '',
|
||||||
|
`command` varchar(255) NOT NULL DEFAULT '',
|
||||||
|
`args` text NOT NULL,
|
||||||
|
`flags` text NOT NULL,
|
||||||
|
`user_id` char(36) DEFAULT NULL,
|
||||||
|
`state` varchar(20) NOT NULL,
|
||||||
|
`created_at` datetime DEFAULT NULL,
|
||||||
|
`started_at` datetime DEFAULT NULL,
|
||||||
|
`finished_at` datetime DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE `sessions` (
|
||||||
|
`id` char(36) NOT NULL DEFAULT '',
|
||||||
|
`user_id` char(36) NOT NULL DEFAULT '',
|
||||||
|
`created_at` datetime NOT NULL,
|
||||||
|
`expires_at` datetime NOT NULL,
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE `users` (
|
||||||
|
`id` char(36) NOT NULL DEFAULT '',
|
||||||
|
`github_id` int(11) unsigned NOT NULL,
|
||||||
|
`github_login` varchar(255) NOT NULL DEFAULT '',
|
||||||
|
`github_name` varchar(255) NOT NULL DEFAULT '',
|
||||||
|
`github_picture` varchar(255) NOT NULL DEFAULT '',
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
Loading…
Reference in New Issue