1
0
mirror of https://github.com/gusaul/grpcox.git synced 2025-04-28 01:55:36 +00:00

Compare commits

..

No commits in common. "master" and "v1.0.0" have entirely different histories.

18 changed files with 57 additions and 1319 deletions

5
.gitignore vendored
View File

@ -1,5 +1,2 @@
grpcox
log
*.out
vendor/
.idea
log

View File

@ -9,15 +9,6 @@ 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
@ -31,7 +22,6 @@ 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
@ -39,7 +29,6 @@ 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.

View File

@ -121,9 +121,7 @@ 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) {

View File

@ -3,14 +3,16 @@ 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
@ -32,14 +34,6 @@ 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
@ -77,37 +71,21 @@ func (g *GrpCox) GetResource(ctx context.Context, target string, plainText, isRe
var err error
r := new(Resource)
h := append(g.headers, g.reflectHeaders...)
r.md = grpcurl.MetadataFromHeaders(h)
md := grpcurl.MetadataFromHeaders(h)
refCtx := metadata.NewOutgoingContext(ctx, md)
r.clientConn, err = g.dial(ctx, target, plainText)
if err != nil {
return nil, err
}
// what is r.Headers used for?
r.refClient = grpcreflect.NewClient(refCtx, reflectpb.NewServerReflectionClient(r.clientConn))
r.descSource = grpcurl.DescriptorSourceFromServer(ctx, r.refClient)
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()
@ -138,11 +116,6 @@ 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)

View File

@ -5,13 +5,8 @@ import (
"context"
"fmt"
"io"
"io/ioutil"
"log"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"github.com/fullstorydev/grpcurl"
@ -19,88 +14,21 @@ 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 == "" {
@ -137,11 +65,6 @@ 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
@ -195,28 +118,28 @@ func (r *Resource) Describe(symbol string) (string, string, error) {
}
// Invoke - invoking gRPC function
func (r *Resource) Invoke(ctx context.Context, metadata []string, symbol string, in io.Reader) (string, time.Duration, error) {
err := r.openDescriptor()
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()
if err != nil {
return "", 0, err
}
defer r.closeDescriptor()
var resultBuffer bytes.Buffer
os.Stdout = w
rf, formatter, err := grpcurl.RequestParserAndFormatterFor(grpcurl.Format("json"), r.descSource, false, true, in)
if err != nil {
return "", 0, err
}
h := grpcurl.NewDefaultEventHandler(&resultBuffer, r.descSource, formatter, false)
var headers []string
if len(metadata) != 0 {
headers = metadata
}
h := grpcurl.NewDefaultEventHandler(os.Stdout, r.descSource, formatter, false)
start := time.Now()
err = grpcurl.InvokeRPC(ctx, r.descSource, r.clientConn, symbol, headers, h, rf.Next)
err = grpcurl.InvokeRPC(ctx, r.descSource, r.clientConn, symbol, r.headers, h, rf.Next)
end := time.Now().Sub(start) / time.Millisecond
if err != nil {
return "", end, err
@ -226,42 +149,40 @@ func (r *Resource) Invoke(ctx context.Context, metadata []string, symbol string,
return "", end, fmt.Errorf(h.Status.Message())
}
return resultBuffer.String(), end, nil
// 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
}
// Close - to close all resources that was opened before
func (r *Resource) Close() {
var wg sync.WaitGroup
wg.Add(1)
done := make(chan int)
go func() {
defer wg.Done()
if r.refClient != nil {
r.refClient.Reset()
r.refClient = nil
}
if r.clientConn != nil {
r.clientConn.Close()
r.clientConn = nil
}
}()
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()
done <- 1
}()
select {
case <-c:
case <-done:
return
case <-time.After(3 * time.Second):
log.Printf("Connection %s failed to close\n", r.clientConn.Target())
log.Printf("Connection %s falied to close\n", r.clientConn.Target())
return
}
}
@ -275,71 +196,3 @@ 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)
}

View File

@ -1,70 +0,0 @@
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))
}
})
}
}

View File

@ -1,16 +1,13 @@
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"
)
@ -25,54 +22,16 @@ func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
// start app
addr := "0.0.0.0:6969"
if value, ok := os.LookupEnv("BIND_ADDR"); ok {
addr = value
}
port := ":6969"
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", 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
fmt.Println("Service started on", port)
log.Fatal(srv.ListenAndServe())
}

View File

@ -4,7 +4,6 @@ import (
"bytes"
"context"
"fmt"
"io/ioutil"
"net/http"
"strconv"
"strings"
@ -70,26 +69,6 @@ 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)
@ -106,65 +85,6 @@ 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"]
@ -241,26 +161,8 @@ 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(), metadata, funcName, r.Body)
result, timer, err := res.Invoke(context.Background(), funcName, r.Body)
if err != nil {
writeError(w, err)
return

View File

@ -14,7 +14,6 @@ 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)

View File

@ -1,115 +0,0 @@
#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);
}

View File

@ -200,53 +200,4 @@ 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;;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -10,115 +10,24 @@
<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-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="container pt-50">
<div class="row animated fadeIn">
<div class="col">
<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">
@ -202,42 +111,13 @@
<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.jsdelivr.net/gh/google/code-prettify@master/loader/run_prettify.js"></script>
<script src="https://cdn.rawgit.com/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>

View File

@ -1,64 +0,0 @@
// 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;
}

View File

@ -1,143 +0,0 @@
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)
}
})
}

View File

@ -1,165 +0,0 @@
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;
}

View File

@ -1,162 +0,0 @@
$('#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()
});

View File

@ -1,11 +1,6 @@
var target, use_tls, editor;
$('#get-services').click(function(){
// reset all selected list
resetReqResData()
removeRequestSelectedClass()
var t = get_valid_target();
use_tls = "false";
@ -13,28 +8,16 @@ $('#get-services').click(function(){
if($('#restart-conn').is(":checked")) {
restart = "1"
}
if($('#use-tls').is(":checked")) {
use_tls = "true"
}
// 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) {
if (target != t || restart == "1") {
target = t;
} else {
return false;
}
// prepare ajax options beforehand
// makes it easier for local proto to modify some of its properties
const ajaxProps = {
$('.other-elem').hide();
var button = $(this).html();
$.ajax({
url: "server/"+target+"/services?restart="+restart,
global: true,
method: "GET",
@ -57,9 +40,6 @@ $('#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();
},
@ -68,21 +48,7 @@ $('#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(){
@ -152,13 +118,6 @@ $('#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;
@ -184,9 +143,6 @@ $('#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();
},