feat: add stream type support

This commit is contained in:
tiny-craft 2023-07-25 19:12:50 +08:00
parent 9b63e51300
commit 2d0e8f5445
12 changed files with 456 additions and 23 deletions

View File

@ -497,6 +497,21 @@ func (c *connectionService) GetKeyValue(connName string, db int, key string) (re
} }
} }
value = items value = items
case "stream":
var msgs []redis.XMessage
items := []types.StreamItem{}
msgs, err = rdb.XRevRange(ctx, key, "+", "-").Result()
if err != nil {
resp.Msg = err.Error()
return
}
for _, msg := range msgs {
items = append(items, types.StreamItem{
ID: msg.ID,
Value: msg.Values,
})
}
value = items
} }
if err != nil { if err != nil {
resp.Msg = err.Error() resp.Msg = err.Error()
@ -538,13 +553,10 @@ func (c *connectionService) SetKeyValue(connName string, db int, key, keyType st
resp.Msg = "invalid list value" resp.Msg = "invalid list value"
return return
} else { } else {
_, err = rdb.Pipelined(ctx, func(pipe redis.Pipeliner) error { err = rdb.LPush(ctx, key, strs...).Err()
pipe.LPush(ctx, key, strs...) if err == nil && expiration > 0 {
if expiration > 0 { rdb.Expire(ctx, key, expiration)
pipe.Expire(ctx, key, expiration)
} }
return nil
})
} }
case "hash": case "hash":
if strs, ok := value.([]any); !ok { if strs, ok := value.([]any); !ok {
@ -552,11 +564,7 @@ func (c *connectionService) SetKeyValue(connName string, db int, key, keyType st
return return
} else { } else {
if len(strs) > 1 { if len(strs) > 1 {
kvs := map[string]any{} err = rdb.HSet(ctx, key, strs).Err()
for i := 0; i < len(strs); i += 2 {
kvs[strs[i].(string)] = strs[i+1]
}
err = rdb.HSet(ctx, key, kvs).Err()
if err == nil && expiration > 0 { if err == nil && expiration > 0 {
rdb.Expire(ctx, key, expiration) rdb.Expire(ctx, key, expiration)
} }
@ -594,6 +602,22 @@ func (c *connectionService) SetKeyValue(connName string, db int, key, keyType st
} }
} }
} }
case "stream":
if strs, ok := value.([]any); !ok {
resp.Msg = "invalid stream value"
return
} else {
if len(strs) > 2 {
err = rdb.XAdd(ctx, &redis.XAddArgs{
Stream: key,
ID: strs[0].(string),
Values: strs[1:],
}).Err()
if err == nil && expiration > 0 {
rdb.Expire(ctx, key, expiration)
}
}
}
} }
if err != nil { if err != nil {
@ -886,6 +910,41 @@ func (c *connectionService) AddZSetValue(connName string, db int, key string, ac
return return
} }
// AddStreamValue add stream field
func (c *connectionService) AddStreamValue(connName string, db int, key, ID string, fieldItems []any) (resp types.JSResp) {
rdb, ctx, err := c.getRedisClient(connName, db)
if err != nil {
resp.Msg = err.Error()
return
}
_, err = rdb.XAdd(ctx, &redis.XAddArgs{
Stream: key,
ID: ID,
Values: fieldItems,
}).Result()
if err != nil {
resp.Msg = err.Error()
return
}
resp.Success = true
return
}
// RemoveStreamValues remove stream values by id
func (c *connectionService) RemoveStreamValues(connName string, db int, key string, IDs []string) (resp types.JSResp) {
rdb, ctx, err := c.getRedisClient(connName, db)
if err != nil {
resp.Msg = err.Error()
return
}
_, err = rdb.XDel(ctx, key, IDs...).Result()
resp.Success = true
return
}
// SetKeyTTL set ttl of key // SetKeyTTL set ttl of key
func (c *connectionService) SetKeyTTL(connName string, db int, key string, ttl int64) (resp types.JSResp) { func (c *connectionService) SetKeyTTL(connName string, db int, key string, ttl int64) (resp types.JSResp) {
rdb, ctx, err := c.getRedisClient(connName, db) rdb, ctx, err := c.getRedisClient(connName, db)

View File

@ -4,3 +4,8 @@ type ZSetItem struct {
Value string `json:"value"` Value string `json:"value"`
Score float64 `json:"score"` Score float64 `json:"score"`
} }
type StreamItem struct {
ID string `json:"id"`
Value map[string]any `json:"value"`
}

View File

@ -8,6 +8,7 @@ import Save from '../icons/Save.vue'
const props = defineProps({ const props = defineProps({
bindKey: String, bindKey: String,
editing: Boolean, editing: Boolean,
readonly: Boolean,
}) })
const emit = defineEmits(['edit', 'delete', 'save', 'cancel']) const emit = defineEmits(['edit', 'delete', 'save', 'cancel'])
@ -20,7 +21,7 @@ const emit = defineEmits(['edit', 'delete', 'save', 'cancel'])
<icon-button :icon="Close" @click="emit('cancel')" /> <icon-button :icon="Close" @click="emit('cancel')" />
</div> </div>
<div v-else class="flex-box-h edit-column-func"> <div v-else class="flex-box-h edit-column-func">
<icon-button :icon="Edit" @click="emit('edit')" /> <icon-button v-if="!props.readonly" :icon="Edit" @click="emit('edit')" />
<n-popconfirm :negative-text="$t('cancel')" :positive-text="$t('confirm')" @positive-click="emit('delete')"> <n-popconfirm :negative-text="$t('cancel')" :positive-text="$t('confirm')" @positive-click="emit('delete')">
<template #trigger> <template #trigger>
<icon-button :icon="Delete" /> <icon-button :icon="Delete" />

View File

@ -13,6 +13,7 @@ import useConnectionStore from '../../stores/connections.js'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useConfirmDialog } from '../../utils/confirm_dialog.js' import { useConfirmDialog } from '../../utils/confirm_dialog.js'
import ContentServerStatus from '../content_value/ContentServerStatus.vue' import ContentServerStatus from '../content_value/ContentServerStatus.vue'
import ContentValueStream from '../content_value/ContentValueStream.vue'
const serverInfo = ref({}) const serverInfo = ref({})
const autoRefresh = ref(false) const autoRefresh = ref(false)
@ -62,6 +63,7 @@ const valueComponents = {
[types.LIST]: ContentValueList, [types.LIST]: ContentValueList,
[types.SET]: ContentValueSet, [types.SET]: ContentValueSet,
[types.ZSET]: ContentValueZset, [types.ZSET]: ContentValueZset,
[types.STREAM]: ContentValueStream,
} }
const dialog = useDialog() const dialog = useDialog()
@ -71,7 +73,7 @@ const tab = computed(() =>
map(tabStore.tabs, (item) => ({ map(tabStore.tabs, (item) => ({
key: item.name, key: item.name,
label: item.title, label: item.title,
})) })),
) )
watch( watch(
@ -80,7 +82,7 @@ watch(
if (nav === 'browser') { if (nav === 'browser') {
refreshInfo() refreshInfo()
} }
} },
) )
const tabContent = computed(() => { const tabContent = computed(() => {

View File

@ -11,6 +11,8 @@ import { useMessage } from 'naive-ui'
import IconButton from '../common/IconButton.vue' import IconButton from '../common/IconButton.vue'
import useConnectionStore from '../../stores/connections.js' import useConnectionStore from '../../stores/connections.js'
import { useConfirmDialog } from '../../utils/confirm_dialog.js' import { useConfirmDialog } from '../../utils/confirm_dialog.js'
import Copy from '../icons/Copy.vue'
import { ClipboardSetText } from '../../../wailsjs/runtime/runtime.js'
const props = defineProps({ const props = defineProps({
server: String, server: String,
@ -38,6 +40,18 @@ const onReloadKey = () => {
connectionStore.loadKeyValue(props.server, props.db, props.keyPath) connectionStore.loadKeyValue(props.server, props.db, props.keyPath)
} }
const onCopyKey = () => {
ClipboardSetText(props.keyPath)
.then((succ) => {
if (succ) {
message.success(i18n.t('copy_succ'))
}
})
.catch((e) => {
message.error(e.message)
})
}
const confirmDialog = useConfirmDialog() const confirmDialog = useConfirmDialog()
const onDeleteKey = () => { const onDeleteKey = () => {
confirmDialog.warning(i18n.t('remove_tip', { name: props.keyPath }), () => { confirmDialog.warning(i18n.t('remove_tip', { name: props.keyPath }), () => {
@ -53,12 +67,13 @@ const onDeleteKey = () => {
<template> <template>
<div class="content-toolbar flex-box-h"> <div class="content-toolbar flex-box-h">
<n-input-group> <n-input-group>
<redis-type-tag :type="props.keyType" size="large"></redis-type-tag> <redis-type-tag :type="props.keyType" size="large" />
<n-input v-model:value="props.keyPath"> <n-input v-model:value="props.keyPath">
<template #suffix> <template #suffix>
<icon-button :icon="Refresh" t-tooltip="reload" size="18" @click="onReloadKey" /> <icon-button :icon="Refresh" t-tooltip="reload" size="18" @click="onReloadKey" />
</template> </template>
</n-input> </n-input>
<icon-button :icon="Copy" t-tooltip="copy_key" size="18" border @click="onCopyKey" />
</n-input-group> </n-input-group>
<n-button-group> <n-button-group>
<n-tooltip> <n-tooltip>

View File

@ -0,0 +1,202 @@
<script setup>
import { computed, h, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentToolbar from './ContentToolbar.vue'
import AddLink from '../icons/AddLink.vue'
import { NButton, NCode, NIcon, NInput, useMessage } from 'naive-ui'
import { types, types as redisTypes } from '../../consts/support_redis_type.js'
import EditableTableColumn from '../common/EditableTableColumn.vue'
import useDialogStore from '../../stores/dialog.js'
import useConnectionStore from '../../stores/connections.js'
import { includes, keys, some, values } from 'lodash'
const i18n = useI18n()
const props = defineProps({
name: String,
db: Number,
keyPath: String,
ttl: {
type: Number,
default: -1,
},
value: Object,
})
const filterOption = computed(() => [
{
value: 1,
label: i18n.t('field'),
},
{
value: 2,
label: i18n.t('value'),
},
])
const filterType = ref(1)
const connectionStore = useConnectionStore()
const dialogStore = useDialogStore()
const keyType = redisTypes.STREAM
const idColumn = reactive({
key: 'id',
title: 'ID',
align: 'center',
titleAlign: 'center',
resizable: true,
})
const valueColumn = reactive({
key: 'value',
title: i18n.t('value'),
align: 'center',
titleAlign: 'center',
resizable: true,
filterOptionValue: null,
filter(value, row) {
const v = value.toString()
if (filterType.value === 1) {
// filter key
return some(keys(row.value), (key) => includes(key, v))
} else {
// filter value
return some(values(row.value), (val) => includes(val, v))
}
},
// sorter: (row1, row2) => row1.value - row2.value,
// ellipsis: {
// tooltip: true
// },
render: (row) => {
return h(NCode, { language: 'json', wordWrap: true }, { default: () => JSON.stringify(row.value) })
},
})
const actionColumn = {
key: 'action',
title: i18n.t('action'),
width: 60,
align: 'center',
titleAlign: 'center',
fixed: 'right',
render: (row) => {
return h(EditableTableColumn, {
bindKey: row.id,
readonly: true,
onDelete: async () => {
try {
const { success, msg } = await connectionStore.removeStreamValues(
props.name,
props.db,
props.keyPath,
row.id,
)
if (success) {
connectionStore.loadKeyValue(props.name, props.db, props.keyPath).then((r) => {})
message.success(i18n.t('delete_key_succ', { key: row.id }))
// update display value
// if (!isEmpty(removed)) {
// for (const elem of removed) {
// delete props.value[elem]
// }
// }
} else {
message.error(msg)
}
} catch (e) {
message.error(e.message)
}
},
})
},
}
const columns = reactive([idColumn, valueColumn, actionColumn])
const tableData = computed(() => {
const data = []
for (const elem of props.value) {
data.push({
id: elem.id,
value: elem.value,
})
}
return data
})
const message = useMessage()
const onAddRow = () => {
dialogStore.openAddFieldsDialog(props.name, props.db, props.keyPath, types.STREAM)
}
const filterValue = ref('')
const onFilterInput = (val) => {
valueColumn.filterOptionValue = val
}
const onChangeFilterType = (type) => {
onFilterInput(filterValue.value)
}
const clearFilter = () => {
idColumn.filterOptionValue = null
valueColumn.filterOptionValue = null
}
const onUpdateFilter = (filters, sourceColumn) => {
switch (filterType.value) {
case filterOption[0].value:
idColumn.filterOptionValue = filters[sourceColumn.key]
break
case filterOption[1].value:
valueColumn.filterOptionValue = filters[sourceColumn.key]
break
}
}
</script>
<template>
<div class="content-wrapper flex-box-v">
<content-toolbar :db="props.db" :key-path="props.keyPath" :key-type="keyType" :server="props.name" :ttl="ttl" />
<div class="tb2 flex-box-h">
<div class="flex-box-h">
<n-input-group>
<n-select
v-model:value="filterType"
:consistent-menu-width="false"
:options="filterOption"
style="width: 120px"
@update:value="onChangeFilterType"
/>
<n-input
v-model:value="filterValue"
:placeholder="$t('search')"
clearable
@clear="clearFilter"
@update:value="onFilterInput"
/>
</n-input-group>
</div>
<div class="flex-item-expand"></div>
<n-button plain @click="onAddRow">
<template #icon>
<n-icon :component="AddLink" size="18" />
</template>
{{ $t('add_row') }}
</n-button>
</div>
<div class="fill-height flex-box-h" style="user-select: text">
<n-data-table
:key="(row) => row.id"
:columns="columns"
:data="tableData"
:single-column="true"
:single-line="false"
flex-height
max-height="100%"
size="small"
striped
virtual-scroll
@update:filters="onUpdateFilter"
/>
</div>
</div>
</template>
<style lang="scss" scoped></style>

View File

@ -10,6 +10,8 @@ import AddListValue from '../new_value/AddListValue.vue'
import AddHashValue from '../new_value/AddHashValue.vue' import AddHashValue from '../new_value/AddHashValue.vue'
import AddZSetValue from '../new_value/AddZSetValue.vue' import AddZSetValue from '../new_value/AddZSetValue.vue'
import useConnectionStore from '../../stores/connections.js' import useConnectionStore from '../../stores/connections.js'
import NewStreamValue from '../new_value/NewStreamValue.vue'
import { size, slice } from 'lodash'
const i18n = useI18n() const i18n = useI18n()
const newForm = reactive({ const newForm = reactive({
@ -29,6 +31,7 @@ const addValueComponent = {
[types.LIST]: AddListValue, [types.LIST]: AddListValue,
[types.SET]: NewSetValue, [types.SET]: NewSetValue,
[types.ZSET]: AddZSetValue, [types.ZSET]: AddZSetValue,
[types.STREAM]: NewStreamValue,
} }
const defaultValue = { const defaultValue = {
[types.STRING]: '', [types.STRING]: '',
@ -36,6 +39,7 @@ const defaultValue = {
[types.LIST]: [], [types.LIST]: [],
[types.SET]: [], [types.SET]: [],
[types.ZSET]: [], [types.ZSET]: [],
[types.STREAM]: ['*'],
} }
/** /**
@ -52,6 +56,8 @@ const title = computed(() => {
return i18n.t('new_field') return i18n.t('new_field')
case types.ZSET: case types.ZSET:
return i18n.t('new_field') return i18n.t('new_field')
case types.STREAM:
return i18n.t('new_field')
} }
return '' return ''
}) })
@ -69,7 +75,7 @@ watch(
newForm.opType = 0 newForm.opType = 0
newForm.value = null newForm.value = null
} }
} },
) )
const connectionStore = useConnectionStore() const connectionStore = useConnectionStore()
@ -143,6 +149,28 @@ const onAdd = async () => {
} }
} }
break break
case types.STREAM:
{
if (size(value) > 2) {
const { success, msg } = await connectionStore.addStreamValue(
server,
db,
key,
value[0],
slice(value, 1),
)
if (success) {
if (newForm.reload) {
connectionStore.loadKeyValue(server, db, key).then(() => {})
}
message.success(i18n.t('handle_succ'))
} else {
message.error(msg)
}
}
}
break
} }
dialogStore.closeAddFieldsDialog() dialogStore.closeAddFieldsDialog()
} catch (e) { } catch (e) {

View File

@ -12,6 +12,7 @@ import { useI18n } from 'vue-i18n'
import useConnectionStore from '../../stores/connections.js' import useConnectionStore from '../../stores/connections.js'
import { NSpace, useMessage } from 'naive-ui' import { NSpace, useMessage } from 'naive-ui'
import useTabStore from '../../stores/tab.js' import useTabStore from '../../stores/tab.js'
import NewStreamValue from '../new_value/NewStreamValue.vue'
const i18n = useI18n() const i18n = useI18n()
const newForm = reactive({ const newForm = reactive({
@ -52,6 +53,7 @@ const addValueComponent = {
[types.LIST]: NewListValue, [types.LIST]: NewListValue,
[types.SET]: NewSetValue, [types.SET]: NewSetValue,
[types.ZSET]: NewZSetValue, [types.ZSET]: NewZSetValue,
[types.STREAM]: NewStreamValue,
} }
const defaultValue = { const defaultValue = {
[types.STRING]: '', [types.STRING]: '',
@ -59,6 +61,7 @@ const defaultValue = {
[types.LIST]: [], [types.LIST]: [],
[types.SET]: [], [types.SET]: [],
[types.ZSET]: [], [types.ZSET]: [],
[types.STREAM]: [],
} }
const dialogStore = useDialog() const dialogStore = useDialog()
@ -165,7 +168,7 @@ const onClose = () => {
<n-input v-model:value="newForm.key" placeholder="" /> <n-input v-model:value="newForm.key" placeholder="" />
</n-form-item> </n-form-item>
<n-form-item :label="$t('db_index')" path="db" required> <n-form-item :label="$t('db_index')" path="db" required>
<n-select v-model:value="newForm.db" :options="dbOptions" /> <n-select v-model:value="newForm.db" :options="dbOptions" filterable />
</n-form-item> </n-form-item>
<n-form-item :label="$t('type')" path="type" required> <n-form-item :label="$t('type')" path="type" required>
<n-select v-model:value="newForm.type" :options="options" :render-label="renderTypeLabel" /> <n-select v-model:value="newForm.type" :options="options" :render-label="renderTypeLabel" />

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref } from 'vue' import { defineOptions, ref } from 'vue'
import { isEmpty, reject } from 'lodash' import { isEmpty, reject } from 'lodash'
import Add from '../icons/Add.vue' import Add from '../icons/Add.vue'
import Delete from '../icons/Delete.vue' import Delete from '../icons/Delete.vue'
@ -10,6 +10,9 @@ const props = defineProps({
type: Number, type: Number,
value: Object, value: Object,
}) })
defineOptions({
inheritAttrs: false,
})
const emit = defineEmits(['update:value', 'update:type']) const emit = defineEmits(['update:value', 'update:type'])
const i18n = useI18n() const i18n = useI18n()

View File

@ -0,0 +1,62 @@
<script setup>
import { defineOptions, ref } from 'vue'
import { flatMap, isEmpty, reject } from 'lodash'
import Add from '../icons/Add.vue'
import Delete from '../icons/Delete.vue'
import IconButton from '../common/IconButton.vue'
const props = defineProps({
value: Array,
})
defineOptions({
inheritAttrs: false,
})
const id = ref('*')
const emit = defineEmits(['update:value'])
/**
* @typedef Hash
* @property {string} key
* @property {string} [value]
*/
const kvList = ref([{ key: '', value: '' }])
/**
*
* @param {Hash[]} val
*/
const onUpdate = (val) => {
val = reject(val, { key: '' })
const vals = flatMap(val, (item) => [item.key, item.value])
vals.splice(0, 0, id.value || '*')
emit('update:value', vals)
}
defineExpose({
validate: () => {
return !isEmpty(props.value)
},
})
</script>
<template>
<n-form-item label="ID">
<n-input v-model:value="id" />
</n-form-item>
<n-form-item :label="$t('element')" required>
<n-dynamic-input
v-model:value="kvList"
:key-placeholder="$t('enter_field')"
:value-placeholder="$t('enter_value')"
preset="pair"
@update:value="onUpdate"
>
<template #action="{ index, create, remove, move }">
<icon-button v-if="kvList.length > 1" :icon="Delete" size="18" @click="() => remove(index)" />
<icon-button :icon="Add" size="18" @click="() => create(index)" />
</template>
</n-dynamic-input>
</n-form-item>
</template>
<style lang="scss" scoped></style>

View File

@ -4,14 +4,16 @@ export const types = {
LIST: 'LIST', LIST: 'LIST',
SET: 'SET', SET: 'SET',
ZSET: 'ZSET', ZSET: 'ZSET',
STREAM: 'STREAM',
} }
export const typesColor = { export const typesColor = {
[types.STRING]: '#8256DC', [types.STRING]: '#8256DC',
[types.HASH]: '#2983ED', [types.HASH]: '#0171F5',
[types.LIST]: '#26A15E', [types.LIST]: '#23C338',
[types.SET]: '#EE9F33', [types.SET]: '#F29E33',
[types.ZSET]: '#CE3352', [types.ZSET]: '#F53227',
[types.STREAM]: '#F5C201',
} }
export const typesBgColor = { export const typesBgColor = {
@ -20,6 +22,7 @@ export const typesBgColor = {
[types.LIST]: '#E3F3EB', [types.LIST]: '#E3F3EB',
[types.SET]: '#FDF1DF', [types.SET]: '#FDF1DF',
[types.ZSET]: '#FAEAED', [types.ZSET]: '#FAEAED',
[types.STREAM]: '#FFF8DF',
} }
// export const typesName = Object.fromEntries(Object.entries(types).map(([key, value]) => [key, value.name])) // export const typesName = Object.fromEntries(Object.entries(types).map(([key, value]) => [key, value.name]))

View File

@ -3,6 +3,7 @@ import { endsWith, get, isEmpty, join, remove, size, slice, sortedIndexBy, split
import { import {
AddHashField, AddHashField,
AddListItem, AddListItem,
AddStreamValue,
AddZSetValue, AddZSetValue,
CloseConnection, CloseConnection,
CreateGroup, CreateGroup,
@ -15,6 +16,7 @@ import {
ListConnection, ListConnection,
OpenConnection, OpenConnection,
OpenDatabase, OpenDatabase,
RemoveStreamValues,
RenameGroup, RenameGroup,
RenameKey, RenameKey,
SaveConnection, SaveConnection,
@ -1134,6 +1136,54 @@ const useConnectionStore = defineStore('connections', {
} }
}, },
/**
* insert new stream field item
* @param {string} connName
* @param {number} db
* @param {string} key
* @param {string} id
* @param {string[]} values field1, value1, filed2, value2...
* @returns {Promise<{[msg]: string, success: boolean, [updated]: {}}>}
*/
async addStreamValue(connName, db, key, id, values) {
try {
const { data = {}, success, msg } = await AddStreamValue(connName, db, key, id, values)
if (success) {
const { updated = {} } = data
return { success, updated }
} else {
return { success: false, msg }
}
} catch (e) {
return { success: false, msg: e.message }
}
},
/**
* remove stream field
* @param {string} connName
* @param {number} db
* @param {string} key
* @param {string[]|string} ids
* @returns {Promise<{[msg]: {}, success: boolean, [removed]: string[]}>}
*/
async removeStreamValues(connName, db, key, ids) {
if (typeof ids === 'string') {
ids = [ids]
}
try {
const { data = {}, success, msg } = await RemoveStreamValues(connName, db, key, ids)
if (success) {
const { removed = [] } = data
return { success, removed }
} else {
return { success, msg }
}
} catch (e) {
return { success: false, msg: e.message }
}
},
/** /**
* reset key's ttl * reset key's ttl
* @param {string} connName * @param {string} connName