Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

export statements #778

Merged
merged 24 commits into from
Nov 5, 2020
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 163 additions & 9 deletions pkg/apiserver/statement/statement.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,24 @@
package statement

import (
"bufio"
"bytes"
"encoding/csv"
"errors"
"fmt"
"io/ioutil"
"net/http"
"os"
"reflect"
"strconv"
"strings"
"time"

"github.com/gin-gonic/gin"
"github.com/gtank/cryptopasta"
"github.com/pingcap/log"
"go.uber.org/fx"
"go.uber.org/zap"

"github.com/pingcap-incubator/tidb-dashboard/pkg/apiserver/user"
"github.com/pingcap-incubator/tidb-dashboard/pkg/apiserver/utils"
Expand All @@ -40,15 +53,23 @@ func NewService(p ServiceParams) *Service {

func Register(r *gin.RouterGroup, auth *user.AuthService, s *Service) {
endpoint := r.Group("/statements")
endpoint.Use(auth.MWAuthRequired())
endpoint.Use(utils.MWConnectTiDB(s.params.TiDBClient))
endpoint.GET("/config", s.configHandler)
endpoint.POST("/config", s.modifyConfigHandler)
endpoint.GET("/time_ranges", s.timeRangesHandler)
endpoint.GET("/stmt_types", s.stmtTypesHandler)
endpoint.GET("/overviews", s.overviewsHandler)
endpoint.GET("/plans", s.getPlansHandler)
endpoint.GET("/plan/detail", s.getPlanDetailHandler)
{
endpoint.GET("/download", s.downloadHandler)
baurine marked this conversation as resolved.
Show resolved Hide resolved

endpoint.Use(auth.MWAuthRequired())
endpoint.Use(utils.MWConnectTiDB(s.params.TiDBClient))
{
endpoint.GET("/config", s.configHandler)
endpoint.POST("/config", s.modifyConfigHandler)
endpoint.GET("/time_ranges", s.timeRangesHandler)
endpoint.GET("/stmt_types", s.stmtTypesHandler)
endpoint.GET("/overviews", s.overviewsHandler)
endpoint.GET("/plans", s.getPlansHandler)
endpoint.GET("/plan/detail", s.getPlanDetailHandler)

endpoint.POST("/download/token", s.downloadTokenHandler)
}
}
}

// @Summary Get statement configurations
Expand Down Expand Up @@ -210,3 +231,136 @@ func (s *Service) getPlanDetailHandler(c *gin.Context) {
}
c.JSON(http.StatusOK, result)
}

// @Router /statements/download/token [post]
// @Summary Generate a download token for exported statements
// @Produce plain
// @Param request body GetStatementsRequest true "Request body"
// @Success 200 {string} string "xxx"
// @Security JwtAuth
// @Failure 401 {object} utils.APIError "Unauthorized failure"
func (s *Service) downloadTokenHandler(c *gin.Context) {
var req GetStatementsRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.MakeInvalidRequestErrorFromError(c, err)
return
}
db := utils.GetTiDBConnection(c)
fields := []string{}
if strings.TrimSpace(req.Fields) != "" {
fields = strings.Split(req.Fields, ",")
}
overviews, err := QueryStatementsOverview(
db,
req.BeginTime, req.EndTime,
req.Schemas,
req.StmtTypes,
req.Text,
fields)
if err != nil {
_ = c.Error(err)
return
}
if len(overviews) == 0 {
utils.MakeInvalidRequestErrorFromError(c, errors.New("no data to export"))
return
}

// convert data
fieldsMap := make(map[string]string)
t := reflect.TypeOf(overviews[0])
fieldsNum := t.NumField()
for i := 0; i < fieldsNum; i++ {
field := t.Field(i)
fieldsMap[strings.ToLower(field.Tag.Get("json"))] = field.Name
}

csvData := [][]string{fields}
for _, overview := range overviews {
row := []string{}
v := reflect.ValueOf(overview)
for _, field := range fields {
filedName := fieldsMap[field]
s := v.FieldByName(filedName)
var val string
switch s.Interface().(type) {
case int:
val = strconv.FormatInt(s.Int(), 10)
default:
val = s.String()
}
row = append(row, val)
}
csvData = append(csvData, row)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have some 3rd-party library for easier use? This logic may be the same when exporting slow queries.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make sense, let me have a check.

Copy link
Collaborator Author

@baurine baurine Oct 29, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems this lib can help us: https://github.com/oleiade/reflections , let me try it.

But it doesn't reduce much code, we need to extract it to a util method when we need to reuse it.

}

// generate temp file that persist encrypted data
timeLayout := "01021504"
beginTime := time.Unix(int64(req.BeginTime), 0).Format(timeLayout)
endTime := time.Unix(int64(req.EndTime), 0).Format(timeLayout)
csvFile, err := ioutil.TempFile("", fmt.Sprintf("statements_%s_%s_*.csv", beginTime, endTime))
if err != nil {
_ = c.Error(err)
return
}
defer csvFile.Close()
// generate buf writer for tmpfile
csvBuf := bufio.NewWriter(csvFile)
// generate encrypted key
secretKey := cryptopasta.NewEncryptionKey()

rowBuf := bytes.NewBuffer(nil)
csvwriter := csv.NewWriter(rowBuf)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This library implements an encrypted io.Reader / io.Writer, which could be a better choice. Maybe you can have a try: https://github.com/Xeoncross/go-aesctr-with-hmac

The encapsulation of the stream is file stream -> encrypted stream -> csv stream.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, thank you! let me have a look. Since I found zip the encrypted file won't reduce the size much, so I think it doesn't need to zip file in local, right? but to still zip it when sending data back to the client after being decrypted.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIRC, we use gzip already. Is there any further benefit of packaging as a zip for transmission?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make sense, let me focus on the encryption first.

Copy link
Collaborator Author

@baurine baurine Oct 29, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It works fine! and the final flow likes this:

image

for _, csvRow := range csvData {
_ = csvwriter.Write(csvRow)
csvwriter.Flush()
row := make([]byte, len(rowBuf.String()))
_, _ = rowBuf.Read(row)

encrypted, err := cryptopasta.Encrypt(row, secretKey)
if err != nil {
_ = c.Error(err)
return
}
csvBuf.Write(encrypted)
}
csvBuf.Flush()

// zip file and encrypt

// generate token by filepath
token, err := utils.NewJWTString("statements/download", csvFile.Name())
if err != nil {
_ = c.Error(err)
return
}
c.String(http.StatusOK, token)
}

// @Router /statements/download [get]
// @Summary Download statements
// @Produce application/zip
// @Param token query string true "download token"
// @Failure 400 {object} utils.APIError
// @Failure 401 {object} utils.APIError "Unauthorized failure"
func (s *Service) downloadHandler(c *gin.Context) {
token := c.Query("token")
fielPath, err := utils.ParseJWTString("statements/download", token)
if err != nil {
utils.MakeInvalidRequestErrorFromError(c, err)
return
}

_, err = os.Stat(fielPath)
baurine marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
_ = c.Error(err)
return
}

c.Writer.Header().Set("Content-type", "application/octet-stream")
baurine marked this conversation as resolved.
Show resolved Hide resolved
c.Writer.Header().Set("Content-Disposition", "attachment; filename=\"statements.zip\"")
err = utils.StreamZipPack(c.Writer, []string{fielPath}, true)
baurine marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
log.Error("Stream zip pack failed", zap.Error(err))
}
baurine marked this conversation as resolved.
Show resolved Hide resolved
}
15 changes: 15 additions & 0 deletions ui/lib/apps/Statement/pages/List/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import TimeRangeSelector from './TimeRangeSelector'
import useStatementTableController, {
DEF_STMT_COLUMN_KEYS,
} from '../../utils/useStatementTableController'
import client from '@lib/client'

const { Search } = Input

Expand Down Expand Up @@ -47,8 +48,19 @@ export default function StatementsOverview() {
allStmtTypes,
loadingStatements,
tableColumns,

getDownloadToken,
downloading,
} = controller

async function exportCSV() {
const token = await getDownloadToken()
breezewish marked this conversation as resolved.
Show resolved Hide resolved
if (token) {
const url = `${client.getBasePath()}/statements/download?token=${token}`
window.open(url)
}
}

return (
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
<Card>
Expand Down Expand Up @@ -106,6 +118,9 @@ export default function StatementsOverview() {
setQueryOptions({ ...queryOptions, searchText })
}
/>
<Button type="primary" onClick={exportCSV} loading={downloading}>
baurine marked this conversation as resolved.
Show resolved Hide resolved
{t('statement.pages.overview.toolbar.export')}
</Button>
</Space>

<Space>
Expand Down
1 change: 1 addition & 0 deletions ui/lib/apps/Statement/translations/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ statement:
recent: Recent
usual_time_ranges: Common
custom_time_ranges: Custom
export: export
settings:
title: Settings
disabled_result:
Expand Down
1 change: 1 addition & 0 deletions ui/lib/apps/Statement/translations/zh.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ statement:
recent: 最近
usual_time_ranges: 常用时间范围
custom_time_ranges: 自定义时间范围
export: 导出
settings:
title: 设置
disabled_result:
Expand Down
26 changes: 26 additions & 0 deletions ui/lib/apps/Statement/utils/useStatementTableController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ export interface IStatementTableController {

tableColumns: IColumn[]
visibleColumnKeys: IColumnKeys

getDownloadToken: () => Promise<string>
downloading: boolean
}

export default function useStatementTableController(
Expand Down Expand Up @@ -218,6 +221,26 @@ export default function useStatementTableController(
queryStatementList()
}, [queryOptions, allTimeRanges, validTimeRange, selectedFields])

const [downloading, setDownloading] = useState(false)
async function getDownloadToken() {
let token = ''
try {
setDownloading(true)
const res = await client.getInstance().statementsDownloadTokenPost({
begin_time: validTimeRange.begin_time,
end_time: validTimeRange.end_time,
fields: selectedFields,
schemas: queryOptions.schemas,
stmt_types: queryOptions.stmtTypes,
text: queryOptions.searchText,
})
token = res.data
} finally {
setDownloading(false)
}
return token
}

return {
queryOptions,
setQueryOptions,
Expand All @@ -237,5 +260,8 @@ export default function useStatementTableController(

tableColumns,
visibleColumnKeys,

getDownloadToken,
downloading,
}
}