feat: add export capability for selected keys

This commit is contained in:
Lykin 2023-12-18 00:58:20 +08:00
parent b06217adc0
commit bce4e2323e
15 changed files with 461 additions and 8 deletions

View File

@ -2,11 +2,15 @@ package services
import ( import (
"context" "context"
"encoding/csv"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"github.com/wailsapp/wails/v2/pkg/runtime"
"net/url" "net/url"
"os"
"slices" "slices"
"sort" "sort"
"strconv" "strconv"
@ -1936,6 +1940,77 @@ func (b *browserService) DeleteOneKey(server string, db int, k any) (resp types.
return return
} }
// ExportKey export keys
func (b *browserService) ExportKey(server string, db int, ks []any, path string) (resp types.JSResp) {
// connect a new connection to export keys
conf := Connection().getConnection(server)
if conf == nil {
resp.Msg = fmt.Sprintf("no connection profile named: %s", server)
return
}
var client redis.UniversalClient
var err error
var connConfig = conf.ConnectionConfig
connConfig.LastDB = db
if client, err = Connection().createRedisClient(connConfig); err != nil {
resp.Msg = err.Error()
return
}
// TODO: add cancel handle
ctx, cancelFunc := context.WithCancel(b.ctx)
defer cancelFunc()
file, err := os.Create(path)
if err != nil {
resp.Msg = err.Error()
return
}
defer file.Close()
writer := csv.NewWriter(file)
defer writer.Flush()
runtime.EventsOnce(ctx, "export:stop:"+path, func(data ...any) {
cancelFunc()
})
processEvent := "exporting:" + path
total := len(ks)
var exported, failed int64
var canceled bool
for i, k := range ks {
param := map[string]any{
"total": total,
"progress": i + 1,
"processing": k,
}
runtime.EventsEmit(b.ctx, processEvent, param)
key := strutil.DecodeRedisKey(k)
content, dumpErr := client.Dump(ctx, key).Bytes()
if errors.Is(dumpErr, context.Canceled) {
canceled = true
break
}
if err = writer.Write([]string{hex.EncodeToString([]byte(key)), hex.EncodeToString(content)}); err != nil {
failed += 1
} else {
exported += 1
}
}
resp.Success = true
resp.Data = struct {
Canceled bool `json:"canceled"`
Exported int64 `json:"exported"`
Failed int64 `json:"failed"`
}{
Canceled: canceled,
Exported: exported,
Failed: failed,
}
return
}
// FlushDB flush database // FlushDB flush database
func (b *browserService) FlushDB(connName string, db int, async bool) (resp types.JSResp) { func (b *browserService) FlushDB(connName string, db int, async bool) (resp types.JSResp) {
item, err := b.getRedisClient(connName, db) item, err := b.getRedisClient(connName, db)

View File

@ -127,6 +127,9 @@ func (c *connectionService) buildOption(config types.ConnectionConfig) (*redis.O
WriteTimeout: time.Duration(config.ExecTimeout) * time.Second, WriteTimeout: time.Duration(config.ExecTimeout) * time.Second,
TLSConfig: tlsConfig, TLSConfig: tlsConfig,
} }
if config.LastDB > 0 {
option.DB = config.LastDB
}
if sshClient != nil { if sshClient != nil {
option.Dialer = func(ctx context.Context, network, addr string) (net.Conn, error) { option.Dialer = func(ctx context.Context, network, addr string) (net.Conn, error) {
return sshClient.Dial(network, addr) return sshClient.Dial(network, addr)

View File

@ -3,11 +3,11 @@ package services
import ( import (
"context" "context"
"github.com/wailsapp/wails/v2/pkg/runtime" "github.com/wailsapp/wails/v2/pkg/runtime"
"log"
"sync" "sync"
"time" "time"
"tinyrdm/backend/consts" "tinyrdm/backend/consts"
"tinyrdm/backend/types" "tinyrdm/backend/types"
sliceutil "tinyrdm/backend/utils/slice"
) )
type systemService struct { type systemService struct {
@ -50,7 +50,30 @@ func (s *systemService) SelectFile(title string) (resp types.JSResp) {
ShowHiddenFiles: true, ShowHiddenFiles: true,
}) })
if err != nil { if err != nil {
log.Println(err) resp.Msg = err.Error()
return
}
resp.Success = true
resp.Data = map[string]any{
"path": filepath,
}
return
}
// SaveFile open file dialog to save a file
func (s *systemService) SaveFile(title string, defaultName string, extensions []string) (resp types.JSResp) {
filters := sliceutil.Map(extensions, func(i int) runtime.FileFilter {
return runtime.FileFilter{
Pattern: "*." + extensions[i],
}
})
filepath, err := runtime.SaveFileDialog(s.ctx, runtime.SaveDialogOptions{
Title: title,
ShowHiddenFiles: true,
DefaultFilename: defaultName,
Filters: filters,
})
if err != nil {
resp.Msg = err.Error() resp.Msg = err.Error()
return return
} }

View File

@ -18,6 +18,7 @@ import { WindowSetDarkTheme, WindowSetLightTheme } from 'wailsjs/runtime/runtime
import { darkThemeOverrides, themeOverrides } from '@/utils/theme.js' import { darkThemeOverrides, themeOverrides } from '@/utils/theme.js'
import AboutDialog from '@/components/dialogs/AboutDialog.vue' import AboutDialog from '@/components/dialogs/AboutDialog.vue'
import FlushDbDialog from '@/components/dialogs/FlushDbDialog.vue' import FlushDbDialog from '@/components/dialogs/FlushDbDialog.vue'
import ExportKeyDialog from '@/components/dialogs/ExportKeyDialog.vue'
const prefStore = usePreferencesStore() const prefStore = usePreferencesStore()
const connectionStore = useConnectionStore() const connectionStore = useConnectionStore()
@ -67,6 +68,7 @@ watch(
<add-fields-dialog /> <add-fields-dialog />
<rename-key-dialog /> <rename-key-dialog />
<delete-key-dialog /> <delete-key-dialog />
<export-key-dialog />
<flush-db-dialog /> <flush-db-dialog />
<set-ttl-dialog /> <set-ttl-dialog />
<preferences-dialog /> <preferences-dialog />

View File

@ -0,0 +1,41 @@
<script setup>
import { SaveFile } from 'wailsjs/go/services/systemService.js'
import { get } from 'lodash'
const props = defineProps({
value: String,
placeholder: String,
disabled: Boolean,
defaultPath: String,
})
const emit = defineEmits(['update:value'])
const onClear = () => {
emit('update:value', '')
}
const handleSaveFile = async () => {
const { success, data } = await SaveFile(null, props.defaultPath, ['csv'])
if (success) {
const path = get(data, 'path', '')
emit('update:value', path)
} else {
emit('update:value', '')
}
}
</script>
<template>
<n-input-group>
<n-input
v-model:value="props.value"
:disabled="props.disabled"
:placeholder="placeholder"
clearable
@clear="onClear" />
<n-button :disabled="props.disabled" :focusable="false" @click="handleSaveFile">...</n-button>
</n-input-group>
</template>
<style lang="scss" scoped></style>

View File

@ -99,10 +99,10 @@ const onClose = () => {
<n-form :model="deleteForm" :show-require-mark="false" label-placement="top"> <n-form :model="deleteForm" :show-require-mark="false" label-placement="top">
<n-grid :x-gap="10"> <n-grid :x-gap="10">
<n-form-item-gi :label="$t('dialogue.key.server')" :span="12"> <n-form-item-gi :label="$t('dialogue.key.server')" :span="12">
<n-input :value="deleteForm.server" readonly /> <n-input :autofocus="false" :value="deleteForm.server" readonly />
</n-form-item-gi> </n-form-item-gi>
<n-form-item-gi :label="$t('dialogue.key.db_index')" :span="12"> <n-form-item-gi :label="$t('dialogue.key.db_index')" :span="12">
<n-input :value="deleteForm.db.toString()" readonly /> <n-input :autofocus="false" :value="deleteForm.db.toString()" readonly />
</n-form-item-gi> </n-form-item-gi>
</n-grid> </n-grid>
<n-form-item <n-form-item

View File

@ -0,0 +1,112 @@
<script setup>
import { computed, reactive, ref, watchEffect } from 'vue'
import useDialog from 'stores/dialog'
import { useI18n } from 'vue-i18n'
import useBrowserStore from 'stores/browser.js'
import FileSaveInput from '@/components/common/FileSaveInput.vue'
import { isEmpty, map, size } from 'lodash'
import { decodeRedisKey } from '@/utils/key_convert.js'
import dayjs from 'dayjs'
const exportKeyForm = reactive({
server: '',
db: 0,
keys: [],
file: '',
})
const dialogStore = useDialog()
const browserStore = useBrowserStore()
const loading = ref(false)
const deleting = ref(false)
watchEffect(() => {
if (dialogStore.exportKeyDialogVisible) {
const { server, db, keys } = dialogStore.exportKeyParam
exportKeyForm.server = server
exportKeyForm.db = db
exportKeyForm.keys = keys
// exportKeyForm.async = true
deleting.value = false
}
})
const keyLines = computed(() => {
return map(exportKeyForm.keys, (k) => decodeRedisKey(k))
})
const exportEnable = computed(() => {
return !isEmpty(exportKeyForm.keys) && !isEmpty(exportKeyForm.file)
})
const i18n = useI18n()
const onConfirmDelete = async () => {
try {
deleting.value = true
const { server, db, keys, file } = exportKeyForm
browserStore.exportKeys(server, db, keys, file).catch((e) => {})
} catch (e) {
$message.error(e.message)
return
} finally {
deleting.value = false
}
dialogStore.closeExportKeyDialog()
}
const onClose = () => {
dialogStore.closeExportKeyDialog()
}
</script>
<template>
<n-modal
v-model:show="dialogStore.exportKeyDialogVisible"
:closable="false"
:close-on-esc="false"
:mask-closable="false"
:show-icon="false"
:title="$t('dialogue.export.name')"
preset="dialog"
transform-origin="center">
<n-spin :show="loading">
<n-form :model="exportKeyForm" :show-require-mark="false" label-placement="top">
<n-grid :x-gap="10">
<n-form-item-gi :label="$t('dialogue.key.server')" :span="12">
<n-input :autofocus="false" :value="exportKeyForm.server" readonly />
</n-form-item-gi>
<n-form-item-gi :label="$t('dialogue.key.db_index')" :span="12">
<n-input :autofocus="false" :value="exportKeyForm.db.toString()" readonly />
</n-form-item-gi>
</n-grid>
<n-form-item :label="$t('dialogue.export.save_file')" required>
<file-save-input
v-model:value="exportKeyForm.file"
:default-path="`export_${dayjs().format('YYYYMMDDHHmmss')}.csv`"
:placeholder="$t('dialogue.export.save_file_tip')" />
</n-form-item>
<n-card
:title="$t('dialogue.key.affected_key') + `(${size(exportKeyForm.keys)})`"
embedded
size="small">
<n-log :line-height="1.5" :lines="keyLines" :rows="10" style="user-select: text; cursor: text" />
</n-card>
</n-form>
</n-spin>
<template #action>
<div class="flex-item n-dialog__action">
<n-button :disabled="loading" :focusable="false" @click="onClose">{{ $t('common.cancel') }}</n-button>
<n-button
:disabled="!exportEnable"
:focusable="false"
:loading="loading"
type="error"
@click="onConfirmDelete">
{{ $t('dialogue.export.export') }}
</n-button>
</div>
</template>
</n-modal>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,45 @@
<script setup>
const props = defineProps({
strokeWidth: {
type: [Number, String],
default: 3,
},
})
</script>
<template>
<svg fill="none" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<mask
id="icon-122098f7f10b972"
height="48"
maskUnits="userSpaceOnUse"
style="mask-type: alpha"
width="48"
x="0"
y="0">
<path d="M48 0H0V48H48V0Z" fill="currentColor" />
</mask>
<g mask="url(#icon-122098f7f10b972)">
<path
:stroke-width="props.strokeWidth"
d="M6 24.0083V42H42V24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round" />
<path
:stroke-width="props.strokeWidth"
d="M33 15L24 6L15 15"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round" />
<path
:stroke-width="props.strokeWidth"
d="M23.9917 32V6"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round" />
</g>
</svg>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,33 @@
<script setup>
const props = defineProps({
strokeWidth: {
type: [Number, String],
default: 3,
},
})
</script>
<template>
<svg fill="none" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<path
:stroke-width="props.strokeWidth"
d="M6 24.0083V42H42V24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round" />
<path
:stroke-width="props.strokeWidth"
d="M33 23L24 32L15 23"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round" />
<path
:stroke-width="props.strokeWidth"
d="M23.9917 6V32"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</template>
<style lang="scss" scoped></style>

View File

@ -23,6 +23,7 @@ import useConnectionStore from 'stores/connections.js'
import ListCheckbox from '@/components/icons/ListCheckbox.vue' import ListCheckbox from '@/components/icons/ListCheckbox.vue'
import Close from '@/components/icons/Close.vue' import Close from '@/components/icons/Close.vue'
import More from '@/components/icons/More.vue' import More from '@/components/icons/More.vue'
import Export from '@/components/icons/Export.vue'
const props = defineProps({ const props = defineProps({
server: String, server: String,
@ -146,6 +147,10 @@ const onDeleteChecked = () => {
browserTreeRef.value?.deleteCheckedItems() browserTreeRef.value?.deleteCheckedItems()
} }
const onExportChecked = () => {
browserTreeRef.value?.exportCheckedItems()
}
const onFlush = () => { const onFlush = () => {
dialogStore.openFlushDBDialog(props.server, props.db) dialogStore.openFlushDBDialog(props.server, props.db)
} }
@ -329,6 +334,14 @@ onMounted(() => onReload())
<!-- check mode function bar --> <!-- check mode function bar -->
<div v-else class="flex-box-h nav-pane-func"> <div v-else class="flex-box-h nav-pane-func">
<icon-button
:button-class="['nav-pane-func-btn']"
:disabled="checkedCount <= 0"
:icon="Export"
:stroke-width="3.5"
size="20"
t-tooltip="interface.export_checked"
@click="onExportChecked" />
<icon-button <icon-button
:button-class="['nav-pane-func-btn']" :button-class="['nav-pane-func-btn']"
:disabled="checkedCount <= 0" :disabled="checkedCount <= 0"

View File

@ -581,6 +581,13 @@ defineExpose({
dialogStore.openDeleteKeyDialog(props.server, props.db, redisKeys) dialogStore.openDeleteKeyDialog(props.server, props.db, redisKeys)
} }
}, },
exportCheckedItems: () => {
const checkedKeys = tabStore.currentCheckedKeys
const redisKeys = map(checkedKeys, 'redisKey')
if (!isEmpty(redisKeys)) {
dialogStore.openExportKeyDialog(props.server, props.db, redisKeys)
}
},
}) })
</script> </script>

View File

@ -79,6 +79,7 @@
"check_mode": "Check Mode", "check_mode": "Check Mode",
"quit_check_mode": "Quit Check Mode", "quit_check_mode": "Quit Check Mode",
"delete_checked": "Delete Checked Items", "delete_checked": "Delete Checked Items",
"export_checked": "Export Checked Items",
"copy_value": "Copy Value", "copy_value": "Copy Value",
"edit_value": "Edit Value", "edit_value": "Edit Value",
"save_update": "Save Update", "save_update": "Save Update",
@ -146,6 +147,7 @@
"delete_completed": "Deletion process has been completed, {success} successed, {fail} failed", "delete_completed": "Deletion process has been completed, {success} successed, {fail} failed",
"rename_binary_key_fail": "Rename binary key name is unsupported", "rename_binary_key_fail": "Rename binary key name is unsupported",
"handle_succ": "Success!", "handle_succ": "Success!",
"handle_cancel": "The operation has been canceled.",
"reload_succ": "Reloaded!", "reload_succ": "Reloaded!",
"field_required": "This item should not be blank", "field_required": "This item should not be blank",
"spec_field_required": "\"{key}\" should not be blank", "spec_field_required": "\"{key}\" should not be blank",
@ -269,6 +271,14 @@
"filter_pattern": "Pattern", "filter_pattern": "Pattern",
"filter_pattern_tip": "* : Matches zero or more characters. For example, 'key*' matches all keys starting with 'key'.\n? : Matches a single character. For example, 'key?' matches 'key1', 'key2'.\n[] : Matches a single character within the specified range. For example, 'key[1-3]' matches keys like 'key1', 'key2', 'key3'.\n\\ : Escape character. To match *, ?, [, or ], use the backslash '\\' for escaping." "filter_pattern_tip": "* : Matches zero or more characters. For example, 'key*' matches all keys starting with 'key'.\n? : Matches a single character. For example, 'key?' matches 'key1', 'key2'.\n[] : Matches a single character within the specified range. For example, 'key[1-3]' matches keys like 'key1', 'key2', 'key3'.\n\\ : Escape character. To match *, ?, [, or ], use the backslash '\\' for escaping."
}, },
"export": {
"name": "Export Keys",
"export": "Export",
"save_file": "Export Path",
"save_file_tip": "Select the export file save path",
"exporting": "Exporting key: {key} ({index}/{count})",
"export_completed": "Export process has been completed, {success} successed, {fail} failed"
},
"ttl": { "ttl": {
"title": "Set Key TTL" "title": "Set Key TTL"
}, },

View File

@ -78,7 +78,8 @@
"flush_db": "清空数据库", "flush_db": "清空数据库",
"check_mode": "勾选模式", "check_mode": "勾选模式",
"quit_check_mode": "退出勾选模式", "quit_check_mode": "退出勾选模式",
"delete_checked": "删除勾选项", "delete_checked": "删除所选项",
"export_checked": "导出所选项",
"copy_value": "复制值", "copy_value": "复制值",
"edit_value": "修改值", "edit_value": "修改值",
"save_update": "保存修改", "save_update": "保存修改",
@ -146,6 +147,7 @@
"delete_completed": "已完成删除操作,成功{success}个,失败{fail}个", "delete_completed": "已完成删除操作,成功{success}个,失败{fail}个",
"rename_binary_key_fail": "不支持重命名二进制键名", "rename_binary_key_fail": "不支持重命名二进制键名",
"handle_succ": "操作成功", "handle_succ": "操作成功",
"handle_cancel": "操作已取消",
"reload_succ": "已重新载入", "reload_succ": "已重新载入",
"field_required": "此项不能为空", "field_required": "此项不能为空",
"spec_field_required": "{key} 不能为空", "spec_field_required": "{key} 不能为空",
@ -268,6 +270,14 @@
"filter_pattern": "过滤表达式", "filter_pattern": "过滤表达式",
"filter_pattern_tip": "*:匹配零个或多个字符。例如:\"key*\"匹配到以\"key\"开头的所有键\n?:匹配单个字符。例如:\"key?\"匹配\"key1\"、\"key2\"\n[ ]:匹配指定范围内的单个字符。例如:\"key[1-3]\"可以匹配类似于 \"key1\"、\"key2\"、\"key3\" 的键\n\\:转义字符。如果想要匹配 *、?、[、或],需要使用反斜杠\"\\\"进行转义" "filter_pattern_tip": "*:匹配零个或多个字符。例如:\"key*\"匹配到以\"key\"开头的所有键\n?:匹配单个字符。例如:\"key?\"匹配\"key1\"、\"key2\"\n[ ]:匹配指定范围内的单个字符。例如:\"key[1-3]\"可以匹配类似于 \"key1\"、\"key2\"、\"key3\" 的键\n\\:转义字符。如果想要匹配 *、?、[、或],需要使用反斜杠\"\\\"进行转义"
}, },
"export": {
"name": "导出键",
"export": "确认导出",
"save_file": "导出路径",
"save_file_tip": "选择保存文件路径",
"exporting": "正在导出键:{key} ({index}/{count})",
"export_completed": "已完成导出操作,成功{success}个,失败{fail}个"
},
"ttl": { "ttl": {
"title": "设置键存活时间" "title": "设置键存活时间"
}, },

View File

@ -26,6 +26,7 @@ import {
ConvertValue, ConvertValue,
DeleteKey, DeleteKey,
DeleteOneKey, DeleteOneKey,
ExportKey,
FlushDB, FlushDB,
GetCmdHistory, GetCmdHistory,
GetKeyDetail, GetKeyDetail,
@ -56,6 +57,7 @@ import useConnectionStore from 'stores/connections.js'
import { decodeTypes, formatTypes } from '@/consts/value_view_type.js' import { decodeTypes, formatTypes } from '@/consts/value_view_type.js'
import { isRedisGlob } from '@/utils/glob_pattern.js' import { isRedisGlob } from '@/utils/glob_pattern.js'
import { i18nGlobal } from '@/utils/i18n.js' import { i18nGlobal } from '@/utils/i18n.js'
import { EventsEmit, EventsOff, EventsOn } from 'wailsjs/runtime/runtime.js'
const useBrowserStore = defineStore('browser', { const useBrowserStore = defineStore('browser', {
/** /**
@ -2012,6 +2014,60 @@ const useBrowserStore = defineStore('browser', {
} }
}, },
/**
* export multiple keys
* @param {string} server
* @param {number} db
* @param {string[]|number[][]} keys
* @param {string} path
* @returns {Promise<void>}
*/
async exportKeys(server, db, keys, path) {
const delMsgRef = $message.loading('', { duration: 0, closable: true })
let exported = 0
let failCount = 0
let canceled = false
const eventName = 'exporting:' + path
try {
EventsOn(eventName, ({ total, progress, processing }) => {
// update export progress
delMsgRef.content = i18nGlobal.t('dialogue.export.exporting', {
key: decodeRedisKey(processing),
index: progress,
count: total,
})
})
delMsgRef.onClose = () => {
EventsEmit('export:stop:' + path)
}
const { data, success, msg } = await ExportKey(server, db, keys, path)
if (success) {
canceled = get(data, 'canceled', false)
exported = get(data, 'exported', 0)
failCount = get(data, 'failed', 0)
} else {
$message.error(msg)
}
} finally {
delMsgRef.destroy()
EventsOff(eventName)
}
if (canceled) {
$message.info(i18nGlobal.t('dialogue.handle_cancel'))
} else if (failCount <= 0) {
// no fail
$message.success(
i18nGlobal.t('dialogue.export.export_completed', { success: exported, fail: failCount }),
)
} else if (failCount >= exported) {
// all fail
$message.error(i18nGlobal.t('dialogue.export.export_completed', { success: exported, fail: failCount }))
} else {
// some fail
$message.warn(i18nGlobal.t('dialogue.export.export_completed', { success: exported, fail: failCount }))
}
},
/** /**
* flush database * flush database
* @param connName * @param connName

View File

@ -1,5 +1,5 @@
import { defineStore } from "pinia"; import { defineStore } from 'pinia'
import useConnectionStore from "./connections.js"; import useConnectionStore from './connections.js'
/** /**
* connection dialog type * connection dialog type
@ -63,6 +63,13 @@ const useDialogStore = defineStore('dialog', {
}, },
deleteKeyDialogVisible: false, deleteKeyDialogVisible: false,
exportKeyParam: {
server: '',
db: 0,
keys: [],
},
exportKeyDialogVisible: false,
flushDBParam: { flushDBParam: {
server: '', server: '',
db: 0, db: 0,
@ -176,6 +183,22 @@ const useDialogStore = defineStore('dialog', {
this.deleteKeyDialogVisible = false this.deleteKeyDialogVisible = false
}, },
/**
*
* @param {string} server
* @param {number} db
* @param {string|string[]} keys
*/
openExportKeyDialog(server, db, keys) {
this.exportKeyParam.server = server
this.exportKeyParam.db = db
this.exportKeyParam.keys = keys
this.exportKeyDialogVisible = true
},
closeExportKeyDialog() {
this.exportKeyDialogVisible = false
},
openFlushDBDialog(server, db) { openFlushDBDialog(server, db) {
this.flushDBParam.server = server this.flushDBParam.server = server
this.flushDBParam.db = db this.flushDBParam.db = db