Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
665f79d26c | ||
|
|
427a08029a | ||
|
|
df7ef6d8f0 | ||
|
|
b9b646bb11 | ||
|
|
ae62f8ea3d | ||
|
|
0939632399 | ||
|
|
4b5a2b96a8 | ||
|
|
79bf430dee | ||
|
|
62a168d44f | ||
|
|
502c8dae91 | ||
|
|
afdfc54802 | ||
|
|
651b65a882 | ||
|
|
c2290acae3 | ||
|
|
e1276afd71 | ||
|
|
dea0fb8b78 | ||
|
|
faebfb3e62 | ||
|
|
6fd7b7d427 | ||
|
|
385b415676 | ||
|
|
50f0f5f6dc | ||
|
|
8889847c71 | ||
|
|
d26bb75ebc | ||
|
|
a65cca44e5 | ||
|
|
18b0260d1f | ||
|
|
c9d4ef5e89 | ||
|
|
c0f3f027ae | ||
|
|
0bd5360315 | ||
|
|
29b46ba59c | ||
|
|
04fe0023b7 | ||
|
|
cb3c3e0e2e | ||
|
|
0ac9d72deb | ||
|
|
dd2200bdb8 | ||
|
|
ca894c621f | ||
|
|
4224b3d326 | ||
|
|
891f45c1ca | ||
|
|
595f51d76c | ||
|
|
50cdf99913 | ||
|
|
e9f2f8eb83 | ||
|
|
73d2aa8fe1 | ||
|
|
579eaf9141 | ||
|
|
c513930e27 | ||
|
|
7a75447364 | ||
|
|
ec6bb5590d | ||
|
|
637e7a4e24 | ||
|
|
08a70baf3f | ||
|
|
bc43ca6f0f |
@@ -6,7 +6,6 @@ addons:
|
|||||||
go:
|
go:
|
||||||
- 1.4.1
|
- 1.4.1
|
||||||
- 1.4.2
|
- 1.4.2
|
||||||
- tip
|
|
||||||
|
|
||||||
install:
|
install:
|
||||||
- make setup
|
- make setup
|
||||||
|
|||||||
+16
-1
@@ -1,4 +1,19 @@
|
|||||||
## 0.5.2 - Unreleased
|
## 0.6.0 - 2015-05-31
|
||||||
|
|
||||||
|
- Adds ability to execute only selected SQL query in run command view, GH-85
|
||||||
|
- Adds ability to delete/truncate table via context meny on sidebar view
|
||||||
|
- Adds ability to export table contents to CSV via context menu on sidebar view
|
||||||
|
- Changes sidebar color scheme to a lighter and better looking one
|
||||||
|
|
||||||
|
## 0.5.3 - 2015-05-06
|
||||||
|
|
||||||
|
- Changes default server port from 8080 to 8081 to avoil conflict with RethinkDB
|
||||||
|
- Changes styles for table rows and connection settings window
|
||||||
|
- Adds highlighting styles for columns with sort order
|
||||||
|
- Adds git sha into program version output
|
||||||
|
- Add new endpoint /api/info to get build details
|
||||||
|
|
||||||
|
## 0.5.2 - 2015-04-13
|
||||||
|
|
||||||
- Adds a new endpoint /activity that retuns active queries
|
- Adds a new endpoint /activity that retuns active queries
|
||||||
- Adds tab to view active queries
|
- Adds tab to view active queries
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
- Fork repository
|
||||||
|
- Create a new git branch
|
||||||
|
- Make changes
|
||||||
|
- Run tests: `make test`
|
||||||
|
- If you change frontend code (js/css) make sure to rebuild assets: `make assets`
|
||||||
|
- Open a PR
|
||||||
+4
-3
@@ -1,10 +1,11 @@
|
|||||||
FROM golang:1.4.2
|
FROM golang:1.4.2
|
||||||
|
|
||||||
COPY . /go/src/pgweb
|
COPY . /go/src/github.com/sosedoff/pgweb
|
||||||
WORKDIR /go/src/pgweb
|
WORKDIR /go/src/github.com/sosedoff/pgweb
|
||||||
|
|
||||||
RUN go get github.com/tools/godep
|
RUN go get github.com/tools/godep
|
||||||
|
|
||||||
RUN godep restore
|
RUN godep restore
|
||||||
RUN godep go build && godep go install
|
RUN godep go build && godep go install
|
||||||
|
|
||||||
CMD ["pgweb"]
|
CMD ["pgweb", "--bind", "0.0.0.0"]
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
TARGETS = darwin/amd64 darwin/386 linux/amd64 linux/386 windows/amd64 windows/386
|
||||||
|
GIT_COMMIT = $(shell git rev-parse HEAD)
|
||||||
|
BUILD_TIME = $(shell date -u +"%Y-%m-%dT%H:%M:%SZ" | tr -d '\n')
|
||||||
DOCKER_RELEASE_TAG = "sosedoff/pgweb:$(shell git describe --abbrev=0 --tags | sed 's/v//')"
|
DOCKER_RELEASE_TAG = "sosedoff/pgweb:$(shell git describe --abbrev=0 --tags | sed 's/v//')"
|
||||||
BINDATA_IGNORE = $(shell git ls-files -io --exclude-standard $< | sed 's/^/-ignore=/;s/[.]/[.]/g')
|
BINDATA_IGNORE = $(shell git ls-files -io --exclude-standard $< | sed 's/^/-ignore=/;s/[.]/[.]/g')
|
||||||
|
|
||||||
@@ -11,6 +14,7 @@ usage:
|
|||||||
@echo "make build : Generate production build for current OS"
|
@echo "make build : Generate production build for current OS"
|
||||||
@echo "make bootstrap : Install cross-compilation toolchain"
|
@echo "make bootstrap : Install cross-compilation toolchain"
|
||||||
@echo "make release : Generate binaries for all supported OSes"
|
@echo "make release : Generate binaries for all supported OSes"
|
||||||
|
@echo "make test : Execute test suite"
|
||||||
@echo "make clean : Remove all build files and reset assets"
|
@echo "make clean : Remove all build files and reset assets"
|
||||||
@echo "make assets : Generate production assets file"
|
@echo "make assets : Generate production assets file"
|
||||||
@echo "make dev-assets : Generate development assets file"
|
@echo "make dev-assets : Generate development assets file"
|
||||||
@@ -19,10 +23,10 @@ usage:
|
|||||||
@echo ""
|
@echo ""
|
||||||
|
|
||||||
test:
|
test:
|
||||||
godep go test -cover
|
godep go test -cover ./...
|
||||||
|
|
||||||
assets: static/
|
assets: static/
|
||||||
go-bindata $(BINDATA_OPTS) $(BINDATA_IGNORE) -ignore=[.]gitignore -ignore=[.]gitkeep $<...
|
go-bindata -o pkg/data/bindata.go -pkg data $(BINDATA_OPTS) $(BINDATA_IGNORE) -ignore=[.]gitignore -ignore=[.]gitkeep $<...
|
||||||
|
|
||||||
dev-assets:
|
dev-assets:
|
||||||
@$(MAKE) --no-print-directory assets BINDATA_OPTS="-debug"
|
@$(MAKE) --no-print-directory assets BINDATA_OPTS="-debug"
|
||||||
@@ -36,7 +40,14 @@ build: assets
|
|||||||
@echo "You can now execute ./pgweb"
|
@echo "You can now execute ./pgweb"
|
||||||
|
|
||||||
release: assets
|
release: assets
|
||||||
gox -osarch="darwin/amd64 darwin/386 linux/amd64 linux/386 windows/amd64 windows/386" -output="./bin/pgweb_{{.OS}}_{{.Arch}}"
|
@echo "Building binaries..."
|
||||||
|
@gox \
|
||||||
|
-osarch="$(TARGETS)" \
|
||||||
|
-ldflags "-X github.com/sosedoff/pgweb/pkg/command.GitCommit $(GIT_COMMIT) -X github.com/sosedoff/pgweb/pkg/command.BuildTime $(BUILD_TIME)" \
|
||||||
|
-output="./bin/pgweb_{{.OS}}_{{.Arch}}"
|
||||||
|
|
||||||
|
@echo "\nPackaging binaries...\n"
|
||||||
|
@./script/package.sh
|
||||||
|
|
||||||
bootstrap:
|
bootstrap:
|
||||||
gox -build-toolchain
|
gox -build-toolchain
|
||||||
@@ -50,7 +61,7 @@ setup:
|
|||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f ./pgweb
|
rm -f ./pgweb
|
||||||
rm -f ./bin/*
|
rm -rf ./bin/*
|
||||||
rm -f bindata.go
|
rm -f bindata.go
|
||||||
make assets
|
make assets
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
Web-based PostgreSQL database browser written in Go.
|
Web-based PostgreSQL database browser written in Go.
|
||||||
|
|
||||||
[](https://github.com/sosedoff/pgweb/releases)
|
[](https://github.com/sosedoff/pgweb/releases)
|
||||||
|
[](https://travis-ci.org/sosedoff/pgweb)
|
||||||
|
[](https://ci.appveyor.com/project/sosedoff/pgweb)
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -12,8 +14,7 @@ is to utilize ability of the compiler to produce zero-dependency binaries for
|
|||||||
multiple platforms. Pgweb was created as an attempt to build very simple and portable
|
multiple platforms. Pgweb was created as an attempt to build very simple and portable
|
||||||
application to work with local or remote PostgreSQL databases.
|
application to work with local or remote PostgreSQL databases.
|
||||||
|
|
||||||
<img src="screenshots/browse.png" width="345px" />
|
[See application screenshots](SCREENS.md)
|
||||||
<img src="screenshots/query.png" width="345px" />
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -21,10 +22,10 @@ application to work with local or remote PostgreSQL databases.
|
|||||||
- Zero dependencies
|
- Zero dependencies
|
||||||
- Simple installation (distributes as a single binary)
|
- Simple installation (distributes as a single binary)
|
||||||
- Connect to local or remote servers
|
- Connect to local or remote servers
|
||||||
- Browse tables and table data
|
- Browse tables and table rows
|
||||||
- Get table details: structure, size, indeces, row count
|
- Get table details: structure, size, indeces, row count
|
||||||
- Run / analyze custom queries
|
- Run and analyze custom SQL queries
|
||||||
- Export query results to CSV
|
- Export table rows and query results as CSV
|
||||||
- Query history
|
- Query history
|
||||||
- Server bookmarks
|
- Server bookmarks
|
||||||
|
|
||||||
@@ -63,23 +64,25 @@ pgweb --url postgres://user:password@host:port/database?sslmode=[mode]
|
|||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Run tests:
|
Before running tests, make sure you have PostgreSQL server running on `localhost:5432`
|
||||||
|
interface. Also, you must have `postgres` user that could create new databases
|
||||||
|
in your local environment. Pgweb server should not be running at the same time.
|
||||||
|
|
||||||
|
Execute test suite:
|
||||||
|
|
||||||
```
|
```
|
||||||
make test
|
make test
|
||||||
```
|
```
|
||||||
|
|
||||||
Continuous integration status:
|
|
||||||
|
|
||||||
- Travis (Linux) - [](https://travis-ci.org/sosedoff/pgweb)
|
|
||||||
- Appveyor (Windows) - [](https://ci.appveyor.com/project/sosedoff/pgweb)
|
|
||||||
|
|
||||||
## Contribute
|
## Contribute
|
||||||
|
|
||||||
- Fork repository
|
- Fork this repository
|
||||||
- Create a feature or bugfix branch
|
- Create a new feature branch for a new functionality or bugfix
|
||||||
- Open a new pull request
|
- Commit your changes
|
||||||
- Use [github issues](https://github.com/sosedoff/pgweb/issues) for any questions
|
- Execute test suite
|
||||||
|
- Push your code and open a new pull request
|
||||||
|
- Use [issues](https://github.com/sosedoff/pgweb/issues) for any questions
|
||||||
|
- Check [wiki](https://github.com/sosedoff/pgweb/wiki) for extra documentation
|
||||||
|
|
||||||
## Contact
|
## Contact
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Screenshots
|
||||||
|
|
||||||
|
### Browse table rows
|
||||||
|
<img src="screenshots/browse.png" />
|
||||||
|
|
||||||
|
### Write SQL queries
|
||||||
|
<img src="screenshots/query.png" />
|
||||||
@@ -1,355 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"mime"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
var extraMimeTypes = map[string]string{
|
|
||||||
".icon": "image-x-icon",
|
|
||||||
".ttf": "application/x-font-ttf",
|
|
||||||
".woff": "application/x-font-woff",
|
|
||||||
".eot": "application/vnd.ms-fontobject",
|
|
||||||
".svg": "image/svg+xml",
|
|
||||||
}
|
|
||||||
|
|
||||||
type Error struct {
|
|
||||||
Message string `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewError(err error) Error {
|
|
||||||
return Error{err.Error()}
|
|
||||||
}
|
|
||||||
|
|
||||||
func assetContentType(name string) string {
|
|
||||||
ext := filepath.Ext(name)
|
|
||||||
result := mime.TypeByExtension(ext)
|
|
||||||
|
|
||||||
if result == "" {
|
|
||||||
result = extraMimeTypes[ext]
|
|
||||||
}
|
|
||||||
|
|
||||||
if result == "" {
|
|
||||||
result = "text/plain; charset=utf-8"
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupRoutes(router *gin.Engine) {
|
|
||||||
router.GET("/", API_Home)
|
|
||||||
router.GET("/static/*path", API_ServeAsset)
|
|
||||||
|
|
||||||
api := router.Group("/api")
|
|
||||||
{
|
|
||||||
api.Use(ApiMiddleware())
|
|
||||||
|
|
||||||
api.POST("/connect", API_Connect)
|
|
||||||
api.GET("/databases", API_GetDatabases)
|
|
||||||
api.GET("/connection", API_ConnectionInfo)
|
|
||||||
api.GET("/activity", API_Activity)
|
|
||||||
api.GET("/schemas", API_GetSchemas)
|
|
||||||
api.GET("/tables", API_GetTables)
|
|
||||||
api.GET("/tables/:table", API_GetTable)
|
|
||||||
api.GET("/tables/:table/rows", API_GetTableRows)
|
|
||||||
api.GET("/tables/:table/info", API_GetTableInfo)
|
|
||||||
api.GET("/tables/:table/indexes", API_TableIndexes)
|
|
||||||
api.GET("/query", API_RunQuery)
|
|
||||||
api.POST("/query", API_RunQuery)
|
|
||||||
api.GET("/explain", API_ExplainQuery)
|
|
||||||
api.POST("/explain", API_ExplainQuery)
|
|
||||||
api.GET("/history", API_History)
|
|
||||||
api.GET("/bookmarks", API_Bookmarks)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Middleware function to check database connection status before running queries
|
|
||||||
func ApiMiddleware() gin.HandlerFunc {
|
|
||||||
allowedPaths := []string{
|
|
||||||
"/api/connect",
|
|
||||||
"/api/bookmarks",
|
|
||||||
"/api/history",
|
|
||||||
}
|
|
||||||
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if dbClient != nil {
|
|
||||||
c.Next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
currentPath := c.Request.URL.Path
|
|
||||||
allowed := false
|
|
||||||
|
|
||||||
for _, path := range allowedPaths {
|
|
||||||
if path == currentPath {
|
|
||||||
allowed = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if allowed {
|
|
||||||
c.Next()
|
|
||||||
} else {
|
|
||||||
c.JSON(400, Error{"Not connected"})
|
|
||||||
c.Abort()
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func API_Home(c *gin.Context) {
|
|
||||||
data, err := Asset("static/index.html")
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.String(400, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Data(200, "text/html; charset=utf-8", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func API_Connect(c *gin.Context) {
|
|
||||||
url := c.Request.FormValue("url")
|
|
||||||
|
|
||||||
if url == "" {
|
|
||||||
c.JSON(400, Error{"Url parameter is required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
opts := Options{Url: url}
|
|
||||||
url, err := formatConnectionUrl(opts)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(400, Error{err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := NewClientFromUrl(url)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(400, Error{err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = client.Test()
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(400, Error{err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := client.Info()
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
if dbClient != nil {
|
|
||||||
dbClient.db.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
dbClient = client
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(200, info.Format()[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
func API_GetDatabases(c *gin.Context) {
|
|
||||||
names, err := dbClient.Databases()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(400, NewError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(200, names)
|
|
||||||
}
|
|
||||||
|
|
||||||
func API_RunQuery(c *gin.Context) {
|
|
||||||
query := strings.TrimSpace(c.Request.FormValue("query"))
|
|
||||||
|
|
||||||
if query == "" {
|
|
||||||
c.JSON(400, errors.New("Query parameter is missing"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
API_HandleQuery(query, c)
|
|
||||||
}
|
|
||||||
|
|
||||||
func API_ExplainQuery(c *gin.Context) {
|
|
||||||
query := strings.TrimSpace(c.Request.FormValue("query"))
|
|
||||||
|
|
||||||
if query == "" {
|
|
||||||
c.JSON(400, errors.New("Query parameter is missing"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
API_HandleQuery(fmt.Sprintf("EXPLAIN ANALYZE %s", query), c)
|
|
||||||
}
|
|
||||||
|
|
||||||
func API_GetSchemas(c *gin.Context) {
|
|
||||||
names, err := dbClient.Schemas()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(400, NewError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(200, names)
|
|
||||||
}
|
|
||||||
|
|
||||||
func API_GetTables(c *gin.Context) {
|
|
||||||
names, err := dbClient.Tables()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(400, NewError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(200, names)
|
|
||||||
}
|
|
||||||
|
|
||||||
func API_GetTable(c *gin.Context) {
|
|
||||||
res, err := dbClient.Table(c.Params.ByName("table"))
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(400, NewError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(200, res)
|
|
||||||
}
|
|
||||||
|
|
||||||
func API_GetTableRows(c *gin.Context) {
|
|
||||||
limit := 1000 // Number of rows to fetch
|
|
||||||
limitVal := c.Request.FormValue("limit")
|
|
||||||
|
|
||||||
if limitVal != "" {
|
|
||||||
num, err := strconv.Atoi(limitVal)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(400, Error{"Invalid limit value"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if num <= 0 {
|
|
||||||
c.JSON(400, Error{"Limit should be greater than 0"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
limit = num
|
|
||||||
}
|
|
||||||
|
|
||||||
opts := RowsOptions{
|
|
||||||
Limit: limit,
|
|
||||||
SortColumn: c.Request.FormValue("sort_column"),
|
|
||||||
SortOrder: c.Request.FormValue("sort_order"),
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := dbClient.TableRows(c.Params.ByName("table"), opts)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(400, NewError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(200, res)
|
|
||||||
}
|
|
||||||
|
|
||||||
func API_GetTableInfo(c *gin.Context) {
|
|
||||||
res, err := dbClient.TableInfo(c.Params.ByName("table"))
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(400, NewError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(200, res.Format()[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
func API_History(c *gin.Context) {
|
|
||||||
c.JSON(200, dbClient.history)
|
|
||||||
}
|
|
||||||
|
|
||||||
func API_ConnectionInfo(c *gin.Context) {
|
|
||||||
res, err := dbClient.Info()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(400, NewError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(200, res.Format()[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
func API_Activity(c *gin.Context) {
|
|
||||||
res, err := dbClient.Activity()
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(400, NewError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(200, res)
|
|
||||||
}
|
|
||||||
|
|
||||||
func API_TableIndexes(c *gin.Context) {
|
|
||||||
res, err := dbClient.TableIndexes(c.Params.ByName("table"))
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(400, NewError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(200, res)
|
|
||||||
}
|
|
||||||
|
|
||||||
func API_HandleQuery(query string, c *gin.Context) {
|
|
||||||
result, err := dbClient.Query(query)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(400, NewError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
q := c.Request.URL.Query()
|
|
||||||
|
|
||||||
if len(q["format"]) > 0 && q["format"][0] == "csv" {
|
|
||||||
filename := fmt.Sprintf("pgweb-%v.csv", time.Now().Unix())
|
|
||||||
c.Writer.Header().Set("Content-disposition", "attachment;filename="+filename)
|
|
||||||
c.Data(200, "text/csv", result.CSV())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(200, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func API_Bookmarks(c *gin.Context) {
|
|
||||||
bookmarks, err := readAllBookmarks(bookmarksPath())
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(400, NewError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(200, bookmarks)
|
|
||||||
}
|
|
||||||
|
|
||||||
func API_ServeAsset(c *gin.Context) {
|
|
||||||
path := "static" + c.Params.ByName("path")
|
|
||||||
data, err := Asset(path)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.String(400, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(data) == 0 {
|
|
||||||
c.String(404, "Asset is empty")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Data(200, assetContentType(path), data)
|
|
||||||
}
|
|
||||||
+4
-2
@@ -1,5 +1,7 @@
|
|||||||
|
clone_folder: c:\go\src\github.com\sosedoff\pgweb
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
GOPATH: c:\projects
|
GOPATH: c:\go
|
||||||
|
|
||||||
services:
|
services:
|
||||||
- postgresql
|
- postgresql
|
||||||
@@ -18,6 +20,6 @@ build_script:
|
|||||||
- godep go build
|
- godep go build
|
||||||
|
|
||||||
test_script:
|
test_script:
|
||||||
- godep go test
|
- godep go test -cover ./...
|
||||||
|
|
||||||
deploy: off
|
deploy: off
|
||||||
-21
@@ -1,21 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type HistoryRecord struct {
|
|
||||||
Query string `json:"query"`
|
|
||||||
Timestamp string `json:"timestamp"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewHistory() []HistoryRecord {
|
|
||||||
return make([]HistoryRecord, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewHistoryRecord(query string) HistoryRecord {
|
|
||||||
return HistoryRecord{
|
|
||||||
Query: query,
|
|
||||||
Timestamp: time.Now().String(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,31 +7,14 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/jessevdk/go-flags"
|
"github.com/sosedoff/pgweb/pkg/api"
|
||||||
_ "github.com/lib/pq"
|
"github.com/sosedoff/pgweb/pkg/client"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/command"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/connection"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
const VERSION = "0.5.2"
|
var options command.Options
|
||||||
|
|
||||||
type Options struct {
|
|
||||||
Version bool `short:"v" long:"version" description:"Print version"`
|
|
||||||
Debug bool `short:"d" long:"debug" description:"Enable debugging mode" default:"false"`
|
|
||||||
Url string `long:"url" description:"Database connection string"`
|
|
||||||
Host string `long:"host" description:"Server hostname or IP"`
|
|
||||||
Port int `long:"port" description:"Server port" default:"5432"`
|
|
||||||
User string `long:"user" description:"Database user"`
|
|
||||||
Pass string `long:"pass" description:"Password for user"`
|
|
||||||
DbName string `long:"db" description:"Database name"`
|
|
||||||
Ssl string `long:"ssl" description:"SSL option"`
|
|
||||||
HttpHost string `long:"bind" description:"HTTP server host" default:"localhost"`
|
|
||||||
HttpPort uint `long:"listen" description:"HTTP server listen port" default:"8080"`
|
|
||||||
AuthUser string `long:"auth-user" description:"HTTP basic auth user"`
|
|
||||||
AuthPass string `long:"auth-pass" description:"HTTP basic auth password"`
|
|
||||||
SkipOpen bool `short:"s" long:"skip-open" description:"Skip browser open on start"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var dbClient *Client
|
|
||||||
var options Options
|
|
||||||
|
|
||||||
func exitWithMessage(message string) {
|
func exitWithMessage(message string) {
|
||||||
fmt.Println("Error:", message)
|
fmt.Println("Error:", message)
|
||||||
@@ -39,49 +22,57 @@ func exitWithMessage(message string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func initClient() {
|
func initClient() {
|
||||||
if connectionSettingsBlank(options) {
|
if connection.IsBlank(command.Opts) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := NewClient()
|
cl, err := client.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exitWithMessage(err.Error())
|
exitWithMessage(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
if options.Debug {
|
if command.Opts.Debug {
|
||||||
fmt.Println("Server connection string:", client.connectionString)
|
fmt.Println("Server connection string:", cl.ConnectionString)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Connecting to server...")
|
fmt.Println("Connecting to server...")
|
||||||
err = client.Test()
|
err = cl.Test()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exitWithMessage(err.Error())
|
exitWithMessage(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Checking tables...")
|
fmt.Println("Checking tables...")
|
||||||
_, err = client.Tables()
|
_, err = cl.Tables()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
exitWithMessage(err.Error())
|
exitWithMessage(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
dbClient = client
|
api.DbClient = cl
|
||||||
}
|
}
|
||||||
|
|
||||||
func initOptions() {
|
func initOptions() {
|
||||||
_, err := flags.ParseArgs(&options, os.Args)
|
err := command.ParseOptions()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if options.Url == "" {
|
options = command.Opts
|
||||||
options.Url = os.Getenv("DATABASE_URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
if options.Version {
|
if options.Version {
|
||||||
fmt.Printf("pgweb v%s\n", VERSION)
|
printVersion()
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
printVersion()
|
||||||
|
}
|
||||||
|
|
||||||
|
func printVersion() {
|
||||||
|
str := fmt.Sprintf("Pgweb v%s", command.VERSION)
|
||||||
|
if command.GitCommit != "" {
|
||||||
|
str += fmt.Sprintf(" (git: %s)", command.GitCommit)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(str)
|
||||||
}
|
}
|
||||||
|
|
||||||
func startServer() {
|
func startServer() {
|
||||||
@@ -93,7 +84,7 @@ func startServer() {
|
|||||||
router.Use(gin.BasicAuth(auth))
|
router.Use(gin.BasicAuth(auth))
|
||||||
}
|
}
|
||||||
|
|
||||||
setupRoutes(router)
|
api.SetupRoutes(router)
|
||||||
|
|
||||||
fmt.Println("Starting server...")
|
fmt.Println("Starting server...")
|
||||||
go func() {
|
go func() {
|
||||||
@@ -129,20 +120,19 @@ func openPage() {
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
initOptions()
|
initOptions()
|
||||||
|
|
||||||
fmt.Println("Pgweb version", VERSION)
|
|
||||||
initClient()
|
initClient()
|
||||||
|
|
||||||
if dbClient != nil {
|
if api.DbClient != nil {
|
||||||
defer dbClient.db.Close()
|
defer api.DbClient.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
if !options.Debug {
|
if !options.Debug {
|
||||||
gin.SetMode("release")
|
gin.SetMode("release")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Print memory usage every 30 seconds with debug flag
|
||||||
if options.Debug {
|
if options.Debug {
|
||||||
startRuntimeProfiler()
|
util.StartProfiler()
|
||||||
}
|
}
|
||||||
|
|
||||||
startServer()
|
startServer()
|
||||||
|
|||||||
+213
@@ -0,0 +1,213 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/bookmarks"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/client"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/command"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/connection"
|
||||||
|
)
|
||||||
|
|
||||||
|
var DbClient *client.Client
|
||||||
|
|
||||||
|
func GetHome(c *gin.Context) {
|
||||||
|
serveStaticAsset("/index.html", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAsset(c *gin.Context) {
|
||||||
|
serveStaticAsset(c.Params.ByName("path"), c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Connect(c *gin.Context) {
|
||||||
|
url := c.Request.FormValue("url")
|
||||||
|
|
||||||
|
if url == "" {
|
||||||
|
c.JSON(400, Error{"Url parameter is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := command.Options{Url: url}
|
||||||
|
url, err := connection.FormatUrl(opts)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(400, Error{err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cl, err := client.NewFromUrl(url)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(400, Error{err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cl.Test()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(400, Error{err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := cl.Info()
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
if DbClient != nil {
|
||||||
|
DbClient.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
DbClient = cl
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(200, info.Format()[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetDatabases(c *gin.Context) {
|
||||||
|
names, err := DbClient.Databases()
|
||||||
|
serveResult(names, err, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RunQuery(c *gin.Context) {
|
||||||
|
query := strings.TrimSpace(c.Request.FormValue("query"))
|
||||||
|
|
||||||
|
if query == "" {
|
||||||
|
c.JSON(400, errors.New("Query parameter is missing"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
HandleQuery(query, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExplainQuery(c *gin.Context) {
|
||||||
|
query := strings.TrimSpace(c.Request.FormValue("query"))
|
||||||
|
|
||||||
|
if query == "" {
|
||||||
|
c.JSON(400, errors.New("Query parameter is missing"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
HandleQuery(fmt.Sprintf("EXPLAIN ANALYZE %s", query), c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSchemas(c *gin.Context) {
|
||||||
|
names, err := DbClient.Schemas()
|
||||||
|
serveResult(names, err, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetTables(c *gin.Context) {
|
||||||
|
names, err := DbClient.Tables()
|
||||||
|
serveResult(names, err, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetTable(c *gin.Context) {
|
||||||
|
res, err := DbClient.Table(c.Params.ByName("table"))
|
||||||
|
serveResult(res, err, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetTableRows(c *gin.Context) {
|
||||||
|
limit := 1000 // Number of rows to fetch
|
||||||
|
limitVal := c.Request.FormValue("limit")
|
||||||
|
|
||||||
|
if limitVal != "" {
|
||||||
|
num, err := strconv.Atoi(limitVal)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(400, Error{"Invalid limit value"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if num <= 0 {
|
||||||
|
c.JSON(400, Error{"Limit should be greater than 0"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
limit = num
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := client.RowsOptions{
|
||||||
|
Limit: limit,
|
||||||
|
SortColumn: c.Request.FormValue("sort_column"),
|
||||||
|
SortOrder: c.Request.FormValue("sort_order"),
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := DbClient.TableRows(c.Params.ByName("table"), opts)
|
||||||
|
serveResult(res, err, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetTableInfo(c *gin.Context) {
|
||||||
|
res, err := DbClient.TableInfo(c.Params.ByName("table"))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(400, NewError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(200, res.Format()[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetHistory(c *gin.Context) {
|
||||||
|
c.JSON(200, DbClient.History)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetConnectionInfo(c *gin.Context) {
|
||||||
|
res, err := DbClient.Info()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(400, NewError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(200, res.Format()[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetActivity(c *gin.Context) {
|
||||||
|
res, err := DbClient.Activity()
|
||||||
|
serveResult(res, err, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetTableIndexes(c *gin.Context) {
|
||||||
|
res, err := DbClient.TableIndexes(c.Params.ByName("table"))
|
||||||
|
serveResult(res, err, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleQuery(query string, c *gin.Context) {
|
||||||
|
result, err := DbClient.Query(query)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(400, NewError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
q := c.Request.URL.Query()
|
||||||
|
|
||||||
|
if len(q["format"]) > 0 && q["format"][0] == "csv" {
|
||||||
|
filename := fmt.Sprintf("pgweb-%v.csv", time.Now().Unix())
|
||||||
|
if len(q["filename"]) > 0 && q["filename"][0] != "" {
|
||||||
|
filename = q["filename"][0]
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Writer.Header().Set("Content-disposition", "attachment;filename="+filename)
|
||||||
|
c.Data(200, "text/csv", result.CSV())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(200, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetBookmarks(c *gin.Context) {
|
||||||
|
bookmarks, err := bookmarks.ReadAll(bookmarks.Path())
|
||||||
|
serveResult(bookmarks, err, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetInfo(c *gin.Context) {
|
||||||
|
info := map[string]string{
|
||||||
|
"version": command.VERSION,
|
||||||
|
"git_sha": command.GitCommit,
|
||||||
|
"build_time": command.BuildTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(200, info)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"mime"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/data"
|
||||||
|
)
|
||||||
|
|
||||||
|
var extraMimeTypes = map[string]string{
|
||||||
|
".icon": "image-x-icon",
|
||||||
|
".ttf": "application/x-font-ttf",
|
||||||
|
".woff": "application/x-font-woff",
|
||||||
|
".eot": "application/vnd.ms-fontobject",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".html": "text/html; charset-utf-8",
|
||||||
|
}
|
||||||
|
|
||||||
|
type Error struct {
|
||||||
|
Message string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func assetContentType(name string) string {
|
||||||
|
ext := filepath.Ext(name)
|
||||||
|
result := mime.TypeByExtension(ext)
|
||||||
|
|
||||||
|
if result == "" {
|
||||||
|
result = extraMimeTypes[ext]
|
||||||
|
}
|
||||||
|
|
||||||
|
if result == "" {
|
||||||
|
result = "text/plain; charset=utf-8"
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewError(err error) Error {
|
||||||
|
return Error{err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware function to check database connection status before running queries
|
||||||
|
func dbCheckMiddleware() gin.HandlerFunc {
|
||||||
|
allowedPaths := []string{
|
||||||
|
"/api/info",
|
||||||
|
"/api/connect",
|
||||||
|
"/api/bookmarks",
|
||||||
|
"/api/history",
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if DbClient != nil {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPath := c.Request.URL.Path
|
||||||
|
allowed := false
|
||||||
|
|
||||||
|
for _, path := range allowedPaths {
|
||||||
|
if path == currentPath {
|
||||||
|
allowed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if allowed {
|
||||||
|
c.Next()
|
||||||
|
} else {
|
||||||
|
c.JSON(400, Error{"Not connected"})
|
||||||
|
c.Abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveStaticAsset(path string, c *gin.Context) {
|
||||||
|
data, err := data.Asset("static" + path)
|
||||||
|
if err != nil {
|
||||||
|
c.String(400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Data(200, assetContentType(path), data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveResult(result interface{}, err error, c *gin.Context) {
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(400, NewError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(200, result)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SetupRoutes(router *gin.Engine) {
|
||||||
|
router.GET("/", GetHome)
|
||||||
|
router.GET("/static/*path", GetAsset)
|
||||||
|
|
||||||
|
api := router.Group("/api")
|
||||||
|
{
|
||||||
|
api.Use(dbCheckMiddleware())
|
||||||
|
|
||||||
|
api.GET("/info", GetInfo)
|
||||||
|
api.POST("/connect", Connect)
|
||||||
|
api.GET("/databases", GetDatabases)
|
||||||
|
api.GET("/connection", GetConnectionInfo)
|
||||||
|
api.GET("/activity", GetActivity)
|
||||||
|
api.GET("/schemas", GetSchemas)
|
||||||
|
api.GET("/tables", GetTables)
|
||||||
|
api.GET("/tables/:table", GetTable)
|
||||||
|
api.GET("/tables/:table/rows", GetTableRows)
|
||||||
|
api.GET("/tables/:table/info", GetTableInfo)
|
||||||
|
api.GET("/tables/:table/indexes", GetTableIndexes)
|
||||||
|
api.GET("/query", RunQuery)
|
||||||
|
api.POST("/query", RunQuery)
|
||||||
|
api.GET("/explain", ExplainQuery)
|
||||||
|
api.POST("/explain", ExplainQuery)
|
||||||
|
api.GET("/history", GetHistory)
|
||||||
|
api.GET("/bookmarks", GetBookmarks)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package bookmarks
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -37,12 +37,12 @@ func fileBasename(path string) string {
|
|||||||
return strings.Replace(filename, filepath.Ext(path), "", 1)
|
return strings.Replace(filename, filepath.Ext(path), "", 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func bookmarksPath() string {
|
func Path() string {
|
||||||
path, _ := homedir.Dir()
|
path, _ := homedir.Dir()
|
||||||
return fmt.Sprintf("%s/.pgweb/bookmarks", path)
|
return fmt.Sprintf("%s/.pgweb/bookmarks", path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func readAllBookmarks(path string) (map[string]Bookmark, error) {
|
func ReadAll(path string) (map[string]Bookmark, error) {
|
||||||
results := map[string]Bookmark{}
|
results := map[string]Bookmark{}
|
||||||
|
|
||||||
files, err := ioutil.ReadDir(path)
|
files, err := ioutil.ReadDir(path)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package bookmarks
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
@@ -10,17 +10,17 @@ func Test_Invalid_Bookmark_Files(t *testing.T) {
|
|||||||
_, err := readServerConfig("foobar")
|
_, err := readServerConfig("foobar")
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
|
|
||||||
_, err = readServerConfig("./data/invalid.toml")
|
_, err = readServerConfig("../../data/invalid.toml")
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Equal(t, "Near line 1, key 'invalid encoding': Near line 2: Expected key separator '=', but got '\\n' instead.", err.Error())
|
assert.Equal(t, "Near line 1, key 'invalid encoding': Near line 2: Expected key separator '=', but got '\\n' instead.", err.Error())
|
||||||
|
|
||||||
_, err = readServerConfig("./data/invalid_port.toml")
|
_, err = readServerConfig("../../data/invalid_port.toml")
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Equal(t, "Type mismatch for 'main.Bookmark.Port': Expected string but found 'int64'.", err.Error())
|
assert.Equal(t, "Type mismatch for 'bookmarks.Bookmark.Port': Expected string but found 'int64'.", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_Bookmark(t *testing.T) {
|
func Test_Bookmark(t *testing.T) {
|
||||||
bookmark, err := readServerConfig("./data/bookmark.toml")
|
bookmark, err := readServerConfig("../../data/bookmark.toml")
|
||||||
|
|
||||||
assert.Equal(t, nil, err)
|
assert.Equal(t, nil, err)
|
||||||
assert.Equal(t, "localhost", bookmark.Host)
|
assert.Equal(t, "localhost", bookmark.Host)
|
||||||
@@ -33,7 +33,7 @@ func Test_Bookmark(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Test_Bookmark_URL(t *testing.T) {
|
func Test_Bookmark_URL(t *testing.T) {
|
||||||
bookmark, err := readServerConfig("./data/bookmark_url.toml")
|
bookmark, err := readServerConfig("../../data/bookmark_url.toml")
|
||||||
|
|
||||||
assert.Equal(t, nil, err)
|
assert.Equal(t, nil, err)
|
||||||
assert.Equal(t, "postgres://username:password@host:port/database?sslmode=disable", bookmark.Url)
|
assert.Equal(t, "postgres://username:password@host:port/database?sslmode=disable", bookmark.Url)
|
||||||
@@ -46,7 +46,7 @@ func Test_Bookmark_URL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Test_Bookmarks_Path(t *testing.T) {
|
func Test_Bookmarks_Path(t *testing.T) {
|
||||||
assert.NotEqual(t, "/.pgweb/bookmarks", bookmarksPath())
|
assert.NotEqual(t, "/.pgweb/bookmarks", Path())
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_Basename(t *testing.T) {
|
func Test_Basename(t *testing.T) {
|
||||||
@@ -57,14 +57,14 @@ func Test_Basename(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Test_ReadBookmarks_Invalid(t *testing.T) {
|
func Test_ReadBookmarks_Invalid(t *testing.T) {
|
||||||
bookmarks, err := readAllBookmarks("foobar")
|
bookmarks, err := ReadAll("foobar")
|
||||||
|
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Equal(t, 0, len(bookmarks))
|
assert.Equal(t, 0, len(bookmarks))
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_ReadBookmarks(t *testing.T) {
|
func Test_ReadBookmarks(t *testing.T) {
|
||||||
bookmarks, err := readAllBookmarks("./data")
|
bookmarks, err := ReadAll("../../data")
|
||||||
|
|
||||||
assert.Equal(t, nil, err)
|
assert.Equal(t, nil, err)
|
||||||
assert.Equal(t, 2, len(bookmarks))
|
assert.Equal(t, 2, len(bookmarks))
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -6,13 +6,19 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/command"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/connection"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/history"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/statements"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
db *sqlx.DB
|
db *sqlx.DB
|
||||||
history []HistoryRecord
|
History []history.Record
|
||||||
connectionString string
|
ConnectionString string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Row []interface{}
|
type Row []interface{}
|
||||||
@@ -29,10 +35,10 @@ type RowsOptions struct {
|
|||||||
SortOrder string // Sort direction (ASC, DESC)
|
SortOrder string // Sort direction (ASC, DESC)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClient() (*Client, error) {
|
func New() (*Client, error) {
|
||||||
str, err := buildConnectionString(options)
|
str, err := connection.BuildString(command.Opts)
|
||||||
|
|
||||||
if options.Debug && str != "" {
|
if command.Opts.Debug && str != "" {
|
||||||
fmt.Println("Creating a new client for:", str)
|
fmt.Println("Creating a new client for:", str)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,28 +54,27 @@ func NewClient() (*Client, error) {
|
|||||||
|
|
||||||
client := Client{
|
client := Client{
|
||||||
db: db,
|
db: db,
|
||||||
connectionString: str,
|
ConnectionString: str,
|
||||||
history: NewHistory(),
|
History: history.New(),
|
||||||
}
|
}
|
||||||
|
|
||||||
return &client, nil
|
return &client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClientFromUrl(url string) (*Client, error) {
|
func NewFromUrl(url string) (*Client, error) {
|
||||||
if options.Debug {
|
if command.Opts.Debug {
|
||||||
fmt.Println("Creating a new client for:", url)
|
fmt.Println("Creating a new client for:", url)
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := sqlx.Open("postgres", url)
|
db, err := sqlx.Open("postgres", url)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
client := Client{
|
client := Client{
|
||||||
db: db,
|
db: db,
|
||||||
connectionString: url,
|
ConnectionString: url,
|
||||||
history: NewHistory(),
|
History: history.New(),
|
||||||
}
|
}
|
||||||
|
|
||||||
return &client, nil
|
return &client, nil
|
||||||
@@ -80,23 +85,23 @@ func (client *Client) Test() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (client *Client) Info() (*Result, error) {
|
func (client *Client) Info() (*Result, error) {
|
||||||
return client.query(PG_INFO)
|
return client.query(statements.PG_INFO)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (client *Client) Databases() ([]string, error) {
|
func (client *Client) Databases() ([]string, error) {
|
||||||
return client.fetchRows(PG_DATABASES)
|
return client.fetchRows(statements.PG_DATABASES)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (client *Client) Schemas() ([]string, error) {
|
func (client *Client) Schemas() ([]string, error) {
|
||||||
return client.fetchRows(PG_SCHEMAS)
|
return client.fetchRows(statements.PG_SCHEMAS)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (client *Client) Tables() ([]string, error) {
|
func (client *Client) Tables() ([]string, error) {
|
||||||
return client.fetchRows(PG_TABLES)
|
return client.fetchRows(statements.PG_TABLES)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (client *Client) Table(table string) (*Result, error) {
|
func (client *Client) Table(table string) (*Result, error) {
|
||||||
return client.query(PG_TABLE_SCHEMA, table)
|
return client.query(statements.PG_TABLE_SCHEMA, table)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (client *Client) TableRows(table string, opts RowsOptions) (*Result, error) {
|
func (client *Client) TableRows(table string, opts RowsOptions) (*Result, error) {
|
||||||
@@ -118,11 +123,11 @@ func (client *Client) TableRows(table string, opts RowsOptions) (*Result, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (client *Client) TableInfo(table string) (*Result, error) {
|
func (client *Client) TableInfo(table string) (*Result, error) {
|
||||||
return client.query(PG_TABLE_INFO, table)
|
return client.query(statements.PG_TABLE_INFO, table)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (client *Client) TableIndexes(table string) (*Result, error) {
|
func (client *Client) TableIndexes(table string) (*Result, error) {
|
||||||
res, err := client.query(PG_TABLE_INDEXES, table)
|
res, err := client.query(statements.PG_TABLE_INDEXES, table)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -133,7 +138,7 @@ func (client *Client) TableIndexes(table string) (*Result, error) {
|
|||||||
|
|
||||||
// Returns all active queriers on the server
|
// Returns all active queriers on the server
|
||||||
func (client *Client) Activity() (*Result, error) {
|
func (client *Client) Activity() (*Result, error) {
|
||||||
return client.query(PG_ACTIVITY)
|
return client.query(statements.PG_ACTIVITY)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (client *Client) Query(query string) (*Result, error) {
|
func (client *Client) Query(query string) (*Result, error) {
|
||||||
@@ -141,7 +146,7 @@ func (client *Client) Query(query string) (*Result, error) {
|
|||||||
|
|
||||||
// Save history records only if query did not fail
|
// Save history records only if query did not fail
|
||||||
if err == nil {
|
if err == nil {
|
||||||
client.history = append(client.history, NewHistoryRecord(query))
|
client.History = append(client.History, history.NewRecord(query))
|
||||||
}
|
}
|
||||||
|
|
||||||
return res, err
|
return res, err
|
||||||
@@ -231,6 +236,14 @@ func (res *Result) CSV() []byte {
|
|||||||
return buff.Bytes()
|
return buff.Bytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close database connection
|
||||||
|
func (client *Client) Close() error {
|
||||||
|
if client.db != nil {
|
||||||
|
return client.db.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch all rows as strings for a single column
|
// Fetch all rows as strings for a single column
|
||||||
func (client *Client) fetchRows(q string) ([]string, error) {
|
func (client *Client) fetchRows(q string) ([]string, error) {
|
||||||
res, err := client.query(q)
|
res, err := client.query(q)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -10,8 +10,10 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
var testClient *Client
|
var (
|
||||||
var testCommands map[string]string
|
testClient *Client
|
||||||
|
testCommands map[string]string
|
||||||
|
)
|
||||||
|
|
||||||
func setupCommands() {
|
func setupCommands() {
|
||||||
testCommands = map[string]string{
|
testCommands = map[string]string{
|
||||||
@@ -40,7 +42,7 @@ func setup() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err = exec.Command(testCommands["psql"], "-U", "postgres", "-h", "localhost", "-f", "./data/booktown.sql", "booktown").CombinedOutput()
|
out, err = exec.Command(testCommands["psql"], "-U", "postgres", "-h", "localhost", "-f", "../../data/booktown.sql", "booktown").CombinedOutput()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("Database import failed:", string(out))
|
fmt.Println("Database import failed:", string(out))
|
||||||
@@ -50,7 +52,7 @@ func setup() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func setupClient() {
|
func setupClient() {
|
||||||
testClient, _ = NewClientFromUrl("postgres://postgres@localhost/booktown?sslmode=disable")
|
testClient, _ = NewFromUrl("postgres://postgres@localhost/booktown?sslmode=disable")
|
||||||
}
|
}
|
||||||
|
|
||||||
func teardownClient() {
|
func teardownClient() {
|
||||||
@@ -69,14 +71,14 @@ func teardown() {
|
|||||||
|
|
||||||
func test_NewClientFromUrl(t *testing.T) {
|
func test_NewClientFromUrl(t *testing.T) {
|
||||||
url := "postgres://postgres@localhost/booktown?sslmode=disable"
|
url := "postgres://postgres@localhost/booktown?sslmode=disable"
|
||||||
client, err := NewClientFromUrl(url)
|
client, err := NewFromUrl(url)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
defer client.db.Close()
|
defer client.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Equal(t, nil, err)
|
assert.Equal(t, nil, err)
|
||||||
assert.Equal(t, url, client.connectionString)
|
assert.Equal(t, url, client.ConnectionString)
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_Test(t *testing.T) {
|
func test_Test(t *testing.T) {
|
||||||
@@ -209,7 +211,7 @@ func test_ResultCsv(t *testing.T) {
|
|||||||
|
|
||||||
func test_History(t *testing.T) {
|
func test_History(t *testing.T) {
|
||||||
_, err := testClient.Query("SELECT * FROM books")
|
_, err := testClient.Query("SELECT * FROM books")
|
||||||
query := testClient.history[len(testClient.history)-1].Query
|
query := testClient.History[len(testClient.History)-1].Query
|
||||||
|
|
||||||
assert.Equal(t, nil, err)
|
assert.Equal(t, nil, err)
|
||||||
assert.Equal(t, "SELECT * FROM books", query)
|
assert.Equal(t, "SELECT * FROM books", query)
|
||||||
@@ -217,7 +219,7 @@ func test_History(t *testing.T) {
|
|||||||
|
|
||||||
func test_HistoryError(t *testing.T) {
|
func test_HistoryError(t *testing.T) {
|
||||||
_, err := testClient.Query("SELECT * FROM books123")
|
_, err := testClient.Query("SELECT * FROM books123")
|
||||||
query := testClient.history[len(testClient.history)-1].Query
|
query := testClient.History[len(testClient.History)-1].Query
|
||||||
|
|
||||||
assert.NotEqual(t, nil, err)
|
assert.NotEqual(t, nil, err)
|
||||||
assert.NotEqual(t, "SELECT * FROM books123", query)
|
assert.NotEqual(t, "SELECT * FROM books123", query)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package command
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/jessevdk/go-flags"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
Version bool `short:"v" long:"version" description:"Print version"`
|
||||||
|
Debug bool `short:"d" long:"debug" description:"Enable debugging mode" default:"false"`
|
||||||
|
Url string `long:"url" description:"Database connection string"`
|
||||||
|
Host string `long:"host" description:"Server hostname or IP"`
|
||||||
|
Port int `long:"port" description:"Server port" default:"5432"`
|
||||||
|
User string `long:"user" description:"Database user"`
|
||||||
|
Pass string `long:"pass" description:"Password for user"`
|
||||||
|
DbName string `long:"db" description:"Database name"`
|
||||||
|
Ssl string `long:"ssl" description:"SSL option"`
|
||||||
|
HttpHost string `long:"bind" description:"HTTP server host" default:"localhost"`
|
||||||
|
HttpPort uint `long:"listen" description:"HTTP server listen port" default:"8081"`
|
||||||
|
AuthUser string `long:"auth-user" description:"HTTP basic auth user"`
|
||||||
|
AuthPass string `long:"auth-pass" description:"HTTP basic auth password"`
|
||||||
|
SkipOpen bool `short:"s" long:"skip-open" description:"Skip browser open on start"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var Opts Options
|
||||||
|
|
||||||
|
func ParseOptions() error {
|
||||||
|
_, err := flags.ParseArgs(&Opts, os.Args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if Opts.Url == "" {
|
||||||
|
Opts.Url = os.Getenv("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package command
|
||||||
|
|
||||||
|
const VERSION = "0.6.1"
|
||||||
|
|
||||||
|
var (
|
||||||
|
GitCommit string
|
||||||
|
BuildTime string
|
||||||
|
)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package connection
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/user"
|
"os/user"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sosedoff/pgweb/pkg/command"
|
||||||
)
|
)
|
||||||
|
|
||||||
func currentUser() (string, error) {
|
func currentUser() (string, error) {
|
||||||
@@ -22,7 +24,7 @@ func currentUser() (string, error) {
|
|||||||
return "", errors.New("Unable to detect OS user")
|
return "", errors.New("Unable to detect OS user")
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatConnectionUrl(opts Options) (string, error) {
|
func FormatUrl(opts command.Options) (string, error) {
|
||||||
url := opts.Url
|
url := opts.Url
|
||||||
|
|
||||||
// Make sure to only accept urls in a standard format
|
// Make sure to only accept urls in a standard format
|
||||||
@@ -50,13 +52,13 @@ func formatConnectionUrl(opts Options) (string, error) {
|
|||||||
return url, nil
|
return url, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func connectionSettingsBlank(opts Options) bool {
|
func IsBlank(opts command.Options) bool {
|
||||||
return opts.Host == "" && opts.User == "" && opts.DbName == "" && opts.Url == ""
|
return opts.Host == "" && opts.User == "" && opts.DbName == "" && opts.Url == ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildConnectionString(opts Options) (string, error) {
|
func BuildString(opts command.Options) (string, error) {
|
||||||
if opts.Url != "" {
|
if opts.Url != "" {
|
||||||
return formatConnectionUrl(opts)
|
return FormatUrl(opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to detect user from current OS user
|
// Try to detect user from current OS user
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
package main
|
package connection
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/user"
|
"os/user"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sosedoff/pgweb/pkg/command"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Test_Invalid_Url(t *testing.T) {
|
func Test_Invalid_Url(t *testing.T) {
|
||||||
opts := Options{}
|
opts := command.Options{}
|
||||||
examples := []string{
|
examples := []string{
|
||||||
"postgresql://foobar",
|
"postgresql://foobar",
|
||||||
"foobar",
|
"foobar",
|
||||||
@@ -17,7 +18,7 @@ func Test_Invalid_Url(t *testing.T) {
|
|||||||
|
|
||||||
for _, val := range examples {
|
for _, val := range examples {
|
||||||
opts.Url = val
|
opts.Url = val
|
||||||
str, err := buildConnectionString(opts)
|
str, err := BuildString(opts)
|
||||||
|
|
||||||
assert.Equal(t, "", str)
|
assert.Equal(t, "", str)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
@@ -27,14 +28,14 @@ func Test_Invalid_Url(t *testing.T) {
|
|||||||
|
|
||||||
func Test_Valid_Url(t *testing.T) {
|
func Test_Valid_Url(t *testing.T) {
|
||||||
url := "postgres://myhost/database"
|
url := "postgres://myhost/database"
|
||||||
str, err := buildConnectionString(Options{Url: url})
|
str, err := BuildString(command.Options{Url: url})
|
||||||
|
|
||||||
assert.Equal(t, nil, err)
|
assert.Equal(t, nil, err)
|
||||||
assert.Equal(t, url, str)
|
assert.Equal(t, url, str)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_Url_And_Ssl_Flag(t *testing.T) {
|
func Test_Url_And_Ssl_Flag(t *testing.T) {
|
||||||
str, err := buildConnectionString(Options{
|
str, err := BuildString(command.Options{
|
||||||
Url: "postgres://myhost/database",
|
Url: "postgres://myhost/database",
|
||||||
Ssl: "disable",
|
Ssl: "disable",
|
||||||
})
|
})
|
||||||
@@ -44,14 +45,14 @@ func Test_Url_And_Ssl_Flag(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Test_Localhost_Url_And_No_Ssl_Flag(t *testing.T) {
|
func Test_Localhost_Url_And_No_Ssl_Flag(t *testing.T) {
|
||||||
str, err := buildConnectionString(Options{
|
str, err := BuildString(command.Options{
|
||||||
Url: "postgres://localhost/database",
|
Url: "postgres://localhost/database",
|
||||||
})
|
})
|
||||||
|
|
||||||
assert.Equal(t, nil, err)
|
assert.Equal(t, nil, err)
|
||||||
assert.Equal(t, "postgres://localhost/database?sslmode=disable", str)
|
assert.Equal(t, "postgres://localhost/database?sslmode=disable", str)
|
||||||
|
|
||||||
str, err = buildConnectionString(Options{
|
str, err = BuildString(command.Options{
|
||||||
Url: "postgres://127.0.0.1/database",
|
Url: "postgres://127.0.0.1/database",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@ func Test_Localhost_Url_And_No_Ssl_Flag(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Test_Localhost_Url_And_Ssl_Flag(t *testing.T) {
|
func Test_Localhost_Url_And_Ssl_Flag(t *testing.T) {
|
||||||
str, err := buildConnectionString(Options{
|
str, err := BuildString(command.Options{
|
||||||
Url: "postgres://localhost/database",
|
Url: "postgres://localhost/database",
|
||||||
Ssl: "require",
|
Ssl: "require",
|
||||||
})
|
})
|
||||||
@@ -68,7 +69,7 @@ func Test_Localhost_Url_And_Ssl_Flag(t *testing.T) {
|
|||||||
assert.Equal(t, nil, err)
|
assert.Equal(t, nil, err)
|
||||||
assert.Equal(t, "postgres://localhost/database?sslmode=require", str)
|
assert.Equal(t, "postgres://localhost/database?sslmode=require", str)
|
||||||
|
|
||||||
str, err = buildConnectionString(Options{
|
str, err = BuildString(command.Options{
|
||||||
Url: "postgres://127.0.0.1/database",
|
Url: "postgres://127.0.0.1/database",
|
||||||
Ssl: "require",
|
Ssl: "require",
|
||||||
})
|
})
|
||||||
@@ -78,14 +79,14 @@ func Test_Localhost_Url_And_Ssl_Flag(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Test_Localhost_Url_And_Ssl_Arg(t *testing.T) {
|
func Test_Localhost_Url_And_Ssl_Arg(t *testing.T) {
|
||||||
str, err := buildConnectionString(Options{
|
str, err := BuildString(command.Options{
|
||||||
Url: "postgres://localhost/database?sslmode=require",
|
Url: "postgres://localhost/database?sslmode=require",
|
||||||
})
|
})
|
||||||
|
|
||||||
assert.Equal(t, nil, err)
|
assert.Equal(t, nil, err)
|
||||||
assert.Equal(t, "postgres://localhost/database?sslmode=require", str)
|
assert.Equal(t, "postgres://localhost/database?sslmode=require", str)
|
||||||
|
|
||||||
str, err = buildConnectionString(Options{
|
str, err = BuildString(command.Options{
|
||||||
Url: "postgres://127.0.0.1/database?sslmode=require",
|
Url: "postgres://127.0.0.1/database?sslmode=require",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -94,7 +95,7 @@ func Test_Localhost_Url_And_Ssl_Arg(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Test_Flag_Args(t *testing.T) {
|
func Test_Flag_Args(t *testing.T) {
|
||||||
str, err := buildConnectionString(Options{
|
str, err := BuildString(command.Options{
|
||||||
Host: "host",
|
Host: "host",
|
||||||
Port: 5432,
|
Port: 5432,
|
||||||
User: "user",
|
User: "user",
|
||||||
@@ -107,7 +108,7 @@ func Test_Flag_Args(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Test_Localhost(t *testing.T) {
|
func Test_Localhost(t *testing.T) {
|
||||||
opts := Options{
|
opts := command.Options{
|
||||||
Host: "localhost",
|
Host: "localhost",
|
||||||
Port: 5432,
|
Port: 5432,
|
||||||
User: "user",
|
User: "user",
|
||||||
@@ -115,18 +116,18 @@ func Test_Localhost(t *testing.T) {
|
|||||||
DbName: "db",
|
DbName: "db",
|
||||||
}
|
}
|
||||||
|
|
||||||
str, err := buildConnectionString(opts)
|
str, err := BuildString(opts)
|
||||||
assert.Equal(t, nil, err)
|
assert.Equal(t, nil, err)
|
||||||
assert.Equal(t, "postgres://user:password@localhost:5432/db?sslmode=disable", str)
|
assert.Equal(t, "postgres://user:password@localhost:5432/db?sslmode=disable", str)
|
||||||
|
|
||||||
opts.Host = "127.0.0.1"
|
opts.Host = "127.0.0.1"
|
||||||
str, err = buildConnectionString(opts)
|
str, err = BuildString(opts)
|
||||||
assert.Equal(t, nil, err)
|
assert.Equal(t, nil, err)
|
||||||
assert.Equal(t, "postgres://user:password@127.0.0.1:5432/db?sslmode=disable", str)
|
assert.Equal(t, "postgres://user:password@127.0.0.1:5432/db?sslmode=disable", str)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_Localhost_And_Ssl(t *testing.T) {
|
func Test_Localhost_And_Ssl(t *testing.T) {
|
||||||
opts := Options{
|
opts := command.Options{
|
||||||
Host: "localhost",
|
Host: "localhost",
|
||||||
Port: 5432,
|
Port: 5432,
|
||||||
User: "user",
|
User: "user",
|
||||||
@@ -135,31 +136,31 @@ func Test_Localhost_And_Ssl(t *testing.T) {
|
|||||||
Ssl: "require",
|
Ssl: "require",
|
||||||
}
|
}
|
||||||
|
|
||||||
str, err := buildConnectionString(opts)
|
str, err := BuildString(opts)
|
||||||
assert.Equal(t, nil, err)
|
assert.Equal(t, nil, err)
|
||||||
assert.Equal(t, "postgres://user:password@localhost:5432/db?sslmode=require", str)
|
assert.Equal(t, "postgres://user:password@localhost:5432/db?sslmode=require", str)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_No_User(t *testing.T) {
|
func Test_No_User(t *testing.T) {
|
||||||
opts := Options{Host: "host", Port: 5432, DbName: "db"}
|
opts := command.Options{Host: "host", Port: 5432, DbName: "db"}
|
||||||
u, _ := user.Current()
|
u, _ := user.Current()
|
||||||
str, err := buildConnectionString(opts)
|
str, err := BuildString(opts)
|
||||||
|
|
||||||
assert.Equal(t, nil, err)
|
assert.Equal(t, nil, err)
|
||||||
assert.Equal(t, fmt.Sprintf("postgres://%s@host:5432/db", u.Username), str)
|
assert.Equal(t, fmt.Sprintf("postgres://%s@host:5432/db", u.Username), str)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_Port(t *testing.T) {
|
func Test_Port(t *testing.T) {
|
||||||
opts := Options{Host: "host", User: "user", Port: 5000, DbName: "db"}
|
opts := command.Options{Host: "host", User: "user", Port: 5000, DbName: "db"}
|
||||||
str, err := buildConnectionString(opts)
|
str, err := BuildString(opts)
|
||||||
|
|
||||||
assert.Equal(t, nil, err)
|
assert.Equal(t, nil, err)
|
||||||
assert.Equal(t, "postgres://user@host:5000/db", str)
|
assert.Equal(t, "postgres://user@host:5000/db", str)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_Blank(t *testing.T) {
|
func Test_Blank(t *testing.T) {
|
||||||
assert.Equal(t, true, connectionSettingsBlank(Options{}))
|
assert.Equal(t, true, IsBlank(command.Options{}))
|
||||||
assert.Equal(t, false, connectionSettingsBlank(Options{Host: "host", User: "user"}))
|
assert.Equal(t, false, IsBlank(command.Options{Host: "host", User: "user"}))
|
||||||
assert.Equal(t, false, connectionSettingsBlank(Options{Host: "host", User: "user", DbName: "db"}))
|
assert.Equal(t, false, IsBlank(command.Options{Host: "host", User: "user", DbName: "db"}))
|
||||||
assert.Equal(t, false, connectionSettingsBlank(Options{Url: "url"}))
|
assert.Equal(t, false, IsBlank(command.Options{Url: "url"}))
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package connection
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check if the TCP port available on localhost
|
||||||
|
func portAvailable(port int) bool {
|
||||||
|
conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%v", port))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if strings.Index(err.Error(), "connection refused") > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.Close()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get available TCP port on localhost by trying available ports in a range
|
||||||
|
func getAvailablePort(start int, limit int) (int, error) {
|
||||||
|
for i := start; i <= (start + limit); i++ {
|
||||||
|
if portAvailable(i) {
|
||||||
|
return i, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1, errors.New("No available port")
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package connection
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_portAvailable(t *testing.T) {
|
||||||
|
assert.Equal(t, true, portAvailable(8081))
|
||||||
|
|
||||||
|
serv, err := net.Listen("tcp", "127.0.0.1:8081")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(os.Stderr, "Unable to start test tcp listener:", err)
|
||||||
|
t.Fail()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer serv.Close()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
conn, err := serv.Accept()
|
||||||
|
if err == nil {
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
assert.Equal(t, false, portAvailable(8081))
|
||||||
|
assert.Equal(t, true, portAvailable(8082))
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_getAvailablePort(t *testing.T) {
|
||||||
|
port, err := getAvailablePort(8081, 1)
|
||||||
|
assert.Equal(t, nil, err)
|
||||||
|
assert.Equal(t, 8081, port)
|
||||||
|
|
||||||
|
serv, err := net.Listen("tcp", "127.0.0.1:8081")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(os.Stderr, "Unable to start test tcp listener:", err)
|
||||||
|
t.Fail()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer serv.Close()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
conn, err := serv.Accept()
|
||||||
|
if err == nil {
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
port, err = getAvailablePort(8081, 0)
|
||||||
|
assert.EqualError(t, err, "No available port")
|
||||||
|
assert.Equal(t, -1, port)
|
||||||
|
|
||||||
|
port, err = getAvailablePort(8081, 1)
|
||||||
|
assert.Equal(t, nil, err)
|
||||||
|
assert.Equal(t, 8082, port)
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,21 @@
|
|||||||
|
package history
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Record struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() []Record {
|
||||||
|
return make([]Record, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRecord(query string) Record {
|
||||||
|
return Record{
|
||||||
|
Query: query,
|
||||||
|
Timestamp: time.Now().String(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package statements
|
||||||
|
|
||||||
const (
|
const (
|
||||||
PG_DATABASES = `SELECT datname FROM pg_database WHERE NOT datistemplate ORDER BY datname ASC`
|
PG_DATABASES = `SELECT datname FROM pg_database WHERE NOT datistemplate ORDER BY datname ASC`
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const MEGABYTE = 1024 * 1024
|
||||||
|
|
||||||
|
func runProfiler() {
|
||||||
|
logger := log.New(os.Stdout, "", 0)
|
||||||
|
m := &runtime.MemStats{}
|
||||||
|
|
||||||
|
for {
|
||||||
|
runtime.ReadMemStats(m)
|
||||||
|
|
||||||
|
logger.Printf(
|
||||||
|
"[DEBUG] Goroutines: %v, Mem used: %v (%v mb), Mem acquired: %v (%v mb)\n",
|
||||||
|
runtime.NumGoroutine(),
|
||||||
|
m.Alloc, m.Alloc/MEGABYTE,
|
||||||
|
m.Sys, m.Sys/MEGABYTE,
|
||||||
|
)
|
||||||
|
|
||||||
|
time.Sleep(time.Second * 30)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartProfiler() {
|
||||||
|
go runProfiler()
|
||||||
|
}
|
||||||
-30
@@ -1,30 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"runtime"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const MEGABYTE = 1024 * 1024
|
|
||||||
|
|
||||||
func startRuntimeProfiler() {
|
|
||||||
go func() {
|
|
||||||
logger := log.New(os.Stdout, "", 0)
|
|
||||||
m := &runtime.MemStats{}
|
|
||||||
|
|
||||||
for {
|
|
||||||
runtime.ReadMemStats(m)
|
|
||||||
|
|
||||||
logger.Printf(
|
|
||||||
"[DEBUG] Goroutines: %v, Mem used: %v (%v mb), Mem acquired: %v (%v mb)\n",
|
|
||||||
runtime.NumGoroutine(),
|
|
||||||
m.Alloc, m.Alloc/MEGABYTE,
|
|
||||||
m.Sys, m.Sys/MEGABYTE,
|
|
||||||
)
|
|
||||||
|
|
||||||
time.Sleep(time.Second * 30)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 317 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 367 KiB |
Executable
+14
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
DIR="./bin"
|
||||||
|
rm -f $DIR/*.zip
|
||||||
|
|
||||||
|
for file in $(ls $DIR)
|
||||||
|
do
|
||||||
|
fin=$DIR/$file
|
||||||
|
fout=$DIR/$file.zip
|
||||||
|
zip -9 -q -j $fout $fin
|
||||||
|
shasum -a 256 $fout
|
||||||
|
done
|
||||||
+38
-19
@@ -73,6 +73,8 @@
|
|||||||
left: 0px;
|
left: 0px;
|
||||||
top: 0px;
|
top: 0px;
|
||||||
bottom: 0px;
|
bottom: 0px;
|
||||||
|
border-right: 1px solid #d4d4d4;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebar .title {
|
#sidebar .title {
|
||||||
@@ -83,8 +85,9 @@
|
|||||||
height: 33px;
|
height: 33px;
|
||||||
padding: 0px 8px;
|
padding: 0px 8px;
|
||||||
padding-left: 12px;
|
padding-left: 12px;
|
||||||
color: #fff;
|
color: #000;
|
||||||
background: #31373d;
|
font-weight: bold;
|
||||||
|
background: #f6f6f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebar div.tables-list {
|
#sidebar div.tables-list {
|
||||||
@@ -94,7 +97,7 @@
|
|||||||
left: 0px;
|
left: 0px;
|
||||||
bottom: 130px;
|
bottom: 130px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
background: #31373d;
|
background: #f6f6f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebar div.tables-list .wrap {
|
#sidebar div.tables-list .wrap {
|
||||||
@@ -114,17 +117,17 @@
|
|||||||
float: right;
|
float: right;
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #95a7b7;
|
color: #aaa;
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebar div.tables-list .title span.refresh:hover {
|
#sidebar div.tables-list .title span.refresh:hover {
|
||||||
color: #fff;
|
color: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebar div.tables-list #tables {
|
#sidebar div.tables-list #tables {
|
||||||
padding: 33px 0 0;
|
padding: 33px 0 0;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebar div.table-information {
|
#sidebar div.table-information {
|
||||||
@@ -133,7 +136,10 @@
|
|||||||
left: 0px;
|
left: 0px;
|
||||||
height: 130px;
|
height: 130px;
|
||||||
bottom: 0px;
|
bottom: 0px;
|
||||||
background: #272c30;
|
background: #f6f6f6;
|
||||||
|
border-top: 1px solid #d4d4d4;
|
||||||
|
border-right: 1px solid #d4d4d4;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebar div.table-information .wrap {
|
#sidebar div.table-information .wrap {
|
||||||
@@ -142,7 +148,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#sidebar div.table-information .title {
|
#sidebar div.table-information .title {
|
||||||
background: #272c30;
|
background: #f6f6f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebar div.table-information ul {
|
#sidebar div.table-information ul {
|
||||||
@@ -156,11 +162,11 @@
|
|||||||
height: 24px;
|
height: 24px;
|
||||||
padding: 0 8px 0 12px;
|
padding: 0 8px 0 12px;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
color: #637D94 !important;
|
color: #7F7E7F !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebar div.table-information ul li span {
|
#sidebar div.table-information ul li span {
|
||||||
color: #95a7b7;
|
color: #4F4F4F;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding-left: 0px;
|
padding-left: 0px;
|
||||||
}
|
}
|
||||||
@@ -177,19 +183,18 @@
|
|||||||
padding: 0px;
|
padding: 0px;
|
||||||
margin: 0px;
|
margin: 0px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #95a7b7 !important;
|
color: #4F4E4F !important;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebar li.selected {
|
#sidebar li.selected {
|
||||||
color: #fff !important;
|
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
background: #272c30;
|
background: #e6e6e6;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebar ul li:hover span {
|
#sidebar ul li:hover span {
|
||||||
background: #272c30;
|
background: #f1f1f1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebar ul li i {
|
#sidebar ul li i {
|
||||||
@@ -295,7 +300,7 @@
|
|||||||
|
|
||||||
#results tr:nth-child(even) > td {
|
#results tr:nth-child(even) > td {
|
||||||
border: none;
|
border: none;
|
||||||
background: #f8f8f8;
|
background: #fafafa;
|
||||||
}
|
}
|
||||||
|
|
||||||
#results tr:nth-child(odd) > td {
|
#results tr:nth-child(odd) > td {
|
||||||
@@ -314,10 +319,23 @@
|
|||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#results th.active {
|
||||||
|
background: #e3e3e3;
|
||||||
|
}
|
||||||
|
|
||||||
|
#results[data-mode="browse"] th:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
#results tbody tr:hover td {
|
#results tbody tr:hover td {
|
||||||
background: #ffe;
|
background: #ffe;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#results tbody tr {
|
||||||
|
border-bottom: 1px solid #f2f2f2;
|
||||||
|
}
|
||||||
|
|
||||||
#results tr.selected td {
|
#results tr.selected td {
|
||||||
background: #3874d7 !important;
|
background: #3874d7 !important;
|
||||||
color: #fff !important;
|
color: #fff !important;
|
||||||
@@ -369,7 +387,7 @@
|
|||||||
#connection_window {
|
#connection_window {
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
background: #ebeef0;
|
background: #fff;
|
||||||
top: 0px;
|
top: 0px;
|
||||||
left: 0px;
|
left: 0px;
|
||||||
bottom: 0px;
|
bottom: 0px;
|
||||||
@@ -409,12 +427,12 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
text-shadow: 0px 1px 0px #fff;
|
text-shadow: 0px 1px 0px #fff;
|
||||||
margin-bottom: 25px;
|
margin-bottom: 25px;
|
||||||
color: #95A7B7;
|
color: #999;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.connection-settings form {
|
.connection-settings form {
|
||||||
background: #fff;
|
background: #f6f6f6;
|
||||||
padding: 25px;
|
padding: 25px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
-moz-border-radius: 4px;
|
-moz-border-radius: 4px;
|
||||||
@@ -431,4 +449,5 @@
|
|||||||
|
|
||||||
.connection-ssh-group {
|
.connection-ssh-group {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|||||||
+10
-2
@@ -12,13 +12,14 @@
|
|||||||
<script type="text/javascript" src="/static/js/jquery.js"></script>
|
<script type="text/javascript" src="/static/js/jquery.js"></script>
|
||||||
<script type="text/javascript" src="/static/js/ace.js"></script>
|
<script type="text/javascript" src="/static/js/ace.js"></script>
|
||||||
<script type="text/javascript" src="/static/js/ace-pgsql.js"></script>
|
<script type="text/javascript" src="/static/js/ace-pgsql.js"></script>
|
||||||
|
<script type="text/javascript" src="/static/js/bootstrap-contextmenu.js"></script>
|
||||||
<script type="text/javascript" src="/static/js/app.js"></script>
|
<script type="text/javascript" src="/static/js/app.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="main">
|
<div id="main">
|
||||||
<div id="nav">
|
<div id="nav">
|
||||||
<ul>
|
<ul>
|
||||||
<li id="table_content">Content</li>
|
<li id="table_content">Rows</li>
|
||||||
<li id="table_structure">Structure</li>
|
<li id="table_structure">Structure</li>
|
||||||
<li id="table_indexes">Indexes</li>
|
<li id="table_indexes">Indexes</li>
|
||||||
<li id="table_query" class="selected">SQL Query</li>
|
<li id="table_query" class="selected">SQL Query</li>
|
||||||
@@ -81,7 +82,6 @@
|
|||||||
<div class="btn-group btn-group-sm connection-group-switch">
|
<div class="btn-group btn-group-sm connection-group-switch">
|
||||||
<button type="button" data="scheme" class="btn btn-default" id="connection_scheme">Scheme</button>
|
<button type="button" data="scheme" class="btn btn-default" id="connection_scheme">Scheme</button>
|
||||||
<button type="button" data="standard" class="btn btn-default active" id="connection_standard">Standard</button>
|
<button type="button" data="standard" class="btn btn-default active" id="connection_standard">Standard</button>
|
||||||
<!--<button type="button" data="ssh" class="btn btn-default">SSH</button>-->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -193,5 +193,13 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="tables_context_menu">
|
||||||
|
<ul class="dropdown-menu" role="menu">
|
||||||
|
<li><a href="#" data-action="export">Export to CSV</a></li>
|
||||||
|
<li class="divider"></li>
|
||||||
|
<li><a href="#" data-action="truncate">Truncate table</a></li>
|
||||||
|
<li><a href="#" data-action="delete">Delete table</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+68
-26
@@ -37,7 +37,7 @@ function loadTables() {
|
|||||||
|
|
||||||
getTables(function(data) {
|
getTables(function(data) {
|
||||||
data.forEach(function(item) {
|
data.forEach(function(item) {
|
||||||
$("<li><span><i class='fa fa-table'></i> " + item + "</span></li>").appendTo("#tables");
|
$("<li><span><i class='fa fa-table'></i> " + item + " </span></li>").appendTo("#tables");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -68,15 +68,45 @@ function resetTable() {
|
|||||||
removeClass("no-crop");
|
removeClass("no-crop");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function performTableAction(table, action) {
|
||||||
|
if (action == "truncate" || action == "delete") {
|
||||||
|
var message = "Are you sure you want to " + action + " table " + table + " ?";
|
||||||
|
if (!confirm(message)) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch(action) {
|
||||||
|
case "truncate":
|
||||||
|
executeQuery("TRUNCATE TABLE " + table, function(data) {
|
||||||
|
if (data.error) alert(data.error);
|
||||||
|
resetTable();
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "delete":
|
||||||
|
executeQuery("DROP TABLE " + table, function(data) {
|
||||||
|
if (data.error) alert(data.error);
|
||||||
|
loadTables();
|
||||||
|
resetTable();
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "export":
|
||||||
|
var filename = table + ".csv"
|
||||||
|
var query = window.encodeURI("SELECT * FROM " + table);
|
||||||
|
var url = "http://" + window.location.host + "/api/query?format=csv&filename=" + filename + "&query=" + query;
|
||||||
|
var win = window.open(url, "_blank");
|
||||||
|
win.focus();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function sortArrow(direction) {
|
function sortArrow(direction) {
|
||||||
switch (direction) {
|
switch (direction) {
|
||||||
case "ASC":
|
case "ASC":
|
||||||
return "▲";
|
return "▲";
|
||||||
case "DESC":
|
case "DESC":
|
||||||
return "▼";
|
return "▼";
|
||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTable(results, sortColumn, sortOrder) {
|
function buildTable(results, sortColumn, sortOrder) {
|
||||||
@@ -98,11 +128,12 @@ function buildTable(results, sortColumn, sortOrder) {
|
|||||||
var rows = "";
|
var rows = "";
|
||||||
|
|
||||||
results.columns.forEach(function(col) {
|
results.columns.forEach(function(col) {
|
||||||
if (col === sortColumn) {
|
if (col === sortColumn) {
|
||||||
cols += "<th data='" + col + "'" + "data-sort-order=" + sortOrder + ">" + col + " " + sortArrow(sortOrder) + "</th>";
|
cols += "<th class='active' data='" + col + "'" + "data-sort-order=" + sortOrder + ">" + col + " " + sortArrow(sortOrder) + "</th>";
|
||||||
} else {
|
}
|
||||||
cols += "<th data='" + col + "'>" + col + "</th>";
|
else {
|
||||||
}
|
cols += "<th data='" + col + "'>" + col + "</th>";
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
results.rows.forEach(function(row) {
|
results.rows.forEach(function(row) {
|
||||||
@@ -252,7 +283,7 @@ function runQuery() {
|
|||||||
$("#run, #explain, #csv").prop("disabled", true);
|
$("#run, #explain, #csv").prop("disabled", true);
|
||||||
$("#query_progress").show();
|
$("#query_progress").show();
|
||||||
|
|
||||||
var query = $.trim(editor.getValue());
|
var query = $.trim(editor.getSelectedText() || editor.getValue());
|
||||||
|
|
||||||
if (query.length == 0) {
|
if (query.length == 0) {
|
||||||
$("#run, #explain, #csv").prop("disabled", false);
|
$("#run, #explain, #csv").prop("disabled", false);
|
||||||
@@ -449,20 +480,21 @@ $(document).ready(function() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
$("#results").on("click", "th", function(e) {
|
$("#results").on("click", "th", function(e) {
|
||||||
var sortColumn = this.attributes['data'].value;
|
var sortColumn = this.attributes['data'].value;
|
||||||
var contentTab = $('#table_content').hasClass('selected');
|
var contentTab = $('#table_content').hasClass('selected');
|
||||||
|
|
||||||
if (!contentTab) {
|
if (!contentTab) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.dataset.sortOrder === "ASC") {
|
if (this.dataset.sortOrder === "ASC") {
|
||||||
this.dataset.sortOrder = "DESC"
|
this.dataset.sortOrder = "DESC"
|
||||||
} else {
|
}
|
||||||
this.dataset.sortOrder = "ASC"
|
else {
|
||||||
}
|
this.dataset.sortOrder = "ASC"
|
||||||
|
}
|
||||||
|
|
||||||
showTableContent(sortColumn, this.dataset.sortOrder);
|
showTableContent(sortColumn, this.dataset.sortOrder);
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#results").on("dblclick", "td > div", function() {
|
$("#results").on("dblclick", "td > div", function() {
|
||||||
@@ -494,6 +526,16 @@ $(document).ready(function() {
|
|||||||
showTableInfo();
|
showTableInfo();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$("#tables").contextmenu({
|
||||||
|
target: "#tables_context_menu",
|
||||||
|
scopes: "li",
|
||||||
|
onItem: function(context, e) {
|
||||||
|
var table = $.trim($(context[0]).text());
|
||||||
|
var action = $(e.target).data("action");
|
||||||
|
performTableAction(table, action);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$("#refresh_tables").on("click", function() {
|
$("#refresh_tables").on("click", function() {
|
||||||
loadTables();
|
loadTables();
|
||||||
});
|
});
|
||||||
|
|||||||
Vendored
+205
@@ -0,0 +1,205 @@
|
|||||||
|
/*!
|
||||||
|
* Bootstrap Context Menu
|
||||||
|
* Author: @sydcanem
|
||||||
|
* https://github.com/sydcanem/bootstrap-contextmenu
|
||||||
|
*
|
||||||
|
* Inspired by Bootstrap's dropdown plugin.
|
||||||
|
* Bootstrap (http://getbootstrap.com).
|
||||||
|
*
|
||||||
|
* Licensed under MIT
|
||||||
|
* ========================================================= */
|
||||||
|
|
||||||
|
;(function($) {
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/* CONTEXTMENU CLASS DEFINITION
|
||||||
|
* ============================ */
|
||||||
|
var toggle = '[data-toggle="context"]';
|
||||||
|
|
||||||
|
var ContextMenu = function (element, options) {
|
||||||
|
this.$element = $(element);
|
||||||
|
|
||||||
|
this.before = options.before || this.before;
|
||||||
|
this.onItem = options.onItem || this.onItem;
|
||||||
|
this.scopes = options.scopes || null;
|
||||||
|
|
||||||
|
if (options.target) {
|
||||||
|
this.$element.data('target', options.target);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.listen();
|
||||||
|
};
|
||||||
|
|
||||||
|
ContextMenu.prototype = {
|
||||||
|
|
||||||
|
constructor: ContextMenu
|
||||||
|
,show: function(e) {
|
||||||
|
|
||||||
|
var $menu
|
||||||
|
, evt
|
||||||
|
, tp
|
||||||
|
, items
|
||||||
|
, relatedTarget = { relatedTarget: this, target: e.currentTarget };
|
||||||
|
|
||||||
|
if (this.isDisabled()) return;
|
||||||
|
|
||||||
|
this.closemenu();
|
||||||
|
|
||||||
|
if (this.before.call(this,e,$(e.currentTarget)) === false) return;
|
||||||
|
|
||||||
|
$menu = this.getMenu();
|
||||||
|
$menu.trigger(evt = $.Event('show.bs.context', relatedTarget));
|
||||||
|
|
||||||
|
tp = this.getPosition(e, $menu);
|
||||||
|
items = 'li:not(.divider)';
|
||||||
|
$menu.attr('style', '')
|
||||||
|
.css(tp)
|
||||||
|
.addClass('open')
|
||||||
|
.on('click.context.data-api', items, $.proxy(this.onItem, this, $(e.currentTarget)))
|
||||||
|
.trigger('shown.bs.context', relatedTarget);
|
||||||
|
|
||||||
|
// Delegating the `closemenu` only on the currently opened menu.
|
||||||
|
// This prevents other opened menus from closing.
|
||||||
|
$('html')
|
||||||
|
.on('click.context.data-api', $menu.selector, $.proxy(this.closemenu, this));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
,closemenu: function(e) {
|
||||||
|
var $menu
|
||||||
|
, evt
|
||||||
|
, items
|
||||||
|
, relatedTarget;
|
||||||
|
|
||||||
|
$menu = this.getMenu();
|
||||||
|
|
||||||
|
if(!$menu.hasClass('open')) return;
|
||||||
|
|
||||||
|
relatedTarget = { relatedTarget: this };
|
||||||
|
$menu.trigger(evt = $.Event('hide.bs.context', relatedTarget));
|
||||||
|
|
||||||
|
items = 'li:not(.divider)';
|
||||||
|
$menu.removeClass('open')
|
||||||
|
.off('click.context.data-api', items)
|
||||||
|
.trigger('hidden.bs.context', relatedTarget);
|
||||||
|
|
||||||
|
$('html')
|
||||||
|
.off('click.context.data-api', $menu.selector);
|
||||||
|
// Don't propagate click event so other currently
|
||||||
|
// opened menus won't close.
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
,keydown: function(e) {
|
||||||
|
if (e.which == 27) this.closemenu(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
,before: function(e) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
,onItem: function(e) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
,listen: function () {
|
||||||
|
this.$element.on('contextmenu.context.data-api', this.scopes, $.proxy(this.show, this));
|
||||||
|
$('html').on('click.context.data-api', $.proxy(this.closemenu, this));
|
||||||
|
$('html').on('keydown.context.data-api', $.proxy(this.keydown, this));
|
||||||
|
}
|
||||||
|
|
||||||
|
,destroy: function() {
|
||||||
|
this.$element.off('.context.data-api').removeData('context');
|
||||||
|
$('html').off('.context.data-api');
|
||||||
|
}
|
||||||
|
|
||||||
|
,isDisabled: function() {
|
||||||
|
return this.$element.hasClass('disabled') ||
|
||||||
|
this.$element.attr('disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
,getMenu: function () {
|
||||||
|
var selector = this.$element.data('target')
|
||||||
|
, $menu;
|
||||||
|
|
||||||
|
if (!selector) {
|
||||||
|
selector = this.$element.attr('href');
|
||||||
|
selector = selector && selector.replace(/.*(?=#[^\s]*$)/, ''); //strip for ie7
|
||||||
|
}
|
||||||
|
|
||||||
|
$menu = $(selector);
|
||||||
|
|
||||||
|
return $menu && $menu.length ? $menu : this.$element.find(selector);
|
||||||
|
}
|
||||||
|
|
||||||
|
,getPosition: function(e, $menu) {
|
||||||
|
var mouseX = e.clientX
|
||||||
|
, mouseY = e.clientY
|
||||||
|
, boundsX = $(window).width()
|
||||||
|
, boundsY = $(window).height()
|
||||||
|
, menuWidth = $menu.find('.dropdown-menu').outerWidth()
|
||||||
|
, menuHeight = $menu.find('.dropdown-menu').outerHeight()
|
||||||
|
, tp = {"position":"absolute","z-index":9999}
|
||||||
|
, Y, X, parentOffset;
|
||||||
|
|
||||||
|
if (mouseY + menuHeight > boundsY) {
|
||||||
|
Y = {"top": mouseY - menuHeight + $(window).scrollTop()};
|
||||||
|
} else {
|
||||||
|
Y = {"top": mouseY + $(window).scrollTop()};
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((mouseX + menuWidth > boundsX) && ((mouseX - menuWidth) > 0)) {
|
||||||
|
X = {"left": mouseX - menuWidth + $(window).scrollLeft()};
|
||||||
|
} else {
|
||||||
|
X = {"left": mouseX + $(window).scrollLeft()};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If context-menu's parent is positioned using absolute or relative positioning,
|
||||||
|
// the calculated mouse position will be incorrect.
|
||||||
|
// Adjust the position of the menu by its offset parent position.
|
||||||
|
parentOffset = $menu.offsetParent().offset();
|
||||||
|
X.left = X.left - parentOffset.left;
|
||||||
|
Y.top = Y.top - parentOffset.top;
|
||||||
|
|
||||||
|
return $.extend(tp, Y, X);
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
/* CONTEXT MENU PLUGIN DEFINITION
|
||||||
|
* ========================== */
|
||||||
|
|
||||||
|
$.fn.contextmenu = function (option,e) {
|
||||||
|
return this.each(function () {
|
||||||
|
var $this = $(this)
|
||||||
|
, data = $this.data('context')
|
||||||
|
, options = (typeof option == 'object') && option;
|
||||||
|
|
||||||
|
if (!data) $this.data('context', (data = new ContextMenu($this, options)));
|
||||||
|
if (typeof option == 'string') data[option].call(data, e);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
$.fn.contextmenu.Constructor = ContextMenu;
|
||||||
|
|
||||||
|
/* APPLY TO STANDARD CONTEXT MENU ELEMENTS
|
||||||
|
* =================================== */
|
||||||
|
|
||||||
|
$(document)
|
||||||
|
.on('contextmenu.context.data-api', function() {
|
||||||
|
$(toggle).each(function () {
|
||||||
|
var data = $(this).data('context');
|
||||||
|
if (!data) return;
|
||||||
|
data.closemenu();
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.on('contextmenu.context.data-api', toggle, function(e) {
|
||||||
|
$(this).contextmenu('show', e);
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
|
||||||
|
}(jQuery));
|
||||||
Reference in New Issue
Block a user