Compare commits

...
45 Commits
Author SHA1 Message Date
Dan Sosedoff 665f79d26c Version bump: 0.6.1 2015-06-18 13:10:02 -05:00
Dan Sosedoff 427a08029a Moar readme 2015-06-03 20:43:22 -05:00
Dan Sosedoff df7ef6d8f0 Tweak screens page 2015-06-03 20:34:45 -05:00
Dan Sosedoff b9b646bb11 Fix readme link 2015-06-03 20:33:01 -05:00
Dan Sosedoff ae62f8ea3d Add separate screens 2015-06-03 20:32:33 -05:00
Dan Sosedoff 0939632399 Update changelog 2015-05-31 20:48:06 -05:00
Dan Sosedoff 4b5a2b96a8 Print SHA256 of the zip archive, not the binary 2015-05-31 20:42:55 -05:00
Dan Sosedoff 79bf430dee Version bump: 0.6.0 2015-05-31 20:40:10 -05:00
Dan Sosedoff 62a168d44f Contributing guidelines 2015-05-20 21:50:22 -05:00
Dan Sosedoff 502c8dae91 Set test command for appveyor 2015-05-19 15:19:12 -05:00
Dan Sosedoff afdfc54802 Update screenshots 2015-05-19 12:57:24 -05:00
Dan Sosedoff 651b65a882 Implement table item context menu
- Add ability to export table contents into CSV
- Add ability to truncate table
- Add ability to delete table
2015-05-19 12:24:59 -05:00
Dan Sosedoff c2290acae3 Add port checking functionality and tests 2015-05-18 17:13:17 -05:00
Dan Sosedoff e1276afd71 Change sidebar color theme to light one 2015-05-18 16:57:53 -05:00
Dan Sosedoff dea0fb8b78 Tweak readme 2015-05-18 16:28:05 -05:00
Dan Sosedoff faebfb3e62 Regenerate assets 2015-05-18 16:22:19 -05:00
Dan Sosedoff 6fd7b7d427 Merge pull request #85 from brianlow/selected-text-1
Run selected text. Implements sosedoff/pgweb#78.
2015-05-18 16:20:38 -05:00
brianlow 385b415676 Run selected text. Implements sosedoff/pgweb#78.
In the Query tab, only run the selected text. If nothing selected, run all text in the editor.
2015-05-18 14:58:03 -06:00
Dan Sosedoff 50f0f5f6dc Shields 2015-05-09 22:12:38 -05:00
Dan Sosedoff 8889847c71 Fix version 2015-05-08 09:00:24 -05:00
Dan Sosedoff d26bb75ebc Tweak packaging script 2015-05-06 21:31:05 -05:00
Dan Sosedoff a65cca44e5 Add script to package binaries 2015-05-06 21:24:15 -05:00
Dan Sosedoff 18b0260d1f Changelog 2015-05-06 21:03:00 -05:00
Dan Sosedoff c9d4ef5e89 Tweak table row styles 2015-05-06 20:58:21 -05:00
Dan Sosedoff c0f3f027ae Move version definition into command package, include build timestamp, add /api/info endpoint 2015-05-05 00:34:23 -05:00
Dan Sosedoff 0bd5360315 Update styles for connection window 2015-05-05 00:15:30 -05:00
Dan Sosedoff 29b46ba59c Update bindata 2015-05-02 20:50:01 -05:00
Dan Sosedoff 04fe0023b7 DRY up api module 2015-05-02 20:32:16 -05:00
Dan Sosedoff cb3c3e0e2e Fix function names in api module 2015-05-02 20:13:04 -05:00
Dan Sosedoff 0ac9d72deb Refactor and simplify asset serving 2015-05-02 20:10:14 -05:00
Dan Sosedoff dd2200bdb8 Tweak appveyor config 2015-04-30 21:24:53 -05:00
Dan Sosedoff ca894c621f Inject git version when building a release 2015-04-30 20:40:06 -05:00
Dan Sosedoff 4224b3d326 Fix docker build and specify bind interface for command 2015-04-30 20:22:08 -05:00
Dan Sosedoff 891f45c1ca Split up api into smaller files 2015-04-30 19:59:48 -05:00
Dan Sosedoff 595f51d76c Add test task to makefile readme 2015-04-30 12:10:25 -05:00
Dan Sosedoff 50cdf99913 Fix all tests 2015-04-30 12:09:29 -05:00
Dan Sosedoff e9f2f8eb83 Test all 2015-04-30 11:53:21 -05:00
Dan Sosedoff 73d2aa8fe1 Check in compiled assets 2015-04-30 11:51:17 -05:00
Dan Sosedoff 579eaf9141 Do not uri encode password for now 2015-04-30 11:51:02 -05:00
Dan Sosedoff c513930e27 Restructure application 2015-04-30 11:47:07 -05:00
Dan Sosedoff 7a75447364 Encode connection password so it could be passed via get request 2015-04-29 20:03:39 -05:00
Dan Sosedoff ec6bb5590d Tweak js formatting, highlight header with sort order 2015-04-28 10:01:34 -05:00
Dan Sosedoff 637e7a4e24 Change default http port to 8081 to not conflict with rethinkdb web ui 2015-04-28 09:49:36 -05:00
Dan Sosedoff 08a70baf3f Remove go tip from travis config for now 2015-04-20 21:30:55 -05:00
Dan Sosedoff bc43ca6f0f Changelog 2015-04-13 09:03:52 -05:00
37 changed files with 1102 additions and 605 deletions
-1
View File
@@ -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
View File
@@ -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
+6
View File
@@ -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
View File
@@ -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"]
+15 -4
View File
@@ -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
+19 -16
View File
@@ -2,7 +2,9 @@
Web-based PostgreSQL database browser written in Go. Web-based PostgreSQL database browser written in Go.
[![Release](https://img.shields.io/github/release/sosedoff/pgweb.svg)](https://github.com/sosedoff/pgweb/releases) [![Release](https://img.shields.io/github/release/sosedoff/pgweb.svg?label=Release)](https://github.com/sosedoff/pgweb/releases)
[![Linux Build](https://img.shields.io/travis/sosedoff/pgweb.svg?label=Linux)](https://travis-ci.org/sosedoff/pgweb)
[![Windows Build](https://img.shields.io/appveyor/ci/sosedoff/pgweb/master.svg?label=Windows)](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) - [![Build Status](https://img.shields.io/travis/sosedoff/pgweb.svg)](https://travis-ci.org/sosedoff/pgweb)
- Appveyor (Windows) - [![Build Status](https://img.shields.io/appveyor/ci/sosedoff/pgweb/master.svg)](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
+7
View File
@@ -0,0 +1,7 @@
# Screenshots
### Browse table rows
<img src="screenshots/browse.png" />
### Write SQL queries
<img src="screenshots/query.png" />
-355
View File
@@ -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
View File
@@ -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
View File
@@ -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(),
}
}
+32 -42
View File
@@ -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
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
package main package api
import ( import (
"testing" "testing"
+96
View File
@@ -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)
}
+33
View File
@@ -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)
}
}
+3 -3
View File
@@ -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))
+35 -22
View File
@@ -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)
+12 -10
View File
@@ -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)
+39
View File
@@ -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
}
+8
View File
@@ -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"}))
} }
+33
View File
@@ -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")
}
+65
View File
@@ -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
+21
View File
@@ -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 -1
View File
@@ -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`
+32
View File
@@ -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
View File
@@ -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

+14
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 "&#x25B2;"; return "&#x25B2;";
case "DESC": case "DESC":
return "&#x25BC;"; return "&#x25BC;";
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 + "&nbsp;" + sortArrow(sortOrder) + "</th>"; cols += "<th class='active' data='" + col + "'" + "data-sort-order=" + sortOrder + ">" + col + "&nbsp;" + 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();
}); });
+205
View File
@@ -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));