diff --git a/.gitignore b/.gitignore index 2842faaffa..6f03943f72 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.env /bin # Binaries for programs and plugins diff --git a/.golangci.yml b/.golangci.yml index e2d685594e..30322f04ae 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -29,5 +29,4 @@ linters: - depguard - prealloc - scopelint - - gocritic - whitespace diff --git a/README.md b/README.md index 371f7452e8..29809d9132 100644 --- a/README.md +++ b/README.md @@ -153,3 +153,33 @@ If you want to develop Dashboard UI, the recommended workflow is as follows: Currently the development server will not watch for Golang code changes, which means you must manually rebuild the Dashboard API Client if back-end code is updated (for example, you pulled latest change from the repository). + +## For Developers How To ... + +### Keep session valid after rebooting the server + +By default, the session secret key is generated dynamically when the server starts. This results in invalidating +your previously acquired session token. For easier development, you can supply a fixed session secret key by +setting `DASHBOARD_SESSION_SECRET` in the environment variable or in `.env` file like: + +```env +DASHBOARD_SESSION_SECRET=aaaaaaaaaabbbbbbbbbbccccccccccdd +``` + +The supplied secret key must be 32 bytes, otherwise it will not be effective. + +Note: the maximum lifetime of a token is 24 hours by default, so you still need to acquire token every 24 hours. + +### Supply session token in the Swagger UI + +1. Acquire a token first through `/user/login` in the Swagger UI. + +2. Click the "Authorize" button in the Swagger UI, set value to `Bearer xxxx` where `xxxx` is the token you acquired + in step 1. + + + +### Release new UI assets + +Simply modify `ui/.github_release_version`. The assets will be released automatically after your change is merged +to master. diff --git a/cmd/tidb-dashboard/main.go b/cmd/tidb-dashboard/main.go index 7f8e809d1a..e42f006e7e 100644 --- a/cmd/tidb-dashboard/main.go +++ b/cmd/tidb-dashboard/main.go @@ -16,6 +16,9 @@ // @license.name Apache 2.0 // @license.url http://www.apache.org/licenses/LICENSE-2.0.html // @BasePath /dashboard/api +// @securityDefinitions.apikey JwtAuth +// @in header +// @name Authorization package main @@ -31,6 +34,7 @@ import ( "sync" "syscall" + "github.com/joho/godotenv" "github.com/pingcap/log" "go.etcd.io/etcd/clientv3" "go.uber.org/zap" @@ -43,12 +47,12 @@ import ( keyvisualregion "github.com/pingcap-incubator/tidb-dashboard/pkg/keyvisual/region" "github.com/pingcap-incubator/tidb-dashboard/pkg/pd" "github.com/pingcap-incubator/tidb-dashboard/pkg/swaggerserver" + "github.com/pingcap-incubator/tidb-dashboard/pkg/tidb" "github.com/pingcap-incubator/tidb-dashboard/pkg/uiserver" "github.com/pingcap-incubator/tidb-dashboard/pkg/utils" ) type DashboardCLIConfig struct { - Version bool ListenHost string ListenPort int CoreConfig *config.Config @@ -61,10 +65,10 @@ type DashboardCLIConfig struct { func NewCLIConfig() *DashboardCLIConfig { cfg := &DashboardCLIConfig{} cfg.CoreConfig = &config.Config{} - cfg.CoreConfig.Version = utils.ReleaseVersion - flag.BoolVar(&cfg.Version, "V", false, "Print version information and exit") - flag.BoolVar(&cfg.Version, "version", false, "Print version information and exit") + var showVersion bool + flag.BoolVar(&showVersion, "v", false, "Print version information and exit") + flag.BoolVar(&showVersion, "version", false, "Print version information and exit") flag.StringVar(&cfg.ListenHost, "host", "0.0.0.0", "The listen address of the Dashboard Server") flag.IntVar(&cfg.ListenPort, "port", 12333, "The listen port of the Dashboard Server") flag.StringVar(&cfg.CoreConfig.DataDir, "data-dir", "/tmp/dashboard-data", "Path to the Dashboard Server data directory") @@ -76,7 +80,7 @@ func NewCLIConfig() *DashboardCLIConfig { flag.Parse() - if cfg.Version { + if showVersion { utils.PrintInfo() exit(0) } @@ -111,6 +115,8 @@ func getContext() (context.Context, *sync.WaitGroup) { } func main() { + _ = godotenv.Load() + // Flushing any buffered log entries defer log.Sync() //nolint:errcheck @@ -121,6 +127,10 @@ func main() { defer store.Close() //nolint:errcheck etcdClient := pd.NewEtcdClient(cliConfig.CoreConfig) + tidbForwarder := tidb.NewForwarder(tidb.NewForwarderConfig(), etcdClient) + // FIXME: Handle open error + tidbForwarder.Open() //nolint:errcheck + defer tidbForwarder.Close() //nolint:errcheck // key visual remoteDataProvider := &keyvisualregion.PDDataProvider{ @@ -136,8 +146,9 @@ func main() { keyvisualService.Start() services := &apiserver.Services{ - Store: store, - KeyVisual: keyvisualService, + Store: store, + KeyVisual: keyvisualService, + TiDBForwarder: tidbForwarder, } mux := http.DefaultServeMux mux.Handle("/dashboard/", http.StripPrefix("/dashboard", uiserver.Handler())) @@ -147,9 +158,8 @@ func main() { listenAddr := fmt.Sprintf("%s:%d", cliConfig.ListenHost, cliConfig.ListenPort) listener, err := net.Listen("tcp", listenAddr) if err != nil { - log.Fatal("Dashboard server listen failed", zap.String("addr", listenAddr), zap.Error(err)) store.Close() //nolint:errcheck - exit(1) + log.Fatal("Dashboard server listen failed", zap.String("addr", listenAddr), zap.Error(err)) } utils.LogInfo() diff --git a/etc/readme_howto_swagger_session.jpg b/etc/readme_howto_swagger_session.jpg new file mode 100644 index 0000000000..1bfa127626 Binary files /dev/null and b/etc/readme_howto_swagger_session.jpg differ diff --git a/go.mod b/go.mod index e102cc1278..a0440fa0c5 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,16 @@ go 1.13 require ( github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 + github.com/appleboy/gin-jwt/v2 v2.6.3 github.com/elazarl/go-bindata-assetfs v1.0.0 github.com/gin-contrib/gzip v0.0.1 github.com/gin-gonic/gin v1.5.0 github.com/go-bindata/go-bindata v3.1.2+incompatible + github.com/go-sql-driver/mysql v1.4.1 + github.com/gtank/cryptopasta v0.0.0-20170601214702-1f550f6f2f69 github.com/jinzhu/gorm v1.9.12 + github.com/joho/godotenv v1.3.0 + github.com/joomcode/errorx v1.0.1 github.com/pingcap/check v0.0.0-20191216031241-8a5a85928f12 github.com/pingcap/log v0.0.0-20200117041106-d28c14d3b1cd github.com/pkg/errors v0.9.1 diff --git a/go.sum b/go.sum index 70e5f8b2ee..cd85e8800d 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,10 @@ github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuy github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/appleboy/gin-jwt/v2 v2.6.3 h1:aK4E3DjihWEBUTjEeRnGkA5nUkmwJPL1CPonMa2usRs= +github.com/appleboy/gin-jwt/v2 v2.6.3/go.mod h1:MfPYA4ogzvOcVkRwAxT7quHOtQmVKDpTwxyUrC2DNw0= +github.com/appleboy/gofight/v2 v2.1.2 h1:VOy3jow4vIK8BRQJoC/I9muxyYlJ2yb9ht2hZoS3rf4= +github.com/appleboy/gofight/v2 v2.1.2/go.mod h1:frW+U1QZEdDgixycTj4CygQ48yLTUhplt43+Wczp3rw= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0 h1:HWo1m869IqiPhD389kmkxeTalrjNbbJTC8LXupb+sl0= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= @@ -115,6 +119,8 @@ github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92Bcuy github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.9.5 h1:UImYN5qQ8tuGpGE16ZmjvcTtTw24zw1QAp/SlnNrZhI= github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/gtank/cryptopasta v0.0.0-20170601214702-1f550f6f2f69 h1:7xsUJsB2NrdcttQPa7JLEaGzvdbk7KvfrjgHZXOQRo0= +github.com/gtank/cryptopasta v0.0.0-20170601214702-1f550f6f2f69/go.mod h1:YLEMZOtU+AZ7dhN9T/IpGhXVGly2bvkJQ+zxj3WeVQo= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/jinzhu/gorm v1.9.12 h1:Drgk1clyWT9t9ERbzHza6Mj/8FY/CqMyVzOiHviMo6Q= @@ -123,8 +129,12 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.0.1 h1:HjfetcXq097iXP0uoPCdnM4Efp5/9MsM0/M+XOTeR3M= github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= +github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= github.com/jonboulle/clockwork v0.1.0 h1:VKV+ZcuP6l3yW9doeqz6ziZGgcynBVQO+obU0+0hcPo= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/joomcode/errorx v1.0.1 h1:CalpDWz14ZHd68fIqluJasJosAewpz2TFaJALrUxjrk= +github.com/joomcode/errorx v1.0.1/go.mod h1:kgco15ekB6cs+4Xjzo7SPeXzx38PbJzBwbnu9qfVNHQ= github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.7 h1:KfgG9LzI+pYjr4xvmz/5H4FXjokeP+rlHLhv3iH62Fo= @@ -233,6 +243,12 @@ github.com/swaggo/swag v1.5.1/go.mod h1:1Bl9F/ZBpVWh22nY0zmYyASPO1lI/zIwRDrpZU+t github.com/swaggo/swag v1.6.3/go.mod h1:wcc83tB4Mb2aNiL/HP4MFeQdpHUrca+Rp/DRNgWAUio= github.com/swaggo/swag v1.6.5 h1:2C+t+xyK6p1sujqncYO/VnMvPZcBJjNdKKyxbOdAW8o= github.com/swaggo/swag v1.6.5/go.mod h1:Y7ZLSS0d0DdxhWGVhQdu+Bu1QhaF5k0RD7FKdiAykeY= +github.com/tidwall/gjson v1.3.5 h1:2oW9FBNu8qt9jy5URgrzsVx/T/KSn3qn/smJQ0crlDQ= +github.com/tidwall/gjson v1.3.5/go.mod h1:P256ACg0Mn+j1RXIDXoss50DeIABTYK1PULOJHhxOls= +github.com/tidwall/match v1.0.1 h1:PnKP62LPNxHKTwvHHZZzdOAOCtsJTjo6dZLCwpKm5xc= +github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E= +github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4= +github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8 h1:ndzgwNDnKIqyCvHTXaCqh9KlOWKvBry6nuXMJmonVsE= github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= @@ -334,6 +350,7 @@ golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191107010934-f79515f33823 h1:akkRBeitX2EZP59KdtKw310CI4WGPCNPyrLbE7WZA8Y= golang.org/x/tools v0.0.0-20191107010934-f79515f33823/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= @@ -361,6 +378,8 @@ gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bl gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo= +gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM= diff --git a/pkg/apiserver/apiserver.go b/pkg/apiserver/apiserver.go index cc114bc8cc..096fcf2e40 100644 --- a/pkg/apiserver/apiserver.go +++ b/pkg/apiserver/apiserver.go @@ -23,16 +23,20 @@ import ( "github.com/pingcap-incubator/tidb-dashboard/pkg/apiserver/foo" "github.com/pingcap-incubator/tidb-dashboard/pkg/apiserver/info" + "github.com/pingcap-incubator/tidb-dashboard/pkg/apiserver/user" + "github.com/pingcap-incubator/tidb-dashboard/pkg/apiserver/utils" "github.com/pingcap-incubator/tidb-dashboard/pkg/config" "github.com/pingcap-incubator/tidb-dashboard/pkg/dbstore" "github.com/pingcap-incubator/tidb-dashboard/pkg/keyvisual" + "github.com/pingcap-incubator/tidb-dashboard/pkg/tidb" ) var once sync.Once type Services struct { - Store *dbstore.DB - KeyVisual *keyvisual.Service + Store *dbstore.DB + TiDBForwarder *tidb.Forwarder + KeyVisual *keyvisual.Service } func Handler(apiPrefix string, config *config.Config, services *Services) http.Handler { @@ -45,12 +49,16 @@ func Handler(apiPrefix string, config *config.Config, services *Services) http.H r.Use(cors.AllowAll()) r.Use(gin.Recovery()) r.Use(gzip.Gzip(gzip.BestSpeed)) + r.Use(utils.MWHandleErrors()) endpoint := r.Group(apiPrefix) - foo.NewService(config).Register(endpoint) - info.NewService(config, services.Store).Register(endpoint) - services.KeyVisual.Register(endpoint) + auth := user.NewAuthService(services.TiDBForwarder) + auth.Register(endpoint) + + foo.NewService(config).Register(endpoint, auth) + info.NewService(config, services.TiDBForwarder, services.Store).Register(endpoint, auth) + services.KeyVisual.Register(endpoint, auth) return r } diff --git a/pkg/apiserver/foo/foo.go b/pkg/apiserver/foo/foo.go index 6d55945ceb..61e1a03d62 100644 --- a/pkg/apiserver/foo/foo.go +++ b/pkg/apiserver/foo/foo.go @@ -18,6 +18,9 @@ import ( "github.com/gin-gonic/gin" + "github.com/pingcap-incubator/tidb-dashboard/pkg/apiserver/user" + // Import for swag go doc + _ "github.com/pingcap-incubator/tidb-dashboard/pkg/apiserver/utils" "github.com/pingcap-incubator/tidb-dashboard/pkg/config" ) @@ -28,8 +31,9 @@ func NewService(config *config.Config) *Service { return &Service{} } -func (s *Service) Register(r *gin.RouterGroup) { +func (s *Service) Register(r *gin.RouterGroup, auth *user.AuthService) { endpoint := r.Group("/foo") + endpoint.Use(auth.MWAuthRequired()) endpoint.GET("/:name", s.greetHandler) } @@ -40,6 +44,8 @@ func (s *Service) Register(r *gin.RouterGroup) { // @Param name path string true "Name" // @Success 200 {string} string // @Router /foo/{name} [get] +// @Security JwtAuth +// @Failure 401 {object} utils.APIError "Unauthorized failure" func (s *Service) greetHandler(c *gin.Context) { name := c.Param("name") c.String(http.StatusOK, "Hello %s", name) diff --git a/pkg/apiserver/info/info.go b/pkg/apiserver/info/info.go index 89a867cca8..b503f4de3e 100644 --- a/pkg/apiserver/info/info.go +++ b/pkg/apiserver/info/info.go @@ -17,39 +17,88 @@ import ( "net/http" "github.com/gin-gonic/gin" + "github.com/jinzhu/gorm" + "github.com/pingcap-incubator/tidb-dashboard/pkg/apiserver/user" + "github.com/pingcap-incubator/tidb-dashboard/pkg/apiserver/utils" "github.com/pingcap-incubator/tidb-dashboard/pkg/config" "github.com/pingcap-incubator/tidb-dashboard/pkg/dbstore" + "github.com/pingcap-incubator/tidb-dashboard/pkg/tidb" + utils2 "github.com/pingcap-incubator/tidb-dashboard/pkg/utils" ) -type Info struct { - Version string `json:"version"` - PDEndPoint string `json:"pd_end_point"` -} - type Service struct { - config *config.Config - db *dbstore.DB + config *config.Config + db *dbstore.DB + tidbForwarder *tidb.Forwarder } -func NewService(config *config.Config, db *dbstore.DB) *Service { - return &Service{config: config, db: db} +func NewService(config *config.Config, tidbForwarder *tidb.Forwarder, db *dbstore.DB) *Service { + return &Service{config: config, db: db, tidbForwarder: tidbForwarder} } -func (s *Service) Register(r *gin.RouterGroup) { +func (s *Service) Register(r *gin.RouterGroup, auth *user.AuthService) { endpoint := r.Group("/info") + endpoint.Use(auth.MWAuthRequired()) endpoint.GET("/info", s.infoHandler) + endpoint.GET("/whoami", s.whoamiHandler) + endpoint.GET("/databases", utils.MWConnectTiDB(s.tidbForwarder), s.databasesHandler) +} + +type InfoResponse struct { //nolint:golint + Version utils2.VersionInfo `json:"version"` + PDEndPoint string `json:"pd_end_point"` } // @Summary Dashboard info // @Description Get information about the dashboard service. +// @ID getInfo // @Produce json -// @Success 200 {object} Info +// @Success 200 {object} InfoResponse // @Router /info/info [get] +// @Security JwtAuth +// @Failure 401 {object} utils.APIError "Unauthorized failure" func (s *Service) infoHandler(c *gin.Context) { - info := Info{ - Version: s.config.Version, + resp := InfoResponse{ + Version: utils2.GetVersionInfo(), PDEndPoint: s.config.PDEndPoint, } - c.JSON(http.StatusOK, info) + c.JSON(http.StatusOK, resp) +} + +type WhoAmIResponse struct { + Username string `json:"username"` +} + +// @Summary Current login +// @Description Get current login session +// @Produce json +// @Success 200 {object} WhoAmIResponse +// @Router /info/whoami [get] +// @Security JwtAuth +// @Failure 401 {object} utils.APIError "Unauthorized failure" +func (s *Service) whoamiHandler(c *gin.Context) { + sessionUser := c.MustGet(utils.SessionUserKey).(*utils.SessionUser) + resp := WhoAmIResponse{Username: sessionUser.TiDBUsername} + c.JSON(http.StatusOK, resp) +} + +type DatabaseResponse = []string + +// @Summary Example: Get all databases +// @Description Get all databases. +// @Produce json +// @Success 200 {object} DatabaseResponse +// @Router /info/databases [get] +// @Security JwtAuth +// @Failure 401 {object} utils.APIError "Unauthorized failure" +func (s *Service) databasesHandler(c *gin.Context) { + db := c.MustGet(utils.TiDBConnectionKey).(*gorm.DB) + var result DatabaseResponse + err := db.Raw("show databases").Pluck("Databases", &result).Error + if err != nil { + _ = c.Error(err) + return + } + c.JSON(http.StatusOK, result) } diff --git a/pkg/apiserver/user/auth.go b/pkg/apiserver/user/auth.go new file mode 100644 index 0000000000..5624113952 --- /dev/null +++ b/pkg/apiserver/user/auth.go @@ -0,0 +1,228 @@ +// Copyright 2020 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// See the License for the specific language governing permissions and +// limitations under the License. + +package user + +import ( + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "os" + "time" + + jwt "github.com/appleboy/gin-jwt/v2" + "github.com/gin-gonic/gin" + "github.com/gtank/cryptopasta" + "github.com/joomcode/errorx" + "github.com/pingcap/log" + "go.uber.org/zap" + + "github.com/pingcap-incubator/tidb-dashboard/pkg/apiserver/utils" + "github.com/pingcap-incubator/tidb-dashboard/pkg/tidb" +) + +var ( + ErrNS = errorx.NewNamespace("error.api.user") + ErrNSSignIn = ErrNS.NewSubNamespace("signin") + ErrSignInUnsupportedAuthType = ErrNSSignIn.NewType("unsupported_auth_type") + ErrSignInMissingParameter = ErrNSSignIn.NewType("missing_parameter") + ErrSignInOther = ErrNSSignIn.NewType("other") +) + +type AuthService struct { + middleware *jwt.GinJWTMiddleware +} + +type authenticateForm struct { + IsTiDBAuth bool `json:"is_tidb_auth"` + Username string `json:"username"` + Password string `json:"password"` +} + +type TokenResponse struct { + Token string `json:"token"` + Expire time.Time `json:"expire"` +} + +func (f *authenticateForm) Authenticate(tidbForwarder *tidb.Forwarder) (*utils.SessionUser, error) { + // TODO: Support non TiDB auth + if !f.IsTiDBAuth { + return nil, ErrSignInUnsupportedAuthType.New("unsupported auth type, only TiDB auth is supported") + } + db, err := tidb.OpenTiDB(tidbForwarder, f.Username, f.Password) + if err != nil { + if errorx.Cast(err) == nil { + return nil, ErrSignInOther.WrapWithNoMessage(err) + } + // Possible errors could be: + // tidb.ErrNoAliveTiDB + // tidb.ErrPDAccessFailed + // tidb.ErrTiDBConnFailed + // tidb.ErrTiDBAuthFailed + return nil, err + } + defer db.Close() //nolint:errcheck + + // TODO: Fill privilege tables here + return &utils.SessionUser{ + IsTiDBAuth: f.IsTiDBAuth, + TiDBUsername: f.Username, + TiDBPassword: f.Password, + }, nil +} + +func NewAuthService(tidbForwarder *tidb.Forwarder) *AuthService { + var secret *[32]byte + + secretStr := os.Getenv("DASHBOARD_SESSION_SECRET") + switch len(secretStr) { + case 0: + secret = cryptopasta.NewEncryptionKey() + case 32: + log.Info("DASHBOARD_SESSION_SECRET is overridden from env var") + secret = &[32]byte{} + copy(secret[:], secretStr) + default: + log.Warn("DASHBOARD_SESSION_SECRET does not meet the 32 byte size requirement, ignored") + secret = cryptopasta.NewEncryptionKey() + } + + middleware, err := jwt.New(&jwt.GinJWTMiddleware{ + IdentityKey: utils.SessionUserKey, + Realm: "dashboard", + Key: secret[:], + Timeout: time.Hour * 24, + MaxRefresh: time.Hour * 24, + Authenticator: func(c *gin.Context) (interface{}, error) { + var form authenticateForm + if err := c.ShouldBindJSON(&form); err != nil { + return nil, ErrSignInMissingParameter.WrapWithNoMessage(err) + } + u, err := form.Authenticate(tidbForwarder) + if err != nil { + return nil, errorx.Decorate(err, "authenticate failed") + } + return u, nil + }, + PayloadFunc: func(data interface{}) jwt.MapClaims { + user, ok := data.(*utils.SessionUser) + if !ok { + return jwt.MapClaims{} + } + // `user` contains sensitive information, thus it is encrypted in the token. + // In order to be simple, we keep using JWS instead of JWE for thus scenario. + plain, err := json.Marshal(user) + if err != nil { + return jwt.MapClaims{} + } + encrypted, err := cryptopasta.Encrypt(plain, secret) + if err != nil { + return jwt.MapClaims{} + } + return jwt.MapClaims{ + "p": base64.StdEncoding.EncodeToString(encrypted), + } + }, + IdentityHandler: func(c *gin.Context) interface{} { + claims := jwt.ExtractClaims(c) + + encoded, ok := claims["p"].(string) + if !ok { + return nil + } + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil + } + decrypted, err := cryptopasta.Decrypt(decoded, secret) + if err != nil { + return nil + } + var user utils.SessionUser + if err := json.Unmarshal(decrypted, &user); err != nil { + return nil + } + return &user + }, + Authorizator: func(data interface{}, c *gin.Context) bool { + // Ensure identity is valid + if data == nil { + return false + } + user := data.(*utils.SessionUser) + if user == nil { + return false + } + // Currently we don't support privileges, so only root user is allowed to sign in. + if user.TiDBUsername != "root" { + return false + } + return true + }, + HTTPStatusMessageFunc: func(e error, c *gin.Context) string { + var err error + if errorxErr := errorx.Cast(e); errorxErr != nil { + // If the error is an errorx, use it directly. + err = e + } else if errors.Is(e, jwt.ErrFailedTokenCreation) { + // Try to catch other sign in failure errors. + err = ErrSignInOther.WrapWithNoMessage(e) + } else { + // The remaining error comes from checking tokens for protected endpoints. + err = utils.ErrUnauthorized.NewWithNoMessage() + } + _ = c.Error(err) + return err.Error() + }, + Unauthorized: func(c *gin.Context, code int, message string) { + c.Status(code) + }, + LoginResponse: func(c *gin.Context, code int, token string, expire time.Time) { + c.JSON(http.StatusOK, TokenResponse{ + Token: token, + Expire: expire, + }) + }, + }) + + if err != nil { + // Error only comes from configuration errors. Fatal is fine. + log.Fatal("Failed to configure auth service", zap.Error(err)) + } + + return &AuthService{middleware: middleware} +} + +func (s *AuthService) Register(r *gin.RouterGroup) { + endpoint := r.Group("/user") + endpoint.POST("/login", s.loginHandler) +} + +// MWAuthRequired creates a middleware that verifies the authentication token (JWT) in the request. If the token +// is valid, identity information will be attached in the context. If there is no authentication token, or the +// token is invalid, subsequent handlers will be skipped and errors will be generated. +func (s *AuthService) MWAuthRequired() gin.HandlerFunc { + return s.middleware.MiddlewareFunc() +} + +// @Summary Log in +// @Description Log into dashboard. +// @Accept json +// @Param message body authenticateForm true "Credentials" +// @Success 200 {object} TokenResponse +// @Failure 401 {object} utils.APIError "Login failure" +// @Router /user/login [post] +func (s *AuthService) loginHandler(c *gin.Context) { + s.middleware.LoginHandler(c) +} diff --git a/pkg/apiserver/utils/auth.go b/pkg/apiserver/utils/auth.go new file mode 100644 index 0000000000..543edb1b07 --- /dev/null +++ b/pkg/apiserver/utils/auth.go @@ -0,0 +1,42 @@ +// Copyright 2020 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// See the License for the specific language governing permissions and +// limitations under the License. + +package utils + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +type SessionUser struct { + IsTiDBAuth bool + TiDBUsername string + TiDBPassword string + // TODO: Add privilege table fields +} + +const ( + // The key that attached the SessionUser in the gin Context. + SessionUserKey = "user" +) + +func MakeUnauthorizedError(c *gin.Context) { + _ = c.Error(ErrUnauthorized.NewWithNoMessage()) + c.Status(http.StatusUnauthorized) +} + +func MakeInsufficientPrivilegeError(c *gin.Context) { + _ = c.Error(ErrInsufficientPrivilege.NewWithNoMessage()) + c.Status(http.StatusForbidden) +} diff --git a/pkg/apiserver/utils/error.go b/pkg/apiserver/utils/error.go new file mode 100644 index 0000000000..8a96aecc74 --- /dev/null +++ b/pkg/apiserver/utils/error.go @@ -0,0 +1,68 @@ +// Copyright 2020 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// See the License for the specific language governing permissions and +// limitations under the License. + +package utils + +import ( + "fmt" + + "github.com/gin-gonic/gin" + "github.com/joomcode/errorx" +) + +var ( + ErrNS = errorx.NewNamespace("error.api") + ErrOther = ErrNS.NewType("other") + ErrUnauthorized = ErrNS.NewType("unauthorized") + ErrInsufficientPrivilege = ErrNS.NewType("insufficient_privilege") +) + +type APIError struct { + Error bool `json:"error"` + Message string `json:"message"` + Code string `json:"code"` + FullText string `json:"full_text"` +} + +// MWHandleErrors creates a middleware that turns (last) error in the context into an APIError json response. +// In handlers, `c.Error(err)` can be used to attach the error to the context. +// When error is attached in the context: +// - The handler can optionally assign the HTTP status code. +// - The handler must not self-generate a response body. +func MWHandleErrors() gin.HandlerFunc { + return func(c *gin.Context) { + c.Next() + + err := c.Errors.Last() + if err == nil { + return + } + + statusCode := c.Writer.Status() + if statusCode == 200 { + statusCode = 500 + } + + innerErr := errorx.Cast(err.Err) + if innerErr == nil { + innerErr = ErrOther.WrapWithNoMessage(err.Err) + } + + c.AbortWithStatusJSON(statusCode, APIError{ + Error: true, + Message: innerErr.Error(), + Code: errorx.GetTypeName(innerErr), + FullText: fmt.Sprintf("%+v", innerErr), + }) + } +} diff --git a/pkg/apiserver/utils/tidb_conn.go b/pkg/apiserver/utils/tidb_conn.go new file mode 100644 index 0000000000..68c9dfaa80 --- /dev/null +++ b/pkg/apiserver/utils/tidb_conn.go @@ -0,0 +1,65 @@ +// Copyright 2020 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// See the License for the specific language governing permissions and +// limitations under the License. + +package utils + +import ( + "github.com/gin-gonic/gin" + "github.com/joomcode/errorx" + + "github.com/pingcap-incubator/tidb-dashboard/pkg/tidb" +) + +const ( + // The key that attached the TiDB connection in the gin Context. + TiDBConnectionKey = "tidb" +) + +// MWConnectTiDB creates a middleware that attaches TiDB connection to the context, according to the identity +// information attached in the context. If a connection cannot be established, subsequent handlers will be skipped +// and errors will be generated. +// +// This middleware must be placed after the `MWAuthRequired()` middleware, otherwise it will panic. +func MWConnectTiDB(tidbForwarder *tidb.Forwarder) gin.HandlerFunc { + return func(c *gin.Context) { + sessionUser := c.MustGet(SessionUserKey).(*SessionUser) + if sessionUser == nil { + panic("invalid sessionUser") + } + + if !sessionUser.IsTiDBAuth { + // Only TiDBAuth is able to access. Raise error in this case. + // The error is privilege error instead of authorization error so that user will not be redirected. + MakeInsufficientPrivilegeError(c) + return + } + + db, err := tidb.OpenTiDB(tidbForwarder, sessionUser.TiDBUsername, sessionUser.TiDBPassword) + if err != nil && errorx.IsOfType(err, tidb.ErrTiDBAuthFailed) { + // If TiDB conn is ok when login but fail this time, it means TiDB credential has been changed since + // login. In this case, we return unauthorized error, so that the front-end can let user to login again. + MakeUnauthorizedError(c) + return + } else if err != nil { + // For other kind of connection errors, for example, PD goes away, return these errors directly. + // In front-end we will simply display these errors but not ask user to login again. + c.Status(500) + _ = c.Error(err) + return + } + + defer db.Close() //nolint:errcheck + c.Set(TiDBConnectionKey, db) + c.Next() + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 61d2e7466c..e93e280998 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -18,7 +18,6 @@ import ( ) type Config struct { - Version string DataDir string PDEndPoint string TLSConfig *tls.Config diff --git a/pkg/keyvisual/service.go b/pkg/keyvisual/service.go index 1398000ff9..a3e14d0c48 100644 --- a/pkg/keyvisual/service.go +++ b/pkg/keyvisual/service.go @@ -26,6 +26,9 @@ import ( "github.com/pingcap/log" "go.uber.org/zap" + "github.com/pingcap-incubator/tidb-dashboard/pkg/apiserver/user" + // Import for swag go doc + _ "github.com/pingcap-incubator/tidb-dashboard/pkg/apiserver/utils" "github.com/pingcap-incubator/tidb-dashboard/pkg/config" "github.com/pingcap-incubator/tidb-dashboard/pkg/keyvisual/decorator" "github.com/pingcap-incubator/tidb-dashboard/pkg/keyvisual/input" @@ -87,8 +90,9 @@ func (s *Service) Start() { }() } -func (s *Service) Register(r *gin.RouterGroup) { +func (s *Service) Register(r *gin.RouterGroup, auth *user.AuthService) { endpoint := r.Group("/keyvisual") + endpoint.Use(auth.MWAuthRequired()) endpoint.GET("/heatmaps", s.heatmapsHandler) } @@ -102,6 +106,8 @@ func (s *Service) Register(r *gin.RouterGroup) { // @Param type query string false "Main types of data" Enums(written_bytes, read_bytes, written_keys, read_keys, integration) // @Success 200 {object} matrix.Matrix // @Router /keyvisual/heatmaps [get] +// @Security JwtAuth +// @Failure 401 {object} utils.APIError "Unauthorized failure" func (s *Service) heatmapsHandler(c *gin.Context) { startKey := c.Query("startkey") endKey := c.Query("endkey") diff --git a/pkg/tidb/conn.go b/pkg/tidb/conn.go new file mode 100644 index 0000000000..e7c083fdab --- /dev/null +++ b/pkg/tidb/conn.go @@ -0,0 +1,53 @@ +// Copyright 2020 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// See the License for the specific language governing permissions and +// limitations under the License. + +package tidb + +import ( + "database/sql/driver" + "fmt" + "time" + + "github.com/go-sql-driver/mysql" + "github.com/jinzhu/gorm" + + // MySQL driver used by gorm + _ "github.com/jinzhu/gorm/dialects/mysql" +) + +func OpenTiDB(forwarder *Forwarder, user string, pass string) (*gorm.DB, error) { + host, port, err := forwarder.GetDBConnProp() + if err != nil { + return nil, err + } + + dsnConfig := mysql.NewConfig() + dsnConfig.Net = "tcp" + dsnConfig.Addr = fmt.Sprintf("%s:%d", host, port) + dsnConfig.User = user + dsnConfig.Passwd = pass + dsnConfig.Timeout = time.Second + dsn := dsnConfig.FormatDSN() + + db, err := gorm.Open("mysql", dsn) + if err == driver.ErrBadConn { + return nil, ErrTiDBConnFailed.Wrap(err, "failed to connect to TiDB") + } else if mysqlErr, ok := err.(*mysql.MySQLError); ok { + if mysqlErr.Number == 1045 { + return nil, ErrTiDBAuthFailed.New("bad TiDB username or password") + } + return nil, err + } + + return db, nil +} diff --git a/pkg/tidb/errors.go b/pkg/tidb/errors.go new file mode 100644 index 0000000000..e1c1bd98db --- /dev/null +++ b/pkg/tidb/errors.go @@ -0,0 +1,26 @@ +// Copyright 2020 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// See the License for the specific language governing permissions and +// limitations under the License. + +package tidb + +import ( + "github.com/joomcode/errorx" +) + +var ( + ErrorNS = errorx.NewNamespace("error.tidb") + ErrPDAccessFailed = ErrorNS.NewType("pd_access_failed") + ErrNoAliveTiDB = ErrorNS.NewType("no_alive_tidb") + ErrTiDBConnFailed = ErrorNS.NewType("tidb_conn_failed") + ErrTiDBAuthFailed = ErrorNS.NewType("tidb_auth_failed") +) diff --git a/pkg/tidb/forwarder.go b/pkg/tidb/forwarder.go new file mode 100644 index 0000000000..9d38a9888b --- /dev/null +++ b/pkg/tidb/forwarder.go @@ -0,0 +1,84 @@ +// Copyright 2020 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// See the License for the specific language governing permissions and +// limitations under the License. + +package tidb + +import ( + "context" + "encoding/json" + "time" + + "go.etcd.io/etcd/clientv3" + + "github.com/pingcap-incubator/tidb-dashboard/pkg/pd" +) + +// FIXME: This is duplicated with the one in KeyVis. +type tidbServerInfo struct { + IP string `json:"ip"` + Port int `json:"listening_port"` +} + +type ForwarderConfig struct { + TiDBRetrieveTimeout time.Duration +} + +func NewForwarderConfig() *ForwarderConfig { + return &ForwarderConfig{ + TiDBRetrieveTimeout: time.Second, + } +} + +type Forwarder struct { + ctx context.Context + config *ForwarderConfig + etcdClient *clientv3.Client +} + +func (f *Forwarder) Open() error { + // Currently this function does nothing. + return nil +} + +func (f *Forwarder) Close() error { + // Currently this function does nothing. + return nil +} + +func (f *Forwarder) GetDBConnProp() (host string, port int, err error) { + ctx, cancel := context.WithTimeout(f.ctx, f.config.TiDBRetrieveTimeout) + resp, err := f.etcdClient.Get(ctx, pd.TiDBServerInformationPath, clientv3.WithPrefix()) + cancel() + + if err != nil { + return "", 0, ErrPDAccessFailed.New("access PD failed: %s", err) + } + + var info tidbServerInfo + for _, kv := range resp.Kvs { + err = json.Unmarshal(kv.Value, &info) + if err != nil { + continue + } + return info.IP, info.Port, nil + } + return "", 0, ErrNoAliveTiDB.New("no TiDB is alive") +} + +func NewForwarder(config *ForwarderConfig, etcdClient *clientv3.Client) *Forwarder { + return &Forwarder{ + etcdClient: etcdClient, + config: config, + ctx: context.TODO(), + } +} diff --git a/pkg/utils/info.go b/pkg/utils/info.go index 10ee4d5f61..37b0cc9cea 100644 --- a/pkg/utils/info.go +++ b/pkg/utils/info.go @@ -28,6 +28,13 @@ var ( GitBranch = "None" ) +type VersionInfo struct { + ReleaseVersion string `json:"release_version"` + BuildTime string `json:"build_time"` + BuildGitHash string `json:"build_git_hash"` + BuildGitBranch string `json:"build_git_branch"` +} + func LogInfo() { log.Info("Welcome to TiDB Dashboard") log.Info("", zap.String("release-version", ReleaseVersion)) @@ -42,3 +49,12 @@ func PrintInfo() { fmt.Println("Git Branch:", GitBranch) fmt.Println("UTC Build Time: ", BuildTS) } + +func GetVersionInfo() VersionInfo { + return VersionInfo{ + ReleaseVersion: ReleaseVersion, + BuildTime: BuildTS, + BuildGitHash: GitHash, + BuildGitBranch: GitBranch, + } +} diff --git a/ui/.openapi_config.yaml b/ui/.openapi_config.yaml new file mode 100644 index 0000000000..5bc4e8292c --- /dev/null +++ b/ui/.openapi_config.yaml @@ -0,0 +1,3 @@ +enumPropertyNaming: original +modelPropertyNaming: original +supportsES6: true diff --git a/ui/config-overrides.js b/ui/config-overrides.js index 9494f36698..4b00b5eeb3 100644 --- a/ui/config-overrides.js +++ b/ui/config-overrides.js @@ -5,6 +5,7 @@ const { addLessLoader, addWebpackResolve, addWebpackPlugin, + addDecoratorsLegacy, } = require('customize-cra'); const AntdDayjsWebpackPlugin = require('antd-dayjs-webpack-plugin'); const addYaml = require('react-app-rewire-yaml'); @@ -35,6 +36,7 @@ module.exports = override( addWebpackResolve({ alias: { '@': path.resolve(__dirname, 'src') }, }), + addDecoratorsLegacy(), enableEslintIgnore(), addYaml ); diff --git a/ui/package.json b/ui/package.json index a7d4e09463..0659e5a66a 100644 --- a/ui/package.json +++ b/ui/package.json @@ -4,8 +4,7 @@ "private": true, "dependencies": { "@baurine/grafana-value-formats": "^0.1.1", - "@pingcap-incubator/pd-client-js": "^0.1.5", - "@types/lodash": "^4.14.149", + "@g07cha/flexbox-react": "^5.0.0", "antd": "^3.26.5", "axios": "^0.19.0", "classnames": "^2.2.6", @@ -25,7 +24,7 @@ "build": "react-app-rewired build", "test": "react-app-rewired test", "eject": "react-scripts eject", - "build_api_client": "openapi-generator generate -i ../docs/swagger.yaml -g typescript-axios -o src/utils/dashboard_client" + "build_api_client": "openapi-generator generate -i ../docs/swagger.yaml -g typescript-axios -c .openapi_config.yaml -o src/utils/dashboard_client" }, "homepage": "/dashboard", "eslintConfig": { @@ -44,10 +43,12 @@ ] }, "devDependencies": { + "@babel/plugin-proposal-decorators": "^7.8.3", "@openapitools/openapi-generator-cli": "^1.0.8-4.2.2", "@types/d3": "^5.7.2", + "@types/lodash": "^4.14.149", "@types/node": "^13.1.5", - "@types/react": "^16.9.17", + "@types/react": "^16.9.22", "@types/react-router-dom": "^5.1.3", "antd-dayjs-webpack-plugin": "^0.0.8", "babel-plugin-import": "^1.13.0", diff --git a/ui/public/favicon.ico b/ui/public/favicon.ico old mode 100644 new mode 100755 index a11777cc47..9d5d2b14e1 Binary files a/ui/public/favicon.ico and b/ui/public/favicon.ico differ diff --git a/ui/src/apps/demo/index.js b/ui/src/apps/demo/index.js index 9b0fc8fd85..9eb5375147 100644 --- a/ui/src/apps/demo/index.js +++ b/ui/src/apps/demo/index.js @@ -3,5 +3,4 @@ module.exports = { loader: () => import('./app.js'), routerPrefix: '/demo', icon: 'pie-chart', - menuTitle: 'Demo 2', // TODO: I18N -} +}; diff --git a/ui/src/apps/home/index.js b/ui/src/apps/home/index.js index eef073b283..286640e043 100644 --- a/ui/src/apps/home/index.js +++ b/ui/src/apps/home/index.js @@ -3,5 +3,4 @@ module.exports = { loader: () => import('./app.js'), routerPrefix: '/home', icon: 'desktop', - menuTitle: 'Demo 1', // TODO: I18N -} +}; diff --git a/ui/src/apps/keyvis/RootComponent.less b/ui/src/apps/keyvis/RootComponent.less index afe86d76f0..a8c88176a4 100644 --- a/ui/src/apps/keyvis/RootComponent.less +++ b/ui/src/apps/keyvis/RootComponent.less @@ -17,6 +17,7 @@ align-items: center; background-color: white; padding: 16px; + flex-wrap: wrap; .PD-Cluster-Legend { width: 410px; @@ -155,11 +156,6 @@ padding: 8px; border: 1px solid #ccc; border-radius: 4px; - - display: flex; - flex-direction: column; - align-items: center; - box-shadow: @box-shadow-base; } diff --git a/ui/src/apps/keyvis/ToolBar.tsx b/ui/src/apps/keyvis/ToolBar.tsx index a73ead402a..dd742cdbe8 100644 --- a/ui/src/apps/keyvis/ToolBar.tsx +++ b/ui/src/apps/keyvis/ToolBar.tsx @@ -1,6 +1,7 @@ import { Slider, Spin, Icon, Select, Dropdown, Button } from 'antd'; import React, { Component } from 'react'; import { withTranslation, WithTranslation } from 'react-i18next'; +import Flexbox from '@g07cha/flexbox-react'; export interface IKeyVisToolBarProps { isLoading: boolean; @@ -19,7 +20,6 @@ export interface IKeyVisToolBarProps { class KeyVisToolBar extends Component { state = { - brightnessDropdownVisible: false, exp: 0, }; @@ -40,8 +40,7 @@ class KeyVisToolBar extends Component { this.setState({ exp }); }; - handleBrightnessDropdown = (visible: boolean) => { - this.setState({ brightnessDropdownVisible: visible }); + handleBrightnessDropdown = () => { setTimeout(() => { this.handleBrightLevel(this.state.exp); }, 0); @@ -89,20 +88,27 @@ class KeyVisToolBar extends Component { - - this.handleBrightLevel(value as number)} - /> + { + e.stopPropagation(); + }} + > + + + this.handleBrightLevel(value as number)} + /> + + } trigger={['click']} onVisibleChange={this.handleBrightnessDropdown} - visible={this.state.brightnessDropdownVisible} > {t('keyvis.toolbar.brightness')} diff --git a/ui/src/apps/keyvis/index.js b/ui/src/apps/keyvis/index.js index 1fca4c3b87..db69bbd402 100644 --- a/ui/src/apps/keyvis/index.js +++ b/ui/src/apps/keyvis/index.js @@ -3,10 +3,6 @@ module.exports = { loader: () => import('./app.js'), routerPrefix: '/keyvis', icon: 'eye', - menuTitle: 'Key Visualizer', // TODO: I18N isDefaultRouter: true, - translations: { - en: require('./translations/en.yaml'), - zh_CN: require('./translations/zh_CN.yaml'), - }, + translations: require.context('./translations/', false, /\.yaml$/), }; diff --git a/ui/src/apps/keyvis/translations/en.yaml b/ui/src/apps/keyvis/translations/en.yaml index badae4a067..ab2c36ef5f 100644 --- a/ui/src/apps/keyvis/translations/en.yaml +++ b/ui/src/apps/keyvis/translations/en.yaml @@ -1,4 +1,5 @@ keyvis: + nav_title: Key Visualizer toolbar: brightness: Brightness zoom: diff --git a/ui/src/apps/keyvis/translations/zh_CN.yaml b/ui/src/apps/keyvis/translations/zh-CN.yaml similarity index 92% rename from ui/src/apps/keyvis/translations/zh_CN.yaml rename to ui/src/apps/keyvis/translations/zh-CN.yaml index 768c7ca5d5..361ff7a5e1 100644 --- a/ui/src/apps/keyvis/translations/zh_CN.yaml +++ b/ui/src/apps/keyvis/translations/zh-CN.yaml @@ -1,4 +1,5 @@ keyvis: + nav_title: 热点可视化 toolbar: brightness: 调整亮度 zoom: diff --git a/ui/src/apps/statement/index.js b/ui/src/apps/statement/index.js index bfe51e3ab4..4947c82395 100644 --- a/ui/src/apps/statement/index.js +++ b/ui/src/apps/statement/index.js @@ -3,5 +3,4 @@ module.exports = { loader: () => import('./app.js'), routerPrefix: '/statement', icon: 'line-chart', - menuTitle: 'Statement' // TODO: I18N -} +}; diff --git a/ui/src/layout/LangDropdown.js b/ui/src/components/LanguageDropdown/index.js similarity index 57% rename from ui/src/layout/LangDropdown.js rename to ui/src/components/LanguageDropdown/index.js index 760a55133e..36260bec12 100644 --- a/ui/src/layout/LangDropdown.js +++ b/ui/src/components/LanguageDropdown/index.js @@ -1,23 +1,26 @@ import React from 'react'; -import { Menu, Icon, Dropdown } from 'antd'; +import { Menu, Dropdown } from 'antd'; import _ from 'lodash'; import { withTranslation } from 'react-i18next'; -import NavAction from './NavAction'; -class LangDropdown extends React.PureComponent { +@withTranslation() +class LanguageDropdown extends React.PureComponent { handleClick = e => { - console.log('Changing language to', e.key); + console.log('Change language to', e.key); this.props.i18n.changeLanguage(e.key); }; render() { const languages = { - zh_CN: '简体中文', + 'zh-CN': '简体中文', en: 'English', }; const menu = ( - + {_.map(languages, (name, key) => { return {name}; })} @@ -26,12 +29,10 @@ class LangDropdown extends React.PureComponent { return ( - - - + {this.props.children} ); } } -export default withTranslation()(LangDropdown); +export default LanguageDropdown; diff --git a/ui/src/index.js b/ui/src/index.js index 9ee85e56dc..60ec2f9a50 100644 --- a/ui/src/index.js +++ b/ui/src/index.js @@ -1,122 +1,63 @@ -import React from 'react'; -import { Menu, Icon } from 'antd'; -import { Link } from 'react-router-dom'; import * as singleSpa from 'single-spa'; +import AppRegistry from '@/utils/registry'; +import * as routingUtil from '@/utils/routing'; +import * as authUtil from '@/utils/auth'; +import * as i18nUtil from '@/utils/i18n'; -import i18n from './utils/i18n'; - -import * as LayoutSPA from '@/layout'; +import * as LayoutMain from '@/layout'; +import * as LayoutSignIn from '@/layout/signin'; import AppKeyVis from '@/apps/keyvis'; import AppHome from '@/apps/home'; import AppDemo from '@/apps/demo'; import AppStatement from '@/apps/statement'; -// TODO: This part might be better in TS. -class AppRegistry { - constructor() { - this.defaultRouter = ''; - this.apps = {}; - } - - /** - * Register a TiDB Dashboard application. - * - * This function is a light encapsulation over single-spa's registerApplication - * which provides some extra registry capabilities. - * - * @param {{ - * id: string, - * loader: Function, - * routerPrefix: string, - * indexRoute: string, - * isDefaultRouter: boolean, - * icon: string, - * menuTitle: string, - * }} app - */ - register(app) { - if (app.translations) { - i18n.loadResource(app.translations); - } - - singleSpa.registerApplication( - app.id, - app.loader, - location => { - return location.hash.indexOf('#' + app.routerPrefix) === 0; - }, - { - registry: this, - app, - } - ); - if (!app.indexRoute) { - app.indexRoute = app.routerPrefix; - } - if (!this.defaultRouter || app.isDefaultRouter) { - this.defaultRouter = app.indexRoute; - } - this.apps[app.id] = app; - return this; - } - - /** - * Get the default router for initial routing. - */ - getDefaultRouter() { - return this.defaultRouter || '/'; +async function main() { + const registry = new AppRegistry(); + + singleSpa.registerApplication( + 'layout', + LayoutMain, + () => { + return !routingUtil.isLocationMatchPrefix(authUtil.signInRoute); + }, + { registry } + ); + + singleSpa.registerApplication( + 'signin', + LayoutSignIn, + () => { + return routingUtil.isLocationMatchPrefix(authUtil.signInRoute); + }, + { registry } + ); + + i18nUtil.init(); + i18nUtil.addTranslations( + require.context('@/layout/translations/', false, /\.yaml$/) + ); + + registry + .register(AppKeyVis) + .register(AppHome) + .register(AppDemo) + .register(AppStatement); + + if (routingUtil.isLocationMatch('/')) { + singleSpa.navigateToUrl('#' + registry.getDefaultRouter()); } - /** - * Get the registry of the current active app. - */ - getActiveApp() { - const mountedApps = singleSpa.getMountedApps(); - for (let i = 0; i < mountedApps.length; i++) { - const app = mountedApps[i]; - if (this.apps[app] !== undefined) { - return this.apps[app]; + window.addEventListener('single-spa:app-change', () => { + if (!routingUtil.isLocationMatchPrefix(authUtil.signInRoute)) { + if (!authUtil.getAuthTokenAsBearer()) { + singleSpa.navigateToUrl('#' + authUtil.signInRoute); + return; } } - } - - /** - * Render an Antd menu item according to the app id. - * - * @param {string} appId - */ - renderAppMenuItem(appId) { - const app = this.apps[appId]; - if (!app) { - return null; - } - return ( - - - {app.icon ? : null} - {app.menuTitle} - - - ); - } -} - -const registry = new AppRegistry(); - -singleSpa.registerApplication('layout', LayoutSPA, () => true, { registry }); -registry - .register(AppKeyVis) - .register(AppHome) - .register(AppDemo) - .register(AppStatement); - -i18n.initFromResources(); - -singleSpa.start(); + }); -const hash = window.location.hash; -if (hash === '' || hash === '#' || hash === '#/') { - singleSpa.navigateToUrl('#' + registry.getDefaultRouter()); + singleSpa.start(); + document.getElementById('dashboard_page_spinner').remove(); } -document.getElementById('dashboard_page_spinner').remove(); +main(); diff --git a/ui/src/layout/Nav.js b/ui/src/layout/Nav.js new file mode 100644 index 0000000000..e41249ee34 --- /dev/null +++ b/ui/src/layout/Nav.js @@ -0,0 +1,82 @@ +import React from 'react'; +import { Layout, Menu, Dropdown, Icon } from 'antd'; +import Flexbox from '@g07cha/flexbox-react'; +import LanguageDropdown from '@/components/LanguageDropdown'; +import NavAction from './NavAction'; +import { withTranslation } from 'react-i18next'; +import client from '@/utils/client'; +import * as authUtil from '@/utils/auth'; + +import styles from './Nav.module.less'; + +@withTranslation() +class Nav extends React.PureComponent { + state = { + login: null, + }; + + handleToggle = () => { + this.props.onToggle && this.props.onToggle(); + }; + + handleUserMenuClick = item => { + switch (item.key) { + case 'signout': + authUtil.clearAuthToken(); + window.location.reload(); + break; + default: + } + }; + + async componentDidMount() { + const resp = await client.dashboard.infoWhoamiGet(); + if (resp.data) { + this.setState({ login: resp.data }); + } + } + + render() { + const userMenu = ( + + + {this.props.t('nav.user.signout')} + + + ); + + return ( + + + + + + + + + + + + + + {this.state.login ? this.state.login.username : '...'} + + + + + + + ); + } +} + +export default Nav; diff --git a/ui/src/layout/Nav.module.less b/ui/src/layout/Nav.module.less new file mode 100644 index 0000000000..fd3426d1bd --- /dev/null +++ b/ui/src/layout/Nav.module.less @@ -0,0 +1,30 @@ +@import '~antd/es/style/themes/default.less'; + +.siderTrigger { + display: inline-block; + height: @layout-header-height; + padding: 0 24px; + font-size: 20px; + cursor: pointer; + transition: all 0.3s, padding 0s; + &:hover { + color: @primary-color; + } +} + +.nav { + position: fixed; + right: 0; + z-index: 10; + padding: 0; + background: #fff; + box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08); + width: 100%; + transition: width 0.2s; +} + +.navRight { + float: right; + margin-left: auto; + overflow: hidden; +} diff --git a/ui/src/layout/RootComponent.js b/ui/src/layout/RootComponent.js index f6a84e7526..377f56d0ad 100644 --- a/ui/src/layout/RootComponent.js +++ b/ui/src/layout/RootComponent.js @@ -1,21 +1,13 @@ import React from 'react'; import { Layout, Menu, Icon } from 'antd'; +import { Link } from 'react-router-dom'; import { HashRouter as Router } from 'react-router-dom'; -import LangDropdown from './LangDropdown'; +import { withTranslation } from 'react-i18next'; +import Nav from './Nav'; import styles from './RootComponent.module.less'; -class NavRight extends React.PureComponent { - render() { - return ( - <> - - {this.props.children} - > - ); - } -} - +@withTranslation() class App extends React.PureComponent { state = { collapsed: false, @@ -28,7 +20,7 @@ class App extends React.PureComponent { window.dispatchEvent(event); }; - toggle = () => { + handleToggle = () => { this.setState( { collapsed: !this.state.collapsed, @@ -48,7 +40,7 @@ class App extends React.PureComponent { } }; - componentDidMount() { + async componentDidMount() { window.addEventListener('single-spa:routing-event', this.handleRouting); } @@ -56,6 +48,22 @@ class App extends React.PureComponent { window.removeEventListener('single-spa:routing-event', this.handleRouting); } + renderAppMenuItem = appId => { + const registry = this.props.registry; + const app = registry.apps[appId]; + if (!app) { + return null; + } + return ( + + + {app.icon ? : null} + {this.props.t(`${appId}.nav_title`)} + + + ); + }; + render() { const siderWidth = 260; const isDev = process.env.NODE_ENV === 'development'; @@ -76,10 +84,8 @@ class App extends React.PureComponent { selectedKeys={[this.state.activeAppId]} defaultOpenKeys={['sub1']} > - {this.props.registry.renderAppMenuItem('keyvis')} - {isDev - ? this.props.registry.renderAppMenuItem('statement') - : null} + {this.renderAppMenuItem('keyvis')} + {isDev ? this.renderAppMenuItem('statement') : null} {isDev ? ( } > - {this.props.registry.renderAppMenuItem('home')} - {this.props.registry.renderAppMenuItem('demo')} + {this.renderAppMenuItem('home')} + {this.renderAppMenuItem('demo')} ) : null} - - - - - - - - + { + this.setState({ loading: true }); + try { + const r = await client.dashboard.userLoginPost({ + username: form.username, + password: form.password, + is_tidb_auth: true, + }); + authUtil.setAuthToken(r.data.token); + message.success(this.props.t('signin.message.success')); + singleSpa.navigateToUrl('#' + this.props.registry.getDefaultRouter()); + } catch (e) { + console.log(e); + if (!e.handled) { + let msg; + if (e.response.data) { + msg = this.props.t(e.response.data.code); + } else { + msg = e.message; + } + message.error(this.props.t('signin.message.error', { msg })); + } + } + this.setState({ loading: false }); + }; + + handleSubmit = e => { + e.preventDefault(); + this.props.form.validateFields((err, values) => { + if (err) { + return; + } + this.signIn(values); + }); + }; + + render() { + const { getFieldDecorator } = this.props.form; + const { t } = this.props; + return ( + + + + + + {getFieldDecorator('username', { + rules: [ + { + required: true, + message: t('signin.form.tidb_auth.check.username'), + }, + ], + initialValue: 'root', + })(} disabled />)} + + + {getFieldDecorator('password')( + } + type="password" + disabled={this.state.loading} + /> + )} + + + + {t('signin.form.button')} + + + + ); + } +} + +@withTranslation() +class App extends React.PureComponent { + render() { + const { t, registry } = this.props; + return ( + + + + TiDB Dashboard + + + + + + + + + + + + + + ); + } +} + +export default App; diff --git a/ui/src/layout/signin/RootComponent.module.less b/ui/src/layout/signin/RootComponent.module.less new file mode 100644 index 0000000000..087f1fce76 --- /dev/null +++ b/ui/src/layout/signin/RootComponent.module.less @@ -0,0 +1,13 @@ +.container { + width: 380px; + margin: 0 auto; + padding-top: 30px; +} + +.dialog { + background-color: #fff; + padding: 40px; + padding-bottom: 10px; + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} diff --git a/ui/src/layout/signin/index.js b/ui/src/layout/signin/index.js new file mode 100644 index 0000000000..d480dbdce5 --- /dev/null +++ b/ui/src/layout/signin/index.js @@ -0,0 +1,19 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import singleSpaReact from 'single-spa-react'; +import RootComponent from './RootComponent.js'; + +const reactLifecycles = singleSpaReact({ + React, + ReactDOM, + rootComponent: RootComponent, + domElementGetter, +}); + +export const bootstrap = [reactLifecycles.bootstrap]; +export const mount = [reactLifecycles.mount]; +export const unmount = [reactLifecycles.unmount]; + +function domElementGetter() { + return document.getElementById('root'); +} diff --git a/ui/src/layout/translations/en.yaml b/ui/src/layout/translations/en.yaml new file mode 100644 index 0000000000..7a3dbe03a7 --- /dev/null +++ b/ui/src/layout/translations/en.yaml @@ -0,0 +1,27 @@ +error: + message: + network: Network connection error + unauthorized: Session is expired. Please sign in again. + tidb: + no_alive_tidb: No alive TiDB instance in the cluster + pd_access_failed: Failed to access PD node + tidb_conn_failed: Failed to connect to TiDB + tidb_auth_failed: TiDB authentication failed + api: + other: Other error +signin: + message: + error: 'Sign in failed: {{ msg }}' + success: Sign in successfully + form: + username: Username + password: Password + button: Sign In + tidb_auth: + message: You can sign in using TiDB SQL user if TiDB instance is started in the cluster. + title: TiDB User + check: + message: Username is required +nav: + user: + signout: Sign Out diff --git a/ui/src/layout/translations/zh-CN.yaml b/ui/src/layout/translations/zh-CN.yaml new file mode 100644 index 0000000000..c255ffbdea --- /dev/null +++ b/ui/src/layout/translations/zh-CN.yaml @@ -0,0 +1,27 @@ +error: + message: + network: 网络连接失败 + unauthorized: 会话已过期,请重新登录 + tidb: + no_alive_tidb: 集群未启动 TiDB 实例 + pd_access_failed: 无法访问 PD 节点 + tidb_conn_failed: 无法连接到 TiDB + tidb_auth_failed: TiDB 登录验证失败 + api: + other: 其他错误 +signin: + message: + error: '登录失败: {{ msg }}' + success: 登录成功 + form: + username: 用户名 + password: 密码 + button: 登录 + tidb_auth: + message: 集群中启动有 TiDB 节点时您可以使用 TiDB SQL 用户登录集群。 + title: TiDB 用户 + check: + message: 请输入用户名 +nav: + user: + signout: 登出 diff --git a/ui/src/utils/auth.js b/ui/src/utils/auth.js new file mode 100644 index 0000000000..b3c699a967 --- /dev/null +++ b/ui/src/utils/auth.js @@ -0,0 +1,23 @@ +const tokenKey = 'dashboard_auth_token'; + +export const signInRoute = '/signin'; + +export function getAuthToken() { + return localStorage.getItem(tokenKey); +} + +export function getAuthTokenAsBearer() { + const token = getAuthToken(); + if (!token) { + return null; + } + return `Bearer ${token}`; +} + +export function setAuthToken(token) { + localStorage.setItem(tokenKey, token); +} + +export function clearAuthToken() { + localStorage.removeItem(tokenKey); +} diff --git a/ui/src/utils/client/index.js b/ui/src/utils/client/index.js index dce4680826..eb1beb6ceb 100644 --- a/ui/src/utils/client/index.js +++ b/ui/src/utils/client/index.js @@ -1,5 +1,10 @@ -import * as DashboardClient from '../dashboard_client'; -import PDClient from '@pingcap-incubator/pd-client-js'; +import i18n from 'i18next'; +import axios from 'axios'; +import { message } from 'antd'; +import * as singleSpa from 'single-spa'; +import * as DashboardClient from '@/utils/dashboard_client'; +import * as authUtil from '@/utils/auth'; +import * as routingUtil from '@/utils/routing'; let DASHBOARD_API_URL_PERFIX = 'http://127.0.0.1:12333'; if (process.env.REACT_APP_DASHBOARD_API_URL !== undefined) { @@ -11,16 +16,35 @@ const DASHBOARD_API_URL = `${DASHBOARD_API_URL_PERFIX}/dashboard/api`; console.log(`Dashboard API URL: ${DASHBOARD_API_URL}`); -const dashboardClient = new DashboardClient.DefaultApi({ - basePath: DASHBOARD_API_URL, +axios.interceptors.response.use(undefined, function(err) { + const { response } = err; + // Handle unauthorized error in a unified way + if ( + response && + response.data && + response.data.code === 'error.api.unauthorized' + ) { + if ( + !routingUtil.isLocationMatch('/') && + !routingUtil.isLocationMatchPrefix(authUtil.signInRoute) + ) { + message.error(i18n.t('error.message.unauthorized')); + } + authUtil.clearAuthToken(); + singleSpa.navigateToUrl('#' + authUtil.signInRoute); + err.handled = true; + } else if (err.message === 'Network Error') { + message.error(i18n.t('error.message.network')); + err.handled = true; + } + return Promise.reject(err); }); -// TODO: replace 'PD_API_BASE_URL' by real value -const pdClient = new PDClient({ - endpoint: 'PD_API_BASE_URL' +const dashboardClient = new DashboardClient.DefaultApi({ + basePath: DASHBOARD_API_URL, + apiKey: () => authUtil.getAuthTokenAsBearer(), }); export default { dashboard: dashboardClient, - pd: pdClient }; diff --git a/ui/src/utils/i18n.js b/ui/src/utils/i18n.js index 147ccfd237..18ae5918de 100644 --- a/ui/src/utils/i18n.js +++ b/ui/src/utils/i18n.js @@ -1,39 +1,29 @@ -import i18n from 'i18next'; +import i18next from 'i18next'; import { initReactI18next } from 'react-i18next'; import LanguageDetector from 'i18next-browser-languagedetector'; -import _ from 'lodash'; -const resources = {}; -const languages = ['en', 'zh_CN']; - -function loadResource(res) { - languages.forEach(lang => { - _.merge(resources, { [lang]: { translation: res[lang] } }); +export function addTranslations(requireContext) { + const keys = requireContext.keys(); + keys.forEach(key => { + const m = key.match(/\/(.+)\.yaml/); + if (!m) { + return; + } + const lang = m[1]; + const translations = requireContext(key); + i18next.addResourceBundle(lang, 'translation', translations, true, false); }); } -function initFromResources() { - // Resource languages are like `zh_CN` for easier writing. - // However we need to change them to `zh-CN` to follow IETF language codes. - const r = _(resources) - .toPairs() - .map(([key, value]) => [key.replace(/_/g, '-'), value]) - .fromPairs() - .value(); - - i18n +export function init() { + i18next .use(LanguageDetector) .use(initReactI18next) .init({ - resources: r, + resources: {}, fallbackLng: 'en', interpolation: { escapeValue: false, }, }); } - -export default { - loadResource, - initFromResources, -}; diff --git a/ui/src/utils/registry.js b/ui/src/utils/registry.js new file mode 100644 index 0000000000..5d35219e48 --- /dev/null +++ b/ui/src/utils/registry.js @@ -0,0 +1,72 @@ +import * as singleSpa from 'single-spa'; +import * as i18nUtil from '@/utils/i18n'; +import * as routingUtil from '@/utils/routing'; + +// TODO: This part might be better in TS. +export default class AppRegistry { + constructor() { + this.defaultRouter = ''; + this.apps = {}; + } + + /** + * Register a TiDB Dashboard application. + * + * This function is a light encapsulation over single-spa's registerApplication + * which provides some extra registry capabilities. + * + * @param {{ + * id: string, + * loader: Function, + * routerPrefix: string, + * indexRoute: string, + * isDefaultRouter: boolean, + * icon: string, + * }} app + */ + register(app) { + if (app.translations) { + i18nUtil.addTranslations(app.translations); + } + + singleSpa.registerApplication( + app.id, + app.loader, + () => { + return routingUtil.isLocationMatchPrefix(app.routerPrefix); + }, + { + registry: this, + app, + } + ); + if (!app.indexRoute) { + app.indexRoute = app.routerPrefix; + } + if (!this.defaultRouter || app.isDefaultRouter) { + this.defaultRouter = app.indexRoute; + } + this.apps[app.id] = app; + return this; + } + + /** + * Get the default router for initial routing. + */ + getDefaultRouter() { + return this.defaultRouter || '/'; + } + + /** + * Get the registry of the current active app. + */ + getActiveApp() { + const mountedApps = singleSpa.getMountedApps(); + for (let i = 0; i < mountedApps.length; i++) { + const app = mountedApps[i]; + if (this.apps[app] !== undefined) { + return this.apps[app]; + } + } + } +} diff --git a/ui/src/utils/routing.js b/ui/src/utils/routing.js new file mode 100644 index 0000000000..83fc52937b --- /dev/null +++ b/ui/src/utils/routing.js @@ -0,0 +1,15 @@ +export function isLocationMatch(s, matchPrefix) { + let hash = window.location.hash; + if (!hash || hash === '#') { + hash = '#/'; + } + if (matchPrefix) { + return hash.indexOf(`#${s}`) === 0; + } else { + return hash.trim() === `#${s}`; + } +} + +export function isLocationMatchPrefix(s) { + return isLocationMatch(s, true); +} diff --git a/ui/tsconfig.json b/ui/tsconfig.json index 9d658048f5..ba2d52ea76 100644 --- a/ui/tsconfig.json +++ b/ui/tsconfig.json @@ -20,6 +20,7 @@ "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, + "experimentalDecorators": true, "jsx": "react" }, "include": [ diff --git a/ui/yarn.lock b/ui/yarn.lock index f5aa85c9c2..5bfcd1eeb0 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -37,6 +37,13 @@ dependencies: "@babel/highlight" "^7.0.0" +"@babel/code-frame@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.8.3.tgz#33e25903d7481181534e12ec0a25f16b6fcf419e" + integrity sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g== + dependencies: + "@babel/highlight" "^7.8.3" + "@babel/core@7.6.0": version "7.6.0" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.6.0.tgz#9b00f73554edd67bebc86df8303ef678be3d7b48" @@ -107,6 +114,16 @@ lodash "^4.17.13" source-map "^0.5.0" +"@babel/generator@^7.8.4": + version "7.8.4" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.8.4.tgz#35bbc74486956fe4251829f9f6c48330e8d0985e" + integrity sha512-PwhclGdRpNAf3IxZb0YVuITPZmmrXz9zf6fH8lT4XbrmfQKr6ryBzhv593P5C6poJRciFCL/eHGW2NuGrgEyxA== + dependencies: + "@babel/types" "^7.8.3" + jsesc "^2.5.1" + lodash "^4.17.13" + source-map "^0.5.0" + "@babel/helper-annotate-as-pure@^7.7.4": version "7.7.4" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.7.4.tgz#bb3faf1e74b74bd547e867e48f551fa6b098b6ce" @@ -151,6 +168,18 @@ "@babel/helper-replace-supers" "^7.7.4" "@babel/helper-split-export-declaration" "^7.7.4" +"@babel/helper-create-class-features-plugin@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.8.3.tgz#5b94be88c255f140fd2c10dd151e7f98f4bff397" + integrity sha512-qmp4pD7zeTxsv0JNecSBsEmG1ei2MqwJq4YQcK3ZWm/0t07QstWfvuV/vm3Qt5xNMFETn2SZqpMx2MQzbtq+KA== + dependencies: + "@babel/helper-function-name" "^7.8.3" + "@babel/helper-member-expression-to-functions" "^7.8.3" + "@babel/helper-optimise-call-expression" "^7.8.3" + "@babel/helper-plugin-utils" "^7.8.3" + "@babel/helper-replace-supers" "^7.8.3" + "@babel/helper-split-export-declaration" "^7.8.3" + "@babel/helper-create-regexp-features-plugin@^7.7.4": version "7.7.4" resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.7.4.tgz#6d5762359fd34f4da1500e4cff9955b5299aaf59" @@ -185,6 +214,15 @@ "@babel/template" "^7.7.4" "@babel/types" "^7.7.4" +"@babel/helper-function-name@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.8.3.tgz#eeeb665a01b1f11068e9fb86ad56a1cb1a824cca" + integrity sha512-BCxgX1BC2hD/oBlIFUgOCQDOPV8nSINxCwM3o93xP4P9Fq6aV5sgv2cOOITDMtCfQ+3PvHp3l689XZvAM9QyOA== + dependencies: + "@babel/helper-get-function-arity" "^7.8.3" + "@babel/template" "^7.8.3" + "@babel/types" "^7.8.3" + "@babel/helper-get-function-arity@^7.7.4": version "7.7.4" resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.7.4.tgz#cb46348d2f8808e632f0ab048172130e636005f0" @@ -192,6 +230,13 @@ dependencies: "@babel/types" "^7.7.4" +"@babel/helper-get-function-arity@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.8.3.tgz#b894b947bd004381ce63ea1db9f08547e920abd5" + integrity sha512-FVDR+Gd9iLjUMY1fzE2SR0IuaJToR4RkCDARVfsBBPSP53GEqSFjD8gNyxg246VUyc/ALRxFaAK8rVG7UT7xRA== + dependencies: + "@babel/types" "^7.8.3" + "@babel/helper-hoist-variables@^7.7.4": version "7.7.4" resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.7.4.tgz#612384e3d823fdfaaf9fce31550fe5d4db0f3d12" @@ -206,6 +251,13 @@ dependencies: "@babel/types" "^7.7.4" +"@babel/helper-member-expression-to-functions@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.8.3.tgz#659b710498ea6c1d9907e0c73f206eee7dadc24c" + integrity sha512-fO4Egq88utkQFjbPrSHGmGLFqmrshs11d46WI+WZDESt7Wu7wN2G2Iu+NMMZJFDOVRHAMIkB5SNh30NtwCA7RA== + dependencies: + "@babel/types" "^7.8.3" + "@babel/helper-module-imports@^7.0.0", "@babel/helper-module-imports@^7.7.4": version "7.7.4" resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.7.4.tgz#e5a92529f8888bf319a6376abfbd1cebc491ad91" @@ -232,11 +284,23 @@ dependencies: "@babel/types" "^7.7.4" +"@babel/helper-optimise-call-expression@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.8.3.tgz#7ed071813d09c75298ef4f208956006b6111ecb9" + integrity sha512-Kag20n86cbO2AvHca6EJsvqAd82gc6VMGule4HwebwMlwkpXuVqrNRj6CkCV2sKxgi9MyAUnZVnZ6lJ1/vKhHQ== + dependencies: + "@babel/types" "^7.8.3" + "@babel/helper-plugin-utils@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.0.0.tgz#bbb3fbee98661c569034237cc03967ba99b4f250" integrity sha512-CYAOUCARwExnEixLdB6sDm2dIJ/YgEAKDM1MOeMeZu9Ld/bDgVo8aiWrXwcY7OBh+1Ea2uUcVRcxKk0GJvW7QA== +"@babel/helper-plugin-utils@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.8.3.tgz#9ea293be19babc0f52ff8ca88b34c3611b208670" + integrity sha512-j+fq49Xds2smCUNYmEHF9kGNkhbet6yVIBp4e6oeQpH1RUs/Ir06xUKzDjDkGcaaokPiTNs2JBWHjaE4csUkZQ== + "@babel/helper-regex@^7.0.0", "@babel/helper-regex@^7.4.4": version "7.5.5" resolved "https://registry.yarnpkg.com/@babel/helper-regex/-/helper-regex-7.5.5.tgz#0aa6824f7100a2e0e89c1527c23936c152cab351" @@ -265,6 +329,16 @@ "@babel/traverse" "^7.7.4" "@babel/types" "^7.7.4" +"@babel/helper-replace-supers@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.8.3.tgz#91192d25f6abbcd41da8a989d4492574fb1530bc" + integrity sha512-xOUssL6ho41U81etpLoT2RTdvdus4VfHamCuAm4AHxGr+0it5fnwoVdwUJ7GFEqCsQYzJUhcbsN9wB9apcYKFA== + dependencies: + "@babel/helper-member-expression-to-functions" "^7.8.3" + "@babel/helper-optimise-call-expression" "^7.8.3" + "@babel/traverse" "^7.8.3" + "@babel/types" "^7.8.3" + "@babel/helper-simple-access@^7.7.4": version "7.7.4" resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.7.4.tgz#a169a0adb1b5f418cfc19f22586b2ebf58a9a294" @@ -280,6 +354,13 @@ dependencies: "@babel/types" "^7.7.4" +"@babel/helper-split-export-declaration@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.8.3.tgz#31a9f30070f91368a7182cf05f831781065fc7a9" + integrity sha512-3x3yOeyBhW851hroze7ElzdkeRXQYQbFIb7gLK1WQYsw2GWDay5gAJNw1sWJ0VFP6z5J1whqeXH/WCdCjZv6dA== + dependencies: + "@babel/types" "^7.8.3" + "@babel/helper-wrap-function@^7.7.4": version "7.7.4" resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.7.4.tgz#37ab7fed5150e22d9d7266e830072c0cdd8baace" @@ -308,11 +389,25 @@ esutils "^2.0.2" js-tokens "^4.0.0" +"@babel/highlight@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.8.3.tgz#28f173d04223eaaa59bc1d439a3836e6d1265797" + integrity sha512-PX4y5xQUvy0fnEVHrYOarRPXVWafSjTW9T0Hab8gVIawpl2Sj0ORyrygANq+KjcNlSSTw0YCLSNA8OyZ1I4yEg== + dependencies: + chalk "^2.0.0" + esutils "^2.0.2" + js-tokens "^4.0.0" + "@babel/parser@^7.0.0", "@babel/parser@^7.1.0", "@babel/parser@^7.4.3", "@babel/parser@^7.6.0", "@babel/parser@^7.7.4", "@babel/parser@^7.7.7": version "7.7.7" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.7.7.tgz#1b886595419cf92d811316d5b715a53ff38b4937" integrity sha512-WtTZMZAZLbeymhkd/sEaPD8IQyGAhmuTuvTzLiCFM7iXiVdY0gc0IaI+cW0fh1BnSMbJSzXX6/fHllgHKwHhXw== +"@babel/parser@^7.8.3", "@babel/parser@^7.8.4": + version "7.8.4" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.8.4.tgz#d1dbe64691d60358a974295fa53da074dd2ce8e8" + integrity sha512-0fKu/QqildpXmPVaRBoXOlyBb3MC+J0A66x97qEfLOMkn3u6nfY5esWogQwi/K0BjASYy4DbnsEWnpNL6qT5Mw== + "@babel/plugin-proposal-async-generator-functions@^7.7.4": version "7.7.4" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.7.4.tgz#0351c5ac0a9e927845fffd5b82af476947b7ce6d" @@ -339,6 +434,15 @@ "@babel/helper-plugin-utils" "^7.0.0" "@babel/plugin-syntax-decorators" "^7.7.4" +"@babel/plugin-proposal-decorators@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.8.3.tgz#2156860ab65c5abf068c3f67042184041066543e" + integrity sha512-e3RvdvS4qPJVTe288DlXjwKflpfy1hr0j5dz5WpIYYeP7vQZg2WfAEIp8k5/Lwis/m5REXEteIz6rrcDtXXG7w== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.8.3" + "@babel/helper-plugin-utils" "^7.8.3" + "@babel/plugin-syntax-decorators" "^7.8.3" + "@babel/plugin-proposal-dynamic-import@^7.7.4": version "7.7.4" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.7.4.tgz#dde64a7f127691758cbfed6cf70de0fa5879d52d" @@ -425,6 +529,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.0.0" +"@babel/plugin-syntax-decorators@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.8.3.tgz#8d2c15a9f1af624b0025f961682a9d53d3001bda" + integrity sha512-8Hg4dNNT9/LcA1zQlfwuKR8BUc/if7Q7NkTam9sGTcJphLwpf2g4S42uhspQrIrR+dpzE0dtTqBVFoHl8GtnnQ== + dependencies: + "@babel/helper-plugin-utils" "^7.8.3" + "@babel/plugin-syntax-dynamic-import@7.7.4", "@babel/plugin-syntax-dynamic-import@^7.7.4": version "7.7.4" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.7.4.tgz#29ca3b4415abfe4a5ec381e903862ad1a54c3aec" @@ -984,6 +1095,15 @@ "@babel/parser" "^7.7.4" "@babel/types" "^7.7.4" +"@babel/template@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.8.3.tgz#e02ad04fe262a657809327f578056ca15fd4d1b8" + integrity sha512-04m87AcQgAFdvuoyiQ2kgELr2tV8B4fP/xJAVUL3Yb3bkNdMedD3d0rlSQr3PegP0cms3eHjl1F7PWlvWbU8FQ== + dependencies: + "@babel/code-frame" "^7.8.3" + "@babel/parser" "^7.8.3" + "@babel/types" "^7.8.3" + "@babel/traverse@^7.0.0", "@babel/traverse@^7.1.0", "@babel/traverse@^7.4.3", "@babel/traverse@^7.6.0", "@babel/traverse@^7.7.4": version "7.7.4" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.7.4.tgz#9c1e7c60fb679fe4fcfaa42500833333c2058558" @@ -999,6 +1119,21 @@ globals "^11.1.0" lodash "^4.17.13" +"@babel/traverse@^7.8.3": + version "7.8.4" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.8.4.tgz#f0845822365f9d5b0e312ed3959d3f827f869e3c" + integrity sha512-NGLJPZwnVEyBPLI+bl9y9aSnxMhsKz42so7ApAv9D+b4vAFPpY013FTS9LdKxcABoIYFU52HcYga1pPlx454mg== + dependencies: + "@babel/code-frame" "^7.8.3" + "@babel/generator" "^7.8.4" + "@babel/helper-function-name" "^7.8.3" + "@babel/helper-split-export-declaration" "^7.8.3" + "@babel/parser" "^7.8.4" + "@babel/types" "^7.8.3" + debug "^4.1.0" + globals "^11.1.0" + lodash "^4.17.13" + "@babel/types@^7.0.0", "@babel/types@^7.3.0", "@babel/types@^7.4.0", "@babel/types@^7.4.4", "@babel/types@^7.6.0", "@babel/types@^7.7.4": version "7.7.4" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.7.4.tgz#516570d539e44ddf308c07569c258ff94fde9193" @@ -1008,6 +1143,15 @@ lodash "^4.17.13" to-fast-properties "^2.0.0" +"@babel/types@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.8.3.tgz#5a383dffa5416db1b73dedffd311ffd0788fb31c" + integrity sha512-jBD+G8+LWpMBBWvVcdr4QysjUE4mU/syrhN17o1u3gx0/WzJB1kwiVZAXRtWbsIPOwW8pF/YJV5+nmetPzepXg== + dependencies: + esutils "^2.0.2" + lodash "^4.17.13" + to-fast-properties "^2.0.0" + "@baurine/grafana-value-formats@^0.1.1": version "0.1.1" resolved "https://npm.pkg.github.com/download/@baurine/grafana-value-formats/0.1.1/35ae41f50724009ae383412b6d39fb7e87620b3b17c2467bdfbf5d72c60e636e#ee5dabfc2bb59e148e6304e5d272c2f40fecbefc" @@ -1031,6 +1175,16 @@ resolved "https://registry.yarnpkg.com/@csstools/normalize.css/-/normalize.css-9.0.1.tgz#c27b391d8457d1e893f1eddeaf5e5412d12ffbb5" integrity sha512-6It2EVfGskxZCQhuykrfnALg7oVeiI6KclWSmGDqB0AiInVrTGB9Jp9i4/Ad21u9Jde/voVQz6eFX/eSg/UsPA== +"@g07cha/flexbox-react@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@g07cha/flexbox-react/-/flexbox-react-5.0.0.tgz#159b0f8b7c5b8ee41a19256fb0024238abe01b3f" + integrity sha512-OiszZSDH/oZmN/jhpGZhYTtDIEVwT3E4ArWk0mUMPTUaiSDJRykMLuOC6fWuNm1nrti04ihtXPr4Tr4S9kSeyg== + dependencies: + styled-components "^2.0.0" + optionalDependencies: + "@types/react" "^16.0.34" + "@types/react-dom" "^16.0.3" + "@hapi/address@2.x.x": version "2.1.4" resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.1.4.tgz#5d67ed43f3fd41a69d4b9ff7b56e7c0d1d0a81e5" @@ -1229,11 +1383,6 @@ resolved "https://registry.yarnpkg.com/@openapitools/openapi-generator-cli/-/openapi-generator-cli-1.0.8-4.2.2.tgz#60a680a092138ff244318cafca5c6be188ba5880" integrity sha512-K14xUl5Cc2Epm5hoclvi1KGANy9SCSWddEZCLk9Ij0v0ujbLtK1bMOkrjCOz0ADxdb0Vfsc6T6EE3G3lmBrOCQ== -"@pingcap-incubator/pd-client-js@^0.1.5": - version "0.1.5" - resolved "https://npm.pkg.github.com/download/@pingcap-incubator/pd-client-js/0.1.5/acedeadce7d625873d68373797956b23015bf22e478cc56840f5339f79e0d4cd#" - integrity sha512-Vm6zD17pJzy6WVUMO/lsnhFzcBIfFPUENXFS6uAcSPEENthbueZ5QRAtrLtV50ToKFXr8QCzYyqDnEEjzBBuKA== - "@svgr/babel-plugin-add-jsx-attribute@^4.2.0": version "4.2.0" resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-4.2.0.tgz#dadcb6218503532d6884b210e7f3c502caaa44b1" @@ -1656,6 +1805,13 @@ resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.2.tgz#690a1475b84f2a884fd07cd797c00f5f31356ea8" integrity sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw== +"@types/react-dom@^16.0.3": + version "16.9.5" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.5.tgz#5de610b04a35d07ffd8f44edad93a71032d9aaa7" + integrity sha512-BX6RQ8s9D+2/gDhxrj8OW+YD4R+8hj7FEM/OJHGNR0KipE1h1mSsf39YeyC81qafkq+N3rU3h3RFbLSwE5VqUg== + dependencies: + "@types/react" "*" + "@types/react-router-dom@^5.1.3": version "5.1.3" resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-5.1.3.tgz#b5d28e7850bd274d944c0fbbe5d57e6b30d71196" @@ -1680,7 +1836,7 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@^16.9.17": +"@types/react@*": version "16.9.17" resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.17.tgz#58f0cc0e9ec2425d1441dd7b623421a867aa253e" integrity sha512-UP27In4fp4sWF5JgyV6pwVPAQM83Fj76JOcg02X5BZcpSu5Wx+fP9RMqc2v0ssBoQIFvD5JdKY41gjJJKmw6Bg== @@ -1688,6 +1844,14 @@ "@types/prop-types" "*" csstype "^2.2.0" +"@types/react@^16.0.34", "@types/react@^16.9.22": + version "16.9.22" + resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.22.tgz#f0288c92d94e93c4b43e3f5633edf788b2c040ae" + integrity sha512-7OSt4EGiLvy0h5R7X+r0c7S739TCU/LvWbkNOrm10lUwNHe7XPz5OLhLOSZeCkqO9JSCly1NkYJ7ODTUqVnHJQ== + dependencies: + "@types/prop-types" "*" + csstype "^2.2.0" + "@types/stack-utils@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" @@ -2755,6 +2919,14 @@ buffer@^4.3.0: ieee754 "^1.1.4" isarray "^1.0.0" +buffer@^5.0.3: + version "5.4.3" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.4.3.tgz#3fbc9c69eb713d323e3fc1a895eee0710c072115" + integrity sha512-zvj65TkFeIt3i6aj5bIvJDzjjQQGs4o/sNoezg1F1kYap9Nu2jcUdpwzRSJTHMMzG0H7bZkn4rNQpImhuxWX2A== + dependencies: + base64-js "^1.0.2" + ieee754 "^1.1.4" + builtin-status-codes@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8" @@ -2863,6 +3035,11 @@ camelcase@^5.0.0, camelcase@^5.2.0, camelcase@^5.3.1: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== +camelize@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/camelize/-/camelize-1.0.0.tgz#164a5483e630fa4321e5af07020e531831b2609b" + integrity sha1-FkpUg+Yw+kMh5a8HAg5TGDGyYJs= + caniuse-api@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-3.0.0.tgz#5e4d90e2274961d46291997df599e3ed008ee4c0" @@ -3435,6 +3612,11 @@ css-blank-pseudo@^0.1.4: dependencies: postcss "^7.0.5" +css-color-keywords@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/css-color-keywords/-/css-color-keywords-1.0.0.tgz#fea2616dc676b2962686b3af8dbdbe180b244e05" + integrity sha1-/qJhbcZ2spYmhrOvjb2+GAskTgU= + css-color-names@0.0.4, css-color-names@^0.0.4: version "0.0.4" resolved "https://registry.yarnpkg.com/css-color-names/-/css-color-names-0.0.4.tgz#808adc2e79cf84738069b646cb20ec27beb629e0" @@ -3505,6 +3687,15 @@ css-select@^2.0.0: domutils "^1.7.0" nth-check "^1.0.2" +css-to-react-native@^2.0.3: + version "2.3.2" + resolved "https://registry.yarnpkg.com/css-to-react-native/-/css-to-react-native-2.3.2.tgz#e75e2f8f7aa385b4c3611c52b074b70a002f2e7d" + integrity sha512-VOFaeZA053BqvvvqIA8c9n0+9vFppVBAHCp6JgFTtTMU3Mzi+XnelJ9XC9ul3BqFzZyQ5N+H0SnwsWT2Ebchxw== + dependencies: + camelize "^1.0.0" + css-color-keywords "^1.0.0" + postcss-value-parser "^3.3.0" + css-tree@1.0.0-alpha.37: version "1.0.0-alpha.37" resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.0.0-alpha.37.tgz#98bebd62c4c1d9f960ec340cf9f7522e30709a22" @@ -5362,6 +5553,11 @@ has-ansi@^2.0.0: dependencies: ansi-regex "^2.0.0" +has-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-1.0.0.tgz#9d9e793165ce017a00f00418c43f942a7b1d11fa" + integrity sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo= + has-flag@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" @@ -5457,6 +5653,11 @@ hmac-drbg@^1.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" +hoist-non-react-statics@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-1.2.0.tgz#aa448cf0986d55cc40773b17174b7dd066cb7cfb" + integrity sha1-qkSM8JhtVcxAdzsXF0t90GbLfPs= + hoist-non-react-statics@^2.3.1: version "2.5.5" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz#c5903cf409c0dfd908f388e619d86b9c1174cb47" @@ -10899,6 +11100,20 @@ style-loader@1.0.0: loader-utils "^1.2.3" schema-utils "^2.0.1" +styled-components@^2.0.0: + version "2.4.1" + resolved "https://registry.yarnpkg.com/styled-components/-/styled-components-2.4.1.tgz#663bd0485d4b6ab46f946210dc03d2398d1ade74" + integrity sha1-ZjvQSF1LarRvlGIQ3APSOY0a3nQ= + dependencies: + buffer "^5.0.3" + css-to-react-native "^2.0.3" + fbjs "^0.8.9" + hoist-non-react-statics "^1.2.0" + is-plain-object "^2.0.1" + prop-types "^15.5.4" + stylis "^3.4.0" + supports-color "^3.2.3" + stylehacks@^4.0.0: version "4.0.3" resolved "https://registry.yarnpkg.com/stylehacks/-/stylehacks-4.0.3.tgz#6718fcaf4d1e07d8a1318690881e8d96726a71d5" @@ -10908,11 +11123,23 @@ stylehacks@^4.0.0: postcss "^7.0.0" postcss-selector-parser "^3.0.0" +stylis@^3.4.0: + version "3.5.4" + resolved "https://registry.yarnpkg.com/stylis/-/stylis-3.5.4.tgz#f665f25f5e299cf3d64654ab949a57c768b73fbe" + integrity sha512-8/3pSmthWM7lsPBKv7NXkzn2Uc9W7NotcwGNpJaa3k7WMM1XDCA4MgT5k/8BIexd5ydZdboXtU90XH9Ec4Bv/Q== + supports-color@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" integrity sha1-U10EXOa2Nj+kARcIRimZXp3zJMc= +supports-color@^3.2.3: + version "3.2.3" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-3.2.3.tgz#65ac0504b3954171d8a64946b2ae3cbb8a5f54f6" + integrity sha1-ZawFBLOVQXHYpklGsq48u4pfVPY= + dependencies: + has-flag "^1.0.0" + supports-color@^5.3.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"