diff --git a/backend/services/browser_service.go b/backend/services/browser_service.go index 2c1944f..66ff222 100644 --- a/backend/services/browser_service.go +++ b/backend/services/browser_service.go @@ -494,27 +494,68 @@ func (b *browserService) scanKeys(ctx context.Context, client redis.UniversalCli return keys, cursor, nil } +// check if key exists +func (b *browserService) existsKey(ctx context.Context, client redis.UniversalClient, key, keyType string) bool { + var keyExists atomic.Bool + if cluster, ok := client.(*redis.ClusterClient); ok { + // cluster mode + cluster.ForEachMaster(ctx, func(ctx context.Context, cli *redis.Client) error { + if n := cli.Exists(ctx, key).Val(); n > 0 { + if len(keyType) <= 0 || strings.ToLower(keyType) == cli.Type(ctx, key).Val() { + keyExists.Store(true) + } + } + return nil + }) + } else { + if n := client.Exists(ctx, key).Val(); n > 0 { + if len(keyType) <= 0 || strings.ToLower(keyType) == client.Type(ctx, key).Val() { + keyExists.Store(true) + } + } + } + return keyExists.Load() +} + // LoadNextKeys load next key from saved cursor -func (b *browserService) LoadNextKeys(server string, db int, match, keyType string) (resp types.JSResp) { +func (b *browserService) LoadNextKeys(server string, db int, match, keyType string, exactMatch bool) (resp types.JSResp) { item, err := b.getRedisClient(server, db) if err != nil { resp.Msg = err.Error() return } + if match == "*" { + exactMatch = false + } client, ctx, count := item.client, item.ctx, item.stepSize + var matchKeys []any + var maxKeys int64 cursor := item.cursor[db] - keys, cursor, err := b.scanKeys(ctx, client, match, keyType, cursor, count) - if err != nil { - resp.Msg = err.Error() - return + fullScan := match == "*" || match == "" + if exactMatch && !fullScan { + if b.existsKey(ctx, client, match, keyType) { + matchKeys = []any{match} + maxKeys = 1 + } + b.setClientCursor(server, db, 0) + } else { + matchKeys, cursor, err = b.scanKeys(ctx, client, match, keyType, cursor, count) + if err != nil { + resp.Msg = err.Error() + return + } + b.setClientCursor(server, db, cursor) + if fullScan { + maxKeys = b.loadDBSize(ctx, client) + } else { + maxKeys = int64(len(matchKeys)) + } } - b.setClientCursor(server, db, cursor) - maxKeys := b.loadDBSize(ctx, client) resp.Success = true resp.Data = map[string]any{ - "keys": keys, + "keys": matchKeys, "end": cursor == 0, "maxKeys": maxKeys, } @@ -522,7 +563,7 @@ func (b *browserService) LoadNextKeys(server string, db int, match, keyType stri } // LoadNextAllKeys load next all keys -func (b *browserService) LoadNextAllKeys(server string, db int, match, keyType string) (resp types.JSResp) { +func (b *browserService) LoadNextAllKeys(server string, db int, match, keyType string, exactMatch bool) (resp types.JSResp) { item, err := b.getRedisClient(server, db) if err != nil { resp.Msg = err.Error() @@ -530,25 +571,39 @@ func (b *browserService) LoadNextAllKeys(server string, db int, match, keyType s } client, ctx := item.client, item.ctx - cursor := item.cursor[db] - keys, _, err := b.scanKeys(ctx, client, match, keyType, cursor, 0) - if err != nil { - resp.Msg = err.Error() - return + var matchKeys []any + var maxKeys int64 + fullScan := match == "*" || match == "" + if exactMatch && !fullScan { + if b.existsKey(ctx, client, match, keyType) { + matchKeys = []any{match} + maxKeys = 1 + } + } else { + cursor := item.cursor[db] + matchKeys, _, err = b.scanKeys(ctx, client, match, keyType, cursor, 0) + if err != nil { + resp.Msg = err.Error() + return + } + b.setClientCursor(server, db, 0) + if fullScan { + maxKeys = b.loadDBSize(ctx, client) + } else { + maxKeys = int64(len(matchKeys)) + } } - b.setClientCursor(server, db, 0) - maxKeys := b.loadDBSize(ctx, client) resp.Success = true resp.Data = map[string]any{ - "keys": keys, + "keys": matchKeys, "maxKeys": maxKeys, } return } // LoadAllKeys load all keys -func (b *browserService) LoadAllKeys(server string, db int, match, keyType string) (resp types.JSResp) { +func (b *browserService) LoadAllKeys(server string, db int, match, keyType string, exactMatch bool) (resp types.JSResp) { item, err := b.getRedisClient(server, db) if err != nil { resp.Msg = err.Error() @@ -556,15 +611,23 @@ func (b *browserService) LoadAllKeys(server string, db int, match, keyType strin } client, ctx := item.client, item.ctx - keys, _, err := b.scanKeys(ctx, client, match, keyType, 0, 0) - if err != nil { - resp.Msg = err.Error() - return + var matchKeys []any + fullScan := match == "*" || match == "" + if exactMatch && !fullScan { + if b.existsKey(ctx, client, match, keyType) { + matchKeys = []any{match} + } + } else { + matchKeys, _, err = b.scanKeys(ctx, client, match, keyType, 0, 0) + if err != nil { + resp.Msg = err.Error() + return + } } resp.Success = true resp.Data = map[string]any{ - "keys": keys, + "keys": matchKeys, } return } diff --git a/backend/services/connection_service.go b/backend/services/connection_service.go index 51d3d1e..f57757f 100644 --- a/backend/services/connection_service.go +++ b/backend/services/connection_service.go @@ -86,7 +86,6 @@ func (c *connectionService) buildOption(config types.ConnectionConfig) (*redis.O if config.SSH.Enable { sshConfig = &ssh.ClientConfig{ User: config.SSH.Username, - Auth: []ssh.AuthMethod{ssh.Password(config.SSH.Password)}, HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: time.Duration(config.ConnTimeout) * time.Second, } diff --git a/frontend/src/components/common/IconButton.vue b/frontend/src/components/common/IconButton.vue index 5ff819f..959d583 100644 --- a/frontend/src/components/common/IconButton.vue +++ b/frontend/src/components/common/IconButton.vue @@ -1,9 +1,7 @@ @@ -65,7 +67,9 @@ const hasTooltip = computed(() => { - {{ props.tTooltip ? $t(props.tTooltip) : props.tooltip }} + + {{ props.tTooltip ? $t(props.tTooltip) : props.tooltip }} + -import { computed, reactive } from 'vue' +import { computed, nextTick, reactive } from 'vue' import { debounce, isEmpty, trim } from 'lodash' import { NButton, NInput } from 'naive-ui' import IconButton from '@/components/common/IconButton.vue' -import Help from '@/components/icons/Help.vue' +import SpellCheck from '@/components/icons/SpellCheck.vue' const props = defineProps({ fullSearchIcon: { @@ -22,17 +22,22 @@ const props = defineProps({ type: Boolean, default: false, }, + exact: { + type: Boolean, + default: false, + }, }) -const emit = defineEmits(['filterChanged', 'matchChanged']) +const emit = defineEmits(['filterChanged', 'matchChanged', 'exactChanged']) /** * - * @type {UnwrapNestedRefs<{filter: string, match: string}>} + * @type {UnwrapNestedRefs<{filter: string, match: string, exact: boolean}>} */ const inputData = reactive({ match: '', filter: '', + exact: false, }) const hasMatch = computed(() => { @@ -43,26 +48,32 @@ const hasFilter = computed(() => { return !isEmpty(trim(inputData.filter)) }) +const onExactChecked = () => { + // update search search result + if (hasMatch.value) { + nextTick(() => onForceFullSearch()) + } +} + const onFullSearch = () => { inputData.filter = trim(inputData.filter) if (!isEmpty(inputData.filter)) { inputData.match = inputData.filter inputData.filter = '' - emit('matchChanged', inputData.match, inputData.filter) + emit('matchChanged', inputData.match, inputData.filter, inputData.exact) } } +const onForceFullSearch = () => { + inputData.filter = trim(inputData.filter) + emit('matchChanged', inputData.match, inputData.filter, inputData.exact) +} + const _onInput = () => { - emit('filterChanged', inputData.filter) + emit('filterChanged', inputData.filter, inputData.exact) } const onInput = debounce(_onInput, props.debounceWait, { leading: true, trailing: true }) -const onKeyup = (evt) => { - if (evt.key === 'Enter') { - onFullSearch() - } -} - const onClearFilter = () => { inputData.filter = '' onClearMatch() @@ -77,9 +88,9 @@ const onClearMatch = () => { const changed = !isEmpty(inputData.match) inputData.match = '' if (changed) { - emit('matchChanged', inputData.match, inputData.filter) + emit('matchChanged', inputData.match, inputData.filter, inputData.exact) } else { - emit('filterChanged', inputData.filter) + emit('filterChanged', inputData.filter, inputData.exact) } } @@ -99,7 +110,7 @@ defineExpose({ clearable @clear="onClearFilter" @input="onInput" - @keyup.enter="onKeyup"> + @keyup.enter="onFullSearch"> @@ -117,12 +128,23 @@ defineExpose({ - + - + + + + + - {{ $t('dialogue.filter.filter_pattern_tip') }} + {{ $t('dialogue.filter.exact_match_tip') }} @@ -134,11 +156,17 @@ defineExpose({ :disabled="hasMatch && !hasFilter" :icon="props.fullSearchIcon" :size="small ? 16 : 20" + :tooltip-delay="1" border small stroke-width="4" - t-tooltip="interface.full_search" - @click="onFullSearch" /> + @click="onFullSearch"> + + + {{ $t('dialogue.filter.filter_pattern_tip') }} + + + {{ $t('interface.full_search') }} diff --git a/frontend/src/components/icons/SpellCheck.vue b/frontend/src/components/icons/SpellCheck.vue new file mode 100644 index 0000000..d3ca115 --- /dev/null +++ b/frontend/src/components/icons/SpellCheck.vue @@ -0,0 +1,26 @@ + + + + + + + + + + + diff --git a/frontend/src/components/sidebar/BrowserPane.vue b/frontend/src/components/sidebar/BrowserPane.vue index 5c07f3b..863c616 100644 --- a/frontend/src/components/sidebar/BrowserPane.vue +++ b/frontend/src/components/sidebar/BrowserPane.vue @@ -50,7 +50,10 @@ const inCheckState = ref(false) const dbSelectOptions = computed(() => { const dblist = browserStore.getDBList(props.server) + const hasPattern = !isEmpty(filterForm.pattern) return map(dblist, ({ db, alias, keyCount, maxKeys }) => { + keyCount = Math.max(0, keyCount) + maxKeys = Math.max(keyCount, maxKeys) let label if (!isEmpty(alias)) { // has alias @@ -59,7 +62,11 @@ const dbSelectOptions = computed(() => { label = `db${db}` } if (props.db === db) { - label += ` (${keyCount}/${maxKeys})` + if (hasPattern) { + label += ` (${keyCount})` + } else { + label += ` (${keyCount}/${maxKeys})` + } } else { label += ` (${maxKeys})` } @@ -80,6 +87,11 @@ const moreOptions = [ ] const loadProgress = computed(() => { + const hasPattern = !isEmpty(filterForm.pattern) + if (hasPattern) { + return 100 + } + const db = browserStore.getDatabase(props.server, props.db) if (db.maxKeys <= 0) { return 100 @@ -111,6 +123,7 @@ const onReload = async () => { browserStore.setKeyFilter(props.server, { type: matchType, pattern: unref(filterForm.pattern), + exact: unref(filterForm.exact) === true, }) await browserStore.openDatabase(props.server, db) fullyLoaded.value = await browserStore.loadMoreKeys(props.server, db) @@ -210,6 +223,7 @@ const handleSelectDB = async (db) => { const filterForm = reactive({ type: '', + exact: false, pattern: '', filter: '', }) @@ -217,13 +231,15 @@ const onSelectFilterType = (select) => { onReload() } -const onFilterInput = (val) => { +const onFilterInput = (val, exact) => { filterForm.filter = val + filterForm.exact = exact } -const onMatchInput = (matchVal, filterVal) => { +const onMatchInput = (matchVal, filterVal, exact) => { filterForm.pattern = matchVal filterForm.filter = filterVal + filterForm.exact = exact onReload() } diff --git a/frontend/src/components/sidebar/BrowserTree.vue b/frontend/src/components/sidebar/BrowserTree.vue index 8ec5cf2..b288745 100644 --- a/frontend/src/components/sidebar/BrowserTree.vue +++ b/frontend/src/components/sidebar/BrowserTree.vue @@ -168,8 +168,8 @@ const handleSelectContextMenu = (action) => { dialogStore.openNewKeyDialog(redisKey, props.server, db) break case 'db_filter': - const { match: pattern, type } = browserStore.getKeyFilter(props.server) - dialogStore.openKeyFilterDialog(props.server, db, pattern, type) + // const { match: pattern, type } = browserStore.getKeyFilter(props.server) + // dialogStore.openKeyFilterDialog(props.server, db, pattern, type) break case 'key_reload': if (node != null && !!!node.loading) { diff --git a/frontend/src/langs/en-us.json b/frontend/src/langs/en-us.json index 54f85b1..68f9039 100644 --- a/frontend/src/langs/en-us.json +++ b/frontend/src/langs/en-us.json @@ -325,7 +325,8 @@ "filter": { "set_key_filter": "Set Key Filter", "filter_pattern": "Pattern", - "filter_pattern_tip": "* matches 0 or more chars, e.g. 'key*' \n? matches single char, e.g. 'key?'\n[] matches range, e.g. 'key[1-3]'\n\\ escapes special chars" + "filter_pattern_tip": "* matches 0 or more chars, e.g. 'key*' \n? matches single char, e.g. 'key?'\n[] matches range, e.g. 'key[1-3]'\n\\ escapes special chars", + "exact_match_tip": "Exact Match" }, "export": { "name": "Export Data", diff --git a/frontend/src/langs/pt-br.json b/frontend/src/langs/pt-br.json index bbcdd62..4cb8587 100644 --- a/frontend/src/langs/pt-br.json +++ b/frontend/src/langs/pt-br.json @@ -325,7 +325,8 @@ "filter": { "set_key_filter": "Definir Filtro de Chave", "filter_pattern": "Padrão", - "filter_pattern_tip": "* corresponde a 0 ou mais caracteres, ex: 'chave*'\n? corresponde a um único caractere, ex: 'chave?'\n[] corresponde a um intervalo, ex: 'chave[1-3]'\n\\ escapa caracteres especiais" + "filter_pattern_tip": "* corresponde a 0 ou mais caracteres, ex: 'chave*'\n? corresponde a um único caractere, ex: 'chave?'\n[] corresponde a um intervalo, ex: 'chave[1-3]'\n\\ escapa caracteres especiais", + "exact_match_tip": "Correspondência Exata" }, "export": { "name": "Exportar Dados", diff --git a/frontend/src/langs/zh-cn.json b/frontend/src/langs/zh-cn.json index 0321925..5f08b37 100644 --- a/frontend/src/langs/zh-cn.json +++ b/frontend/src/langs/zh-cn.json @@ -325,7 +325,8 @@ "filter": { "set_key_filter": "设置键过滤器", "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\\:转义字符。如果想要匹配 *、?、[、或],需要使用反斜杠\"\\\"进行转义", + "exact_match_tip": "完全匹配" }, "export": { "name": "导出数据", diff --git a/frontend/src/langs/zh-tw.json b/frontend/src/langs/zh-tw.json index 255da9c..4109bde 100644 --- a/frontend/src/langs/zh-tw.json +++ b/frontend/src/langs/zh-tw.json @@ -325,7 +325,8 @@ "filter": { "set_key_filter": "設定鍵過濾器", "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\\:轉義字元。如果想要匹配 *, ?, [, 或],需要使用反斜線\"\\\"進行轉義", + "exact_match_tip": "精準匹配" }, "export": { "name": "匯出資料", diff --git a/frontend/src/objects/redisServerState.js b/frontend/src/objects/redisServerState.js index e75991a..dfc23dd 100644 --- a/frontend/src/objects/redisServerState.js +++ b/frontend/src/objects/redisServerState.js @@ -24,6 +24,7 @@ export class RedisServerState { * @param {Object.} databases database list * @param {string|null} patternFilter pattern filter * @param {string|null} typeFilter redis type filter + * @param {boolean} exactFilter exact match filter keyword * @param {LoadingState} loadingState all loading state in opened connections map by server and LoadingState * @param {KeyViewType} viewType view type selection for all opened connections group by 'server' * @param {Map} nodeMap map nodes by "type#key" @@ -35,6 +36,7 @@ export class RedisServerState { databases = {}, patternFilter = null, typeFilter = null, + exactFilter = false, loadingState = {}, viewType = KeyViewType.Tree, nodeMap = new Map(), @@ -46,6 +48,7 @@ export class RedisServerState { this.databases = databases this.patternFilter = patternFilter this.typeFilter = typeFilter + this.exactFilter = exactFilter this.loadingState = loadingState this.viewType = viewType this.nodeMap = nodeMap @@ -62,6 +65,7 @@ export class RedisServerState { this.stats = {} this.patternFilter = null this.typeFilter = null + this.exactFilter = false this.nodeMap.clear() } @@ -447,6 +451,7 @@ export class RedisServerState { return { match: pattern, type: toUpper(this.typeFilter), + exact: this.exactFilter === true, } } @@ -454,10 +459,12 @@ export class RedisServerState { * set key filter * @param {string} [pattern] * @param {string} [type] + * @param {boolean} [exact] */ - setFilter({ pattern, type }) { + setFilter({ pattern, type, exact = false }) { this.patternFilter = pattern === null ? this.patternFilter : pattern this.typeFilter = type === null ? this.typeFilter : type + this.exactFilter = exact === true } /** diff --git a/frontend/src/stores/browser.js b/frontend/src/stores/browser.js index b47e562..5438ce1 100644 --- a/frontend/src/stores/browser.js +++ b/frontend/src/stores/browser.js @@ -293,8 +293,7 @@ const useBrowserStore = defineStore('browser', { * @returns {Promise} */ async openDatabase(server, db) { - const { match: filterPattern, type: filterType } = this.getKeyFilter(server) - const { data, success, msg } = await OpenDatabase(server, db, filterPattern, filterType) + const { data, success, msg } = await OpenDatabase(server, db) if (!success) { throw new Error(msg) } @@ -565,22 +564,23 @@ const useBrowserStore = defineStore('browser', { * @param {string} server * @param {number} db * @param {string} match + * @param {boolean} exact * @param {string} [matchType] * @param {number} [loadType] 0.load next; 1.load next full; 2.reload load all * @returns {Promise<{keys: string[], maxKeys: number, end: boolean}>} */ - async scanKeys({ server, db, match = '*', matchType = '', loadType = 0 }) { + async scanKeys({ server, db, match = '*', exact = false, matchType = '', loadType = 0 }) { let resp switch (loadType) { case 0: default: - resp = await LoadNextKeys(server, db, match, matchType) + resp = await LoadNextKeys(server, db, match, matchType, exact) break case 1: - resp = await LoadNextAllKeys(server, db, match, matchType) + resp = await LoadNextAllKeys(server, db, match, matchType, exact) break case 2: - resp = await LoadAllKeys(server, db, match, matchType) + resp = await LoadAllKeys(server, db, match, matchType, exact) break } const { data, success, msg } = resp || {} @@ -595,22 +595,24 @@ const useBrowserStore = defineStore('browser', { * * @param {string} server * @param {number} db - * @param {string|null} prefix + * @param {string|null} match + * @param {boolean} exact * @param {string|null} matchType * @param {boolean} [all] * @return {Promise<{keys: Array, maxKeys: number, end: boolean}>} * @private */ - async _loadKeys(server, db, prefix, matchType, all) { - let match = prefix + async _loadKeys({ server, db, match, exact, matchType, all }) { if (isEmpty(match)) { match = '*' - } else if (!isRedisGlob(match)) { - if (!endsWith(prefix, '*')) { - match = prefix + '*' + } + + if (!isRedisGlob(match) && !exact) { + if (!endsWith(match, '*')) { + match = match + '*' } } - return this.scanKeys({ server, db, match, matchType, loadType: all ? 1 : 0 }) + return this.scanKeys({ server, db, match, exact, matchType, loadType: all ? 1 : 0 }) }, /** @@ -620,8 +622,15 @@ const useBrowserStore = defineStore('browser', { * @return {Promise} */ async loadMoreKeys(server, db) { - const { match, type: keyType } = this.getKeyFilter(server) - const { keys, maxKeys, end } = await this._loadKeys(server, db, match, keyType, false) + const { match, type: keyType, exact } = this.getKeyFilter(server) + const { keys, maxKeys, end } = await this._loadKeys({ + server, + db, + match, + exact, + matchType: keyType, + all: false, + }) /** @type RedisServerState **/ const serverInst = this.servers[server] if (serverInst != null) { @@ -640,8 +649,8 @@ const useBrowserStore = defineStore('browser', { * @return {Promise} */ async loadAllKeys(server, db) { - const { match, type: keyType } = this.getKeyFilter(server) - const { keys, maxKeys } = await this._loadKeys(server, db, match, keyType, true) + const { match, type: keyType, exact } = this.getKeyFilter(server) + const { keys, maxKeys } = await this._loadKeys({ server, db, match, exact, matchType: keyType, all: true }) /** @type RedisServerState **/ const serverInst = this.servers[server] if (serverInst != null) { @@ -670,8 +679,15 @@ const useBrowserStore = defineStore('browser', { match += '*' } // FIXME: ignore original match pattern due to redis not support combination matching - const { match: originMatch, type: keyType } = this.getKeyFilter(server) - const { keys, maxKeys, success } = await this._loadKeys(server, db, match, keyType, true) + const { match: originMatch, type: keyType, exact } = this.getKeyFilter(server) + const { keys, maxKeys, success } = await this._loadKeys({ + server, + db, + match: originMatch, + exact: false, + matchType: keyType, + all: true, + }) if (!success) { return } @@ -1915,7 +1931,7 @@ const useBrowserStore = defineStore('browser', { /** * get key filter pattern and filter type * @param {string} server - * @returns {{match: string, type: string}} + * @returns {{match: string, type: string, exact: boolean}} */ getKeyFilter(server) { let serverInst = this.servers[server] @@ -1933,11 +1949,12 @@ const useBrowserStore = defineStore('browser', { * @param {string} server * @param {string} [pattern] * @param {string} [type] + * @param {boolean} [exact] */ - setKeyFilter(server, { pattern, type }) { + setKeyFilter(server, { pattern, type, exact = false }) { const serverInst = this.servers[server] if (serverInst != null) { - serverInst.setFilter({ pattern, type }) + serverInst.setFilter({ pattern, type, exact }) } },