feat: add app launch log page

This commit is contained in:
tiny-craft 2023-07-16 01:50:01 +08:00
parent 15c80bc9f7
commit 4879901a33
16 changed files with 355 additions and 53 deletions

View File

@ -12,13 +12,22 @@ import (
. "tinyrdm/backend/storage" . "tinyrdm/backend/storage"
"tinyrdm/backend/types" "tinyrdm/backend/types"
maputil "tinyrdm/backend/utils/map" maputil "tinyrdm/backend/utils/map"
mathutil "tinyrdm/backend/utils/math"
redis2 "tinyrdm/backend/utils/redis" redis2 "tinyrdm/backend/utils/redis"
) )
type cmdHistoryItem struct {
timestamp int64
Time string `json:"time"`
Server string `json:"server"`
Cmd string `json:"cmd"`
}
type connectionService struct { type connectionService struct {
ctx context.Context ctx context.Context
conns *ConnectionsStorage conns *ConnectionsStorage
connMap map[string]connectionItem connMap map[string]connectionItem
cmdHistory []cmdHistoryItem
} }
type connectionItem struct { type connectionItem struct {
@ -230,7 +239,7 @@ func (c *connectionService) CloseConnection(name string) (resp types.JSResp) {
} }
// get redis client from local cache or create a new open // get redis client from local cache or create a new open
// if db >= 0, also switch to db index // if db >= 0, will also switch to db index
func (c *connectionService) getRedisClient(connName string, db int) (*redis.Client, context.Context, error) { func (c *connectionService) getRedisClient(connName string, db int) (*redis.Client, context.Context, error) {
item, ok := c.connMap[connName] item, ok := c.connMap[connName]
var rdb *redis.Client var rdb *redis.Client
@ -249,7 +258,19 @@ func (c *connectionService) getRedisClient(connName string, db int) (*redis.Clie
Password: selConn.Password, Password: selConn.Password,
ReadTimeout: -1, ReadTimeout: -1,
}) })
rdb.AddHook(redis2.NewHook(connName)) rdb.AddHook(redis2.NewHook(connName, func(cmd string) {
now := time.Now()
last := strings.LastIndex(cmd, ":")
if last != -1 {
cmd = cmd[:last]
}
c.cmdHistory = append(c.cmdHistory, cmdHistoryItem{
timestamp: now.UnixMilli(),
Time: now.Format("2006-01-02 15:04:05"),
Server: connName,
Cmd: cmd,
})
}))
if _, err := rdb.Ping(c.ctx).Result(); err != nil && err != redis.Nil { if _, err := rdb.Ping(c.ctx).Result(); err != nil && err != redis.Nil {
return nil, nil, errors.New("can not connect to redis server:" + err.Error()) return nil, nil, errors.New("can not connect to redis server:" + err.Error())
@ -960,6 +981,28 @@ func (c *connectionService) RenameKey(connName string, db int, key, newKey strin
return return
} }
func (c *connectionService) GetCmdHistory(pageNo, pageSize int) (resp types.JSResp) {
resp.Success = true
if pageSize <= 0 || pageNo <= 0 {
// return all history
resp.Data = map[string]any{
"list": c.cmdHistory,
"pageNo": 1,
"pageSize": -1,
}
} else {
total := len(c.cmdHistory)
startIndex := total / pageSize * (pageNo - 1)
endIndex := mathutil.Min(startIndex+pageSize, total)
resp.Data = map[string]any{
"list": c.cmdHistory[startIndex:endIndex],
"pageNo": pageNo,
"pageSize": pageSize,
}
}
return
}
// update or insert key info to database // update or insert key info to database
//func (c *connectionService) updateDBKey(connName string, db int, keys []string, separator string) { //func (c *connectionService) updateDBKey(connName string, db int, keys []string, separator string) {
// dbStruct := map[string]any{} // dbStruct := map[string]any{}

View File

@ -102,7 +102,7 @@ func (c *ConnectionsStorage) GetConnection(name string) *types.Connection {
return findConn(name, "", conns) return findConn(name, "", conns)
} }
// GetGroup get connection group by name // GetGroup get one connection group by name
func (c *ConnectionsStorage) GetGroup(name string) *types.Connection { func (c *ConnectionsStorage) GetGroup(name string) *types.Connection {
conns := c.getConnections() conns := c.getConnections()

View File

@ -0,0 +1,93 @@
package mathutil
import (
"math"
. "tinyrdm/backend/utils"
)
// MaxWithIndex 查找所有元素中的最大值
func MaxWithIndex[T Hashable](items ...T) (T, int) {
selIndex := -1
for i, t := range items {
if selIndex < 0 {
selIndex = i
} else {
if t > items[selIndex] {
selIndex = i
}
}
}
return items[selIndex], selIndex
}
func Max[T Hashable](items ...T) T {
val, _ := MaxWithIndex(items...)
return val
}
// MinWithIndex 查找所有元素中的最小值
func MinWithIndex[T Hashable](items ...T) (T, int) {
selIndex := -1
for i, t := range items {
if selIndex < 0 {
selIndex = i
} else {
if t < items[selIndex] {
selIndex = i
}
}
}
return items[selIndex], selIndex
}
func Min[T Hashable](items ...T) T {
val, _ := MinWithIndex(items...)
return val
}
// Clamp 返回限制在minVal和maxVal范围内的value
func Clamp[T Hashable](value T, minVal T, maxVal T) T {
if minVal > maxVal {
minVal, maxVal = maxVal, minVal
}
if value < minVal {
value = minVal
} else if value > maxVal {
value = maxVal
}
return value
}
// Abs 计算绝对值
func Abs[T SignedNumber](val T) T {
return T(math.Abs(float64(val)))
}
// Floor 向下取整
func Floor[T SignedNumber | UnsignedNumber](val T) T {
return T(math.Floor(float64(val)))
}
// Ceil 向上取整
func Ceil[T SignedNumber | UnsignedNumber](val T) T {
return T(math.Ceil(float64(val)))
}
// Round 四舍五入取整
func Round[T SignedNumber | UnsignedNumber](val T) T {
return T(math.Round(float64(val)))
}
// Sum 计算所有元素总和
func Sum[T SignedNumber | UnsignedNumber](items ...T) T {
var sum T
for _, item := range items {
sum += item
}
return sum
}
// Average 计算所有元素的平均值
func Average[T SignedNumber | UnsignedNumber](items ...T) T {
return Sum(items...) / T(len(items))
}

View File

@ -7,31 +7,41 @@ import (
"net" "net"
) )
type execCallback func(string)
type LogHook struct { type LogHook struct {
name string name string
cmdExec execCallback
} }
func NewHook(name string) LogHook { func NewHook(name string, cmdExec execCallback) *LogHook {
return LogHook{ return &LogHook{
name: name, name: name,
cmdExec: cmdExec,
} }
} }
func (LogHook) DialHook(next redis.DialHook) redis.DialHook { func (l *LogHook) DialHook(next redis.DialHook) redis.DialHook {
return func(ctx context.Context, network, addr string) (net.Conn, error) { return func(ctx context.Context, network, addr string) (net.Conn, error) {
return next(ctx, network, addr) return next(ctx, network, addr)
} }
} }
func (LogHook) ProcessHook(next redis.ProcessHook) redis.ProcessHook { func (l *LogHook) ProcessHook(next redis.ProcessHook) redis.ProcessHook {
return func(ctx context.Context, cmd redis.Cmder) error { return func(ctx context.Context, cmd redis.Cmder) error {
log.Println(cmd.String()) log.Println(cmd)
if l.cmdExec != nil {
l.cmdExec(cmd.String())
}
return next(ctx, cmd) return next(ctx, cmd)
} }
} }
func (LogHook) ProcessPipelineHook(next redis.ProcessPipelineHook) redis.ProcessPipelineHook { func (l *LogHook) ProcessPipelineHook(next redis.ProcessPipelineHook) redis.ProcessPipelineHook {
return func(ctx context.Context, cmds []redis.Cmder) error { return func(ctx context.Context, cmds []redis.Cmder) error {
for _, cmd := range cmds { for _, cmd := range cmds {
log.Println(cmd.String()) log.Println("pipeline: ", cmd)
if l.cmdExec != nil {
l.cmdExec(cmd.String())
}
} }
return next(ctx, cmds) return next(ctx, cmds)
} }

View File

@ -10,6 +10,7 @@ import ContentServerPane from './components/content/ContentServerPane.vue'
import useTabStore from './stores/tab.js' import useTabStore from './stores/tab.js'
import usePreferencesStore from './stores/preferences.js' import usePreferencesStore from './stores/preferences.js'
import useConnectionStore from './stores/connections.js' import useConnectionStore from './stores/connections.js'
import ContentLogPane from './components/content/ContentLogPane.vue'
const themeVars = useThemeVars() const themeVars = useThemeVars()
@ -60,8 +61,8 @@ const dragging = computed(() => {
<!-- app content--> <!-- app content-->
<div id="app-content-wrapper" :class="{ dragging }" class="flex-box-h" :style="prefStore.generalFont"> <div id="app-content-wrapper" :class="{ dragging }" class="flex-box-h" :style="prefStore.generalFont">
<nav-menu v-model:value="tabStore.nav" :width="data.navMenuWidth" /> <nav-menu v-model:value="tabStore.nav" :width="data.navMenuWidth" />
<!-- structure page--> <!-- browser page-->
<div v-show="tabStore.nav === 'structure'" class="flex-box-h flex-item-expand"> <div v-show="tabStore.nav === 'browser'" class="flex-box-h flex-item-expand">
<div id="app-side" :style="{ width: asideWidthVal }" class="flex-box-h flex-item"> <div id="app-side" :style="{ width: asideWidthVal }" class="flex-box-h flex-item">
<browser-pane <browser-pane
v-for="t in tabStore.tabs" v-for="t in tabStore.tabs"
@ -102,7 +103,11 @@ const dragging = computed(() => {
</div> </div>
<!-- log page --> <!-- log page -->
<div v-show="tabStore.nav === 'log'">display log</div> <div v-if="tabStore.nav === 'log'" class="flex-box-h flex-item-expand">
<keep-alive>
<content-log-pane class="flex-item-expand" />
</keep-alive>
</div>
</div> </div>
</template> </template>

View File

@ -0,0 +1,116 @@
<script setup>
import { computed, nextTick, onActivated, reactive, ref } from 'vue'
import IconButton from '../common/IconButton.vue'
import Refresh from '../icons/Refresh.vue'
import useConnectionStore from '../../stores/connections.js'
import { map, uniqBy } from 'lodash'
import { useI18n } from 'vue-i18n'
const connectionStore = useConnectionStore()
const i18n = useI18n()
const data = reactive({
loading: false,
server: '',
keyword: '',
history: [],
})
const filterServerOption = computed(() => {
const serverSet = uniqBy(data.history, 'server')
const options = map(serverSet, ({ server }) => ({
label: server,
value: server,
}))
options.splice(0, 0, {
label: i18n.t('all'),
value: '',
})
return options
})
const tableRef = ref(null)
const loadHistory = () => {
data.loading = true
connectionStore
.getCmdHistory()
.then((list) => {
data.history = list
})
.finally(() => {
data.loading = false
tableRef.value?.scrollTo({ top: 999999 })
})
}
onActivated(() => {
nextTick().then(loadHistory)
})
</script>
<template>
<n-card
:title="$t('launch_log')"
class="content-container flex-box-v"
content-style="display: flex;flex-direction: column; overflow: hidden;"
>
<n-form inline :disabled="data.loading" class="flex-item">
<n-form-item :label="$t('filter_server')">
<n-select
style="min-width: 100px"
v-model:value="data.server"
:options="filterServerOption"
:consistent-menu-width="false"
/>
</n-form-item>
<n-form-item :label="$t('filter_keyword')">
<n-input v-model:value="data.keyword" placeholder="" clearable />
</n-form-item>
<n-form-item>
<icon-button :icon="Refresh" border t-tooltip="refresh" @click="loadHistory" />
</n-form-item>
</n-form>
<div class="fill-height flex-box-h" style="user-select: text">
<n-data-table
ref="tableRef"
class="flex-item-expand"
:columns="[
{
title: $t('exec_time'),
key: 'time',
defaultSortOrder: 'ascend',
sorter: 'default',
width: 180,
},
{
title: $t('server'),
key: 'server',
filterOptionValue: data.server,
filter(value, row) {
return value === '' || row.server === value.toString()
},
width: 150,
ellipsis: true,
},
{
title: $t('cmd'),
key: 'cmd',
filterOptionValue: data.keyword,
filter(value, row) {
return value === '' || !!~row.cmd.indexOf(value.toString())
},
},
]"
:data="data.history"
flex-height
/>
</div>
</n-card>
</template>
<style scoped lang="scss">
@import 'content';
.content-container {
padding: 5px;
box-sizing: border-box;
}
</style>

View File

@ -63,7 +63,7 @@ const tab = computed(() =>
watch( watch(
() => tabStore.nav, () => tabStore.nav,
(nav) => { (nav) => {
if (nav === 'structure') { if (nav === 'browser') {
refreshInfo() refreshInfo()
} }
} }

View File

@ -1,10 +1,9 @@
<script setup> <script setup>
import { get, map, mapValues, pickBy, split, sum, toArray, toNumber } from 'lodash' import { get, map, mapValues, pickBy, split, sum, toArray, toNumber } from 'lodash'
import { computed, reactive, ref } from 'vue' import { computed, ref } from 'vue'
import Help from '../icons/Help.vue' import Help from '../icons/Help.vue'
import IconButton from '../common/IconButton.vue' import IconButton from '../common/IconButton.vue'
import Filter from '../icons/Filter.vue' import Filter from '../icons/Filter.vue'
import { useI18n } from 'vue-i18n'
import Refresh from '../icons/Refresh.vue' import Refresh from '../icons/Refresh.vue'
const props = defineProps({ const props = defineProps({
@ -70,26 +69,7 @@ const totalKeys = computed(() => {
return sum(toArray(nums)) return sum(toArray(nums))
}) })
const infoList = computed(() => map(props.info, (value, key) => ({ value, key }))) const infoList = computed(() => map(props.info, (value, key) => ({ value, key })))
const i18n = useI18n()
const infoColumns = [
reactive({
title: i18n.t('key'),
key: 'key',
defaultSortOrder: 'ascend',
sorter: 'default',
minWidth: 100,
filterOptionValue: null,
filter(value, row) {
return !!~row.key.indexOf(value.toString())
},
}),
{ title: i18n.t('value'), key: 'value' },
]
const infoFilter = ref('') const infoFilter = ref('')
const onFilterInfo = (val) => {
infoColumns[0].filterOptionValue = val
}
</script> </script>
<template> <template>
@ -165,13 +145,29 @@ const onFilterInfo = (val) => {
</n-card> </n-card>
<n-card :title="$t('all_info')"> <n-card :title="$t('all_info')">
<template #header-extra> <template #header-extra>
<n-input v-model:value="infoFilter" @update:value="onFilterInfo" placeholder="" clearable> <n-input v-model:value="infoFilter" placeholder="" clearable>
<template #prefix> <template #prefix>
<icon-button :icon="Filter" size="18" /> <icon-button :icon="Filter" size="18" />
</template> </template>
</n-input> </n-input>
</template> </template>
<n-data-table :columns="infoColumns" :data="infoList" /> <n-data-table
:columns="[
{
title: $t('key'),
key: 'key',
defaultSortOrder: 'ascend',
sorter: 'default',
minWidth: 100,
filterOptionValue: infoFilter,
filter(value, row) {
return !!~row.key.indexOf(value.toString())
},
},
{ title: $t('value'), key: 'value' },
]"
:data="infoList"
/>
</n-card> </n-card>
</n-space> </n-space>
</n-scrollbar> </n-scrollbar>

View File

@ -2,7 +2,6 @@
import { reactive, ref, watch } from 'vue' import { reactive, ref, watch } from 'vue'
import useDialog from '../../stores/dialog' import useDialog from '../../stores/dialog'
import useTabStore from '../../stores/tab.js' import useTabStore from '../../stores/tab.js'
import { useMessage } from 'naive-ui'
import useConnectionStore from '../../stores/connections.js' import useConnectionStore from '../../stores/connections.js'
const ttlForm = reactive({ const ttlForm = reactive({
@ -42,7 +41,6 @@ const onClose = () => {
dialogStore.closeTTLDialog() dialogStore.closeTTLDialog()
} }
const message = useMessage()
const onConfirm = async () => { const onConfirm = async () => {
try { try {
const tab = tabStore.currentTab const tab = tabStore.currentTab

View File

@ -23,7 +23,7 @@ const props = defineProps({
:stroke="props.fillColor" :stroke="props.fillColor"
:stroke-width="props.strokeWidth" :stroke-width="props.strokeWidth"
d="M41 4H7C5.34315 4 4 5.34315 4 7V41C4 42.6569 5.34315 44 7 44H41C42.6569 44 44 42.6569 44 41V7C44 5.34315 42.6569 4 41 4Z" d="M41 4H7C5.34315 4 4 5.34315 4 7V41C4 42.6569 5.34315 44 7 44H41C42.6569 44 44 42.6569 44 41V7C44 5.34315 42.6569 4 41 4Z"
fill="#dc423c" :fill="props.fillColor"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
/> />

View File

@ -2,7 +2,7 @@
import useDialogStore from '../../stores/dialog.js' import useDialogStore from '../../stores/dialog.js'
import { h, nextTick, reactive, ref, watch } from 'vue' import { h, nextTick, reactive, ref, watch } from 'vue'
import useConnectionStore from '../../stores/connections.js' import useConnectionStore from '../../stores/connections.js'
import { NIcon, useDialog, useMessage } from 'naive-ui' import { NIcon, useDialog, useMessage, useThemeVars } from 'naive-ui'
import { ConnectionType } from '../../consts/connection_type.js' import { ConnectionType } from '../../consts/connection_type.js'
import ToggleFolder from '../icons/ToggleFolder.vue' import ToggleFolder from '../icons/ToggleFolder.vue'
import ToggleServer from '../icons/ToggleServer.vue' import ToggleServer from '../icons/ToggleServer.vue'
@ -17,6 +17,7 @@ import useTabStore from '../../stores/tab.js'
import Edit from '../icons/Edit.vue' import Edit from '../icons/Edit.vue'
import { useConfirmDialog } from '../../utils/confirm_dialog.js' import { useConfirmDialog } from '../../utils/confirm_dialog.js'
const themeVars = useThemeVars()
const i18n = useI18n() const i18n = useI18n()
const openingConnection = ref(false) const openingConnection = ref(false)
const connectionStore = useConnectionStore() const connectionStore = useConnectionStore()

View File

@ -38,8 +38,8 @@ const i18n = useI18n()
const menuOptions = computed(() => { const menuOptions = computed(() => {
return [ return [
{ {
label: i18n.t('structure'), label: i18n.t('browser'),
key: 'structure', key: 'browser',
icon: renderIcon(ToggleDb), icon: renderIcon(ToggleDb),
show: connectionStore.anyConnectionOpened, show: connectionStore.anyConnectionOpened,
}, },

View File

@ -127,7 +127,7 @@
"empty_server_content": "Connect server from left list", "empty_server_content": "Connect server from left list",
"reload_when_succ": "Reload immediately after success", "reload_when_succ": "Reload immediately after success",
"server": "Server", "server": "Server",
"structure": "Structure", "browser": "Browser",
"log": "Log", "log": "Log",
"about": "About", "about": "About",
"help": "Help", "help": "Help",
@ -141,5 +141,11 @@
"unit_day": "D", "unit_day": "D",
"unit_hour": "H", "unit_hour": "H",
"unit_minute": "M", "unit_minute": "M",
"all_info": "All Info" "all_info": "All Info",
"all": "All",
"launch_log": "Launch Log",
"filter_server": "Filter Server",
"filter_keyword": "Filter Keyword",
"exec_time": "Exec Time",
"cmd": "Command"
} }

View File

@ -130,7 +130,7 @@
"empty_server_content": "可以从左边选择并打开连接", "empty_server_content": "可以从左边选择并打开连接",
"reload_when_succ": "操作成功后立即重新加载", "reload_when_succ": "操作成功后立即重新加载",
"server": "服务器", "server": "服务器",
"structure": "结构", "browser": "浏览器",
"log": "日志", "log": "日志",
"about": "关于", "about": "关于",
"help": "帮助", "help": "帮助",
@ -144,5 +144,11 @@
"unit_day": "天", "unit_day": "天",
"unit_hour": "小时", "unit_hour": "小时",
"unit_minute": "分钟", "unit_minute": "分钟",
"all_info": "所有信息" "all_info": "所有信息",
"all": "所有",
"launch_log": "运行日志",
"filter_server": "筛选服务器",
"filter_keyword": "筛选关键字",
"exec_time": "执行时间",
"cmd": "命令"
} }

View File

@ -9,6 +9,7 @@ import {
DeleteConnection, DeleteConnection,
DeleteGroup, DeleteGroup,
DeleteKey, DeleteKey,
GetCmdHistory,
GetConnection, GetConnection,
GetKeyValue, GetKeyValue,
ListConnection, ListConnection,
@ -64,6 +65,13 @@ const useConnectionStore = defineStore('connections', {
* @property {Object.<string, Map<string, DatabaseItem>>} nodeMap key format likes 'server#db', children key format likes 'key#type' * @property {Object.<string, Map<string, DatabaseItem>>} nodeMap key format likes 'server#db', children key format likes 'key#type'
*/ */
/**
* @typedef {Object} HistoryItem
* @property {string} time
* @property {string} server
* @property {string} cmd
*/
/** /**
* *
* @returns {ConnectionState} * @returns {ConnectionState}
@ -1149,6 +1157,26 @@ const useConnectionStore = defineStore('connections', {
return { success: false, msg } return { success: false, msg }
} }
}, },
/**
* get command history
* @param {number} [pageNo]
* @param {number} [pageSize]
* @returns {Promise<HistoryItem[]>}
*/
async getCmdHistory(pageNo, pageSize) {
if (pageNo === undefined || pageSize === undefined) {
pageNo = -1
pageSize = -1
}
try {
const { success, data = { list: [] } } = await GetCmdHistory(pageNo, pageSize)
const { list } = data
return list
} catch {
return []
}
},
}, },
}) })

View File

@ -74,7 +74,7 @@ const useTabStore = defineStore('tab', {
_setActivatedIndex(idx, switchNav) { _setActivatedIndex(idx, switchNav) {
this.activatedIndex = idx this.activatedIndex = idx
if (switchNav === true) { if (switchNav === true) {
this.nav = idx >= 0 ? 'structure' : 'server' this.nav = idx >= 0 ? 'browser' : 'server'
} else { } else {
if (idx < 0) { if (idx < 0) {
this.nav = 'server' this.nav = 'server'