mirror of
https://github.com/gusaul/grpcox.git
synced 2025-04-25 16:45:37 +00:00
Compare commits
30 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
6feae4127c | ||
|
b998f098b8 | ||
|
f240d0494e | ||
|
c5b61eb2d0 | ||
|
c46a882ba7 | ||
|
ca8f456f00 | ||
|
63fe96c73d | ||
|
6d348c6611 | ||
|
c2a6f5dd7f | ||
|
8534ee1dc1 | ||
|
54efdebe24 | ||
|
464de07064 | ||
|
e7578eaed5 | ||
|
c83858ebfc | ||
|
6eccacc39f | ||
|
b941d8727d | ||
|
f5768c3035 | ||
|
d1a4237cb2 | ||
|
790d251031 | ||
|
ec4ba2090d | ||
|
70e5c28119 | ||
|
8aa67b6529 | ||
|
1080e8f0b0 | ||
|
30fef12ac9 | ||
|
3908c00c11 | ||
|
6148904bec | ||
|
293bb364c0 | ||
|
3633418ad0 | ||
|
6d9d03f52a | ||
|
2676b0ccef |
5
.gitignore
vendored
5
.gitignore
vendored
|
@ -1,2 +1,5 @@
|
|||
grpcox
|
||||
log
|
||||
log
|
||||
*.out
|
||||
vendor/
|
||||
.idea
|
11
README.md
11
README.md
|
@ -9,6 +9,15 @@ turn [gRPCurl](https://github.com/fullstorydev/grpcurl) into web based UI, extre
|
|||
- Save established connection, and reuse it for next invoke/request (also can close/restart connection)
|
||||
|
||||
## Installation
|
||||
### Docker
|
||||
```shell
|
||||
docker pull gusaul/grpcox:latest
|
||||
```
|
||||
then run
|
||||
```shell
|
||||
docker run -p 6969:6969 -v {ABSOLUTE_PATH_TO_LOG}/log:/log -d gusaul/grpcox
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
from terminal, move to grpcox directory, then run command
|
||||
```shell
|
||||
|
@ -22,6 +31,7 @@ if you have golang installed on your local machine, just run command
|
|||
```shell
|
||||
make start
|
||||
```
|
||||
from grpcox directory
|
||||
|
||||
configure app preferences by editing `config.env` file
|
||||
|
||||
|
@ -29,6 +39,7 @@ configure app preferences by editing `config.env` file
|
|||
|-----------------|---------------------------------------------|--------|--------|
|
||||
| MAX_LIFE_CONN | maximum idle time connection before closed | number | minute |
|
||||
| TICK_CLOSE_CONN | ticker interval to sweep expired connection | number | second |
|
||||
| BIND_ADDR | ip:port to bind service | string | |
|
||||
|
||||
set value `0 (zero)` to disable auto close idle connection.
|
||||
|
||||
|
|
|
@ -121,7 +121,9 @@ func (c *ConnStore) addConnection(host string, res *Resource, ttl ...time.Durati
|
|||
conn.expired = time.Now().Add(ttl[0])
|
||||
}
|
||||
|
||||
c.Lock()
|
||||
c.conn[host] = conn
|
||||
c.Unlock()
|
||||
}
|
||||
|
||||
func (c *ConnStore) getConnection(host string) (res *Resource, found bool) {
|
||||
|
|
|
@ -3,16 +3,14 @@ package core
|
|||
import (
|
||||
"context"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/fullstorydev/grpcurl"
|
||||
"github.com/jhump/protoreflect/grpcreflect"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/keepalive"
|
||||
"google.golang.org/grpc/metadata"
|
||||
reflectpb "google.golang.org/grpc/reflection/grpc_reflection_v1alpha"
|
||||
)
|
||||
|
||||
// GrpCox - main object
|
||||
|
@ -34,6 +32,14 @@ type GrpCox struct {
|
|||
isUnixSocket func() bool
|
||||
}
|
||||
|
||||
// Proto define protofile uploaded from client
|
||||
// will be used to be persisted to disk and indicator
|
||||
// whether connections should reflect from server or local proto
|
||||
type Proto struct {
|
||||
Name string
|
||||
Content []byte
|
||||
}
|
||||
|
||||
// InitGrpCox constructor
|
||||
func InitGrpCox() *GrpCox {
|
||||
maxLife, tick := 10, 3
|
||||
|
@ -71,21 +77,37 @@ func (g *GrpCox) GetResource(ctx context.Context, target string, plainText, isRe
|
|||
var err error
|
||||
r := new(Resource)
|
||||
h := append(g.headers, g.reflectHeaders...)
|
||||
md := grpcurl.MetadataFromHeaders(h)
|
||||
refCtx := metadata.NewOutgoingContext(ctx, md)
|
||||
r.md = grpcurl.MetadataFromHeaders(h)
|
||||
r.clientConn, err = g.dial(ctx, target, plainText)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r.refClient = grpcreflect.NewClient(refCtx, reflectpb.NewServerReflectionClient(r.clientConn))
|
||||
r.descSource = grpcurl.DescriptorSourceFromServer(ctx, r.refClient)
|
||||
// what is r.Headers used for?
|
||||
r.headers = h
|
||||
|
||||
g.activeConn.addConnection(target, r, g.maxLifeConn)
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// GetResourceWithProto - open resource to targeted grpc server using given protofile
|
||||
func (g *GrpCox) GetResourceWithProto(ctx context.Context, target string, plainText, isRestartConn bool, protos []Proto) (*Resource, error) {
|
||||
r, err := g.GetResource(ctx, target, plainText, isRestartConn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// if given protofile is equal to current, skip adding protos as it's already
|
||||
// persisted in the harddisk anyway
|
||||
if reflect.DeepEqual(r.protos, protos) {
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// add protos property to resource and persist it to harddisk
|
||||
err = r.AddProtos(protos)
|
||||
return r, err
|
||||
}
|
||||
|
||||
// GetActiveConns - get all saved active connection
|
||||
func (g *GrpCox) GetActiveConns(ctx context.Context) []string {
|
||||
active := g.activeConn.getAllConn()
|
||||
|
@ -116,6 +138,11 @@ func (g *GrpCox) Extend(host string) {
|
|||
g.activeConn.extend(host, g.maxLifeConn)
|
||||
}
|
||||
|
||||
// SetReflectHeaders sets grpcox reflection headers
|
||||
func (g *GrpCox) SetReflectHeaders(headers ...string) {
|
||||
g.reflectHeaders = headers
|
||||
}
|
||||
|
||||
func (g *GrpCox) dial(ctx context.Context, target string, plainText bool) (*grpc.ClientConn, error) {
|
||||
dialTime := 10 * time.Second
|
||||
ctx, cancel := context.WithTimeout(ctx, dialTime)
|
||||
|
|
211
core/resource.go
211
core/resource.go
|
@ -5,8 +5,13 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fullstorydev/grpcurl"
|
||||
|
@ -14,21 +19,88 @@ import (
|
|||
"github.com/jhump/protoreflect/grpcreflect"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
reflectpb "google.golang.org/grpc/reflection/grpc_reflection_v1alpha"
|
||||
)
|
||||
|
||||
// BasePath define path where proto file will persisted
|
||||
const BasePath = "/tmp/grpcox/"
|
||||
|
||||
// Resource - hold 3 main function (List, Describe, and Invoke)
|
||||
type Resource struct {
|
||||
clientConn *grpc.ClientConn
|
||||
descSource grpcurl.DescriptorSource
|
||||
refClient *grpcreflect.Client
|
||||
protos []Proto
|
||||
protosets []Proto
|
||||
|
||||
headers []string
|
||||
md metadata.MD
|
||||
}
|
||||
|
||||
//openDescriptor - use it to reflect server descriptor
|
||||
func (r *Resource) openDescriptor() error {
|
||||
ctx := context.Background()
|
||||
refCtx := metadata.NewOutgoingContext(ctx, r.md)
|
||||
r.refClient = grpcreflect.NewClient(refCtx, reflectpb.NewServerReflectionClient(r.clientConn))
|
||||
|
||||
// if no protos available use server reflection
|
||||
if r.protos == nil && r.protosets == nil {
|
||||
r.descSource = grpcurl.DescriptorSourceFromServer(ctx, r.refClient)
|
||||
return nil
|
||||
}
|
||||
|
||||
protoPath := filepath.Join(BasePath, r.clientConn.Target())
|
||||
|
||||
var err error
|
||||
if len(r.protosets) > 0 {
|
||||
// make list of protos name to be used as descriptor
|
||||
protos := make([]string, 0, len(r.protosets))
|
||||
for _, proto := range r.protosets {
|
||||
protos = append(protos, filepath.Join(protoPath, proto.Name))
|
||||
}
|
||||
|
||||
r.descSource, err = grpcurl.DescriptorSourceFromProtoSets(protos...)
|
||||
} else {
|
||||
// make list of protos name to be used as descriptor
|
||||
protos := make([]string, 0, len(r.protos))
|
||||
for _, proto := range r.protos {
|
||||
protos = append(protos, proto.Name)
|
||||
}
|
||||
|
||||
r.descSource, err = grpcurl.DescriptorSourceFromProtoFiles([]string{protoPath}, protos...)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
//closeDescriptor - please ensure to always close after open in the same flow
|
||||
func (r *Resource) closeDescriptor() {
|
||||
done := make(chan int)
|
||||
go func() {
|
||||
if r.refClient != nil {
|
||||
r.refClient.Reset()
|
||||
}
|
||||
done <- 1
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-time.After(3 * time.Second):
|
||||
log.Printf("Reflection %s failed to close\n", r.clientConn.Target())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// List - To list all services exposed by a server
|
||||
// symbol can be "" to list all available services
|
||||
// symbol also can be service name to list all available method
|
||||
func (r *Resource) List(symbol string) ([]string, error) {
|
||||
err := r.openDescriptor()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer r.closeDescriptor()
|
||||
|
||||
var result []string
|
||||
if symbol == "" {
|
||||
|
@ -65,6 +137,11 @@ func (r *Resource) List(symbol string) ([]string, error) {
|
|||
// It also prints a description of that symbol, in the form of snippets of proto source.
|
||||
// It won't necessarily be the original source that defined the element, but it will be equivalent.
|
||||
func (r *Resource) Describe(symbol string) (string, string, error) {
|
||||
err := r.openDescriptor()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer r.closeDescriptor()
|
||||
|
||||
var result, template string
|
||||
|
||||
|
@ -118,28 +195,28 @@ func (r *Resource) Describe(symbol string) (string, string, error) {
|
|||
}
|
||||
|
||||
// Invoke - invoking gRPC function
|
||||
func (r *Resource) Invoke(ctx context.Context, symbol string, in io.Reader) (string, time.Duration, error) {
|
||||
// because of grpcurl directly fmt.Printf on their invoke function
|
||||
// so we stub the Stdout using os.Pipe
|
||||
backUpStdout := os.Stdout
|
||||
defer func() {
|
||||
os.Stdout = backUpStdout
|
||||
}()
|
||||
|
||||
f, w, err := os.Pipe()
|
||||
func (r *Resource) Invoke(ctx context.Context, metadata []string, symbol string, in io.Reader) (string, time.Duration, error) {
|
||||
err := r.openDescriptor()
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
os.Stdout = w
|
||||
defer r.closeDescriptor()
|
||||
|
||||
var resultBuffer bytes.Buffer
|
||||
|
||||
rf, formatter, err := grpcurl.RequestParserAndFormatterFor(grpcurl.Format("json"), r.descSource, false, true, in)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
h := grpcurl.NewDefaultEventHandler(os.Stdout, r.descSource, formatter, false)
|
||||
h := grpcurl.NewDefaultEventHandler(&resultBuffer, r.descSource, formatter, false)
|
||||
|
||||
var headers []string
|
||||
if len(metadata) != 0 {
|
||||
headers = metadata
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
err = grpcurl.InvokeRPC(ctx, r.descSource, r.clientConn, symbol, r.headers, h, rf.Next)
|
||||
err = grpcurl.InvokeRPC(ctx, r.descSource, r.clientConn, symbol, headers, h, rf.Next)
|
||||
end := time.Now().Sub(start) / time.Millisecond
|
||||
if err != nil {
|
||||
return "", end, err
|
||||
|
@ -149,40 +226,42 @@ func (r *Resource) Invoke(ctx context.Context, symbol string, in io.Reader) (str
|
|||
return "", end, fmt.Errorf(h.Status.Message())
|
||||
}
|
||||
|
||||
// copy the output in a separate goroutine so printing can't block indefinitely
|
||||
outC := make(chan string)
|
||||
go func() {
|
||||
var buf bytes.Buffer
|
||||
io.Copy(&buf, f)
|
||||
outC <- buf.String()
|
||||
}()
|
||||
|
||||
w.Close()
|
||||
out := <-outC
|
||||
|
||||
return out, end, nil
|
||||
return resultBuffer.String(), end, nil
|
||||
}
|
||||
|
||||
// Close - to close all resources that was opened before
|
||||
func (r *Resource) Close() {
|
||||
done := make(chan int)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
if r.refClient != nil {
|
||||
r.refClient.Reset()
|
||||
r.refClient = nil
|
||||
}
|
||||
defer wg.Done()
|
||||
if r.clientConn != nil {
|
||||
r.clientConn.Close()
|
||||
r.clientConn = nil
|
||||
}
|
||||
done <- 1
|
||||
}()
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
err := os.RemoveAll(BasePath)
|
||||
if err != nil {
|
||||
log.Printf("error removing proto dir from tmp: %s", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
c := make(chan struct{})
|
||||
go func() {
|
||||
defer close(c)
|
||||
wg.Wait()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-c:
|
||||
return
|
||||
case <-time.After(3 * time.Second):
|
||||
log.Printf("Connection %s falied to close\n", r.clientConn.Target())
|
||||
log.Printf("Connection %s failed to close\n", r.clientConn.Target())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
@ -196,3 +275,71 @@ func (r *Resource) exit(code int) {
|
|||
r.Close()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// AddProtos to resource properties and harddisk
|
||||
// added protos will be persisted in `basepath + connection target`
|
||||
// i.e. connection target == 127.0.0.1:8888
|
||||
// proto files will be persisted in /tmp/grpcox/127.0.0.1:8888
|
||||
// if the directory is already there, remove it first
|
||||
func (r *Resource) AddProtos(protos []Proto) error {
|
||||
protoPath := filepath.Join(BasePath, r.clientConn.Target())
|
||||
err := os.MkdirAll(protoPath, 0777)
|
||||
if os.IsExist(err) {
|
||||
err = os.RemoveAll(protoPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = os.MkdirAll(protoPath, 0777)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var protoSlice, protosetSlice []Proto
|
||||
for _, proto := range protos {
|
||||
var err error
|
||||
if strings.HasSuffix(proto.Name, ".protoset") {
|
||||
protosetSlice = append(protosetSlice, proto)
|
||||
err = ioutil.WriteFile(filepath.Join(protoPath, "/", proto.Name),
|
||||
proto.Content,
|
||||
0777)
|
||||
} else {
|
||||
protoSlice = append(protoSlice, proto)
|
||||
err = ioutil.WriteFile(filepath.Join(protoPath, "/", proto.Name),
|
||||
prepareImport(proto.Content),
|
||||
0777)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
r.protos = protoSlice
|
||||
r.protosets = protosetSlice
|
||||
return nil
|
||||
}
|
||||
|
||||
// prepareImport transforming proto import into local path
|
||||
// with exception to google proto import as it won't cause any problem
|
||||
func prepareImport(proto []byte) []byte {
|
||||
const pattern = `import ".+`
|
||||
result := string(proto)
|
||||
|
||||
re := regexp.MustCompile(pattern)
|
||||
matchs := re.FindAllString(result, -1)
|
||||
for _, match := range matchs {
|
||||
if strings.Contains(match, "\"google/") {
|
||||
continue
|
||||
}
|
||||
name := strings.Split(match, "/")
|
||||
if len(name) < 2 {
|
||||
continue
|
||||
}
|
||||
importString := `import "` + name[len(name)-1]
|
||||
result = strings.Replace(result, match, importString, -1)
|
||||
}
|
||||
|
||||
return []byte(result)
|
||||
}
|
||||
|
|
70
core/resource_test.go
Normal file
70
core/resource_test.go
Normal file
|
@ -0,0 +1,70 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func Test_prepareImport(t *testing.T) {
|
||||
type args struct {
|
||||
proto []byte
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want []byte
|
||||
}{
|
||||
{
|
||||
name: "sucess change import path to local",
|
||||
args: args{
|
||||
proto: []byte(`
|
||||
package testing;
|
||||
|
||||
import "test.com/owner/repo/content.proto";`),
|
||||
},
|
||||
want: []byte(`
|
||||
package testing;
|
||||
|
||||
import "content.proto";`),
|
||||
},
|
||||
{
|
||||
name: "sucess keep google import",
|
||||
args: args{
|
||||
proto: []byte(`
|
||||
package testing;
|
||||
|
||||
import "google/proto/buf";
|
||||
import "test.com/owner/repo/content.proto";`),
|
||||
},
|
||||
want: []byte(`
|
||||
package testing;
|
||||
|
||||
import "google/proto/buf";
|
||||
import "content.proto";`),
|
||||
},
|
||||
{
|
||||
name: "sucess keep local import",
|
||||
args: args{
|
||||
proto: []byte(`
|
||||
package testing;
|
||||
|
||||
import "repo.proto";
|
||||
import "test.com/owner/repo/content.proto";`),
|
||||
},
|
||||
want: []byte(`
|
||||
package testing;
|
||||
|
||||
import "repo.proto";
|
||||
import "content.proto";`),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := prepareImport(tt.args.proto); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("prepareImport() = %v, want %v",
|
||||
string(got),
|
||||
string(tt.want))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
53
grpcox.go
53
grpcox.go
|
@ -1,13 +1,16 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gusaul/grpcox/core"
|
||||
"github.com/gusaul/grpcox/handler"
|
||||
)
|
||||
|
||||
|
@ -22,16 +25,54 @@ func main() {
|
|||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
|
||||
// start app
|
||||
port := ":6969"
|
||||
addr := "0.0.0.0:6969"
|
||||
if value, ok := os.LookupEnv("BIND_ADDR"); ok {
|
||||
addr = value
|
||||
}
|
||||
muxRouter := mux.NewRouter()
|
||||
handler.Init(muxRouter)
|
||||
var wait time.Duration = time.Second * 15
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
WriteTimeout: time.Second * 15,
|
||||
ReadTimeout: time.Second * 15,
|
||||
IdleTimeout: time.Second * 60,
|
||||
Handler: muxRouter,
|
||||
Addr: "0.0.0.0" + port,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
}
|
||||
|
||||
fmt.Println("Service started on", port)
|
||||
log.Fatal(srv.ListenAndServe())
|
||||
fmt.Println("Service started on", addr)
|
||||
go func() {
|
||||
log.Fatal(srv.ListenAndServe())
|
||||
}()
|
||||
|
||||
c := make(chan os.Signal, 1)
|
||||
|
||||
// We'll accept graceful shutdowns when quit via SIGINT (Ctrl+C)
|
||||
// SIGKILL, SIGQUIT or SIGTERM (Ctrl+/) will not be caught.
|
||||
signal.Notify(c, os.Interrupt)
|
||||
|
||||
// Block until we receive our signal.
|
||||
<-c
|
||||
|
||||
// Create a deadline to wait for.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), wait)
|
||||
defer cancel()
|
||||
|
||||
err = removeProtos()
|
||||
if err != nil {
|
||||
log.Printf("error while removing protos: %s", err.Error())
|
||||
}
|
||||
|
||||
srv.Shutdown(ctx)
|
||||
log.Println("shutting down")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// removeProtos will remove all uploaded proto file
|
||||
// this function will be called as the server shutdown gracefully
|
||||
func removeProtos() error {
|
||||
log.Println("removing proto dir from /tmp")
|
||||
err := os.RemoveAll(core.BasePath)
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import (
|
|||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
@ -69,6 +70,26 @@ func (h *Handler) getLists(w http.ResponseWriter, r *http.Request) {
|
|||
useTLS, _ := strconv.ParseBool(r.Header.Get("use_tls"))
|
||||
restart, _ := strconv.ParseBool(r.FormValue("restart"))
|
||||
|
||||
// treat metadata as reflection headers
|
||||
metadataHeader := r.Header.Get("Metadata")
|
||||
metadataArr := strings.Split(metadataHeader, ",")
|
||||
|
||||
// construct array of strings with "key: value" form to be used in the reflection headers
|
||||
var metadata []string
|
||||
var metadataStr string
|
||||
for i, m := range metadataArr {
|
||||
i += 1
|
||||
if isEven := i%2 == 0; isEven {
|
||||
metadataStr = metadataStr + m
|
||||
metadata = append(metadata, metadataStr)
|
||||
metadataStr = ""
|
||||
continue
|
||||
}
|
||||
metadataStr = fmt.Sprintf("%s:", m)
|
||||
}
|
||||
|
||||
h.g.SetReflectHeaders(metadata...)
|
||||
|
||||
res, err := h.g.GetResource(context.Background(), host, !useTLS, restart)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
|
@ -85,6 +106,65 @@ func (h *Handler) getLists(w http.ResponseWriter, r *http.Request) {
|
|||
response(w, result)
|
||||
}
|
||||
|
||||
// getListsWithProto handling client request for service list with proto
|
||||
func (h *Handler) getListsWithProto(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
host := vars["host"]
|
||||
if host == "" {
|
||||
writeError(w, fmt.Errorf("Invalid Host"))
|
||||
return
|
||||
}
|
||||
|
||||
service := vars["serv_name"]
|
||||
|
||||
useTLS, _ := strconv.ParseBool(r.Header.Get("use_tls"))
|
||||
restart, _ := strconv.ParseBool(r.FormValue("restart"))
|
||||
|
||||
// limit upload file to 5mb
|
||||
err := r.ParseMultipartForm(5 << 20)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// convert uploaded files to list of Proto struct
|
||||
files := r.MultipartForm.File["protos"]
|
||||
protos := make([]core.Proto, 0, len(files))
|
||||
for _, file := range files {
|
||||
fileData, err := file.Open()
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
defer fileData.Close()
|
||||
|
||||
content, err := ioutil.ReadAll(fileData)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
}
|
||||
|
||||
protos = append(protos, core.Proto{
|
||||
Name: file.Filename,
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
|
||||
res, err := h.g.GetResourceWithProto(context.Background(), host, !useTLS, restart, protos)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := res.List(service)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.g.Extend(host)
|
||||
response(w, result)
|
||||
}
|
||||
|
||||
func (h *Handler) describeFunction(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
host := vars["host"]
|
||||
|
@ -161,8 +241,26 @@ func (h *Handler) invokeFunction(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// context metadata
|
||||
metadataHeader := r.Header.Get("Metadata")
|
||||
metadataArr := strings.Split(metadataHeader, ",")
|
||||
|
||||
// construct array of string with "key: value" form to satisfy grpcurl MetadataFromHeaders
|
||||
var metadata []string
|
||||
var metadataStr string
|
||||
for i, m := range metadataArr {
|
||||
i += 1
|
||||
if isEven := i%2 == 0; isEven {
|
||||
metadataStr = metadataStr + m
|
||||
metadata = append(metadata, metadataStr)
|
||||
metadataStr = ""
|
||||
continue
|
||||
}
|
||||
metadataStr = fmt.Sprintf("%s:", m)
|
||||
}
|
||||
|
||||
// get param
|
||||
result, timer, err := res.Invoke(context.Background(), funcName, r.Body)
|
||||
result, timer, err := res.Invoke(context.Background(), metadata, funcName, r.Body)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
|
|
|
@ -14,6 +14,7 @@ func Init(router *mux.Router) {
|
|||
|
||||
ajaxRoute := router.PathPrefix("/server/{host}").Subrouter()
|
||||
ajaxRoute.HandleFunc("/services", corsHandler(h.getLists)).Methods(http.MethodGet, http.MethodOptions)
|
||||
ajaxRoute.HandleFunc("/services", corsHandler(h.getListsWithProto)).Methods(http.MethodPost)
|
||||
ajaxRoute.HandleFunc("/service/{serv_name}/functions", corsHandler(h.getLists)).Methods(http.MethodGet, http.MethodOptions)
|
||||
ajaxRoute.HandleFunc("/function/{func_name}/describe", corsHandler(h.describeFunction)).Methods(http.MethodGet, http.MethodOptions)
|
||||
ajaxRoute.HandleFunc("/function/{func_name}/invoke", corsHandler(h.invokeFunction)).Methods(http.MethodPost, http.MethodOptions)
|
||||
|
|
115
index/css/proto.css
Normal file
115
index/css/proto.css
Normal file
|
@ -0,0 +1,115 @@
|
|||
#proto-input {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
[type="file"] {
|
||||
border: 0;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute !important;
|
||||
white-space: nowrap;
|
||||
width: 1px;
|
||||
}
|
||||
[type="file"] + label {
|
||||
background-color: #59698d;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
font-family: 'Poppins', sans-serif;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
padding: 5px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
[type="file"]:focus + label,
|
||||
[type="file"] + label:hover {
|
||||
background-color: #324674;
|
||||
}
|
||||
|
||||
[type="file"]:focus + label {
|
||||
outline: 1px dotted #000;
|
||||
outline: -webkit-focus-ring-color auto 5px;
|
||||
}
|
||||
|
||||
.proto-top-collection {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.proto-toggle {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.proto-toggle:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.proto-collection {
|
||||
background-color: #e0dfe6;
|
||||
border-radius: 5px;
|
||||
min-height: 120px;
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
flex-direction: row;
|
||||
padding: 20px;
|
||||
padding-right: 0px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.proto-item {
|
||||
height: 80px;
|
||||
margin-right: 20px;
|
||||
margin-left: 10px;
|
||||
margin-bottom: 30px;
|
||||
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.proto-icon {
|
||||
height: 80px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.proto-desc {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.proto-caption {
|
||||
font-weight: 600;
|
||||
color: rgba(0,0,0,0.6);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.proto-size {
|
||||
font-size: 1rem;
|
||||
color: rgba(0,0,0,0.6);
|
||||
}
|
||||
|
||||
.proto-remove {
|
||||
background-color: rgba(0,0,0,0.5);
|
||||
padding: 3px !important;
|
||||
margin-left: 0px !important;
|
||||
-webkit-box-shadow: none;
|
||||
-moz-box-shadow: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.proto-remove:hover {
|
||||
background-color: rgba(255,0,0,0.4);
|
||||
}
|
|
@ -200,4 +200,53 @@ circle {
|
|||
transform: scaleY(1.0);
|
||||
-webkit-transform: scaleY(1.0);
|
||||
}
|
||||
}
|
||||
|
||||
.save-button-dropdown {
|
||||
padding-left: 10px;
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
.save-button-dropdown .dropdown-menu.show {
|
||||
transform: translate3d(639px, 46px, 0px);
|
||||
}
|
||||
|
||||
.one-long-line {
|
||||
margin-bottom: 0;
|
||||
margin-left: 5px;
|
||||
max-width: 100%;
|
||||
white-space:nowrap;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
}
|
||||
.one-long-line:hover {
|
||||
cursor: pointer;
|
||||
overflow:visible;
|
||||
}
|
||||
|
||||
.column-row-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.column-row-left .md-form{
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.request-list {
|
||||
display: flex;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.request-list .fa {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.request-list.selected {
|
||||
background: #dadfe3;;
|
||||
}
|
BIN
index/img/file.png
Normal file
BIN
index/img/file.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.8 KiB |
130
index/index.html
130
index/index.html
|
@ -10,24 +10,115 @@
|
|||
<link href="css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="css/mdb.min.css" rel="stylesheet">
|
||||
<link href="css/style.css" rel="stylesheet">
|
||||
<link href="css/proto.css" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container pt-50">
|
||||
<div class="row animated fadeIn">
|
||||
<div class="col">
|
||||
<div class="container-fluid pt-50">
|
||||
<div class="row animated fadeIn justify-content-md-center">
|
||||
<div class="col-3">
|
||||
<div class="row">
|
||||
<div class="col" style="padding-left: 70px;">
|
||||
<div class="row column-row-left">
|
||||
<div class="md-form input-group">
|
||||
<input type="text" class="form-control search" id="search-request" onkeyup="search(this)">
|
||||
<label for="search-request" class="">Search Request</label>
|
||||
</div>
|
||||
<ul class="list-group list-group-flush list" id="request-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-7">
|
||||
<div class="md-form input-group">
|
||||
<input type="text" class="form-control" id="server-target">
|
||||
<label for="server-target">gRPC Server Target</label>
|
||||
<div class="input-group-append">
|
||||
<button id="get-services" class="btn btn-mdb-color waves-effect m-0" type="button"><i class="fa fa-plug"></i></button>
|
||||
<div class="dropdown">
|
||||
<button type="button" class="btn btn-mdb-color waves-effect m-0 dropdown-toggle save-button-dropdown" data-toggle="dropdown"
|
||||
aria-haspopup="true" aria-expanded="true">
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="btnGroupDrop1">
|
||||
<a class="dropdown-item" id="show-modal-save-request">Save</a>
|
||||
<a class="dropdown-item" id="show-modal-save-as-request">Save As</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="custom-control custom-checkbox">
|
||||
<input type="checkbox" class="custom-control-input" id="use-tls">
|
||||
<label class="custom-control-label" for="use-tls">Use TLS</label>
|
||||
</div>
|
||||
|
||||
<div class="custom-control custom-checkbox">
|
||||
<input type="checkbox" class="custom-control-input" id="restart-conn">
|
||||
<label class="custom-control-label" for="restart-conn">Restart Connection</label>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<div class="custom-control custom-checkbox">
|
||||
<input type="checkbox" class="custom-control-input" id="local-proto">
|
||||
<label class="custom-control-label" for="local-proto">Use local proto</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-group" id="proto-input" style="display: none">
|
||||
<div class="proto-top-collection">
|
||||
<input class="proto-uploader" type="file" id="proto-file" multiple>
|
||||
<label for="proto-file"><i class="fa fa-plus-circle"></i> proto files</label>
|
||||
|
||||
<span id="proto-collection-toggle" class="proto-toggle">Hide Proto Collection</span>
|
||||
</div>
|
||||
|
||||
<div class="proto-collection"></div>
|
||||
</div>
|
||||
|
||||
<!-- Context metadata -->
|
||||
<div class="input-group">
|
||||
<div class="custom-control custom-checkbox">
|
||||
<input type="checkbox" class="custom-control-input" id="ctx-metadata-switch">
|
||||
<label class="custom-control-label" for="ctx-metadata-switch">Use request metadata</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-group" id="ctx-metadata-input" style="display: none">
|
||||
<br>
|
||||
<div id="ctx-metadata-table" class="table-editable">
|
||||
<table class="table table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-start" style="width: 10%"></th>
|
||||
<th class="text-start" style="width: 20%">Key</th>
|
||||
<th class="text-start" style="width: 70%">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<span class="table-remove">
|
||||
<button type="button" class="btn btn-danger btn-rounded btn-sm my-0">
|
||||
<i class="fa fa-times"></i>
|
||||
</button>
|
||||
</span>
|
||||
</td>
|
||||
<td class="ctx-metadata-input-field pt-3-half" contenteditable="true"></td>
|
||||
<td class="ctx-metadata-input-field pt-3-half" contenteditable="true"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="input-group-append">
|
||||
<span class="table-add">
|
||||
<button type="button" class="btn btn-success btn-rounded btn-sm my-0">
|
||||
<i class="fa fa-plus"></i>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Context metadata -->
|
||||
|
||||
<div class="other-elem" id="choose-service" style="display: none">
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
|
@ -111,13 +202,42 @@
|
|||
<div class="rect5"></div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Save request-->
|
||||
<div class="modal fade" id="saveRequest" tabindex="-1" role="dialog" aria-labelledby="exampleModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="exampleModalLabel">Input the name for the request</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form>
|
||||
<div class="form-group row">
|
||||
<label for="input-request-name" class="col-sm-2 col-form-label">Name</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" id="input-request-name">
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
|
||||
<button id="save-request" type="button" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript" src="js/jquery-3.3.1.min.js"></script>
|
||||
<script type="text/javascript" src="js/popper.min.js"></script>
|
||||
<script type="text/javascript" src="js/bootstrap.min.js"></script>
|
||||
<script type="text/javascript" src="js/mdb.min.js"></script>
|
||||
<script src="https://cdn.rawgit.com/google/code-prettify/master/loader/run_prettify.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/gh/google/code-prettify@master/loader/run_prettify.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.4.1/ace.js"></script>
|
||||
<script type="text/javascript" src="js/db.js"></script>
|
||||
<script type="text/javascript" src="js/style.js"></script>
|
||||
<script type="text/javascript" src="js/proto.js"></script>
|
||||
<script type="text/javascript" src="js/ctx.metadata.js"></script>
|
||||
<script type="text/javascript" src="js/request.list.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
|
64
index/js/ctx.metadata.js
Normal file
64
index/js/ctx.metadata.js
Normal file
|
@ -0,0 +1,64 @@
|
|||
// table input for context metadata
|
||||
const ctxMetadataTable = $('#ctx-metadata-table');
|
||||
|
||||
// new row for each context being added
|
||||
const newTr = `
|
||||
<tr>
|
||||
<td>
|
||||
<span class="table-remove">
|
||||
<button type="button" class="btn btn-danger btn-rounded btn-sm my-0">
|
||||
<i class="fa fa-times"></i>
|
||||
</button>
|
||||
</span>
|
||||
</td>
|
||||
<td class="ctx-metadata-input-field pt-3-half" contenteditable="true"></td>
|
||||
<td class="ctx-metadata-input-field pt-3-half" contenteditable="true"></td>
|
||||
</tr>`;
|
||||
|
||||
// helper variable to contains all of the context metadata input
|
||||
let ctxArr = [];
|
||||
|
||||
// helper variable to contain the usage of metadata
|
||||
let ctxUse = false;
|
||||
|
||||
// ctx metadata event listener
|
||||
(function () {
|
||||
// add event listener on ctx metadata checkbox
|
||||
const ctxMetadataSwitch = document.getElementById("ctx-metadata-switch");
|
||||
ctxMetadataSwitch.addEventListener("change", function(event) {
|
||||
const { checked } = event.target;
|
||||
ctxUse = checked;
|
||||
toggleDisplayCtxMetadataTable(checked);
|
||||
});
|
||||
|
||||
// remove for each row in ctx metadata table
|
||||
ctxMetadataTable.on('click', '.table-remove', function () {
|
||||
$(this).parents('tr').detach();
|
||||
});
|
||||
|
||||
// add new row
|
||||
ctxMetadataTable.on('click', '.table-add', () => {
|
||||
$('tbody').append(newTr);
|
||||
});
|
||||
|
||||
// only allow any paste action with plain text
|
||||
ctxMetadataTable.on('paste', '.ctx-metadata-input-field', function (e) {
|
||||
// cancel paste
|
||||
e.preventDefault();
|
||||
// get text representation of clipboard
|
||||
const text = (e.originalEvent || e).clipboardData.getData('text/plain');
|
||||
// insert text manually
|
||||
document.execCommand("insertHTML", false, text);
|
||||
});
|
||||
|
||||
}());
|
||||
|
||||
// toggle ctx metadata display
|
||||
// will show the key-value pairs table input
|
||||
function toggleDisplayCtxMetadataTable(show) {
|
||||
const style = show ? "display: block" : "display: none";
|
||||
|
||||
const protoInput = document.getElementById("ctx-metadata-input");
|
||||
protoInput.removeAttribute("style");
|
||||
protoInput.style.cssText = style;
|
||||
}
|
143
index/js/db.js
Normal file
143
index/js/db.js
Normal file
|
@ -0,0 +1,143 @@
|
|||
const objectStoreRequests = "requests";
|
||||
let dbConn = null;
|
||||
|
||||
function gdb(){
|
||||
if (!window.indexedDB) {
|
||||
alert(`Your browser doesn't support IndexedDB`)
|
||||
return;
|
||||
}
|
||||
return new Promise(function (success, error){
|
||||
if (dbConn !== null){
|
||||
success(dbConn)
|
||||
return
|
||||
}
|
||||
|
||||
const request = indexedDB.open('grpcox',1);
|
||||
request.onerror = (event) => {
|
||||
error(request.error)
|
||||
};
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
// add implementation here
|
||||
console.log("success open DB")
|
||||
dbConn = event.target.result;
|
||||
success(dbConn);
|
||||
};
|
||||
// create the Contacts object store and indexes
|
||||
request.onupgradeneeded = (event) => {
|
||||
let dbConn = event.target.result;
|
||||
let store = dbConn.createObjectStore(objectStoreRequests,{
|
||||
keyPath:"name"
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
function getRequest(name) {
|
||||
return new Promise(async function (success, error){
|
||||
let db = await gdb()
|
||||
const txn = db.transaction(objectStoreRequests, 'readwrite');
|
||||
const store = txn.objectStore(objectStoreRequests);
|
||||
let idbRequest = store.get(name);
|
||||
idbRequest.onerror = function (param) {
|
||||
error(idbRequest.error.name)
|
||||
}
|
||||
idbRequest.onsuccess = function (event) {
|
||||
success(event.target.result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getAllRequestKey() {
|
||||
return new Promise(async function (success, error){
|
||||
let db = await gdb();
|
||||
const txn = db.transaction(objectStoreRequests, 'readwrite');
|
||||
// get the Contacts object store
|
||||
const store = txn.objectStore(objectStoreRequests);
|
||||
let idbRequest = store.getAllKeys();
|
||||
idbRequest.onerror = function (param) {
|
||||
error(param.target)
|
||||
}
|
||||
idbRequest.onsuccess = function (event) {
|
||||
success(event.target.result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function insertRequest(request) {
|
||||
return new Promise(async function(success, error){
|
||||
let db = await gdb()
|
||||
|
||||
// create a new transaction
|
||||
const txn = db.transaction(objectStoreRequests, 'readwrite');
|
||||
|
||||
const store = txn.objectStore(objectStoreRequests);
|
||||
|
||||
let query = store.add(request);
|
||||
|
||||
// handle success case
|
||||
query.onsuccess = function (event) {
|
||||
success('success')
|
||||
};
|
||||
|
||||
// handle the error case
|
||||
query.onerror = function (event) {
|
||||
if (query.error.name === "ConstraintError") {
|
||||
error('Duplicate request name')
|
||||
return
|
||||
}
|
||||
error(query.error.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function updateRequest(request) {
|
||||
return new Promise(async function(success, error){
|
||||
let db = await gdb()
|
||||
|
||||
// create a new transaction
|
||||
const txn = db.transaction(objectStoreRequests, 'readwrite');
|
||||
|
||||
const store = txn.objectStore(objectStoreRequests);
|
||||
|
||||
let query = store.put(request);
|
||||
|
||||
// handle success case
|
||||
query.onsuccess = function (event) {
|
||||
success('success')
|
||||
};
|
||||
|
||||
// handle the error case
|
||||
query.onerror = function (event) {
|
||||
if (query.error.name === "ConstraintError") {
|
||||
error('Duplicate request name')
|
||||
return
|
||||
}
|
||||
error(query.error.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function deleteRequest(name){
|
||||
return new Promise(async function (success, error){
|
||||
let db = await gdb()
|
||||
|
||||
// create a new transaction
|
||||
const txn = db.transaction(objectStoreRequests, 'readwrite');
|
||||
|
||||
const store = txn.objectStore(objectStoreRequests);
|
||||
|
||||
let query = store.delete(name);
|
||||
|
||||
// handle success case
|
||||
query.onsuccess = function (event) {
|
||||
success('success')
|
||||
};
|
||||
|
||||
// handle the error case
|
||||
query.onerror = function (event) {
|
||||
error(query.error.name)
|
||||
}
|
||||
})
|
||||
}
|
165
index/js/proto.js
Normal file
165
index/js/proto.js
Normal file
|
@ -0,0 +1,165 @@
|
|||
let protoMap;
|
||||
let showCollection;
|
||||
|
||||
// IIFE to setup event listener
|
||||
(function () {
|
||||
// add event listener on proto checkbox
|
||||
// if checked the uploader and proto collection view will be displayed
|
||||
// also the grpc reflection will use given proto instead of server reflection
|
||||
const protoSwitch = document.getElementById("local-proto");
|
||||
protoSwitch.addEventListener("change", function(event) {
|
||||
const { checked } = event.target;
|
||||
toggleDisplayProtoInput(checked);
|
||||
});
|
||||
|
||||
// add event listener on upload proto file
|
||||
// uploaded file extension will be checked, only those with *.proto will be processed
|
||||
// add successful file to protoMap to be displayed
|
||||
const protoUploader = document.getElementById("proto-file");
|
||||
protoUploader.addEventListener("change", handleProtoUpload, false);
|
||||
|
||||
// add event listener on toggle proto collection display
|
||||
// it will show / hide the proto collection
|
||||
const protoCollectionToggle = document.getElementById("proto-collection-toggle");
|
||||
protoCollectionToggle.addEventListener("click", toggleDisplayProtoCollection);
|
||||
|
||||
// init map to handle proto files
|
||||
// every proto files will be unique to their name
|
||||
protoMap = new Map();
|
||||
|
||||
// set proto collection display status to true
|
||||
// by default the collection will be shown
|
||||
showCollection = true;
|
||||
}());
|
||||
|
||||
// toggle proto files display
|
||||
// displaying upload button
|
||||
function toggleDisplayProtoInput(show) {
|
||||
const style = show ? "display: block" : "display: none";
|
||||
|
||||
const protoInput = document.getElementById("proto-input");
|
||||
protoInput.removeAttribute("style");
|
||||
protoInput.style.cssText = style;
|
||||
}
|
||||
|
||||
// toggle proto files collection
|
||||
// displaying uploaded protos collection
|
||||
function toggleDisplayProtoCollection() {
|
||||
const protoCollection = document.getElementsByClassName("proto-collection")[0];
|
||||
protoCollection.removeAttribute("style");
|
||||
const protoToggle = document.getElementById("proto-collection-toggle");
|
||||
|
||||
let collectionStyle = "";
|
||||
let toggleText = protoToggle.innerHTML;
|
||||
|
||||
if (showCollection) {
|
||||
collectionStyle = "display: none";
|
||||
toggleText = toggleText.replace("Hide", "Show");
|
||||
} else {
|
||||
collectionStyle = "display: block";
|
||||
toggleText = toggleText.replace("Show", "Hide");
|
||||
}
|
||||
|
||||
protoCollection.style.cssText = collectionStyle;
|
||||
protoToggle.innerHTML = toggleText;
|
||||
showCollection = !showCollection;
|
||||
}
|
||||
|
||||
// handling file upload event
|
||||
// add uploaded files to protoMap to avoid duplication, on file with same name the older
|
||||
// file will be replaced with the latest
|
||||
// if the file isn't available before, add DOM element for UI representation
|
||||
// file without *.proto won't be processed
|
||||
function handleProtoUpload() {
|
||||
const files = this.files;
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.name.endsWith(".proto") && !file.name.endsWith(".protoset")) {
|
||||
continue;
|
||||
}
|
||||
if (!protoMap.has(file.name)) {
|
||||
addProtoItem(file.name, file.size);
|
||||
}
|
||||
protoMap.set(file.name, file);
|
||||
}
|
||||
}
|
||||
|
||||
// adding proto item to proto collection view
|
||||
// give visual representation to user and access to remove unwanted uploaded files
|
||||
function addProtoItem(name, size) {
|
||||
const protoItem = createProtoItem(name, size);
|
||||
const collection = document.getElementsByClassName("proto-collection")[0];
|
||||
collection.appendChild(protoItem);
|
||||
}
|
||||
|
||||
// create dom element for proto item
|
||||
// every item will be given id corresponding to it's name for easier access
|
||||
function createProtoItem(name, size) {
|
||||
const item = document.createElement("div");
|
||||
item.classList.add("proto-item");
|
||||
item.id = name;
|
||||
|
||||
const icon = document.createElement("img");
|
||||
icon.src = "img/file.png";
|
||||
icon.alt = "file icon";
|
||||
icon.classList.add("proto-icon");
|
||||
item.appendChild(icon);
|
||||
|
||||
const desc = createProtoItemDesc(name, size);
|
||||
item.appendChild(desc);
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
// create dom element for proto item description
|
||||
function createProtoItemDesc(name, size) {
|
||||
const desc = document.createElement("div");
|
||||
desc.classList.add("proto-desc");
|
||||
|
||||
const caption = document.createElement("span");
|
||||
caption.classList.add("proto-caption");
|
||||
const captionText = document.createTextNode(name.length > 15 ?
|
||||
name.substring(0, 12) + "..." :
|
||||
name);
|
||||
caption.appendChild(captionText);
|
||||
desc.appendChild(caption);
|
||||
|
||||
const sizeDOM = document.createElement("span");
|
||||
sizeDOM.classList.add("proto-size");
|
||||
const sizeText = document.createTextNode(size > 1000 ?
|
||||
`${size/1000}kb` :
|
||||
`${size}b`);
|
||||
sizeDOM.appendChild(sizeText);
|
||||
desc.appendChild(sizeDOM);
|
||||
|
||||
const remove = document.createElement("button");
|
||||
remove.classList.add("btn", "btn-sm", "proto-remove");
|
||||
remove.addEventListener("click", function() {removeProtoItem(name);});
|
||||
const removeIcon = document.createElement("i");
|
||||
removeIcon.classList.add("fa", "fa-trash");
|
||||
remove.appendChild(removeIcon);
|
||||
const removeText = document.createTextNode(" remove");
|
||||
remove.appendChild(removeText);
|
||||
desc.appendChild(remove);
|
||||
|
||||
return desc;
|
||||
}
|
||||
|
||||
// remove proto item based on it's ID
|
||||
function removeProtoItem(name) {
|
||||
const item = document.getElementById(name);
|
||||
item.parentNode.removeChild(item);
|
||||
protoMap.delete(name);
|
||||
}
|
||||
|
||||
// fetch all proto from protoMap
|
||||
// compose it into formData to make it easier to send via ajax
|
||||
function getProtos() {
|
||||
const formData = new FormData();
|
||||
|
||||
for (const proto of protoMap.values()) {
|
||||
formData.append("protos", proto, proto.name);
|
||||
}
|
||||
|
||||
return formData;
|
||||
}
|
162
index/js/request.list.js
Normal file
162
index/js/request.list.js
Normal file
|
@ -0,0 +1,162 @@
|
|||
$('#save-request').click(function(){
|
||||
let requestName = document.getElementById("input-request-name").value;
|
||||
if (requestName === "") {
|
||||
alert("request name is require")
|
||||
} else {
|
||||
let data = getReqResData();
|
||||
data.name = requestName
|
||||
insertRequest(data).then(success => {
|
||||
window.location.reload()
|
||||
}).catch(error => {
|
||||
alert(error);
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
$('#show-modal-save-request').click(function () {
|
||||
const reqData = getReqResData();
|
||||
const activeRequestName = getActiveRequestListName();
|
||||
if ( activeRequestName === ""){
|
||||
console.log(activeRequestName);
|
||||
// generate name
|
||||
// name format will be method
|
||||
$('#input-request-name').val(`${reqData.selected_function}`)
|
||||
$('#saveRequest').modal('toggle');
|
||||
} else {
|
||||
reqData.name = activeRequestName
|
||||
updateRequest(reqData).catch(error => {
|
||||
alert(error);
|
||||
})
|
||||
}
|
||||
});
|
||||
$('#show-modal-save-as-request').click(function () {
|
||||
const reqData = getReqResData();
|
||||
// generate name
|
||||
// name format will be method
|
||||
$('#input-request-name').val(`copy ${reqData.selected_function}`)
|
||||
$('#saveRequest').modal('toggle');
|
||||
});
|
||||
|
||||
function getReqResData() {
|
||||
const serverTarget = document.getElementById("server-target").value;
|
||||
const selectService = document.getElementById("select-service").value;
|
||||
const selectFunction = document.getElementById("select-function").value;
|
||||
const responseHTML = document.getElementById("json-response").innerHTML;
|
||||
const schemaProtoHTML = document.getElementById("schema-proto").innerHTML;
|
||||
editor = ace.edit("editor");
|
||||
return {
|
||||
server_target:serverTarget,
|
||||
selected_service:selectService,
|
||||
selected_function:selectFunction,
|
||||
raw_request:editor.getValue(),
|
||||
response_html:responseHTML,
|
||||
schema_proto_html:schemaProtoHTML,
|
||||
}
|
||||
}
|
||||
|
||||
function setReqResData(data) {
|
||||
$('#server-target').val(data.server_target);
|
||||
target = data.server_target;
|
||||
$("#select-service").html(new Option(data.selected_service, data.selected_service,true,true));
|
||||
$('#choose-service').show();
|
||||
$("#select-function").html(new Option(data.selected_function.substr(data.selected_service.length), data.selected_function,true,true));
|
||||
$('#choose-function').show();
|
||||
generate_editor(data.raw_request);
|
||||
$('#body-request').show();
|
||||
$('#schema-proto').html(data.schema_proto_html);
|
||||
$('#json-response').html(data.response_html);
|
||||
$('#response').show();
|
||||
}
|
||||
|
||||
function resetReqResData() {
|
||||
target="";
|
||||
$('#choose-service').hide();
|
||||
$('#choose-function').hide();
|
||||
$('#body-request').hide();
|
||||
$('#response').hide();
|
||||
}
|
||||
|
||||
async function renderRequestList() {
|
||||
const ul = document.getElementById("request-list")
|
||||
ul.innerHTML = ""
|
||||
|
||||
const nameList = await getAllRequestKey();
|
||||
|
||||
nameList.forEach(function (item){
|
||||
let node = document.createElement("li")
|
||||
node.classList.add("list-group-item","request-list")
|
||||
node.setAttribute("request-name",item)
|
||||
node.addEventListener("click", function(el){
|
||||
updateRequestView(el.target.children[1])
|
||||
});
|
||||
node.innerHTML = `
|
||||
<a title="Delete this request" class="delete-request" onclick="removeRequest(this)"><i class="fa fa-times"></i></a>
|
||||
<p class="one-long-line request" onclick="updateRequestView(this)">${item}</p>
|
||||
`
|
||||
ul.appendChild(node);
|
||||
})
|
||||
}
|
||||
|
||||
function removeRequestSelectedClass(){
|
||||
const elems = document.querySelectorAll(".request-list");
|
||||
[].forEach.call(elems, function(el) {
|
||||
el.classList.remove("selected");
|
||||
});
|
||||
}
|
||||
|
||||
function getActiveRequestListName(){
|
||||
const elems = document.querySelectorAll(".request-list");
|
||||
for (let i = 0; i < elems.length; i++) {
|
||||
const e = elems[i]
|
||||
if (e.classList.contains("selected")) {
|
||||
return e.innerText;
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
function setServerTargetActive() {
|
||||
const elems = document.querySelectorAll('[for="server-target"]');
|
||||
[].forEach.call(elems, function(el) {
|
||||
el.classList.add("active");
|
||||
});
|
||||
}
|
||||
|
||||
function updateRequestView(elm) {
|
||||
if (elm) {
|
||||
getRequest(elm.innerText).then(data => {
|
||||
resetReqResData()
|
||||
setReqResData(data)
|
||||
removeRequestSelectedClass()
|
||||
elm.parentElement.classList.add('selected')
|
||||
setServerTargetActive();
|
||||
}).catch(error => {
|
||||
alert(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function removeRequest(elm) {
|
||||
const requestName = elm.parentElement.lastElementChild.innerText;
|
||||
deleteRequest(requestName).then(()=>{
|
||||
window.location.reload()
|
||||
}).catch((error)=>{
|
||||
alert(error)
|
||||
})
|
||||
}
|
||||
|
||||
function search(elm) {
|
||||
const li = document.querySelectorAll(".request-list")
|
||||
li.forEach(function (el) {
|
||||
if (el.getAttribute("request-name").toLowerCase().includes(elm.value.toLowerCase())){
|
||||
el.style.display = ""
|
||||
}else{
|
||||
el.style.display = "none"
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
$(document).ready(function(){
|
||||
renderRequestList()
|
||||
});
|
|
@ -1,6 +1,11 @@
|
|||
var target, use_tls, editor;
|
||||
|
||||
$('#get-services').click(function(){
|
||||
|
||||
// reset all selected list
|
||||
resetReqResData()
|
||||
removeRequestSelectedClass()
|
||||
|
||||
var t = get_valid_target();
|
||||
|
||||
use_tls = "false";
|
||||
|
@ -8,16 +13,28 @@ $('#get-services').click(function(){
|
|||
if($('#restart-conn').is(":checked")) {
|
||||
restart = "1"
|
||||
}
|
||||
if($('#use-tls').is(":checked")) {
|
||||
use_tls = "true"
|
||||
}
|
||||
|
||||
if (target != t || restart == "1") {
|
||||
// use metadata if there is any
|
||||
ctxArr = [];
|
||||
$(".ctx-metadata-input-field").each(function(index, val){
|
||||
ctxArr.push($(val).text())
|
||||
});
|
||||
|
||||
// determine whether the proto connection will use local proto or not
|
||||
const use_proto = $('#local-proto').is(":checked");
|
||||
|
||||
if (target != t || restart == "1" || use_proto) {
|
||||
target = t;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
$('.other-elem').hide();
|
||||
var button = $(this).html();
|
||||
$.ajax({
|
||||
// prepare ajax options beforehand
|
||||
// makes it easier for local proto to modify some of its properties
|
||||
const ajaxProps = {
|
||||
url: "server/"+target+"/services?restart="+restart,
|
||||
global: true,
|
||||
method: "GET",
|
||||
|
@ -40,6 +57,9 @@ $('#get-services').click(function(){
|
|||
beforeSend: function(xhr){
|
||||
$('#choose-service').hide();
|
||||
xhr.setRequestHeader('use_tls', use_tls);
|
||||
if(ctxUse) {
|
||||
xhr.setRequestHeader('Metadata', ctxArr);
|
||||
}
|
||||
$(this).html("Loading...");
|
||||
show_loading();
|
||||
},
|
||||
|
@ -48,7 +68,21 @@ $('#get-services').click(function(){
|
|||
$(this).html(button);
|
||||
hide_loading();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// modify ajax options if use local proto
|
||||
if (use_proto) {
|
||||
ajaxProps.method = "POST";
|
||||
ajaxProps.enctype = "multipart/form-data";
|
||||
ajaxProps.processData = false;
|
||||
ajaxProps.contentType = false;
|
||||
ajaxProps.cache = false;
|
||||
ajaxProps.data = getProtos();
|
||||
}
|
||||
|
||||
$('.other-elem').hide();
|
||||
var button = $(this).html();
|
||||
$.ajax(ajaxProps);
|
||||
});
|
||||
|
||||
$('#select-service').change(function(){
|
||||
|
@ -118,6 +152,13 @@ $('#select-function').change(function(){
|
|||
});
|
||||
|
||||
$('#invoke-func').click(function(){
|
||||
|
||||
// use metadata if there is any
|
||||
ctxArr = [];
|
||||
$(".ctx-metadata-input-field").each(function(index, val){
|
||||
ctxArr.push($(val).text())
|
||||
});
|
||||
|
||||
var func = $('#select-function').val();
|
||||
if (func == "") {
|
||||
return false;
|
||||
|
@ -143,6 +184,9 @@ $('#invoke-func').click(function(){
|
|||
beforeSend: function(xhr){
|
||||
$('#response').hide();
|
||||
xhr.setRequestHeader('use_tls', use_tls);
|
||||
if(ctxUse) {
|
||||
xhr.setRequestHeader('Metadata', ctxArr);
|
||||
}
|
||||
$(this).html("Loading...");
|
||||
show_loading();
|
||||
},
|
||||
|
|
Loading…
Reference in New Issue
Block a user